Skip to content

Commit 4f369d7

Browse files
committed
Implement socket server to remotely control processor
1 parent bc9ed48 commit 4f369d7

File tree

5 files changed

+220
-5
lines changed

5 files changed

+220
-5
lines changed

mod/assets/scripts/main.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,20 @@ global.override.block(LogicBlock, {
5353
})
5454
.tooltip("Dump mlogv32 RAM")
5555
.size(40);
56+
57+
buttons
58+
.button(Icon.host, Styles.clearTogglei, () => {
59+
if (processor.isServerRunning()) {
60+
processor.stopServer();
61+
Vars.ui.hudfrag.showToast("Stopped socket server.");
62+
} else {
63+
processor.startServer("localhost", 5000);
64+
Vars.ui.hudfrag.showToast("Started socket server at localhost:5000.");
65+
}
66+
})
67+
.tooltip("Start/stop mlogv32 socket server")
68+
.checked(processor.isServerRunning())
69+
.size(40);
5670
}
5771
},
5872
});

mod/build.gradle.kts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import com.xpdustry.toxopid.spec.ModMetadata
33
import com.xpdustry.toxopid.spec.ModPlatform
44

55
plugins {
6-
alias(libs.plugins.kotlin)
6+
alias(libs.plugins.kotlin.jvm)
7+
alias(libs.plugins.kotlin.serialization)
78
alias(libs.plugins.indra.common)
89
alias(libs.plugins.shadow)
910
alias(libs.plugins.toxopid)
@@ -28,7 +29,10 @@ dependencies {
2829
compileOnly(toxopid.dependencies.arcCore)
2930
compileOnly(toxopid.dependencies.mindustryCore)
3031

31-
implementation(kotlin("stdlib-jdk8"))
32+
implementation(libs.kotlin.stdlib)
33+
implementation(libs.kotlinx.coroutines)
34+
implementation(libs.kotlinx.serialization.json)
35+
implementation(libs.ktor.network)
3236
}
3337

3438
indra {

mod/gradle/libs.versions.toml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
[versions]
22
kotlin = "2.1.20"
3+
kotlinx-coroutines = "1.10.2"
4+
kotlinx-serialization-json = "1.8.1"
5+
ktor = "3.1.3"
36
indra = "3.1.3"
47
shadow = "8.3.6"
58
toxopid = "4.1.2"
69

10+
[libraries]
11+
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" }
12+
kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
13+
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
14+
ktor-network = { module = "io.ktor:ktor-network", version.ref = "ktor" }
15+
716
[plugins]
8-
kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
17+
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
18+
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
919
indra-common = { id = "net.kyori.indra", version.ref = "indra" }
1020
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
1121
toxopid = { id = "com.xpdustry.toxopid", version.ref = "toxopid" }

mod/src/main/kotlin/gay/object/mlogv32/Mlogv32UtilsMod.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,25 @@
11
package gay.`object`.mlogv32
22

33
import arc.Core
4+
import arc.Events
45
import arc.util.CommandHandler
56
import arc.util.Log
67
import mindustry.Vars
8+
import mindustry.core.GameState
9+
import mindustry.game.EventType
710
import mindustry.mod.Mod
811
import mindustry.world.blocks.logic.LogicBlock.LogicBuild
912

1013
@Suppress("unused")
1114
class Mlogv32UtilsMod : Mod() {
15+
override fun init() {
16+
Events.on(EventType.StateChangeEvent::class.java) { event ->
17+
if (event.to == GameState.State.menu) {
18+
ProcessorAccess.stopServer()
19+
}
20+
}
21+
}
22+
1223
override fun registerServerCommands(handler: CommandHandler) {
1324
handler.register(
1425
"mlogv32.flash",

mod/src/main/kotlin/gay/object/mlogv32/ProcessorAccess.kt

Lines changed: 178 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,25 @@
11
package gay.`object`.mlogv32
22

3+
import arc.Core
34
import arc.files.Fi
4-
import arc.util.Timer
5+
import arc.util.Log
6+
import io.ktor.network.selector.*
7+
import io.ktor.network.sockets.*
8+
import io.ktor.utils.io.*
9+
import kotlinx.coroutines.*
10+
import kotlinx.coroutines.CancellationException
11+
import kotlinx.serialization.SerialName
12+
import kotlinx.serialization.Serializable
13+
import kotlinx.serialization.json.Json
514
import mindustry.Vars
615
import mindustry.gen.Building
716
import mindustry.world.blocks.logic.LogicBlock.LogicBuild
817
import mindustry.world.blocks.logic.SwitchBlock.SwitchBuild
18+
import kotlin.concurrent.thread
19+
import kotlin.coroutines.resume
20+
import kotlin.coroutines.resumeWithException
921

10-
data class ProcessorAccess(
22+
class ProcessorAccess(
1123
val build: LogicBuild,
1224
val memoryX: Int,
1325
val memoryY: Int,
@@ -70,6 +82,41 @@ data class ProcessorAccess(
7082
writes.close()
7183
}
7284

85+
fun isServerRunning() = serverThread != null && serverJob != null && serverBuildId == build.id
86+
87+
fun startServer(hostname: String, port: Int) {
88+
if (serverThread != null) stopServer()
89+
90+
Log.info("Starting ProcessorAccess socket server at $hostname:$port for building ${build.id}...")
91+
92+
serverBuildId = build.id
93+
serverThread = thread(isDaemon = true) {
94+
runBlocking {
95+
serverJob = launch {
96+
try {
97+
val selector = SelectorManager(Dispatchers.IO)
98+
aSocket(selector).tcp().bind(hostname, port).use { serverSocket ->
99+
runServer(serverSocket)
100+
}
101+
} catch (e: Exception) {
102+
Log.err("ProcessorAccess server failed", e)
103+
}
104+
}
105+
}
106+
107+
// cleanup
108+
serverJob = null
109+
serverThread = null
110+
serverBuildId = null
111+
112+
Log.info("ProcessorAccess thread exiting.")
113+
}
114+
}
115+
116+
fun stopServer() {
117+
ProcessorAccess.stopServer()
118+
}
119+
73120
private fun ramWordsSequence(startAddress: Int = 0) = sequence {
74121
require(startAddress in ramStart..<ramEnd) { "Start address must be within RAM." }
75122
require(startAddress.mod(4) == 0) { "Start address must be aligned to 4 bytes." }
@@ -133,7 +180,40 @@ data class ProcessorAccess(
133180
return proc
134181
}
135182

183+
private suspend fun runServer(serverSocket: ServerSocket) {
184+
while (true) {
185+
Log.info("Waiting for clients...")
186+
serverSocket.accept().use { client ->
187+
Log.info("Client connected!")
188+
val rx = client.openReadChannel()
189+
val tx = client.openWriteChannel(true)
190+
while (true) {
191+
val response: Response = try {
192+
val line = rx.readUTF8Line() ?: break
193+
Log.info("Got request: $line")
194+
val request = Json.decodeFromString<Request>(line)
195+
request.handle(this)
196+
} catch (e: CancellationException) {
197+
throw e
198+
} catch (e: IllegalArgumentException) {
199+
Log.err("Bad request", e)
200+
ErrorResponse.badRequest(e)
201+
} catch (e: Exception) {
202+
Log.err("Request failed", e)
203+
ErrorResponse(e)
204+
}
205+
tx.writeStringUtf8(Json.encodeToString(response) + "\n")
206+
}
207+
Log.info("Client disconnected.")
208+
}
209+
}
210+
}
211+
136212
companion object {
213+
private var serverThread: Thread? = null
214+
private var serverJob: Job? = null
215+
private var serverBuildId: Int? = null
216+
137217
fun of(build: LogicBuild): ProcessorAccess? {
138218
return ProcessorAccess(
139219
build,
@@ -150,6 +230,14 @@ data class ProcessorAccess(
150230
resetSwitch = buildVar<SwitchBuild>(build, "RESET_SWITCH") ?: return null,
151231
)
152232
}
233+
234+
fun stopServer() {
235+
if (serverThread == null) return
236+
Log.info("Stopping ProcessorAccess server for building $serverBuildId...")
237+
serverJob?.cancel()
238+
serverThread?.join()
239+
Log.info("Stopped ProcessorAccess server.")
240+
}
153241
}
154242
}
155243

@@ -171,3 +259,91 @@ private fun positiveIntVar(build: LogicBuild, name: String): Int? =
171259

172260
private inline fun <reified T : Building> buildVar(build: LogicBuild, name: String): T? =
173261
build.executor.optionalVar(name)?.obj() as? T
262+
263+
@Serializable
264+
sealed class Request {
265+
abstract suspend fun handle(processor: ProcessorAccess): Response
266+
267+
protected suspend fun <T> runOnMainThread(block: () -> T): T {
268+
return suspendCancellableCoroutine { continuation ->
269+
Core.app.post {
270+
try {
271+
continuation.resume(block())
272+
} catch (e: Exception) {
273+
continuation.resumeWithException(e)
274+
}
275+
}
276+
}
277+
}
278+
}
279+
280+
@Serializable
281+
@SerialName("flash")
282+
data class FlashRequest(val path: String) : Request() {
283+
override suspend fun handle(processor: ProcessorAccess) = runOnMainThread {
284+
val file = Core.files.absolute(path)
285+
require(file.exists()) { "File not found." }
286+
287+
val bytes = processor.flashRom(file)
288+
SuccessResponse("Successfully flashed $bytes bytes from $file to ROM.")
289+
}
290+
}
291+
292+
@Serializable
293+
@SerialName("dump")
294+
data class DumpRequest(val path: String) : Request() {
295+
override suspend fun handle(processor: ProcessorAccess) = runOnMainThread {
296+
val file = Core.files.absolute(path)
297+
file.parent().mkdirs()
298+
299+
val bytes = processor.dumpRam(file)
300+
SuccessResponse("Successfully dumped $bytes bytes from RAM to $file.")
301+
}
302+
}
303+
304+
@Serializable
305+
@SerialName("start")
306+
data class StartRequest(val wait: Boolean) : Request() {
307+
override suspend fun handle(processor: ProcessorAccess): Response {
308+
runOnMainThread {
309+
processor.resetSwitch.configure(false)
310+
}
311+
if (!wait) {
312+
return SuccessResponse("Processor started.")
313+
}
314+
315+
while (true) {
316+
delay(500)
317+
val stopped = runOnMainThread { processor.resetSwitch.enabled }
318+
if (stopped) {
319+
return SuccessResponse("Processor has halted.")
320+
}
321+
}
322+
}
323+
}
324+
325+
@Serializable
326+
@SerialName("stop")
327+
data object StopRequest : Request() {
328+
override suspend fun handle(processor: ProcessorAccess) = runOnMainThread {
329+
processor.resetSwitch.configure(true)
330+
SuccessResponse("Processor stopped.")
331+
}
332+
}
333+
334+
@Serializable
335+
sealed class Response
336+
337+
@Serializable
338+
@SerialName("success")
339+
data class SuccessResponse(val message: String) : Response()
340+
341+
@Serializable
342+
@SerialName("error")
343+
data class ErrorResponse(val message: String) : Response() {
344+
constructor(e: Exception) : this("Request failed: $e")
345+
346+
companion object {
347+
fun badRequest(e: IllegalArgumentException) = ErrorResponse("Bad request: $e")
348+
}
349+
}

0 commit comments

Comments
 (0)