diff --git a/build.gradle.kts b/build.gradle.kts index d3e234d47..8a704a2f8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -71,6 +71,7 @@ subprojects { dependencies { runtimeOnly(projects.gui.proxyTool) runtimeOnly(projects.proxy) + runtimeOnly(projects.mcp) } buildscript { diff --git a/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/App.kt b/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/App.kt index 60327950f..db95c7ae5 100644 --- a/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/App.kt +++ b/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/App.kt @@ -11,6 +11,7 @@ import net.rsprox.gui.dialogs.AboutDialog import net.rsprox.gui.sessions.SessionsPanel import net.rsprox.gui.sidebar.SideBar import net.rsprox.proxy.ProxyService +import net.rsprox.proxy.StartedExtensions import net.rsprox.proxy.config.BINARY_PATH import net.rsprox.proxy.config.ERROR_LOGS_PATH import java.awt.CardLayout @@ -24,6 +25,7 @@ import java.awt.event.WindowEvent import java.net.URI import java.util.prefs.Preferences import javax.swing.BorderFactory +import javax.swing.JCheckBoxMenuItem import javax.swing.JFrame import javax.swing.JMenu import javax.swing.JMenuItem @@ -33,7 +35,9 @@ import javax.swing.KeyStroke import javax.swing.WindowConstants.DO_NOTHING_ON_CLOSE import kotlin.system.exitProcess -public class App { +public class App( + private val extensions: StartedExtensions, +) { private val frame: JFrame = JFrame() private val sessionsPanel: SessionsPanel = SessionsPanel(this) private val launchBar: LaunchBar = LaunchBar(sessionsPanel) @@ -169,6 +173,20 @@ public class App { addSeparator() + val serveMcpEndpoint = + JCheckBoxMenuItem( + "Serve MCP Endpoint", + isMcpEndpointServed(service.getMcpEnabled(), extensions::serve), + ) + serveMcpEndpoint.mnemonic = 'M'.code + serveMcpEndpoint.toolTipText = MCP_ENDPOINT_TOOLTIP + serveMcpEndpoint.addActionListener { + this@App.switchMcpEndpoint(serveMcpEndpoint) + } + add(serveMcpEndpoint) + + addSeparator() + val exitItem = JMenuItem("Exit") exitItem.mnemonic = 'X'.code exitItem.accelerator = KeyStroke.getKeyStroke("alt F4") @@ -283,6 +301,19 @@ public class App { homePanel.refreshRecentDumps() } + private fun switchMcpEndpoint(item: JCheckBoxMenuItem) { + val switch = + switchMcpEndpoint(item.isSelected, extensions::serve, extensions::stopServing, service::setMcpEnabled) + item.isSelected = switch.served + val failure = switch.failure ?: return + JOptionPane.showMessageDialog( + frame, + failure, + "MCP Endpoint", + JOptionPane.ERROR_MESSAGE, + ) + } + private fun openBinaryLogsFolder() { openFolder(BINARY_PATH, "You have not created any logs yet.") } diff --git a/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/McpEndpointNotice.kt b/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/McpEndpointNotice.kt new file mode 100644 index 000000000..9281835e9 --- /dev/null +++ b/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/McpEndpointNotice.kt @@ -0,0 +1,43 @@ +package net.rsprox.gui + +internal const val MCP_ENDPOINT_TOOLTIP: String = + "No password: any program on this computer can read every session's packets, chat included. " + + "While off, nothing is served; sessions of clients launched in this run are kept in memory and " + + "listed again when it is turned back on." + +internal data class McpEndpointSwitch( + val served: Boolean, + val failure: String?, +) + +/** + * Whether the item starts ticked. An endpoint that is on was served at start-up, unless its port was + * taken then, so it is asked once more. The saved choice is left alone, so the next start tries again. + */ +internal fun isMcpEndpointServed( + enabled: Boolean, + serve: () -> String?, +): Boolean = enabled && serve() == null + +/** + * Turns the endpoint on or off at once, and saves the choice once it holds. An endpoint that cannot be + * served stays off with nothing saved, and the result says why. + */ +internal fun switchMcpEndpoint( + enabled: Boolean, + serve: () -> String?, + stopServing: () -> Unit, + save: (Boolean) -> Unit, +): McpEndpointSwitch { + if (!enabled) { + stopServing() + save(false) + return McpEndpointSwitch(served = false, failure = null) + } + val refusal = serve() + if (refusal != null) { + return McpEndpointSwitch(served = false, failure = refusal.replace(". ", ".\n")) + } + save(true) + return McpEndpointSwitch(served = true, failure = null) +} diff --git a/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/ProxyToolGui.kt b/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/ProxyToolGui.kt index 3c004c06e..4e5d3744a 100644 --- a/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/ProxyToolGui.kt +++ b/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/ProxyToolGui.kt @@ -1,5 +1,6 @@ package net.rsprox.gui +import net.rsprox.proxy.ProxyExtension import java.util.Locale import javax.swing.ImageIcon import javax.swing.SwingUtilities @@ -28,6 +29,7 @@ public fun main(args: Array) { App.service.start(rspsJavConfigUrl, rspsModulus) { percentage, actionText, subActionText, progressText -> SplashScreen.stage(percentage, actionText, subActionText, progressText) } + val extensions = ProxyExtension.startAll(App.service) SplashScreen.stop() SwingUtilities.invokeLater { // Disable icons in all trees. @@ -42,7 +44,7 @@ public fun main(args: Array) { // This ensures that the theme you see is the same on initial boot as well as swapping back and forth AppThemes.applyThemeEdt(App.service.getAppTheme()) - val app = App() + val app = App(extensions) app.init() app.start() } diff --git a/gui/proxy-tool/src/test/kotlin/net/rsprox/gui/McpEndpointNoticeTest.kt b/gui/proxy-tool/src/test/kotlin/net/rsprox/gui/McpEndpointNoticeTest.kt new file mode 100644 index 000000000..eedd7988b --- /dev/null +++ b/gui/proxy-tool/src/test/kotlin/net/rsprox/gui/McpEndpointNoticeTest.kt @@ -0,0 +1,68 @@ +package net.rsprox.gui + +import kotlin.test.Test +import kotlin.test.assertEquals + +class McpEndpointNoticeTest { + private val calls = ArrayList() + + private fun switch( + enabled: Boolean, + refusal: String? = null, + ): McpEndpointSwitch = + switchMcpEndpoint( + enabled, + serve = { + calls += "serve" + refusal + }, + stopServing = { calls += "stop serving" }, + save = { calls += "save $it" }, + ) + + @Test + fun `turning the endpoint on serves it at once and then saves the choice`() { + assertEquals(McpEndpointSwitch(served = true, failure = null), switch(enabled = true)) + assertEquals(listOf("serve", "save true"), calls) + } + + @Test + fun `turning the endpoint off stops serving it at once and then saves the choice`() { + assertEquals(McpEndpointSwitch(served = false, failure = null), switch(enabled = false)) + assertEquals(listOf("stop serving", "save false"), calls) + } + + @Test + fun `an endpoint that cannot be served stays off, saves nothing and says why with a sentence to a line`() { + val refusal = + "The MCP endpoint is not served: 127.0.0.1:43999 cannot be bound (Address already in use). " + + "Another rsprox may hold it. Close that one." + + assertEquals( + McpEndpointSwitch( + served = false, + failure = + "The MCP endpoint is not served: 127.0.0.1:43999 cannot be bound (Address already in use).\n" + + "Another rsprox may hold it.\n" + + "Close that one.", + ), + switch(enabled = true, refusal), + ) + assertEquals(listOf("serve"), calls) + } + + @Test + fun `two clicks in a row leave the endpoint as the second click says`() { + switch(enabled = true) + + assertEquals(McpEndpointSwitch(served = false, failure = null), switch(enabled = false)) + assertEquals(listOf("serve", "save true", "stop serving", "save false"), calls) + } + + @Test + fun `the item starts ticked only for an endpoint that is on and served`() { + assertEquals(true, isMcpEndpointServed(enabled = true) { null }) + assertEquals(false, isMcpEndpointServed(enabled = true) { "127.0.0.1:43999 cannot be bound" }) + assertEquals(false, isMcpEndpointServed(enabled = false) { error("an endpoint that is off is not served") }) + } +} diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 000000000..cc4433d4b --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,204 @@ +# rsprox MCP server + +This module runs rsprox without its GUI and serves it to a coding agent as an +[MCP](https://modelcontextprotocol.io) server. The agent launches a RuneLite client through the proxy, +reads the decoded packets in both directions, and looks at and drives the client. It is meant for +checking a private server end to end with a real client. The rsprox GUI serves the same endpoint once +you turn it on, and attaches it to the clients that you launch by hand. + +The proxy only observes, so it cannot inject or change packets. All input goes through the client as +mouse and key events, from a small RuneLite plugin that the server installs before each launch. + +## Never input on the official game + +The tool is for testing a private server that you run. On the official game, which is target 0 and +any target whose jav_config comes from a Jagex domain, it only reads: `client_state`, +`client_screenshot`, `client_widgets`, `client_vars`, `client_entities`, `client_camera` without a +turn, and `packets_read`. `client_login`, `client_click`, `client_type`, `client_interact` and a +`client_camera` turn are refused there before anything reaches the client, and `session_list` shows +such a session with `"access":"read"`. Two more layers hold if the server is wrong: the link it makes +for a read-only session forwards no input op, and the plugin builds its input ops only when the +server's welcome grants input and the client is not the stock RuneLite one, whose home directory +`.runelite` is what the official game is launched with. A welcome without the grant is read-only. + +To go further, disable the plugin. Switch "rsprox MCP bridge" off in RuneLite's plugin list, which +closes its connection. Or set `mcp.plugin=false` in `~/.rsprox/proxy.properties` for the GUI, or pass +`--no-plugin` to the standalone server: launched clients then get no plugin, their sessions are +`"state":"unbridged"`, and only `packets_read` works on them. + +## Run it + +``` +./gradlew :mcp:run +``` + +The server listens on `http://127.0.0.1:43580/mcp`. It uses the proxy targets that the rsprox GUI +uses, from `~/.rsprox/proxy-targets.yaml`. A session records to the usual folder of its target, as it +does when launched from the GUI. Each client opens a window, so the machine needs a display or a +virtual one, as described under "Run it without a display". + +Options go in `--args`: + +``` +./gradlew :mcp:run --args="--start 'My Server'" +``` + +| Option | Meaning | +|---|---| +| `--port ` | Loopback port of the MCP endpoint. Default 43580. | +| `--start ` | Launch a client for this target at startup. | +| `--sideload-dir ` | Where to install the client plugin, when the client does not use the default directory. | +| `--port-skip ` | Proxy ports to leave free at the start of the range for a GUI. Default 50. | +| `--software-rendering` | Stop the GPU plugin in launched clients. Needed on a virtual display. | +| `--no-plugin` | Launch clients without the plugin. Only their packets can then be read. | + +The plugin is installed as `rsprox-mcp-bridge.jar` in `~/.rlcustom/sideloaded-plugins` for a custom +target and in `~/.runelite/sideloaded-plugins` for the official one. A client that the MCP server did +not launch loads the plugin too, and the plugin then does nothing. An installed jar from before a +change to the wire protocol is rejected at its hello and replaced at the next launch. + +Run one MCP endpoint per user account. The plugin finds its server through the file +`~/.rsprox/mcp/bridge.json`, which every endpoint of the account writes, so another `--port` is not +enough. To run this server next to a GUI, leave the GUI's endpoint off. + +## Run it inside the GUI + +``` +./gradlew proxy +``` + +The GUI serves the endpoint only while it is turned on. Tick File > Serve MCP Endpoint and the GUI +serves `http://127.0.0.1:43580/mcp` at once, without a restart. It then also serves at every start, +from the moment the proxy has started. + +Untick the item and the GUI stops serving at once. The port is closed and no request is answered. A +request that still runs can no longer launch a client, stop one or send one input, and a client that +such a request was launching is closed when its launch completes. + +While off, nothing is served; sessions of clients launched in this run are kept in memory and listed +again when it is turned back on. Their packets are recorded in the meantime, so the logs and cursors +continue without a gap. The clients that `session_start` launched stay open. All of it is gone when +the GUI exits. + +A client that was already open when you first turned the endpoint on in a run is not attached. Clients +that you launch from then on are. Close such a client and launch it again to attach it. + +The item saves `mcp.enabled` in `~/.rsprox/proxy.properties`. Edit that file only while the GUI is +closed, because the GUI writes it again when it saves its own settings. + +| Property | Meaning | +|---|---| +| `mcp.enabled` | Whether the GUI serves the endpoint. Default `false`. | +| `mcp.port` | Loopback port of the endpoint. Default 43580. The GUI has no control for it. | +| `mcp.plugin` | Whether clients that `session_start` launches get the plugin. Default `true`. | + +When the port is taken, by a second rsprox or by the standalone server, the item goes back to unticked +and a dialog says so. At start-up the GUI logs one line instead, runs without the endpoint and shows +the item unticked. Tick it to try again. + +Every RuneLite or native client that you launch from the GUI, once the endpoint has been turned on in +the run, appears in `session_list` as a session +with `"kind":"attached"`, numbered together with the sessions that `session_start` launches. An agent +picks the client you mean by the `target`, the `proxyPort` and, once you are logged in, the values +under `login`, such as the `world` and the display `name`. The proxy never sees a login name or a +password, so neither is listed. RuneScape 3 clients are not attached. + +An attached session is read-only, and its client is yours: `packets_read` works on it, while +`session_stop`, `session_start` and every `client_*` tool refuse it with `not available for an +attached session`. When you close the session's tab in the GUI, or the client exits, the session +becomes `"state":"ended"`. It stays listed and its packets stay readable. + +Inside the GUI the packet log holds the GUI's view, for attached sessions and for sessions that +`session_start` launches alike. The GUI's filters and settings decide which packets are transcribed +at all, so a packet that they hide is missing from `packets_read` too. Enable a filter in the GUI to +see its packets. The standalone server has no such filters and logs every packet. + +The endpoint has no password. Any program that runs under your user account, and any other user +of the machine, can reach it on loopback and read everything the log holds, including public and +private chat, for as long as the endpoint is on. That is why it is off until you turn it on. + +## Run it without a display + +On Linux, run the server under Xvfb and pass `--software-rendering`: + +``` +xvfb-run -s "-screen 0 1280x800x24" ./gradlew :mcp:run --args="--software-rendering" +``` + +Without `--software-rendering` everything works except `client_screenshot`, which returns a black +image. Besides a JDK, the machine needs Xvfb and the X11 and font libraries that Java's AWT loads: + +``` +apt-get install xvfb libxrender1 libxtst6 libxi6 libxext6 libfontconfig1 libfreetype6 fonts-dejavu-core +``` + +## Register it with an MCP client + +The transport is plain HTTP. With Claude Code: + +``` +claude mcp add --transport http rsprox http://127.0.0.1:43580/mcp +``` + +Any other client takes the same URL in its configuration for an HTTP server. Start the server before +the client connects. The server keeps its sessions and packets for as long as it runs. + +## Tools + +The server describes each tool itself. `tools/list` returns every tool with its description and the +schema of its arguments: + +``` +curl -s http://127.0.0.1:43580/mcp -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +The descriptions leave out the following: + +- `session_start` gives the launcher of a client 180 seconds to complete its handshake. When the + launcher takes longer, the server abandons the launch and refuses further launches until it is + restarted. +- The key codes of typed text are zeroed in the recorded packets, as in every rsprox recording. Read + what was typed from the packet that carries the text, such as `RESUME_P_STRINGDIALOG`. +- Only one call drives the mouse and the keyboard of a client at a time. `client_interact`, + `client_click`, `client_type` and a `client_camera` call that turns the camera take turns. The + calls that only read run beside them. +- The client offers only Cancel at a point that an interface covers, such as the chatbox or the + minimap. `client_interact` never clicks there. It turns the camera after a few such points, and + then fails with `the client offers only Cancel; an interface covers the game view there`. An + interface that blocks the whole game view, such as the bank, gives the same message. Close it first. +- A target that is hidden from every angle, behind a wall for instance, cannot be clicked. Neither + can anything in a secondary world view such as a boat: `client_entities` and `client_interact` + read only the top-level world view. + +## Example: check that setting a display name works + +The display-name interface is group 558, and its name field is widget `558:12`. + +``` +1. session_start {"target":"My Server"} + -> {"session":"s1","kind":"launched","target":"My Server","state":"connected", ...,"cursor":2} +2. client_login {"username":"alice","password":"any"} + -> {"gameState":"LOGGED_IN","cursor":2} +3. client_click {"widget":"558:12"} + -> {"x":633,"y":361,"cursor":471} +4. client_type {"text":"Alice","enter":true} + -> {"typed":5,"cursor":484} +5. packets_read {"after":484,"prots":["IF_SETTEXT"],"contains":"Alice","wait_ms":5000} + -> {"next":519,"head":519,"dropped":0,"timedOut":false,"count":1} + 519 L1 T8 S IF_SETTEXT [if_settext] com=tutorial_displayname:status (558:13), text="Great! The + display name Alice is available! ..." +``` + +Step 5 proves that the server answered the name the client sent. The same wait proves that nothing +happened. Wait for the packet and expect a timeout: +`packets_read {"after":519,"prots":["IF_SETTEXT"],"wait_ms":1800}` returning `"timedOut":true` means +the server sent no such packet for three ticks. + +When the client of a session has exited or was stopped, call `session_start {"session":"s1"}`. The +session keeps its id and its packet log. The client comes back on new ports, and the packets of its +next login are marked with the next login number. + +## What the automated tests do not cover + +The tests run no game client. Check what the plugin does inside RuneLite by hand against a live +client, after a RuneLite update, a game revision change or a change to the plugin. diff --git a/mcp/build.gradle.kts b/mcp/build.gradle.kts new file mode 100644 index 000000000..e11d0af31 --- /dev/null +++ b/mcp/build.gradle.kts @@ -0,0 +1,30 @@ +// Resolves to the plugin's plain jar. It is shipped as a resource and installed before each launch. +val bridgePlugin: Configuration by configurations.creating { + isCanBeConsumed = false + isTransitive = false +} + +dependencies { + bridgePlugin(project(":runelite:rsprox-mcp-bridge")) + implementation(projects.proxy) + implementation(projects.shared) + implementation(projects.cache.cacheApi) + implementation(platform(rootProject.libs.netty.bom)) + implementation(rootProject.libs.netty.buffer) + implementation(rootProject.libs.bundles.jackson) + implementation(rootProject.libs.clikt) + implementation(rootProject.libs.inline.logger) +} + +tasks.processResources { + from(bridgePlugin) { + rename { "rsprox-mcp-bridge.jar" } + } +} + +tasks.register("run") { + environment("APP_VERSION", project.version) + group = "run" + classpath = sourceSets["main"].runtimeClasspath + mainClass.set("net.rsprox.mcp.McpMainKt") +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt b/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt new file mode 100644 index 000000000..6c49f461b --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt @@ -0,0 +1,173 @@ +package net.rsprox.mcp + +import com.github.michaelbull.logging.InlineLogger +import net.rsprox.mcp.bridge.Gate +import net.rsprox.mcp.bridge.Rendering +import net.rsprox.mcp.server.McpHttpServer +import net.rsprox.mcp.server.ToolError +import net.rsprox.mcp.server.tools +import net.rsprox.mcp.session.SessionManager +import net.rsprox.proxy.ProxyExtension +import net.rsprox.proxy.ProxyService +import java.io.IOException +import java.util.concurrent.atomic.AtomicReference + +/** + * Serves the MCP endpoint from inside the rsprox GUI, which finds this class through `META-INF/services`. + * Every client that is launched from the GUI while the sessions exist becomes an attached session. The + * proxy keeps the filter and setting stores of the GUI, so the packet logs hold what the GUI shows. + */ +public class McpExtension : ProxyExtension { + /** The switch of the endpoint, or null before [start]. Set on the main thread, read on the Swing thread. */ + @Volatile + private var switch: EndpointSwitch? = null + + /** Serve the endpoint on the configured port, if the user turned it on and the port is free. */ + override fun start(service: ProxyService) { + val gate = Gate() + val switch = + EndpointSwitch(service.getMcpPort(), gate) { opened -> + sessionManager(service, sideloadDir = null, Rendering.GPU, service.getMcpPluginEnabled(), opened, gate) + } + + this.switch = switch + switch.atStartUp(service.getMcpEnabled()) + } + + /** Serve the endpoint from this call on. Returns null once it is served, and otherwise why it is not. */ + override fun serve(): String? = checkNotNull(switch) { "the extension has not been started" }.serve() + + /** Stop serving the endpoint from this call on. */ + override fun stopServing() { + switch?.stopServing() + } +} + +/** + * Turns the endpoint of the GUI on and off while the GUI runs. + * + * On binds [port] and serves the sessions over HTTP. The first time, it also builds the sessions, with + * the bridge hub and the listener that attaches a session to every client the proxy launches from then + * on. A client that was already open is not attached, because the proxy asks its listeners only at a + * launch. + * + * Off closes the port and every connection, and closes [gate], so that a request that still runs can no + * longer fork a client, kill one or send one a request. Off does not take the sessions down. They, the + * hub and the listener stay in memory until the GUI exits, and the packet taps on the open clients keep + * recording, for three reasons. The proxy cannot take a packet tap off a client that runs, or a listener + * off itself. Closing the hub closes every link, and a closed link has the client behind it killed, so + * turning the endpoint off would close the clients it launched. And a tap that dropped packets while + * the endpoint is off would leave a session that lies when it is listed again: its login state and its + * tick would be stale and its log would have a hole. Nothing outside the process reaches what is kept, + * since the port is its only door. The GUI holds the same packets either way. + * + * On again serves the same sessions on a new HTTP server, so they are listed with their logs and cursors. + */ +internal class EndpointSwitch( + /** The loopback port of the endpoint. */ + private val port: Int, + /** The gate that the sessions act on clients through. It is open exactly while the endpoint is served. */ + private val gate: Gate, + /** + * Builds the sessions, the first time the endpoint is served. It may add what must be closed when a + * later step fails to the list it is given. + */ + private val build: (opened: MutableList) -> SessionManager, +) { + /** The sessions, or null until the endpoint has been served once. They outlive every HTTP server. */ + private val sessions = AtomicReference() + + /** The HTTP server, or null while the endpoint is off. Guarded by the monitor of the switch. */ + private var http: McpHttpServer? = null + + init { + gate.close() + } + + /** Serve the endpoint if [enabled] says the user turned it on, and log why it is not served otherwise. */ + fun atStartUp(enabled: Boolean) { + if (!enabled) { + logger.info { + "The MCP endpoint is off. Turn it on with File > Serve MCP Endpoint, or with mcp.enabled in " + + "proxy.properties." + } + + return + } + + serve()?.let { refusal -> logger.warn { refusal } } + } + + /** + * Serve the endpoint from this call on. Returns null once it is served, and otherwise the reason it + * is not, in words for the user: the port is taken, or the sessions cannot be built. Neither leaves + * anything bound, and neither is thrown, since the endpoint is no reason for its host to stop. + * Idempotent. Safe to call from any thread, and quick enough for the Swing thread. + */ + @Synchronized + fun serve(): String? { + if (http != null) return null + + val server = endpoint(port, sessions) + + try { + server.start() + } catch (e: IOException) { + return "The MCP endpoint is not served: 127.0.0.1:$port cannot be bound (${e.message}). Another " + + "rsprox or the standalone MCP server may hold it. Close that one, or set mcp.port in " + + "proxy.properties." + } + + if (sessions.get() == null) { + try { + sessions.set( + closingOnFailure { opened -> + opened += server + build(opened) + }, + ) + } catch (e: Exception) { + logger.error(e) { "The MCP endpoint could not start and is not served" } + + return "The MCP endpoint is not served: it could not start (${e.message}). The log has the details." + } + } + + gate.open() + http = server + logger.info { "MCP endpoint listening on http://127.0.0.1:$port/mcp" } + + return null + } + + /** + * Stop serving from this call on: no request that still runs acts on a client once this returns, and + * no connection is accepted or answered. The sessions are kept, as the class says. Idempotent. Safe + * to call from any thread, and quick enough for the Swing thread. + */ + @Synchronized + fun stopServing() { + val server = http ?: return + + http = null + gate.close() + server.close() + logger.info { "The MCP endpoint is off. Its sessions are kept until rsprox exits." } + } + + private companion object { + /** The logger of the endpoint. */ + private val logger = InlineLogger() + } +} + +/** Build the endpoint on [port], whose tools refuse every call until [sessions] holds the sessions. */ +internal fun endpoint( + port: Int, + sessions: AtomicReference, +): McpHttpServer = + McpHttpServer( + port, + tools { sessions.get() ?: throw ToolError("rsprox is still starting; try again shortly") }, + System.getenv("APP_VERSION") ?: "dev", + ) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt new file mode 100644 index 000000000..a4e57f91e --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt @@ -0,0 +1,197 @@ +package net.rsprox.mcp + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.switch +import com.github.ajalt.clikt.parameters.types.int +import com.github.ajalt.clikt.parameters.types.path +import com.github.michaelbull.logging.InlineLogger +import io.netty.buffer.ByteBufAllocator +import net.rsprox.mcp.bridge.BridgeHub +import net.rsprox.mcp.bridge.BridgeJar +import net.rsprox.mcp.bridge.Gate +import net.rsprox.mcp.bridge.Rendering +import net.rsprox.mcp.packets.TapSettingSetStore +import net.rsprox.mcp.packets.UnfilteredFilterSetStore +import net.rsprox.mcp.server.ToolError +import net.rsprox.mcp.session.ProxyServiceLauncher +import net.rsprox.mcp.session.SessionManager +import net.rsprox.proxy.ClientListener +import net.rsprox.proxy.ProxyService +import net.rsprox.proxy.binary.BinaryHeader +import net.rsprox.proxy.config.DEFAULT_MCP_PORT +import net.rsprox.proxy.target.ProxyTargetConfig +import net.rsprox.shared.SessionMonitor +import java.nio.file.Path +import java.util.Locale +import java.util.concurrent.atomic.AtomicReference +import kotlin.system.exitProcess + +/** Runs rsprox without its GUI and serves it to an agent as an MCP server on loopback. */ +public class McpCommand : CliktCommand(name = "mcp") { + /** The loopback port of the MCP endpoint. */ + private val port by option("--port", help = "Loopback port of the MCP endpoint").int().default(DEFAULT_MCP_PORT) + + /** The number of proxy ports left unused at the start of the range. */ + private val portSkip by option( + "--port-skip", + help = "Proxy ports to leave unused at the start of the range, for a GUI running at the same time", + ).int().default(50) + + /** The directory the client sideloads plugins from, or null for the default of the target. */ + private val sideloadDir by option( + "--sideload-dir", + help = "Directory the client sideloads plugins from, when it is not the default of the target", + ).path() + + /** The rendering of the launched clients. */ + private val rendering by option( + help = "Stop the GPU plugin in launched clients, for a virtual display such as Xvfb", + ).switch("--software-rendering" to Rendering.SOFTWARE).default(Rendering.GPU) + + /** The name of the target to launch at startup, or null to launch none. */ + private val autostart by option("--start", help = "Target name to launch immediately") + + /** Whether launched clients go without the bridge plugin, so that only their packets are read. */ + private val noPlugin by option( + "--no-plugin", + help = "Launch clients without the bridge plugin, so that only their packets can be read", + ).flag() + + /** Serve the MCP endpoint, start the proxy and the bridge hub, and run until the process is killed. */ + override fun run() { + Locale.setDefault(Locale.US) + val sessions = AtomicReference() + val http = endpoint(port, sessions) + + // Binding first makes a second instance exit here, before it touches the shared configuration. + http.start() + logger.info { "MCP endpoint listening on http://127.0.0.1:$port/mcp" } + + val manager = + try { + closingOnFailure { opened -> + opened += http + startSessions(opened) + } + } catch (t: Throwable) { + // The proxy leaves threads behind that are not daemons, so returning would keep the process alive. + logger.error(t) { "rsprox could not start" } + exitProcess(1) + } + + sessions.set(manager) + logger.info { "Ready. Targets: ${manager.targets().joinToString(", ")}" } + + autostart?.let { target -> + try { + manager.start(target, null, SessionManager.DEFAULT_WAIT_MS) + } catch (e: ToolError) { + logger.error { "Unable to start '$target': ${e.message}" } + } + } + + Thread.currentThread().join() + } + + /** Start the proxy and the bridge hub, and add what must be closed when a later step fails to [opened]. */ + private fun startSessions(opened: MutableList): SessionManager { + val service = ProxyService(ByteBufAllocator.DEFAULT) + service.start(null, null) { percentage, _, subActionText, _ -> + logger.debug { "Starting proxy service: $subActionText (${(percentage * 100).toInt()}%)" } + } + + service.filterSetStore = UnfilteredFilterSetStore + service.settingsStore = TapSettingSetStore + + // Leaves the low ports to a GUI that shares the same configured range. + repeat(portSkip.coerceAtLeast(1)) { service.allocatePort() } + + return sessionManager(service, sideloadDir, rendering, plugin = !noPlugin, opened) + } + + private companion object { + /** The logger of the command. */ + private val logger = InlineLogger() + } +} + +/** + * Start the bridge hub and build the sessions of a started proxy, with the stores the proxy holds at + * this moment. The sessions hear of every client the proxy launches and closes. With [plugin] false, + * launched clients get no bridge plugin, so only their packets can be read. Adds what must be closed + * when a later step fails to [opened]. A request acts on a client only through [gate], which nothing + * closes unless the caller does. + */ +internal fun sessionManager( + service: ProxyService, + sideloadDir: Path?, + rendering: Rendering, + plugin: Boolean, + opened: MutableList, + gate: Gate = Gate(), +): SessionManager { + val launcher = ProxyServiceLauncher(service, if (plugin) BridgeJar(sideloadDir) else null) + + // The plugin reads the same path in McpBridgePlugin.java. + val rendezvous = Path.of(System.getProperty("user.home"), ".rsprox", "mcp", "bridge.json") + val hub = BridgeHub(rendezvous, rendering) + + opened += hub + hub.start() + + // The proxy's own hook kills the clients; this one removes the rendezvous file they dial through. + Runtime.getRuntime().addShutdownHook(Thread(hub::close, "mcp-bridge-shutdown")) + + val manager = SessionManager(launcher, service.settingsStore, hub, gate = gate) + service.addClientListener(ClientWatch(manager)) + + return manager +} + +/** + * Attaches a session to each client that something other than the manager launches through the proxy, + * and tells the manager when any client is gone. + */ +private class ClientWatch( + /** The sessions that hear of the clients. */ + private val sessions: SessionManager, +) : ClientListener { + /** Attach a session to the client, unless the session manager launched it itself. */ + override fun onClientLaunch( + port: Int, + target: ProxyTargetConfig, + ): SessionMonitor? = sessions.attach(port, target) + + /** End or stop the session of the client, if any. */ + override fun onClientClosed(port: Int) { + sessions.detach(port) + } +} + +/** + * Run the startup steps in [body], and close what they added to the list, newest first, when one of them fails. + * The failure is passed on. + */ +internal fun closingOnFailure(body: (opened: MutableList) -> T): T { + val opened = ArrayList() + + try { + return body(opened) + } catch (t: Throwable) { + for (closeable in opened.asReversed()) { + try { + closeable.close() + } catch (e: Exception) { + t.addSuppressed(e) + } + } + + throw t + } +} + +/** Run the MCP command with the arguments of the process. */ +public fun main(args: Array): Unit = McpCommand().main(args) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/Access.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/Access.kt new file mode 100644 index 000000000..0ad24a50d --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/Access.kt @@ -0,0 +1,16 @@ +package net.rsprox.mcp.bridge + +/** + * What may be done with a client: read it, or also drive it by sending it input. The order matters: + * [DRIVE] includes [READ], so an access is enough for a call when it is at least what the call needs. + */ +public enum class Access( + /** The name on the wire: in the welcome the hub sends and in the `access` of a session. */ + public val wire: String, +) { + /** Read the client: its state, widgets, variables, entities, a screenshot and where the camera points. */ + READ("read"), + + /** Read the client and send it input: clicks, keys, a login and camera turns. */ + DRIVE("drive"), +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt new file mode 100644 index 000000000..f165368e0 --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt @@ -0,0 +1,270 @@ +package net.rsprox.mcp.bridge + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.github.michaelbull.logging.InlineLogger +import java.io.IOException +import java.io.Writer +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.attribute.PosixFilePermissions +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 +import java.util.concurrent.ConcurrentHashMap + +/** What a session wants to hear about the client it is waiting for. */ +internal interface BridgeListener { + /** The session id, told to the plugin for its log. */ + val session: String + + /** The most the session may do with the client, told to the plugin and bound into its link. */ + val access: Access + + /** + * Take the link of the client that said hello for this session. + * Returns false when the session no longer waits for this client; the hub then drops the link. + */ + fun onHello( + link: BridgeLink, + pid: Long, + ): Boolean + + /** Hear that the link, which this listener accepted earlier, has closed. */ + fun onClosed(link: BridgeLink) + + /** Hear that a hello for this session was rejected for [reason]. The hub keeps expecting the client. */ + fun onRejected(reason: String) +} + +/** How a launched client draws its frames. [SOFTWARE] has the plugin stop the client's GPU plugin. */ +public enum class Rendering { + /** Frames drawn by the GPU plugin, when the client's profile enables it. */ + GPU, + + /** Frames drawn without the GPU plugin, which the bridge plugin stops. */ + SOFTWARE, +} + +/** + * Where in-client bridge plugins connect. The plugin dials in: it finds the port and a token in the + * rendezvous file and names the HTTP port of its launch, which routes it to the session that expects it. + * A client this process did not launch is rejected and stays dormant. + */ +public class BridgeHub( + /** The file through which a plugin finds this hub. */ + private val rendezvous: Path, + /** The rendering that every welcomed client is told to use. */ + private val rendering: Rendering, +) : AutoCloseable { + /** The listeners that wait for a client, by the HTTP port of its launch. */ + private val expected = ConcurrentHashMap() + + /** The links to the connected clients. */ + private val links = ConcurrentHashMap.newKeySet() + + /** The secret that a plugin must repeat from the rendezvous file. */ + private val token = newToken() + + /** The socket that plugins dial, or null before [start]. */ + @Volatile + private var server: ServerSocket? = null + + /** Bind an ephemeral loopback port and publish it, with the token, in the rendezvous file. */ + public fun start() { + val server = ServerSocket(0, BACKLOG, InetAddress.getLoopbackAddress()) + this.server = server + + writeRendezvous(server.localPort) + + val thread = Thread({ acceptLoop(server) }, "mcp-bridge-accept") + thread.isDaemon = true + thread.start() + } + + /** Route the hello that names [httpPort] to [listener]. Must be called before the client is forked. */ + internal fun expect( + httpPort: Int, + listener: BridgeListener, + ) { + expected[httpPort] = listener + } + + /** Stop expecting the hello that names [httpPort], so a client that says it later is rejected. Idempotent. */ + internal fun forget(httpPort: Int) { + expected.remove(httpPort) + } + + /** Delete the rendezvous file, stop accepting plugins and drop every link. */ + override fun close() { + Files.deleteIfExists(rendezvous) + server?.close() + links.forEach { it.close() } + } + + /** Publish the port and the token in the rendezvous file, readable only by its owner. */ + private fun writeRendezvous(port: Int) { + val content = + MAPPER + .createObjectNode() + .put("port", port) + .put("token", token) + + Files.createDirectories(rendezvous.parent) + + // Written beside the target and moved into place, so a plugin never reads a partial file + // and the token is never in a file that others may read. + val temp = Files.createTempFile(rendezvous.parent, "bridge", ".tmp") + if (Files.getFileStore(temp).supportsFileAttributeView("posix")) { + Files.setPosixFilePermissions(temp, PosixFilePermissions.fromString("rw-------")) + } + + Files.write(temp, MAPPER.writeValueAsBytes(content)) + Files.move(temp, rendezvous, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } + + /** Hand each plugin that dials in to a thread of its own, until the server socket closes. */ + private fun acceptLoop(server: ServerSocket) { + while (true) { + val socket = + try { + server.accept() + } catch (e: IOException) { + return + } + + // A plugin dials once and gives up after a few seconds, so one silent peer must not hold up the next. + val thread = Thread({ greetOrDrop(socket) }, "mcp-bridge-hello") + thread.isDaemon = true + thread.start() + } + } + + /** Greet the plugin, and drop its connection when it does not complete its hello. */ + private fun greetOrDrop(socket: Socket) { + try { + greet(socket) + } catch (e: Exception) { + logger.debug(e) { "Dropped a bridge connection that did not complete its hello" } + socket.close() + } + } + + /** + * Read the hello of a plugin, then welcome it and hand its link to the session that expects it, or reject it. + * + * @throws IOException when the plugin closes the connection or stays silent before its hello + */ + private fun greet(socket: Socket) { + socket.soTimeout = HELLO_TIMEOUT_MS + val reader = socket.getInputStream().bufferedReader(Charsets.UTF_8) + val writer = socket.getOutputStream().bufferedWriter(Charsets.UTF_8) + val hello = MAPPER.readTree(reader.readLine() ?: throw IOException("closed before hello")) + val httpPort = hello.get("httpPort")?.asInt() ?: -1 + + // Only a caller that holds the token may end a launch, so a stray local connection cannot stop a session. + if (!tokenMatches(hello.get("token"))) return reject(socket, writer, "bad token") + + if (hello.get("hello")?.asInt() != PROTOCOL) return rejectStale(socket, writer, hello.get("hello"), httpPort) + + val listener = expected.remove(httpPort) + if (listener == null) return reject(socket, writer, "no session expects httpPort $httpPort") + + socket.soTimeout = 0 + val link = + BridgeLink(listener.access, socket, reader, writer) { closed -> + links.remove(closed) + listener.onClosed(closed) + } + + // The welcome goes out before the session can see the link, so no request can overtake it. + writer.line(welcome(listener)) + + if (!listener.onHello(link, hello.get("pid")?.asLong() ?: -1)) return socket.close() + + links += link + link.start() + logger.info { "Bridge connected for session ${listener.session} (httpPort $httpPort)" } + } + + /** Build the welcome that tells the plugin its session, what it may do with the client and the rendering to use. */ + private fun welcome(listener: BridgeListener): JsonNode = + MAPPER + .createObjectNode() + .put("welcome", PROTOCOL) + .put("session", listener.session) + .put("access", listener.access.wire) + .put("softwareRendering", rendering == Rendering.SOFTWARE) + + /** + * Reject a plugin that speaks another protocol than this hub, and tell the session that waits for + * its client, which will not dial again. + */ + private fun rejectStale( + socket: Socket, + writer: Writer, + protocol: JsonNode?, + httpPort: Int, + ) { + val refusal = "bridge protocol $protocol is not supported; the installed plugin jar is stale" + reject(socket, writer, refusal) + expected[httpPort]?.onRejected(refusal) + } + + /** Tell the plugin why it is not welcome and close its connection. */ + private fun reject( + socket: Socket, + writer: Writer, + reason: String, + ) { + writer.line(MAPPER.createObjectNode().put("reject", reason)) + socket.close() + } + + /** Determine if the candidate carries this hub's token, in constant time. */ + private fun tokenMatches(candidate: JsonNode?): Boolean { + if (candidate == null || !candidate.isTextual) return false + + return MessageDigest.isEqual(candidate.asText().toByteArray(), token.toByteArray()) + } + + /** Write the message as one line. */ + private fun Writer.line(message: JsonNode) { + write(MAPPER.writeValueAsString(message)) + write("\n") + flush() + } + + private companion object { + /** The logger of the hub. */ + private val logger = InlineLogger() + + /** The JSON mapper of the wire and the rendezvous file. */ + private val MAPPER: ObjectMapper = jacksonObjectMapper() + + /** + * The version of the wire protocol that this hub speaks. Version 2 added `access` to the welcome, and + * a plugin that does not read it would drive a client it was meant to read, so a version 1 jar is rejected. + */ + private const val PROTOCOL = 2 + + /** The number of plugins that may wait to be accepted. */ + private const val BACKLOG = 16 + + /** The longest wait for a plugin to send its hello. */ + private const val HELLO_TIMEOUT_MS = 5_000 + + /** Generate a random token of 22 URL-safe characters. */ + private fun newToken(): String { + val bytes = ByteArray(16) + SecureRandom().nextBytes(bytes) + + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + } + } +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt new file mode 100644 index 000000000..3a0a31ea8 --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt @@ -0,0 +1,56 @@ +package net.rsprox.mcp.bridge + +import net.rsprox.proxy.target.ProxyTargetConfig +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption + +/** Installs the embedded bridge plugin jar into the sideload directory of a target's client. */ +public class BridgeJar( + /** The sideload directory to use for every target, or null for the default of each target. */ + private val overrideDir: Path?, +) { + /** + * Install the jar unless the installed one already has the same content. A client that has the + * old jar open keeps reading it, because the new one is moved into place rather than written over it. + */ + public fun installFor(target: ProxyTargetConfig) { + val dir = overrideDir ?: defaultDir(target) + val installed = dir.resolve(FILE_NAME) + val wanted = embedded() + + if (Files.isRegularFile(installed) && Files.readAllBytes(installed).contentEquals(wanted)) return + + Files.createDirectories(dir) + + // The client loads every `.jar` in the directory, so the partial file must not look like one. + val temp = Files.createTempFile(dir, FILE_NAME, ".tmp") + Files.write(temp, wanted) + Files.move(temp, installed, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } + + /** Get the sideload directory of the client of the target, in the home directory of the current user. */ + private fun defaultDir(target: ProxyTargetConfig): Path { + // Custom targets run a client that keeps its files apart from those of the official one. + val client = if (target.id == 0) ".runelite" else ".rlcustom" + + return Path.of(System.getProperty("user.home"), client, SIDELOAD_DIR) + } + + /** Read the jar that this build embeds. */ + private fun embedded(): ByteArray { + val resource = + BridgeJar::class.java.getResourceAsStream("/$FILE_NAME") + ?: error("The bridge plugin jar is missing from this build") + + return resource.use { it.readAllBytes() } + } + + private companion object { + /** The name of the jar, both as a resource and as installed. */ + private const val FILE_NAME = "rsprox-mcp-bridge.jar" + + /** The name of the directory, under the client's own, that plugins are sideloaded from. */ + private const val SIDELOAD_DIR = "sideloaded-plugins" + } +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt new file mode 100644 index 000000000..f5c949a0b --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt @@ -0,0 +1,173 @@ +package net.rsprox.mcp.bridge + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import java.io.BufferedReader +import java.io.Closeable +import java.io.IOException +import java.io.Writer +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong + +/** + * A failed bridge call. [code] is one the plugin reports (`bad_args`, `not_found`, `wrong_state`, + * `timeout`, `internal`) or `closed` when the client went away. + */ +public class BridgeError( + /** The code that says what kind of failure this is. */ + public val code: String, + message: String, +) : Exception(message) + +/** + * One connected in-client bridge. Compared by identity, so a late event from an old link cannot match a new one. + * + * The wire carries one JSON object per line in each direction. Requests may overlap; each reply is + * matched to its caller by id. A link made for reading never forwards a request that sends input, + * whatever the caller asks. + */ +public class BridgeLink internal constructor( + /** The most that the plugin behind the link was told it may do, which bounds every call. */ + public val access: Access, + /** The connection to the plugin. */ + private val socket: Closeable, + /** The source of the plugin's replies. */ + private val reader: BufferedReader, + /** The sink of the requests. */ + private val writer: Writer, + /** The callback that hears, once, that the link has closed. */ + private val onClosed: (BridgeLink) -> Unit, +) { + /** The calls that wait for a reply, by request id. */ + private val pending = ConcurrentHashMap>() + + /** The source of request ids. */ + private val ids = AtomicLong() + + /** Whether the reader has stopped, after which no reply can arrive. */ + private val closed = AtomicBoolean() + + /** Start the thread that reads the plugin's replies. */ + internal fun start() { + val thread = Thread(::readLoop, "mcp-bridge-reader") + thread.isDaemon = true + thread.start() + } + + /** + * Send one request, which needs [access], and block the calling thread for its reply. The request + * is sent only through the open [gate]. + * + * @return the `ok` value of the reply + * @throws BridgeError when the request needs more than this link's access (code `read_only`), when + * the plugin answers `err`, when no reply arrives within [timeoutMs] (code `timeout`), or when the + * link is or becomes closed or the gate is closed (code `closed`) + */ + public fun call( + op: String, + args: ObjectNode, + access: Access, + timeoutMs: Long, + gate: Gate = Gate(), + ): JsonNode { + if (access > this.access) { + throw BridgeError("read_only", "'$op' sends input, which a link made for reading never forwards") + } + + val id = ids.incrementAndGet() + val reply = CompletableFuture() + pending[id] = reply + + try { + // Checked after registering: a close that happens from here on fails this call through `pending`. + if (closed.get()) throw closedError() + + val request = MAPPER.createObjectNode().put("id", id).put("op", op) + request.set("args", args) + + val sent = + try { + gate.ifOpen { send(MAPPER.writeValueAsString(request)) } + } catch (e: IOException) { + close() + throw closedError() + } + + if (!sent) throw BridgeError("closed", "the MCP endpoint was turned off") + + return reply.get(timeoutMs, TimeUnit.MILLISECONDS) + } catch (e: TimeoutException) { + throw BridgeError("timeout", "the client did not answer '$op' within $timeoutMs ms") + } catch (e: ExecutionException) { + throw e.cause ?: e + } finally { + pending.remove(id) + } + } + + /** Drop the connection. The reader then fails the pending calls and reports the close. */ + internal fun close() { + try { + socket.close() + } catch (e: IOException) { + // The link is being dropped either way. + } + } + + /** Write one line to the plugin. */ + @Synchronized + private fun send(line: String) { + writer.write(line) + writer.write("\n") + writer.flush() + } + + /** Complete each pending call with its reply until the connection ends, then fail the rest and report the close. */ + private fun readLoop() { + try { + while (true) { + val line = reader.readLine() ?: break + val message = MAPPER.readTree(line) + val id = message?.get("id")?.asLong() ?: continue + val reply = pending[id] ?: continue + val err = message.get("err") + + if (err != null) { + val code = err.get("code")?.asText() ?: "internal" + reply.completeExceptionally(BridgeError(code, err.get("message")?.asText().orEmpty())) + } else { + reply.complete(message.get("ok") ?: MAPPER.nullNode()) + } + } + } catch (e: IOException) { + // Falls through to the close below, like an orderly end of stream. + } catch (e: JsonProcessingException) { + // A peer that stops speaking the protocol cannot be resynchronised. + } finally { + closed.set(true) + close() + + for (reply in pending.values) { + reply.completeExceptionally(closedError()) + } + + onClosed(this) + } + } + + /** Build the error for a call on a link whose client has gone away. */ + private fun closedError(): BridgeError = BridgeError("closed", "the client is no longer connected") + + private companion object { + /** The JSON mapper of the wire. */ + private val MAPPER: ObjectMapper = jacksonObjectMapper() + } +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/Gate.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/Gate.kt new file mode 100644 index 000000000..7c0279cc0 --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/Gate.kt @@ -0,0 +1,45 @@ +package net.rsprox.mcp.bridge + +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.read +import kotlin.concurrent.write + +/** + * Decides whether a request may still act on a client: fork one, kill one or send one a request. + * + * A request can run for minutes, so closing the endpoint cannot wait for it, and its thread cannot be + * stopped from outside. Each act therefore passes the gate at the moment it happens, and [close] waits + * only for the acts that are passing, which are short. Once [close] has returned, no act happens until + * [open]. The standalone server never closes its gate. + */ +public class Gate( + open: Boolean = true, +) { + /** The lock that the acts share and that opening and closing take alone. */ + private val lock = ReentrantReadWriteLock() + + /** Whether acts pass. Guarded by [lock]. */ + private var open = open + + /** Whether acts pass at this moment, which says nothing of the next. */ + public val isOpen: Boolean + get() = lock.read { open } + + /** Run [act] if the gate is open, and keep the gate from closing until it returns. Returns whether it ran. */ + public fun ifOpen(act: () -> Unit): Boolean = + lock.read { + if (open) act() + + open + } + + /** Let acts pass. Idempotent. */ + public fun open() { + lock.write { open = true } + } + + /** Refuse every act from now on, once the acts that are passing have returned. Idempotent. */ + public fun close() { + lock.write { open = false } + } +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt new file mode 100644 index 000000000..f1989d6c1 --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt @@ -0,0 +1,252 @@ +package net.rsprox.mcp.packets + +import java.util.concurrent.TimeUnit +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +/** Position in one session's packet log. 0 is before the first record. Monotonic across logins and restarts. */ +@JvmInline +public value class Cursor( + /** The sequence number of the last record before the position. */ + public val seq: Long, +) + +/** Who produced a record: the game client, the game server, or rsprox itself (lifecycle markers). */ +public enum class Origin( + /** The letter that stands for the origin in a rendered packet line. */ + public val letter: Char, +) { + /** A packet the game client sent to the server. */ + CLIENT('C'), + + /** A packet the game server sent to the client. */ + SERVER('S'), + + /** A lifecycle marker that rsprox added. */ + PROXY('P'), +} + +/** One record of the log: a decoded packet, or a lifecycle marker that rsprox added. */ +public data class PacketRecord( + /** The position in the log: 1-based, contiguous, never reused. */ + val seq: Long, + /** The login epoch within the session, or 0 before the first login. */ + val login: Int, + /** The server tick within that login. Always 0 for [Origin.PROXY] records. */ + val cycle: Int, + /** The producer of the record. */ + val origin: Origin, + /** The upper-case prot name such as `IF_SETTEXT`, or a lifecycle marker such as `LOGIN`. */ + val prot: String, + /** The decoded packet as text, or the detail of a lifecycle marker. */ + val text: String, +) + +/** What a read of the log asks for: where to start, which records to keep and how many. */ +public data class PacketQuery( + /** The cursor to read after. */ + val after: Cursor = Cursor(0), + /** The upper-case prot names to keep. Empty matches every prot. */ + val prots: Set = emptySet(), + /** The one origin to keep, or null to keep every origin. */ + val origin: Origin? = null, + /** The case-insensitive substring that the record text must hold, or null to keep every text. */ + val contains: String? = null, + /** The most records to return. */ + val limit: Int = DEFAULT_LIMIT, + /** The upper-case prefixes to keep. A prot name must start with one of them. Empty matches every prot. */ + val protPrefixes: Set = emptySet(), +) { + public companion object { + /** The limit of a query that names none. */ + public const val DEFAULT_LIMIT: Int = 200 + } +} + +/** What a read of the log returns: the records that matched and where the read ended. */ +public data class PacketPage( + /** The records that matched, oldest first. */ + val packets: List, + /** The cursor to pass as `after` next time. Everything up to here was either returned or did not match. */ + val next: Cursor, + /** The cursor of the newest record in the log. */ + val head: Cursor, + /** The number of records after the query's cursor that were evicted before they could be scanned. */ + val dropped: Long, + /** Whether a wait elapsed with no match. */ + val timedOut: Boolean, +) + +/** + * Bounded append-only log. Records are contiguous, so the record with sequence `seq` sits at index + * `seq - firstSeq` and a read after a cursor only scans the tail it has not seen. + */ +public class PacketLog( + /** The most records the log keeps before it evicts the oldest. */ + private val capacity: Int = 100_000, +) { + /** The lock that guards the records. */ + private val lock = ReentrantLock() + + /** The condition that wakes the readers that wait for a record. */ + private val appended = lock.newCondition() + + /** The records that have not been evicted, oldest first. */ + private val records = ArrayDeque() + + /** The sequence number of the oldest record that has not been evicted. */ + private var firstSeq = 1L + + /** Append a record, evict the oldest one when the log is full, and return the new sequence number. */ + internal fun append( + login: Int, + cycle: Int, + origin: Origin, + prot: String, + text: String, + ): Long = + lock.withLock { + val seq = firstSeq + records.size + records.addLast(PacketRecord(seq, login, cycle, origin, prot, text)) + + if (records.size > capacity) { + records.removeFirst() + firstSeq++ + } + + appended.signalAll() + seq + } + + /** + * Read the records after the query's cursor that match it. + * With `waitMs` of 0 this returns whatever matches now. With a positive `waitMs` and nothing matching, + * it blocks until the first match arrives or the wait elapses. + */ + public fun read( + query: PacketQuery, + waitMs: Long = 0, + ): PacketPage { + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(waitMs) + var target = head().seq + + // A cursor past the head can only come from another log; reading from the head keeps it usable. + val scan = Scan(query, query.after.seq.coerceIn(0, target)) + + while (true) { + scan.advance(target) + + if (!scan.isAt(target)) continue + + if (scan.matches.isNotEmpty() || waitMs <= 0) return scan.page(timedOut = false) + + target = awaitAppend(target, deadline) ?: return scan.page(timedOut = true) + } + } + + /** One read in progress: the records matched so far and how far the log has been scanned. */ + private inner class Scan( + /** The query that the read answers. */ + private val query: PacketQuery, + /** The sequence number of the last record that was scanned or evicted. */ + private var scanned: Long, + ) { + /** The records that matched so far, oldest first. */ + val matches = ArrayList() + + /** The number of records that were evicted before they could be scanned. */ + private var dropped = 0L + + /** + * Scan the next slice of the records up to [target]. The records are matched outside the lock, + * a slice at a time, so a broad query never holds up an append. + */ + fun advance(target: Long) { + val slice = lock.withLock { sliceAfter(scanned, target) } + scanned = slice.first - 1 + dropped += slice.dropped + + for (record in slice.records) { + if (isFull()) return + + scanned++ + + if (query.matches(record)) matches += record + } + } + + /** Determine if the scan has nothing left to do up to [target]: it reached it or holds the limit. */ + fun isAt(target: Long): Boolean = isFull() || scanned >= target + + /** Build the page of the read, whose wait elapsed with no match when [timedOut]. */ + fun page(timedOut: Boolean): PacketPage = PacketPage(matches, Cursor(scanned), head(), dropped, timedOut) + + /** Determine if the scan holds as many matches as the query asks for. */ + private fun isFull(): Boolean = matches.size >= query.limit + } + + /** Get the cursor of the newest record. */ + public fun head(): Cursor = lock.withLock { Cursor(headSeq()) } + + /** Get the sequence number of the newest record, or one below the first when the log is empty. */ + private fun headSeq(): Long = firstSeq + records.size - 1 + + /** + * Copy the next records after [scanned], up to [target] and at most [SLICE] of them. + * Must be called with the lock held. + */ + private fun sliceAfter( + scanned: Long, + target: Long, + ): Slice { + val first = maxOf(scanned + 1, firstSeq) + val count = (target - first + 1).coerceIn(0, SLICE) + val offset = (first - firstSeq).toInt() + + return Slice(first, first - (scanned + 1), List(count.toInt()) { records[offset + it] }) + } + + /** Wait for a record after [head] and return the new head, or null when the deadline passes first. */ + private fun awaitAppend( + head: Long, + deadline: Long, + ): Long? = + lock.withLock { + while (headSeq() <= head) { + val remaining = deadline - System.nanoTime() + if (remaining <= 0) return null + + appended.awaitNanos(remaining) + } + + headSeq() + } + + /** Determine if the record passes the prot, prot prefix, origin and text filters of the query. */ + private fun PacketQuery.matches(record: PacketRecord): Boolean { + if (prots.isNotEmpty() && record.prot !in prots) return false + + if (protPrefixes.isNotEmpty() && protPrefixes.none(record.prot::startsWith)) return false + + if (origin != null && record.origin != origin) return false + + if (contains != null && !record.text.contains(contains, ignoreCase = true)) return false + + return true + } + + /** A run of consecutive records, copied out of the log under its lock. */ + private class Slice( + /** The sequence number of the first record of the slice, or of the next record when the slice is empty. */ + val first: Long, + /** The number of records before [first] that were evicted before they could be scanned. */ + val dropped: Long, + /** The records of the slice, oldest first. */ + val records: List, + ) + + private companion object { + /** The most records copied under the lock at a time. */ + private const val SLICE = 1024L + } +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt new file mode 100644 index 000000000..d6cb61c07 --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt @@ -0,0 +1,133 @@ +package net.rsprox.mcp.packets + +import net.rsprox.cache.api.CacheProvider +import net.rsprox.mcp.session.LoginInfo +import net.rsprox.mcp.session.LoginRegistry +import net.rsprox.proxy.binary.BinaryHeader +import net.rsprox.proxy.util.NopSessionMonitor +import net.rsprox.shared.SessionMonitor +import net.rsprox.shared.StreamDirection +import net.rsprox.shared.property.OmitFilteredPropertyTreeFormatter +import net.rsprox.shared.property.PropertyFormatterCollection +import net.rsprox.shared.property.PropertyTreeFormatter +import net.rsprox.shared.property.RootProperty +import net.rsprox.shared.settings.SettingSetStore +import net.rsprox.shared.symbols.SymbolDictionaryProvider + +/** + * The session monitor of one client. The proxy only delivers callbacks to the per-login instance that + * [forSession] returns, so the callbacks of this class itself do nothing. + */ +internal class PacketTap( + /** The log that the packets of every login go to. */ + private val log: PacketLog, + /** The registry of the session's logins. */ + private val logins: LoginRegistry, + /** The settings that the packets are formatted with. */ + private val settings: SettingSetStore, + /** The folder the proxy records the logins to, under its `binary` directory, or null when it records none. */ + private val captureFolder: String?, +) : SessionMonitor by NopSessionMonitor { + /** Create the tap of one login, under the next login epoch. */ + override fun forSession(header: BinaryHeader): SessionMonitor = + LoginTap(log, logins, settings, logins.nextEpoch(), captureFolder?.let { "$it/${header.fileName()}" }) +} + +/** The session monitor of one login, which appends the packets of that login to the log as text. */ +private class LoginTap( + /** The log that the packets of this login go to. */ + private val log: PacketLog, + /** The registry that hears what happens to this login. */ + private val logins: LoginRegistry, + settings: SettingSetStore, + /** The login epoch that every record of this tap carries. */ + private val epoch: Int, + /** The file the proxy records this login to, or null when it records none. */ + private val captureFile: String?, +) : SessionMonitor by NopSessionMonitor { + /** + * The game cache that the formatter looks names up in, or null before the proxy has handed it over. + * Written on a Netty thread when the transcriber is hooked, read on the transcriber worker. + */ + @Volatile + private var cache: CacheProvider? = null + + /** The direction of the packet being transcribed. Only touched on the transcriber worker. */ + private var direction = StreamDirection.SERVER_TO_CLIENT + + /** Whether a packet of this login has been decoded. Only touched on the transcriber worker. */ + private var transcribing = false + + /** The newest tick that was registered for this login. Only touched on the transcriber worker. */ + private var tick = -1 + + /** The formatter that turns a decoded packet into text. */ + private val formatter: PropertyTreeFormatter = + OmitFilteredPropertyTreeFormatter( + PropertyFormatterCollection.default(SymbolDictionaryProvider.get(), settings) { + cache?.get() ?: error("Cache unavailable") + }, + ) + + /** Get this tap, which is the monitor of its one login already. */ + override fun forSession(header: BinaryHeader): SessionMonitor = this + + /** Keep the cache for the formatter. */ + override fun onCacheUpdate(cacheProvider: CacheProvider) { + cache = cacheProvider + } + + /** Keep the direction of the next packet. The first packet also registers that this login is being transcribed. */ + override fun onPacketDirection(direction: StreamDirection) { + this.direction = direction + + // The cache update arrives before the proxy knows whether a decoder exists for the revision, + // so the first decoded packet is the earliest proof that a transcriber is hooked. + if (!transcribing) { + transcribing = true + logins.update(epoch) { it.copy(transcribing = true) } + } + } + + /** + * Append the packet to the log as text, and register its tick when it is a new one. + * Formats on the worker, while the transcriber's session state still matches the packet. + */ + override fun onTranscribe( + cycle: Int, + property: RootProperty, + ) { + val origin = if (direction == StreamDirection.CLIENT_TO_SERVER) Origin.CLIENT else Origin.SERVER + val text = + try { + formatter.format(property).joinToString("\n") + } catch (e: Exception) { + // An exception here would unwind into the proxy's transcriber worker. + "[${property.prot.lowercase()}] " + } + + log.append(epoch, cycle, origin, property.prot.uppercase(), text) + + if (cycle != tick) { + tick = cycle + logins.update(epoch) { it.copy(tick = cycle) } + } + } + + /** Register the login and mark it in the log. */ + override fun onLogin(header: BinaryHeader) { + logins.login(LoginInfo.of(epoch, header, captureFile)) + log.append(epoch, 0, Origin.PROXY, "LOGIN", "revision=${header.revision} world=${header.worldId}") + } + + /** Register the logout and mark it in the log. */ + override fun onLogout(header: BinaryHeader) { + logins.update(epoch) { it.copy(online = false) } + log.append(epoch, 0, Origin.PROXY, "LOGOUT", "world=${header.worldId}") + } + + /** Register the name of the player that logged in. */ + override fun onNameUpdate(name: String) { + logins.update(epoch) { it.copy(name = name) } + } +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/packets/Unfiltered.kt b/mcp/src/main/kotlin/net/rsprox/mcp/packets/Unfiltered.kt new file mode 100644 index 000000000..71c22a87a --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/packets/Unfiltered.kt @@ -0,0 +1,62 @@ +package net.rsprox.mcp.packets + +import net.rsprox.proxy.filters.DefaultPropertyFilterSetStore +import net.rsprox.proxy.filters.UnmodifiablePropertyFilterSet +import net.rsprox.proxy.settings.DefaultSettingSetStore +import net.rsprox.shared.filters.PropertyFilter +import net.rsprox.shared.filters.PropertyFilterSet +import net.rsprox.shared.filters.PropertyFilterSetStore +import net.rsprox.shared.settings.NopSettingSet +import net.rsprox.shared.settings.Setting +import net.rsprox.shared.settings.SettingSet +import net.rsprox.shared.settings.SettingSetStore +import java.nio.file.Path + +/** + * Every filter enabled, no regex filters, nothing persisted. The stores on disk belong to the GUI, + * and every mutator of the default filter set saves to them. Outside the GUI only the active set is + * asked for, which reads and writes no file. + */ +internal object UnfilteredFilterSetStore : + PropertyFilterSetStore by DefaultPropertyFilterSetStore(Path.of("."), mutableListOf(AllEnabled)) + +/** The one filter set of the store, in which every filter is on, so that no packet is omitted. */ +private object AllEnabled : PropertyFilterSet by UnmodifiablePropertyFilterSet() { + /** Report the filter as enabled, as every filter is. */ + override fun get(filter: PropertyFilter): Boolean = true +} + +/** + * The default of every setting, except that each setting the transcriber reads to drop output is off. + * Fixed in memory so the transcript does not depend on what the user ticked in the GUI. + * + * Settings that drop output, and where the transcriber reads them: + * - `SKIP_FIRST_TICK`: every packet of tick 0 (`TextTranscriber`, `TextRs3Transcriber`). + * - `HIDE_UNNECESSARY_VARPS`, `HIDE_SAME_VALUE_VARPS`: varp packets (`TextServerPacketTranscriber`). + * - `PLAYER_INFO_HIDE_EMPTY`: an empty player info packet (`TextPlayerInfoTranscriber`). + * - `PLAYER_INFO_LOCAL_PLAYER_ONLY`: updates of every other player (`TextPlayerInfoTranscriber`). + * - `NPC_INFO_HIDE_EMPTY`: an empty NPC info packet (`TextNpcInfoTranscriber`). + * - `WORLDENTITY_INFO_HIDE_EMPTY`: an empty world entity info packet (`TextServerPacketTranscriber`). + * - `HIDE_RS3_LOBBY`: RuneScape 3 lobby packets (`TextRs3Transcriber`). + */ +internal object TapSettingSetStore : + SettingSetStore by DefaultSettingSetStore(Path.of("."), mutableListOf(Fixed)) + +/** The one setting set of the store, whose values cannot be changed. */ +private object Fixed : SettingSet by NopSettingSet { + /** The settings that drop output, which this set reports as off. */ + private val off = + setOf( + Setting.SKIP_FIRST_TICK, + Setting.HIDE_UNNECESSARY_VARPS, + Setting.HIDE_SAME_VALUE_VARPS, + Setting.PLAYER_INFO_HIDE_EMPTY, + Setting.PLAYER_INFO_LOCAL_PLAYER_ONLY, + Setting.NPC_INFO_HIDE_EMPTY, + Setting.WORLDENTITY_INFO_HIDE_EMPTY, + Setting.HIDE_RS3_LOBBY, + ) + + /** Determine if the setting is on: its default, unless it is one that drops output. */ + override fun get(setting: Setting): Boolean = setting.enabled && setting !in off +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/InteractionProof.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/InteractionProof.kt new file mode 100644 index 000000000..dc9a38a2d --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/InteractionProof.kt @@ -0,0 +1,130 @@ +package net.rsprox.mcp.server + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ObjectNode +import net.rsprox.mcp.packets.Cursor +import net.rsprox.mcp.packets.Origin +import net.rsprox.mcp.packets.PacketLog +import net.rsprox.mcp.packets.PacketQuery +import net.rsprox.mcp.packets.PacketRecord +import net.rsprox.mcp.session.Session +import java.util.concurrent.TimeUnit + +/** + * The prot prefixes that prove an interaction, for each expected prefix that is not its own only proof. + * A widget op on a dialog option runs the widget's script, which sends RESUME_PAUSEBUTTON and no button + * packet, and a close button sends CLOSE_MODAL. + */ +private val PROVING_PREFIXES = + mapOf("IF_BUTTON" to setOf("IF_BUTTON", "IF_SUBOP", "RESUME_PAUSEBUTTON", "CLOSE_MODAL")) + +/** The prot prefix of the record that the client sends for every press of a mouse button. */ +private const val MOUSE_CLICK = "EVENT_MOUSE_CLICK" + +/** The canvas position in the text of a mouse click record. */ +private val CLICK_POINT = Regex("""\bx=(\d+), y=(\d+)\b""") + +/** The note on an interaction whose login has no decoder, so that none of its packets reach the log. */ +private const val NOT_DECODED = + "the packets of this login are not decoded, so the action could not be confirmed in the packet log" + +/** The note on a widget interaction that the client performed without a packet. */ +private const val NO_PACKET = + "the client performed the option and sent no packet for it, which is normal for an option the client " + + "handles by itself" + +/** + * Add the packet that the client sent for an interaction to the plugin's [answer], as `packet`. + * The plugin names the prot prefix to expect and the canvas point it pressed, and neither is part of + * the result. Packets reach the log up to a tick late, so the packet is tied to this interaction by + * the client's own record of the press: it is the first packet with a proving prefix after that + * record, and `proof` is then `click`. When no such record arrives within [waitMs], the packet is the + * first one with a proving prefix after the answer's `cursor`, and `proof` is `prefix`. + * + * Throws [ToolError] when the client sent no packet for a target in the game world. A widget option + * that the client handles by itself sends none, so `packet` is then null and `note` says why. + */ +internal fun confirmSent( + session: Session, + answer: ObjectNode, + waitMs: Long, +): ObjectNode { + val expect = answer.remove("expect").asText() + val pressed = point(answer.remove("pressed")) + + if (session.logins.current()?.transcribing != true) return answer.putNull("packet").put("note", NOT_DECODED) + + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(waitMs) + val cursor = Cursor(answer.get("cursor").asLong()) + val click = awaitClick(session.packets, cursor, pressed, deadline) + val query = + PacketQuery( + after = if (click == null) cursor else Cursor(click.seq), + origin = Origin.CLIENT, + limit = 1, + protPrefixes = PROVING_PREFIXES[expect] ?: setOf(expect), + ) + + val sent = session.packets.read(query, remainingMs(deadline)).packets.firstOrNull() + if (sent == null) return unsent(answer, expect, waitMs) + + return answer.put("packet", line(sent)).put("proof", if (click == null) "prefix" else "click") +} + +/** + * Find the client's record of the press at [pressed] among the mouse clicks after [after]. + * Returns null when none arrives before [deadline]. + */ +private fun awaitClick( + packets: PacketLog, + after: Cursor, + pressed: Pair, + deadline: Long, +): PacketRecord? { + var cursor = after + + while (true) { + val query = PacketQuery(after = cursor, origin = Origin.CLIENT, protPrefixes = setOf(MOUSE_CLICK)) + val page = packets.read(query, remainingMs(deadline)) + val click = page.packets.firstOrNull { clickPoint(it.text) == pressed } + + if (click != null || page.packets.isEmpty()) return click + + cursor = page.next + } +} + +/** + * Get the canvas position in the text of a mouse click record, or null when the text holds none. + * The transcriber prints the record as + * `[event_mouse_click_v2] lasttransmitted=40ms, x=312, y=171, rightclick=false, code=0`. + */ +private fun clickPoint(text: String): Pair? { + val (x, y) = CLICK_POINT.find(text)?.destructured ?: return null + + return x.toInt() to y.toInt() +} + +/** Get the canvas point that the plugin answered as an array of its x and its y. */ +private fun point(pressed: JsonNode): Pair = pressed.get(0).asInt() to pressed.get(1).asInt() + +/** Get the milliseconds that are left until [deadline], or 0 once it has passed. */ +private fun remainingMs(deadline: Long): Long = + TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()).coerceAtLeast(0) + +/** + * Answer an interaction that the client sent no packet for. Throws [ToolError] for a target in the + * game world, where the client dropped the action. + */ +private fun unsent( + answer: ObjectNode, + expect: String, + waitMs: Long, +): ObjectNode { + val kind = TargetKind.of(answer.get("target").asText()) + if (!kind.inWorld) return answer.putNull("packet").put("note", NO_PACKET) + + val dropped = "the client sent no $expect packet within $waitMs ms, so the action was dropped" + + throw ToolError("$dropped. Likely cause: ${kind.dropCause}.") +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt new file mode 100644 index 000000000..152030def --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt @@ -0,0 +1,451 @@ +package net.rsprox.mcp.server + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.github.michaelbull.logging.InlineLogger +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import net.rsprox.mcp.bridge.BridgeError +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.URI +import java.net.URISyntaxException +import java.util.concurrent.Executors + +/** A failure the agent can act on. Becomes a tool result with `isError: true` and the message as its text. */ +public class ToolError( + message: String, +) : Exception(message) + +/** What a tool returns, as the content blocks of an MCP reply. */ +public sealed interface ToolResult { + /** One text block holding [value] as compact JSON. */ + public data class Json( + /** The value to serialize. */ + val value: Any, + ) : ToolResult + + /** One text block holding [text] as is. */ + public data class Text( + /** The text of the block. */ + val text: String, + ) : ToolResult + + /** A PNG image block followed by one text block holding [meta] as compact JSON. */ + public data class Image( + /** The PNG, encoded as Base64. */ + val pngBase64: String, + /** The value that describes the image. */ + val meta: Any, + ) : ToolResult +} + +/** One tool of the MCP server: its name, its description, the schema of its arguments and its handler. */ +public class Tool( + /** The name an MCP client calls the tool by. */ + public val name: String, + /** The description that tells an agent what the tool does and returns. */ + public val description: String, + /** The JSON Schema of the arguments. */ + public val inputSchema: ObjectNode, + /** The handler, which receives arguments that already satisfy [inputSchema]. */ + public val run: (args: ObjectNode) -> ToolResult, +) + +/** The reply to one HTTP request. */ +internal class HttpReply( + /** The HTTP status code. */ + val status: Int, + /** The JSON body, or null for a reply without one. */ + val body: String?, +) + +/** A failure of the JSON-RPC message itself, which becomes a JSON-RPC error and not a tool result. */ +private class RpcError( + /** The JSON-RPC error code. */ + val code: Int, + message: String, +) : Exception(message) + +/** The MCP protocol without the socket: one HTTP request in, one reply out. */ +internal class McpDispatcher( + tools: List, + /** The version that the server reports of itself. */ + private val version: String, +) { + /** The tools by name, in the order they are listed. */ + private val tools = tools.associateBy { it.name } + + /** Answer one HTTP request with the reply to its JSON-RPC message. */ + fun handle( + httpMethod: String, + origin: String?, + body: String, + ): HttpReply { + // A browser page on another site could otherwise reach this loopback server through DNS rebinding. + if (origin != null && !isLocalOrigin(origin)) return HttpReply(403, null) + + if (httpMethod != "POST") return HttpReply(405, null) + + val root = + try { + MAPPER.readTree(body) + } catch (e: JsonProcessingException) { + return HttpReply(400, error(null, PARSE_ERROR, "Parse error")) + } + + if (root !is ObjectNode) return invalidRequest(null, "Expected one JSON-RPC object; batching is unsupported") + + val id = root.get("id")?.takeUnless { it.isNull } + val method = root.get("method")?.takeIf { it.isTextual }?.asText() + + if (method == null) { + val isResponse = root.has("result") || root.has("error") + if (isResponse) return HttpReply(202, null) + + return invalidRequest(id, "Missing method") + } + + if (id == null) return HttpReply(202, null) + + val result = + try { + call(method, root.get("params")) + } catch (e: RpcError) { + return HttpReply(200, error(id, e.code, e.message.orEmpty())) + } + + val reply = MAPPER.createObjectNode() + reply.put("jsonrpc", "2.0") + reply.set("id", id) + reply.set("result", result) + + return HttpReply(200, MAPPER.writeValueAsString(reply)) + } + + /** Run the JSON-RPC method. Throws [RpcError] for a method this server does not have. */ + private fun call( + method: String, + params: JsonNode?, + ): JsonNode = + when (method) { + "initialize" -> initialize(params) + "ping" -> MAPPER.createObjectNode() + "tools/list" -> listTools() + "tools/call" -> callTool(params) + else -> throw RpcError(METHOD_NOT_FOUND, "Method not found: $method") + } + + /** Answer the handshake with the protocol version to speak and what the server offers. */ + private fun initialize(params: JsonNode?): JsonNode { + val requested = params?.get("protocolVersion")?.asText() + val result = MAPPER.createObjectNode() + result.put("protocolVersion", if (requested in SUPPORTED) requested else SUPPORTED.last()) + result.putObject("capabilities").putObject("tools") + result + .putObject("serverInfo") + .put("name", "rsprox") + .put("version", version) + + return result + } + + /** List every tool with its description and the schema of its arguments. */ + private fun listTools(): JsonNode { + val result = MAPPER.createObjectNode() + val list = result.putArray("tools") + + for (tool in tools.values) { + list + .addObject() + .put("name", tool.name) + .put("description", tool.description) + .set("inputSchema", tool.inputSchema) + } + + return result + } + + /** + * Run the named tool and render its result, or its failure as a result with `isError`. + * Throws [RpcError] when the call names no known tool or its arguments are not an object. + */ + private fun callTool(params: JsonNode?): JsonNode { + val name = + params?.get("name")?.takeIf { it.isTextual }?.asText() + ?: throw RpcError(INVALID_PARAMS, "tools/call requires params.name") + + val tool = tools[name] ?: throw RpcError(INVALID_PARAMS, "Unknown tool: $name") + val arguments = params.get("arguments")?.takeUnless { it.isNull } ?: MAPPER.createObjectNode() + + if (arguments !is ObjectNode) throw RpcError(INVALID_PARAMS, "params.arguments must be an object") + + // An explicit null means the argument is absent, for the checks, the tool and the plugin alike. + arguments.retain(arguments.fieldNames().asSequence().filter { arguments.hasNonNull(it) }.toList()) + + val result = MAPPER.createObjectNode() + val content = + try { + checkArguments(tool, arguments) + val content = render(tool.run(arguments)) + result.put("isError", false) + content + } catch (e: ToolError) { + result.put("isError", true) + listOf(textBlock(e.message.orEmpty())) + } catch (e: BridgeError) { + result.put("isError", true) + listOf(textBlock("${e.code}: ${e.message}")) + } catch (t: Throwable) { + logger.error(t) { "Tool $name failed" } + result.put("isError", true) + listOf(textBlock("internal: $t")) + } + + result.putArray("content").addAll(content) + + return result + } + + /** Turn a tool result into the content blocks of an MCP reply. */ + private fun render(result: ToolResult): List = + when (result) { + is ToolResult.Json -> listOf(textBlock(MAPPER.writeValueAsString(result.value))) + is ToolResult.Text -> listOf(textBlock(result.text)) + is ToolResult.Image -> + listOf( + MAPPER + .createObjectNode() + .put("type", "image") + .put("data", result.pngBase64) + .put("mimeType", "image/png"), + textBlock(MAPPER.writeValueAsString(result.meta)), + ) + } + + /** Build a text content block. */ + private fun textBlock(text: String): ObjectNode = + MAPPER + .createObjectNode() + .put("type", "text") + .put("text", text) + + /** + * Check the arguments against the schema of the tool. Throws [ToolError] for the first mismatch. + * Checks the subset of JSON Schema the tool table uses: `required`, per-property `type`, `enum`, + * `minimum`, `maximum`, and `items.type` and `items.enum` for arrays. Unknown keys are rejected so a misspelt + * argument fails loudly instead of being ignored. + */ + private fun checkArguments( + tool: Tool, + arguments: ObjectNode, + ) { + val properties = tool.inputSchema.get("properties") ?: MAPPER.createObjectNode() + + for (key in arguments.fieldNames()) { + if (!properties.has(key)) { + val allowed = properties.fieldNames().asSequence().joinToString(", ") + throw ToolError("${tool.name}: unknown argument '$key'. Allowed: $allowed") + } + } + + for (required in tool.inputSchema.get("required")?.map { it.asText() }.orEmpty()) { + if (!arguments.hasNonNull(required)) throw ToolError("${tool.name}: missing required argument '$required'") + } + + for ((key, value) in arguments.fields()) { + val schema = properties.get(key) + val argument = "${tool.name}: argument '$key'" + val type = schema.get("type").asText() + + if (!value.hasType(type)) throw ToolError("$argument must be of type $type") + + val itemType = schema.get("items")?.get("type")?.asText() + val mistyped = itemType != null && value.any { !it.hasType(itemType) } + + if (mistyped) throw ToolError("${tool.name}: every item of '$key' must be of type $itemType") + + val allowedItems = schema.get("items")?.get("enum") + if (allowedItems != null && value.any { it !in allowedItems }) { + val values = allowedItems.joinToString(", ") { it.asText() } + throw ToolError("${tool.name}: every item of '$key' must be one of $values") + } + + val allowed = schema.get("enum") + if (allowed != null && allowed.none { it == value }) { + val values = allowed.joinToString(", ") { it.asText() } + throw ToolError("$argument must be one of $values") + } + + val minimum = schema.get("minimum")?.asLong() + if (minimum != null && value.asLong() < minimum) throw ToolError("$argument must be at least $minimum") + + val maximum = schema.get("maximum")?.asLong() + if (maximum != null && value.asLong() > maximum) throw ToolError("$argument must be at most $maximum") + } + } + + /** Determine if the node has the JSON Schema type. */ + private fun JsonNode.hasType(type: String): Boolean = + when (type) { + "string" -> isTextual + "integer" -> isIntegralNumber + "number" -> isNumber + "boolean" -> isBoolean + "array" -> isArray + "object" -> isObject + else -> error("Unsupported schema type: $type") + } + + /** Determine if the Origin header names this machine. */ + private fun isLocalOrigin(origin: String): Boolean { + val host = + try { + URI(origin).host + } catch (e: URISyntaxException) { + null + } + + return host in LOCAL_HOSTS + } + + /** Build the reply to a message that is not a valid JSON-RPC request. */ + private fun invalidRequest( + id: JsonNode?, + message: String, + ): HttpReply = HttpReply(400, error(id, INVALID_REQUEST, message)) + + /** Build the body of a JSON-RPC error. */ + private fun error( + id: JsonNode?, + code: Int, + message: String, + ): String { + val reply = MAPPER.createObjectNode() + reply.put("jsonrpc", "2.0") + reply.set("id", id ?: MAPPER.nullNode()) + reply + .putObject("error") + .put("code", code) + .put("message", message) + + return MAPPER.writeValueAsString(reply) + } + + internal companion object { + /** The JSON mapper of the MCP endpoint. */ + internal val MAPPER: ObjectMapper = jacksonObjectMapper() + + /** The logger of the dispatcher. */ + private val logger = InlineLogger() + + /** The MCP protocol versions this server speaks, oldest first. */ + private val SUPPORTED = listOf("2025-03-26", "2025-06-18", "2025-11-25") + + /** The hosts an Origin header may name. */ + private val LOCAL_HOSTS = setOf("localhost", "127.0.0.1", "[::1]") + + /** The JSON-RPC code for a body that is not JSON. */ + private const val PARSE_ERROR = -32700 + + /** The JSON-RPC code for a message that is not a request. */ + private const val INVALID_REQUEST = -32600 + + /** The JSON-RPC code for a method this server does not have. */ + private const val METHOD_NOT_FOUND = -32601 + + /** The JSON-RPC code for parameters a method cannot use. */ + private const val INVALID_PARAMS = -32602 + } +} + +/** + * Serves MCP over plain request/response HTTP on loopback. Every request runs on its own pool thread, + * so a tool may block for as long as it needs. + */ +public class McpHttpServer( + /** The loopback port to bind, or 0 for an ephemeral one. */ + private val port: Int, + tools: List, + version: String, +) : AutoCloseable { + /** The protocol handler that every request goes through. */ + private val dispatcher = McpDispatcher(tools, version) + + /** The HTTP server, or null before [start]. */ + private var server: HttpServer? = null + + /** The pool whose threads run the requests. Its idle threads end when it is shut down. */ + private val workers = + Executors.newCachedThreadPool { runnable -> + Thread(runnable, "mcp-http").apply { isDaemon = true } + } + + /** The port the server listens on, which differs from the requested one when that was 0. */ + internal val localPort: Int + get() = checkNotNull(server) { "the server has not been started" }.address.port + + /** + * Bind the port and start serving. + * Throws [java.net.BindException] when the port is taken, which is how a second instance is refused. + */ + public fun start() { + val server = HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), port), 0) + server.executor = workers + server.createContext(PATH, ::handle) + server.start() + this.server = server + } + + /** Answer one HTTP exchange through the dispatcher, with 404 for any path but the endpoint. */ + private fun handle(exchange: HttpExchange) { + try { + if (exchange.requestURI.path != PATH) { + exchange.sendResponseHeaders(404, -1) + + return + } + + val body = exchange.requestBody.readAllBytes().toString(Charsets.UTF_8) + val reply = + dispatcher.handle( + exchange.requestMethod, + exchange.requestHeaders.getFirst("Origin"), + body, + ) + + if (reply.status == 405) exchange.responseHeaders.add("Allow", "POST") + + if (reply.body == null) { + exchange.sendResponseHeaders(reply.status, -1) + + return + } + + val bytes = reply.body.toByteArray(Charsets.UTF_8) + exchange.responseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(reply.status, bytes.size.toLong()) + exchange.responseBody.write(bytes) + } finally { + exchange.close() + } + } + + /** + * Stop serving at once, if the server was started: the port is closed and so is every connection, + * so a request that still runs has nobody to answer. Its thread is not stopped and ends when its + * tool returns. A closed server is not started again. + */ + override fun close() { + server?.stop(0) + workers.shutdown() + } + + private companion object { + /** The path of the MCP endpoint. */ + private const val PATH = "/mcp" + } +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/TargetKind.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/TargetKind.kt new file mode 100644 index 000000000..b62847566 --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/TargetKind.kt @@ -0,0 +1,54 @@ +package net.rsprox.mcp.server + +/** The likely cause when the client performs an action in the game world and sends no packet for it. */ +private const val LOST_CONNECTION = "the client logged out or lost its connection" + +/** + * The kinds of thing that client_interact acts on. The tool schemas and the proof of an interaction + * are derived from this one list. The plugin finds a target by the same name, in `Targets.java`. + */ +internal enum class TargetKind( + /** The name of the kind in the arguments and the result of a tool. */ + val wire: String, + /** The likely cause when the client performs an action on a target of the kind and sends no packet for it. */ + val dropCause: String = LOST_CONNECTION, +) { + /** An NPC, which the client drops an action on once the NPC is out of its view. */ + NPC("npc", "the NPC left the client's view"), + + /** An object on a tile. */ + OBJECT("object"), + + /** An item that lies on a tile. */ + GROUND_ITEM("ground_item"), + + /** Another player, which the client drops an action on once the player is out of its view. */ + PLAYER("player", "the player left the client's view"), + + /** A tile to walk to. */ + TILE("tile"), + + /** An interface component, some of whose options the client handles without a packet. */ + WIDGET("widget"), + + /** The open dialog, which the plugin resolves to the widget of the option. */ + DIALOG("dialog"), + ; + + /** Whether a target of the kind is in the game world, where the camera can look at it. */ + val inWorld: Boolean + get() = this != WIDGET && this != DIALOG + + /** Whether client_entities lists the targets of the kind. */ + val listed: Boolean + get() = inWorld && this != TILE + + companion object { + /** Get the names of the kinds that pass the test, in the order of the list. */ + fun names(wanted: (TargetKind) -> Boolean = { true }): Array = + entries.filter(wanted).map { it.wire }.toTypedArray() + + /** Get the kind with the name that the plugin answered with. */ + fun of(wire: String): TargetKind = entries.first { it.wire == wire } + } +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt new file mode 100644 index 000000000..02d795bdd --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt @@ -0,0 +1,576 @@ +package net.rsprox.mcp.server + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ObjectNode +import net.rsprox.mcp.bridge.Access +import net.rsprox.mcp.bridge.BridgeError +import net.rsprox.mcp.packets.Cursor +import net.rsprox.mcp.packets.Origin +import net.rsprox.mcp.packets.PacketPage +import net.rsprox.mcp.packets.PacketQuery +import net.rsprox.mcp.packets.PacketRecord +import net.rsprox.mcp.session.Session +import net.rsprox.mcp.session.SessionManager + +/** + * Build the tool table. [sessions] is resolved on every call and may throw a [ToolError] while the proxy + * is still starting. + */ +public fun tools(sessions: () -> SessionManager): List = + listOf( + Tool( + name = "session_start", + description = + "Launch a RuneLite client through rsprox, or relaunch a stopped session. " + + "Blocks until the in-client bridge connects and then reports `state` as `connected`, " + + "after which the client_* tools work. If `wait_ms` elapses first, `state` is `launching`; " + + "call it again with the same `session` to keep waiting. With no arguments it starts a new " + + "session on the first custom target. A session of the official game launches too, but is " + + "read-only: the server never sends input to the live game, so the person at the keyboard " + + "logs in. Pass `session` to relaunch a stopped session on fresh " + + "ports; its packet log and cursor continue. Calling it for a session that is already " + + "running launches nothing. An attached session cannot be started.", + inputSchema = + schema( + "target" to string("Proxy target name, as listed by session_list. Example: \"My Server\"."), + "session" to string("Existing session id, such as \"s1\"."), + "wait_ms" to + integer( + "How long to wait for the session to connect. Default ${SessionManager.DEFAULT_WAIT_MS}.", + 0, + 600_000, + ), + ), + ) { args -> + ToolResult.Json( + sessions().start( + target = args.text("target"), + session = args.text("session"), + waitMs = args.long("wait_ms") ?: SessionManager.DEFAULT_WAIT_MS, + ), + ) + }, + Tool( + name = "session_stop", + description = + "Kill the client of a session that session_start launched. The session and its packets stay " + + "readable. Not available for an attached session, whose client belongs to whoever runs " + + "the rsprox GUI.", + inputSchema = schema("session" to SESSION), + ) { args -> + ToolResult.Json(sessions().stop(args.text("session"))) + }, + Tool( + name = "session_list", + description = + "List every session, plus the target names. `kind` is `launched` for a client that " + + "session_start launched, and `attached` for a client that was launched by hand from the " + + "rsprox GUI, which only packets_read works on. Each session has `session`, `target`, " + + "`access`, `state`, `proxyPort` and `cursor`, the newest packet cursor. `access` is `drive` " + + "when the client_* tools that send input may be used on the session, and `read` when only " + + "the tools that read may: a session of the official game is read-only, and so is an " + + "attached one. A launched session with a " + + "client also has `generation`, the number of its launch, and `httpPort`, and `pid` once " + + "connected. `state` is `stopped`, " + + "`launching` or `connected` when launched, and `attached` or `ended` when attached; " + + "`reason` says why a session has no client. Once the client has logged in, `login` holds " + + "`epoch` (the L number of its packets), `revision`, `world`, `host`, `localPlayerIndex`, " + + "`connectedAt`, `online` (whether it is still logged in) and `transcribing` (whether its " + + "packets are decoded). `captureFile`, the recording under the rsprox binary directory, is " + + "absent for a target that is not recorded; rsprox first writes the file some minutes " + + "into the login, or at its logout. `name`, the display name, and `tick`, the newest " + + "server tick seen, are absent until known.", + inputSchema = schema(), + ) { + val manager = sessions() + ToolResult.Json(mapOf("sessions" to manager.list(), "targets" to manager.targets())) + }, + Tool( + name = "packets_read", + description = + "Read decoded packets of a session after a cursor, in both directions. The standalone MCP " + + "server logs every packet, unfiltered. Inside the rsprox GUI the log holds the GUI's view: " + + "a packet that the GUI's filters or settings hide is missing from every session. " + + "To wait for a packet, pass `wait_ms`. The call then blocks until the first match. " + + "Line 1 of the result is JSON: pass `next` as `after` on the following call; `dropped` counts " + + "records that were evicted before they could be read; `timedOut` is true when a wait " + + "elapsed with no match; `head` is the newest cursor of the log and `count` the number of " + + "packets that follow. Each further line is one packet: " + + "` L T `, where C is client to server, S is server " + + "to client and P is an rsprox marker (CLIENT_LAUNCHED, CLIENT_CONNECTED, CLIENT_ATTACHED, " + + "CLIENT_EXITED, LOGIN, LOGOUT). Continuation lines of one packet are indented.", + inputSchema = + schema( + "session" to SESSION, + "after" to integer("Cursor to read after. Default 0, the start of the log.", 0), + "prots" to stringArray("Prot names to keep, such as [\"IF_SETTEXT\"]. Default: all."), + "origin" to string("Keep only one origin.", "client", "server", "proxy"), + "contains" to string("Case-insensitive substring the packet text must contain."), + "wait_ms" to integer("How long to block for a first match. Default 0.", 0, 120_000), + "limit" to + integer("Maximum number of packets to return. Default ${PacketQuery.DEFAULT_LIMIT}.", 1, 1000), + ), + ) { args -> + val query = + PacketQuery( + after = Cursor(args.long("after") ?: 0), + prots = args.get("prots")?.map { it.asText().uppercase() }?.toSet().orEmpty(), + origin = args.text("origin")?.let { Origin.valueOf(it.uppercase()) }, + contains = args.text("contains"), + limit = args.long("limit")?.toInt() ?: PacketQuery.DEFAULT_LIMIT, + ) + + val page = sessions().resolve(args.text("session")).packets.read(query, args.long("wait_ms") ?: 0) + ToolResult.Text(render(page)) + }, + clientTool( + name = "client_state", + access = Access.READ, + description = + "Read what the client is doing: `gameState` (such as LOGIN_SCREEN or LOGGED_IN), `tick`, " + + "`canvas` as [width, height], `world`, `player` with its name and tile (null unless logged " + + "in), and `menu` with the entries a right-click would show at the current mouse position.", + schema = schema("session" to SESSION), + sessions = sessions, + ), + clientTool( + name = "client_screenshot", + access = Access.READ, + description = + "Capture the game canvas as a PNG. The image has the size of the canvas, so a pixel " + + "position in it is the `x`,`y` to pass to client_click. The text block holds `width` " + + "and `height`.", + schema = schema("session" to SESSION), + sessions = sessions, + result = { ok, _ -> ToolResult.Image(ok.remove("png")?.asText().orEmpty(), ok) }, + ), + clientTool( + name = "client_widgets", + access = Access.READ, + description = + "List the interface widgets that have text, a name or options. Each has `id` " + + "(\":\", or \":[]\" for a dynamic child), `text`, `name`, " + + "`actions` (its options), `bounds` as [x, y, width, height], `click` as the [x, y] at its " + + "centre, `type` " + + "and `hidden`. `roots` lists the groups of the top-level interfaces. `truncated` is true " + + "when `limit` cut the list short; narrow it with `group` or `text`.", + schema = + schema( + "session" to SESSION, + "group" to integer("Keep only widgets of this interface group, such as 558.", 0), + "text" to string("Case-insensitive substring to find in the text, name or options."), + "hidden" to boolean("Also list hidden widgets. Default false."), + "limit" to integer("Maximum number of widgets to return. Default $WIDGET_LIMIT.", 1, 2000), + ), + sessions = sessions, + defaults = mapOf("limit" to WIDGET_LIMIT), + ), + clientTool( + name = "client_vars", + access = Access.READ, + description = + "Read client variables by id. The result maps each requested id to its value under " + + "`varps`, `varbits`, `varcInts` and `varcStrs`.", + schema = + schema( + "session" to SESSION, + "varps" to integerArray("Player variable ids, such as [1055]."), + "varbits" to integerArray("Varbit ids, such as [8119]."), + "varcInts" to integerArray("Client integer variable ids."), + "varcStrs" to integerArray("Client string variable ids."), + ), + sessions = sessions, + ), + clientTool( + name = "client_login", + access = Access.DRIVE, + description = + "Log in with the given credentials and wait until the client is in the game. A client that is " + + "still starting is given until `wait_ms` to reach the login screen first. " + + "Returns `gameState` LOGGED_IN. Fails with `wrong_state` when the client is already past " + + "the login screen or the login is refused, and with `timeout` when `wait_ms` " + + "elapses first.", + schema = + schema( + "session" to SESSION, + "username" to string("Account name to log in with."), + "password" to + string("Password. It must not be empty. A server that ignores passwords accepts any value."), + "wait_ms" to + integer("How long to wait for the login to complete. Default $LOGIN_WAIT_MS.", 0, 120_000), + required = listOf("username", "password"), + ), + sessions = sessions, + defaults = mapOf("wait_ms" to LOGIN_WAIT_MS), + timeoutMs = { forwarded -> forwarded.get("wait_ms").asLong() + CLIENT_CALL_TIMEOUT_MS }, + ), + clientTool( + name = "client_click", + access = Access.DRIVE, + description = + "Click on the game canvas, either at `x`,`y` in canvas units (the pixels of a " + + "client_screenshot) or at the centre of `widget`. This is the low-level fallback: prefer " + + "client_interact, which aims by identity and cancels a click that would hit the wrong " + + "thing. A raw click is not guarded; the client performs whatever is under the mouse. " + + "Returns the `x`,`y` that was clicked. Fails with `not_found` when the widget does not " + + "exist or is not visible.", + schema = + schema( + "session" to SESSION, + "x" to integer("Horizontal canvas position. Requires `y`.", 0), + "y" to integer("Vertical canvas position. Requires `x`.", 0), + "widget" to WIDGET, + "button" to string("Mouse button. Default left.", "left", "right"), + ), + sessions = sessions, + ), + clientTool( + name = "client_type", + access = Access.DRIVE, + description = + "Type text into the client as key presses, as if on the keyboard. Returns `typed`, the " + + "number of characters sent.", + schema = + schema( + "session" to SESSION, + "text" to string("The characters to type."), + "enter" to boolean("Press Enter after the text. Default false."), + required = listOf("text"), + ), + sessions = sessions, + ), + clientTool( + name = "client_entities", + access = Access.READ, + description = + "List what is near the local player on its plane, nearest first: `npcs`, `objects`, " + + "`ground_items` and `players`, each with `name`, its tile `x`,`y` in world coordinates and " + + "`options`, the names that client_interact accepts as `option`. An NPC and a player also " + + "have `index`; an NPC, an object and a ground item have `id`. An object is listed once, at " + + "its tile of origin, and only when it has options. `origin` is the tile of the local " + + "player. `truncated` is true when `limit` cut off the farthest entities; narrow the " + + "list with `kinds`, `radius` or `name`.", + schema = + schema( + "session" to SESSION, + "kinds" to + stringArray("Kinds to list. Default: all.", *TargetKind.names { it.listed }), + "radius" to + integer("Greatest distance from the local player in tiles. Default $ENTITY_RADIUS.", 0, 52), + "name" to string("Case-insensitive substring the name must contain."), + "limit" to integer("Maximum number of entities to return. Default $ENTITY_LIMIT.", 1, 2000), + ), + sessions = sessions, + defaults = mapOf("radius" to ENTITY_RADIUS, "limit" to ENTITY_LIMIT), + ), + clientTool( + name = "client_interact", + access = Access.DRIVE, + description = + "Perform one option on one thing in the game with a real mouse click, and confirm it in " + + "the packet log. It aims by identity, cancels a click that the client resolves to " + + "anything else and aims again, and turns the camera when the target is out of view. " + + "The server receives exactly what a player's click sends, mouse packets included. " + + "Pick the target from client_entities. Name an `npc` or a `player` by `index`. Name an " + + "`object` or a `ground_item` by `id` and its tile `x`,`y`. Name a `widget` by its " + + "client_widgets id. For `dialog`, `option` is \"continue\", or the text or the 1-based " + + "number of a choice. A `tile` is named by `x`,`y`; the call walks there and takes no " + + "`option`. Otherwise `option` is one of the target's own options, in any case. An NPC, " + + "an object and a ground item also accept \"Examine\", and a widget \"Continue\". " + + "Returns what was resolved, `attempts`, `tick` (the client's tick count when it " + + "performed the action), `tried` (the last attempts in words) and `camera` when it was " + + "turned. `packet` is the line of the packet the client sent, as packets_read prints " + + "it. `proof` is `click` when that packet follows the client's own record of the " + + "press, and `prefix` when no such record arrived and the packet was matched by its " + + "name alone. `packet` is null, with a `note`, for a widget option that the client " + + "handles without a packet, and while the packets of the login are not decoded. " + + "Fails with `not_found` when the client does not have the target or the target does " + + "not offer the option. Fails with `wrong_state` when the client never offered the " + + "option within the deadline, and says what it offered instead. The deadline is about " + + "three ticks, or six seconds after a camera turn. Fails with an error that names the " + + "likely cause when the client sent no packet for a target in the game world.", + schema = + schema( + "session" to SESSION, + "target" to string("Kind of thing to act on.", *TargetKind.names()), + "option" to string("Option to perform, such as \"Talk-to\". Required unless `target` is tile."), + *TARGET_FIELDS, + "widget" to WIDGET, + required = listOf("target"), + ), + sessions = sessions, + timeoutMs = { INTERACTION_TIMEOUT_MS }, + result = { ok, session -> ToolResult.Json(confirmSent(session, ok, SENT_WAIT_MS)) }, + ), + clientTool( + name = "client_camera", + request = ::cameraRequest, + description = + "Read or turn the camera. With no arguments it returns the current `yaw` and `pitch`. " + + "Pass `yaw` and/or `pitch`, or `look_at` with the same target fields as client_interact, " + + "to turn it; the call then waits until the camera has settled and returns the resulting " + + "`yaw` and `pitch`, plus `onScreen` for a looked-at target. Angles are in the client's " + + "units: 16384 to a full turn of yaw, where 0 has north up; pitch from 1024 (lowest) to " + + "3064 (highest). client_interact turns the camera itself when it needs to.", + schema = + schema( + "session" to SESSION, + "yaw" to integer("Yaw to turn to.", 0, 16383), + "pitch" to integer("Pitch to turn to.", 1024, 3064), + "look_at" to + string( + "Kind of target to face, named by the fields of client_interact.", + *TargetKind.names { it.inWorld }, + ), + *TARGET_FIELDS, + ), + sessions = sessions, + timeoutMs = { INTERACTION_TIMEOUT_MS }, + ), + ) + +/** One request to the plugin: the op to run, and the access that running it needs. */ +private class Request( + /** The name of the op in the plugin. */ + val op: String, + /** [Access.DRIVE] when the op sends the client input, [Access.READ] when it only reads. */ + val access: Access, +) + +/** + * Build a client_* tool with one fixed [access], which forwards its arguments to the op of the same + * name in the plugin. The description says whether the tool sends input, and so is refused on a read-only session. + */ +private fun clientTool( + name: String, + description: String, + schema: ObjectNode, + sessions: () -> SessionManager, + access: Access, + defaults: Map = emptyMap(), + timeoutMs: (ObjectNode) -> Long = { CLIENT_CALL_TIMEOUT_MS }, + result: (ok: ObjectNode, session: Session) -> ToolResult = { ok, _ -> ToolResult.Json(ok) }, +): Tool { + val note = if (access == Access.DRIVE) DRIVE_NOTE else READ_NOTE + val request = Request(name.removePrefix("client_"), access) + + return clientTool(name, "$description $note", schema, sessions, { request }, defaults, timeoutMs, result) +} + +/** + * Build a client_* tool, which forwards its arguments to the plugin as the [request] that the arguments + * call for. The plugin's answer is the tool result, plus the packet cursor taken before the call, so + * everything the action caused has a sequence number above it. The session decides whether a request + * with that access may reach its client, and refuses every request for an attached session. + * + * The plugin has no defaults of its own: each of [defaults] is forwarded when the caller left it out. + */ +private fun clientTool( + name: String, + description: String, + schema: ObjectNode, + sessions: () -> SessionManager, + request: (ObjectNode) -> Request, + defaults: Map = emptyMap(), + timeoutMs: (ObjectNode) -> Long = { CLIENT_CALL_TIMEOUT_MS }, + result: (ok: ObjectNode, session: Session) -> ToolResult = { ok, _ -> ToolResult.Json(ok) }, +): Tool = + Tool(name, "$description $CURSOR_NOTE", schema) { args -> + val manager = sessions() + val session = manager.resolve(args.text("session")) + val requested = request(args) + val link = session.link(name, requested.access) + val cursor = session.packets.head().seq + val forwarded = args.deepCopy().without("session") + + for ((argument, value) in defaults) { + if (!forwarded.hasNonNull(argument)) forwarded.put(argument, value) + } + + val ok = link.call(requested.op, forwarded, requested.access, timeoutMs(forwarded), manager.gate) + if (ok !is ObjectNode) throw BridgeError("internal", "the client answered $name with $ok") + + result(ok.put("cursor", cursor), session) + } + +/** The camera request the arguments call for: a turn, which sends input, when any turn argument is given. */ +private fun cameraRequest(args: ObjectNode): Request = + if (CAMERA_TURN_ARGUMENTS.any(args::hasNonNull)) { + Request("camera_turn", Access.DRIVE) + } else { + Request("camera", Access.READ) + } + +/** The arguments of client_camera that ask for a turn. */ +private val CAMERA_TURN_ARGUMENTS = listOf("yaw", "pitch", "look_at") + +/** The longest wait for the plugin to answer a call, on top of any wait the call itself asks for. */ +private const val CLIENT_CALL_TIMEOUT_MS = 10_000L + +/** + * The longest wait for an interaction or a camera turn. It exceeds the plugin's longest deadline, six + * seconds after a camera turn, plus the wait for a tick and for the camera to settle. + */ +private const val INTERACTION_TIMEOUT_MS = 15_000L + +/** The wait for a login to complete, unless the caller says otherwise. */ +private const val LOGIN_WAIT_MS = 15_000L + +/** The most widgets that client_widgets returns, unless the caller says otherwise. */ +private const val WIDGET_LIMIT = 200L + +/** The greatest distance from the local player that client_entities lists, unless the caller says otherwise. */ +private const val ENTITY_RADIUS = 15L + +/** The most entities that client_entities returns, unless the caller says otherwise. */ +private const val ENTITY_LIMIT = 100L + +/** + * The wait for the mouse click and the packet of an interaction. Packets reach the log in batches at + * the end of each server tick, so the wait spans several ticks. + */ +private const val SENT_WAIT_MS = 3_000L + +/** The sentences that end the description of every client_* tool. */ +private const val CURSOR_NOTE = + "The result carries `cursor`, the packet cursor taken just before the call: pass it as `after` to " + + "packets_read to see only the packets from this call onwards. Not available for an attached session." + +/** The sentence in the description of a client_* tool that only reads. */ +private const val READ_NOTE = "Reads only, so it works on a read-only session." + +/** The sentence in the description of a client_* tool that sends input. */ +private const val DRIVE_NOTE = + "Sends input to the client, so it is refused on a read-only session: one whose target is the official " + + "game, which the server never drives." + +/** The schema of the `session` argument that every tool but session_start and session_list takes. */ +private val SESSION: ObjectNode = string("Session id, such as \"s1\". May be omitted while only one session exists.") + +/** The schema of the `widget` argument of the tools that take a widget. */ +private val WIDGET: ObjectNode = string("Widget id as listed by client_widgets, such as \"558:7\" or \"558:7[3]\".") + +/** The arguments that name a target in the game world, for the tools that act on one. */ +private val TARGET_FIELDS: Array> = + arrayOf( + "index" to integer("Index of the NPC or player, as listed by client_entities.", 0), + "id" to integer("Id of the object or ground item, as listed by client_entities.", 0), + "x" to integer("World x of the tile of the object, ground item or tile.", 0), + "y" to integer("World y of the tile of the object, ground item or tile.", 0), + ) + +/** Render a page as one line of JSON meta followed by one line per packet. */ +private fun render(page: PacketPage): String { + val meta = + linkedMapOf( + "next" to page.next.seq, + "head" to page.head.seq, + "dropped" to page.dropped, + "timedOut" to page.timedOut, + "count" to page.packets.size, + ) + + val out = StringBuilder(McpDispatcher.MAPPER.writeValueAsString(meta)) + + for (packet in page.packets) { + out.append('\n').append(line(packet)) + } + + return out.toString() +} + +/** Render a packet as its line, with the continuation lines of its text indented. */ +internal fun line(packet: PacketRecord): String { + val text = packet.text.replace("\n", "\n ") + + return "${packet.seq} L${packet.login} T${packet.cycle} ${packet.origin.letter} ${packet.prot} $text" +} + +/** Build the schema of an object with the given properties. */ +private fun schema( + vararg properties: Pair, + required: List = emptyList(), +): ObjectNode { + val schema = McpDispatcher.MAPPER.createObjectNode() + schema.put("type", "object") + val node = schema.putObject("properties") + + for ((name, property) in properties) { + node.set(name, property) + } + + if (required.isNotEmpty()) { + val names = schema.putArray("required") + required.forEach(names::add) + } + + return schema +} + +/** Build the schema of a boolean argument. */ +private fun boolean(description: String): ObjectNode = property("boolean", description) + +/** Build the schema of an argument that is an array of integers. */ +private fun integerArray(description: String): ObjectNode { + val node = property("array", description) + node.putObject("items").put("type", "integer") + + return node +} + +/** Build the schema of a string argument, limited to the allowed values when any are given. */ +private fun string( + description: String, + vararg allowed: String, +): ObjectNode = property("string", description).limitedTo(allowed) + +/** Build the schema of an integer argument within the given bounds. */ +private fun integer( + description: String, + minimum: Long, + maximum: Long? = null, +): ObjectNode { + val node = property("integer", description) + node.put("minimum", minimum) + + if (maximum != null) node.put("maximum", maximum) + + return node +} + +/** Build the schema of an argument that is an array of strings, limited to the allowed values when any are given. */ +private fun stringArray( + description: String, + vararg allowed: String, +): ObjectNode { + val node = property("array", description) + node.putObject("items").put("type", "string").limitedTo(allowed) + + return node +} + +/** Limit the schema to the allowed values when any are given. */ +private fun ObjectNode.limitedTo(allowed: Array): ObjectNode { + if (allowed.isNotEmpty()) { + val values = putArray("enum") + allowed.forEach(values::add) + } + + return this +} + +/** Build the schema of an argument with the given type and description. */ +private fun property( + type: String, + description: String, +): ObjectNode = + McpDispatcher.MAPPER + .createObjectNode() + .put("type", type) + .put("description", description) + +/** Get the named argument as text, or null when it is absent. */ +private fun ObjectNode.text(name: String): String? = get(name)?.asText() + +/** Get the named argument as a long, or null when it is absent. */ +private fun ObjectNode.long(name: String): Long? = get(name)?.asLong() diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt new file mode 100644 index 000000000..ce2eed43b --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt @@ -0,0 +1,132 @@ +package net.rsprox.mcp.session + +import net.rsprox.mcp.bridge.BridgeJar +import net.rsprox.proxy.ProxyService +import net.rsprox.proxy.connection.ClientTypeDictionary +import net.rsprox.proxy.target.ProxyTarget +import net.rsprox.proxy.target.ProxyTargetConfig +import java.io.IOException +import java.net.BindException +import java.net.ServerSocket +import java.util.concurrent.CompletionException +import java.util.stream.Collectors + +/** Launches clients through a started [ProxyService]. */ +internal class ProxyServiceLauncher( + /** The proxy that launches the clients. */ + private val service: ProxyService, + /** The installer of the bridge plugin, or null to launch clients without it. */ + private val bridgeJar: BridgeJar?, +) : ClientLauncher { + /** Get the proxy targets the proxy is configured with. */ + override fun targets(): List = service.proxyTargets + + /** Install the bridge plugin, if any, pick a free proxy port and bind the HTTP server of the target. */ + override fun reserve(target: ProxyTargetConfig): Reservation { + bridgeJar?.installFor(target) + + // The proxy logs and returns when it cannot bind a proxy port, and a GUI may own any port in the range. + val (port, proxyTarget) = firstBound(target) + val forks = ForkWatch() + + return Reservation( + port, + proxyTarget.httpPort, + launch = { monitor -> + service.launchRuneLiteClient(monitor, null, port, proxyTarget) + + // Probing leaves a window in which another process can take the port. The proxy registers + // a client type for a port only after it has bound it. + check(hasClientType(port)) { "proxy port $port could not be bound" } + }, + launcherExited = forks::allExited, + bridged = bridgeJar != null, + ) + } + + /** + * Get the first port of the proxy that is free and whose HTTP port can be bound, with the target that + * was bound for it. Throws when none of [BIND_ATTEMPTS] ports works. + */ + private fun firstBound(target: ProxyTargetConfig): Pair { + repeat(BIND_ATTEMPTS) { + val port = service.allocatePort() + val bound = if (canBind(port)) bindHttpServer(port, target) else null + + if (bound != null) return port to bound + } + + throw IllegalStateException("none of $BIND_ATTEMPTS proxy ports in a row could be bound with its HTTP port") + } + + /** Kill the client on the proxy port, if any, and release the proxy state held for it. */ + override fun kill(proxyPort: Int) { + service.killAliveProcess(proxyPort) + } + + /** Bind the HTTP server that belongs to the proxy port, or return null when its port is taken. */ + private fun bindHttpServer( + port: Int, + target: ProxyTargetConfig, + ): ProxyTarget? = + try { + service.initializeHttpServer(port, target) + } catch (e: CompletionException) { + // ProxyTarget.launchHttpServer joins the bind, which fails with the cause wrapped. Nothing + // of the target is kept by the proxy at that point, so the next port starts clean. + if (e.cause !is BindException) throw e + + null + } + + /** Determine if the proxy registered a client type for the port, which it does once it has bound it. */ + private fun hasClientType(port: Int): Boolean = + try { + ClientTypeDictionary[port] + true + } catch (e: IllegalArgumentException) { + false + } + + /** Determine if the port can be bound right now, which it cannot while a process holds it. */ + private fun canBind(port: Int): Boolean = + try { + ServerSocket(port).close() + true + } catch (e: IOException) { + false + } +} + +/** The most proxy ports that one reservation tries. */ +private const val BIND_ATTEMPTS = 64 + +/** + * The processes this JVM forks from the moment the watch is made. Launches take turns, so the forks that + * follow a reservation are those of its launch. + */ +private class ForkWatch { + /** The children that were running before the launch. */ + private val before = children() + + /** The processes of the launch seen so far. An exited child is no longer listed, so they are remembered. */ + private val seen = HashSet() + + /** + * Determine if every process forked since the watch was made has exited. False until one has been seen. + * A process that starts and exits between two calls is never seen. + */ + fun allExited(): Boolean { + for (child in children() - before) { + seen += child + + // A launcher may hand over to a client of its own and exit, which is not the launch dying. + child.descendants().forEach { seen += it } + } + + return seen.isNotEmpty() && seen.none { it.isAlive } + } + + /** Get the direct children of this JVM that are running. */ + private fun children(): Set = ProcessHandle.current().children().collect(Collectors.toSet()) +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt new file mode 100644 index 000000000..e4538b329 --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt @@ -0,0 +1,507 @@ +package net.rsprox.mcp.session + +import com.fasterxml.jackson.annotation.JsonInclude +import net.rsprox.mcp.bridge.Access +import net.rsprox.mcp.bridge.BridgeLink +import net.rsprox.mcp.packets.Origin +import net.rsprox.mcp.packets.PacketLog +import net.rsprox.mcp.server.ToolError +import net.rsprox.proxy.binary.BinaryHeader +import net.rsprox.proxy.target.ProxyTargetConfig +import java.net.URI +import java.net.URISyntaxException +import java.time.Instant +import java.util.concurrent.TimeUnit +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +/** + * What the MCP server may do with a client of the target. A client of the official game is only read. + * That is target 0, and also any target that takes its jav_config from Jagex, since such a client plays + * the live game whatever its id. Target 0 with an overridden jav_config is read-only too, which errs on + * the safe side. + */ +internal val ProxyTargetConfig.access: Access + get() = if (id == 0 || isJagexHost(javConfigUrl)) Access.READ else Access.DRIVE + +/** Determine if the URL is served from a domain of Jagex, which only the official game is configured from. */ +private fun isJagexHost(url: String): Boolean { + val host = + try { + URI(url).host?.lowercase() ?: return false + } catch (e: URISyntaxException) { + return false + } + + return JAGEX_DOMAINS.any { host == it || host.endsWith(".$it") } +} + +/** The domains that the official game's jav_config is served from. */ +private val JAGEX_DOMAINS = setOf("runescape.com", "jagex.com") + +/** The sentence that tells an agent what works on a read-only session, for the refusal of a tool that does not. */ +internal const val READ_TOOLS: String = + "Use the tools that read: client_state, client_screenshot, client_widgets, client_vars, client_entities, " + + "client_camera without yaw, pitch or look_at, and packets_read." + +/** Stable for the life of the MCP process. Survives client restarts. Never a port. */ +@JvmInline +public value class SessionId( + /** The id as callers write it, such as `s1`. */ + public val value: String, +) { + /** Get the id as callers write it. */ + override fun toString(): String = value +} + +/** One client process generation of a session. A restart makes a new launch on new ports. */ +public data class Launch( + /** The number of this launch within its session, starting at 1. */ + val generation: Int, + /** The port the client reaches the proxy on. */ + val proxyPort: Int, + /** The identity the in-client bridge reports when it connects. */ + val httpPort: Int, +) + +/** Whether a launched session has a client, and how far that client has come. */ +public sealed interface ClientState { + /** The launch of the client, or null when the state has no client. */ + public val launch: Launch? + + /** The session has no client. */ + public data class Stopped( + /** The reason the session has no client. */ + val reason: String, + ) : ClientState { + /** Null, since the session has no client. */ + override val launch: Launch? get() = null + } + + /** The client was launched and its plugin has not said hello yet. */ + public data class Launching( + /** The launch whose client has not connected yet. */ + override val launch: Launch, + ) : ClientState + + /** The client was launched without the bridge plugin, so only its packets are read. */ + public data class Unbridged( + /** The launch of the client. */ + override val launch: Launch, + ) : ClientState + + /** The plugin in the client is connected, so the client can be driven. */ + public data class Connected( + /** The launch that the client belongs to. */ + override val launch: Launch, + /** The link to the plugin in the client. */ + val link: BridgeLink, + /** The process id of the client. */ + val pid: Long, + ) : ClientState +} + +/** One login of a session, as the proxy reports it. A value that is not known yet is left out of the JSON. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public data class LoginInfo( + /** The number of this login within its session, starting at 1. */ + val epoch: Int, + /** The game revision of the client. */ + val revision: Int, + /** The world that was logged in to. */ + val world: Int, + /** The host of that world. */ + val host: String, + /** The index of the local player in that world. */ + val localPlayerIndex: Int, + /** The moment the server accepted the login, in ISO-8601. */ + val connectedAt: String, + /** + * The file the login is recorded to, relative to the `binary` directory of rsprox. The proxy first + * writes it at its write interval or at the logout. Null for a target that is not recorded. + */ + val captureFile: String?, + /** The display name of the player, or null until the proxy reports it. */ + val name: String?, + /** Whether the login is still in the game. */ + val online: Boolean, + /** + * Whether a packet of this login has been decoded. + * False while online means no decoder was hooked for this login, so its packets never reach the log. + */ + val transcribing: Boolean, + /** The newest server tick that a packet was decoded in, or null before the first packet. */ + val tick: Int?, +) { + internal companion object { + /** Build the login of the given epoch from what the proxy knows the moment the server accepts it. */ + internal fun of( + epoch: Int, + header: BinaryHeader, + captureFile: String?, + ): LoginInfo = + LoginInfo( + epoch = epoch, + revision = header.revision, + world = header.worldId, + host = header.worldHost, + localPlayerIndex = header.localPlayerIndex, + connectedAt = Instant.ofEpochMilli(header.timestamp).toString(), + captureFile = captureFile, + name = null, + online = true, + transcribing = false, + tick = null, + ) + } +} + +/** The newest login of a session. Updates from an older login are ignored. */ +internal class LoginRegistry { + /** The epoch handed to the newest login. */ + private var epoch = 0 + + /** The newest login, or null before the first one. */ + private var info: LoginInfo? = null + + /** Get the epoch for a login that is about to begin. */ + @Synchronized + fun nextEpoch(): Int = ++epoch + + /** Get the newest login, or null before the first one. */ + @Synchronized + fun current(): LoginInfo? = info + + /** + * Register that the login is in the game, unless a newer login is known. + * A login that is known already, which the proxy reports again when it reconnects, only goes back online. + */ + @Synchronized + fun login(login: LoginInfo) { + val current = info + if (current != null && current.epoch > login.epoch) return + + info = if (current != null && current.epoch == login.epoch) current.copy(online = true) else login + } + + /** Apply the change to the newest login when it is the one of the given epoch. */ + @Synchronized + fun update( + epoch: Int, + change: (LoginInfo) -> LoginInfo, + ) { + val current = info ?: return + if (current.epoch == epoch) info = change(current) + } +} + +/** What the session tools return. A value that does not apply, or is not known yet, is left out of the JSON. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public data class SessionSnapshot( + /** The id of the session. */ + val session: String, + /** Who owns the client: `launched` when the MCP server started it, `attached` when the rsprox GUI did. */ + val kind: String, + /** The name of the proxy target the session belongs to. */ + val target: String, + /** `drive` when the client tools that send input may be used on the session, `read` when only those that read. */ + val access: String, + /** The state: `stopped`, `launching` or `connected` when launched, `attached` or `ended` when attached. */ + val state: String, + /** The reason the session has no client, or null while it has one. */ + val reason: String?, + /** The number of the current launch, or null while stopped and for an attached session. */ + val generation: Int?, + /** The proxy port of the client, or null while a launched session is stopped. */ + val proxyPort: Int?, + /** The HTTP port of the current launch, or null while stopped and for an attached session. */ + val httpPort: Int?, + /** The process id of the client, or null unless a launched session is connected. */ + val pid: Long?, + /** The newest login, or null before the first one. */ + val login: LoginInfo?, + /** The packet cursor of the newest record in the session's log. */ + val cursor: Long, +) + +/** + * One client of a proxy target, with its packet log and its logins. + * A session is either launched, and its client is the MCP server's to drive and to stop, or attached + * to a client that the rsprox GUI launched, which the MCP server only reads the packets of. + */ +public sealed class Session( + /** The id that callers name the session by. */ + public val id: SessionId, + /** The proxy target that the client of the session is for. */ + public val target: ProxyTargetConfig, +) { + /** The packet log. Owned by the session, not a launch, so a restart keeps the records and the cursor space. */ + public val packets: PacketLog = PacketLog() + + /** The logins of the session. */ + internal val logins = LoginRegistry() + + /** The most that the client tools may do with the client of the session. */ + public abstract val access: Access + + /** + * Get the link to the plugin in the client, for the client tool named [tool], which needs [access]. + * Throws a [ToolError] that says why the tool may not, or cannot, reach the client. + */ + internal abstract fun link( + tool: String, + access: Access, + ): BridgeLink + + /** Get what the session tools report of the session at this moment. */ + public abstract fun snapshot(): SessionSnapshot + + /** Append a lifecycle marker to the packet log. */ + protected fun mark( + prot: String, + text: String, + ) { + packets.append(logins.current()?.epoch ?: 0, 0, Origin.PROXY, prot, text) + } +} + +/** A session whose client the MCP server launched, across every launch of that client. */ +public class LaunchedSession internal constructor( + id: SessionId, + target: ProxyTargetConfig, +) : Session(id, target) { + /** The lock that makes each state transition and its marker one step. */ + private val lock = ReentrantLock() + + /** The condition that wakes the callers that wait for the client to connect. */ + private val changed = lock.newCondition() + + /** The number of launches so far. */ + private var generations = 0 + + /** What the target allows: a client of the official game is only read. */ + override val access: Access = target.access + + /** The state of the client, which only [transition] changes. */ + @Volatile + public var client: ClientState = ClientState.Stopped("not started") + private set + + /** Get the generation number for a launch that is about to begin. */ + internal fun nextGeneration(): Int = lock.withLock { ++generations } + + /** + * Move to the state that [next] makes of the current one, and determine if that changed it. + * [next] returns the state it is given when what happened is stale or does not apply. + * The only writer of [client]. Every real transition leaves a marker in the packet log. + */ + private fun transition(next: (ClientState) -> ClientState): Boolean = + lock.withLock { + val before = client + val after = next(before) + + if (after !== before) { + client = after + mark(after) + changed.signalAll() + } + + after !== before + } + + /** + * Register that [launch] of the client began, unless the session has a client already. A client + * launched with the plugin is launching until its hello; one launched without it is unbridged. + */ + internal fun launched( + launch: Launch, + bridged: Boolean, + ) { + val next = if (bridged) ClientState.Launching(launch) else ClientState.Unbridged(launch) + + transition { if (it is ClientState.Stopped) next else it } + } + + /** + * Connect the session to the plugin that said hello, when the session is launching and [httpPort] is + * the one of its launch. Determines if the session is connected through [link] afterwards. + */ + internal fun hello( + httpPort: Int, + link: BridgeLink, + pid: Long, + ): Boolean = + lock.withLock { + transition { + if (it is ClientState.Launching && it.launch.httpPort == httpPort) { + ClientState.Connected(it.launch, link, pid) + } else { + it + } + } + + (client as? ClientState.Connected)?.link === link + } + + /** Stop the session when [link] is the link of its connected client, and determine if that stopped it. */ + internal fun linkClosed(link: BridgeLink): Boolean = + transition { if (it is ClientState.Connected && it.link === link) ClientState.Stopped("client exited") else it } + + /** Stop the session for [reason], unless it is stopped already. */ + internal fun stop(reason: String) { + transition { if (it is ClientState.Stopped) it else ClientState.Stopped(reason) } + } + + /** + * Stop the session for [reason] when it still waits for the client of [launch], and determine if + * that stopped it. + */ + internal fun neverConnected( + launch: Launch, + reason: String, + ): Boolean = + transition { if (it is ClientState.Launching && it.launch == launch) ClientState.Stopped(reason) else it } + + /** Block while the client is launching, for at most [waitMs]. Never throws on timeout. */ + internal fun awaitConnected(waitMs: Long) { + lock.withLock { + var remaining = TimeUnit.MILLISECONDS.toNanos(waitMs) + while (client is ClientState.Launching && remaining > 0) { + remaining = changed.awaitNanos(remaining) + } + } + } + + /** + * Get the link of the connected client. Throws a [ToolError] that says why a tool that sends input + * is refused on the official game, or else what to do about the absence of a client. + */ + override fun link( + tool: String, + access: Access, + ): BridgeLink { + if (access > this.access) { + throw ToolError( + "$tool sends input to the client, which is refused for session $id: its target '${target.name}' " + + "is the official game, so the session is read-only. $READ_TOOLS", + ) + } + + return when (val state = client) { + is ClientState.Connected -> state.link + is ClientState.Launching -> + throw ToolError("session $id is still launching; call session_start with this session to wait for it") + is ClientState.Unbridged -> + throw ToolError( + "$tool is not available for session $id: its client was launched without the bridge plugin, " + + "which mcp.plugin in proxy.properties, or --no-plugin, turns off, so only its packets " + + "can be read", + ) + is ClientState.Stopped -> + throw ToolError("session $id has no connected client: ${state.reason}") + } + } + + /** Get what the session tools report of the session at this moment. */ + override fun snapshot(): SessionSnapshot { + val state = client + val launch = state.launch + + return SessionSnapshot( + session = id.value, + kind = "launched", + target = target.name, + access = access.wire, + state = + when (state) { + is ClientState.Stopped -> "stopped" + is ClientState.Launching -> "launching" + is ClientState.Unbridged -> "unbridged" + is ClientState.Connected -> "connected" + }, + reason = (state as? ClientState.Stopped)?.reason, + generation = launch?.generation, + proxyPort = launch?.proxyPort, + httpPort = launch?.httpPort, + pid = (state as? ClientState.Connected)?.pid, + login = logins.current(), + cursor = packets.head().seq, + ) + } + + /** Append the lifecycle marker of the state to the packet log. */ + private fun mark(state: ClientState) { + when (state) { + is ClientState.Launching -> mark("CLIENT_LAUNCHED", describe(state.launch)) + is ClientState.Unbridged -> mark("CLIENT_LAUNCHED", "${describe(state.launch)} plugin=none") + is ClientState.Connected -> mark("CLIENT_CONNECTED", "pid=${state.pid}") + is ClientState.Stopped -> mark("CLIENT_EXITED", state.reason) + } + } + + /** Describe the launch for its marker. */ + private fun describe(launch: Launch): String = + "generation=${launch.generation} proxyPort=${launch.proxyPort} httpPort=${launch.httpPort}" +} + +/** + * A session of a client that the rsprox GUI launched. The client belongs to whoever runs the GUI, so + * the MCP server reads its packets and never stops or drives it. + */ +public class AttachedSession internal constructor( + id: SessionId, + target: ProxyTargetConfig, + /** The port the client reaches the proxy on. */ + public val proxyPort: Int, +) : Session(id, target) { + /** The reason the session ended, or null while the client runs. */ + @Volatile + private var ended: String? = null + + /** Read only, since the client belongs to the person at the keyboard. */ + override val access: Access = Access.READ + + init { + mark("CLIENT_ATTACHED", "proxyPort=$proxyPort") + } + + /** End the session for [reason], since its client is gone. Its packets stay readable. Idempotent. */ + @Synchronized + internal fun end(reason: String) { + if (ended != null) return + + ended = reason + mark("CLIENT_EXITED", reason) + } + + /** Refuse the tool, since no client tool reaches the client of an attached session yet. */ + override fun link( + tool: String, + access: Access, + ): BridgeLink = throw ToolError(notAvailable(tool)) + + /** Build the refusal of a tool that acts on the client, for the error of that tool. */ + internal fun notAvailable(tool: String): String = + "$tool is not available for an attached session: session $id belongs to a client that was " + + "launched from the rsprox GUI, so only its packets can be read" + + /** Get what the session tools report of the session at this moment. */ + override fun snapshot(): SessionSnapshot { + val reason = ended + + return SessionSnapshot( + session = id.value, + kind = "attached", + target = target.name, + access = access.wire, + state = if (reason == null) "attached" else "ended", + reason = reason, + generation = null, + proxyPort = proxyPort, + httpPort = null, + pid = null, + login = logins.current(), + cursor = packets.head().seq, + ) + } +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt new file mode 100644 index 000000000..8f01eace0 --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt @@ -0,0 +1,466 @@ +package net.rsprox.mcp.session + +import net.rsprox.mcp.bridge.Access +import net.rsprox.mcp.bridge.BridgeHub +import net.rsprox.mcp.bridge.BridgeLink +import net.rsprox.mcp.bridge.BridgeListener +import net.rsprox.mcp.bridge.Gate +import net.rsprox.mcp.packets.PacketTap +import net.rsprox.mcp.server.ToolError +import net.rsprox.proxy.binary.BinaryHeader +import net.rsprox.proxy.target.ProxyTargetConfig +import net.rsprox.shared.SessionMonitor +import net.rsprox.shared.settings.SettingSetStore +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** The part of the proxy a session needs. Narrow so the lifecycle can be exercised without a real client. */ +internal interface ClientLauncher { + /** Get the proxy targets a client can be launched for. */ + fun targets(): List + + /** Pick free ports and bind the HTTP server of [target]. Throws when the target cannot be prepared. */ + fun reserve(target: ProxyTargetConfig): Reservation + + /** Kill the client on [proxyPort], if any, and release the proxy state held for it. Idempotent. */ + fun kill(proxyPort: Int) +} + +/** The ports that were reserved for one launch, with the call that performs the launch. */ +internal class Reservation( + /** The port the client reaches the proxy on. */ + val proxyPort: Int, + /** The port the client fetches its configuration from. */ + val httpPort: Int, + /** The call that starts the client and blocks until its launcher has completed the handshake. Throws on failure. */ + val launch: (monitor: SessionMonitor) -> Unit, + /** + * The check of whether every process that [launch] forked has exited. False until one has been seen. + * Asked again and again while [launch] blocks, always by the same thread. + */ + val launcherExited: () -> Boolean, + /** Whether the bridge plugin was installed for the launch, so that the client's hello is to be expected. */ + val bridged: Boolean, +) + +/** + * Creates the sessions and feeds what happens to a client into its session. It launches and stops the + * clients of the launched sessions, and attaches a session to each client that something else launched. + */ +public class SessionManager internal constructor( + /** The part of the proxy that launches and kills clients. */ + private val launcher: ClientLauncher, + /** The settings that the packets of every session are formatted with. */ + private val settings: SettingSetStore, + /** The hub that the launched clients dial. */ + private val bridge: BridgeHub, + /** The longest wait for a launcher to complete its handshake. */ + private val launchTimeoutMs: Long = DEFAULT_LAUNCH_TIMEOUT_MS, + /** The longest wait for the plugin of a launched client to say hello. */ + private val helloTimeoutMs: Long = DEFAULT_HELLO_TIMEOUT_MS, + /** The gate that a request passes to fork a client, to kill one or to send one a request. */ + internal val gate: Gate = Gate(), +) { + /** The sessions in the order they were first launched or attached. Added to under its own monitor. */ + private val sessions = CopyOnWriteArrayList() + + /** The lock that makes launches and stops take turns, since the proxy's launch path is not re-entrant. */ + private val launchLock = Any() + + /** Whether a launch never returned, after which the proxy cannot launch again. Guarded by [launchLock]. */ + private var hungLaunch = false + + /** The timer that stops each launch whose plugin never says hello. */ + private val deadlines = + Executors.newSingleThreadScheduledExecutor { task -> + Thread(task, "mcp-hello-deadline").apply { isDaemon = true } + } + + /** + * Create a session, or relaunch a stopped one. Calling it again for a session that is launching + * or connected launches nothing and only waits, so a caller can extend a wait that elapsed. + */ + public fun start( + target: String?, + session: String?, + waitMs: Long, + ): SessionSnapshot { + val started = + synchronized(launchLock) { + val existing = session?.let { launched(resolve(it), "session_start") } + if (existing != null) requireTarget(existing, target) + + if (existing == null || existing.client is ClientState.Stopped) { + launch(existing, existing?.target ?: resolveTarget(target)) + } else { + existing + } + } + + started.awaitConnected(waitMs) + + return started.snapshot() + } + + /** + * Kill the client of a launched session. The session and its packets stay listed and readable. Idempotent. + * Throws [ToolError] for an attached session, whose client is not the MCP server's to kill, and when + * the gate is closed. + */ + public fun stop(session: String?): SessionSnapshot { + val resolved = launched(resolve(session), "session_stop") + + synchronized(launchLock) { + val stopped = + gate.ifOpen { + val state = resolved.client + + // Stopped first, so the close of the link below is not mistaken for the client exiting. + resolved.stop("stopped by caller") + + when (state) { + is ClientState.Stopped -> { + // + } + is ClientState.Launching -> release(state.launch) + is ClientState.Unbridged -> launcher.kill(state.launch.proxyPort) + is ClientState.Connected -> { + state.link.close() + launcher.kill(state.launch.proxyPort) + } + } + } + + if (!stopped) throw ToolError(ENDPOINT_OFF) + } + + return resolved.snapshot() + } + + /** + * Create the session of a client that something other than this manager is launching on [proxyPort], + * and return the monitor that records its packets. Returns null for a launch of this manager's own, + * which has its session already. + */ + internal fun attach( + proxyPort: Int, + target: ProxyTargetConfig, + ): SessionMonitor? { + if (launchedSessions().any { it.client.launch?.proxyPort == proxyPort }) return null + + val session = register { id -> AttachedSession(id, target, proxyPort) } + + return tap(session) + } + + /** + * Register that the client on [proxyPort] is gone: end the session attached to it, or stop the + * launched session whose unbridged client it was, which nothing else tells about the exit. Idempotent. + */ + internal fun detach(proxyPort: Int) { + val attached = sessions.filterIsInstance().firstOrNull { it.proxyPort == proxyPort } + attached?.end(CLIENT_CLOSED) + + val unbridged = + launchedSessions().firstOrNull { + val state = it.client + state is ClientState.Unbridged && state.launch.proxyPort == proxyPort + } + + if (unbridged != null) { + unbridged.stop(CLIENT_CLOSED) + synchronized(launchLock) { launcher.kill(proxyPort) } + } + } + + /** Get a snapshot of every session. */ + public fun list(): List = sessions.map { it.snapshot() } + + /** + * Get the session with the given id. A null [ref] means the only session. + * Throws [ToolError] when no session matches, or when [ref] is null and several exist. + */ + public fun resolve(ref: String?): Session { + if (ref != null) { + return sessions.firstOrNull { it.id.value == ref } + ?: throw ToolError("no session '$ref'. Sessions: ${sessionIds()}") + } + + if (sessions.isEmpty()) { + throw ToolError( + "no session exists yet; call session_start, or launch a client from the rsprox GUI when it " + + "serves this endpoint", + ) + } + + return sessions.singleOrNull() ?: throw ToolError("several sessions exist; pass one of: ${sessionIds()}") + } + + /** Get the names of the proxy targets. */ + public fun targets(): List = launcher.targets().map { it.name } + + /** Build the monitor that records the packets and the logins of the client of the session. */ + private fun tap(session: Session): PacketTap = + PacketTap(session.packets, session.logins, settings, session.target.binaryFolder) + + /** Get the launched sessions. */ + private fun launchedSessions(): List = sessions.filterIsInstance() + + /** + * Get the session as a launched one, for a tool that needs the client to be the MCP server's own. + * Throws [ToolError] for an attached session. + */ + private fun launched( + session: Session, + tool: String, + ): LaunchedSession = + when (session) { + is LaunchedSession -> session + is AttachedSession -> throw ToolError(session.notAvailable(tool)) + } + + /** + * Make a session under the next id and list it. Launches and attaches number their sessions through + * here, and not under [launchLock], which a launch holds while the proxy calls back into [attach]. + */ + private fun register(create: (SessionId) -> S): S = + synchronized(sessions) { + create(SessionId("s${sessions.size + 1}")).also { sessions += it } + } + + /** List the session ids for an error message. */ + private fun sessionIds(): String = sessions.joinToString(", ") { it.id.value }.ifEmpty { "none" } + + /** Throw a [ToolError] when the caller names a target other than the one the session belongs to. */ + private fun requireTarget( + session: LaunchedSession, + target: String?, + ) { + if (target == null || session.target.name.equals(target, ignoreCase = true)) return + + throw ToolError("session ${session.id} belongs to target '${session.target.name}'") + } + + /** Get the target with the given name, or the first custom target for null. Throws [ToolError] for no match. */ + private fun resolveTarget(name: String?): ProxyTargetConfig { + val targets = launcher.targets() + + if (name == null) { + // A custom target, whose client can be driven, is what a headless caller is here to test. + return targets.firstOrNull { it.access == Access.DRIVE } ?: targets.first() + } + + return targets.firstOrNull { it.name.equals(name, ignoreCase = true) } + ?: throw ToolError("no target '$name'. Targets: ${targets.joinToString(", ") { it.name }}") + } + + /** + * Launch a client of the target for [stopped], or for a new session when it is null, and wait until its + * launcher has completed the handshake. A new session is listed once the ports of its launch are reserved. + * Throws [ToolError] when the launch fails or hangs, and when the gate is closed. Must be called with + * [launchLock] held. + */ + private fun launch( + stopped: LaunchedSession?, + target: ProxyTargetConfig, + ): LaunchedSession { + if (hungLaunch) throw ToolError(HUNG_LAUNCH) + + // Refused before anything is reserved or listed. The gate decides again where the client is forked. + if (!gate.isOpen) throw ToolError(ENDPOINT_OFF) + + val reservation = reserve(target) + val session = stopped ?: register { id -> LaunchedSession(id, target) } + val launch = Launch(session.nextGeneration(), reservation.proxyPort, reservation.httpPort) + + // Registered before the client is forked: its hello can arrive before the launch call returns. + if (reservation.bridged) bridge.expect(launch.httpPort, listener(session, launch)) + + session.launched(launch, reservation.bridged) + + val reason = fork(session, reservation) + + if (reason == null) { + if (reservation.bridged) expectHello(session, launch) + + return session + } + + release(launch) + session.stop(reason) + throw ToolError("session ${session.id} failed to launch: $reason") + } + + /** Reserve the ports for a launch of the target. Throws [ToolError] when the target cannot be prepared. */ + private fun reserve(target: ProxyTargetConfig): Reservation = + try { + launcher.reserve(target) + } catch (e: Exception) { + throw ToolError("could not prepare target '${target.name}': ${rootMessage(e)}") + } + + /** + * Fork the client on a thread of its own and wait until its launcher has completed the handshake. + * Returns the reason when the launch failed or hung, and null when it succeeded. The launch is handed + * to the proxy only through the open gate. The proxy cannot be told to give a launch up, so a launch + * that returns after the gate closed counts as failed, which has its client killed. + * Must be called with [launchLock] held. + */ + private fun fork( + session: LaunchedSession, + reservation: Reservation, + ): String? { + // The proxy waits for the launcher's handshake with no timeout, so the wait is bounded from outside. + val failure = AtomicReference() + val thread = + Thread({ + try { + reservation.launch(tap(session)) + } catch (t: Throwable) { + failure.set(t) + } + }, "mcp-launch-${session.id}") + + thread.isDaemon = true + if (!gate.ifOpen { thread.start() }) return ENDPOINT_OFF + + val exited = awaitLaunch(thread, reservation.launcherExited) + + hungLaunch = thread.isAlive + + return when { + !hungLaunch -> failure.get()?.let(::rootMessage) ?: ENDPOINT_OFF.takeUnless { gate.isOpen } + exited -> "the launcher exited before completing its handshake" + else -> "launcher never completed its handshake" + } + } + + /** + * Wait for the launch thread to end, and determine if the wait was cut short because the launcher exited. + * The wait also ends, with false, when the launch timeout passes. + */ + private fun awaitLaunch( + thread: Thread, + launcherExited: () -> Boolean, + ): Boolean { + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(launchTimeoutMs) + + while (thread.isAlive) { + val remainingMs = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()) + if (remainingMs <= 0) return false + + if (launcherExited()) { + // A launcher that exits right after its handshake leaves the launch a moment from returning. + thread.join(LAUNCHER_EXIT_GRACE_MS) + + return true + } + + thread.join(minOf(remainingMs, LAUNCH_POLL_MS)) + } + + return false + } + + /** Give the plugin of the launched client a bounded time to say hello. */ + private fun expectHello( + session: LaunchedSession, + launch: Launch, + ) { + val overdue = Runnable { abandon(session, launch, NEVER_CONNECTED) } + + deadlines.schedule(overdue, helloTimeoutMs, TimeUnit.MILLISECONDS) + } + + /** Stop the session for [reason] and kill its client, unless the session has moved on from waiting for it. */ + private fun abandon( + session: LaunchedSession, + launch: Launch, + reason: String, + ) { + if (!session.neverConnected(launch, reason)) return + + synchronized(launchLock) { release(launch) } + } + + /** Stop expecting the hello of the launch and kill its client, if any. Must be called with [launchLock] held. */ + private fun release(launch: Launch) { + bridge.forget(launch.httpPort) + launcher.kill(launch.proxyPort) + } + + /** Build the listener that feeds the hello and the close of one launch into its session. */ + private fun listener( + session: LaunchedSession, + launch: Launch, + ): BridgeListener = + object : BridgeListener { + /** The id of the session that waits for the client. */ + override val session: String = session.id.value + + /** What the session may do with the client, which its target decides. */ + override val access: Access = session.access + + /** Connect the session to the link, unless the session has moved on from this launch. */ + override fun onHello( + link: BridgeLink, + pid: Long, + ): Boolean = session.hello(launch.httpPort, link, pid) + + /** Stop the session when the link of its connected client closes, and release the proxy state. */ + override fun onClosed(link: BridgeLink) { + if (!session.linkClosed(link)) return + + // The client is gone, or cannot be driven any more. Either way the proxy still holds + // its process handle and session monitor for the port. + synchronized(launchLock) { launcher.kill(launch.proxyPort) } + } + + /** Stop the session with the reason, since the client that was refused dials only once. */ + override fun onRejected(reason: String) { + abandon(session, launch, "the client started, but its bridge plugin was rejected: $reason") + } + } + + /** Get the message of the innermost cause. */ + private fun rootMessage(throwable: Throwable): String { + val root = generateSequence(throwable) { it.cause }.last() + + return root.message ?: root.toString() + } + + public companion object { + /** The time a start waits for the in-client bridge to connect, unless the caller says otherwise. */ + public const val DEFAULT_WAIT_MS: Long = 180_000L + + /** + * The default of the longest wait for a launcher to complete its handshake. + * Covers a first run, where the launcher downloads the client before it handshakes. + */ + private const val DEFAULT_LAUNCH_TIMEOUT_MS = 180_000L + + /** The default of the longest wait for the plugin of a launched client to say hello. */ + private const val DEFAULT_HELLO_TIMEOUT_MS = 60_000L + + /** The pause between two checks of whether the launcher of a blocked launch has exited. */ + private const val LAUNCH_POLL_MS = 100L + + /** The time a launch gets to return after its launcher has exited. */ + private const val LAUNCHER_EXIT_GRACE_MS = 1_000L + + /** The reason of a session whose client never said hello. */ + private const val NEVER_CONNECTED = "the client started, but its bridge plugin never connected" + + /** The reason of a session whose client the proxy reported gone. */ + private const val CLIENT_CLOSED = "the client was closed" + + /** The refusal of a request that would act on a client through a closed gate. */ + private const val ENDPOINT_OFF = "the MCP endpoint was turned off" + + /** The refusal of every launch that follows one whose launcher never returned. */ + private const val HUNG_LAUNCH = + "an earlier launch never completed its handshake; restart the rsprox MCP process" + } +} diff --git a/mcp/src/main/resources/META-INF/services/net.rsprox.proxy.ProxyExtension b/mcp/src/main/resources/META-INF/services/net.rsprox.proxy.ProxyExtension new file mode 100644 index 000000000..40b636627 --- /dev/null +++ b/mcp/src/main/resources/META-INF/services/net.rsprox.proxy.ProxyExtension @@ -0,0 +1 @@ +net.rsprox.mcp.McpExtension diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/McpExtensionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/McpExtensionTest.kt new file mode 100644 index 000000000..87c8e54e8 --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/McpExtensionTest.kt @@ -0,0 +1,276 @@ +package net.rsprox.mcp + +import net.rsprox.mcp.bridge.BridgeError +import net.rsprox.mcp.bridge.Gate +import net.rsprox.mcp.server.McpDispatcher +import net.rsprox.mcp.server.McpHttpServer +import net.rsprox.mcp.server.ToolServer +import net.rsprox.mcp.server.tools +import net.rsprox.mcp.session.FakeLauncher +import net.rsprox.mcp.session.loginHeader +import net.rsprox.mcp.session.target +import net.rsprox.proxy.ProxyExtension +import net.rsprox.shared.StreamDirection +import net.rsprox.shared.property.ChildProperty +import net.rsprox.shared.property.RootProperty +import java.io.IOException +import java.net.ConnectException +import java.net.ServerSocket +import java.util.ServiceLoader +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull + +class McpExtensionTest { + private lateinit var switch: EndpointSwitch + + private fun freePort(): Int = ServerSocket(0).use { it.localPort } + + /** The tools over a switch that is off. Closing the server turns the switch off. */ + private fun switched(): ToolServer { + val gate = Gate() + + // The port is picked once the test server holds its other ports, so that none of them takes it. + return ToolServer(gate) { sessions -> + val port = freePort() + switch = EndpointSwitch(port, gate) { sessions } + + port to AutoCloseable { switch.stopServing() } + } + } + + private fun ToolServer.refuses() { + assertFailsWith { rpc("ping", "{}") } + } + + private fun packet(prot: String): RootProperty = + object : RootProperty { + override val prot = prot + override val children = mutableListOf>() + } + + @Test + fun `the GUI finds the extension among the proxy extensions on its classpath`() { + val found = ServiceLoader.load(ProxyExtension::class.java).map { it.javaClass } + + assertEquals(listOf>(McpExtension::class.java), found) + } + + @Test + fun `an endpoint that is off at start-up binds nothing and builds no sessions`() { + val port = freePort() + var built = false + val off = + EndpointSwitch(port, Gate()) { + built = true + error("no session is built for an endpoint that is off") + } + + off.atStartUp(enabled = false) + off.stopServing() + + ServerSocket(port).close() + assertFalse(built) + } + + @Test + fun `an endpoint that is on at start-up serves the tools on its port`() { + switched().use { server -> + switch.atStartUp(enabled = true) + + assertEquals( + """{"sessions":[],"targets":["Old School RuneScape","My Server"]}""", + server.call("session_list"), + ) + } + } + + @Test + fun `turning the endpoint on serves the tools and lists a client that is launched after it`() { + switched().use { server -> + server.refuses() + + assertNull(switch.serve()) + server.manager.attach(43701, target(1, "Local")) + + assertEquals( + """{"sessions":[{"session":"s1","kind":"attached","target":"Local","access":"read",""" + + """"state":"attached","proxyPort":43701,"cursor":1}],""" + + """"targets":["Old School RuneScape","My Server"]}""", + server.call("session_list"), + ) + } + } + + @Test + fun `turning the endpoint off refuses a connection and keeps a request that still runs from forking a client`() { + switched().use { server -> + val reserved = CountDownLatch(1) + val off = CountDownLatch(1) + var forked = false + server.launcher.onReserve = { + reserved.countDown() + off.await() + } + server.launcher.launch = { forked = true } + switch.serve() + + val running = CompletableFuture.runAsync { server.rpc("tools/call", """{"name":"session_start"}""") } + assertEquals(true, reserved.await(10, TimeUnit.SECONDS)) + switch.stopServing() + off.countDown() + + assertIs(assertFailsWith { running.get(10, TimeUnit.SECONDS) }.cause) + server.refuses() + assertFalse(forked) + assertEquals( + listOf("stopped" to "the MCP endpoint was turned off"), + server.manager.list().map { it.state to it.reason }, + ) + } + } + + @Test + fun `a tool that runs after the endpoint is turned off sends the client nothing`() { + switched().use { server -> + val received = CopyOnWriteArrayList() + switch.serve() + server.call("session_start", """{"target":"My Server","wait_ms":0}""") + val plugin = server.hub.dial(FakeLauncher.FIRST_HTTP_PORT) + plugin.read() + plugin.serve { op, _ -> + received += op + """"ok":{}""" + } + server.call("session_start", """{"session":"s1","wait_ms":10000}""") + val click = tools { server.manager }.single { it.name == "client_click" } + val arguments = McpDispatcher.MAPPER.createObjectNode().put("x", 10).put("y", 20) + + switch.stopServing() + + assertEquals("closed", assertFailsWith { click.run(arguments) }.code) + assertEquals(emptyList(), received) + assertEquals(emptyList(), server.launcher.killed) + } + } + + @Test + fun `turning the endpoint on again lists the earlier session with its log and what it recorded while off`() { + switched().use { server -> + switch.serve() + val login = checkNotNull(server.manager.attach(43701, target(1, "Local"))).forSession(loginHeader) + login.onLogin(loginHeader) + login.onPacketDirection(StreamDirection.SERVER_TO_CLIENT) + login.onTranscribe(4, packet("REBUILD_NORMAL")) + val served = server.call("packets_read", """{"session":"s1"}""") + + switch.stopServing() + login.onTranscribe(5, packet("UPDATE_STAT")) + assertNull(switch.serve()) + + assertEquals( + listOf( + """{"next":3,"head":3,"dropped":0,"timedOut":false,"count":3}""", + "1 L0 T0 P CLIENT_ATTACHED proxyPort=43701", + "2 L1 T0 P LOGIN revision=235 world=301", + "3 L1 T4 S REBUILD_NORMAL [rebuild_normal] ", + ), + served.lines(), + ) + assertEquals(listOf("4 L1 T5 S UPDATE_STAT [update_stat] "), server.rows("""{"session":"s1","after":3}""")) + assertEquals(4, server.callJson("session_list").get("sessions").single().get("cursor").asInt()) + } + } + + @Test + fun `turning the endpoint on while its port is taken says why, builds no sessions and binds nothing`() { + val holder = McpHttpServer(0, emptyList(), "1.2.3").also { it.start() } + val port = holder.localPort + var built = false + val taken = + EndpointSwitch(port, Gate()) { + built = true + error("no session is built behind a port that is taken") + } + + try { + val refusal = checkNotNull(taken.serve()) + + // The reason of the operating system sits between the two halves. + assertEquals( + "The MCP endpoint is not served: 127.0.0.1:$port cannot be bound", + refusal.substringBefore(" ("), + ) + assertEquals( + "Another rsprox or the standalone MCP server may hold it. Close that one, or set mcp.port " + + "in proxy.properties.", + refusal.substringAfter("). "), + ) + assertFalse(built) + } finally { + holder.close() + } + + ServerSocket(port).close() + } + + @Test + fun `sessions that cannot be built leave no endpoint and free the port, and the next turn builds them`() { + switched().use { server -> + val port = server.port + var fail = true + val failing = + EndpointSwitch(port, server.gate) { + if (fail) throw IllegalStateException("the bridge hub could not start") + + server.manager + } + + assertEquals( + "The MCP endpoint is not served: it could not start (the bridge hub could not start). " + + "The log has the details.", + failing.serve(), + ) + ServerSocket(port).close() + + fail = false + assertNull(failing.serve()) + server.call("session_list") + failing.stopServing() + } + } + + @Test + fun `turning the endpoint on and off again and again ends in the last state`() { + switched().use { server -> + switch.serve() + switch.serve() + switch.stopServing() + switch.stopServing() + switch.serve() + server.call("session_list") + + val clicks = + (1..8).map { thread -> + CompletableFuture.runAsync { + repeat(25) { click -> if ((thread + click) % 2 == 0) switch.serve() else switch.stopServing() } + } + } + clicks.forEach { it.get(30, TimeUnit.SECONDS) } + + switch.stopServing() + server.refuses() + + assertNull(switch.serve()) + server.call("session_list") + } + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt new file mode 100644 index 000000000..0460e5783 --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt @@ -0,0 +1,144 @@ +package net.rsprox.mcp.bridge + +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame + +class BridgeHubTest { + private val mapper = jacksonObjectMapper() + private val fixture = TestHub() + private val hub = fixture.hub + + @AfterTest + fun cleanUp() { + fixture.close() + } + + private fun args(json: String = "{}"): ObjectNode = mapper.readTree(json) as ObjectNode + + /** A welcomed plugin and the link the hub made for it. */ + private fun connect(listener: RecordingListener = RecordingListener()): Pair { + hub.expect(43650, listener) + val plugin = fixture.dial(43650) + assertEquals(2, plugin.read()?.get("welcome")?.asInt()) + + return plugin to listener.hello.get(10, TimeUnit.SECONDS).first + } + + private fun async(body: () -> T): CompletableFuture = CompletableFuture.supplyAsync(body) + + private fun bridgeError(call: CompletableFuture<*>): BridgeError { + val failure = assertFailsWith { call.get(10, TimeUnit.SECONDS) } + + return failure.cause as BridgeError + } + + @Test + fun `a hello for an expected port is welcomed and handed to its listener`() { + val listener = RecordingListener(session = "s3") + hub.expect(43650, listener) + val plugin = fixture.dial(43650) + + val welcome = plugin.read()!! + assertEquals(2, welcome.get("welcome").asInt()) + assertEquals("s3", welcome.get("session").asText()) + assertEquals("drive", welcome.get("access").asText()) + assertFalse(welcome.get("softwareRendering").asBoolean()) + assertEquals(4242, listener.hello.get(10, TimeUnit.SECONDS).second) + } + + @Test + fun `a wrong token is rejected and leaves the expectation in place`() { + val listener = RecordingListener() + hub.expect(43650, listener) + + val intruder = fixture.dial(43650, token = "not-the-token") + assertEquals("bad token", intruder.read()?.get("reject")?.asText()) + assertNull(intruder.read()) + assertFalse(listener.hello.isDone) + + assertEquals(2, fixture.dial(43650).read()?.get("welcome")?.asInt()) + assertEquals(emptyList(), listener.rejected) + } + + @Test + fun `a port that no session expects is rejected`() { + hub.expect(43650, RecordingListener()) + val stranger = fixture.dial(43600) + assertEquals("no session expects httpPort 43600", stranger.read()?.get("reject")?.asText()) + assertNull(stranger.read()) + } + + @Test + fun `a call sends the op and its args and returns the ok value`() { + val (plugin, link) = connect() + val call = async { link.call("click", args("""{"x":380,"y":215}"""), Access.DRIVE, TIMEOUT_MS) } + + val request = plugin.read()!! + assertEquals("click", request.get("op").asText()) + assertEquals(args("""{"x":380,"y":215}"""), request.get("args")) + plugin.send("""{"id":${request.get("id")},"ok":{"x":380,"y":215}}""") + + assertEquals(args("""{"x":380,"y":215}"""), call.get(10, TimeUnit.SECONDS)) + } + + @Test + fun `a listener that reads is welcomed with read access, and its link refuses an op that sends input`() { + val listener = RecordingListener(access = Access.READ) + hub.expect(43650, listener) + val plugin = fixture.dial(43650) + assertEquals("read", plugin.read()?.get("access")?.asText()) + val link = listener.hello.get(10, TimeUnit.SECONDS).first + + val refused = + assertFailsWith { link.call("click", args("""{"x":1,"y":2}"""), Access.DRIVE, TIMEOUT_MS) } + assertEquals("read_only", refused.code) + + val call = async { link.call("state", args(), Access.READ, TIMEOUT_MS) } + val request = plugin.read()!! + assertEquals("state", request.get("op").asText()) + plugin.send("""{"id":${request.get("id")},"ok":{}}""") + call.get(10, TimeUnit.SECONDS) + } + + @Test + fun `an err reply becomes a bridge error with its code and message`() { + val (plugin, link) = connect() + plugin.serve { _, _ -> """"err":{"code":"not_found","message":"widget 558:7 is not visible"}""" } + + val error = + assertFailsWith { + link.call("click", args("""{"widget":"558:7"}"""), Access.DRIVE, TIMEOUT_MS) + } + assertEquals("not_found", error.code) + assertEquals("widget 558:7 is not visible", error.message) + } + + @Test + fun `a closed connection fails the pending calls, reports the close and refuses new calls`() { + val listener = RecordingListener() + val (plugin, link) = connect(listener) + val pending = async { link.call("screenshot", args(), Access.READ, TIMEOUT_MS) } + plugin.read() + + plugin.close() + + assertEquals("closed", bridgeError(pending).code) + assertSame(link, listener.closed.get(10, TimeUnit.SECONDS)) + val refused = assertFailsWith { link.call("state", args(), Access.READ, TIMEOUT_MS) } + assertEquals("closed", refused.code) + } + + private companion object { + private const val TIMEOUT_MS = 10_000L + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt new file mode 100644 index 000000000..aac41ffa6 --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt @@ -0,0 +1,137 @@ +package net.rsprox.mcp.bridge + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import java.io.IOException +import java.net.InetAddress +import java.net.Socket +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.concurrent.thread + +private val mapper = jacksonObjectMapper() + +/** Polls until [condition] holds, so a test fails with a message instead of hanging. */ +internal fun awaitTrue( + what: String, + condition: () -> Boolean, +) { + val deadline = System.nanoTime() + 10_000_000_000 + + while (!condition()) { + check(System.nanoTime() < deadline) { "timed out waiting until $what" } + Thread.sleep(5) + } +} + +/** A started hub whose rendezvous file lives in a temporary directory. */ +internal class TestHub : AutoCloseable { + private val rendezvous = Files.createTempDirectory("mcp-bridge-test").resolve("mcp").resolve("bridge.json") + private val plugins = ArrayList() + val hub = BridgeHub(rendezvous, Rendering.GPU).also { it.start() } + + /** Connects a plugin that says hello for [httpPort]. The hub's answer is its next [FakePlugin.read]. */ + fun dial( + httpPort: Int, + token: String? = null, + protocol: Int = 2, + ): FakePlugin = FakePlugin.dial(rendezvous, httpPort, token, protocol).also { plugins += it } + + override fun close() { + plugins.forEach { it.close() } + hub.close() + rendezvous.parent.parent + .toFile() + .deleteRecursively() + } +} + +internal class RecordingListener( + override val session: String = "s1", + override val access: Access = Access.DRIVE, +) : BridgeListener { + val hello = CompletableFuture>() + val closed = CompletableFuture() + val rejected = CopyOnWriteArrayList() + + override fun onHello( + link: BridgeLink, + pid: Long, + ): Boolean { + hello.complete(link to pid) + + return true + } + + override fun onClosed(link: BridgeLink) { + closed.complete(link) + } + + override fun onRejected(reason: String) { + rejected += reason + } +} + +/** The plugin's side of the wire, driven by the test. */ +internal class FakePlugin private constructor( + private val socket: Socket, +) : AutoCloseable { + private val reader = socket.getInputStream().bufferedReader() + private val writer = socket.getOutputStream().bufferedWriter() + + /** Null when the hub closed the connection. */ + fun read(): JsonNode? = reader.readLine()?.let(mapper::readTree) + + @Synchronized + fun send(json: String) { + writer.write(json) + writer.write("\n") + writer.flush() + } + + /** Answers every request with the `"ok":...` or `"err":...` member that [answer] returns. */ + fun serve(answer: (op: String, args: JsonNode) -> String) { + thread(isDaemon = true) { + try { + while (true) { + val request = read() ?: break + send("""{"id":${request.get("id")},${answer(request.get("op").asText(), request.get("args"))}}""") + } + } catch (e: IOException) { + // The test closed the connection. + } + } + } + + override fun close() { + socket.close() + } + + companion object { + /** Connects and says hello the way the plugin does. */ + fun dial( + rendezvous: Path, + httpPort: Int, + token: String?, + protocol: Int, + ): FakePlugin { + val file = mapper.readTree(Files.readAllBytes(rendezvous)) + val socket = Socket(InetAddress.getLoopbackAddress(), file.get("port").asInt()) + socket.soTimeout = 10_000 + val plugin = FakePlugin(socket) + val hello = + mapper + .createObjectNode() + .put("hello", protocol) + .put("httpPort", httpPort) + .put("token", token ?: file.get("token").asText()) + .put("pid", 4242) + + plugin.send(mapper.writeValueAsString(hello)) + + return plugin + } + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketLogTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketLogTest.kt new file mode 100644 index 000000000..7d27017c0 --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketLogTest.kt @@ -0,0 +1,168 @@ +package net.rsprox.mcp.packets + +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PacketLogTest { + private fun PacketLog.add( + prot: String, + origin: Origin = Origin.SERVER, + text: String = "[${prot.lowercase()}]", + ): Long = append(login = 1, cycle = 0, origin = origin, prot = prot, text = text) + + private fun PacketPage.seqs(): List = packets.map { it.seq } + + @Test + fun `an empty log reads as empty at cursor zero`() { + val log = PacketLog() + val page = log.read(PacketQuery()) + assertEquals(emptyList(), page.packets) + assertEquals(Cursor(0), page.next) + assertEquals(Cursor(0), page.head) + assertEquals(0, page.dropped) + assertFalse(page.timedOut) + } + + @Test + fun `sequences start at one and reading from next returns only what came later`() { + val log = PacketLog() + assertEquals(1, log.add("A")) + assertEquals(2, log.add("B")) + val first = log.read(PacketQuery()) + assertEquals(listOf(1L, 2L), first.seqs()) + assertEquals(Cursor(2), first.next) + assertEquals(Cursor(2), log.head()) + + log.add("C") + val second = log.read(PacketQuery(after = first.next)) + assertEquals(listOf(3L), second.seqs()) + assertEquals(Cursor(3), second.next) + assertEquals(emptyList(), log.read(PacketQuery(after = second.next)).packets) + } + + @Test + fun `next skips past scanned records that did not match`() { + val log = PacketLog() + log.add("A") + log.add("B") + log.add("B") + val page = log.read(PacketQuery(prots = setOf("A"))) + assertEquals(listOf(1L), page.seqs()) + assertEquals(Cursor(3), page.next) + assertEquals(Cursor(3), page.head) + } + + @Test + fun `a page cut short by the limit resumes at the next unreturned record`() { + val log = PacketLog() + repeat(5) { log.add("A") } + val first = log.read(PacketQuery(limit = 2)) + assertEquals(listOf(1L, 2L), first.seqs()) + assertEquals(Cursor(2), first.next) + assertEquals(Cursor(5), first.head) + val rest = log.read(PacketQuery(after = first.next, limit = 10)) + assertEquals(listOf(3L, 4L, 5L), rest.seqs()) + assertEquals(Cursor(5), rest.next) + } + + @Test + fun `evicted records are reported as dropped and sequences are never reused`() { + val log = PacketLog(capacity = 3) + repeat(5) { log.add("A") } + val all = log.read(PacketQuery()) + assertEquals(listOf(3L, 4L, 5L), all.seqs()) + assertEquals(2, all.dropped) + + val partial = log.read(PacketQuery(after = Cursor(1))) + assertEquals(1, partial.dropped) + assertEquals(0, log.read(PacketQuery(after = Cursor(2))).dropped) + assertEquals(6, log.add("A")) + } + + @Test + fun `filters by prot, origin and text`() { + val log = PacketLog() + log.add("IF_BUTTON", Origin.CLIENT, "[if_button] com=558:7") + log.add("IF_SETTEXT", Origin.SERVER, "[if_settext] com=558:13, text=\"McpProto is available\"") + log.add("LOGIN", Origin.PROXY, "revision=241 world=1") + log.add("IF_SETTEXT", Origin.SERVER, "[if_settext] com=162:5, text=\"hello\"") + + assertEquals(listOf(2L, 4L), log.read(PacketQuery(prots = setOf("IF_SETTEXT"))).seqs()) + assertEquals(listOf(1L, 3L), log.read(PacketQuery(prots = setOf("IF_BUTTON", "LOGIN"))).seqs()) + assertEquals(listOf(1L), log.read(PacketQuery(origin = Origin.CLIENT)).seqs()) + assertEquals(listOf(3L), log.read(PacketQuery(origin = Origin.PROXY)).seqs()) + assertEquals(listOf(2L), log.read(PacketQuery(contains = "mcpproto")).seqs()) + assertEquals( + emptyList(), + log.read(PacketQuery(prots = setOf("IF_SETTEXT"), origin = Origin.CLIENT)).seqs(), + ) + } + + @Test + fun `a waiting read returns as soon as a matching record is appended`() { + val log = PacketLog() + log.add("A") + val read = + CompletableFuture.supplyAsync { + log.read(PacketQuery(after = Cursor(1), prots = setOf("WANTED")), waitMs = 30_000) + } + + log.add("NOISE") + assertFalse(read.isDone) + log.add("WANTED") + val page = read.get(10, TimeUnit.SECONDS) + assertEquals(listOf(3L), page.seqs()) + assertEquals(Cursor(3), page.next) + assertFalse(page.timedOut) + } + + @Test + fun `a waiting read times out when nothing matches and still advances the cursor`() { + val log = PacketLog() + log.add("A") + val page = log.read(PacketQuery(prots = setOf("WANTED")), waitMs = 50) + assertTrue(page.timedOut) + assertEquals(emptyList(), page.packets) + assertEquals(Cursor(1), page.next) + } + + @Test + fun `a waiting read with matches already present returns at once`() { + val log = PacketLog() + log.add("WANTED") + val page = log.read(PacketQuery(prots = setOf("WANTED")), waitMs = 30_000) + assertEquals(listOf(1L), page.seqs()) + assertFalse(page.timedOut) + } + + @Test + fun `a cursor beyond the head reads from the head`() { + val log = PacketLog() + log.add("A") + val page = log.read(PacketQuery(after = Cursor(99))) + assertEquals(Cursor(1), page.next) + log.add("B") + assertEquals(listOf(2L), log.read(PacketQuery(after = page.next)).seqs()) + } + + @Test + fun `a prot prefix keeps every prot that starts with it and combines with the other filters`() { + val log = PacketLog() + log.add("OPNPC1_V2", Origin.CLIENT) + log.add("OPNPC_FAKE", Origin.SERVER) + log.add("IF_BUTTONX", Origin.CLIENT) + log.add("XOPNPC1", Origin.CLIENT) + log.add("OPNPC6", Origin.CLIENT) + + val npcOps = setOf("OPNPC") + + assertEquals(listOf(1L, 2L, 5L), log.read(PacketQuery(protPrefixes = npcOps)).seqs()) + assertEquals(listOf(1L, 5L), log.read(PacketQuery(protPrefixes = npcOps, origin = Origin.CLIENT)).seqs()) + assertEquals(listOf(1L, 3L, 5L), log.read(PacketQuery(protPrefixes = setOf("OPNPC1", "IF_", "OPNPC6"))).seqs()) + assertEquals(listOf(5L), log.read(PacketQuery(protPrefixes = npcOps, prots = setOf("OPNPC6"))).seqs()) + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt new file mode 100644 index 000000000..d66422ef5 --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt @@ -0,0 +1,234 @@ +package net.rsprox.mcp.server + +import net.rsprox.mcp.session.FakeLauncher +import net.rsprox.mcp.session.loginHeader +import net.rsprox.mcp.session.target +import net.rsprox.proxy.binary.BinaryHeader +import net.rsprox.shared.SessionMonitor +import net.rsprox.shared.StreamDirection +import net.rsprox.shared.property.ChildProperty +import net.rsprox.shared.property.RootProperty +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class AttachedSessionTest { + private val server = ToolServer() + + @AfterTest + fun cleanUp() { + server.close() + } + + private fun sessions(): List = server.callJson("session_list").get("sessions").map { it.toString() } + + private fun attach( + proxyPort: Int = 43701, + captureFolder: String? = "Local", + ): SessionMonitor = + checkNotNull(server.manager.attach(proxyPort, target(1, "Local").copy(binaryFolder = captureFolder))) + + private fun SessionMonitor.logIn(): SessionMonitor { + val login = forSession(loginHeader) + login.onLogin(loginHeader) + login.onCacheUpdate { error("the test formats no packet that needs the cache") } + + return login + } + + private fun SessionMonitor.packet( + tick: Int, + direction: StreamDirection, + prot: String, + ) { + onPacketDirection(direction) + onTranscribe( + tick, + object : RootProperty { + override val prot = prot + override val children = mutableListOf>() + }, + ) + } + + @Test + fun `a client the GUI launches is listed as an attached session, numbered with the launched ones`() { + server.call("session_start", """{"target":"My Server","wait_ms":0}""") + + attach(proxyPort = 43701) + + assertEquals( + listOf( + """{"session":"s1","kind":"launched","target":"My Server","access":"drive","state":"launching",""" + + """"generation":1,"proxyPort":43751,"httpPort":43650,"cursor":1}""", + """{"session":"s2","kind":"attached","target":"Local","access":"read","state":"attached",""" + + """"proxyPort":43701,"cursor":1}""", + ), + sessions(), + ) + } + + @Test + fun `a client that the MCP server launched itself gets no attached session`() { + server.call("session_start", """{"target":"My Server","wait_ms":0}""") + + assertNull(server.manager.attach(FakeLauncher.FIRST_PROXY_PORT, target(1, "My Server"))) + + assertEquals(listOf("s1"), server.manager.list().map { it.session }) + } + + @Test + fun `the session list reports each fact of a login from the moment the proxy knows it`() { + val client = attach() + val attached = + """{"session":"s1","kind":"attached","target":"Local","access":"read","state":"attached",""" + + """"proxyPort":43701""" + val accepted = + """"login":{"epoch":1,"revision":235,"world":301,"host":"127.0.1.3","localPlayerIndex":7,""" + + """"connectedAt":"2023-11-14T22:13:20Z"""" + + assertEquals(listOf("""$attached,"cursor":1}"""), sessions()) + + val login = client.forSession(loginHeader) + login.onLogin(loginHeader) + + val recorded = """$accepted,"captureFile":"Local/${loginHeader.fileName()}"""" + + assertEquals(listOf("""$attached,$recorded,"online":true,"transcribing":false},"cursor":2}"""), sessions()) + + login.packet(tick = 4, StreamDirection.SERVER_TO_CLIENT, "REBUILD_NORMAL") + + assertEquals( + listOf("""$attached,$recorded,"online":true,"transcribing":true,"tick":4},"cursor":3}"""), + sessions(), + ) + + login.onNameUpdate("Alice") + login.packet(tick = 5, StreamDirection.CLIENT_TO_SERVER, "NO_TIMEOUT") + + assertEquals( + listOf("""$attached,$recorded,"name":"Alice","online":true,"transcribing":true,"tick":5},"cursor":4}"""), + sessions(), + ) + + login.onLogout(loginHeader) + + assertEquals( + listOf("""$attached,$recorded,"name":"Alice","online":false,"transcribing":true,"tick":5},"cursor":5}"""), + sessions(), + ) + } + + @Test + fun `a login of a target that is not recorded has no capture file`() { + attach(captureFolder = null).logIn() + + assertEquals( + listOf( + """{"session":"s1","kind":"attached","target":"Local","access":"read","state":"attached",""" + + """"proxyPort":43701,"login":{"epoch":1,"revision":235,"world":301,"host":"127.0.1.3",""" + + """"localPlayerIndex":7,"connectedAt":"2023-11-14T22:13:20Z","online":true,""" + + """"transcribing":false},"cursor":2}""", + ), + sessions(), + ) + } + + @Test + fun `the packets of an attached session are read by cursor, with a new login number for each login`() { + val client = attach() + val first = client.logIn() + first.packet(tick = 4, StreamDirection.SERVER_TO_CLIENT, "REBUILD_NORMAL") + first.packet(tick = 5, StreamDirection.CLIENT_TO_SERVER, "NO_TIMEOUT") + first.onLogout(loginHeader) + client.logIn().packet(tick = 1, StreamDirection.SERVER_TO_CLIENT, "REBUILD_NORMAL") + + assertEquals( + listOf( + """{"next":7,"head":7,"dropped":0,"timedOut":false,"count":7}""", + "1 L0 T0 P CLIENT_ATTACHED proxyPort=43701", + "2 L1 T0 P LOGIN revision=235 world=301", + "3 L1 T4 S REBUILD_NORMAL [rebuild_normal] ", + "4 L1 T5 C NO_TIMEOUT [no_timeout] ", + "5 L1 T0 P LOGOUT world=301", + "6 L2 T0 P LOGIN revision=235 world=301", + "7 L2 T1 S REBUILD_NORMAL [rebuild_normal] ", + ), + server.call("packets_read", """{"session":"s1"}""").lines(), + ) + + assertEquals(listOf("4 L1 T5 C NO_TIMEOUT [no_timeout] "), server.rows("""{"after":3,"origin":"client"}""")) + } + + @Test + fun `the monitor of a login is its own monitor for that login`() { + val login = attach().logIn() + + login.forSession(loginHeader).packet(tick = 4, StreamDirection.SERVER_TO_CLIENT, "REBUILD_NORMAL") + + assertEquals(listOf("3 L1 T4 S REBUILD_NORMAL [rebuild_normal] "), server.rows("""{"after":2}""")) + } + + @Test + fun `session_stop and session_start refuse an attached session and leave its client alone`() { + attach() + val refusal = + "is not available for an attached session: session s1 belongs to a client that was launched " + + "from the rsprox GUI, so only its packets can be read" + + assertEquals("session_stop $refusal", server.error("session_stop", """{"session":"s1"}""")) + assertEquals("session_start $refusal", server.error("session_start", """{"session":"s1","wait_ms":0}""")) + + assertEquals(emptyList(), server.launcher.killed) + assertEquals(emptyList(), server.launcher.reserved) + assertEquals("attached", server.manager.list().single().state) + } + + @Test + fun `every client tool refuses an attached session`() { + attach() + val required = + mapOf( + "client_login" to """{"username":"alice","password":"any"}""", + "client_type" to """{"text":"hello"}""", + "client_interact" to """{"target":"tile","x":3094,"y":3107}""", + ) + + val clientTools = tools { server.manager }.map { it.name }.filter { it.startsWith("client_") } + + assertEquals(10, clientTools.size) + + for (tool in clientTools) { + assertEquals( + "$tool is not available for an attached session: session s1 belongs to a client that was " + + "launched from the rsprox GUI, so only its packets can be read", + server.error(tool, required[tool] ?: "{}"), + ) + } + } + + @Test + fun `an attached session ends when its client is closed and stays listed with its packets`() { + attach(proxyPort = 43701).logIn() + attach(proxyPort = 43702) + + server.manager.detach(43701) + server.manager.detach(43701) + server.manager.detach(43999) + + assertEquals( + listOf("ended" to "the client was closed", "attached" to null), + server.manager.list().map { it.state to it.reason }, + ) + + assertEquals( + listOf( + "1 L0 T0 P CLIENT_ATTACHED proxyPort=43701", + "2 L1 T0 P LOGIN revision=235 world=301", + "3 L1 T0 P CLIENT_EXITED the client was closed", + ), + server.rows("""{"session":"s1"}"""), + ) + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/ClosedGateTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/ClosedGateTest.kt new file mode 100644 index 000000000..5a516d0e9 --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/ClosedGateTest.kt @@ -0,0 +1,138 @@ +package net.rsprox.mcp.server + +import net.rsprox.mcp.session.FakeLauncher +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ClosedGateTest { + private val server = ToolServer() + + // Every request the plugin received, as its op. + private val received = CopyOnWriteArrayList() + + @AfterTest + fun cleanUp() { + server.close() + } + + private fun connect() { + server.call("session_start", """{"target":"My Server","wait_ms":0}""") + val plugin = server.hub.dial(FakeLauncher.FIRST_HTTP_PORT) + plugin.read() + plugin.serve { op, _ -> + received += op + """"ok":{}""" + } + + assertEquals("connected", server.manager.start(null, "s1", 10_000).state) + } + + private fun states(): List> = server.manager.list().map { it.state to it.reason } + + @Test + fun `a session_start that arrives at a closed gate reserves no ports and lists no session`() { + server.gate.close() + + assertEquals( + "the MCP endpoint was turned off", + server.error("session_start", """{"target":"My Server","wait_ms":0}"""), + ) + assertEquals(emptyList(), server.launcher.reserved) + assertEquals(emptyList(), states()) + } + + @Test + fun `a session_start that waits its turn while the gate closes forks no client`() { + val forking = CountDownLatch(1) + val release = CountDownLatch(1) + server.launcher.launch = { + forking.countDown() + release.await() + } + + val first = CompletableFuture.supplyAsync { server.error("session_start", """{"wait_ms":0}""") } + assertEquals(true, forking.await(10, TimeUnit.SECONDS)) + val second = CompletableFuture.supplyAsync { server.error("session_start", """{"wait_ms":0}""") } + + server.gate.close() + release.countDown() + + assertEquals("session s1 failed to launch: the MCP endpoint was turned off", first.get(10, TimeUnit.SECONDS)) + assertEquals("the MCP endpoint was turned off", second.get(10, TimeUnit.SECONDS)) + assertEquals(1, server.launcher.reserved.size) + assertEquals(listOf(FakeLauncher.FIRST_PROXY_PORT), server.launcher.killed) + assertEquals(listOf("stopped" to "the MCP endpoint was turned off"), states()) + } + + @Test + fun `a gate that closes once the ports of a launch are reserved forks no client`() { + var forked = false + server.launcher.onReserve = { server.gate.close() } + server.launcher.launch = { forked = true } + + assertEquals( + "session s1 failed to launch: the MCP endpoint was turned off", + server.error("session_start", """{"wait_ms":0}"""), + ) + assertEquals(false, forked) + assertEquals(listOf(FakeLauncher.FIRST_PROXY_PORT), server.launcher.killed) + assertEquals(listOf("stopped" to "the MCP endpoint was turned off"), states()) + } + + @Test + fun `a client tool sends the plugin nothing through a closed gate, and works again once it opens`() { + connect() + server.gate.close() + + assertEquals( + "closed: the MCP endpoint was turned off", + server.error("client_click", """{"session":"s1","x":10,"y":20}"""), + ) + assertEquals(emptyList(), received) + + server.gate.open() + server.call("client_click", """{"session":"s1","x":10,"y":20}""") + + assertEquals(listOf("click"), received) + } + + @Test + fun `session_stop kills no client through a closed gate`() { + connect() + server.gate.close() + + assertEquals("the MCP endpoint was turned off", server.error("session_stop", """{"session":"s1"}""")) + assertEquals(emptyList(), server.launcher.killed) + assertEquals(listOf("connected" to null), states()) + } + + @Test + fun `closing the gate waits for the act that is passing it and then refuses the next`() { + val passing = CountDownLatch(1) + val release = CountDownLatch(1) + val act = + CompletableFuture.supplyAsync { + server.gate.ifOpen { + passing.countDown() + release.await() + } + } + + assertEquals(true, passing.await(10, TimeUnit.SECONDS)) + val closed = CompletableFuture.runAsync { server.gate.close() } + + assertFailsWith { closed.get(200, TimeUnit.MILLISECONDS) } + release.countDown() + closed.get(10, TimeUnit.SECONDS) + + assertEquals(true, act.get(10, TimeUnit.SECONDS)) + assertEquals(false, server.gate.ifOpen { error("no act passes a closed gate") }) + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/McpHttpServerTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/McpHttpServerTest.kt new file mode 100644 index 000000000..e035a65cc --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/McpHttpServerTest.kt @@ -0,0 +1,180 @@ +package net.rsprox.mcp.server + +import net.rsprox.mcp.bridge.awaitTrue +import java.io.IOException +import java.net.BindException +import java.net.ConnectException +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs + +class McpHttpServerTest { + private val echo = + Tool( + name = "echo", + description = "Returns its text argument", + inputSchema = + McpDispatcher.MAPPER + .createObjectNode() + .put("type", "object") + .also { it.putObject("properties").putObject("text").put("type", "string") }, + ) { args -> ToolResult.Text(args.get("text").asText()) } + + private val server = McpHttpServer(0, listOf(echo), "1.2.3").also { it.start() } + private val client = HttpClient.newHttpClient() + + @AfterTest + fun cleanUp() { + server.close() + } + + private fun request(path: String = "/mcp"): HttpRequest.Builder = + HttpRequest.newBuilder(URI("http://127.0.0.1:${server.localPort}$path")) + + private fun send(request: HttpRequest.Builder): HttpResponse = + client.send(request.build(), HttpResponse.BodyHandlers.ofString()) + + private fun post( + body: String, + origin: String? = null, + ): HttpResponse { + val request = request().POST(HttpRequest.BodyPublishers.ofString(body)) + + if (origin != null) request.header("Origin", origin) + + return send(request) + } + + @Test + fun `a tool call is answered as json over http`() { + val response = + post( + """{"jsonrpc":"2.0","id":7,"method":"tools/call",""" + + """"params":{"name":"echo","arguments":{"text":"héllo"}}}""", + ) + + assertEquals(200, response.statusCode()) + assertEquals("application/json", response.headers().firstValue("Content-Type").orElse(null)) + assertEquals( + """{"jsonrpc":"2.0","id":7,"result":{"isError":false,"content":[{"type":"text","text":"héllo"}]}}""", + response.body(), + ) + } + + @Test + fun `a request from a page on another site is forbidden and one from a local page is served`() { + val ping = """{"jsonrpc":"2.0","id":1,"method":"ping"}""" + + val foreign = post(ping, origin = "https://example.com") + assertEquals(403, foreign.statusCode()) + assertEquals("", foreign.body()) + + val local = post(ping, origin = "http://localhost:6274") + assertEquals(200, local.statusCode()) + assertEquals("""{"jsonrpc":"2.0","id":1,"result":{}}""", local.body()) + } + + @Test + fun `a notification is accepted with no body`() { + val response = post("""{"jsonrpc":"2.0","method":"notifications/initialized"}""") + + assertEquals(202, response.statusCode()) + assertEquals("", response.body()) + } + + @Test + fun `a GET is refused and told that POST is allowed`() { + val response = send(request().GET()) + + assertEquals(405, response.statusCode()) + assertEquals("POST", response.headers().firstValue("Allow").orElse(null)) + assertEquals("", response.body()) + } + + @Test + fun `a body that is not json is a parse error`() { + val response = post("{not json") + + assertEquals(400, response.statusCode()) + assertEquals( + """{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}""", + response.body(), + ) + } + + @Test + fun `a batch is an invalid request`() { + val response = post("""[{"jsonrpc":"2.0","id":1,"method":"ping"}]""") + + assertEquals(400, response.statusCode()) + assertEquals( + """{"jsonrpc":"2.0","id":null,"error":{"code":-32600,""" + + """"message":"Expected one JSON-RPC object; batching is unsupported"}}""", + response.body(), + ) + } + + @Test + fun `a path below the endpoint is not found`() { + val response = send(request("/mcp/other").POST(HttpRequest.BodyPublishers.ofString("{}"))) + + assertEquals(404, response.statusCode()) + } + + @Test + fun `a second server on the same port is refused`() { + val second = McpHttpServer(server.localPort, listOf(echo), "1.2.3") + + assertFailsWith { second.start() } + } + + @Test + fun `a closed server refuses a connection`() { + val ping = request().POST(HttpRequest.BodyPublishers.ofString("""{"jsonrpc":"2.0","id":1,"method":"ping"}""")) + + server.close() + + assertFailsWith { send(ping) } + } + + @Test + fun `a request that runs when the server closes is not answered and leaves no thread behind`() { + val running = CompletableFuture() + val release = CountDownLatch(1) + val slow = + Tool("slow", "Returns once the test lets it", echo.inputSchema) { + running.complete(Thread.currentThread()) + release.await() + ToolResult.Text("late") + } + + val closing = McpHttpServer(0, listOf(slow), "1.2.3").also { it.start() } + val call = + HttpRequest + .newBuilder(URI("http://127.0.0.1:${closing.localPort}/mcp")) + .POST( + HttpRequest.BodyPublishers.ofString( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"slow"}}""", + ), + ).build() + + val answer = client.sendAsync(call, HttpResponse.BodyHandlers.ofString()) + val worker = running.get(10, TimeUnit.SECONDS) + + closing.close() + release.countDown() + + assertIs(assertFailsWith { answer.get(10, TimeUnit.SECONDS) }.cause) + awaitTrue("the thread of the request has ended") { !worker.isAlive } + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/ReadOnlySessionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/ReadOnlySessionTest.kt new file mode 100644 index 000000000..0cfe2c1ed --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/ReadOnlySessionTest.kt @@ -0,0 +1,157 @@ +package net.rsprox.mcp.server + +import net.rsprox.mcp.bridge.Access +import net.rsprox.mcp.session.FakeLauncher +import net.rsprox.mcp.session.target +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class ReadOnlySessionTest { + private val server = ToolServer() + + // Every request a plugin received, as its session, its op and its arguments. + private val received = CopyOnWriteArrayList() + + /** One client tool with arguments that pass its schema, and the op the plugin runs for it. */ + private class Call( + val tool: String, + val access: Access, + val op: String, + val arguments: String = "{}", + ) + + private val calls = + listOf( + Call("client_state", Access.READ, "state"), + Call("client_screenshot", Access.READ, "screenshot"), + Call("client_widgets", Access.READ, "widgets"), + Call("client_vars", Access.READ, "vars"), + Call("client_entities", Access.READ, "entities"), + Call("client_camera", Access.READ, "camera"), + Call("client_login", Access.DRIVE, "login", """{"username":"alice","password":"any"}"""), + Call("client_click", Access.DRIVE, "click", """{"x":10,"y":20}"""), + Call("client_type", Access.DRIVE, "type", """{"text":"hello"}"""), + Call("client_interact", Access.DRIVE, "interact", """{"target":"tile","x":3094,"y":3107}"""), + Call("client_camera", Access.DRIVE, "camera_turn", """{"yaw":100}"""), + Call("client_camera", Access.DRIVE, "camera_turn", """{"pitch":2048}"""), + Call("client_camera", Access.DRIVE, "camera_turn", """{"look_at":"npc","index":0}"""), + ) + + @AfterTest + fun cleanUp() { + server.close() + } + + /** Launches a session of the target and connects a plugin that answers every op with an empty object. */ + private fun connect(target: String): String { + val session = server.callJson("session_start", """{"target":"$target","wait_ms":0}""").get("session").asText() + val plugin = server.hub.dial(FakeLauncher.FIRST_HTTP_PORT + server.launcher.reserved.size - 1) + plugin.read() + plugin.serve { op, args -> + received += "$session $op $args" + if (op == "interact") """"ok":{"expect":"MOVE_GAMECLICK","pressed":[1,2]}""" else """"ok":{"png":""}""" + } + + server.call("session_start", """{"session":"$session","wait_ms":10000}""") + + return session + } + + /** Calls a tool that must succeed, whatever content it returns. */ + private fun succeed(call: Call) { + val result = server.rpc("tools/call", """{"name":"${call.tool}","arguments":${call.arguments}}""") + + assertFalse(result.get("isError").asBoolean(), result.toString()) + } + + private fun refusal( + tool: String, + session: String, + ): String = + "$tool sends input to the client, which is refused for session $session: its target " + + "'Old School RuneScape' is the official game, so the session is read-only. Use the tools that read: " + + "client_state, client_screenshot, client_widgets, client_vars, client_entities, client_camera without " + + "yaw, pitch or look_at, and packets_read." + + @Test + fun `every client tool reaches a client of a custom target`() { + val session = connect("My Server") + + calls.forEach(::succeed) + + assertEquals(calls.map { "$session ${call(it)}" }, received) + } + + @Test + fun `on the official game the tools that read reach the client and the tools that send input are refused`() { + val session = connect("Old School RuneScape") + + for (call in calls.filter { it.access == Access.DRIVE }) { + assertEquals(refusal(call.tool, session), server.error(call.tool, call.arguments)) + } + + assertEquals(emptyList(), received) + + calls.filter { it.access == Access.READ }.forEach(::succeed) + + assertEquals(calls.filter { it.access == Access.READ }.map { "$session ${call(it)}" }, received) + + for (read in received.map { it.substringAfter(' ').substringBefore(' ') }) { + assertContains(refusal("client_click", session), "client_$read") + } + } + + @Test + fun `an official session refuses input before it says that it has no client`() { + server.call("session_start", """{"target":"Old School RuneScape","wait_ms":0}""") + + assertEquals(refusal("client_type", "s1"), server.error("client_type", """{"text":"hello"}""")) + assertEquals( + "session s1 is still launching; call session_start with this session to wait for it", + server.error("client_state", "{}"), + ) + } + + @Test + fun `the session list says whether each session can be driven`() { + server.call("session_start", """{"target":"My Server","wait_ms":0}""") + server.call("session_start", """{"target":"Old School RuneScape","wait_ms":0}""") + server.manager.attach(43701, target(1, "My Server")) + + assertEquals( + listOf("s1 launched drive", "s2 launched read", "s3 attached read"), + server.callJson("session_list").get("sessions").map { + "${it.get("session").asText()} ${it.get("kind").asText()} ${it.get("access").asText()}" + }, + ) + } + + @Test + fun `a target that takes its jav_config from the official game is read-only whatever its id`() { + val mirror = target(1, "Mirror").copy(javConfigUrl = "https://oldschool.config.runescape.com/jav_config.ws") + server.launcher.targets = listOf(target(0, "Old School RuneScape"), mirror, target(2, "My Server")) + + val started = server.callJson("session_start", """{"wait_ms":0}""") + val forced = server.callJson("session_start", """{"target":"Mirror","wait_ms":0}""") + + assertEquals("My Server drive", "${started.get("target").asText()} ${started.get("access").asText()}") + assertEquals("Mirror read", "${forced.get("target").asText()} ${forced.get("access").asText()}") + } + + /** The op and the arguments the plugin receives for the call. */ + private fun call(call: Call): String { + val forwarded = + when (call.tool) { + "client_widgets" -> """{"limit":200}""" + "client_entities" -> """{"radius":15,"limit":100}""" + "client_login" -> """{"username":"alice","password":"any","wait_ms":15000}""" + else -> call.arguments + } + + return "${call.op} $forwarded" + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolCallsTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolCallsTest.kt new file mode 100644 index 000000000..e92a31d5e --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolCallsTest.kt @@ -0,0 +1,280 @@ +package net.rsprox.mcp.server + +import com.fasterxml.jackson.databind.JsonNode +import net.rsprox.mcp.packets.Origin +import net.rsprox.mcp.session.FakeLauncher +import net.rsprox.mcp.session.loginHeader +import net.rsprox.shared.StreamDirection +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ToolCallsTest { + private val server = ToolServer() + private val talkTo = """{"target":"npc","index":0,"option":"Talk-to"}""" + + // Every request the plugin received, as its op and its arguments. + private val received = CopyOnWriteArrayList() + + // What the plugin answers a request with. It runs before the answer is sent, as the client's packets do. + @Volatile + private var answer: (args: JsonNode) -> String = { """"ok":{}""" } + + @AfterTest + fun cleanUp() { + server.close() + } + + private fun connect() { + server.call("session_start", """{"target":"My Server","wait_ms":0}""") + val plugin = server.hub.dial(FakeLauncher.FIRST_HTTP_PORT) + plugin.read() + plugin.serve { op, args -> + received += "$op $args" + answer(args) + } + + val started = server.callJson("session_start", """{"session":"s1","wait_ms":10000}""") + assertEquals("connected", started.get("state").asText()) + } + + private fun logIn(decoded: Boolean = true) { + val tap = server.launcher.monitor.forSession(loginHeader) + tap.onLogin(loginHeader) + + if (decoded) tap.onPacketDirection(StreamDirection.CLIENT_TO_SERVER) + } + + private fun sent( + prot: String, + text: String, + origin: Origin = Origin.CLIENT, + tick: Int = 7, + ): Long = server.manager.resolve("s1").packets.append(1, tick, origin, prot, text) + + private fun clicked( + x: Int, + y: Int, + ): Long = + sent( + "EVENT_MOUSE_CLICK_V2", + "[event_mouse_click_v2] lasttransmitted=40ms, x=$x, y=$y, rightclick=false, code=0", + ) + + private fun performed( + target: String, + expect: String, + ): String = + """"ok":{"target":"$target","option":"Talk-to","attempts":1,"tick":41,""" + + """"expect":"$expect","pressed":[312,171],"tried":[]}""" + + @Test + fun `the tool list names the fourteen tools, each with a description and an object schema`() { + val listed = server.rpc("tools/list", "{}").get("tools") + + assertEquals( + listOf( + "session_start", + "session_stop", + "session_list", + "packets_read", + "client_state", + "client_screenshot", + "client_widgets", + "client_vars", + "client_login", + "client_click", + "client_type", + "client_entities", + "client_interact", + "client_camera", + ), + listed.map { it.get("name").asText() }, + ) + + for (tool in listed) { + assertTrue(tool.get("description").asText().isNotBlank(), tool.toString()) + assertEquals("object", tool.get("inputSchema").get("type").asText(), tool.toString()) + } + } + + @Test + fun `a client tool calls the plugin op of its name and returns its answer with the cursor before the call`() { + connect() + answer = { + sent("EVENT_MOUSE_CLICK_V2", "[event_mouse_click_v2] x=10, y=20") + """"ok":{"x":10,"y":20}""" + } + + val result = server.call("client_click", """{"session":"s1","x":10,"y":20,"widget":null}""") + + assertEquals("""{"x":10,"y":20,"cursor":2}""", result) + assertEquals(listOf("""click {"x":10,"y":20}"""), received) + } + + @Test + fun `invalid arguments are refused by name before anything reaches the plugin`() { + connect() + + assertContains(server.error("client_click", """{"x":"ten","y":20}"""), "'x' must be of type integer") + assertContains(server.error("client_click", """{"x":10,"y":20,"z":1}"""), "unknown argument 'z'") + assertContains( + server.error("client_entities", """{"kinds":["npc","dragon"]}"""), + "every item of 'kinds' must be one of npc, object", + ) + + assertEquals(emptyList(), received) + } + + @Test + fun `an interaction returns the action packet that follows the mouse click of the pressed point`() { + connect() + logIn() + answer = { + sent("OPNPC1_V2", "[opnpc1_v2] npc=(index=9, id=3010)") + clicked(312, 171) + sent("OPNPC1_V2", "[opnpc1_v2] npc=(index=0, id=3308)") + performed("npc", "OPNPC") + } + + val result = server.callJson("client_interact", talkTo) + + assertEquals("6 L1 T7 C OPNPC1_V2 [opnpc1_v2] npc=(index=0, id=3308)", result.get("packet").asText()) + assertEquals("click", result.get("proof").asText()) + assertEquals(3, result.get("cursor").asInt()) + assertFalse(result.has("expect"), result.toString()) + assertFalse(result.has("pressed"), result.toString()) + assertEquals(listOf("interact $talkTo"), received) + } + + @Test + fun `an interaction without a mouse click of the pressed point returns the first packet of the expected prefix`() { + connect() + logIn() + answer = { + clicked(40, 50) + sent("OPNPC1_V2", "[opnpc1_v2] npc=(index=0, id=3308)") + sent("OPNPC3_V2", "[opnpc3_v2] npc=(index=0, id=3308)") + performed("npc", "OPNPC") + } + + val result = server.callJson("client_interact", talkTo) + + assertEquals("5 L1 T7 C OPNPC1_V2 [opnpc1_v2] npc=(index=0, id=3308)", result.get("packet").asText()) + assertEquals("prefix", result.get("proof").asText()) + } + + @Test + fun `an interaction the client sent no packet for fails as dropped in the world and is noted for a widget`() { + connect() + logIn() + answer = { args -> + if (args.get("target").asText() == "npc") performed("npc", "OPNPC") else performed("widget", "IF_BUTTON") + } + + // Each call waits three seconds for a packet, so the two wait side by side. + val npc = CompletableFuture.supplyAsync { server.error("client_interact", talkTo) } + val widget = server.callJson("client_interact", """{"target":"widget","widget":"558:7","option":"Toggle"}""") + + assertContains(npc.get(10, TimeUnit.SECONDS), "no OPNPC packet") + assertContains(npc.get(), "the action was dropped. Likely cause: the NPC left the client's view") + assertTrue(widget.get("packet").isNull, widget.toString()) + assertContains(widget.get("note").asText(), "sent no packet for it") + assertFalse(widget.has("proof"), widget.toString()) + } + + @Test + fun `a widget interaction is proven by the packet of a dialog option and by that of a close button`() { + connect() + logIn() + answer = { + clicked(312, 171) + sent("RESUME_PAUSEBUTTON", "[resume_pausebutton] com=219:1") + performed("widget", "IF_BUTTON") + } + + val option = server.callJson("client_interact", """{"target":"dialog","option":"2"}""") + + assertEquals("5 L1 T7 C RESUME_PAUSEBUTTON [resume_pausebutton] com=219:1", option.get("packet").asText()) + assertEquals("click", option.get("proof").asText()) + + answer = { + clicked(312, 171) + sent("CLOSE_MODAL", "[close_modal]") + performed("widget", "IF_BUTTON") + } + + val close = server.callJson("client_interact", """{"target":"widget","widget":"12:2","option":"Close"}""") + + assertEquals("7 L1 T7 C CLOSE_MODAL [close_modal]", close.get("packet").asText()) + assertEquals("click", close.get("proof").asText()) + } + + @Test + fun `an interaction on a login whose packets are not decoded returns unconfirmed without waiting`() { + connect() + logIn(decoded = false) + answer = { performed("npc", "OPNPC") } + + val result = server.callJson("client_interact", talkTo) + + assertTrue(result.get("packet").isNull, result.toString()) + assertContains(result.get("note").asText(), "not decoded, so the action could not be confirmed") + assertFalse(result.has("expect"), result.toString()) + } + + @Test + fun `packets are read as a meta line and one row each, filtered by cursor, prot, origin and text`() { + connect() + logIn() + sent("OPNPC1_V2", "[opnpc1_v2] npc=(index=0, id=3308)") + sent("IF_SETTEXT", "[if_settext] com=558:7, text=Hello there", Origin.SERVER, tick = 8) + sent("IF_BUTTONX", "[if_buttonx] com=558:7, op=1", tick = 8) + + assertEquals( + """ + {"next":6,"head":6,"dropped":0,"timedOut":false,"count":4} + 3 L1 T0 P LOGIN revision=235 world=301 + 4 L1 T7 C OPNPC1_V2 [opnpc1_v2] npc=(index=0, id=3308) + 5 L1 T8 S IF_SETTEXT [if_settext] com=558:7, text=Hello there + 6 L1 T8 C IF_BUTTONX [if_buttonx] com=558:7, op=1 + """.trimIndent(), + server.call("packets_read", """{"after":2}"""), + ) + + assertEquals(listOf("5", "6"), server.rows("""{"after":4}""").map { it.substringBefore(' ') }) + assertEquals(listOf("5"), server.rows("""{"prots":["if_settext"]}""").map { it.substringBefore(' ') }) + assertEquals(listOf("4", "6"), server.rows("""{"origin":"client"}""").map { it.substringBefore(' ') }) + assertEquals(listOf("5"), server.rows("""{"contains":"hello THERE"}""").map { it.substringBefore(' ') }) + } + + @Test + fun `a packet read that waits returns once a matching packet is appended and reports a timeout when none is`() { + connect() + logIn() + val read = """{"after":3,"contains":"farewell","wait_ms":60000}""" + val waiting = CompletableFuture.supplyAsync { server.call("packets_read", read) } + + sent("IF_SETTEXT", "[if_settext] com=558:7, text=Hello there", Origin.SERVER) + sent("IF_SETTEXT", "[if_settext] com=558:7, text=Farewell", Origin.SERVER) + + assertEquals( + """ + {"next":5,"head":5,"dropped":0,"timedOut":false,"count":1} + 5 L1 T7 S IF_SETTEXT [if_settext] com=558:7, text=Farewell + """.trimIndent(), + waiting.get(10, TimeUnit.SECONDS), + ) + + assertEquals( + """{"next":5,"head":5,"dropped":0,"timedOut":true,"count":0}""", + server.call("packets_read", """{"after":5,"wait_ms":50}"""), + ) + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolServer.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolServer.kt new file mode 100644 index 000000000..6f2591d80 --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolServer.kt @@ -0,0 +1,98 @@ +package net.rsprox.mcp.server + +import com.fasterxml.jackson.databind.JsonNode +import net.rsprox.mcp.bridge.Gate +import net.rsprox.mcp.bridge.TestHub +import net.rsprox.mcp.packets.TapSettingSetStore +import net.rsprox.mcp.session.FakeLauncher +import net.rsprox.mcp.session.SessionManager +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The tools over a fake launcher and a started bridge hub, called over HTTP the way an MCP client calls them. + * [serve] starts the endpoint over the sessions and returns its port with what stops it. The port is a + * free one unless a test says otherwise. The sessions act on clients through [gate]. + */ +internal class ToolServer( + val gate: Gate = Gate(), + serve: (SessionManager) -> Pair = { sessions -> + val server = McpHttpServer(0, tools { sessions }, "1.2.3").also { it.start() } + + server.localPort to server + }, +) : AutoCloseable { + val launcher = FakeLauncher() + val hub = TestHub() + val manager = SessionManager(launcher, TapSettingSetStore, hub.hub, gate = gate) + private val served = serve(manager) + private val http = HttpClient.newHttpClient() + + val port: Int get() = served.first + + /** Sends one JSON-RPC request and returns its `result`. */ + fun rpc( + method: String, + params: String, + ): JsonNode { + val body = """{"jsonrpc":"2.0","id":1,"method":"$method","params":$params}""" + val request = + HttpRequest + .newBuilder(URI("http://127.0.0.1:$port/mcp")) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build() + + val response = http.send(request, HttpResponse.BodyHandlers.ofString()) + + return McpDispatcher.MAPPER.readTree(response.body()).get("result") + } + + /** Calls a tool that must succeed and returns its text. */ + fun call( + tool: String, + arguments: String = "{}", + ): String { + val (text, isError) = toolResult(tool, arguments) + assertFalse(isError, text) + + return text + } + + /** Calls a tool that must succeed and returns its text as JSON. */ + fun callJson( + tool: String, + arguments: String = "{}", + ): JsonNode = McpDispatcher.MAPPER.readTree(call(tool, arguments)) + + /** Calls a tool that must fail and returns its message. */ + fun error( + tool: String, + arguments: String, + ): String { + val (text, isError) = toolResult(tool, arguments) + assertTrue(isError, text) + + return text + } + + /** Reads packets and returns their lines, without the line of meta. */ + fun rows(arguments: String): List = call("packets_read", arguments).lines().drop(1) + + override fun close() { + served.second.close() + hub.close() + } + + private fun toolResult( + tool: String, + arguments: String, + ): Pair { + val result = rpc("tools/call", """{"name":"$tool","arguments":$arguments}""") + + return result.get("content").single().get("text").asText() to result.get("isError").asBoolean() + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/UnbridgedSessionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/UnbridgedSessionTest.kt new file mode 100644 index 000000000..c63597773 --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/UnbridgedSessionTest.kt @@ -0,0 +1,60 @@ +package net.rsprox.mcp.server + +import net.rsprox.mcp.session.FakeLauncher +import net.rsprox.mcp.session.loginHeader +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class UnbridgedSessionTest { + private val server = ToolServer() + + @AfterTest + fun cleanUp() { + server.close() + } + + @Test + fun `a client launched without the plugin is unbridged at once, unexpected by the hub, and keeps its packets`() { + server.launcher.bridged = false + + val started = server.callJson("session_start", """{"target":"My Server","wait_ms":10000}""") + + assertEquals("unbridged", started.get("state").asText()) + assertEquals( + "no session expects httpPort 43650", + server.hub.dial(FakeLauncher.FIRST_HTTP_PORT).read()?.get("reject")?.asText(), + ) + assertEquals( + "client_state is not available for session s1: its client was launched without the bridge plugin, " + + "which mcp.plugin in proxy.properties, or --no-plugin, turns off, so only its packets can be read", + server.error("client_state", "{}"), + ) + + server.launcher.monitor.forSession(loginHeader).onLogin(loginHeader) + + assertEquals( + listOf( + "1 L0 T0 P CLIENT_LAUNCHED generation=1 proxyPort=43751 httpPort=43650 plugin=none", + "2 L1 T0 P LOGIN revision=235 world=301", + ), + server.rows("{}"), + ) + } + + @Test + fun `an unbridged client that is closed stops its session, and session_stop kills one that still runs`() { + server.launcher.bridged = false + server.call("session_start", """{"target":"My Server","wait_ms":0}""") + server.call("session_start", """{"target":"My Server","wait_ms":0}""") + + server.manager.detach(FakeLauncher.FIRST_PROXY_PORT) + server.call("session_stop", """{"session":"s2"}""") + + assertEquals( + listOf("stopped: the client was closed", "stopped: stopped by caller"), + server.manager.list().map { "${it.state}: ${it.reason}" }, + ) + assertEquals(listOf(FakeLauncher.FIRST_PROXY_PORT, FakeLauncher.FIRST_PROXY_PORT + 1), server.launcher.killed) + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt new file mode 100644 index 000000000..0ce4bfeda --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt @@ -0,0 +1,88 @@ +package net.rsprox.mcp.session + +import net.rsprox.proxy.binary.BinaryHeader +import net.rsprox.proxy.target.ProxyTargetConfig +import net.rsprox.shared.SessionMonitor +import java.util.concurrent.CopyOnWriteArrayList + +internal fun target( + id: Int, + name: String, +): ProxyTargetConfig = + ProxyTargetConfig( + id = id, + name = name, + javConfigUrl = "http://127.0.0.1/jav_config.ws", + modulus = null, + varpCount = 5000, + revision = null, + runeliteBootstrapUrl = null, + runeliteBootstrapCommitHash = null, + runeliteGamepackUrl = null, + binaryFolder = null, + ) + +/** The header of a login to world 301, which the proxy hands to a session monitor. */ +internal val loginHeader = + BinaryHeader( + headerVersion = 1, + revision = 235, + subRevision = 1, + clientType = 1, + platformType = 1, + timestamp = 1_700_000_000_000, + worldId = 301, + worldFlags = 0, + worldLocation = 0, + worldHost = "127.0.1.3", + worldActivity = "", + localPlayerIndex = 7, + accountHash = ByteArray(0), + clientName = "RuneLite", + js5MasterIndex = ByteArray(0), + ) + +internal class FakeLauncher : ClientLauncher { + var targets = listOf(target(0, "Old School RuneScape"), target(1, "My Server")) + val reserved = ArrayList() + + // A client that goes away is killed from the bridge's reader thread. + val killed = CopyOnWriteArrayList() + var launch: () -> Unit = {} + + // Runs when the ports of a launch are reserved, before its client is forked. + var onReserve: () -> Unit = {} + var launcherExited: () -> Boolean = { false } + var bridged = true + + // The packet tap of the newest launch, which a test drives the way the proxy does. + lateinit var monitor: SessionMonitor + + override fun targets(): List = targets + + override fun reserve(target: ProxyTargetConfig): Reservation { + reserved += target + val earlier = reserved.size - 1 + onReserve() + + return Reservation( + FIRST_PROXY_PORT + earlier, + FIRST_HTTP_PORT + earlier, + { tap -> + monitor = tap + launch() + }, + { launcherExited() }, + bridged, + ) + } + + override fun kill(proxyPort: Int) { + killed += proxyPort + } + + companion object { + const val FIRST_PROXY_PORT = 43751 + const val FIRST_HTTP_PORT = 43650 + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt new file mode 100644 index 000000000..3969efe9d --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt @@ -0,0 +1,171 @@ +package net.rsprox.mcp.session + +import net.rsprox.mcp.bridge.Access +import net.rsprox.mcp.bridge.TestHub +import net.rsprox.mcp.bridge.awaitTrue +import net.rsprox.mcp.packets.PacketQuery +import net.rsprox.mcp.packets.TapSettingSetStore +import net.rsprox.mcp.server.McpDispatcher +import net.rsprox.mcp.server.ToolError +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class SessionManagerRecoveryTest { + private val launcher = FakeLauncher() + private val fixture = TestHub() + + @AfterTest + fun cleanUp() { + fixture.close() + } + + private fun manager( + launchTimeoutMs: Long = 60_000, + helloTimeoutMs: Long = 60_000, + ) = SessionManager(launcher, TapSettingSetStore, fixture.hub, launchTimeoutMs, helloTimeoutMs) + + private fun SessionManager.awaitStopped(session: String): SessionSnapshot { + awaitTrue("session $session is stopped") { list().single { it.session == session }.state == "stopped" } + + return list().single { it.session == session } + } + + private fun SessionManager.markers(session: String): List = + resolve(session).packets.read(PacketQuery()).packets.map { it.prot } + + private fun rejection(httpPort: Int): String? = fixture.dial(httpPort).read()?.get("reject")?.asText() + + @Test + fun `a client whose plugin never says hello is killed and its session stops with the reason`() { + val manager = manager(helloTimeoutMs = 100) + + val waited = manager.start(null, null, 10_000) + + assertEquals("stopped", waited.state) + assertEquals("the client started, but its bridge plugin never connected", waited.reason) + awaitTrue("the client is killed") { launcher.killed == listOf(43751) } + assertEquals(listOf("CLIENT_LAUNCHED", "CLIENT_EXITED"), manager.markers("s1")) + assertEquals("no session expects httpPort 43650", rejection(43650)) + assertEquals(2, manager.start(null, "s1", 0).generation) + } + + @Test + fun `a client whose plugin says hello in time stays connected after the deadline`() { + val manager = manager(helloTimeoutMs = 1_000) + manager.start(null, null, 0) + fixture.dial(FakeLauncher.FIRST_HTTP_PORT) + assertEquals("connected", manager.start(null, "s1", 10_000).state) + + // The deadlines pass in the order of the launches, so that of s1 has passed once s2 is stopped. + manager.start(null, null, 0) + manager.awaitStopped("s2") + + awaitTrue("only the client of s2 is killed") { launcher.killed == listOf(43752) } + assertEquals("connected", manager.list().first().state) + } + + @Test + fun `a hello of a stale plugin jar stops the session and says the jar is stale`() { + val manager = manager() + manager.start(null, null, 0) + + fixture.dial(43650, protocol = 1).read() + + assertEquals( + "the client started, but its bridge plugin was rejected: " + + "bridge protocol 1 is not supported; the installed plugin jar is stale", + manager.awaitStopped("s1").reason, + ) + } + + @Test + fun `a rejected hello that names the port of a connected session leaves that session alone`() { + val manager = manager() + manager.start(null, null, 0) + fixture.dial(43650) + assertEquals("connected", manager.start(null, "s1", 10_000).state) + + assertEquals("bad token", fixture.dial(43650, token = "not-the-token").read()?.get("reject")?.asText()) + assertEquals("no session expects httpPort 43650", rejection(43650)) + + assertEquals("connected", manager.list().single().state) + assertEquals(emptyList(), launcher.killed) + } + + @Test + fun `a hello that arrives after its session was stopped is rejected`() { + val manager = manager() + manager.start(null, null, 0) + manager.stop(null) + + assertEquals("no session expects httpPort 43650", rejection(43650)) + assertEquals("stopped by caller", manager.list().single().reason) + } + + @Test + fun `the hub expects no hello from any of several launches that failed`() { + val manager = manager() + launcher.launch = { throw IllegalStateException("proxy port could not be bound") } + + assertFailsWith { manager.start(null, null, 0) } + repeat(2) { assertFailsWith { manager.start(null, "s1", 0) } } + + assertEquals(3, launcher.reserved.size) + + for (httpPort in 43650..43652) { + assertEquals("no session expects httpPort $httpPort", rejection(httpPort)) + } + + assertEquals("proxy port could not be bound", manager.list().single().reason) + } + + @Test + fun `a launch whose launcher exits fails at once and leaves the other sessions usable`() { + val manager = manager() + manager.start(null, null, 0) + val plugin = fixture.dial(43650) + plugin.read() + assertEquals("connected", manager.start(null, "s1", 10_000).state) + plugin.serve { _, _ -> """"ok":{"tick":7}""" } + + val release = CountDownLatch(1) + launcher.launch = { release.await() } + launcher.launcherExited = { true } + + try { + val launch = CompletableFuture.supplyAsync { assertFailsWith { manager.start(null, null, 0) } } + + // The launch timeout is a minute; a launcher that is gone must not be waited for that long. + val error = launch.get(20, TimeUnit.SECONDS) + assertEquals( + "session s2 failed to launch: the launcher exited before completing its handshake", + error.message, + ) + + val stopped = manager.list().last() + assertEquals("stopped", stopped.state) + assertEquals("the launcher exited before completing its handshake", stopped.reason) + assertEquals(listOf(43752), launcher.killed) + assertEquals("no session expects httpPort 43651", rejection(43651)) + + val refused = assertFailsWith { manager.start(null, "s2", 0) } + assertTrue(refused.message!!.contains("restart the rsprox MCP process"), refused.message) + + assertEquals(listOf("connected", "stopped"), manager.list().map { it.state }) + assertEquals("connected", manager.start(null, "s1", 0).state) + val link = manager.resolve("s1").link("client_state", Access.READ) + val state = link.call("state", McpDispatcher.MAPPER.createObjectNode(), Access.READ, 10_000) + assertEquals(7, state.get("tick").asInt()) + assertEquals(listOf("CLIENT_LAUNCHED", "CLIENT_EXITED"), manager.markers("s2")) + assertEquals("stopped by caller", manager.stop("s1").reason) + } finally { + release.countDown() + } + } +} diff --git a/proxy/src/main/kotlin/net/rsprox/proxy/ClientListener.kt b/proxy/src/main/kotlin/net/rsprox/proxy/ClientListener.kt new file mode 100644 index 000000000..638295ea2 --- /dev/null +++ b/proxy/src/main/kotlin/net/rsprox/proxy/ClientListener.kt @@ -0,0 +1,23 @@ +package net.rsprox.proxy + +import net.rsprox.proxy.binary.BinaryHeader +import net.rsprox.proxy.target.ProxyTargetConfig +import net.rsprox.shared.SessionMonitor + +/** Hears of every RuneLite and native client that is launched with a session monitor, whoever launched it. */ +public interface ClientListener { + /** + * Called before the client on [port] is started. A monitor that is returned hears every callback + * of that client, after the monitor of whoever launched it. + */ + public fun onClientLaunch( + port: Int, + target: ProxyTargetConfig, + ): SessionMonitor? + + /** + * Called when the client on [port] was killed, failed to launch or exited. It may be called more + * than once for a client, and for a port that no client was launched on. + */ + public fun onClientClosed(port: Int) +} diff --git a/proxy/src/main/kotlin/net/rsprox/proxy/ProxyExtension.kt b/proxy/src/main/kotlin/net/rsprox/proxy/ProxyExtension.kt new file mode 100644 index 000000000..ee40c8186 --- /dev/null +++ b/proxy/src/main/kotlin/net/rsprox/proxy/ProxyExtension.kt @@ -0,0 +1,85 @@ +package net.rsprox.proxy + +import com.github.michaelbull.logging.InlineLogger +import java.util.ServiceConfigurationError +import java.util.ServiceLoader + +/** + * A part of rsprox that lives in another module and runs inside the process of the GUI. + * The GUI finds it through [ServiceLoader], so it does not depend on that module. + */ +public interface ProxyExtension { + /** Called once, after [service] has started. */ + public fun start(service: ProxyService) + + /** + * Serve what the extension offers to other programs, from this call on. Returns null once it is + * served, and otherwise the reason it is not, in words for the user. Called on the Swing thread. + */ + public fun serve(): String? = null + + /** Stop serving it, from this call on. Called on the Swing thread. */ + public fun stopServing() { + } + + public companion object { + private val logger = InlineLogger() + + /** Start every extension on the classpath. One that fails is logged and skipped. */ + public fun startAll(service: ProxyService): StartedExtensions { + val started = ArrayList() + try { + for (extension in ServiceLoader.load(ProxyExtension::class.java)) { + try { + extension.start(service) + started += extension + } catch (t: Throwable) { + logger.error(t) { "Unable to start ${extension.javaClass.name}" } + } + } + } catch (e: ServiceConfigurationError) { + logger.error(e) { "Unable to load the proxy extensions" } + } + return StartedExtensions(started) + } + } +} + +/** The extensions that started, which the GUI switches on and off while it runs. */ +public class StartedExtensions internal constructor( + private val extensions: List, +) { + /** Ask every extension to serve. Returns null once all do, and otherwise the reason of the first that does not. */ + public fun serve(): String? { + if (extensions.isEmpty()) { + return "Nothing is served, because no extension of RSProx is running. " + + "The log names one that failed to start." + } + for (extension in extensions) { + val refusal = + try { + extension.serve() + } catch (t: Throwable) { + logger.error(t) { "Unable to serve ${extension.javaClass.name}" } + "${extension.javaClass.name} failed: ${t.message}" + } + if (refusal != null) return refusal + } + return null + } + + /** Ask every extension to stop serving. One that fails is logged and skipped. */ + public fun stopServing() { + for (extension in extensions) { + try { + extension.stopServing() + } catch (t: Throwable) { + logger.error(t) { "Unable to stop serving ${extension.javaClass.name}" } + } + } + } + + private companion object { + private val logger = InlineLogger() + } +} diff --git a/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt b/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt index ada0db3db..b64df8bdf 100644 --- a/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt +++ b/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt @@ -34,6 +34,9 @@ import net.rsprox.proxy.config.ProxyProperty.Companion.BINARY_WRITE_INTERVAL_SEC import net.rsprox.proxy.config.ProxyProperty.Companion.BIND_TIMEOUT_SECONDS import net.rsprox.proxy.config.ProxyProperty.Companion.FILTERS_STATUS import net.rsprox.proxy.config.ProxyProperty.Companion.JAV_CONFIG_ENDPOINT +import net.rsprox.proxy.config.ProxyProperty.Companion.MCP_ENABLED +import net.rsprox.proxy.config.ProxyProperty.Companion.MCP_PLUGIN +import net.rsprox.proxy.config.ProxyProperty.Companion.MCP_PORT import net.rsprox.proxy.config.ProxyProperty.Companion.PROXY_PORT_MIN import net.rsprox.proxy.config.ProxyProperty.Companion.RUNELITE_RSPROX_CONNECTION import net.rsprox.proxy.config.ProxyProperty.Companion.SELECTED_CLIENT @@ -103,7 +106,9 @@ import java.nio.file.Path import java.time.Instant import java.util.* import java.util.concurrent.Callable +import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.ForkJoinPool import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger @@ -127,14 +132,13 @@ public class ProxyService( public lateinit var jagexAccountStore: JagexAccountStore private set public lateinit var filterSetStore: PropertyFilterSetStore - private set public lateinit var settingsStore: SettingSetStore - private set private var properties: ProxyProperties by Delegates.notNull() private var availablePort: Int = -1 private var initialPort: Int = -1 private val processes = ConcurrentHashMap>() private val connections: ProxyConnectionContainer = ProxyConnectionContainer() + private val clientListeners = CopyOnWriteArrayList() private lateinit var credentials: BinaryCredentialsStore private var rspsModulus: String? = null public lateinit var proxyTargets: List @@ -503,6 +507,23 @@ public class ProxyService( return properties.getPropertyOrNull(FILTERS_STATUS) ?: 0 } + public fun setMcpEnabled(enabled: Boolean) { + properties.setProperty(MCP_ENABLED, enabled) + properties.saveProperties(PROPERTIES_FILE) + } + + public fun getMcpEnabled(): Boolean { + return properties.getProperty(MCP_ENABLED) + } + + public fun getMcpPort(): Int { + return properties.getProperty(MCP_PORT) + } + + public fun getMcpPluginEnabled(): Boolean { + return properties.getProperty(MCP_PLUGIN) + } + public fun setSelectedClient(index: Int) { properties.setProperty(SELECTED_CLIENT, index) properties.saveProperties(PROPERTIES_FILE) @@ -599,6 +620,7 @@ public class ProxyService( public fun killAliveProcess(port: Int) { removeSessionMonitor(port) + notifyClientClosed(port) val processList = processes.remove(port) ?: return for (process in processList) { try { @@ -726,14 +748,61 @@ public class ProxyService( logger.error(t) { "Unable to bind network port $port for native client." } return } - this.connections.addSessionMonitor(port, sessionMonitor) - ClientTypeDictionary[port] = "RuneLite (${operatingSystem.shortName})" - launchJavaProcess( - port, - operatingSystem, - character, - target, - ) + launchObserved(port) { + this.connections.addSessionMonitor(port, observed(sessionMonitor, port, target)) + ClientTypeDictionary[port] = "RuneLite (${operatingSystem.shortName})" + launchJavaProcess( + port, + operatingSystem, + character, + target, + ) + } + } + + public fun addClientListener(listener: ClientListener) { + clientListeners += listener + } + + private fun observed( + sessionMonitor: SessionMonitor, + port: Int, + target: ProxyTarget, + ): SessionMonitor { + val observers = + clientListeners.mapNotNull { listener -> + try { + listener.onClientLaunch(port, target.config) + } catch (t: Throwable) { + logger.error(t) { "Client listener failed for the launch on port $port" } + null + } + } + if (observers.isEmpty()) return sessionMonitor + return FanOutSessionMonitor(sessionMonitor, observers) + } + + private inline fun launchObserved( + port: Int, + launch: () -> Unit, + ) { + try { + launch() + } catch (t: Throwable) { + notifyClientClosed(port) + throw t + } + whenAllExit(processes[port].orEmpty()) { notifyClientClosed(port) } + } + + private fun notifyClientClosed(port: Int) { + for (listener in clientListeners) { + try { + listener.onClientClosed(port) + } catch (t: Throwable) { + logger.error(t) { "Client listener failed for the close on port $port" } + } + } } public fun loadReplaySession(path: Path): ReplaySession = @@ -860,12 +929,15 @@ public class ProxyService( return port - this.initialPort } - public fun initializeHttpServer(port: Int): ProxyTarget { + public fun initializeHttpServer( + port: Int, + config: ProxyTargetConfig = currentProxyTarget, + ): ProxyTarget { val sessionId = portOffset(port) val target = ProxyTarget( - currentProxyTarget, - GamePackProvider(currentProxyTarget.runeliteGamepackUrl), + config, + GamePackProvider(config.runeliteGamepackUrl), sessionId, ) target.load(properties, bootstrapFactory) @@ -949,16 +1021,18 @@ public class ProxyService( logger.error(t) { "Unable to bind network port $port for native client." } return } - launchNativeClientProcess( - os = os, - rsa = rsa, - character = character, - port = port, - target = target, - clientTypeLabel = "Native (${os.shortName})", - registerConnectionInfo = true, - sessionMonitor = sessionMonitor, - ) + launchObserved(port) { + launchNativeClientProcess( + os = os, + rsa = rsa, + character = character, + port = port, + target = target, + clientTypeLabel = "Native (${os.shortName})", + registerConnectionInfo = true, + sessionMonitor = observed(sessionMonitor, port, target), + ) + } } public fun launchRs3Client( @@ -1647,3 +1721,23 @@ public class ProxyService( } } } + +/** + * Run [action] once every process of [processes] that is alive now, and every descendant of it, has exited. + * Returns false, and never runs [action], when none of them is alive. + */ +internal fun whenAllExit( + processes: List, + action: () -> Unit, +): Boolean { + val alive = + processes + .flatMap { process -> process.descendants().collect(Collectors.toList()) + process } + .distinct() + .filter { it.isAlive } + if (alive.isEmpty()) return false + CompletableFuture + .allOf(*alive.map { it.onExit() }.toTypedArray()) + .thenRun(action) + return true +} diff --git a/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperties.kt b/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperties.kt index 32e7c9530..554d13edd 100644 --- a/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperties.kt +++ b/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperties.kt @@ -6,6 +6,9 @@ import net.rsprox.proxy.config.ProxyProperty.Companion.APP_WIDTH import net.rsprox.proxy.config.ProxyProperty.Companion.BINARY_WRITE_INTERVAL_SECONDS import net.rsprox.proxy.config.ProxyProperty.Companion.BIND_TIMEOUT_SECONDS import net.rsprox.proxy.config.ProxyProperty.Companion.JAV_CONFIG_ENDPOINT +import net.rsprox.proxy.config.ProxyProperty.Companion.MCP_ENABLED +import net.rsprox.proxy.config.ProxyProperty.Companion.MCP_PLUGIN +import net.rsprox.proxy.config.ProxyProperty.Companion.MCP_PORT import net.rsprox.proxy.config.ProxyProperty.Companion.PROXY_PORT_MIN import net.rsprox.proxy.config.ProxyProperty.Companion.RUNELITE_RSPROX_CONNECTION import net.rsprox.proxy.config.ProxyProperty.Companion.WORLDLIST_ENDPOINT @@ -93,6 +96,10 @@ public value class ProxyProperties private constructor( properties.setValue(APP_THEME, "RuneLite") properties.setValue(APP_WIDTH, 800) properties.setValue(APP_HEIGHT, 600) + // mcp + properties.setValue(MCP_ENABLED, false) + properties.setValue(MCP_PORT, DEFAULT_MCP_PORT) + properties.setValue(MCP_PLUGIN, true) // other properties.setValue(RUNELITE_RSPROX_CONNECTION, false) return properties diff --git a/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperty.kt b/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperty.kt index 1b6a6779f..e592c896f 100644 --- a/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperty.kt +++ b/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperty.kt @@ -25,7 +25,15 @@ public class ProxyProperty( val SELECTED_CLIENT = ProxyProperty("app.client", IntProperty) val SELECTED_PROXY_TARGET = ProxyProperty("app.target", IntProperty) + // mcp + val MCP_ENABLED = ProxyProperty("mcp.enabled", BooleanProperty) + val MCP_PORT = ProxyProperty("mcp.port", IntProperty) + val MCP_PLUGIN = ProxyProperty("mcp.plugin", BooleanProperty) + // other val RUNELITE_RSPROX_CONNECTION = ProxyProperty("runelite.rsprox.connection", BooleanProperty) } } + +/** The loopback port of the MCP endpoint, for the GUI and for the standalone MCP server alike. */ +public const val DEFAULT_MCP_PORT: Int = 43580 diff --git a/proxy/src/main/kotlin/net/rsprox/proxy/util/FanOutSessionMonitor.kt b/proxy/src/main/kotlin/net/rsprox/proxy/util/FanOutSessionMonitor.kt new file mode 100644 index 000000000..1978a9ccb --- /dev/null +++ b/proxy/src/main/kotlin/net/rsprox/proxy/util/FanOutSessionMonitor.kt @@ -0,0 +1,95 @@ +package net.rsprox.proxy.util + +import com.github.michaelbull.logging.InlineLogger +import net.rsprox.cache.api.CacheProvider +import net.rsprox.shared.SessionMonitor +import net.rsprox.shared.StreamDirection +import net.rsprox.shared.property.RootProperty +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Passes every callback to [primary] and then to each of [observers]. + * [primary] behaves as if it were the only monitor: what it throws is passed on. What an observer + * throws is ignored, so it can never keep a callback from another monitor. Each fan-out, the one of + * the client and the one of each login, logs only the first such failure. + */ +public class FanOutSessionMonitor( + private val primary: SessionMonitor, + private val observers: List>, +) : SessionMonitor { + private val failureLogged = AtomicBoolean() + + override fun forSession(header: T): SessionMonitor { + val primary = this.primary.forSession(header) + val observers = this.observers.mapNotNull { observer -> guarded { observer.forSession(header) } } + if (observers.isEmpty()) return primary + return FanOutSessionMonitor(primary, observers) + } + + override fun onPacketDirection(direction: StreamDirection) { + each { it.onPacketDirection(direction) } + } + + override fun onLogin(header: T) { + each { it.onLogin(header) } + } + + override fun onLogout(header: T) { + each { it.onLogout(header) } + } + + override fun onCacheUpdate(cacheProvider: CacheProvider) { + each { it.onCacheUpdate(cacheProvider) } + } + + override fun onIncomingBytesPerSecondUpdate(bytesPerLastSecond: Long) { + each { it.onIncomingBytesPerSecondUpdate(bytesPerLastSecond) } + } + + override fun onOutgoingBytesPerSecondUpdate(bytesPerLastSecond: Long) { + each { it.onOutgoingBytesPerSecondUpdate(bytesPerLastSecond) } + } + + override fun onNameUpdate(name: String) { + each { it.onNameUpdate(name) } + } + + override fun onUserInformationUpdate( + userId: Long, + userHash: Long, + ) { + each { it.onUserInformationUpdate(userId, userHash) } + } + + override fun onTranscribe( + cycle: Int, + property: RootProperty, + ) { + each { it.onTranscribe(cycle, property) } + } + + private inline fun each(call: (SessionMonitor) -> Unit) { + try { + call(primary) + } finally { + for (observer in observers) { + guarded { call(observer) } + } + } + } + + private inline fun guarded(call: () -> R): R? { + return try { + call() + } catch (t: Throwable) { + if (failureLogged.compareAndSet(false, true)) { + logger.error(t) { "A session observer failed. Its further failures are not logged." } + } + null + } + } + + private companion object { + private val logger = InlineLogger() + } +} diff --git a/proxy/src/test/kotlin/net/rsprox/proxy/ClientListenerTest.kt b/proxy/src/test/kotlin/net/rsprox/proxy/ClientListenerTest.kt new file mode 100644 index 000000000..dc743fb14 --- /dev/null +++ b/proxy/src/test/kotlin/net/rsprox/proxy/ClientListenerTest.kt @@ -0,0 +1,89 @@ +package net.rsprox.proxy + +import io.netty.buffer.ByteBufAllocator +import net.rsprox.proxy.binary.BinaryHeader +import net.rsprox.proxy.target.ProxyTargetConfig +import net.rsprox.shared.SessionMonitor +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +object WaitsForInput { + @JvmStatic + fun main(args: Array) { + System.`in`.read() + } +} + +class ClientListenerTest { + private class ClosedPorts( + private val failing: Boolean = false, + ) : ClientListener { + val closed = ArrayList() + + override fun onClientLaunch( + port: Int, + target: ProxyTargetConfig, + ): SessionMonitor? = null + + override fun onClientClosed(port: Int) { + closed += port + if (failing) throw IllegalStateException("cannot close") + } + } + + private fun waiting(): Process { + val java = ProcessHandle.current().info().command().get() + + return ProcessBuilder(java, "-cp", System.getProperty("java.class.path"), WaitsForInput::class.java.name).start() + } + + @Test + fun `killing the client on a port tells every listener that it closed, past one that throws`() { + val service = ProxyService(ByteBufAllocator.DEFAULT) + val failing = ClosedPorts(failing = true) + val later = ClosedPorts() + service.addClientListener(failing) + service.addClientListener(later) + + service.killAliveProcess(43701) + + assertEquals(listOf(43701), failing.closed) + assertEquals(listOf(43701), later.closed) + } + + @Test + fun `the exit of a process is reported once the last of the watched processes has exited`() { + val first = waiting() + val second = waiting() + val exited = CompletableFuture() + + try { + assertTrue(whenAllExit(listOf(first.toHandle(), second.toHandle())) { exited.complete(Unit) }) + + first.outputStream.close() + first.waitFor(20, TimeUnit.SECONDS) + assertFailsWith { exited.get(300, TimeUnit.MILLISECONDS) } + + second.outputStream.close() + exited.get(20, TimeUnit.SECONDS) + } finally { + first.destroyForcibly() + second.destroyForcibly() + } + } + + @Test + fun `processes that have all exited are not watched`() { + val process = waiting() + process.outputStream.close() + process.waitFor(20, TimeUnit.SECONDS) + + assertFalse(whenAllExit(listOf(process.toHandle())) { error("nothing is left to exit") }) + } +} diff --git a/proxy/src/test/kotlin/net/rsprox/proxy/ProxyExtensionTest.kt b/proxy/src/test/kotlin/net/rsprox/proxy/ProxyExtensionTest.kt new file mode 100644 index 000000000..a0cfc2d0b --- /dev/null +++ b/proxy/src/test/kotlin/net/rsprox/proxy/ProxyExtensionTest.kt @@ -0,0 +1,92 @@ +package net.rsprox.proxy + +import io.netty.buffer.ByteBufAllocator +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** Extensions that the test registers through `META-INF/services`. */ +class FailingExtension : ProxyExtension { + override fun start(service: ProxyService): Unit = throw IllegalStateException("cannot start") + + override fun serve(): String = error("an extension that failed to start is not asked to serve") + + override fun stopServing(): Unit = error("an extension that failed to start is not asked to stop serving") +} + +class RecordingExtension : ProxyExtension { + override fun start(service: ProxyService) { + started += service + } + + override fun serve(): String? { + asked += "serve" + + return refusal + } + + override fun stopServing() { + asked += "stop serving" + } + + companion object { + val started = ArrayList() + val asked = ArrayList() + var refusal: String? = null + } +} + +class ProxyExtensionTest { + @BeforeTest + fun reset() { + RecordingExtension.started.clear() + RecordingExtension.asked.clear() + RecordingExtension.refusal = null + } + + @Test + fun `an extension that fails to start does not keep the next one from starting`() { + val service = ProxyService(ByteBufAllocator.DEFAULT) + + ProxyExtension.startAll(service) + + assertEquals(listOf(service), RecordingExtension.started) + } + + @Test + fun `the started extensions are asked to serve and to stop serving, and one that failed to start is not`() { + val extensions = ProxyExtension.startAll(ProxyService(ByteBufAllocator.DEFAULT)) + + assertNull(extensions.serve()) + extensions.stopServing() + + assertEquals(listOf("serve", "stop serving"), RecordingExtension.asked) + } + + @Test + fun `serving reports why an extension cannot serve`() { + val extensions = ProxyExtension.startAll(ProxyService(ByteBufAllocator.DEFAULT)) + RecordingExtension.refusal = "127.0.0.1:43580 cannot be bound" + + assertEquals("127.0.0.1:43580 cannot be bound", extensions.serve()) + } + + @Test + fun `serving reports an extension that throws instead of throwing`() { + val extensions = StartedExtensions(listOf(FailingExtension())) + + assertEquals( + "net.rsprox.proxy.FailingExtension failed: an extension that failed to start is not asked to serve", + extensions.serve(), + ) + } + + @Test + fun `serving with no extension started reports that nothing is served`() { + assertEquals( + "Nothing is served, because no extension of RSProx is running. The log names one that failed to start.", + StartedExtensions(emptyList()).serve(), + ) + } +} diff --git a/proxy/src/test/kotlin/net/rsprox/proxy/config/ProxyPropertiesTest.kt b/proxy/src/test/kotlin/net/rsprox/proxy/config/ProxyPropertiesTest.kt new file mode 100644 index 000000000..88311a770 --- /dev/null +++ b/proxy/src/test/kotlin/net/rsprox/proxy/config/ProxyPropertiesTest.kt @@ -0,0 +1,62 @@ +package net.rsprox.proxy.config + +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals + +class ProxyPropertiesTest { + @Test + fun `a properties file from before the MCP endpoint reads it as off, at the default port`() { + val file = Files.createTempFile("proxy", ".properties") + Files.writeString(file, "app.theme=RuneLite\n") + + val properties = ProxyProperties(file) + + assertEquals(false, properties.getProperty(ProxyProperty.MCP_ENABLED)) + assertEquals(43580, properties.getProperty(ProxyProperty.MCP_PORT)) + } + + @Test + fun `a new properties file is written with the MCP endpoint off`() { + val file = Files.createTempDirectory("proxy").resolve("proxy.properties") + + assertEquals(false, ProxyProperties(file).getProperty(ProxyProperty.MCP_ENABLED)) + assertContains(Files.readAllLines(file), "mcp.enabled=false") + } + + @Test + fun `a properties file that turns the MCP endpoint on and moves it to another port is kept`() { + val file = Files.createTempFile("proxy", ".properties") + Files.writeString(file, "mcp.enabled=true\nmcp.port=43999\n") + + val properties = ProxyProperties(file) + + assertEquals(true, properties.getProperty(ProxyProperty.MCP_ENABLED)) + assertEquals(43999, properties.getProperty(ProxyProperty.MCP_PORT)) + } + + @Test + fun `an MCP endpoint that was turned on and saved is on when the file is read again`() { + val file = Files.createTempFile("proxy", ".properties") + Files.writeString(file, "app.theme=RuneLite\n") + val properties = ProxyProperties(file) + + properties.setProperty(ProxyProperty.MCP_ENABLED, true) + properties.saveProperties(file) + + assertEquals(true, ProxyProperties(file).getProperty(ProxyProperty.MCP_ENABLED)) + } + + @Test + fun `the bridge plugin is installed unless the file turns it off`() { + val file = Files.createTempFile("proxy", ".properties") + Files.writeString(file, "app.theme=RuneLite\n") + + assertEquals(true, ProxyProperties(file).getProperty(ProxyProperty.MCP_PLUGIN)) + + Files.writeString(file, "mcp.plugin=false\n") + + assertEquals(false, ProxyProperties(file).getProperty(ProxyProperty.MCP_PLUGIN)) + } +} diff --git a/proxy/src/test/kotlin/net/rsprox/proxy/util/FanOutSessionMonitorTest.kt b/proxy/src/test/kotlin/net/rsprox/proxy/util/FanOutSessionMonitorTest.kt new file mode 100644 index 000000000..741ba790e --- /dev/null +++ b/proxy/src/test/kotlin/net/rsprox/proxy/util/FanOutSessionMonitorTest.kt @@ -0,0 +1,135 @@ +package net.rsprox.proxy.util + +import net.rsprox.cache.api.CacheProvider +import net.rsprox.shared.SessionMonitor +import net.rsprox.shared.StreamDirection +import net.rsprox.shared.property.ChildProperty +import net.rsprox.shared.property.RootProperty +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame + +class FanOutSessionMonitorTest { + private class Recorder( + private val label: String = "", + private val failing: Boolean = false, + ) : SessionMonitor { + val calls = ArrayList() + val logins = ArrayList() + + private fun record(call: String) { + calls += label + call + if (failing) throw IllegalStateException("$label$call failed") + } + + override fun forSession(header: String): SessionMonitor { + record("forSession $header") + + return Recorder("$header:").also { logins += it } + } + + override fun onPacketDirection(direction: StreamDirection) = record("direction $direction") + + override fun onLogin(header: String) = record("login $header") + + override fun onLogout(header: String) = record("logout $header") + + override fun onCacheUpdate(cacheProvider: CacheProvider) = record("cache") + + override fun onIncomingBytesPerSecondUpdate(bytesPerLastSecond: Long) = record("in $bytesPerLastSecond") + + override fun onOutgoingBytesPerSecondUpdate(bytesPerLastSecond: Long) = record("out $bytesPerLastSecond") + + override fun onNameUpdate(name: String) = record("name $name") + + override fun onUserInformationUpdate( + userId: Long, + userHash: Long, + ) = record("user $userId $userHash") + + override fun onTranscribe( + cycle: Int, + property: RootProperty, + ) = record("transcribe $cycle ${property.prot}") + } + + private val packet = + object : RootProperty { + override val prot = "IF_SETTEXT" + override val children = mutableListOf>() + } + + private fun everyCallback(monitor: SessionMonitor) { + monitor.onLogin("a") + monitor.onUserInformationUpdate(1, 2) + monitor.onCacheUpdate { error("not read") } + monitor.onPacketDirection(StreamDirection.CLIENT_TO_SERVER) + monitor.onTranscribe(7, packet) + monitor.onNameUpdate("Alice") + monitor.onIncomingBytesPerSecondUpdate(3) + monitor.onOutgoingBytesPerSecondUpdate(4) + monitor.onLogout("a") + } + + private val everyCall = + listOf( + "login a", + "user 1 2", + "cache", + "direction CLIENT_TO_SERVER", + "transcribe 7 IF_SETTEXT", + "name Alice", + "in 3", + "out 4", + "logout a", + ) + + @Test + fun `every callback reaches the primary monitor and each observer, past an observer that throws`() { + val primary = Recorder() + val failing = Recorder(failing = true) + val later = Recorder() + + everyCallback(FanOutSessionMonitor(primary, listOf(failing, later))) + + assertEquals(everyCall, primary.calls) + assertEquals(everyCall, failing.calls) + assertEquals(everyCall, later.calls) + } + + @Test + fun `the callbacks of a login reach the login monitors, less that of an observer that fails to make one`() { + val primary = Recorder() + val failing = Recorder(failing = true) + val later = Recorder() + + everyCallback(FanOutSessionMonitor(primary, listOf(failing, later)).forSession("a")) + + assertEquals(listOf("forSession a"), primary.calls) + assertEquals(listOf("forSession a"), failing.calls) + assertEquals(listOf("forSession a"), later.calls) + assertEquals(everyCall.map { "a:$it" }, primary.logins.single().calls) + assertEquals(everyCall.map { "a:$it" }, later.logins.single().calls) + } + + @Test + fun `a login without a working observer is monitored by the login monitor of the primary alone`() { + val primary = Recorder() + + val login = FanOutSessionMonitor(primary, listOf(Recorder(failing = true))).forSession("a") + + assertSame(primary.logins.single(), login) + } + + @Test + fun `what the primary monitor throws is passed on after the observers heard the callback`() { + val observer = Recorder() + val monitor = FanOutSessionMonitor(Recorder(failing = true), listOf(observer)) + + val thrown = assertFailsWith { monitor.onNameUpdate("Alice") } + + assertEquals("name Alice failed", thrown.message) + assertEquals(listOf("name Alice"), observer.calls) + } +} diff --git a/proxy/src/test/resources/META-INF/services/net.rsprox.proxy.ProxyExtension b/proxy/src/test/resources/META-INF/services/net.rsprox.proxy.ProxyExtension new file mode 100644 index 000000000..a6bfe43c4 --- /dev/null +++ b/proxy/src/test/resources/META-INF/services/net.rsprox.proxy.ProxyExtension @@ -0,0 +1,2 @@ +net.rsprox.proxy.FailingExtension +net.rsprox.proxy.RecordingExtension diff --git a/runelite/rsprox-mcp-bridge/build.gradle.kts b/runelite/rsprox-mcp-bridge/build.gradle.kts new file mode 100644 index 000000000..ab2b84a3f --- /dev/null +++ b/runelite/rsprox-mcp-bridge/build.gradle.kts @@ -0,0 +1,50 @@ +import java.util.zip.ZipFile + +plugins { + id("java") +} + +repositories { + maven { + url = uri("https://repo.runelite.net") + content { + includeGroupByRegex("net\\.runelite.*") + } + } + mavenCentral() +} + +dependencies { + // The client provides its API, Gson, javax.inject and slf4j at runtime; the jar bundles nothing. + compileOnly(libs.runelite.client) +} + +tasks.withType().configureEach { + options.encoding = "UTF-8" + options.release.set(11) +} + +// The root build applies the Kotlin plugin to every subproject. A sideloaded plugin gets its own class +// loader, so anything that leaks into this jar would shadow or duplicate what the client already has. +val verifyJarContents by tasks.registering { + val jar = tasks.jar.flatMap { it.archiveFile } + inputs.file(jar) + doLast { + val own = "net/rsprox/mcpbridge/" + val foreign = + ZipFile(jar.get().asFile).use { zip -> + zip + .entries() + .asSequence() + .map { it.name } + .filterNot { it.startsWith(own) || own.startsWith(it) } + .filterNot { it == "META-INF/" || it == "META-INF/MANIFEST.MF" } + .toList() + } + check(foreign.isEmpty()) { "The bridge plugin jar must hold only its own classes, but found: $foreign" } + } +} + +tasks.check { + dependsOn(verifyJarContents) +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Access.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Access.java new file mode 100644 index 000000000..87c0abe3a --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Access.java @@ -0,0 +1,30 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonObject; +import java.io.File; + +/** What rsprox may do with this client: read it, or also drive it by sending it input. */ +enum Access { + /** Read the client. The ops that send input are never built for a connection with this access. */ + READ, + + /** Read the client and send it input. */ + DRIVE; + + /** The name of the home directory of the stock RuneLite client, which rsprox launches the official game with. */ + private static final String STOCK_HOME = ".runelite"; + + /** + * Decide the access from rsprox's welcome and the client's own home directory. Input is allowed + * only when the welcome grants it and this is not the stock client. rsprox launches the official + * game into the stock home and a custom server into {@code .rlcustom}, so the stock home is the + * client's own evidence that it plays the live game, whatever rsprox said. A welcome without the + * field grants nothing. + */ + static Access of(JsonObject welcome, File clientHome) { + boolean granted = welcome.has("access") && "drive".equals(welcome.get("access").getAsString()); + boolean stockClient = STOCK_HOME.equals(clientHome.getName()); + + return granted && !stockClient ? DRIVE : READ; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ActorTarget.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ActorTarget.java new file mode 100644 index 000000000..cbf3a9d72 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ActorTarget.java @@ -0,0 +1,120 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonObject; +import java.awt.Shape; +import java.util.EnumSet; +import java.util.Set; +import net.runelite.api.Actor; +import net.runelite.api.Client; +import net.runelite.api.MenuAction; +import net.runelite.api.MenuEntry; +import net.runelite.api.WorldView; +import net.runelite.api.coords.WorldPoint; + +/** + * An NPC or another player, named by its index in the client's list of its kind. Its menu entries + * carry that index as the identifier. + */ +final class ActorTarget extends Target { + /** The menu actions that perform an option on an NPC. */ + private static final Set NPC_FAMILY = EnumSet.of( + MenuAction.NPC_FIRST_OPTION, + MenuAction.NPC_SECOND_OPTION, + MenuAction.NPC_THIRD_OPTION, + MenuAction.NPC_FOURTH_OPTION, + MenuAction.NPC_FIFTH_OPTION, + MenuAction.EXAMINE_NPC); + + /** The menu actions that perform an option on a player. */ + private static final Set PLAYER_FAMILY = EnumSet.of( + MenuAction.PLAYER_FIRST_OPTION, + MenuAction.PLAYER_SECOND_OPTION, + MenuAction.PLAYER_THIRD_OPTION, + MenuAction.PLAYER_FOURTH_OPTION, + MenuAction.PLAYER_FIFTH_OPTION, + MenuAction.PLAYER_SIXTH_OPTION, + MenuAction.PLAYER_SEVENTH_OPTION, + MenuAction.PLAYER_EIGHTH_OPTION); + + /** Whether the actor is an NPC, and another player otherwise. */ + private final boolean npc; + + /** The index of the actor. */ + private final int index; + + /** The id of the NPC when it was found, which is unused for a player. */ + private final int id; + + /** The name of the actor when it was found. */ + private final String name; + + /** Create the target for the actor of the kind with the index, as found with the id and name. */ + private ActorTarget(boolean npc, int index, int id, String name, String option) { + super(option); + this.npc = npc; + this.index = index; + this.id = id; + this.name = name; + } + + /** Create the target for the NPC with the index, as found with the id and name. */ + static ActorTarget npc(int index, int id, String name, String option) { + return new ActorTarget(true, index, id, name, option); + } + + /** Create the target for the player with the index, as found with the name. */ + static ActorTarget player(int index, String name, String option) { + return new ActorTarget(false, index, -1, name, option); + } + + /** Get the words that name the actor. */ + @Override + String label() { + return (npc ? "npc " : "player ") + index + " (" + name + ")"; + } + + /** Describe the actor with its index, its id when it is an NPC, and its name. */ + @Override + JsonObject describe() { + JsonObject out = npc ? described("OPNPC", "npc") : described("OPPLAYER", "player"); + out.addProperty("index", index); + + if (npc) out.addProperty("id", id); + + out.addProperty("name", name); + out.addProperty("option", option); + + return out; + } + + /** Get the convex hull of the actor's model, or null while the client does not show it. */ + @Override + Shape shape(Client client) { + Actor actor = find(client); + + return actor == null ? null : actor.getConvexHull(); + } + + /** Determine if the entry is this option on the actor of this kind with this index. */ + @Override + boolean matches(MenuEntry entry, Client client) { + Set family = npc ? NPC_FAMILY : PLAYER_FAMILY; + + return family.contains(entry.getType()) && entry.getIdentifier() == index && sameOption(entry); + } + + /** Get the tile the actor stands on, or null while the client does not have it. */ + @Override + WorldPoint tile(Client client) { + Actor actor = find(client); + + return actor == null ? null : actor.getWorldLocation(); + } + + /** Find the actor in the client's list of its kind, or null while it is not there. */ + private Actor find(Client client) { + WorldView view = client.getTopLevelWorldView(); + + return npc ? view.npcs().byIndex(index) : view.players().byIndex(index); + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Aiming.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Aiming.java new file mode 100644 index 000000000..39ea42a49 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Aiming.java @@ -0,0 +1,42 @@ +package net.rsprox.mcpbridge; + +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.Shape; + +/** + * Picks the canvas point to aim at inside a clickable shape. The centre comes first, and later + * attempts move to other interior points, because another entity or an interface may cover a part + * of the shape. + */ +final class Aiming { + /** The positions to try within the bounds of a shape, as fractions of its width and height. */ + private static final double[][] POSITIONS = { + {0.5, 0.5}, {0.5, 0.25}, {0.5, 0.75}, {0.25, 0.5}, {0.75, 0.5}, + {0.25, 0.25}, {0.75, 0.75}, {0.25, 0.75}, {0.75, 0.25}, + }; + + /** Prevent the creation of instances. */ + private Aiming() { + // + } + + /** + * Get the point of the shape to aim at on the given attempt, or null when no tried point of the + * shape lies within the area. The attempt chooses which position comes first, so attempts differ. + */ + static Point point(Shape shape, Rectangle area, int attempt) { + Rectangle bounds = shape.getBounds().intersection(area); + if (bounds.isEmpty()) return null; + + for (int i = 0; i < POSITIONS.length; i++) { + double[] position = POSITIONS[(attempt - 1 + i) % POSITIONS.length]; + int x = bounds.x + (int) (bounds.width * position[0]); + int y = bounds.y + (int) (bounds.height * position[1]); + + if (shape.contains(x, y) && area.contains(x, y)) return new Point(x, y); + } + + return null; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java new file mode 100644 index 000000000..48611666b --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java @@ -0,0 +1,256 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.OptionalInt; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; +import net.runelite.client.RuneLite; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The socket to rsprox: one JSON object per line in each direction. A reader thread hands each request + * to a small worker pool, so a request that waits on the game never holds up the next one. The ops a + * connection can run are built once, from the access that rsprox's welcome and the client's own home + * directory allow, so a connection made for reading has no op that sends input. + */ +final class BridgeConnection implements AutoCloseable { + /** The logger of the connection. */ + private static final Logger log = LoggerFactory.getLogger(BridgeConnection.class); + + /** The version of the wire protocol that this plugin speaks. Version 2 has `access` in the welcome. */ + private static final int PROTOCOL = 2; + + /** The longest wait for rsprox to accept the socket. */ + private static final int CONNECT_TIMEOUT_MS = 2_000; + + /** The longest wait for rsprox to answer the hello. */ + private static final int HELLO_TIMEOUT_MS = 5_000; + + /** The number of requests that can run at the same time. */ + private static final int WORKERS = 4; + + /** The start of the argument in which rsprox hands a client its jav_config, up to the port. */ + private static final String JAV_CONFIG_PREFIX = "--jav_config=http://127.0.0.1:"; + + /** The socket to rsprox. */ + private final Socket socket; + + /** The source of the requests from rsprox. */ + private final BufferedReader reader; + + /** The sink of the replies, which each writer locks for the length of one line. */ + private final BufferedWriter writer; + + /** The JSON serializer, which writes nulls. */ + private final Gson gson; + + /** The ops that requests run. */ + private final Ops ops; + + /** Whether rsprox asked in its welcome for a client without the GPU plugin. */ + final boolean softwareRendering; + + /** Whether the connection has been closed. */ + private final AtomicBoolean closed = new AtomicBoolean(); + + /** The pool that runs the requests. */ + private ExecutorService workers; + + /** Create a connection over a socket that rsprox has welcomed. */ + private BridgeConnection( + Socket socket, BufferedReader reader, BufferedWriter writer, Gson gson, Ops ops, boolean softwareRendering) { + this.softwareRendering = softwareRendering; + this.socket = socket; + this.reader = reader; + this.writer = writer; + this.gson = gson; + this.ops = ops; + } + + /** + * Connect to the rsprox that launched this client on {@code httpPort}, and build the ops for the + * access its welcome allows. Returns null, having started no thread, when there is no rsprox to + * talk to or it does not expect this client. + */ + static BridgeConnection dial(Path rendezvous, int httpPort, Gson clientGson, GameAccess game) { + if (!Files.isRegularFile(rendezvous)) return null; + + // The protocol spells out an absent player as null, which the client's Gson would drop. + Gson gson = clientGson.newBuilder().serializeNulls().create(); + Socket socket = new Socket(); + + try { + JsonObject file = gson.fromJson(Files.readString(rendezvous), JsonObject.class); + socket.connect( + new InetSocketAddress(InetAddress.getLoopbackAddress(), file.get("port").getAsInt()), + CONNECT_TIMEOUT_MS); + + socket.setSoTimeout(HELLO_TIMEOUT_MS); + BufferedReader reader = + new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); + + BufferedWriter writer = + new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8)); + + JsonObject hello = new JsonObject(); + hello.addProperty("hello", PROTOCOL); + hello.addProperty("httpPort", httpPort); + hello.add("token", file.get("token")); + hello.addProperty("pid", ProcessHandle.current().pid()); + writer.write(gson.toJson(hello)); + writer.write('\n'); + writer.flush(); + + String line = reader.readLine(); + JsonObject answer = line == null ? null : gson.fromJson(line, JsonObject.class); + + if (answer == null || !answer.has("welcome")) { + log.info("rsprox MCP bridge stays dormant: {}", answer == null ? "no answer" : answer.get("reject")); + socket.close(); + + return null; + } + + socket.setSoTimeout(0); + boolean software = answer.has("softwareRendering") && answer.get("softwareRendering").getAsBoolean(); + Access access = Access.of(answer, RuneLite.RUNELITE_DIR); + Ops ops = access == Access.DRIVE ? Ops.driving(game) : Ops.readOnly(game); + BridgeConnection connection = new BridgeConnection(socket, reader, writer, gson, ops, software); + connection.start(); + log.info("rsprox MCP bridge connected as session {} with {} access", answer.get("session"), access); + + return connection; + } catch (IOException | RuntimeException e) { + log.info("rsprox MCP bridge stays dormant: {}", e.toString()); + + try { + socket.close(); + } catch (IOException ignored) { + // Nothing was established, so there is nothing to report. + } + + return null; + } + } + + /** + * Get the port in the jav_config argument of a client's command line, which is how rsprox tells its + * clients apart. Empty when rsprox did not launch the client. + */ + static OptionalInt httpPort(String commandLine) { + int start = commandLine.indexOf(JAV_CONFIG_PREFIX); + if (start < 0) return OptionalInt.empty(); + + start += JAV_CONFIG_PREFIX.length(); + + int end = start; + while (end < commandLine.length() && Character.isDigit(commandLine.charAt(end))) { + end++; + } + + try { + return OptionalInt.of(Integer.parseInt(commandLine.substring(start, end))); + } catch (NumberFormatException e) { + return OptionalInt.empty(); + } + } + + /** Start the worker pool and the thread that reads requests. */ + private void start() { + workers = Executors.newFixedThreadPool(WORKERS, runnable -> daemon(runnable, "rsprox-mcp-bridge-worker")); + daemon(this::readLoop, "rsprox-mcp-bridge-reader").start(); + } + + /** Create a daemon thread, which never keeps the client alive. */ + private static Thread daemon(Runnable runnable, String name) { + Thread thread = new Thread(runnable, name); + thread.setDaemon(true); + + return thread; + } + + /** Hand each request to the workers until the connection ends, then close it. */ + private void readLoop() { + try { + String line; + + while ((line = reader.readLine()) != null) { + JsonObject request = gson.fromJson(line, JsonObject.class); + long id = request.get("id").getAsLong(); + String op = request.get("op").getAsString(); + JsonObject args = request.has("args") ? request.getAsJsonObject("args") : new JsonObject(); + workers.execute(() -> answer(id, op, args)); + } + } catch (IOException | RuntimeException e) { + if (!closed.get()) { + log.info("rsprox MCP bridge lost its connection: {}", e.toString()); + } + } finally { + close(); + } + } + + /** Run one request and write its reply, which carries {@code err} when the op failed. */ + private void answer(long id, String op, JsonObject args) { + JsonObject reply = new JsonObject(); + reply.addProperty("id", id); + + try { + reply.add("ok", ops.run(op, args)); + } catch (BridgeException e) { + reply.add("err", error(e.code, e.getMessage())); + } catch (Throwable t) { + log.warn("rsprox MCP bridge op {} failed", op, t); + reply.add("err", error("internal", t.toString())); + } + + try { + synchronized (writer) { + writer.write(gson.toJson(reply)); + writer.write('\n'); + writer.flush(); + } + } catch (IOException e) { + close(); + } + } + + /** Build the {@code err} member of a reply. */ + private static JsonObject error(String code, String message) { + JsonObject error = new JsonObject(); + error.addProperty("code", code); + error.addProperty("message", message); + + return error; + } + + /** Close the socket and stop the workers. Idempotent. */ + @Override + public void close() { + if (!closed.compareAndSet(false, true)) return; + + try { + socket.close(); + } catch (IOException ignored) { + // The connection is being dropped either way. + } + + workers.shutdownNow(); + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeDial.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeDial.java new file mode 100644 index 000000000..e0c73c92f --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeDial.java @@ -0,0 +1,74 @@ +package net.rsprox.mcpbridge; + +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * One attempt to reach rsprox, and the connection it makes. Dialling blocks for seconds, so it runs on a + * thread of its own. Whoever closes the dial also closes a connection that the attempt makes later. + */ +final class BridgeDial implements AutoCloseable { + /** Whether the dial has been closed. Guarded by this. */ + private boolean closed; + + /** The connection the attempt made, or null while there is none. Guarded by this. */ + private BridgeConnection connection; + + /** Create a dial that has not been attempted yet. */ + private BridgeDial() { + // + } + + /** + * Run {@code dial} on a daemon thread, and hand the connection it returns to {@code onConnected} on + * that thread. A dial that returns null, or that was closed in the meantime, hands over nothing. + */ + static BridgeDial start(Supplier dial, Consumer onConnected) { + BridgeDial attempt = new BridgeDial(); + Thread thread = new Thread(() -> attempt.run(dial, onConnected), "rsprox-mcp-bridge-dial"); + thread.setDaemon(true); + thread.start(); + + return attempt; + } + + /** Dial unless the dial was closed first, and keep the connection unless it was closed meanwhile. */ + private void run(Supplier dial, Consumer onConnected) { + if (isClosed()) return; + + BridgeConnection made = dial.get(); + if (made == null) return; + + if (!keep(made)) { + made.close(); + + return; + } + + onConnected.accept(made); + } + + /** Determine if the dial has been closed. */ + private synchronized boolean isClosed() { + return closed; + } + + /** Take ownership of the connection, and determine if the dial was still open to take it. */ + private synchronized boolean keep(BridgeConnection made) { + if (closed) return false; + + connection = made; + + return true; + } + + /** Close the connection, if there is one, and any that the attempt still makes. Idempotent. */ + @Override + public synchronized void close() { + closed = true; + + if (connection != null) { + connection.close(); + } + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeException.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeException.java new file mode 100644 index 000000000..8d07b6b3f --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeException.java @@ -0,0 +1,13 @@ +package net.rsprox.mcpbridge; + +/** A failure reported to rsprox as {@code err}. Codes: bad_args, not_found, wrong_state, timeout, internal. */ +final class BridgeException extends Exception { + /** The code that tells rsprox what kind of failure this is. */ + final String code; + + /** Create a failure with the given code and message. */ + BridgeException(String code, String message) { + super(message); + this.code = code; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Camera.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Camera.java new file mode 100644 index 000000000..a68e7fb14 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Camera.java @@ -0,0 +1,121 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import net.runelite.api.Client; +import net.runelite.api.Player; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; + +/** + * Reads and turns the camera. Reading is the op {@code camera}; the turn, which is input, is the op + * {@code camera_turn}. A turn waits until the camera has settled, within a bound, because the client + * moves the camera toward its target over several ticks. + */ +final class Camera implements Ops.Op { + /** The cycles over which neither the yaw nor the pitch may change for the camera to count as settled. */ + private static final int SETTLED_CYCLES = 10; + + /** The most cycles a turn is given to settle, which is six seconds. */ + private static final int TURN_CYCLES = 300; + + /** The gateway to the client and the threads it must be used on. */ + private final GameAccess game; + + /** Create the camera op for the given game. */ + Camera(GameAccess game) { + this.game = game; + } + + /** Report the camera's yaw and pitch without touching it. */ + JsonElement read(JsonObject args) throws BridgeException { + return game.onClientThread(client -> describe(client, null)); + } + + /** + * Turn the camera as asked, wait for it to settle, and report its yaw and pitch, with whether the + * looked-at target is on screen. + */ + @Override + public JsonElement run(JsonObject args) throws BridgeException { + String lookAt = Ops.optionalString(args, "look_at"); + Target target = lookAt == null ? null : locate(lookAt, args); + turn(args, target); + settle(); + + return game.onClientThread(client -> describe(client, target)); + } + + /** Find the target of the kind that look_at names, from the same fields as an interaction takes. */ + private Target locate(String kind, JsonObject args) throws BridgeException { + JsonObject named = args.deepCopy(); + named.addProperty("target", kind); + Targets.requireIdentity(named); + + return game.onClientThread(client -> Targets.locate(client, named)); + } + + /** Set the camera's targets: the yaw that faces the target, then the yaw and the pitch that are given. */ + private void turn(JsonObject args, Target target) throws BridgeException { + Integer yaw = Ops.optionalInt(args, "yaw"); + Integer pitch = Ops.optionalInt(args, "pitch"); + + game.onClientThread(client -> { + if (target != null) client.setCameraYawTarget(yawToward(client, target)); + + if (yaw != null) client.setCameraYawTarget(yaw); + + if (pitch != null) client.setCameraPitchTarget(pitch); + + return null; + }); + } + + /** Get the yaw that faces the target from the local player. Throws {@code wrong_state} while there is no player. */ + private static int yawToward(Client client, Target target) throws BridgeException { + Player local = client.getLocalPlayer(); + if (local == null) throw new BridgeException("wrong_state", "the client is not in the game"); + + WorldPoint tile = target.tile(client); + if (tile == null) throw new BridgeException("not_found", target.label() + " is not in the world"); + + LocalPoint there = LocalPoint.fromWorld(client.getTopLevelWorldView(), tile); + if (there == null) throw new BridgeException("not_found", target.label() + " is outside the loaded scene"); + + return CameraTurn.yawToward(local.getLocalLocation(), there); + } + + /** Wait until the yaw and the pitch have not changed for the settled number of cycles, within the bound. */ + private void settle() throws BridgeException { + int start = game.cycle(); + int stillSince = start; + int now = start; + long last = game.onClientThread(Camera::angles); + + while (now - stillSince < SETTLED_CYCLES && now - start < TURN_CYCLES) { + game.nextFrame(); + now = game.cycle(); + long angles = game.onClientThread(Camera::angles); + + if (angles != last) stillSince = now; + + last = angles; + } + } + + /** Get the yaw and the pitch packed into one value, so that a change in either shows. */ + private static long angles(Client client) { + return ((long) client.getCameraYaw() << Integer.SIZE) | client.getCameraPitch(); + } + + /** Describe the camera's yaw and pitch, and whether the target is on screen when one was looked at. */ + private static JsonObject describe(Client client, Target target) { + JsonObject out = new JsonObject(); + out.addProperty("yaw", client.getCameraYaw()); + out.addProperty("pitch", client.getCameraPitch()); + + if (target != null) out.addProperty("onScreen", target.aim(client, 1) != null); + + return out; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/CameraTurn.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/CameraTurn.java new file mode 100644 index 000000000..de07ccebf --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/CameraTurn.java @@ -0,0 +1,93 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonObject; +import net.runelite.api.Client; +import net.runelite.api.Player; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; + +/** + * Turns the camera toward a world tile: first the yaw that puts the tile in front of the camera, + * then, once the yaw has arrived and the tile is still not on screen, a middle pitch. Angles are in + * the client's unit, which has {@link #FULL_TURN} to a full turn. + */ +final class CameraTurn { + /** The number of angle units in a full turn. */ + private static final int FULL_TURN = 16384; + + /** The pitch that shows a good stretch of ground in front of the player. */ + private static final int PITCH_MIDDLE = 2048; + + /** The distance within which the yaw counts as having arrived at its target. */ + private static final int ARRIVED_WITHIN = 64; + + /** The yaw before the turn, or null while the camera has not been turned. */ + private Integer yawBefore; + + /** The yaw the camera was sent to, or null while it has not been turned. */ + private Integer yawTarget; + + /** The pitch before the camera was pitched, or null while it has not been. */ + private Integer pitchBefore; + + /** Get the yaw that puts the tile in front of the local player's camera. */ + static int yawToward(LocalPoint from, LocalPoint to) { + double radians = Math.atan2(from.getX() - to.getX(), to.getY() - from.getY()); + int units = (int) Math.round(radians / (2 * Math.PI) * FULL_TURN); + + return Math.floorMod(units, FULL_TURN); + } + + /** + * Turn the camera one step further toward the tile and say what was done. The first call sends + * the yaw; a later call, once the yaw has arrived, sends the pitch. Throws {@code wrong_state} + * while the client has no local player. + */ + String toward(Client client, WorldPoint tile) throws BridgeException { + Player local = client.getLocalPlayer(); + if (local == null) throw new BridgeException("wrong_state", "the client is not in the game"); + + if (yawTarget == null) return turnYaw(client, local, tile); + + if (pitchBefore == null && arrived(client)) return pitch(client); + + return "waiting for the camera, yaw " + client.getCameraYaw() + " of " + yawTarget; + } + + /** Send the camera to the yaw that faces the tile. */ + private String turnYaw(Client client, Player local, WorldPoint tile) { + LocalPoint there = LocalPoint.fromWorld(client.getTopLevelWorldView(), tile); + yawBefore = client.getCameraYaw(); + yawTarget = there == null ? yawBefore : yawToward(local.getLocalLocation(), there); + client.setCameraYawTarget(yawTarget); + + return "turned the camera from yaw " + yawBefore + " toward " + yawTarget; + } + + /** Send the camera to the middle pitch. */ + private String pitch(Client client) { + pitchBefore = client.getCameraPitch(); + client.setCameraPitchTarget(PITCH_MIDDLE); + + return "pitched the camera from " + pitchBefore + " toward " + PITCH_MIDDLE; + } + + /** Determine if the camera's yaw is at its target, give or take a little. */ + private boolean arrived(Client client) { + int away = Math.floorMod(client.getCameraYaw() - yawTarget, FULL_TURN); + + return Math.min(away, FULL_TURN - away) <= ARRIVED_WITHIN; + } + + /** Describe the turn as its yaw and pitch, each "before -> target", or null while the camera was not turned. */ + JsonObject describe() { + if (yawTarget == null) return null; + + JsonObject out = new JsonObject(); + out.addProperty("yaw", yawBefore + " -> " + yawTarget); + + if (pitchBefore != null) out.addProperty("pitch", pitchBefore + " -> " + PITCH_MIDDLE); + + return out; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ClickGuard.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ClickGuard.java new file mode 100644 index 000000000..dac743d9f --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ClickGuard.java @@ -0,0 +1,134 @@ +package net.rsprox.mcpbridge; + +import net.runelite.api.Client; +import net.runelite.api.events.MenuOptionClicked; + +/** + * Decides, on the click the client is about to perform, whether it is the intended one. It is armed + * just before a click of ours and stays armed until the client reports a click or the interaction + * gives up on it, so that a click that is not ours is never judged. + */ +final class ClickGuard { + /** What became of one attempt. */ + enum Outcome { + /** The client performed the intended action. */ + PERFORMED, + + /** The client performed nothing. */ + MISSED, + + /** The client was stopped from performing another entry at a point where it also offered the intended one. */ + ANOTHER_PREFERRED, + } + + /** The outcome of one attempt, with what happened in words. */ + static final class Verdict { + /** What became of the attempt. */ + final Outcome outcome; + + /** What happened, in a few words. */ + final String words; + + /** The client's tick count when the action was let through, or -1 when it was not. */ + final int tick; + + /** Create a verdict. */ + private Verdict(Outcome outcome, String words, int tick) { + this.outcome = outcome; + this.words = words; + this.tick = tick; + } + + /** Build the verdict of an attempt that the client performed on the given tick. */ + private static Verdict performed(String words, int tick) { + return new Verdict(Outcome.PERFORMED, words, tick); + } + + /** Build the verdict of an attempt that performed nothing. */ + static Verdict missed(String words) { + return new Verdict(Outcome.MISSED, words, -1); + } + + /** Determine if the client performed the intended action. */ + boolean isPerformed() { + return outcome == Outcome.PERFORMED; + } + + /** Get the same verdict with other words. */ + Verdict worded(String words) { + return new Verdict(outcome, words, tick); + } + } + + /** The target whose option the click must perform. */ + private final Target target; + + /** Whether a click of ours is pending. */ + private boolean armed; + + /** The verdict on the pending click, or null until the client reports one. */ + private Verdict verdict; + + /** Create the guard for the target. */ + ClickGuard(Target target) { + this.target = target; + } + + /** Expect a click of ours next. */ + synchronized void arm() { + armed = true; + verdict = null; + } + + /** Determine if the client has reported the pending click. */ + synchronized boolean isSettled() { + return verdict != null; + } + + /** + * Stop expecting a click, and get the verdict, or null when the client reported none. Called on + * the client thread, where the client reports its clicks, so that no report can slip in between. + */ + synchronized Verdict disarm() { + armed = false; + + return verdict; + } + + /** + * Judge the click the client is about to perform. The intended one is let through. Any other is + * consumed, which makes the client send nothing for it. + */ + synchronized void on(Client client, MenuOptionClicked event) { + if (!armed) return; + + armed = false; + verdict = judge(client, event); + } + + /** Let the intended click through and consume any other, and say which it was. */ + private Verdict judge(Client client, MenuOptionClicked event) { + String words = Offer.words(event.getMenuEntry()); + + if (event.isConsumed()) return Verdict.missed("another plugin consumed the click on " + words); + + if (target.matches(event.getMenuEntry(), client)) { + return Verdict.performed("the client performed " + words, client.getTickCount()); + } + + event.consume(); + + return cancelled(client, words); + } + + /** + * Build the verdict of a cancelled click. When the menu still holds the intended entry, the client + * preferred another entry to it at this point, which a click through the open menu gets around. + */ + private Verdict cancelled(Client client, String words) { + boolean offered = Offer.read(client, target, null).match >= 0; + Outcome outcome = offered ? Outcome.ANOTHER_PREFERRED : Outcome.MISSED; + + return new Verdict(outcome, "the click would have performed " + words + ", so it was cancelled", -1); + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Entities.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Entities.java new file mode 100644 index 000000000..7819a3e4e --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Entities.java @@ -0,0 +1,293 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import net.runelite.api.Client; +import net.runelite.api.NPC; +import net.runelite.api.NPCComposition; +import net.runelite.api.ObjectComposition; +import net.runelite.api.Player; +import net.runelite.api.Point; +import net.runelite.api.Tile; +import net.runelite.api.TileItem; +import net.runelite.api.TileObject; +import net.runelite.api.WorldView; +import net.runelite.api.coords.WorldPoint; + +/** Lists what is near the local player, with the options each thing offers. */ +final class Entities implements Ops.Op { + /** The kinds of entity, in the order the result lists them. */ + private static final List KINDS = List.of("npc", "object", "ground_item", "player"); + + /** The gateway to the client and the threads it must be used on. */ + private final GameAccess game; + + /** Create the listing for the given game. */ + Entities(GameAccess game) { + this.game = game; + } + + /** List the entities that pass the kind, radius and name filters, nearest first, up to the limit. */ + @Override + public JsonElement run(JsonObject args) throws BridgeException { + Set kinds = kinds(args); + int radius = args.get("radius").getAsInt(); + String name = Ops.optionalString(args, "name"); + String needle = name == null ? null : name.toLowerCase(Locale.ROOT); + int limit = args.get("limit").getAsInt(); + + return game.onClientThread(client -> { + Player local = client.getLocalPlayer(); + if (local == null) throw new BridgeException("wrong_state", "the client is not in the game"); + + Survey survey = new Survey(client, local, radius, needle); + + if (kinds.contains("npc")) survey.addNpcs(); + + if (kinds.contains("player")) survey.addPlayers(); + + if (kinds.contains("object") || kinds.contains("ground_item")) survey.addTiles(kinds); + + return survey.result(limit); + }); + } + + /** Get the kinds to list, which are all of them unless the arguments name some. */ + private static Set kinds(JsonObject args) { + Set kinds = new LinkedHashSet<>(); + + for (JsonElement kind : args.has("kinds") ? args.getAsJsonArray("kinds") : new JsonArray()) { + kinds.add(kind.getAsString()); + } + + return kinds.isEmpty() ? new LinkedHashSet<>(KINDS) : kinds; + } + + /** One entity that passed the filters. */ + private static final class Entry { + /** The order of a listing: the nearest first, then by index or id, then by tile, so that it is stable. */ + static final Comparator ORDER = Comparator.comparingInt(entry -> entry.distance) + .thenComparingInt(entry -> entry.key) + .thenComparingInt(entry -> entry.x) + .thenComparingInt(entry -> entry.y); + + /** The kind of the entity. */ + final String kind; + + /** The distance from the local player in tiles, where a diagonal step counts as one. */ + final int distance; + + /** The index or id that orders entities at the same distance. */ + final int key; + + /** The world x of the entity. */ + final int x; + + /** The world y of the entity. */ + final int y; + + /** The entity as the result lists it. */ + final JsonObject described; + + /** Create the record of one entity. */ + Entry(String kind, int distance, int key, int x, int y, JsonObject described) { + this.kind = kind; + this.distance = distance; + this.key = key; + this.x = x; + this.y = y; + this.described = described; + } + } + + /** Collects the entities around the local player on its plane, in the top-level world view. */ + private static final class Survey { + /** The entities collected so far. */ + private final List found = new ArrayList<>(); + + /** The game client. */ + private final Client client; + + /** The top-level world view. */ + private final WorldView view; + + /** The local player. */ + private final Player local; + + /** The tile of the local player. */ + private final WorldPoint origin; + + /** The greatest distance from the local player to keep. */ + private final int radius; + + /** The lower-case text a name must hold, or null to keep every name. */ + private final String needle; + + /** Create a survey around the local player with the given filters. */ + Survey(Client client, Player local, int radius, String needle) { + this.client = client; + this.view = client.getTopLevelWorldView(); + this.local = local; + this.origin = local.getWorldLocation(); + this.radius = radius; + this.needle = needle; + } + + /** Collect the NPCs that the client shows. */ + void addNpcs() { + for (NPC npc : view.npcs()) { + NPCComposition composition = npc.getTransformedComposition(); + if (composition == null) continue; + + WorldPoint at = npc.getWorldLocation(); + JsonObject described = new JsonObject(); + described.addProperty("index", npc.getIndex()); + described.addProperty("id", npc.getId()); + described.addProperty("name", Ops.nullToEmpty(composition.getName())); + described.addProperty("x", at.getX()); + described.addProperty("y", at.getY()); + described.add("options", strings(Scenes.offered(composition.getActions()))); + add("npc", npc.getIndex(), at.getX(), at.getY(), described); + } + } + + /** Collect the players other than the local one. */ + void addPlayers() { + JsonArray options = strings(Scenes.offered(client.getPlayerOptions())); + + for (Player player : view.players()) { + if (player == local) continue; + + WorldPoint at = player.getWorldLocation(); + JsonObject described = new JsonObject(); + described.addProperty("index", player.getId()); + described.addProperty("name", Ops.nullToEmpty(player.getName())); + described.addProperty("x", at.getX()); + described.addProperty("y", at.getY()); + described.add("options", options); + add("player", player.getId(), at.getX(), at.getY(), described); + } + } + + /** Collect the objects and the ground items of the asked kinds on every tile within the radius. */ + void addTiles(Set kinds) { + int centreX = origin.getX() - view.getBaseX(); + int centreY = origin.getY() - view.getBaseY(); + Tile[][] plane = view.getScene().getTiles()[view.getPlane()]; + + for (int x = Math.max(0, centreX - radius); x <= Math.min(view.getSizeX() - 1, centreX + radius); x++) { + for (int y = Math.max(0, centreY - radius); y <= Math.min(view.getSizeY() - 1, centreY + radius); y++) { + Tile tile = plane[x][y]; + if (tile == null) continue; + + if (kinds.contains("object")) addObjects(tile); + + if (kinds.contains("ground_item")) addGroundItems(tile); + } + } + } + + /** Collect the objects that have options and whose tile of origin is this tile, so each is listed once. */ + private void addObjects(Tile tile) { + Point at = tile.getSceneLocation(); + + for (TileObject object : Scenes.objects(tile)) { + if (!Scenes.origin(object, tile).equals(at)) continue; + + ObjectComposition shown = Scenes.shown(client, object.getId()); + List options = shown == null ? List.of() : Scenes.offered(shown.getActions()); + + if (options.isEmpty()) continue; + + JsonObject described = new JsonObject(); + described.addProperty("id", object.getId()); + described.addProperty("name", Ops.nullToEmpty(shown.getName())); + described.addProperty("x", worldX(at)); + described.addProperty("y", worldY(at)); + described.add("options", strings(options)); + add("object", object.getId(), worldX(at), worldY(at), described); + } + } + + /** Collect the items that lie on the tile. */ + private void addGroundItems(Tile tile) { + List items = tile.getGroundItems(); + if (items == null) return; + + Point at = tile.getSceneLocation(); + + for (TileItem item : items) { + JsonObject described = new JsonObject(); + described.addProperty("id", item.getId()); + described.addProperty("name", Ops.nullToEmpty(client.getItemDefinition(item.getId()).getName())); + described.addProperty("quantity", item.getQuantity()); + described.addProperty("x", worldX(at)); + described.addProperty("y", worldY(at)); + described.add("options", strings(Scenes.offered(Scenes.GROUND_ITEM_OPTIONS))); + add("ground_item", item.getId(), worldX(at), worldY(at), described); + } + } + + /** Keep the entity when its tile is within the radius and its name holds the needle. */ + private void add(String kind, int key, int x, int y, JsonObject described) { + int distance = Math.max(Math.abs(x - origin.getX()), Math.abs(y - origin.getY())); + if (distance > radius) return; + + String name = described.get("name").getAsString().toLowerCase(Locale.ROOT); + if (needle != null && !name.contains(needle)) return; + + found.add(new Entry(kind, distance, key, x, y, described)); + } + + /** Get the world x of the scene tile. */ + private int worldX(Point scene) { + return view.getBaseX() + scene.getX(); + } + + /** Get the world y of the scene tile. */ + private int worldY(Point scene) { + return view.getBaseY() + scene.getY(); + } + + /** Build the result from the nearest entities, up to the limit, with one list per kind. */ + JsonObject result(int limit) { + found.sort(Entry.ORDER); + JsonObject out = new JsonObject(); + JsonObject at = new JsonObject(); + at.addProperty("x", origin.getX()); + at.addProperty("y", origin.getY()); + at.addProperty("plane", origin.getPlane()); + out.add("origin", at); + + for (String kind : KINDS) { + out.add(kind + "s", new JsonArray()); + } + + for (Entry entity : found.subList(0, Math.min(limit, found.size()))) { + out.getAsJsonArray(entity.kind + "s").add(entity.described); + } + + out.addProperty("truncated", found.size() > limit); + + return out; + } + } + + /** Build a JSON array of the values. */ + private static JsonArray strings(List values) { + JsonArray out = new JsonArray(); + + for (String value : values) { + out.add(value); + } + + return out; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java new file mode 100644 index 000000000..373dc1f3c --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java @@ -0,0 +1,202 @@ +package net.rsprox.mcpbridge; + +import java.awt.Canvas; +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.image.BufferedImage; +import java.lang.reflect.InvocationTargetException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import javax.swing.SwingUtilities; +import net.runelite.api.Client; +import net.runelite.api.events.GameTick; +import net.runelite.client.eventbus.EventBus; +import net.runelite.client.ui.DrawManager; + +/** + * The only holder of the client, its canvas, the draw manager and the event bus. Each is handed out + * solely inside a callback that runs on the thread it belongs to, so an op cannot touch game state + * from a worker thread. + */ +final class GameAccess implements AutoCloseable { + /** The longest wait for the client thread or the renderer to answer. */ + private static final long TIMEOUT_MS = 5_000; + + /** The priority of the subscriptions, which is the default of the client's own plugins. */ + private static final float PRIORITY = 0; + + /** The game client, which is only handed out on its own thread. */ + private final Client client; + + /** The executor that runs a task on the client thread. */ + private final Executor clientThread; + + /** The source of rendered frames. */ + private final DrawManager drawManager; + + /** The bus that the client posts its events on. */ + private final EventBus eventBus; + + /** The subscription that keeps {@link #tickCycle} current, for as long as this access is open. */ + private final Subscription ticks; + + /** The client cycle at which the last game tick started, or the lowest value before the first. */ + private volatile int tickCycle = Integer.MIN_VALUE; + + /** Create the access to the given client, its thread, its renderer and its event bus. */ + GameAccess(Client client, Executor clientThread, DrawManager drawManager, EventBus eventBus) { + this.client = client; + this.clientThread = clientThread; + this.drawManager = drawManager; + this.eventBus = eventBus; + this.ticks = subscribe(GameTick.class, (game, tick) -> tickCycle = game.getGameCycle()); + } + + /** A read of the client, or an act on it, that runs on the client thread. */ + interface ClientCall { + /** Read from or act on the client, on the client thread. */ + T call(Client client) throws BridgeException; + } + + /** A registration for events, which lasts until it is closed. */ + interface Subscription extends AutoCloseable { + /** Stop receiving the events. Idempotent. */ + @Override + void close(); + } + + /** Run {@code body} on the client thread and wait for its result. */ + T onClientThread(ClientCall body) throws BridgeException { + CompletableFuture result = new CompletableFuture<>(); + clientThread.execute(() -> { + try { + result.complete(body.call(client)); + } catch (Throwable t) { + result.completeExceptionally(t); + } + }); + + return await(result, "the client thread"); + } + + /** Run {@code body} on the AWT event thread, where synthetic input events must be dispatched. */ + void input(Consumer body) throws BridgeException { + try { + SwingUtilities.invokeAndWait(() -> body.accept(client.getCanvas())); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new BridgeException("internal", "interrupted while dispatching input"); + } catch (InvocationTargetException e) { + throw new BridgeException("internal", String.valueOf(e.getCause())); + } + } + + /** Receive every event of the type, on the thread the client posts it from, until the subscription is closed. */ + Subscription subscribe(Class type, BiConsumer handler) { + EventBus.Subscriber subscriber = eventBus.register(type, event -> handler.accept(client, event), PRIORITY); + + return () -> eventBus.unregister(subscriber); + } + + /** Get the client cycle at which the last game tick started, or the lowest value before the first. */ + int tickCycle() { + return tickCycle; + } + + /** Get a copy of the next rendered frame, at whatever size the renderer produced it. */ + BufferedImage frame() throws BridgeException { + CompletableFuture result = new CompletableFuture<>(); + drawManager.requestNextFrameListener(image -> { + try { + // Copied on the render thread: the renderer may reuse the image for the next frame. + result.complete(copy(image)); + } catch (Throwable t) { + result.completeExceptionally(t); + } + }); + + return await(result, "a rendered frame"); + } + + /** + * Wait until the client has drawn its next frame. The listener takes no image, so the GPU plugin + * does not read the framebuffer back for it. + */ + void nextFrame() throws BridgeException { + CompletableFuture drawn = new CompletableFuture<>(); + Runnable listener = () -> drawn.complete(null); + drawManager.registerEveryFrameListener(listener); + + try { + await(drawn, "a rendered frame"); + } finally { + drawManager.unregisterEveryFrameListener(listener); + } + } + + /** + * Get the client's own cycle counter. It advances once per client cycle of 20 ms, whatever the + * frame rate, and the client handles the input of a cycle within that cycle. + */ + int cycle() throws BridgeException { + return onClientThread(Client::getGameCycle); + } + + /** + * Wait until the client's cycle counter has advanced by the given number, looking once per frame, + * and then for the frame that the client draws next. A frame is not a unit of time: with an + * unlocked frame rate several frames pass within one cycle. + */ + void awaitCycles(int cycles) throws BridgeException { + int until = cycle() + cycles; + + while (cycle() < until) { + nextFrame(); + } + + nextFrame(); + } + + /** Stop following the game ticks. */ + @Override + public void close() { + ticks.close(); + } + + /** Draw the image into a new one that the renderer does not own. */ + private static BufferedImage copy(Image image) { + BufferedImage copy = + new BufferedImage(image.getWidth(null), image.getHeight(null), BufferedImage.TYPE_INT_RGB); + + Graphics2D graphics = copy.createGraphics(); + graphics.drawImage(image, 0, 0, null); + graphics.dispose(); + + return copy; + } + + /** + * Wait for the result. Throws {@code timeout} when {@code what} does not answer in time, and the + * failure of the awaited work when it failed. + */ + private static T await(CompletableFuture result, String what) throws BridgeException { + try { + return result.get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (TimeoutException e) { + throw new BridgeException("timeout", "no answer from " + what + " within " + TIMEOUT_MS + " ms"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new BridgeException("internal", "interrupted while waiting for " + what); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof BridgeException) throw (BridgeException) cause; + + throw new BridgeException("internal", String.valueOf(cause)); + } + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GroundItemTarget.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GroundItemTarget.java new file mode 100644 index 000000000..3abec6828 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GroundItemTarget.java @@ -0,0 +1,93 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonObject; +import java.awt.Shape; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import net.runelite.api.Client; +import net.runelite.api.MenuAction; +import net.runelite.api.MenuEntry; +import net.runelite.api.Tile; +import net.runelite.api.TileItem; +import net.runelite.api.WorldView; +import net.runelite.api.coords.WorldPoint; + +/** + * An item on the ground, named by its id and its world tile. Its menu entries carry the id as the + * identifier and the scene tile as the parameters. + */ +final class GroundItemTarget extends Target { + /** The menu actions that perform an option on a ground item. */ + private static final Set FAMILY = EnumSet.of( + MenuAction.GROUND_ITEM_FIRST_OPTION, + MenuAction.GROUND_ITEM_SECOND_OPTION, + MenuAction.GROUND_ITEM_THIRD_OPTION, + MenuAction.GROUND_ITEM_FOURTH_OPTION, + MenuAction.GROUND_ITEM_FIFTH_OPTION, + MenuAction.EXAMINE_ITEM_GROUND); + + /** The id of the item. */ + private final int id; + + /** The name of the item. */ + private final String name; + + /** The world tile the item lies on. */ + private final WorldPoint at; + + /** Create the target for the item with the id on the world tile, named as the client names it. */ + GroundItemTarget(int id, String name, WorldPoint at, String option) { + super(option); + this.id = id; + this.name = name; + this.at = at; + } + + /** Get the words that name the item. */ + @Override + String label() { + return "ground item " + id + " (" + name + ")"; + } + + /** Describe the item with its id, name and world tile. */ + @Override + JsonObject describe() { + JsonObject out = described("OPOBJ", "ground_item"); + out.addProperty("id", id); + out.addProperty("name", name); + out.addProperty("x", at.getX()); + out.addProperty("y", at.getY()); + out.addProperty("option", option); + + return out; + } + + /** Get the outline of the item's tile, or null while the item is gone or the tile is not in front of the camera. */ + @Override + Shape shape(Client client) { + WorldView view = client.getTopLevelWorldView(); + Tile tile = Scenes.tile(view, at.getX(), at.getY()); + + if (tile == null) return null; + + List items = tile.getGroundItems(); + if (items == null || items.stream().noneMatch(item -> item.getId() == id)) return null; + + return Scenes.tilePolygon(client, view, at.getX() - view.getBaseX(), at.getY() - view.getBaseY()); + } + + /** Determine if the entry is this option on the item with this id on this tile. */ + @Override + boolean matches(MenuEntry entry, Client client) { + boolean sameTile = Scenes.isOnTile(entry, client, at); + + return FAMILY.contains(entry.getType()) && entry.getIdentifier() == id && sameTile && sameOption(entry); + } + + /** Get the world tile the item lies on. */ + @Override + WorldPoint tile(Client client) { + return at; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interact.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interact.java new file mode 100644 index 000000000..d91f862f3 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interact.java @@ -0,0 +1,31 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +/** + * Performs one option on one thing in the game as a player does: with the real mouse, through the + * menu the client builds for it. The client then sends what it sends for any player's click. + */ +final class Interact implements Ops.Op { + /** The gateway to the client and the threads it must be used on. */ + private final GameAccess game; + + /** The real mouse of the client. */ + private final Mouse mouse; + + /** Create the interactions for the given game, through the given mouse. */ + Interact(GameAccess game, Mouse mouse) { + this.game = game; + this.mouse = mouse; + } + + /** Perform the option on the target, or walk to the tile, and describe what the client did. */ + @Override + public JsonElement run(JsonObject args) throws BridgeException { + Targets.requireArguments(args); + Target target = game.onClientThread(client -> Targets.resolve(client, args)); + + return new Interaction(game, mouse, target).run(); + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interaction.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interaction.java new file mode 100644 index 000000000..56bb0a670 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interaction.java @@ -0,0 +1,366 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.awt.Point; +import java.util.ArrayDeque; +import java.util.Deque; +import net.rsprox.mcpbridge.ClickGuard.Outcome; +import net.rsprox.mcpbridge.ClickGuard.Verdict; +import net.rsprox.mcpbridge.GameAccess.Subscription; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.events.MenuOptionClicked; + +/** + * One interaction with a target, performed as a player performs it: the mouse is moved onto the + * target, the client builds its menu, and the entry is clicked. The click is a left click when the + * entry looks like the default one, and goes through the right-click menu otherwise, or once the + * client has shown that a left click performs another entry. Each attempt is judged by the + * {@link ClickGuard} and repeated until the client performs the intended action or the deadline + * passes. The loop runs on the worker thread of the op. Every wait is counted in client cycles of + * 20 ms, which pass at the same rate whatever the frame rate is. + */ +final class Interaction { + /** The cycles an interaction with a target that is on screen may take: about three game ticks. */ + private static final int ON_SCREEN_CYCLES = 90; + + /** The cycles an interaction may take once the camera had to be turned, which takes several ticks. */ + private static final int CAMERA_CYCLES = 300; + + /** The longest wait for a game tick to start, in cycles: a little over one tick. */ + private static final int TICK_WAIT_CYCLES = 40; + + /** The cycles since the start of a game tick within which the wait for the next one is skipped. */ + private static final int RECENT_TICK_CYCLES = 5; + + /** The least cycles the client is given to report a click, which it handles on the cycle after the press. */ + private static final int CLICK_CYCLES = 5; + + /** The least cycles the right-click menu is given to open after the press. */ + private static final int MENU_OPEN_CYCLES = 8; + + /** The cycles an opened menu is given to fill with its entries. */ + private static final int MENU_FILL_CYCLES = 1; + + /** The cycles a menu that lacks the entry is given to catch up with the pointer before it is read again. */ + private static final int MENU_LAG_CYCLES = 1; + + /** The number of last attempts whose words are reported. */ + private static final int KEPT = 5; + + /** The message when the client offers nothing at the aim, which an interface over that point causes. */ + private static final String BLOCKED = "the client offers only Cancel; an interface covers the game view there"; + + /** The number of covered aims in a row after which the camera is turned to move the target clear. */ + private static final int COVERED_BEFORE_TURN = 3; + + /** The words added to an attempt whose left click the client turned into another entry. */ + private static final String MENU_FROM_NOW = "; a left click performs another option here, so the menu is used next"; + + /** The gateway to the client and the threads it must be used on. */ + private final GameAccess game; + + /** The real mouse of the client. */ + private final Mouse mouse; + + /** The target and the option to perform on it. */ + private final Target target; + + /** The judge of each click. */ + private final ClickGuard guard; + + /** The turn of the camera, when the target is out of view. */ + private final CameraTurn camera = new CameraTurn(); + + /** The words of the last attempts, oldest first. */ + private final Deque tried = new ArrayDeque<>(); + + /** The number of attempts so far. */ + private int attempts; + + /** The number of reads of the menu. */ + private int reads; + + /** The number of reads at which the client offered more than Cancel. */ + private int offeredMore; + + /** The client cycle at which the interaction began. */ + private int began; + + /** The client cycle after which no further attempt is made. */ + private int deadline; + + /** Whether the client has shown that a left click on the target performs another entry than the intended one. */ + private boolean anotherPreferred; + + /** The number of aims in a row at which the client offered only Cancel. */ + private int covered; + + /** The canvas point of the press that the guard let through, or null until it let one through. */ + private Point pressed; + + /** Create the interaction that performs the target's option through the given mouse. */ + Interaction(GameAccess game, Mouse mouse, Target target) { + this.game = game; + this.mouse = mouse; + this.target = target; + this.guard = new ClickGuard(target); + } + + /** + * Perform the interaction and describe it: the target, the number of attempts, the tick on which + * the client performed the action, the canvas point that was pressed, the words of the last + * attempts and any turn of the camera. Throws {@code wrong_state} when the deadline passes first. + */ + JsonObject run() throws BridgeException { + try (Subscription clicks = game.subscribe(MenuOptionClicked.class, guard::on)) { + game.onClientThread(client -> { + Mouse.requireUnstretched(client); + + return null; + }); + + awaitTick(); + began = game.cycle(); + deadline = began + ON_SCREEN_CYCLES; + + while (true) { + attempts++; + Point aim = game.onClientThread(client -> target.aim(client, attempts)); + Verdict verdict = aim == null ? bringIntoView() : attempt(aim); + remember(verdict.words); + + if (verdict.isPerformed()) return result(verdict); + + if (game.cycle() >= deadline) throw expired(); + } + } + } + + /** + * Wait for the start of a game tick, so that positions are fresh and the action lands in a known + * tick, unless one started a moment ago. The wait is bounded, since a client that is logged out + * has no ticks. + */ + private void awaitTick() throws BridgeException { + int start = game.cycle(); + if (game.tickCycle() >= start - RECENT_TICK_CYCLES) return; + + while (game.tickCycle() < start && game.cycle() < start + TICK_WAIT_CYCLES) { + game.nextFrame(); + } + } + + /** + * Move the mouse onto the aim point, read the menu and click the intended entry. The menu may lag + * the pointer, so a missing entry is read once more before the attempt counts as missed. + */ + private Verdict attempt(Point aim) throws BridgeException { + mouse.hover(aim.x, aim.y); + Offer offer = read(aim); + + if (offer.match < 0) { + game.awaitCycles(MENU_LAG_CYCLES); + offer = read(aim); + } + + String at = " at " + Scenes.words(aim.x, aim.y); + + if (offer.match < 0 && offer.onlyCancel) return covered(offer, at); + + if (offer.match < 0) return missed(offer, offerWords(offer) + at); + + covered = 0; + + if (!offer.underMouse) return missed(offer, target.label() + " moved out from under the mouse" + at); + + if (offer.isLast() && !offer.open && !anotherPreferred) return leftClick(aim, offer.offered.get(offer.match)); + + return throughMenu(aim, offer); + } + + /** + * Left-click the aim point, where the entry looks like the default one. Only the client's report + * says what a left click performs: the client sorts its entries after the menu is read, and a + * plugin may swap them. When it performs another entry while it offers the intended one, the + * following attempts go through the right-click menu. + */ + private Verdict leftClick(Point aim, String entry) throws BridgeException { + Verdict verdict = click(aim, "left-clicked " + entry + " at " + Scenes.words(aim.x, aim.y)); + + if (verdict.outcome != Outcome.ANOTHER_PREFERRED) return verdict; + + anotherPreferred = true; + + return verdict.worded(verdict.words + MENU_FROM_NOW); + } + + /** + * Open the right-click menu on the aim point, or use the one that is open, and click the intended + * row. The row is found again in the menu as the client reports it after opening, since the + * entries may be reordered or added to while it opens. + */ + private Verdict throughMenu(Point aim, Offer offer) throws BridgeException { + if (!offer.open) { + mouse.click(aim.x, aim.y, Mouse.Button.RIGHT); + offer = awaitOpenMenu(); + } + + if (!offer.open) return missed(offer, "the menu did not open on " + offer.words()); + + if (offer.match < 0) return missed(offer, "the open menu offers " + offer.words()); + + Point row = offer.row(); + mouse.hover(row.x, row.y); + Offer hovered = read(null); + boolean same = hovered.open && hovered.match == offer.match; + + if (!same) return missed(hovered, "the menu changed to " + hovered.words() + " while the row was hovered"); + + String words = "clicked row " + offer.rowNumber() + ", " + offer.offered.get(offer.match); + + return click(row, words + " at " + Scenes.words(row.x, row.y)); + } + + /** + * Wait for the right-click menu to open and read it once it has filled. The client opens the menu + * on a cycle after the press and fills it on the one after that. + */ + private Offer awaitOpenMenu() throws BridgeException { + int until = game.cycle() + MENU_OPEN_CYCLES; + Offer offer = read(null); + + while (!offer.open && game.cycle() < until) { + game.nextFrame(); + offer = read(null); + } + + if (!offer.open) return offer; + + game.awaitCycles(MENU_FILL_CYCLES); + + return read(null); + } + + /** + * Left-click the point under the guard, and give the client its cycles to report the click. The + * guard stays armed for all of them, so a click that the client handles late is still judged. + */ + private Verdict click(Point at, String words) throws BridgeException { + guard.arm(); + mouse.click(at.x, at.y, Mouse.Button.LEFT); + + int until = game.cycle() + CLICK_CYCLES; + while (!guard.isSettled() && game.cycle() < until) { + game.nextFrame(); + } + + Verdict verdict = game.onClientThread(client -> guard.disarm()); + if (verdict == null) return unreported(words); + + if (verdict.isPerformed()) pressed = at; + + return verdict.worded(words + "; " + verdict.words); + } + + /** + * Build the verdict of a click that the client did not report, once the cycle in which the guard + * was disarmed has passed, so that the next click is never made in that same cycle. + */ + private Verdict unreported(String words) throws BridgeException { + game.awaitCycles(1); + + return Verdict.missed(words + "; the client reported no click within " + CLICK_CYCLES + " cycles"); + } + + /** Build the verdict of a missed attempt, after moving the mouse off an open menu so that it closes. */ + private Verdict missed(Offer offer, String words) throws BridgeException { + if (offer.open) { + Point away = offer.away(); + mouse.hover(away.x, away.y); + } + + return Verdict.missed(words); + } + + /** + * Read the menu the client has built, and whether the target still lies under the aim when one is + * given, and count the reads and those that offered more than Cancel. + */ + private Offer read(Point aim) throws BridgeException { + Offer offer = game.onClientThread(client -> Offer.read(client, target, aim)); + reads++; + + if (!offer.onlyCancel) offeredMore++; + + return offer; + } + + /** Write what the client offered, naming the blocking interface when it offered only Cancel. */ + private static String offerWords(Offer offer) { + return offer.onlyCancel ? BLOCKED : "the client offered " + offer.words(); + } + + /** + * Miss an aim at which the client offers only Cancel. After several such aims in a row, turn the + * camera toward a world target, since an interface such as the chatbox covers where it is drawn. + */ + private Verdict covered(Offer offer, String at) throws BridgeException { + Verdict verdict = missed(offer, BLOCKED + at); + covered++; + + if (covered < COVERED_BEFORE_TURN) return verdict; + + return turnToward(game.onClientThread(target::tile), BLOCKED + at); + } + + /** Turn the camera toward a target that has no clickable shape on screen. */ + private Verdict bringIntoView() throws BridgeException { + WorldPoint tile = game.onClientThread(target::tile); + game.awaitCycles(1); + + return turnToward(tile, "no part of " + target.label() + " is on screen"); + } + + /** + * Build the verdict of an attempt that missed with the words, after turning the camera toward the + * tile and allowing the longer deadline that turning needs, unless the target is not in the world. + */ + private Verdict turnToward(WorldPoint tile, String words) throws BridgeException { + if (tile == null) return Verdict.missed(words); + + deadline = Math.max(deadline, began + CAMERA_CYCLES); + + return Verdict.missed(words + "; " + game.onClientThread(client -> camera.toward(client, tile))); + } + + /** Keep the words of the attempt among the last few. */ + private void remember(String words) { + tried.addLast(words); + + if (tried.size() > KEPT) tried.removeFirst(); + } + + /** Build the result of the interaction that the client performed. */ + private JsonObject result(Verdict verdict) { + JsonObject out = target.describe(); + out.addProperty("attempts", attempts); + out.addProperty("tick", verdict.tick); + out.add("pressed", Ops.ints(pressed.x, pressed.y)); + JsonArray words = new JsonArray(); + tried.forEach(words::add); + out.add("tried", words); + + if (camera.describe() != null) out.add("camera", camera.describe()); + + return out; + } + + /** Build the failure for a deadline that passed, with what happened at the last attempt. */ + private BridgeException expired() { + String last = reads > 0 && offeredMore == 0 ? BLOCKED : tried.peekLast(); + String message = "aimed at " + target.label() + "; " + last + " at the last of " + attempts + " attempts"; + + return new BridgeException("wrong_state", message); + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java new file mode 100644 index 000000000..c5c720c6d --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java @@ -0,0 +1,140 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.Gson; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.OptionalInt; +import javax.inject.Inject; +import net.runelite.api.Client; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.eventbus.EventBus; +import net.runelite.client.plugins.Plugin; +import net.runelite.client.plugins.PluginDescriptor; +import net.runelite.client.plugins.PluginInstantiationException; +import net.runelite.client.plugins.PluginManager; +import net.runelite.client.ui.DrawManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The in-client half of the rsprox MCP server. The sideload directory is shared with clients that + * rsprox did not launch for an agent, so the plugin does nothing unless a running MCP server left a + * rendezvous file and welcomes this client. It is listed among the client's plugins, and turning it off + * there closes its connection. + */ +@PluginDescriptor( + name = "rsprox MCP bridge", + description = "Lets the rsprox MCP server read this client, and drive it on a private server. " + + "On the official game it only reads and never sends input.", + tags = {"rsprox", "mcp"} +) +public class McpBridgePlugin extends Plugin { + /** The logger of the plugin. */ + private static final Logger log = LoggerFactory.getLogger(McpBridgePlugin.class); + + /** The class name of the client's GPU plugin. */ + private static final String GPU_PLUGIN = "GpuPlugin"; + + /** The longest wait for the GPU plugin to take over rendering. */ + private static final long GPU_START_WAIT_MS = 60_000; + + /** The pause between two checks of whether the GPU plugin renders. */ + private static final long GPU_POLL_MS = 100; + + /** The game client. */ + @Inject private Client client; + + /** The runner of tasks on the client thread. */ + @Inject private ClientThread clientThread; + + /** The source of rendered frames. */ + @Inject private DrawManager drawManager; + + /** The bus that the client posts its events on. */ + @Inject private EventBus eventBus; + + /** The client's JSON serializer. */ + @Inject private Gson gson; + + /** The manager that starts and stops the client's plugins. */ + @Inject private PluginManager pluginManager; + + /** The access to the game that the ops share, or null while the plugin is dormant. */ + private GameAccess game; + + /** The dial to rsprox and the connection it made, or null while the plugin is dormant. */ + private BridgeDial dial; + + /** + * Connect to the rsprox that launched this client, and stay dormant when there is none. + * The client calls this on its UI thread, so the dial runs on a thread of its own. + */ + @Override + protected void startUp() { + OptionalInt httpPort = BridgeConnection.httpPort(System.getProperty("sun.java.command", "")); + if (httpPort.isEmpty()) return; + + // The server writes this path in McpMain.kt. + Path rendezvous = Paths.get(System.getProperty("user.home"), ".rsprox", "mcp", "bridge.json"); + GameAccess game = new GameAccess(client, clientThread::invoke, drawManager, eventBus); + this.game = game; + int port = httpPort.getAsInt(); + + dial = BridgeDial.start(() -> BridgeConnection.dial(rendezvous, port, gson, game), this::onConnected); + } + + /** Stop the GPU plugin when rsprox asked for software rendering. Runs on the thread of the dial. */ + private void onConnected(BridgeConnection connection) { + if (!connection.softwareRendering) return; + + stopGpuPlugin(); + } + + /** + * Stop the GPU plugin once it renders, so that screenshots show the game. + * Frames read back from the GPU plugin are black on a virtual display. Stopping the plugin, instead + * of disabling it, leaves the profile untouched for clients that share it. + */ + private void stopGpuPlugin() { + Plugin gpu = pluginManager.getPlugins().stream() + .filter(plugin -> plugin.getClass().getSimpleName().equals(GPU_PLUGIN)) + .reduce((first, last) -> last) + .orElse(null); + + if (gpu == null || !pluginManager.isPluginEnabled(gpu)) return; + + // The GPU plugin finishes starting on the client thread, some time after it is marked active. + // Stopping it before then lets that late start win, so wait until the client renders through it. + long deadline = System.currentTimeMillis() + GPU_START_WAIT_MS; + + try { + while (!(pluginManager.isPluginActive(gpu) && client.isGpu())) { + if (System.currentTimeMillis() >= deadline) { + log.info("The GPU plugin did not take over rendering, so there is nothing to stop"); + + return; + } + + Thread.sleep(GPU_POLL_MS); + } + + pluginManager.stopPlugin(gpu); + log.info("Stopped the GPU plugin for software rendering"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (PluginInstantiationException e) { + log.warn("Unable to stop the GPU plugin", e); + } + } + + /** Close the connection to rsprox, if there is one or the dial still makes one, and let go of the game. */ + @Override + protected void shutDown() { + if (dial == null) return; + + dial.close(); + dial = null; + game.close(); + game = null; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Mouse.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Mouse.java new file mode 100644 index 000000000..6718b133c --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Mouse.java @@ -0,0 +1,102 @@ +package net.rsprox.mcpbridge; + +import java.awt.event.InputEvent; +import java.awt.event.MouseEvent; +import net.runelite.api.Client; +import net.runelite.api.Point; + +/** The real mouse of the client: synthetic events on its canvas, which the client treats as a player's input. */ +final class Mouse { + /** The milliseconds by which the timestamp of a release follows that of its press. No time passes between them. */ + private static final int CLICK_HOLD_MS = 30; + + /** The cycles the client is given to take a new pointer position and build its menu for it. */ + private static final int FOLLOW_CYCLES = 2; + + /** The refusal of input in stretched mode. */ + private static final String STRETCHED = "stretched mode is on, in which a mouse position is not a canvas position"; + + /** A button of the mouse, with the names that AWT gives it. */ + enum Button { + /** The left button. */ + LEFT(MouseEvent.BUTTON1, InputEvent.BUTTON1_DOWN_MASK), + + /** The right button. */ + RIGHT(MouseEvent.BUTTON3, InputEvent.BUTTON3_DOWN_MASK); + + /** The button as a mouse event names it. */ + final int awt; + + /** The modifier that a press of the button holds. */ + final int downMask; + + /** Create the button with its event names. */ + Button(int awt, int downMask) { + this.awt = awt; + this.downMask = downMask; + } + } + + /** The gateway to the canvas and the thread it must be used on. */ + private final GameAccess game; + + /** Create the mouse of the given game. */ + Mouse(GameAccess game) { + this.game = game; + } + + /** + * Move the mouse to the canvas position. While the client tracks the pointer as off the canvas, it + * is first brought onto it, as happens when a pointer enters a window. + */ + private void move(int x, int y) throws BridgeException { + boolean outside = game.onClientThread(Mouse::pointerOutside); + game.input(canvas -> { + long now = System.currentTimeMillis(); + + if (outside) canvas.dispatchEvent(new MouseEvent(canvas, MouseEvent.MOUSE_ENTERED, now, 0, x, y, 0, false)); + + canvas.dispatchEvent(new MouseEvent(canvas, MouseEvent.MOUSE_MOVED, now, 0, x, y, 0, false)); + }); + } + + /** + * Move the mouse to the canvas position and wait until the client has built its menu for it. The + * client takes the position on its next cycle and builds the menu while it draws the frame after. + */ + void hover(int x, int y) throws BridgeException { + move(x, y); + game.awaitCycles(FOLLOW_CYCLES); + } + + /** Press and release the button at the canvas position, where the mouse must already be. */ + void click(int x, int y, Button button) throws BridgeException { + game.input(canvas -> { + long now = System.currentTimeMillis(); + long released = now + CLICK_HOLD_MS; + canvas.dispatchEvent( + new MouseEvent(canvas, MouseEvent.MOUSE_PRESSED, now, button.downMask, x, y, 1, false, button.awt)); + + canvas.dispatchEvent( + new MouseEvent(canvas, MouseEvent.MOUSE_RELEASED, released, 0, x, y, 1, false, button.awt)); + + canvas.dispatchEvent( + new MouseEvent(canvas, MouseEvent.MOUSE_CLICKED, released, 0, x, y, 1, false, button.awt)); + }); + } + + /** + * Check that canvas units are what the client's mouse handling expects. Throws {@code wrong_state} + * in stretched mode, where the canvas is drawn at another size than its coordinates. + */ + static void requireUnstretched(Client client) throws BridgeException { + if (client.isStretchedEnabled()) throw new BridgeException("wrong_state", STRETCHED); + } + + /** Determine if the client tracks the pointer as off the canvas, which it reports as a negative position. */ + private static boolean pointerOutside(Client client) { + Point tracked = client.getMouseCanvasPosition(); + + return tracked == null || tracked.getX() < 0 || tracked.getY() < 0; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ObjectTarget.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ObjectTarget.java new file mode 100644 index 000000000..9101cc9fe --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ObjectTarget.java @@ -0,0 +1,110 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonObject; +import java.awt.Shape; +import java.util.EnumSet; +import java.util.Set; +import net.runelite.api.Client; +import net.runelite.api.GameObject; +import net.runelite.api.MenuAction; +import net.runelite.api.MenuEntry; +import net.runelite.api.Point; +import net.runelite.api.Tile; +import net.runelite.api.TileObject; +import net.runelite.api.coords.WorldPoint; + +/** + * An object, named by its own id and its scene tile of origin. Its menu entries carry the id as the + * identifier and the tile as the parameters. + */ +final class ObjectTarget extends Target { + /** The menu actions that perform an option on an object. */ + private static final Set FAMILY = EnumSet.of( + MenuAction.GAME_OBJECT_FIRST_OPTION, + MenuAction.GAME_OBJECT_SECOND_OPTION, + MenuAction.GAME_OBJECT_THIRD_OPTION, + MenuAction.GAME_OBJECT_FOURTH_OPTION, + MenuAction.GAME_OBJECT_FIFTH_OPTION, + MenuAction.EXAMINE_OBJECT); + + /** The object's own id. */ + private final int id; + + /** The name the client shows the object as. */ + private final String name; + + /** The world tile of origin. */ + private final WorldPoint origin; + + /** Create the target for the object with the id at its world tile of origin, shown with the name. */ + ObjectTarget(int id, String name, WorldPoint origin, String option) { + super(option); + this.id = id; + this.name = name; + this.origin = origin; + } + + /** Get the words that name the object. */ + @Override + String label() { + return "object " + id + " (" + name + ")"; + } + + /** Describe the object with its id, name and world tile of origin. */ + @Override + JsonObject describe() { + JsonObject out = described("OPLOC", "object"); + out.addProperty("id", id); + out.addProperty("name", name); + out.addProperty("x", origin.getX()); + out.addProperty("y", origin.getY()); + out.addProperty("option", option); + + return out; + } + + /** + * Get the clickbox of the object, falling back to the hull of a game object and then to its tile, + * or null while the object is gone or none of them is in front of the camera. + */ + @Override + Shape shape(Client client) { + TileObject object = find(client); + if (object == null) return null; + + Shape clickbox = object.getClickbox(); + if (clickbox != null) return clickbox; + + Shape hull = object instanceof GameObject ? ((GameObject) object).getConvexHull() : null; + + return hull != null ? hull : object.getCanvasTilePoly(); + } + + /** Determine if the entry is this option on the object with this id at this tile. */ + @Override + boolean matches(MenuEntry entry, Client client) { + boolean sameTile = Scenes.isOnTile(entry, client, origin); + + return FAMILY.contains(entry.getType()) && entry.getIdentifier() == id && sameTile && sameOption(entry); + } + + /** Get the world tile of origin. */ + @Override + WorldPoint tile(Client client) { + return origin; + } + + /** Find the object on its tile of origin, or null while it is not there. */ + private TileObject find(Client client) { + Tile tile = Scenes.tile(client.getTopLevelWorldView(), origin.getX(), origin.getY()); + if (tile == null) return null; + + Point at = tile.getSceneLocation(); + + for (TileObject object : Scenes.objects(tile)) { + if (object.getId() == id && Scenes.origin(object, tile).equals(at)) return object; + } + + return null; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Offer.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Offer.java new file mode 100644 index 000000000..a05f7ddd0 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Offer.java @@ -0,0 +1,159 @@ +package net.rsprox.mcpbridge; + +import java.awt.Canvas; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.Shape; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import net.runelite.api.Client; +import net.runelite.api.Menu; +import net.runelite.api.MenuAction; +import net.runelite.api.MenuEntry; + +/** + * What the client's menu offered at one moment, read on the client thread and kept as plain values: + * the entries in the client's array order, whether the aimed-at target was still under the mouse, and + * the row geometry of the menu when it is open. A left click usually performs the last entry, but the + * client sorts the entries after this read and a plugin may swap them, so only the client's own + * report of a click says what it performed. + */ +final class Offer { + /** The height of the header that the client draws above the rows of its menu. */ + private static final int HEADER = 19; + + /** + * The height of a row of the client's menu, one per entry, drawn top to bottom in reverse array + * order. It is used when the height derived from the menu's height and its number of entries is + * not a sane one. + */ + private static final int ROW = 15; + + /** The least row height that counts as a sane derivation. */ + private static final int ROW_MIN = 10; + + /** The greatest row height that counts as a sane derivation. */ + private static final int ROW_MAX = 20; + + /** The distance from the edge of the canvas at which the mouse is parked to close a menu. */ + private static final int MARGIN = 2; + + /** Whether the menu was open. */ + final boolean open; + + /** The entries as "option target" with the colour tags stripped, in the client's array order. */ + final List offered; + + /** The index of the entry that performs the intended action, or -1 when none does. */ + final int match; + + /** Whether every entry is a cancel entry, as while an interface blocks the game view. */ + final boolean onlyCancel; + + /** Whether the target's outline still held the aimed-at point when the menu was read. */ + final boolean underMouse; + + /** The bounds of the open menu on the canvas. */ + private final Rectangle bounds; + + /** The number of rows the open menu is scrolled by. */ + private final int scroll; + + /** The size of the canvas. */ + private final Rectangle canvas; + + /** Create the offer of the entries, of which the one at the index performs the intended action. */ + private Offer(Client client, MenuEntry[] entries, int match, boolean underMouse) { + Menu menu = client.getMenu(); + Canvas surface = client.getCanvas(); + this.open = client.isMenuOpen(); + this.offered = words(entries); + this.match = match; + this.onlyCancel = Arrays.stream(entries).allMatch(entry -> entry.getType() == MenuAction.CANCEL); + this.underMouse = underMouse; + this.bounds = new Rectangle(menu.getMenuX(), menu.getMenuY(), menu.getMenuWidth(), menu.getMenuHeight()); + this.scroll = client.getMenuScroll(); + this.canvas = new Rectangle(0, 0, surface.getWidth(), surface.getHeight()); + } + + /** + * Read the menu the client has built, looking for the entry that performs the option on the + * target, and whether the target still lies under the aimed-at point, if one is given. + */ + static Offer read(Client client, Target target, Point aim) { + MenuEntry[] entries = client.getMenu().getMenuEntries(); + + return new Offer(client, entries, lastMatch(entries, target, client), isUnder(client, target, aim)); + } + + /** Get the index of the last entry that performs the target's option on the target, or -1 when none does. */ + private static int lastMatch(MenuEntry[] entries, Target target, Client client) { + for (int i = entries.length - 1; i >= 0; i--) { + if (target.matches(entries[i], client)) return i; + } + + return -1; + } + + /** Determine if the target's outline holds the aimed-at point, which a read without an aim takes as given. */ + private static boolean isUnder(Client client, Target target, Point aim) { + if (aim == null) return true; + + Shape shape = target.shape(client); + + return shape != null && shape.contains(aim); + } + + /** Write each entry as its option and its target with the colour tags stripped, in the given order. */ + private static List words(MenuEntry[] entries) { + return Arrays.stream(entries).map(entry -> words(entry)).collect(Collectors.toList()); + } + + /** Determine if the matching entry is the last of the array, which is the one a left click usually performs. */ + boolean isLast() { + return match >= 0 && match == offered.size() - 1; + } + + /** Get the canvas point in the vertical middle of the matching entry's row of the open menu. */ + Point row() { + int height = rowHeight(); + + return new Point(bounds.x + bounds.width / 2, bounds.y + HEADER + height * rowNumber() + height / 2); + } + + /** Get the row number of the matching entry, counted from the top of the open menu, where the first is 0. */ + int rowNumber() { + return offered.size() - 1 - match - scroll; + } + + /** Get the height of a row, derived from the menu's height when that gives a sane value. */ + private int rowHeight() { + int derived = offered.isEmpty() ? 0 : (bounds.height - HEADER) / offered.size(); + + return derived >= ROW_MIN && derived <= ROW_MAX ? derived : ROW; + } + + /** Get the corner of the canvas farthest from the menu, where the mouse is out of the menu and it closes. */ + Point away() { + boolean left = bounds.getCenterX() > canvas.getCenterX(); + boolean top = bounds.getCenterY() > canvas.getCenterY(); + + return new Point(left ? MARGIN : canvas.width - MARGIN, top ? MARGIN : canvas.height - MARGIN); + } + + /** Write the entries as a bracketed list, in the client's array order. */ + String words() { + return offered.toString(); + } + + /** Write the entry as its option and its target with the colour tags stripped. */ + static String words(MenuEntry entry) { + return (untagged(entry.getOption()) + " " + untagged(entry.getTarget())).trim(); + } + + /** Get the text without its colour and other tags, or an empty string for null. */ + static String untagged(String text) { + return Ops.nullToEmpty(text).replaceAll("<[^>]*>", ""); + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java new file mode 100644 index 000000000..ae5210914 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java @@ -0,0 +1,629 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import java.awt.Canvas; +import java.awt.Dimension; +import java.awt.Graphics2D; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.RenderingHints; +import java.awt.event.KeyEvent; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.Base64; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.function.IntFunction; +import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.imageio.ImageIO; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.api.MenuEntry; +import net.runelite.api.Player; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.widgets.Widget; + +/** + * The operations rsprox can ask for, by name. Coordinates are canvas units everywhere. The table is + * built once for the access of its connection: {@link #readOnly} holds only ops that read, and + * {@link #driving} adds the ops that send input, together with the one mouse they send it with. + */ +final class Ops { + /** The shape of a widget reference: a group, a child and an optional dynamic child index. */ + private static final Pattern WIDGET_REF = Pattern.compile("(\\d+):(\\d+)(?:\\[(\\d+)])?"); + + /** The message for a widget reference that does not have that shape. */ + private static final String WIDGET_REF_SHAPE = "widget must look like \"558:7\" or \"558:7[3]\""; + + /** The deepest level of nested widgets that a walk or a search descends to. */ + private static final int WIDGET_DEPTH_LIMIT = 12; + + /** The pause between two reads of the game state while a login is awaited. */ + private static final int LOGIN_POLL_MS = 100; + + /** The message for a login that put the client back on the login screen. */ + private static final String LOGIN_REFUSED = "the login was refused; the client is back on the login screen"; + + /** One operation that rsprox can ask for. */ + interface Op { + /** Run the op with the arguments that rsprox forwarded. */ + JsonElement run(JsonObject args) throws BridgeException; + } + + /** The gateway to the client and the threads it must be used on. */ + private final GameAccess game; + + /** The camera, which every connection reads and a driving one also turns. */ + private final Camera camera; + + /** The ops by the name rsprox calls them. */ + private final Map table = new HashMap<>(); + + /** The lock that an op holds for as long as it drives the one mouse and keyboard of the client. */ + private final Object input = new Object(); + + /** Create the table of the ops that only read the client. */ + private Ops(GameAccess game) { + this.game = game; + this.camera = new Camera(game); + reading("state", this::state); + reading("widgets", this::widgets); + reading("vars", this::vars); + reading("screenshot", this::screenshot); + reading("entities", new Entities(game)); + reading("camera", camera::read); + } + + /** Create the ops of a connection that may only read the client. Nothing in this table sends input. */ + static Ops readOnly(GameAccess game) { + return new Ops(game); + } + + /** + * Create the ops of a connection that may drive the client: the reading ops, plus those that send + * input. The mouse they send it with exists only here. + */ + static Ops driving(GameAccess game) { + Ops ops = new Ops(game); + Mouse mouse = new Mouse(game); + ops.driving("login", ops::login); + ops.driving("click", args -> ops.click(mouse, args)); + ops.driving("type", ops::type); + ops.driving("interact", new Interact(game, mouse)); + ops.driving("camera_turn", ops.camera); + + return ops; + } + + /** Register an op that produces no input, which runs beside any other op. */ + private void reading(String name, Op op) { + table.put(name, op); + } + + /** Register an op that produces input, which runs alone among such ops from its start to its end. */ + private void driving(String name, Op op) { + table.put(name, args -> alone(op, args)); + } + + /** + * Run the op while no other op produces input. Two ops that move the pointer at once would each + * click where the other left it, and the click guard of one would judge the click of the other. + */ + private JsonElement alone(Op op, JsonObject args) throws BridgeException { + synchronized (input) { + return op.run(args); + } + } + + /** Run the named op. Throws {@code bad_args} for a name that is not in the table. */ + JsonElement run(String op, JsonObject args) throws BridgeException { + Op handler = table.get(op); + if (handler == null) throw new BridgeException("bad_args", "unknown op '" + op + "'"); + + return handler.run(args); + } + + /** Describe the game state, the canvas, the local player and the right-click menu. */ + private JsonElement state(JsonObject args) throws BridgeException { + return game.onClientThread(client -> { + JsonObject out = new JsonObject(); + out.addProperty("gameState", client.getGameState().name()); + out.addProperty("tick", client.getTickCount()); + Canvas canvas = client.getCanvas(); + out.add("canvas", ints(canvas.getWidth(), canvas.getHeight())); + out.addProperty("world", client.getWorld()); + + Player player = client.getLocalPlayer(); + if (player == null) { + out.add("player", JsonNull.INSTANCE); + } else { + WorldPoint at = player.getWorldLocation(); + JsonObject described = new JsonObject(); + described.addProperty("name", player.getName()); + described.addProperty("x", at.getX()); + described.addProperty("y", at.getY()); + described.addProperty("plane", at.getPlane()); + out.add("player", described); + } + + JsonObject menu = new JsonObject(); + menu.addProperty("open", client.isMenuOpen()); + JsonArray entries = new JsonArray(); + + for (MenuEntry entry : client.getMenu().getMenuEntries()) { + JsonObject described = new JsonObject(); + described.addProperty("option", entry.getOption()); + described.addProperty("target", entry.getTarget()); + described.addProperty("type", entry.getType().name()); + described.addProperty("id", entry.getIdentifier()); + described.addProperty("p0", entry.getParam0()); + described.addProperty("p1", entry.getParam1()); + entries.add(described); + } + + menu.add("entries", entries); + out.add("menu", menu); + + return out; + }); + } + + /** List the widgets that pass the group, text and hidden filters, up to the limit. */ + private JsonElement widgets(JsonObject args) throws BridgeException { + Integer group = optionalInt(args, "group"); + String text = optionalString(args, "text"); + String needle = text == null ? null : text.toLowerCase(Locale.ROOT); + boolean includeHidden = optionalBoolean(args, "hidden"); + int limit = args.get("limit").getAsInt(); + + return game.onClientThread(client -> { + WidgetWalk walk = new WidgetWalk(group, needle, limit); + Set roots = new LinkedHashSet<>(); + + for (Widget root : client.getWidgetRoots()) { + if (root == null) continue; + + roots.add(root.getId() >>> 16); + + if (!walk.truncated) firstWidget(root, includeHidden, walk::collect, 0); + } + + JsonObject out = new JsonObject(); + JsonArray rootGroups = new JsonArray(); + + for (int root : roots) { + rootGroups.add(root); + } + + out.add("roots", rootGroups); + out.add("widgets", walk.found); + out.addProperty("truncated", walk.truncated); + + return out; + }); + } + + /** Lists the widgets that have something to read or click: text, a name or actions. */ + private static final class WidgetWalk { + /** The widgets collected so far. */ + final JsonArray found = new JsonArray(); + + /** Whether the limit cut the walk short. */ + boolean truncated; + + /** The interface group to keep, or null to keep every group. */ + private final Integer group; + + /** The lower-case text to look for, or null to keep every widget. */ + private final String needle; + + /** The most widgets to collect. */ + private final int limit; + + /** Create a walk with the given filters. */ + WidgetWalk(Integer group, String needle, int limit) { + this.group = group; + this.needle = needle; + this.limit = limit; + } + + /** + * Add the widget when it passes the filters, or mark the walk truncated once the limit is + * reached. Returns whether the walk is truncated, which ends it. + */ + boolean collect(Widget widget) { + if (group != null && widget.getId() >>> 16 != group) return false; + + JsonObject described = describe(widget, widget.isHidden()); + if (described == null) return false; + + truncated = found.size() >= limit; + + if (!truncated) found.add(described); + + return truncated; + } + + /** Describe the widget. Returns null when it has nothing to read or click, or does not hold the needle. */ + private JsonObject describe(Widget widget, boolean hidden) { + String text = nullToEmpty(widget.getText()); + String name = nullToEmpty(widget.getName()); + JsonArray actions = new JsonArray(); + String[] raw = widget.getActions(); + + for (String action : raw == null ? new String[0] : raw) { + if (action == null || action.isEmpty()) continue; + + actions.add(action); + } + + if (text.isEmpty() && name.isEmpty() && actions.size() == 0) return null; + + if (needle != null && !contains(text) && !contains(name) && !contains(actions.toString())) return null; + + Rectangle bounds = widget.getBounds(); + JsonObject out = new JsonObject(); + out.addProperty("id", ref(widget)); + out.addProperty("text", text); + out.addProperty("name", name); + out.add("actions", actions); + out.add("bounds", ints(bounds.x, bounds.y, bounds.width, bounds.height)); + out.add("click", ints(centre(bounds).x, centre(bounds).y)); + out.addProperty("type", widget.getType()); + out.addProperty("hidden", hidden); + + return out; + } + + /** Determine if the haystack holds the needle, ignoring case. */ + private boolean contains(String haystack) { + return haystack.toLowerCase(Locale.ROOT).contains(needle); + } + } + + /** + * Find the first widget under the root, itself included, that passes the test. A widget is tested + * before its static, its dynamic and its nested children, down to the depth limit, and a hidden + * widget is skipped with all under it unless hidden ones are included. Null when none passes. + */ + static Widget firstWidget(Widget root, boolean includeHidden, Predicate test, int depth) { + if (root == null || (root.isHidden() && !includeHidden) || depth > WIDGET_DEPTH_LIMIT) return null; + + if (test.test(root)) return root; + + Widget[] nested = root.getNestedChildren(); + + for (Widget[] children : Arrays.asList(root.getStaticChildren(), root.getDynamicChildren(), nested)) { + for (Widget child : children == null ? new Widget[0] : children) { + Widget found = firstWidget(child, includeHidden, test, depth + 1); + if (found != null) return found; + } + } + + return null; + } + + /** Read the requested player variables, varbits and client variables. */ + private JsonElement vars(JsonObject args) throws BridgeException { + int[] varps = optionalInts(args, "varps"); + int[] varbits = optionalInts(args, "varbits"); + int[] varcInts = optionalInts(args, "varcInts"); + int[] varcStrs = optionalInts(args, "varcStrs"); + + return game.onClientThread(client -> { + JsonObject out = new JsonObject(); + out.add("varps", readVars("varp", varps, id -> number(client.getVarpValue(id)))); + out.add("varbits", readVars("varbit", varbits, id -> number(client.getVarbitValue(id)))); + out.add("varcInts", readVars("varcInt", varcInts, id -> number(client.getVarcIntValue(id)))); + out.add("varcStrs", readVars("varcStr", varcStrs, id -> string(client.getVarcStrValue(id)))); + + return out; + }); + } + + /** Read each id through the reader, keyed by id. Throws {@code not_found} for an id the client does not have. */ + private static JsonObject readVars(String kind, int[] ids, IntFunction reader) + throws BridgeException { + JsonObject out = new JsonObject(); + + for (int id : ids) { + try { + out.add(Integer.toString(id), reader.apply(id)); + } catch (RuntimeException e) { + // The client indexes its tables with the id and throws for one that does not exist. + throw new BridgeException("not_found", kind + " " + id + " could not be read: " + e); + } + } + + return out; + } + + /** Capture the next frame as a PNG that has the size of the canvas. */ + private JsonElement screenshot(JsonObject args) throws BridgeException { + BufferedImage frame = game.frame(); + Dimension canvas = game.onClientThread(client -> client.getCanvas().getSize()); + int width = canvas.width; + int height = canvas.height; + + if (width <= 0 || height <= 0) throw new BridgeException("wrong_state", "the canvas has no size yet"); + + // A high-density display renders more pixels than the canvas has units. Clicks are in canvas + // units, so the image is brought to canvas size to make a pixel and a click coordinate the same thing. + BufferedImage sized = frame; + + if (frame.getWidth() != width || frame.getHeight() != height) { + sized = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = sized.createGraphics(); + graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + graphics.drawImage(frame, 0, 0, width, height, null); + graphics.dispose(); + } + + ByteArrayOutputStream png = new ByteArrayOutputStream(); + + try { + ImageIO.write(sized, "png", png); + } catch (IOException e) { + throw new BridgeException("internal", "could not encode the frame: " + e); + } + + JsonObject out = new JsonObject(); + out.addProperty("png", Base64.getEncoder().encodeToString(png.toByteArray())); + out.addProperty("width", width); + out.addProperty("height", height); + + return out; + } + + /** Click the canvas with the mouse at the given coordinates, or at the centre of the given widget. */ + private JsonElement click(Mouse mouse, JsonObject args) throws BridgeException { + boolean right = "right".equals(optionalString(args, "button")); + Point point = clickPoint(args); + mouse.hover(point.x, point.y); + mouse.click(point.x, point.y, right ? Mouse.Button.RIGHT : Mouse.Button.LEFT); + + JsonObject out = new JsonObject(); + out.addProperty("x", point.x); + out.addProperty("y", point.y); + + return out; + } + + /** + * Get the canvas point to click: the centre of the widget when one is named, and the x and y + * otherwise. Throws {@code bad_args} when neither is given. + */ + private Point clickPoint(JsonObject args) throws BridgeException { + String widget = optionalString(args, "widget"); + if (widget != null) return game.onClientThread(client -> centre(visibleWidget(client, widget).getBounds())); + + Integer x = optionalInt(args, "x"); + Integer y = optionalInt(args, "y"); + + if (x == null || y == null) throw new BridgeException("bad_args", "pass either x and y, or widget"); + + return new Point(x, y); + } + + /** + * Get the referenced widget. Throws {@code bad_args} for a malformed reference and + * {@code not_found} for a widget that does not exist or is not visible. + */ + static Widget visibleWidget(Client client, String ref) throws BridgeException { + Matcher matcher = WIDGET_REF.matcher(ref); + if (!matcher.matches()) throw new BridgeException("bad_args", WIDGET_REF_SHAPE); + + int index = matcher.group(3) == null ? -1 : Integer.parseInt(matcher.group(3)); + Widget widget = widget(client, Integer.parseInt(matcher.group(1)), Integer.parseInt(matcher.group(2)), index); + + if (widget == null) throw new BridgeException("not_found", "widget " + ref + " does not exist"); + + if (!isVisible(widget)) throw new BridgeException("not_found", "widget " + ref + " is not visible"); + + return widget; + } + + /** Get the widget of the group and child, or its dynamic child at the index unless it is -1. Null when absent. */ + static Widget widget(Client client, int group, int child, int index) { + Widget widget = client.getWidget(group, child); + + return widget == null || index < 0 ? widget : widget.getChild(index); + } + + /** Determine if the widget is shown with some size. */ + static boolean isVisible(Widget widget) { + Rectangle bounds = widget.getBounds(); + + return !widget.isHidden() && bounds != null && !bounds.isEmpty(); + } + + /** Type the text as key events, followed by Enter when asked. */ + private JsonElement type(JsonObject args) throws BridgeException { + String text = args.get("text").getAsString(); + boolean enter = optionalBoolean(args, "enter"); + game.input(canvas -> { + for (char ch : text.toCharArray()) { + key(canvas, KeyEvent.getExtendedKeyCodeForChar(ch), ch); + } + + if (enter) { + key(canvas, KeyEvent.VK_ENTER, '\n'); + } + }); + + JsonObject out = new JsonObject(); + out.addProperty("typed", text.length()); + + return out; + } + + /** Dispatch the press, the typed character and the release of one key. */ + private static void key(Canvas canvas, int code, char ch) { + long now = System.currentTimeMillis(); + canvas.dispatchEvent(new KeyEvent(canvas, KeyEvent.KEY_PRESSED, now, 0, code, ch)); + canvas.dispatchEvent(new KeyEvent(canvas, KeyEvent.KEY_TYPED, now, 0, KeyEvent.VK_UNDEFINED, ch)); + canvas.dispatchEvent(new KeyEvent(canvas, KeyEvent.KEY_RELEASED, now, 0, code, ch)); + } + + /** Log in with the given credentials and wait until the client is in the game. */ + private JsonElement login(JsonObject args) throws BridgeException { + String username = args.get("username").getAsString(); + String password = args.get("password").getAsString(); + + if (password.isEmpty()) throw new BridgeException("bad_args", "password is required and must not be empty"); + + long deadline = System.currentTimeMillis() + args.get("wait_ms").getAsInt(); + awaitLoaded(deadline); + submitCredentials(username, password); + + return awaitLoggedIn(deadline); + } + + /** + * Wait until the client has finished starting. Throws {@code timeout} when the deadline passes first. + * The bridge connects while the client is still loading, and a login set before the login screen is lost. + */ + private void awaitLoaded(long deadline) throws BridgeException { + while (true) { + GameState state = game.onClientThread(Client::getGameState); + if (state != GameState.STARTING && state != GameState.UNKNOWN) return; + + if (System.currentTimeMillis() >= deadline) { + String reached = "the client did not reach the login screen; gameState is " + state + ". "; + String hint = "A client that cannot reach the game server stays in this state."; + + throw new BridgeException("timeout", reached + hint); + } + + pollAgain("the login screen"); + } + } + + /** Set the credentials and start the login. Throws {@code wrong_state} unless the client is on the login screen. */ + private void submitCredentials(String username, String password) throws BridgeException { + game.onClientThread(client -> { + GameState state = client.getGameState(); + if (state != GameState.LOGIN_SCREEN) { + throw new BridgeException("wrong_state", "the client is not on the login screen: " + state); + } + + client.setUsername(username); + client.setPassword(password); + client.setGameState(GameState.LOGGING_IN); + + return null; + }); + } + + /** + * Wait until the client is in the game. Throws {@code wrong_state} when the login is refused and + * {@code timeout} when the deadline passes first. + */ + private JsonElement awaitLoggedIn(long deadline) throws BridgeException { + while (true) { + GameState state = game.onClientThread(Client::getGameState); + if (state == GameState.LOGGED_IN) { + JsonObject out = new JsonObject(); + out.addProperty("gameState", state.name()); + + return out; + } + + if (state == GameState.LOGIN_SCREEN) throw new BridgeException("wrong_state", LOGIN_REFUSED); + + if (System.currentTimeMillis() >= deadline) { + throw new BridgeException("timeout", "not logged in before the wait elapsed; gameState is " + state); + } + + pollAgain("the login"); + } + } + + /** Sleep until the next read of the game state is due. */ + private static void pollAgain(String awaited) throws BridgeException { + try { + Thread.sleep(LOGIN_POLL_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new BridgeException("internal", "interrupted while waiting for " + awaited); + } + } + + /** Get the point at the centre of the bounds. */ + private static Point centre(Rectangle bounds) { + return new Point(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); + } + + /** Get the reference that names the widget, in the shape that a click accepts. */ + static String ref(Widget widget) { + String ref = (widget.getId() >>> 16) + ":" + (widget.getId() & 0xFFFF); + + return widget.getIndex() >= 0 ? ref + "[" + widget.getIndex() + "]" : ref; + } + + /** Build a JSON array of the values. */ + static JsonArray ints(int... values) { + JsonArray out = new JsonArray(); + + for (int value : values) { + out.add(value); + } + + return out; + } + + /** Wrap the value as a JSON number. */ + private static JsonElement number(int value) { + return new JsonPrimitive(value); + } + + /** Wrap the value as a JSON string, or as JSON null when there is none. */ + private static JsonElement string(String value) { + return value == null ? JsonNull.INSTANCE : new JsonPrimitive(value); + } + + /** Get the value, or an empty string in place of null. */ + static String nullToEmpty(String value) { + return value == null ? "" : value; + } + + /** Get the named integer argument, or null when it is absent. */ + static Integer optionalInt(JsonObject args, String name) { + return args.has(name) ? args.get(name).getAsInt() : null; + } + + /** Get the named string argument, or null when it is absent. */ + static String optionalString(JsonObject args, String name) { + return args.has(name) ? args.get(name).getAsString() : null; + } + + /** Get the named boolean argument, or false when it is absent. */ + private static boolean optionalBoolean(JsonObject args, String name) { + return args.has(name) && args.get(name).getAsBoolean(); + } + + /** Get the named array of integers, or an empty array when it is absent. */ + private static int[] optionalInts(JsonObject args, String name) { + if (!args.has(name)) return new int[0]; + + JsonArray array = args.getAsJsonArray(name); + int[] out = new int[array.size()]; + + for (int i = 0; i < out.length; i++) { + out[i] = array.get(i).getAsInt(); + } + + return out; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Scenes.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Scenes.java new file mode 100644 index 000000000..4663be974 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Scenes.java @@ -0,0 +1,115 @@ +package net.rsprox.mcpbridge; + +import java.awt.Polygon; +import java.awt.Rectangle; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import net.runelite.api.Client; +import net.runelite.api.GameObject; +import net.runelite.api.MenuEntry; +import net.runelite.api.ObjectComposition; +import net.runelite.api.Perspective; +import net.runelite.api.Point; +import net.runelite.api.Tile; +import net.runelite.api.TileObject; +import net.runelite.api.WorldView; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; + +/** + * Reads of the top-level world view that the entity listing and the interactions share. World + * coordinates are the scene coordinates plus the base of the view, which is what the packets carry. + */ +final class Scenes { + /** The options of a ground item by position. The RuneLite API exposes none, and the client takes with the third. */ + static final String[] GROUND_ITEM_OPTIONS = {null, null, "Take", null, null}; + + /** Prevent the creation of instances. */ + private Scenes() { + // + } + + /** Determine if the world coordinates lie in the scene that the view has loaded. */ + static boolean isLoaded(WorldView view, int x, int y) { + int sceneX = x - view.getBaseX(); + int sceneY = y - view.getBaseY(); + + return sceneX >= 0 && sceneY >= 0 && sceneX < view.getSizeX() && sceneY < view.getSizeY(); + } + + /** Get the tile at the world coordinates on the plane in view, or null when the scene has none there. */ + static Tile tile(WorldView view, int x, int y) { + if (!isLoaded(view, x, y)) return null; + + return view.getScene().getTiles()[view.getPlane()][x - view.getBaseX()][y - view.getBaseY()]; + } + + /** Get the objects on the tile that can have options: its game, wall, ground and decorative objects. */ + static List objects(Tile tile) { + List out = new ArrayList<>(); + GameObject[] gameObjects = tile.getGameObjects(); + + for (GameObject object : gameObjects == null ? new GameObject[0] : gameObjects) { + if (object != null) out.add(object); + } + + if (tile.getWallObject() != null) out.add(tile.getWallObject()); + + if (tile.getGroundObject() != null) out.add(tile.getGroundObject()); + + if (tile.getDecorativeObject() != null) out.add(tile.getDecorativeObject()); + + return out; + } + + /** + * Get the scene tile that an option on the object names: the minimum tile of a game object, which + * may cover several, and the tile it stands on for any other object. + */ + static Point origin(TileObject object, Tile tile) { + return object instanceof GameObject ? ((GameObject) object).getSceneMinLocation() : tile.getSceneLocation(); + } + + /** Get the definition that the client shows for the object id, or null while it shows none. */ + static ObjectComposition shown(Client client, int id) { + ObjectComposition composition = client.getObjectDefinition(id); + + return composition.getImpostorIds() == null ? composition : composition.getImpostor(); + } + + /** Get the names of the options that are offered, in order and without their colour tags. */ + static List offered(String[] options) { + return Arrays.stream(options == null ? new String[0] : options) + .map(Offer::untagged) + .filter(name -> !name.isEmpty()) + .collect(Collectors.toList()); + } + + /** Determine if the parameters of the menu entry name the scene tile of the world tile, as an object's do. */ + static boolean isOnTile(MenuEntry entry, Client client, WorldPoint at) { + WorldView view = client.getTopLevelWorldView(); + + return entry.getParam0() == at.getX() - view.getBaseX() && entry.getParam1() == at.getY() - view.getBaseY(); + } + + /** Get the part of the canvas that shows the 3D scene. */ + static Rectangle viewport(Client client) { + return new Rectangle( + client.getViewportXOffset(), + client.getViewportYOffset(), + client.getViewportWidth(), + client.getViewportHeight()); + } + + /** Get the outline of the scene tile on the canvas, or null while the tile is not in front of the camera. */ + static Polygon tilePolygon(Client client, WorldView view, int sceneX, int sceneY) { + return Perspective.getCanvasTilePoly(client, LocalPoint.fromScene(sceneX, sceneY, view)); + } + + /** Write the two coordinates in brackets, whether they name a world tile or a canvas point. */ + static String words(int x, int y) { + return "(" + x + ", " + y + ")"; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Target.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Target.java new file mode 100644 index 000000000..7f4539c99 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Target.java @@ -0,0 +1,70 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonObject; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.Shape; +import net.runelite.api.Client; +import net.runelite.api.MenuEntry; +import net.runelite.api.coords.WorldPoint; + +/** + * One thing in the client that an option is performed on, named by its identity so that it can be + * found again in the client's own state on every attempt. All reads happen on the client thread. + */ +abstract class Target { + /** The option to perform, as the caller wrote it. */ + final String option; + + /** Create a target on which the option is to be performed. */ + Target(String option) { + this.option = option; + } + + /** Get the words that name the target in a message. */ + abstract String label(); + + /** Describe the target for the result: the prefix of the packet to expect, its kind, identity and option. */ + abstract JsonObject describe(); + + /** Get the clickable outline of the target on the canvas, or null while no part of it can be clicked. */ + abstract Shape shape(Client client); + + /** Determine if the menu entry performs this target's option on this very target. */ + abstract boolean matches(MenuEntry entry, Client client); + + /** Get the world tile to turn the camera toward, or null for a target that is not in the world. */ + abstract WorldPoint tile(Client client); + + /** Get the part of the canvas within which the target can be clicked: the 3D view unless overridden. */ + Rectangle area(Client client) { + return Scenes.viewport(client); + } + + /** Get the canvas point to aim at on the given attempt, or null while no part of the target is on screen. */ + Point aim(Client client, int attempt) { + Shape shape = shape(client); + if (shape == null) return null; + + return Aiming.point(shape, area(client), attempt); + } + + /** Determine if the entry's option is exactly the option, ignoring case and colour tags. */ + boolean sameOption(MenuEntry entry) { + return isSameOption(option, entry.getOption()); + } + + /** Determine if the two options or actions are the same, ignoring case and colour tags. */ + static boolean isSameOption(String one, String other) { + return Offer.untagged(one).equalsIgnoreCase(Offer.untagged(other)); + } + + /** Start the description of a target with the prefix of the packet to expect and its kind. */ + static JsonObject described(String expect, String kind) { + JsonObject out = new JsonObject(); + out.addProperty("expect", expect); + out.addProperty("target", kind); + + return out; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Targets.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Targets.java new file mode 100644 index 000000000..421f89e19 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Targets.java @@ -0,0 +1,338 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonObject; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; +import net.runelite.api.Client; +import net.runelite.api.NPC; +import net.runelite.api.NPCComposition; +import net.runelite.api.ObjectComposition; +import net.runelite.api.Player; +import net.runelite.api.Point; +import net.runelite.api.Tile; +import net.runelite.api.TileObject; +import net.runelite.api.WorldView; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.widgets.ComponentID; +import net.runelite.api.widgets.Widget; + +/** + * Finds the target that the arguments of an interaction name in the client's own state, and checks + * that it offers the option. The client would send a packet for a target or an option it does not + * have, so both are refused before anything is clicked. + */ +final class Targets { + /** The kinds of target by name, each with the arguments that name it and the way to find it. */ + private static final Map KINDS = Map.of( + "npc", new Kind(Targets::npc, "index", "option"), + "object", new Kind(Targets::object, "id", "x", "y", "option"), + "ground_item", new Kind(Targets::groundItem, "id", "x", "y", "option"), + "player", new Kind(Targets::player, "index", "option"), + "widget", new Kind(Targets::widget, "widget", "option"), + "dialog", new Kind(Targets::dialog, "option"), + "tile", new Kind(Targets::walk, "x", "y")); + + /** The option that every NPC, object and ground item has after its own five. */ + private static final String EXAMINE = "Examine"; + + /** The position of the examine option, after the five options of the target. */ + private static final int EXAMINE_POSITION = 5; + + /** The number of options a player can have. */ + private static final int PLAYER_OPTIONS = 8; + + /** The option the client offers on a widget that continues a dialog. */ + private static final String CONTINUE = "Continue"; + + /** The text of the widget that continues a dialog. */ + private static final String CONTINUE_TEXT = "Click here to continue"; + + /** Prevent the creation of instances. */ + private Targets() { + // + } + + /** The way to find the target of one kind. */ + private interface Finder { + /** Find the target that the arguments name, with the options the client says it has. */ + Candidate find(Client client, JsonObject args, String option) throws BridgeException; + } + + /** One kind of target: the way to find it and the arguments it needs. */ + private static final class Kind { + /** The way to find a target of the kind. */ + final Finder finder; + + /** The names of the arguments that a target of the kind needs. */ + final List required; + + /** Create the kind that the finder finds from the required arguments. */ + Kind(Finder finder, String... required) { + this.finder = finder; + this.required = List.of(required); + } + } + + /** A target as found, with the options the client says it has. */ + private static final class Candidate { + /** The target. */ + final Target target; + + /** The options the target offers, in the client's order. */ + final List options; + + /** Create the record of a found target. */ + Candidate(Target target, List options) { + this.target = target; + this.options = options; + } + + /** Determine if the target offers the option that is to be performed on it. */ + boolean offersOption() { + return options.stream().anyMatch(offered -> Target.isSameOption(offered, target.option)); + } + + /** Build the failure for a target that does not offer the option. */ + BridgeException noOption() { + return notFound(target.label() + " has no option '" + target.option + "'. It offers: " + options); + } + } + + /** Check that the kind of target has its arguments. Throws {@code bad_args} for the first that is missing. */ + static void requireArguments(JsonObject args) throws BridgeException { + require(args, name -> true); + } + + /** Check that the kind of target has the arguments that name it, whatever the option. Throws {@code bad_args}. */ + static void requireIdentity(JsonObject args) throws BridgeException { + require(args, name -> !name.equals("option")); + } + + /** Check that the kind of target has those of its arguments that pass the test. Throws {@code bad_args}. */ + private static void require(JsonObject args, Predicate wanted) throws BridgeException { + String kind = args.get("target").getAsString(); + + for (String name : KINDS.get(kind).required) { + boolean missing = wanted.test(name) && !args.has(name); + + if (missing) throw new BridgeException("bad_args", "target " + kind + " needs '" + name + "'"); + } + } + + /** + * Find the target that the arguments name and check that it offers the option. Throws + * {@code not_found} for a target the client does not have or an option it does not offer. + */ + static Target resolve(Client client, JsonObject args) throws BridgeException { + Candidate found = find(client, args); + if (!found.offersOption()) throw found.noOption(); + + return found.target; + } + + /** + * Find the target that the arguments name, whatever its options. Throws {@code not_found} when the + * client does not have it. + */ + static Target locate(Client client, JsonObject args) throws BridgeException { + return find(client, args).target; + } + + /** Find the target of the kind the arguments name, with the options the client says it has. */ + private static Candidate find(Client client, JsonObject args) throws BridgeException { + String option = Ops.nullToEmpty(Ops.optionalString(args, "option")); + + return KINDS.get(args.get("target").getAsString()).finder.find(client, args, option); + } + + /** Find the tile to walk to, on which the client offers to walk. */ + private static Candidate walk(Client client, JsonObject args, String option) throws BridgeException { + return new Candidate(new TileTarget(tile(client, args)), List.of(TileTarget.WALK_HERE)); + } + + /** Get the world tile that the arguments name. Throws {@code not_found} for one outside the loaded scene. */ + private static WorldPoint tile(Client client, JsonObject args) throws BridgeException { + int x = args.get("x").getAsInt(); + int y = args.get("y").getAsInt(); + WorldView view = client.getTopLevelWorldView(); + + if (!Scenes.isLoaded(view, x, y)) throw notFound("tile " + Scenes.words(x, y) + " is not in the loaded scene"); + + return new WorldPoint(x, y, view.getPlane()); + } + + /** Find the NPC with the index in the client's list of NPCs. */ + private static Candidate npc(Client client, JsonObject args, String option) throws BridgeException { + int index = args.get("index").getAsInt(); + NPC npc = client.getTopLevelWorldView().npcs().byIndex(index); + NPCComposition composition = npc == null ? null : npc.getTransformedComposition(); + + if (composition == null) throw notFound("no npc with index " + index + " is in the client's view"); + + String name = Ops.nullToEmpty(composition.getName()); + + return new Candidate(ActorTarget.npc(index, npc.getId(), name, option), withExamine(composition.getActions())); + } + + /** Find the player with the index in the client's list of players. */ + private static Candidate player(Client client, JsonObject args, String option) throws BridgeException { + int index = args.get("index").getAsInt(); + Player player = client.getTopLevelWorldView().players().byIndex(index); + + if (player == null) throw notFound("no player with index " + index + " is in the client's view"); + + String[] options = Arrays.copyOf(client.getPlayerOptions(), PLAYER_OPTIONS); + ActorTarget target = ActorTarget.player(index, Ops.nullToEmpty(player.getName()), option); + + return new Candidate(target, Scenes.offered(options)); + } + + /** + * Find the visible widget with the reference. Besides its own ops, the client may offer to continue + * a dialog on it. + */ + private static Candidate widget(Client client, JsonObject args, String option) throws BridgeException { + String ref = args.get("widget").getAsString(); + Widget widget = Ops.visibleWidget(client, ref); + List options = new ArrayList<>(Scenes.offered(widget.getActions())); + options.add(CONTINUE); + + return new Candidate(new WidgetTarget(ref, widget.getId(), widget.getIndex(), option), options); + } + + /** + * Find the widget of the open dialog that the option names: the one that continues it for + * "continue", or the numbered or worded choice of a dialog with options. + */ + private static Candidate dialog(Client client, JsonObject args, String option) throws BridgeException { + if (Target.isSameOption(option, CONTINUE)) return continueWidget(client); + + List choices = choices(client); + + for (Widget choice : choices) { + if (names(option, choice) && Ops.isVisible(choice)) return found(choice); + } + + throw notFound("no dialog option '" + option + "' is shown. It offers: " + numbered(choices)); + } + + /** Get the widgets of the choices of the open dialog, or none while no dialog with options is open. */ + private static List choices(Client client) { + Widget options = client.getWidget(ComponentID.DIALOG_OPTION_OPTIONS); + if (options == null || options.isHidden()) return List.of(); + + Widget[] choices = options.getDynamicChildren(); + + return choices == null ? List.of() : Arrays.asList(choices); + } + + /** Determine if the option names the choice, by its text or by its number. */ + private static boolean names(String option, Widget choice) { + return Target.isSameOption(option, choice.getText()) || Integer.toString(choice.getIndex()).equals(option); + } + + /** Write each choice that has a text as its number and its text. The first child is the title of the dialog. */ + private static List numbered(List choices) { + List out = new ArrayList<>(); + + for (Widget choice : choices) { + String text = Offer.untagged(choice.getText()); + + if (!text.isEmpty() && choice.getIndex() > 0) out.add(choice.getIndex() + ". " + text); + } + + return out; + } + + /** Find the visible widget that continues a dialog. Throws {@code not_found} while no dialog waits. */ + private static Candidate continueWidget(Client client) throws BridgeException { + for (Widget root : client.getWidgetRoots()) { + Widget widget = Ops.firstWidget(root, false, Targets::continues, 0); + if (widget != null) return found(widget); + } + + throw notFound("no dialog is waiting to be continued"); + } + + /** Determine if the widget is the one that continues a dialog, by its text. */ + private static boolean continues(Widget widget) { + return CONTINUE_TEXT.equalsIgnoreCase(Ops.nullToEmpty(widget.getText()).trim()); + } + + /** Build the found record of a dialog widget, which the client offers to continue. */ + private static Candidate found(Widget widget) { + WidgetTarget target = new WidgetTarget(Ops.ref(widget), widget.getId(), widget.getIndex(), CONTINUE); + + return new Candidate(target, List.of(CONTINUE)); + } + + /** Find the object that has the id, or shows as it, on the world tile. */ + private static Candidate object(Client client, JsonObject args, String option) throws BridgeException { + int id = args.get("id").getAsInt(); + WorldPoint at = tile(client, args); + Tile tile = Scenes.tile(client.getTopLevelWorldView(), at.getX(), at.getY()); + + if (tile == null) throw noObject(id, at); + + for (TileObject object : Scenes.objects(tile)) { + ObjectComposition shown = Scenes.shown(client, object.getId()); + if (shown == null || (object.getId() != id && shown.getId() != id)) continue; + + String name = Ops.nullToEmpty(shown.getName()); + ObjectTarget target = new ObjectTarget(object.getId(), name, origin(client, object, tile), option); + + return new Candidate(target, withExamine(shown.getActions())); + } + + throw noObject(id, at); + } + + /** Get the world tile of origin of the object on the tile, on the plane in view. */ + private static WorldPoint origin(Client client, TileObject object, Tile tile) { + WorldView view = client.getTopLevelWorldView(); + Point origin = Scenes.origin(object, tile); + + return new WorldPoint(view.getBaseX() + origin.getX(), view.getBaseY() + origin.getY(), view.getPlane()); + } + + /** Build the failure for an object that is not on the world tile. */ + private static BridgeException noObject(int id, WorldPoint at) { + return notFound("no object " + id + " is on tile " + Scenes.words(at.getX(), at.getY())); + } + + /** Find the item with the id that lies on the world tile. */ + private static Candidate groundItem(Client client, JsonObject args, String option) throws BridgeException { + int id = args.get("id").getAsInt(); + WorldPoint at = tile(client, args); + Tile tile = Scenes.tile(client.getTopLevelWorldView(), at.getX(), at.getY()); + + if (tile == null || tile.getGroundItems() == null) throw noGroundItem(id, at); + + if (tile.getGroundItems().stream().noneMatch(item -> item.getId() == id)) throw noGroundItem(id, at); + + String name = Ops.nullToEmpty(client.getItemDefinition(id).getName()); + + return new Candidate(new GroundItemTarget(id, name, at, option), withExamine(Scenes.GROUND_ITEM_OPTIONS)); + } + + /** Build the failure for a ground item that is not on the world tile. */ + private static BridgeException noGroundItem(int id, WorldPoint at) { + return notFound("no ground item " + id + " is on tile " + Scenes.words(at.getX(), at.getY())); + } + + /** Get the options offered among the five of a target, followed by the examine option that each such target has. */ + private static List withExamine(String[] options) { + String[] out = Arrays.copyOf(options == null ? new String[0] : options, EXAMINE_POSITION + 1); + out[EXAMINE_POSITION] = EXAMINE; + + return Scenes.offered(out); + } + + /** Build the failure for something the client does not have. */ + private static BridgeException notFound(String message) { + return new BridgeException("not_found", message); + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/TileTarget.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/TileTarget.java new file mode 100644 index 000000000..a6dde199e --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/TileTarget.java @@ -0,0 +1,72 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonObject; +import java.awt.Shape; +import net.runelite.api.Client; +import net.runelite.api.MenuAction; +import net.runelite.api.MenuEntry; +import net.runelite.api.Point; +import net.runelite.api.Tile; +import net.runelite.api.WorldView; +import net.runelite.api.coords.WorldPoint; + +/** + * A world tile to walk to. The client's walk entry names no tile: it walks to the tile the mouse + * hovers, so the entry matches only while that is this tile. + */ +final class TileTarget extends Target { + /** The option the client offers on a tile. */ + static final String WALK_HERE = "Walk here"; + + /** The world tile. */ + private final WorldPoint at; + + /** Create the target for walking to the world tile. */ + TileTarget(WorldPoint at) { + super(WALK_HERE); + this.at = at; + } + + /** Get the words that name the tile. */ + @Override + String label() { + return "tile " + Scenes.words(at.getX(), at.getY()); + } + + /** Describe the tile with its world coordinates. */ + @Override + JsonObject describe() { + JsonObject out = described("MOVE_GAMECLICK", "tile"); + out.addProperty("x", at.getX()); + out.addProperty("y", at.getY()); + + return out; + } + + /** Get the outline of the tile, or null while it is not in front of the camera. */ + @Override + Shape shape(Client client) { + WorldView view = client.getTopLevelWorldView(); + if (!Scenes.isLoaded(view, at.getX(), at.getY())) return null; + + return Scenes.tilePolygon(client, view, at.getX() - view.getBaseX(), at.getY() - view.getBaseY()); + } + + /** Determine if the entry is the walk entry while the client's hovered tile is this tile. */ + @Override + boolean matches(MenuEntry entry, Client client) { + if (entry.getType() != MenuAction.WALK) return false; + + WorldView view = client.getTopLevelWorldView(); + Tile hovered = view.getSelectedSceneTile(); + Point wanted = new Point(at.getX() - view.getBaseX(), at.getY() - view.getBaseY()); + + return hovered != null && hovered.getSceneLocation().equals(wanted); + } + + /** Get the world tile. */ + @Override + WorldPoint tile(Client client) { + return at; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/WidgetTarget.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/WidgetTarget.java new file mode 100644 index 000000000..e1ab62c68 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/WidgetTarget.java @@ -0,0 +1,97 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonObject; +import java.awt.Canvas; +import java.awt.Rectangle; +import java.awt.Shape; +import java.util.EnumSet; +import java.util.Set; +import net.runelite.api.Client; +import net.runelite.api.MenuAction; +import net.runelite.api.MenuEntry; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.widgets.Widget; + +/** + * An interface component, named by its packed id and, for a dynamic child, its index. Its menu + * entries carry the id as the second parameter and the child index as the first. + */ +final class WidgetTarget extends Target { + /** The menu actions that perform an option on an interface component. */ + private static final Set FAMILY = EnumSet.of( + MenuAction.CC_OP, + MenuAction.CC_OP_LOW_PRIORITY, + MenuAction.WIDGET_CONTINUE, + MenuAction.WIDGET_TYPE_1, + MenuAction.WIDGET_TYPE_4, + MenuAction.WIDGET_TYPE_5, + MenuAction.WIDGET_CLOSE, + MenuAction.WIDGET_FIRST_OPTION, + MenuAction.WIDGET_SECOND_OPTION, + MenuAction.WIDGET_THIRD_OPTION, + MenuAction.WIDGET_FOURTH_OPTION, + MenuAction.WIDGET_FIFTH_OPTION); + + /** The reference as the caller wrote it, or as the dialog was resolved to. */ + private final String ref; + + /** The packed id of the widget. */ + private final int id; + + /** The index of the dynamic child, or -1 for a static widget. */ + private final int index; + + /** Create the target for the widget with the packed id and child index, referred to by the ref. */ + WidgetTarget(String ref, int id, int index, String option) { + super(option); + this.ref = ref; + this.id = id; + this.index = index; + } + + /** Get the words that name the widget. */ + @Override + String label() { + return "widget " + ref; + } + + /** Describe the widget by its reference. */ + @Override + JsonObject describe() { + JsonObject out = described("IF_BUTTON", "widget"); + out.addProperty("widget", ref); + out.addProperty("option", option); + + return out; + } + + /** Get the bounds of the widget, or null while it is not visible. */ + @Override + Shape shape(Client client) { + Widget widget = Ops.widget(client, id >>> 16, id & 0xFFFF, index); + + return widget == null || !Ops.isVisible(widget) ? null : widget.getBounds(); + } + + /** Determine if the entry is this option on this widget, and on this child for a dynamic one. */ + @Override + boolean matches(MenuEntry entry, Client client) { + boolean sameWidget = entry.getParam1() == id && (index < 0 || entry.getParam0() == index); + + return FAMILY.contains(entry.getType()) && sameWidget && sameOption(entry); + } + + /** Get no tile: an interface is not in the world, so the camera cannot bring it into view. */ + @Override + WorldPoint tile(Client client) { + return null; + } + + /** Get the whole canvas, since an interface lies outside the 3D view. */ + @Override + Rectangle area(Client client) { + Canvas canvas = client.getCanvas(); + + return new Rectangle(0, 0, canvas.getWidth(), canvas.getHeight()); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index a0ef670c2..524ede5d2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,6 +24,7 @@ include( "shared", "launcher", "runelite", + "mcp", ) includeSubprojects(":protocol") diff --git a/shared/src/main/kotlin/net/rsprox/shared/SessionMonitor.kt b/shared/src/main/kotlin/net/rsprox/shared/SessionMonitor.kt index 8b2bb2d3f..3d449d87c 100644 --- a/shared/src/main/kotlin/net/rsprox/shared/SessionMonitor.kt +++ b/shared/src/main/kotlin/net/rsprox/shared/SessionMonitor.kt @@ -7,6 +7,9 @@ public interface SessionMonitor { /** Bind delayed callbacks to one recording rather than the latest active login. */ public fun forSession(header: T): SessionMonitor = this + /** Called on the transcriber worker before the properties of each packet are published. */ + public fun onPacketDirection(direction: StreamDirection) {} + public fun onLogin(header: T) public fun onLogout(header: T) diff --git a/transcriber/src/main/kotlin/net/rsprox/transcriber/state/SessionTracker.kt b/transcriber/src/main/kotlin/net/rsprox/transcriber/state/SessionTracker.kt index 051a9f3b3..e1f2ec263 100644 --- a/transcriber/src/main/kotlin/net/rsprox/transcriber/state/SessionTracker.kt +++ b/transcriber/src/main/kotlin/net/rsprox/transcriber/state/SessionTracker.kt @@ -40,6 +40,7 @@ import net.rsprox.protocol.game.outgoing.model.zone.header.UpdateZoneFullFollows import net.rsprox.protocol.game.outgoing.model.zone.header.UpdateZonePartialEnclosed import net.rsprox.protocol.game.outgoing.model.zone.header.UpdateZonePartialFollows import net.rsprox.shared.SessionMonitor +import net.rsprox.shared.StreamDirection import net.rsprox.transcriber.firstOfInstanceOfNull import net.rsprox.transcriber.legacy.LegacyClientProt import net.rsprox.transcriber.legacy.LegacyServerProt @@ -62,6 +63,7 @@ public class SessionTracker( message: IncomingMessage, prot: net.rsprot.protocol.Prot, ) { + monitor.onPacketDirection(StreamDirection.SERVER_TO_CLIENT) try { processKeys(message) } catch (e: Exception) { @@ -121,6 +123,7 @@ public class SessionTracker( @Suppress("UNUSED_PARAMETER") message: IncomingMessage, prot: net.rsprot.protocol.Prot, ) { + monitor.onPacketDirection(StreamDirection.CLIENT_TO_SERVER) val toString = LegacyClientProt[prot.toString()] val clientProt = GameClientProt.valueOf(toString) setCurrentProt(clientProt)