Skip to content
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
18 changes: 18 additions & 0 deletions src/main/kotlin/mindurka/api/Gamemode.kt
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,24 @@ object Gamemode {
@JvmField
var defaultPatch: Prov<String>? = 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<String> = Seq.with(
SpecialSettings.FORMAT,
SpecialSettings.GAMEMODE,
SpecialSettings.GAMEMODE_LEGACY,
SpecialSettings.PATCH,
)

@JvmField
var bannedTools: java.util.EnumSet<mindurka.coreplugin.SSTool> = java.util.EnumSet.noneOf(mindurka.coreplugin.SSTool::class.java)

Expand Down
19 changes: 13 additions & 6 deletions src/main/kotlin/mindurka/coreplugin/CorePlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -143,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,
Expand Down Expand Up @@ -177,9 +180,11 @@ object CorePlugin {
on<EventType.WorldLoadEvent>(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)
}
// 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) {
Expand Down Expand Up @@ -252,7 +257,7 @@ object CorePlugin {
}
}

Vars.state.patcher.apply(Vars.state.patcher.patches.map { it.patch }.apply { insert(0, run {
val asset = PatchAsset(run {
val patch = StringBuilder()

patch.append("name: Mindurka Default Patch\n")
Expand Down Expand Up @@ -286,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
Expand Down Expand Up @@ -497,7 +504,7 @@ 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) { it.player.con?.let(ModifyWorld::syncRules) }
}

on<EventType.BlockBuildEndEvent> {
Expand Down
2 changes: 1 addition & 1 deletion src/main/kotlin/mindurka/coreplugin/TerminalInput.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
153 changes: 150 additions & 3 deletions src/main/kotlin/mindurka/util/ModifyWorld.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
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
import mindustry.io.JsonIO
import mindustry.content.Blocks
import mindustry.core.NetServer
import mindustry.game.Team
Expand All @@ -18,6 +23,145 @@ import mindustry.world.Tile
import mindustry.world.blocks.environment.Floor

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. 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
fun syncRules(con: NetConnection? = null) {
// 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
}

var budget = rulesPacketLimit - bare
val wanted = Gamemode.syncedTags
val entries = ArrayList<Triple<String, String, Int>>(wanted.size)
// + 8 for quotes, colon and separator
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<String>()
for ((key, value, cost) in entries) {
if (cost <= budget) {
budget -= cost
rules.tags.put(key, value)
} else {
dropped.add(key)
}
}

if (dropped.isNotEmpty()) {
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)
} finally {
rules.tags = tags
}
}

/** Whether [key] is listed in [patterns], either exactly or by a trailing `*` prefix. */
private fun tagWanted(key: String, patterns: Seq<String>): 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
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<Building>) {
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.close()
Call.blockSnapshot(con, sent.toShort(), Consts.syncStream.toByteArray())
sent = 0
Consts.syncStream.reset()
}
}

if (sent > 0) {
Consts.dataStream.close()
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.
*/
Expand All @@ -31,9 +175,12 @@ 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() 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
}
}
Expand All @@ -47,7 +194,7 @@ object ModifyWorld {
Consts.dataStream.writeShort(build.block.id.toInt())
build.writeAll(Writes(Consts.dataStream))
Consts.dataStream.close()
val bytes = Consts.syncStream.bytes
val bytes = Consts.syncStream.toByteArray()
Call.blockSnapshot(1, bytes)
}

Expand Down