From 5b51ecf7dad3eb0355f461d51b576f8416e11468 Mon Sep 17 00:00:00 2001 From: 2b2m22 Date: Fri, 11 Sep 2026 21:11:14 +0300 Subject: [PATCH 1/9] Port coreplugin to Mindustry build 160.1 Content patching moved out of GameState.patcher (DataPatcher) into GameState.data (DataManager) in build 159: patches are PatchAsset objects now, and apply(Seq) became reloadPatches(Seq). The 'Mindurka Default Patch' name check still works -- DataPatcher keeps reading the 'name' key into PatchAsset.name. TerminalInput: the server jar now bundles JLine 4.0.0, whose TerminalBuilder has no jna() (providers are ffm/jni). Dropped the call; provider selection is automatic. build.gradle: jar.archivePath was removed in Gradle 9. --- build.gradle | 2 +- src/main/kotlin/mindurka/coreplugin/CorePlugin.kt | 9 +++++---- src/main/kotlin/mindurka/coreplugin/TerminalInput.kt | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/build.gradle b/build.gradle index 793e462..e434e24 100644 --- a/build.gradle +++ b/build.gradle @@ -152,7 +152,7 @@ tasks.register("generateExtras") { tasks.named("build") { dependsOn ":test" - def jarPath = Paths.get("${jar.archivePath}") + def jarPath = jar.archiveFile.get().asFile.toPath() doLast { Files.createDirectories(workspacePath.resolve(".bin")) Files.copy( diff --git a/src/main/kotlin/mindurka/coreplugin/CorePlugin.kt b/src/main/kotlin/mindurka/coreplugin/CorePlugin.kt index 64f336d..3d44319 100644 --- a/src/main/kotlin/mindurka/coreplugin/CorePlugin.kt +++ b/src/main/kotlin/mindurka/coreplugin/CorePlugin.kt @@ -62,6 +62,7 @@ import mindustry.gen.ConnectCallPacket import mindustry.gen.Groups import mindustry.gen.Player import mindustry.gen.SetTileCallPacket +import mindustry.mod.data.PatchAsset import mindustry.net.Administration import mindustry.world.Block import mindustry.world.blocks.environment.StaticWall @@ -177,8 +178,8 @@ object CorePlugin { on(priority = Priority.Low) { fakeBlockPos.clear() - if (Vars.state.patcher.patches.size > 0 && Vars.state.patcher.patches[0].name == "Mindurka Default Patch") { - Vars.state.patcher.patches.remove(0) + if (Vars.state.data.patches.size > 0 && Vars.state.data.patches[0].name == "Mindurka Default Patch") { + Vars.state.data.patches.remove(0) } fakeBlockKind = run { @@ -252,7 +253,7 @@ object CorePlugin { } } - Vars.state.patcher.apply(Vars.state.patcher.patches.map { it.patch }.apply { insert(0, run { + Vars.state.data.reloadPatches(Vars.state.data.patches.copy().apply { insert(0, PatchAsset(run { val patch = StringBuilder() patch.append("name: Mindurka Default Patch\n") @@ -286,7 +287,7 @@ object CorePlugin { debug{"$patch"} patch.toString() - }) }) + })) }) } Vars.netServer.admins.addActionFilter { act -> if (!(act.type == Administration.ActionType.breakBlock && act.block == fakeBlockKind) || fakeBlockKind == null) return@addActionFilter true diff --git a/src/main/kotlin/mindurka/coreplugin/TerminalInput.kt b/src/main/kotlin/mindurka/coreplugin/TerminalInput.kt index 28c8bb4..158af81 100644 --- a/src/main/kotlin/mindurka/coreplugin/TerminalInput.kt +++ b/src/main/kotlin/mindurka/coreplugin/TerminalInput.kt @@ -39,7 +39,7 @@ fun setupTerminalInput() { } try { - val terminal = TerminalBuilder.builder().jna(true).system(true).dumb(true).build() + val terminal = TerminalBuilder.builder().system(true).dumb(true).build() val reader = LineReaderBuilder.builder().terminal(terminal).build() terminal.enterRawMode() From 08993b74ee862b3f441f19c3b12ac149c0e8508d Mon Sep 17 00:00:00 2001 From: 2b2m22 Date: Fri, 11 Sep 2026 23:00:46 +0300 Subject: [PATCH 2/9] Fix block snapshot payloads; add batched syncBuilds syncBuild sent Consts.syncStream.bytes. ReusableByteOutStream.getBytes() returns the backing buffer, whose length is the capacity, not the number of bytes written -- so every block snapshot carried the whole buffer (which only ever grows) instead of the ~30 bytes of the building. Vanilla uses toByteArray(); so do we now. syncBuilds(con, builds) batches to 800 bytes per packet like NetServer's own block snapshot loop, for callers that need to resync many buildings at once. Calling syncBuild in a loop overflows the UDP write buffer and arcnet drops the connection. --- src/main/kotlin/mindurka/util/ModifyWorld.kt | 55 +++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/mindurka/util/ModifyWorld.kt b/src/main/kotlin/mindurka/util/ModifyWorld.kt index 06b9762..8292910 100644 --- a/src/main/kotlin/mindurka/util/ModifyWorld.kt +++ b/src/main/kotlin/mindurka/util/ModifyWorld.kt @@ -1,5 +1,6 @@ package mindurka.util +import arc.util.Log import arc.util.io.Writes import mindurka.api.Consts import mindustry.Vars @@ -18,6 +19,52 @@ import mindustry.world.Tile import mindustry.world.blocks.environment.Floor object ModifyWorld { + /** Mirrors the private NetServer.maxSnapshotSize. */ + private const val maxSnapshotSize = 800 + + /** + * Synchronize many buildings to one connection. + * + * Use this instead of calling [syncBuild] in a loop. `blockSnapshot` is an unreliable packet, + * so a packet per building goes straight into the server's 41 000-byte UDP write buffer with + * no backpressure; on overflow `arc.net.Server.sendToAllUDP` closes the connection itself + * (`con.close(DcReason.error)`), which the server only notices later as "disappeared". + * Batching to [maxSnapshotSize] is what vanilla's own block snapshot loop does. + */ + @JvmStatic + fun syncBuilds(con: NetConnection, builds: Iterable) { + val prevSyncTarget = NetServer.mdSyncTarget + NetServer.mdSyncTarget = con.player + try { + val writes = Writes(Consts.dataStream) + Consts.syncStream.reset() + var sent = 0 + + for (build in builds) { + Consts.dataStream.writeInt(build.pos()) + Consts.dataStream.writeShort(build.block.id.toInt()) + build.writeAll(writes) + sent++ + + if (Consts.syncStream.size() > maxSnapshotSize) { + Consts.dataStream.flush() + Call.blockSnapshot(con, sent.toShort(), Consts.syncStream.toByteArray()) + sent = 0 + Consts.syncStream.reset() + } + } + + if (sent > 0) { + Consts.dataStream.flush() + Call.blockSnapshot(con, sent.toShort(), Consts.syncStream.toByteArray()) + } + } catch (e: Exception) { + Log.err("Failed to sync buildings", e) + } finally { + NetServer.mdSyncTarget = prevSyncTarget + } + } + /** * Synchronize a building over the network. */ @@ -31,7 +78,10 @@ object ModifyWorld { Consts.dataStream.writeShort(build.block.id.toInt()) build.writeAll(Writes(Consts.dataStream)) Consts.dataStream.close() - val bytes = Consts.syncStream.bytes + // toByteArray(), not .bytes: getBytes() hands back the whole ReusableByteOutStream + // buffer, whose length is its capacity, so the packet carried hundreds of stale bytes + // per building instead of the ~30 actually written. + val bytes = Consts.syncStream.toByteArray() Call.blockSnapshot(con, 1, bytes) } catch (_: Exception) {} finally { NetServer.mdSyncTarget = prevSyncTarget @@ -47,7 +97,8 @@ object ModifyWorld { Consts.dataStream.writeShort(build.block.id.toInt()) build.writeAll(Writes(Consts.dataStream)) Consts.dataStream.close() - val bytes = Consts.syncStream.bytes + // See the note in the overload above. + val bytes = Consts.syncStream.toByteArray() Call.blockSnapshot(1, bytes) } From 0c76c45684281d786735cdf508b55b2c2632c459 Mon Sep 17 00:00:00 2001 From: 2b2m22 Date: Sat, 12 Sep 2026 10:25:23 +0300 Subject: [PATCH 3/9] Stop the post-join setRules packet from killing the connection On join a timer re-sent the whole live ruleset: Call.setRules(con, Vars.state.rules). TypeIO.writeRules serializes Rules as one JSON blob, and arcnet serializes a single object into a 16384-byte buffer (ArcNetProvider: new Server(..., 16384, ...)). Castle keeps its turret/miner tables and 5x5 schematics in Rules.tags, so that blob did not fit: BufferOverflowException inside TcpConnection.send, arcnet closed the connection itself, the player saw 'connection closed' and the server log stayed clean -- arcnet reports that through ArcNet.errorHandler, which only prints at debug level. Now a copy is sent with tags cleared. Nothing in mindustry/core reads Rules.tags on the client, and the client already received them with the world; mdrk.* entries are server-side gamemode config. The serialized size is checked against a 15000-byte limit first, and going over logs a warning instead of silently dropping the player. --- .../kotlin/mindurka/coreplugin/CorePlugin.kt | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/mindurka/coreplugin/CorePlugin.kt b/src/main/kotlin/mindurka/coreplugin/CorePlugin.kt index 3d44319..ca9631f 100644 --- a/src/main/kotlin/mindurka/coreplugin/CorePlugin.kt +++ b/src/main/kotlin/mindurka/coreplugin/CorePlugin.kt @@ -7,6 +7,7 @@ import arc.struct.IntMap import arc.struct.ObjectIntMap import arc.struct.ObjectMap import arc.struct.Seq +import arc.struct.StringMap import arc.util.Log import arc.util.Strings import arc.util.Threads @@ -62,6 +63,7 @@ import mindustry.gen.ConnectCallPacket import mindustry.gen.Groups import mindustry.gen.Player import mindustry.gen.SetTileCallPacket +import mindustry.io.JsonIO import mindustry.mod.data.PatchAsset import mindustry.net.Administration import mindustry.world.Block @@ -75,6 +77,9 @@ import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.hours object CorePlugin { + /** arcnet serializes one object into a 16384-byte buffer (ArcNetProvider: new Server(..., 16384, ...)). */ + private const val rulesPacketLimit = 15_000 + @OptIn(ExperimentalSerializationApi::class) @JvmStatic fun init(loader: ClassLoader) { @@ -498,7 +503,31 @@ object CorePlugin { )) if (restarting) Tl.send(it.player).done("{generic.restart-scheduled}") - timer(0.5f) { Call.setRules(it.player.con, Vars.state.rules) } + timer(0.5f) { + val con = it.player.con + if (con != null) { + // Tags are dropped on purpose. `mdrk.*` entries are server-side gamemode + // config, nothing in the game reads Rules.tags on the client, and the client + // already received them with the world. On a map that packs a lot into them + // -- castle keeps its turret/miner tables and 5x5 schematics there -- the + // serialized ruleset passes arcnet's 16384-byte object buffer, writeRules + // throws BufferOverflowException inside TcpConnection.send, and arcnet closes + // the connection. The player sees "connection closed" and the server log stays + // clean, because arcnet reports that through ArcNet.errorHandler, which only + // prints at debug level. + val rules = Vars.state.rules.copy() + rules.tags = StringMap() + + val size = JsonIO.write(rules).length + if (size > rulesPacketLimit) { + Log.warn( + "Ruleset is @ bytes, over the @ byte packet limit. Not sending it to @ -- it would drop the connection.", + size, rulesPacketLimit, Strings.stripColors(it.player.name)) + } else { + Call.setRules(con, rules) + } + } + } } on { From 48a6834623fd1083c79a07f56e246c92f51e4aaa Mon Sep 17 00:00:00 2001 From: 2b2m22 Date: Sat, 12 Sep 2026 10:33:19 +0300 Subject: [PATCH 4/9] Keep the tags that fit instead of dropping all of them Previous commit cleared Rules.tags outright. Wrong: setRules replaces the client's Rules object wholesale, so that also wiped the mdrk.* tags the compat client reads and that the world had already delivered. Now tags are packed smallest-first until the ruleset reaches the packet limit, and anything left over is named in a warning. mdrk.format and mdrk.gamemode are a few bytes, so they always make it; what gets cut is the per-gamemode tables (castle turrets/miners, forts plot schematics). --- .../kotlin/mindurka/coreplugin/CorePlugin.kt | 64 +++++++++++-------- src/main/kotlin/mindurka/util/ModifyWorld.kt | 15 ++--- 2 files changed, 43 insertions(+), 36 deletions(-) diff --git a/src/main/kotlin/mindurka/coreplugin/CorePlugin.kt b/src/main/kotlin/mindurka/coreplugin/CorePlugin.kt index ca9631f..e9f6cd7 100644 --- a/src/main/kotlin/mindurka/coreplugin/CorePlugin.kt +++ b/src/main/kotlin/mindurka/coreplugin/CorePlugin.kt @@ -80,6 +80,44 @@ object CorePlugin { /** arcnet serializes one object into a 16384-byte buffer (ArcNetProvider: new Server(..., 16384, ...)). */ private const val rulesPacketLimit = 15_000 + /** + * Re-send the ruleset to one player. Tags go smallest-first while they fit: the whole ruleset + * is one JSON blob in one packet, and overflowing arcnet's object buffer makes arcnet drop the + * connection. `setRules` replaces the client's Rules wholesale, so the small `mdrk.*` tags the + * compat client reads must survive. + */ + private fun sendRules(player: Player) { + val con = player.con ?: return + + val rules = Vars.state.rules.copy() + val tags = rules.tags + rules.tags = StringMap() + + var budget = rulesPacketLimit - JsonIO.write(rules).length + + val entries = ArrayList>(tags.size) + tags.each { key, value -> entries.add(key to value) } + entries.sortBy { it.first.length + it.second.length } + + val dropped = ArrayList() + for ((key, value) in entries) { + val cost = key.length + value.length + 8 // quotes, colon, separator + if (cost <= budget) { + budget -= cost + rules.tags.put(key, value) + } else { + dropped.add(key) + } + } + + if (dropped.isNotEmpty()) { + Log.warn("Ruleset does not fit in one packet; @ tag(s) not sent to @: @", + dropped.size, Strings.stripColors(player.name), dropped.joinToString(", ")) + } + + Call.setRules(con, rules) + } + @OptIn(ExperimentalSerializationApi::class) @JvmStatic fun init(loader: ClassLoader) { @@ -503,31 +541,7 @@ object CorePlugin { )) if (restarting) Tl.send(it.player).done("{generic.restart-scheduled}") - timer(0.5f) { - val con = it.player.con - if (con != null) { - // Tags are dropped on purpose. `mdrk.*` entries are server-side gamemode - // config, nothing in the game reads Rules.tags on the client, and the client - // already received them with the world. On a map that packs a lot into them - // -- castle keeps its turret/miner tables and 5x5 schematics there -- the - // serialized ruleset passes arcnet's 16384-byte object buffer, writeRules - // throws BufferOverflowException inside TcpConnection.send, and arcnet closes - // the connection. The player sees "connection closed" and the server log stays - // clean, because arcnet reports that through ArcNet.errorHandler, which only - // prints at debug level. - val rules = Vars.state.rules.copy() - rules.tags = StringMap() - - val size = JsonIO.write(rules).length - if (size > rulesPacketLimit) { - Log.warn( - "Ruleset is @ bytes, over the @ byte packet limit. Not sending it to @ -- it would drop the connection.", - size, rulesPacketLimit, Strings.stripColors(it.player.name)) - } else { - Call.setRules(con, rules) - } - } - } + timer(0.5f) { sendRules(it.player) } } on { diff --git a/src/main/kotlin/mindurka/util/ModifyWorld.kt b/src/main/kotlin/mindurka/util/ModifyWorld.kt index 8292910..e301a98 100644 --- a/src/main/kotlin/mindurka/util/ModifyWorld.kt +++ b/src/main/kotlin/mindurka/util/ModifyWorld.kt @@ -23,13 +23,9 @@ object ModifyWorld { private const val maxSnapshotSize = 800 /** - * Synchronize many buildings to one connection. - * - * Use this instead of calling [syncBuild] in a loop. `blockSnapshot` is an unreliable packet, - * so a packet per building goes straight into the server's 41 000-byte UDP write buffer with - * no backpressure; on overflow `arc.net.Server.sendToAllUDP` closes the connection itself - * (`con.close(DcReason.error)`), which the server only notices later as "disappeared". - * Batching to [maxSnapshotSize] is what vanilla's own block snapshot loop does. + * Synchronize many buildings to one connection, batched like vanilla's own block snapshot + * loop. Use this instead of calling [syncBuild] in a loop: blockSnapshot is unreliable, and a + * packet per building floods the UDP write buffer. */ @JvmStatic fun syncBuilds(con: NetConnection, builds: Iterable) { @@ -78,9 +74,7 @@ object ModifyWorld { Consts.dataStream.writeShort(build.block.id.toInt()) build.writeAll(Writes(Consts.dataStream)) Consts.dataStream.close() - // toByteArray(), not .bytes: getBytes() hands back the whole ReusableByteOutStream - // buffer, whose length is its capacity, so the packet carried hundreds of stale bytes - // per building instead of the ~30 actually written. + // toByteArray(), not .bytes: getBytes() returns the buffer, sized by capacity. val bytes = Consts.syncStream.toByteArray() Call.blockSnapshot(con, 1, bytes) } catch (_: Exception) {} finally { @@ -97,7 +91,6 @@ object ModifyWorld { Consts.dataStream.writeShort(build.block.id.toInt()) build.writeAll(Writes(Consts.dataStream)) Consts.dataStream.close() - // See the note in the overload above. val bytes = Consts.syncStream.toByteArray() Call.blockSnapshot(1, bytes) } From bc557e820686401cbdb43b787b752fb75093a6db Mon Sep 17 00:00:00 2001 From: 2b2m22 Date: Sat, 12 Sep 2026 10:45:43 +0300 Subject: [PATCH 5/9] Move the rules packing into ModifyWorld.syncRules Call.setRules is called from more than the join timer -- forts broadcasts it whenever a modifier is added or removed -- so the size guard has to live where every caller can reach it, not inside CorePlugin. syncRules(con) sends to one connection, syncRules() to everyone, and it now refuses to send at all when the ruleset is over the limit before tags are even added: dropping the refresh beats dropping every client. --- .../kotlin/mindurka/coreplugin/CorePlugin.kt | 45 +--------------- src/main/kotlin/mindurka/util/ModifyWorld.kt | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+), 44 deletions(-) diff --git a/src/main/kotlin/mindurka/coreplugin/CorePlugin.kt b/src/main/kotlin/mindurka/coreplugin/CorePlugin.kt index e9f6cd7..08d97b1 100644 --- a/src/main/kotlin/mindurka/coreplugin/CorePlugin.kt +++ b/src/main/kotlin/mindurka/coreplugin/CorePlugin.kt @@ -7,7 +7,6 @@ import arc.struct.IntMap import arc.struct.ObjectIntMap import arc.struct.ObjectMap import arc.struct.Seq -import arc.struct.StringMap import arc.util.Log import arc.util.Strings import arc.util.Threads @@ -63,7 +62,6 @@ import mindustry.gen.ConnectCallPacket import mindustry.gen.Groups import mindustry.gen.Player import mindustry.gen.SetTileCallPacket -import mindustry.io.JsonIO import mindustry.mod.data.PatchAsset import mindustry.net.Administration import mindustry.world.Block @@ -77,47 +75,6 @@ import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.hours object CorePlugin { - /** arcnet serializes one object into a 16384-byte buffer (ArcNetProvider: new Server(..., 16384, ...)). */ - private const val rulesPacketLimit = 15_000 - - /** - * Re-send the ruleset to one player. Tags go smallest-first while they fit: the whole ruleset - * is one JSON blob in one packet, and overflowing arcnet's object buffer makes arcnet drop the - * connection. `setRules` replaces the client's Rules wholesale, so the small `mdrk.*` tags the - * compat client reads must survive. - */ - private fun sendRules(player: Player) { - val con = player.con ?: return - - val rules = Vars.state.rules.copy() - val tags = rules.tags - rules.tags = StringMap() - - var budget = rulesPacketLimit - JsonIO.write(rules).length - - val entries = ArrayList>(tags.size) - tags.each { key, value -> entries.add(key to value) } - entries.sortBy { it.first.length + it.second.length } - - val dropped = ArrayList() - for ((key, value) in entries) { - val cost = key.length + value.length + 8 // quotes, colon, separator - if (cost <= budget) { - budget -= cost - rules.tags.put(key, value) - } else { - dropped.add(key) - } - } - - if (dropped.isNotEmpty()) { - Log.warn("Ruleset does not fit in one packet; @ tag(s) not sent to @: @", - dropped.size, Strings.stripColors(player.name), dropped.joinToString(", ")) - } - - Call.setRules(con, rules) - } - @OptIn(ExperimentalSerializationApi::class) @JvmStatic fun init(loader: ClassLoader) { @@ -541,7 +498,7 @@ object CorePlugin { )) if (restarting) Tl.send(it.player).done("{generic.restart-scheduled}") - timer(0.5f) { sendRules(it.player) } + timer(0.5f) { it.player.con?.let(ModifyWorld::syncRules) } } on { diff --git a/src/main/kotlin/mindurka/util/ModifyWorld.kt b/src/main/kotlin/mindurka/util/ModifyWorld.kt index e301a98..4e24198 100644 --- a/src/main/kotlin/mindurka/util/ModifyWorld.kt +++ b/src/main/kotlin/mindurka/util/ModifyWorld.kt @@ -1,9 +1,11 @@ package mindurka.util +import arc.struct.StringMap import arc.util.Log import arc.util.io.Writes import mindurka.api.Consts import mindustry.Vars +import mindustry.io.JsonIO import mindustry.content.Blocks import mindustry.core.NetServer import mindustry.game.Team @@ -22,6 +24,56 @@ object ModifyWorld { /** Mirrors the private NetServer.maxSnapshotSize. */ private const val maxSnapshotSize = 800 + /** arcnet serializes one object into a 16384-byte buffer (ArcNetProvider: new Server(..., 16384, ...)). */ + private const val rulesPacketLimit = 15_000 + + /** + * Send the ruleset to one connection, or to everyone when [con] is null. Always use this + * instead of Call.setRules. + * + * Rules go as one JSON blob in one packet, and overflowing arcnet's object buffer makes arcnet + * drop the connection rather than report an error. Tags are packed smallest-first while they + * fit, so the short `mdrk.*` ones the compat client reads survive and only the bulky + * per-gamemode tables are cut. + */ + @JvmStatic + @JvmOverloads + fun syncRules(con: NetConnection? = null) { + val rules = Vars.state.rules.copy() + val tags = rules.tags + rules.tags = StringMap() + + val bare = JsonIO.write(rules).length + if (bare > rulesPacketLimit) { + Log.err("Ruleset is @ B even without tags, over the @ B packet limit. Not syncing it: sending it would drop every client.", + bare, rulesPacketLimit) + return + } + + var budget = rulesPacketLimit - bare + val entries = ArrayList>(tags.size) + tags.each { key, value -> entries.add(key to value) } + entries.sortBy { it.first.length + it.second.length } + + val dropped = ArrayList() + for ((key, value) in entries) { + val cost = key.length + value.length + 8 // quotes, colon, separator + if (cost <= budget) { + budget -= cost + rules.tags.put(key, value) + } else { + dropped.add(key) + } + } + + if (dropped.isNotEmpty()) { + Log.warn("Ruleset does not fit in one packet (@ B without tags); @ tag(s) not synced: @", + bare, dropped.size, dropped.joinToString(", ")) + } + + if (con == null) Call.setRules(rules) else Call.setRules(con, rules) + } + /** * Synchronize many buildings to one connection, batched like vanilla's own block snapshot * loop. Use this instead of calling [syncBuild] in a loop: blockSnapshot is unreliable, and a From be814e4c0a2f6bc592ee7cecfc1dc78688e5f314 Mon Sep 17 00:00:00 2001 From: 2b2m22 Date: Sat, 12 Sep 2026 11:17:39 +0300 Subject: [PATCH 6/9] Log the serialized ruleset size at debug level --- src/main/kotlin/mindurka/util/ModifyWorld.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/mindurka/util/ModifyWorld.kt b/src/main/kotlin/mindurka/util/ModifyWorld.kt index 4e24198..14bc517 100644 --- a/src/main/kotlin/mindurka/util/ModifyWorld.kt +++ b/src/main/kotlin/mindurka/util/ModifyWorld.kt @@ -44,6 +44,7 @@ object ModifyWorld { rules.tags = StringMap() val bare = JsonIO.write(rules).length + Log.debug("Ruleset @ B without tags, @ tag(s), limit @ B", bare, tags.size, rulesPacketLimit) if (bare > rulesPacketLimit) { Log.err("Ruleset is @ B even without tags, over the @ B packet limit. Not syncing it: sending it would drop every client.", bare, rulesPacketLimit) From a1f690cae4ccea63355bcecfea0701cf78ce6637 Mon Sep 17 00:00:00 2001 From: 2b2m22 Date: Sat, 12 Sep 2026 14:30:28 +0300 Subject: [PATCH 7/9] Measure the rules packet in bytes and stop copying the ruleset Two things were wrong with syncRules. The budget was counted in String.length, but TypeIO.writeRules sends JsonIO.write(rules).getBytes(charset) and Vars.charset is UTF-8, so one Cyrillic character costs two of the bytes being budgeted. Between the 15000 limit and arcnet's 16384-byte object buffer there is 1384 bytes of slack, so about 690 non-ASCII characters in the surviving tags were enough for the guard to pass and the packet to still overflow -- the failure it exists to prevent. Sizes now go through utf8Size(), which counts without encoding into a throwaway array. The castle maps in the repo are pure ASCII (22193/17073/17622/55538 B, zero non-ASCII), so this was latent, not live. Rules.copy() was a JSON write plus a parse of the whole 7-22 kB ruleset on every call -- "Not efficient at all, do not use often", per Rules.java itself -- and forts calls this on every modifier toggle. The live ruleset is now borrowed and its tags put back in a finally, which is safe because Call.setRules serializes on the calling thread before it returns (Call -> Net.send -> ArcConnection.send -> Connection.sendTCP -> TcpConnection.send -> serialization.write). Three JSON passes and a parse drop to two passes and no copy. Tag costs are also computed once per tag instead of once per comparison. Three smaller things in the same file, all from the same review: - syncBuild(con, build) swallowed every exception with an empty catch; it now logs the building, like syncBuilds next to it. - syncBuilds used flush() where vanilla's writeBlockSnapshots and syncBuild use close(). - syncBuilds' doc now says vanilla only ever walks BlockFlag.synced blocks, so passing it all of Groups.build is thousands of buildings. --- src/main/kotlin/mindurka/util/ModifyWorld.kt | 88 +++++++++++++------- 1 file changed, 60 insertions(+), 28 deletions(-) diff --git a/src/main/kotlin/mindurka/util/ModifyWorld.kt b/src/main/kotlin/mindurka/util/ModifyWorld.kt index 14bc517..b3653b0 100644 --- a/src/main/kotlin/mindurka/util/ModifyWorld.kt +++ b/src/main/kotlin/mindurka/util/ModifyWorld.kt @@ -39,46 +39,76 @@ object ModifyWorld { @JvmStatic @JvmOverloads fun syncRules(con: NetConnection? = null) { - val rules = Vars.state.rules.copy() + // The live ruleset is borrowed and put back rather than Rules.copy()ied: copy() is a JSON + // write plus a parse of the whole 7-22 kB ruleset ("Not efficient at all, do not use + // often" -- Rules.java), and Call.setRules serializes on this thread before it returns + // (Connection.sendTCP -> TcpConnection.send -> serialization.write). + val rules = Vars.state.rules val tags = rules.tags rules.tags = StringMap() + try { + // TypeIO.writeRules sends JsonIO.write(rules).getBytes(UTF-8), so the budget is bytes, + // not String.length -- one Cyrillic character costs two of them. + val bare = utf8Size(JsonIO.write(rules)) + Log.debug("Ruleset @ B without tags, @ tag(s), limit @ B", bare, tags.size, rulesPacketLimit) + if (bare > rulesPacketLimit) { + Log.err("Ruleset is @ B even without tags, over the @ B packet limit. Not syncing it: sending it would drop every client.", + bare, rulesPacketLimit) + return + } - val bare = JsonIO.write(rules).length - Log.debug("Ruleset @ B without tags, @ tag(s), limit @ B", bare, tags.size, rulesPacketLimit) - if (bare > rulesPacketLimit) { - Log.err("Ruleset is @ B even without tags, over the @ B packet limit. Not syncing it: sending it would drop every client.", - bare, rulesPacketLimit) - return - } + var budget = rulesPacketLimit - bare + val entries = ArrayList>(tags.size) + // + 8 for quotes, colon and separator + tags.each { key, value -> entries.add(Triple(key, value, utf8Size(key) + utf8Size(value) + 8)) } + entries.sortBy { it.third } - var budget = rulesPacketLimit - bare - val entries = ArrayList>(tags.size) - tags.each { key, value -> entries.add(key to value) } - entries.sortBy { it.first.length + it.second.length } + val dropped = ArrayList() + for ((key, value, cost) in entries) { + if (cost <= budget) { + budget -= cost + rules.tags.put(key, value) + } else { + dropped.add(key) + } + } - val dropped = ArrayList() - for ((key, value) in entries) { - val cost = key.length + value.length + 8 // quotes, colon, separator - if (cost <= budget) { - budget -= cost - rules.tags.put(key, value) - } else { - dropped.add(key) + if (dropped.isNotEmpty()) { + Log.warn("Ruleset does not fit in one packet (@ B without tags); @ tag(s) not synced: @", + bare, dropped.size, dropped.joinToString(", ")) } - } - if (dropped.isNotEmpty()) { - Log.warn("Ruleset does not fit in one packet (@ B without tags); @ tag(s) not synced: @", - bare, dropped.size, dropped.joinToString(", ")) + if (con == null) Call.setRules(rules) else Call.setRules(con, rules) + } finally { + rules.tags = tags } + } - if (con == null) Call.setRules(rules) else Call.setRules(con, rules) + /** UTF-8 length of a string, without encoding it into a throwaway array. */ + private fun utf8Size(s: String): Int { + var size = 0 + var i = 0 + while (i < s.length) { + val c = s[i].code + size += when { + c < 0x80 -> 1 + c < 0x800 -> 2 + c in 0xD800..0xDBFF && i + 1 < s.length && s[i + 1].code in 0xDC00..0xDFFF -> { i++; 4 } + else -> 3 + } + i++ + } + return size } /** * Synchronize many buildings to one connection, batched like vanilla's own block snapshot * loop. Use this instead of calling [syncBuild] in a loop: blockSnapshot is unreliable, and a * packet per building floods the UDP write buffer. + * + * Pass the smallest set that answers the question. NetServer.writeBlockSnapshots only ever + * walks blocks flagged BlockFlag.synced; handing this the whole Groups.build is thousands of + * buildings and hundreds of packets in one frame. */ @JvmStatic fun syncBuilds(con: NetConnection, builds: Iterable) { @@ -96,7 +126,7 @@ object ModifyWorld { sent++ if (Consts.syncStream.size() > maxSnapshotSize) { - Consts.dataStream.flush() + Consts.dataStream.close() Call.blockSnapshot(con, sent.toShort(), Consts.syncStream.toByteArray()) sent = 0 Consts.syncStream.reset() @@ -104,7 +134,7 @@ object ModifyWorld { } if (sent > 0) { - Consts.dataStream.flush() + Consts.dataStream.close() Call.blockSnapshot(con, sent.toShort(), Consts.syncStream.toByteArray()) } } catch (e: Exception) { @@ -130,7 +160,9 @@ object ModifyWorld { // toByteArray(), not .bytes: getBytes() returns the buffer, sized by capacity. val bytes = Consts.syncStream.toByteArray() Call.blockSnapshot(con, 1, bytes) - } catch (_: Exception) {} finally { + } catch (e: Exception) { + Log.err("Failed to sync building at ${build.tile}", e) + } finally { NetServer.mdSyncTarget = prevSyncTarget } } From cdf951b30adce3a1ab4fbc4adec633c10c38ddc7 Mon Sep 17 00:00:00 2001 From: 2b2m22 Date: Sat, 12 Sep 2026 14:30:28 +0300 Subject: [PATCH 8/9] Remove the default patch by identity, not by name WorldLoadEvent dropped the previous "Mindurka Default Patch" by comparing PatchAsset.name. That name is not set by the constructor -- PatchAsset(String) only sets a random path, and DataAsset.setPath derives name from it. It is filled later by DataPatcher.apply from the patch's own "name" key (DataPatcher.java:253), and reset to "" when a patch throws (:261). So one broken patch -- a malformed patch.hjson in a gamemode -- makes the check stop matching for good, and every later world load prepends another copy of the default patch to state.data.patches, all of which patcher.apply then walks. The asset is now kept in a field and removed by identity. --- .../kotlin/mindurka/coreplugin/CorePlugin.kt | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/mindurka/coreplugin/CorePlugin.kt b/src/main/kotlin/mindurka/coreplugin/CorePlugin.kt index 08d97b1..4348173 100644 --- a/src/main/kotlin/mindurka/coreplugin/CorePlugin.kt +++ b/src/main/kotlin/mindurka/coreplugin/CorePlugin.kt @@ -144,6 +144,8 @@ object CorePlugin { } private var fakeBlockKind: Block? = null + /** The patch this plugin prepends on every world load, kept so it can be removed by identity. */ + private var defaultPatchAsset: PatchAsset? = null private class FakeBlock( var x: Int, var y: Int, @@ -178,9 +180,11 @@ object CorePlugin { on(priority = Priority.Low) { fakeBlockPos.clear() - if (Vars.state.data.patches.size > 0 && Vars.state.data.patches[0].name == "Mindurka Default Patch") { - Vars.state.data.patches.remove(0) - } + // By identity, not by name: PatchAsset.name is filled by DataPatcher.apply from the + // patch's own "name" key and reset to "" when a patch fails to apply, so matching on + // the name lets a single broken patch make us prepend a new copy every world load. + defaultPatchAsset?.let { Vars.state.data.patches.remove(it, true) } + defaultPatchAsset = null fakeBlockKind = run { for (shift in 0..min(Vars.world.width(), Vars.world.height()) / 2) { @@ -253,7 +257,7 @@ object CorePlugin { } } - Vars.state.data.reloadPatches(Vars.state.data.patches.copy().apply { insert(0, PatchAsset(run { + val asset = PatchAsset(run { val patch = StringBuilder() patch.append("name: Mindurka Default Patch\n") @@ -287,7 +291,9 @@ object CorePlugin { debug{"$patch"} patch.toString() - })) }) + }) + defaultPatchAsset = asset + Vars.state.data.reloadPatches(Vars.state.data.patches.copy().apply { insert(0, asset) }) } Vars.netServer.admins.addActionFilter { act -> if (!(act.type == Administration.ActionType.breakBlock && act.block == fakeBlockKind) || fakeBlockKind == null) return@addActionFilter true From 5e99f5ec2299c00f2bdbb2e675a4f17dc4a900be Mon Sep 17 00:00:00 2001 From: 2b2m22 Date: Sat, 12 Sep 2026 14:43:28 +0300 Subject: [PATCH 9/9] Let the gamemode declare which rules tags the client gets Which tags reached the client was decided by whatever happened to fit: tags were packed smallest-first until the packet limit, so adding one tag to a map could silently push out a different one, and the only trace was a warning. Gamemode.syncedTags is now an explicit list of exact keys or `prefix.*` patterns. Anything not on it is never serialized. Anything on it that still does not fit is logged with Log.err, not warn -- the gamemode said it was needed, so not sending it is a fault, not housekeeping. The default is the four identity markers SpecialSettings parses: mdrk.format, mdrk.gamemode, mindurkaGamemode and mdrk.patch. The gamemode tables (mdrk.castle.*) are read by the server out of the map (CastleUtils.applyRules), and nothing in mindustry/core reads Rules.tags on a client, so they never needed to travel. On the four castle maps in the repo this takes the setRules packet from the 15000 B cap down to 7053-7806 B, which also halves its share of arcnet's 41000-byte per-connection write buffer. --- src/main/kotlin/mindurka/api/Gamemode.kt | 18 +++++++++++ src/main/kotlin/mindurka/util/ModifyWorld.kt | 32 +++++++++++++++----- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/mindurka/api/Gamemode.kt b/src/main/kotlin/mindurka/api/Gamemode.kt index ef31eb8..a4c331c 100644 --- a/src/main/kotlin/mindurka/api/Gamemode.kt +++ b/src/main/kotlin/mindurka/api/Gamemode.kt @@ -311,6 +311,24 @@ object Gamemode { @JvmField var defaultPatch: Prov? = null + /** + * Rules tags that have to reach the client, as exact keys or `prefix.*` patterns. Everything + * else stays server-side. + * + * The whole ruleset travels as one packet with a hard size limit, so it cannot carry + * everything a map puts in `Rules.tags`. Nothing in mindustry/core reads `Rules.tags` on a + * client, and gamemode tables like `mdrk.castle.*` are read by the server + * (CastleUtils.applyRules), so the default is the markers the compat client looks at. + * A tag listed here that does not fit is reported by ModifyWorld.syncRules as an error. + */ + @JvmField + var syncedTags: Seq = Seq.with( + SpecialSettings.FORMAT, + SpecialSettings.GAMEMODE, + SpecialSettings.GAMEMODE_LEGACY, + SpecialSettings.PATCH, + ) + @JvmField var bannedTools: java.util.EnumSet = java.util.EnumSet.noneOf(mindurka.coreplugin.SSTool::class.java) diff --git a/src/main/kotlin/mindurka/util/ModifyWorld.kt b/src/main/kotlin/mindurka/util/ModifyWorld.kt index b3653b0..8f811ff 100644 --- a/src/main/kotlin/mindurka/util/ModifyWorld.kt +++ b/src/main/kotlin/mindurka/util/ModifyWorld.kt @@ -1,7 +1,9 @@ package mindurka.util +import arc.struct.Seq import arc.struct.StringMap import arc.util.Log +import mindurka.api.Gamemode import arc.util.io.Writes import mindurka.api.Consts import mindustry.Vars @@ -32,9 +34,10 @@ object ModifyWorld { * instead of Call.setRules. * * Rules go as one JSON blob in one packet, and overflowing arcnet's object buffer makes arcnet - * drop the connection rather than report an error. Tags are packed smallest-first while they - * fit, so the short `mdrk.*` ones the compat client reads survive and only the bulky - * per-gamemode tables are cut. + * drop the connection rather than report an error. Only the tags the gamemode declares in + * [Gamemode.syncedTags] are sent; the rest never leaves the server. Declared tags are packed + * smallest-first, and anything declared that still does not fit is an error, not a warning -- + * the gamemode said it was needed. */ @JvmStatic @JvmOverloads @@ -58,9 +61,13 @@ object ModifyWorld { } var budget = rulesPacketLimit - bare - val entries = ArrayList>(tags.size) + val wanted = Gamemode.syncedTags + val entries = ArrayList>(wanted.size) // + 8 for quotes, colon and separator - tags.each { key, value -> entries.add(Triple(key, value, utf8Size(key) + utf8Size(value) + 8)) } + tags.each { key, value -> + if (tagWanted(key, wanted)) entries.add(Triple(key, value, utf8Size(key) + utf8Size(value) + 8)) + } + Log.debug("@ of @ tag(s) declared in Gamemode.syncedTags", entries.size, tags.size) entries.sortBy { it.third } val dropped = ArrayList() @@ -74,8 +81,8 @@ object ModifyWorld { } if (dropped.isNotEmpty()) { - Log.warn("Ruleset does not fit in one packet (@ B without tags); @ tag(s) not synced: @", - bare, dropped.size, dropped.joinToString(", ")) + Log.err("Ruleset is @ B without tags, leaving @ B; @ declared tag(s) do not fit and are NOT synced: @", + bare, rulesPacketLimit - bare, dropped.size, dropped.joinToString(", ")) } if (con == null) Call.setRules(rules) else Call.setRules(con, rules) @@ -84,6 +91,17 @@ object ModifyWorld { } } + /** Whether [key] is listed in [patterns], either exactly or by a trailing `*` prefix. */ + private fun tagWanted(key: String, patterns: Seq): Boolean { + for (i in 0 until patterns.size) { + val p = patterns.get(i) + if (p.endsWith("*")) { + if (key.length >= p.length - 1 && key.regionMatches(0, p, 0, p.length - 1)) return true + } else if (p == key) return true + } + return false + } + /** UTF-8 length of a string, without encoding it into a throwaway array. */ private fun utf8Size(s: String): Int { var size = 0