diff --git a/README.md b/README.md index cf8867d..f61335a 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,108 @@ # Go Minecraft Bridge -Go Minecraft Bridge is a Fabric server-side host for plugins written in Go. The -Minecraft-facing code stays in Java, while plugin logic receives immutable tick -snapshots and returns actions or named system calls. +Go Minecraft Bridge hosts plugins written in Go in both Minecraft server and +client processes. The Minecraft-facing code stays in Java, while plugin logic +receives immutable snapshots/events and returns actions or named system calls. -The current MVP targets: +The shared code is built as two version-specific Fabric artifacts: -- Minecraft `26.1.2`, Fabric Loader `0.19.3`, Fabric API `0.149.1+26.1.2`; -- Java 25 and Go 1.24 or newer; +| Minecraft | Java | Loader | Fabric API | Cloth Config | Mod Menu | +|---|---:|---:|---:|---:|---:| +| `1.21.1` | 21 | `0.16.14` | `0.116.14+1.21.1` | `15.0.140` | `11.0.3` | +| `26.1.2` | 25 | `0.19.3` | `0.149.1+26.1.2` | `26.1.154` | `18.0.0` | + +Architectury Loom drives the portable 1.21.1 target, with a small source +overlay for Minecraft API differences. The unobfuscated 26.1.2 target uses +Fabric Loom because Architectury Loom currently requires a mappings artifact +that this Minecraft distribution does not publish. Architectury portability +still produces one JAR per Minecraft ABI; it does not make one universal JAR. + +The current MVP also targets: + +- Go 1.24 or newer; - native Go plugins built with `-buildmode=c-shared`; -- initialization, tick, chat, death, system-call-result, and deinitialization callbacks; +- initialization, server/client tick, chat, death, system-call-result, and deinitialization callbacks; - entity snapshots and explicit subscriptions to block positions; - chat broadcast/direct-message actions; - extensible namespaced system calls; -- capture of Go `stdout`, `stderr`, and the standard `log` package. +- capture of Go `stdout`, `stderr`, and the standard `log` package; +- FlatBuffers tick snapshots (ABI v2), while control-plane messages remain JSON; +- a Cloth Config management screen exposed through Mod Menu. The backend interface is deliberately independent from Fabric and native FFI so that a WASI/WASM backend can implement the same protocol later. +The server runtime is also packaged as one Paper plugin compatible with Paper +and Purpur. Its Bukkit-facing code is compiled during every build against this +API matrix: + +| Server | Paper API used for verification | Java runtime | +|---|---|---:| +| Paper/Purpur `1.21.1` | `1.21.1-R0.1-SNAPSHOT` | 21+ | +| Paper/Purpur `1.21.11` | `1.21.11-R0.1-SNAPSHOT` | 21+ | +| Paper/Purpur `26.1` | `26.1.2.build.74-stable` | 25+ | + +The Paper API preserves the Bukkit surface used by the bridge, so these targets +share one Java 21 bytecode JAR. Purpur accepts it directly because Purpur is a +Paper-compatible server implementation. + ## Build and try the example -Build the Fabric mod: +Build both Fabric targets: ```bash ./gradlew build ``` +The production JARs are written to: + +```text +versions/1.21.1/build/libs/go-minecraft-bridge-1.21.1-.jar +versions/26.1.2/build/libs/go-minecraft-bridge-26.1.2-.jar +platforms/paper/build/libs/go-minecraft-bridge-paper-.jar +``` + +Run a development client for one target with `./gradlew :mc1211:runClient` or +`./gradlew :mc2612:runClient`. + +## Paper and Purpur installation + +Copy the shaded Paper JAR to the server's `plugins` directory, start the server +once, and put native Go libraries in: + +```text +plugins/GoMinecraftBridge/go-plugins/libmy_plugin.so +``` + +Use `.dll` on Windows and `.dylib` on macOS. Plugin data is stored separately in +`plugins/GoMinecraftBridge/data/`. Paper/Purpur invokes the same ABI +operations as Fabric: metadata, init, server tick, chat, death, system-call +result, and deinit. Snapshots, chat actions, and all built-in system calls use +the public Bukkit/Paper API rather than Minecraft internals. + +An operator or the server console can inspect and manage the runtime with: + +```text +/gmb status +/gmb packages +/gmb metadata +/gmb logs [count] +/gmb reload +/gmb rescan +``` + +On `1.21.1` and `26.1.2`, an operator using the matching Fabric client mod can +also open the existing Cloth Config screen. The Paper plugin exposes the same +`go_minecraft_bridge:admin_request`/`admin_response` management channels, with +package paths, metadata, logs, rescan, and reload withheld from non-OP players. +Large responses are shortened below Paper's plugin-message limit; `/gmb` remains +available for complete server-side output. A separate Fabric client target is +still required before this UI can be used on Minecraft `1.21.11`. + +Lifecycle reload does not unload the native library; replacing an already +loaded binary still requires a full server restart. Start Paper/Purpur with +`--enable-native-access=ALL-UNNAMED`, just like the Fabric server. + Build the example Go plugin: ```bash @@ -41,7 +118,23 @@ config/go-minecraft-bridge/plugins/libhello_native.so Use `.dll` on Windows and `.dylib` on macOS. The plugin reports its own ID, name, version, authors, and config schema through the ABI; no sidecar manifest is -required. +required. Plugins may additionally declare an environment of `server`, `client`, +or `both`; omitted environment metadata remains compatible and means `server`. +Client and `both` plugins installed on a client belong in: + +```text +config/go-minecraft-bridge/client-plugins/libmy_plugin.so +``` + +They run independently of the connected server. Client plugin data is kept in +`config/go-minecraft-bridge/client-data/`; server plugin data remains +under `config/go-minecraft-bridge/data/`. + +The client runtime emits `Init`, `ClientTick`, and `Deinit`, captures plugin +logs, supports local rescan/reload, and permits only the local +`minecraft:client.chat.display` action. Server actions, snapshot subscriptions, +and system calls are rejected in a client process, so a client plugin cannot use +the bridge to bypass a remote server's permissions. Native access must be enabled for the unnamed Java module: @@ -56,6 +149,35 @@ After joining a development server, send `!go` in chat. The example responds directly to the player and requests `minecraft:server.info` through the system call registry. +## Cloth Config management screen + +Install the Cloth Config and Mod Menu versions from the target table, then open +**Mods → Go Minecraft Bridge → Configure**. The screen always shows local client +packages and, when supported by the connected server, server packages. It provides: + +- validation results for native packages found in `plugins` and `mods`; +- plugin metadata, config schema, backend, origin, and lifecycle state; +- the latest retained bridge/SDK/stdout/stderr logs; +- a package rescan that can discover and initialize newly added libraries; +- a logical plugin reload (`Deinit → Init`), including recovery from a disabled state. + +Local client package inspection, logs, rescan, and lifecycle reload do not need +server permission. Server information and controls still require a player from +the server's vanilla OP list. + +Management data and actions are returned only to players present in the +server's vanilla OP list. A non-OP response contains no package paths, plugin +metadata, or logs. Cloth Config and Mod Menu are client-only optional +integrations; a dedicated server only needs Go Minecraft Bridge and Fabric API. +The client mod can remain installed when joining an ordinary server without the +bridge: it detects the missing management channel and disables only the remote +package/plugin screen for that connection. + +Native libraries cannot be safely unloaded from a running JVM. Rescan can load a +new file, and lifecycle reload can restart an existing plugin, but replacing the +bytes of an already loaded `.so`, `.dll`, or `.dylib` still requires a full JVM +restart. + ## Plugin programming model A Go project only imports the SDK and registers one value. The native C exports, @@ -105,6 +227,13 @@ func (myPlugin) Chat(ctx *sdk.Context, event sdk.ChatEvent) error { ctx.SendMessage(event.PlayerUUID, "Hello from Go") return nil } + +func (myPlugin) ClientTick(ctx *sdk.Context, event sdk.ClientTickEvent) error { + if event.Connected && event.Tick%200 == 0 { + ctx.DisplayClientMessage("Client Go runtime is active") + } + return nil +} ``` See [`examples/hello-native/main.go`](examples/hello-native/main.go) for a @@ -134,14 +263,39 @@ plugin. Actions are fire-and-forget operations implemented by the bridge: - `minecraft:chat.broadcast`; -- `minecraft:chat.player`. +- `minecraft:chat.player`; +- `minecraft:client.chat.display` via `ctx.DisplayClientMessage(...)` (client runtime only). System calls return a result to the plugin's `SystemCallResult` callback. Built-in calls currently include: -- `minecraft:server.info`; -- `minecraft:player.get`; -- `minecraft:block.get`. +- `sdk.SystemCallServerInfo` (`minecraft:server.info`); +- `sdk.SystemCallPlayerGet` (`minecraft:player.get`); +- `sdk.SystemCallBlockGet` (`minecraft:block.get`); +- `sdk.SystemCallGetEntity` (`minecraft:get_entity`). + +Built-in calls are requested through the typed `SystemCallType` API: + +```go +ctx.SystemCall(sdk.SystemCallServerInfo, map[string]any{}) + +runtimeID := snapshot.Entities[0].RuntimeID +ctx.SystemCall(sdk.SystemCallGetEntity, sdk.GetEntityRequest{ + RuntimeID: &runtimeID, +}) +``` + +`SystemCallGetEntity` accepts exactly one selector: `UUID` or `RuntimeID`. It +returns a complete `sdk.EntitySnapshot`, including position, velocity, type, +health, and dimension. The result data is JSON `null` when the selected entity +is no longer loaded. + +Calls registered by another mod remain available through the explicitly named +custom API: + +```go +ctx.CustomSystemCall("example:claim.owner", map[string]any{}) +``` Another Java mod can expose a custom call without changing the bridge: @@ -153,10 +307,9 @@ GoMinecraftBridgeApi.systemCalls().register("example:claim.owner", (context, pay }); ``` -Discovered metadata, including each plugin's config schema, is available through -`GoMinecraftBridgeApi.plugins()`. This is the integration point for a future -Mod Menu/configuration screen; Go plugins are intentionally not injected into -Fabric Loader's already-finalized mod list. +Discovered metadata, including each plugin's config schema, is also available +through `GoMinecraftBridgeApi.plugins()`. Go plugins are intentionally not +injected into Fabric Loader's already-finalized mod list. ## Failure model @@ -183,18 +336,23 @@ The wire-level contract is documented in [`docs/native-abi.md`](docs/native-abi. ## Codec and performance -Protocol v1 deliberately uses JSON because it makes the ABI inspectable while -the schema is still changing. JSON is not intended to remain the hot-path codec: -it formats every number as text and allocates while decoding large snapshots. +ABI v2 uses FlatBuffers for the high-frequency Java → Go tick snapshot. Metadata, +initialization, chat/death events, logs, actions, and arbitrary system calls stay +JSON because they are smaller and occur less often. Plugin code still receives a +normal `sdk.ServerSnapshot`; generated FlatBuffers types are internal to the SDK. -The planned split is: +On the current development machine (Ryzen 5 1600), the synthetic 1000-entity Go +benchmark measures roughly `0.65–0.69 ms` per FlatBuffers conversion versus +`8.76–8.99 ms` for JSON decode (about 13× faster). Allocated memory drops from +about `451 KB` to `260 KB`. This benchmark isolates Go decoding; actual server +gain depends on entity count, snapshot subscriptions, Java world traversal, FFI +copying, and plugin work. -- JSON for metadata, configuration, logs, and arbitrary custom system calls; -- a generated binary schema for tick snapshots and action batches; -- codec negotiation through the plugin metadata, without changing the three C - ABI functions. +Run the comparison locally with: -FlatBuffers is the current preferred snapshot codec because Go can read the -snapshot directly from the transferred buffer. Protocol Buffers remains a good -alternative if schema evolution and tooling prove more important than avoiding -decode allocations. +```bash +(cd sdk && go test -bench 'Benchmark(JSON|FlatBuffers)Snapshot1000Entities' -benchmem -run '^$') +``` + +The source schema is [`schema/tick_snapshot.fbs`](schema/tick_snapshot.fbs), and +generated Java/Go readers are checked in so plugin consumers do not need `flatc`. diff --git a/build.gradle b/build.gradle index d652f11..c1019f8 100644 --- a/build.gradle +++ b/build.gradle @@ -1,83 +1,25 @@ plugins { - id 'net.fabricmc.fabric-loom' version "${loom_version}" - id 'maven-publish' + id 'base' + id 'dev.architectury.loom' version "${architectury_loom_version}" apply false + id 'net.fabricmc.fabric-loom' version "${loom_version}" apply false + id 'com.gradleup.shadow' version '9.6.0' apply false } -version = project.mod_version group = project.maven_group +version = project.mod_version -base { - archivesName = project.archives_base_name +tasks.named('build') { + dependsOn ':mc1211:build', ':mc2612:build', ':paper:build' } -repositories { - mavenCentral() - maven { url 'https://maven.shedaniel.me/' } - maven { url 'https://maven.terraformersmc.com/releases/' } +tasks.register('buildAll') { + group = 'build' + description = 'Builds every supported Minecraft target.' + dependsOn ':mc1211:build', ':mc2612:build', ':paper:build' } -loom { - splitEnvironmentSourceSets() - - mods { - go_minecraft_bridge { - sourceSet sourceSets.main - sourceSet sourceSets.client - } - } - - runs { - configureEach { - vmArg '--enable-native-access=ALL-UNNAMED' - } - } -} - -dependencies { - minecraft "com.mojang:minecraft:${project.minecraft_version}" - implementation "net.fabricmc:fabric-loader:${project.loader_version}" - implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" - - implementation 'com.google.code.gson:gson:2.13.2' - implementation 'com.google.flatbuffers:flatbuffers-java:25.2.10' - - modCompileOnly 'me.shedaniel.cloth:cloth-config-fabric:26.1.154' - modCompileOnly 'com.terraformersmc:modmenu:18.0.0' - modLocalRuntime 'me.shedaniel.cloth:cloth-config-fabric:26.1.154' - modLocalRuntime 'com.terraformersmc:modmenu:18.0.0' - - testImplementation platform('org.junit:junit-bom:5.13.4') - testImplementation 'org.junit.jupiter:junit-jupiter' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' -} - -processResources { - inputs.property 'version', project.version - - filesMatching('fabric.mod.json') { - expand 'version': project.version - } -} - -tasks.withType(JavaCompile).configureEach { - options.release = 25 -} - -tasks.withType(Test).configureEach { - useJUnitPlatform() - jvmArgs '--enable-native-access=ALL-UNNAMED' -} - -java { - withSourcesJar() - sourceCompatibility = JavaVersion.VERSION_25 - targetCompatibility = JavaVersion.VERSION_25 -} - -publishing { - publications { - create('mavenJava', MavenPublication) { - from components.java - } - } +tasks.register('testAll') { + group = 'verification' + description = 'Runs tests for every supported Minecraft target.' + dependsOn ':mc1211:test', ':mc2612:test', ':paper:test' } diff --git a/docs/native-abi.md b/docs/native-abi.md index d928d32..ef75eb4 100644 --- a/docs/native-abi.md +++ b/docs/native-abi.md @@ -1,4 +1,4 @@ -# Native ABI v1 +# Native ABI v2 Every native plugin is a Go `main` package built with `-buildmode=c-shared` and exports three C symbols: @@ -20,10 +20,10 @@ void gmb_free(void *pointer); Plugin authors do not implement these symbols. Importing the Go SDK includes their native implementation in the final `c-shared` library. -`gmb_call` receives UTF-8 JSON and returns a buffer allocated by the plugin. The -host copies the buffer and always returns it through `gmb_free`. A zero C status -means the transport succeeded. Go callback errors and panics are represented in -the JSON response rather than the C status. +`gmb_call` receives an operation-specific byte buffer and returns a buffer +allocated by the plugin. The host copies the buffer and always returns it through +`gmb_free`. A zero C status means the transport succeeded. Go callback errors and +panics are represented in the JSON response rather than the C status. ## Operations @@ -31,11 +31,12 @@ the JSON response rather than the C status. |---:|---|---| | 1 | metadata | empty | | 2 | init | `InitEvent` | -| 3 | tick | `ServerSnapshot` | +| 3 | tick | FlatBuffers `ServerSnapshot` (`GMBS`) | | 4 | chat | `ChatEvent` | | 5 | death | `DeathEvent` | | 6 | system call result | `SystemCallResult` | | 7 | deinit | `DeinitEvent` | +| 8 | client tick | `ClientTickEvent` | Every successful transport response uses this envelope: @@ -56,6 +57,40 @@ Every successful transport response uses this envelope: panic. An ordinary handler error is logged but does not disable an already-running plugin. +All inputs except operation 3 are UTF-8 JSON. Server tick input follows +[`schema/tick_snapshot.fbs`](../schema/tick_snapshot.fbs) and includes the +FlatBuffers file identifier `GMBS`. Responses remain UTF-8 JSON because action, +log, subscription, and system-call batches are normally small. A native library +compiled against ABI v1 is rejected by an ABI v2 host before initialization. + All memory ownership stays on its allocating side. Java objects, Go pointers, -and Go structs never cross the boundary. JSON is the v1 transport for clarity; -the same ABI can carry a versioned binary codec in a later protocol revision. +and Go structs never cross the boundary. + +## Plugin environment metadata + +The metadata response may contain an `environment` field with one of `server`, +`client`, or `both`. Server hosts execute `server` and `both` plugins; client +hosts execute `client` and `both` plugins. Metadata without this field predates +the declaration and is treated as `server`, so adding it does not change ABI v2. + +Client libraries are loaded from +`config/go-minecraft-bridge/client-plugins`; their persistent data is isolated +under `config/go-minecraft-bridge/client-data/`. They start even when +the connected server does not have Go Minecraft Bridge installed. The client +tick JSON contains the tick number, connection state, remote address, local +player UUID/name, and current dimension; world-dependent strings are absent +outside a world. + +The client runtime accepts only the `minecraft:client.chat.display` action, +queued by `Context.DisplayClientMessage`. Server actions, snapshot subscriptions, +and system calls are rejected locally and are never forwarded to the connected +server. `InitEvent.runtimeEnvironment` tells a `both` plugin which host invoked +it (`server` or `client`). + +The Paper/Purpur host implements the server side of the same ABI without a +separate Go SDK. Native packages are discovered under +`plugins/GoMinecraftBridge/go-plugins`, and receive `runtimeEnvironment=server`. +The public Bukkit/Paper API supplies entity/block snapshots, actions, and the +built-in Minecraft system calls, so native plugin binaries can be moved between +Fabric, Paper, and Purpur servers without recompilation when the operating +system and CPU architecture match. diff --git a/examples/hello-native/go.mod b/examples/hello-native/go.mod index 16596cb..d692784 100644 --- a/examples/hello-native/go.mod +++ b/examples/hello-native/go.mod @@ -4,4 +4,6 @@ go 1.24.0 require github.com/yawaflua/GoMinecraftBridge/sdk v0.0.0 +require github.com/google/flatbuffers v25.2.10+incompatible // indirect + replace github.com/yawaflua/GoMinecraftBridge/sdk => ../../sdk diff --git a/examples/hello-native/main.go b/examples/hello-native/main.go index 4328d45..15489bf 100644 --- a/examples/hello-native/main.go +++ b/examples/hello-native/main.go @@ -16,6 +16,7 @@ func (helloPlugin) Metadata() sdk.Metadata { Version: "0.1.0", Description: "Native Go plugin example for Go Minecraft Bridge", Authors: []string{"yawaflua"}, + Environment: sdk.PluginEnvironmentBoth, ConfigSchema: map[string]any{ "type": "object", "properties": map[string]any{ @@ -30,18 +31,34 @@ func (helloPlugin) Metadata() sdk.Metadata { func (helloPlugin) Init(context *sdk.Context, event sdk.InitEvent) error { fmt.Printf("initialized for Minecraft %s; data=%s\n", event.MinecraftVersion, event.DataDirectory) - context.SubscribeSnapshot(true, sdk.BlockReference{ - Dimension: "minecraft:overworld", - X: 0, - Y: 64, - Z: 0, - }) + if event.RuntimeEnvironment == sdk.PluginEnvironmentServer { + context.SubscribeSnapshot(true, sdk.BlockReference{ + Dimension: "minecraft:overworld", + X: 0, + Y: 64, + Z: 0, + }) + // When you subscribe to a snapshot, you will receive a snapshot event every tick. + //In that snapshot event, you can access the current state of the world, block that you are subscribed to + } + return nil +} + +func (helloPlugin) ClientTick(context *sdk.Context, event sdk.ClientTickEvent) error { + if event.Connected && event.Tick%1200 == 0 { + context.DisplayClientMessage("Client Go runtime is active") + } return nil } func (helloPlugin) Tick(context *sdk.Context, snapshot sdk.ServerSnapshot) error { if snapshot.Tick%200 == 0 { fmt.Printf("tick=%d entities=%d watched_blocks=%d\n", snapshot.Tick, len(snapshot.Entities), len(snapshot.Blocks)) + if len(snapshot.Entities) > 0 { + runtimeID := snapshot.Entities[0].RuntimeID + context.SystemCall(sdk.SystemCallGetEntity, sdk.GetEntityRequest{RuntimeID: &runtimeID}) + + } } return nil } @@ -52,7 +69,7 @@ func (helloPlugin) Chat(context *sdk.Context, event sdk.ChatEvent) error { } context.SendMessage(event.PlayerUUID, "Hello from a native Go plugin!") - context.SystemCall("minecraft:server.info", map[string]any{}) + context.SystemCall(sdk.SystemCallServerInfo, map[string]any{}) return nil } diff --git a/gradle.properties b/gradle.properties index 5d18865..2dbdfa1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,6 +5,7 @@ org.gradle.configuration-cache=false minecraft_version=26.1.2 loader_version=0.19.3 loom_version=1.16-SNAPSHOT +architectury_loom_version=1.17.491 fabric_api_version=0.149.1+26.1.2 mod_version=0.1.0 diff --git a/platforms/paper/build.gradle b/platforms/paper/build.gradle new file mode 100644 index 0000000..cf97ffb --- /dev/null +++ b/platforms/paper/build.gradle @@ -0,0 +1,142 @@ +plugins { + id 'java' + id 'com.gradleup.shadow' +} + +version = rootProject.mod_version +group = rootProject.maven_group + +base { + archivesName = "${rootProject.archives_base_name}-paper" +} + +repositories { + mavenCentral() + maven { + name = 'papermc' + url = 'https://repo.papermc.io/repository/maven-public/' + } +} + +def sharedRoot = rootProject.projectDir +def prepareMainSources = tasks.register('prepareMainSources', Sync) { + from(sharedRoot.toPath().resolve('src/main/java')) { + include 'dev/yawaflua/gominecraftbridge/backend/**' + include 'dev/yawaflua/gominecraftbridge/management/**' + include 'dev/yawaflua/gominecraftbridge/protocol/**' + include 'dev/yawaflua/gominecraftbridge/host/LoadedPlugin.java' + include 'dev/yawaflua/gominecraftbridge/host/PluginState.java' + } + from(sharedRoot.toPath().resolve('src/main/generated')) + from(file('src/main/java')) + into(layout.buildDirectory.dir('shared-sources/main')) +} + +sourceSets { + main { + java.setSrcDirs([layout.buildDirectory.dir('shared-sources/main')]) + resources.setSrcDirs([file('src/main/resources')]) + } + test { + java.setSrcDirs([sharedRoot.toPath().resolve('src/test/java'), file('src/test/java')]) + java.include 'dev/yawaflua/gominecraftbridge/backend/NativePluginBackendTest.java' + java.include 'dev/yawaflua/gominecraftbridge/host/LoadedPluginMetadataTest.java' + java.include 'dev/yawaflua/gominecraftbridge/paper/**' + } +} + +configurations { + paperApi12111 { + canBeConsumed = false + canBeResolved = true + } + paperApi261 { + canBeConsumed = false + canBeResolved = true + } +} + +dependencies { + compileOnly 'io.papermc.paper:paper-api:1.21.1-R0.1-SNAPSHOT' + paperApi12111 'io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT' + paperApi261 'io.papermc.paper:paper-api:26.1.2.build.74-stable' + + implementation 'com.google.code.gson:gson:2.13.2' + implementation 'com.google.flatbuffers:flatbuffers-java:25.2.10' + implementation 'net.java.dev.jna:jna:5.18.1' + + testImplementation 'io.papermc.paper:paper-api:1.21.1-R0.1-SNAPSHOT' + testImplementation platform('org.junit:junit-bom:5.13.4') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.withType(JavaCompile).configureEach { + dependsOn prepareMainSources + options.release = 21 +} + +def registerPaperCompatibilityCompile = { String taskName, Configuration paperApi -> + tasks.register(taskName, JavaCompile) { + group = 'verification' + description = "Compiles the Paper/Purpur adapter against ${paperApi.name}." + dependsOn prepareMainSources + source = sourceSets.main.java + classpath = paperApi + configurations.runtimeClasspath + destinationDirectory = layout.buildDirectory.dir("compatibility/${paperApi.name}") + options.release = 21 + } +} + +def compilePaper12111Compatibility = registerPaperCompatibilityCompile( + 'compilePaper12111Compatibility', configurations.paperApi12111 +) +def compilePaper261Compatibility = registerPaperCompatibilityCompile( + 'compilePaper261Compatibility', configurations.paperApi261 +) + +tasks.named('check') { + dependsOn compilePaper12111Compatibility, compilePaper261Compatibility +} + +processResources { + inputs.property 'version', project.version + filesMatching('plugin.yml') { + expand 'version': project.version + } +} + +tasks.withType(Test).configureEach { + useJUnitPlatform() + jvmArgs '--enable-native-access=ALL-UNNAMED' +} + +tasks.named('jar') { + enabled = false +} + +tasks.named('shadowJar') { + archiveClassifier = '' + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + relocate 'com.google.gson', 'dev.yawaflua.gominecraftbridge.paper.libs.gson' + relocate 'com.google.flatbuffers', 'dev.yawaflua.gominecraftbridge.paper.libs.flatbuffers' +} + +tasks.named('assemble') { + dependsOn tasks.named('shadowJar') +} + +tasks.named('test') { + dependsOn tasks.named('shadowJar') + systemProperty 'gmb.paper.shadowJar', tasks.named('shadowJar').flatMap { it.archiveFile }.get().asFile +} + +java { + withSourcesJar() + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +tasks.named('sourcesJar') { + dependsOn prepareMainSources +} diff --git a/platforms/paper/src/main/java/dev/yawaflua/gominecraftbridge/paper/PaperActionExecutor.java b/platforms/paper/src/main/java/dev/yawaflua/gominecraftbridge/paper/PaperActionExecutor.java new file mode 100644 index 0000000..794c3a1 --- /dev/null +++ b/platforms/paper/src/main/java/dev/yawaflua/gominecraftbridge/paper/PaperActionExecutor.java @@ -0,0 +1,34 @@ +package dev.yawaflua.gominecraftbridge.paper; + +import com.google.gson.JsonObject; +import dev.yawaflua.gominecraftbridge.protocol.ActionRequest; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import java.util.UUID; + +final class PaperActionExecutor { + void execute(ActionRequest action) { + JsonObject payload = action.payload(); + switch (action.type()) { + case "minecraft:chat.broadcast" -> + Bukkit.broadcastMessage(requiredString(payload, "message")); + case "minecraft:chat.player" -> { + UUID playerId = UUID.fromString(requiredString(payload, "playerUuid")); + Player player = Bukkit.getPlayer(playerId); + if (player == null) { + throw new IllegalArgumentException("Player is not online: " + playerId); + } + player.sendMessage(requiredString(payload, "message")); + } + default -> throw new IllegalArgumentException("Unknown plugin action " + action.type()); + } + } + + private static String requiredString(JsonObject payload, String field) { + if (payload == null || !payload.has(field) || !payload.get(field).isJsonPrimitive()) { + throw new IllegalArgumentException("Action field is required: " + field); + } + return payload.get(field).getAsString(); + } +} diff --git a/platforms/paper/src/main/java/dev/yawaflua/gominecraftbridge/paper/PaperAdminCommand.java b/platforms/paper/src/main/java/dev/yawaflua/gominecraftbridge/paper/PaperAdminCommand.java new file mode 100644 index 0000000..04fbd60 --- /dev/null +++ b/platforms/paper/src/main/java/dev/yawaflua/gominecraftbridge/paper/PaperAdminCommand.java @@ -0,0 +1,189 @@ +package dev.yawaflua.gominecraftbridge.paper; + +import dev.yawaflua.gominecraftbridge.management.BridgeManagementSnapshot; +import dev.yawaflua.gominecraftbridge.management.ManagedPluginSnapshot; +import dev.yawaflua.gominecraftbridge.management.PackageInspection; +import dev.yawaflua.gominecraftbridge.management.ReloadResult; +import dev.yawaflua.gominecraftbridge.protocol.PluginLog; +import dev.yawaflua.gominecraftbridge.protocol.ProtocolJson; +import org.bukkit.ChatColor; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +final class PaperAdminCommand implements CommandExecutor, TabCompleter { + private static final String PREFIX = ChatColor.DARK_AQUA + "[GoBridge] " + ChatColor.RESET; + private final PaperGoPluginManager plugins; + + PaperAdminCommand(PaperGoPluginManager plugins) { + this.plugins = plugins; + } + + @Override + public boolean onCommand( + CommandSender sender, + Command command, + String label, + String[] args + ) { + if (sender instanceof Player player && !player.isOp()) { + sender.sendMessage(PREFIX + ChatColor.RED + "Server operator permission is required."); + return true; + } + + String action = args.length == 0 ? "status" : args[0].toLowerCase(Locale.ROOT); + BridgeManagementSnapshot snapshot = this.plugins.managementSnapshot(null); + switch (action) { + case "status" -> status(sender, snapshot); + case "packages" -> packages(sender, snapshot); + case "metadata" -> metadata(sender, snapshot, args); + case "logs" -> logs(sender, snapshot, args); + case "reload" -> reload(sender, args); + case "rescan" -> result(sender, this.plugins.rescan()); + default -> usage(sender, label); + } + return true; + } + + private static void status(CommandSender sender, BridgeManagementSnapshot snapshot) { + sender.sendMessage(PREFIX + "runtime=" + (snapshot.serverRunning() ? "running" : "stopped") + + ", packages=" + snapshot.packages().size() + ", plugins=" + snapshot.plugins().size()); + for (ManagedPluginSnapshot plugin : snapshot.plugins()) { + sender.sendMessage(" - " + plugin.metadata().id() + " " + plugin.metadata().version() + + " [" + plugin.state() + ", " + + plugin.metadata().environment().name().toLowerCase(Locale.ROOT) + "]"); + } + } + + private static void packages(CommandSender sender, BridgeManagementSnapshot snapshot) { + if (snapshot.packages().isEmpty()) { + sender.sendMessage(PREFIX + "No native packages found."); + return; + } + for (PackageInspection inspected : snapshot.packages()) { + sender.sendMessage((inspected.valid() ? ChatColor.GREEN + "✓ " : ChatColor.RED + "✗ ") + + ChatColor.RESET + inspected.path()); + sender.sendMessage(" " + (inspected.valid() + ? "plugin=" + inspected.pluginId() + : "error=" + inspected.error())); + } + } + + private static void metadata(CommandSender sender, BridgeManagementSnapshot snapshot, String[] args) { + ManagedPluginSnapshot plugin = requiredPlugin(sender, snapshot, args, "metadata"); + if (plugin == null) { + return; + } + var metadata = plugin.metadata(); + sender.sendMessage(PREFIX + metadata.name() + " [" + metadata.id() + "]"); + sender.sendMessage(" version=" + metadata.version() + ", ABI=" + metadata.apiVersion() + + ", environment=" + metadata.environment().name().toLowerCase(Locale.ROOT)); + sender.sendMessage(" state=" + plugin.state() + ", backend=" + plugin.backend()); + sender.sendMessage(" authors=" + String.join(", ", metadata.authors())); + sender.sendMessage(" origin=" + plugin.origin()); + if (metadata.description() != null && !metadata.description().isBlank()) { + sender.sendMessage(" " + metadata.description()); + } + if (metadata.configSchema() != null) { + sender.sendMessage(" schema=" + ProtocolJson.GSON.toJson(metadata.configSchema())); + } + } + + private static void logs(CommandSender sender, BridgeManagementSnapshot snapshot, String[] args) { + ManagedPluginSnapshot plugin = requiredPlugin(sender, snapshot, args, "logs"); + if (plugin == null) { + return; + } + int count = 20; + if (args.length >= 3) { + try { + count = Math.clamp(Integer.parseInt(args[2]), 1, 100); + } catch (NumberFormatException exception) { + sender.sendMessage(PREFIX + ChatColor.RED + "Log count must be an integer from 1 to 100."); + return; + } + } + List logs = plugin.logs(); + int from = Math.max(0, logs.size() - count); + sender.sendMessage(PREFIX + "Last " + (logs.size() - from) + " log entries for " + plugin.metadata().id()); + for (PluginLog log : logs.subList(from, logs.size())) { + sender.sendMessage(" [" + value(log.level()) + "/" + value(log.stream()) + "] " + value(log.message())); + } + } + + private void reload(CommandSender sender, String[] args) { + if (args.length < 2) { + sender.sendMessage(PREFIX + ChatColor.RED + "Usage: /gmb reload "); + return; + } + result(sender, this.plugins.reload(args[1])); + } + + private static ManagedPluginSnapshot requiredPlugin( + CommandSender sender, BridgeManagementSnapshot snapshot, String[] args, String action + ) { + if (args.length < 2) { + sender.sendMessage(PREFIX + ChatColor.RED + "Usage: /gmb " + action + " "); + return null; + } + return snapshot.plugins().stream() + .filter(plugin -> plugin.metadata().id().equals(args[1])) + .findFirst() + .orElseGet(() -> { + sender.sendMessage(PREFIX + ChatColor.RED + "Unknown Go plugin: " + args[1]); + return null; + }); + } + + private static void result(CommandSender sender, ReloadResult result) { + sender.sendMessage(PREFIX + (result.success() ? ChatColor.GREEN : ChatColor.RED) + result.message()); + } + + private static void usage(CommandSender sender, String label) { + sender.sendMessage(PREFIX + "Usage: /" + label + + " |logs [count]|reload |rescan>"); + } + + private static String value(String value) { + return value == null ? "" : value; + } + + @Override + public List onTabComplete( + CommandSender sender, + Command command, + String alias, + String[] args + ) { + if (sender instanceof Player player && !player.isOp()) { + return List.of(); + } + if (args.length == 1) { + return matching(List.of("status", "packages", "metadata", "logs", "reload", "rescan"), args[0]); + } + if (args.length == 2 && List.of("metadata", "logs", "reload").contains(args[0].toLowerCase(Locale.ROOT))) { + List ids = this.plugins.managementSnapshot(null).plugins().stream() + .map(plugin -> plugin.metadata().id()) + .toList(); + return matching(ids, args[1]); + } + return List.of(); + } + + private static List matching(List values, String prefix) { + String lower = prefix.toLowerCase(Locale.ROOT); + List result = new ArrayList<>(); + for (String value : values) { + if (value.toLowerCase(Locale.ROOT).startsWith(lower)) { + result.add(value); + } + } + return result; + } +} diff --git a/platforms/paper/src/main/java/dev/yawaflua/gominecraftbridge/paper/PaperAdminMessaging.java b/platforms/paper/src/main/java/dev/yawaflua/gominecraftbridge/paper/PaperAdminMessaging.java new file mode 100644 index 0000000..9c63a7d --- /dev/null +++ b/platforms/paper/src/main/java/dev/yawaflua/gominecraftbridge/paper/PaperAdminMessaging.java @@ -0,0 +1,203 @@ +package dev.yawaflua.gominecraftbridge.paper; + +import dev.yawaflua.gominecraftbridge.management.BridgeManagementSnapshot; +import dev.yawaflua.gominecraftbridge.management.ManagedPluginSnapshot; +import dev.yawaflua.gominecraftbridge.management.ReloadResult; +import dev.yawaflua.gominecraftbridge.protocol.ProtocolJson; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.messaging.PluginMessageListener; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** Fabric custom-payload compatible management bridge exposed by Paper/Purpur. */ +final class PaperAdminMessaging implements PluginMessageListener { + static final String REQUEST_CHANNEL = "go_minecraft_bridge:admin_request"; + static final String RESPONSE_CHANNEL = "go_minecraft_bridge:admin_response"; + private static final int MAX_PLUGIN_MESSAGE_BYTES = 30_000; + + private final Plugin owner; + private final PaperGoPluginManager plugins; + private final Logger logger; + + PaperAdminMessaging(Plugin owner, PaperGoPluginManager plugins) { + this.owner = owner; + this.plugins = plugins; + this.logger = owner.getLogger(); + } + + @Override + public void onPluginMessageReceived(String channel, Player player, byte[] message) { + if (!REQUEST_CHANNEL.equals(channel)) { + return; + } + try { + AdminRequest request = decodeRequest(message); + if (!player.isOp()) { + send(player, withoutDetails(this.plugins.managementSnapshot( + "Administrator permission is required" + ))); + return; + } + + String responseMessage = null; + if ("reload".equals(request.action())) { + ReloadResult result = this.plugins.reload(request.pluginId()); + responseMessage = result.message(); + } else if ("rescan".equals(request.action())) { + ReloadResult result = this.plugins.rescan(); + responseMessage = result.message(); + } else if (!"refresh".equals(request.action())) { + responseMessage = "Unknown admin action: " + request.action(); + } + send(player, this.plugins.managementSnapshot(responseMessage)); + } catch (RuntimeException exception) { + this.logger.log(Level.WARNING, + "Rejected malformed Go Minecraft Bridge management payload from " + player.getName(), exception); + } + } + + private void send(Player player, BridgeManagementSnapshot snapshot) { + player.sendPluginMessage(this.owner, RESPONSE_CHANNEL, encodeString(boundedJson(snapshot))); + } + + static AdminRequest decodeRequest(byte[] payload) { + Cursor cursor = new Cursor(payload); + String action = cursor.readString(32); + String pluginId = cursor.readString(64); + if (!cursor.atEnd()) { + throw new IllegalArgumentException("Trailing bytes in admin request"); + } + return new AdminRequest(action, pluginId); + } + + static byte[] encodeRequest(String action, String pluginId) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + writeString(output, action, 32); + writeString(output, pluginId, 64); + return output.toByteArray(); + } + + static byte[] encodeString(String value) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + writeString(output, value, MAX_PLUGIN_MESSAGE_BYTES); + return output.toByteArray(); + } + + static String decodeString(byte[] payload) { + Cursor cursor = new Cursor(payload); + String value = cursor.readString(MAX_PLUGIN_MESSAGE_BYTES); + if (!cursor.atEnd()) { + throw new IllegalArgumentException("Trailing bytes after string payload"); + } + return value; + } + + static String boundedJson(BridgeManagementSnapshot snapshot) { + String json = ProtocolJson.GSON.toJson(snapshot); + if (fits(json)) { + return json; + } + for (int retainedLogs : new int[]{20, 5, 0}) { + List trimmedPlugins = new ArrayList<>(); + for (ManagedPluginSnapshot plugin : snapshot.plugins()) { + int from = Math.max(0, plugin.logs().size() - retainedLogs); + trimmedPlugins.add(new ManagedPluginSnapshot( + plugin.metadata(), plugin.state(), plugin.backend(), plugin.origin(), + plugin.logs().subList(from, plugin.logs().size()) + )); + } + json = ProtocolJson.GSON.toJson(new BridgeManagementSnapshot( + snapshot.generatedAtUnixMilli(), snapshot.serverRunning(), snapshot.canReload(), + "Response was shortened to fit the Paper plugin-message limit", + snapshot.packages(), trimmedPlugins + )); + if (fits(json)) { + return json; + } + } + return ProtocolJson.GSON.toJson(new BridgeManagementSnapshot( + snapshot.generatedAtUnixMilli(), snapshot.serverRunning(), snapshot.canReload(), + "Management data exceeded the Paper plugin-message limit; use /gmb for full details", + List.of(), List.of() + )); + } + + private static BridgeManagementSnapshot withoutDetails(BridgeManagementSnapshot snapshot) { + return new BridgeManagementSnapshot( + snapshot.generatedAtUnixMilli(), snapshot.serverRunning(), false, + snapshot.message(), List.of(), List.of() + ); + } + + private static boolean fits(String json) { + return json.getBytes(StandardCharsets.UTF_8).length + 3 <= MAX_PLUGIN_MESSAGE_BYTES; + } + + private static void writeString(ByteArrayOutputStream output, String value, int maxChars) { + String safe = value == null ? "" : value; + if (safe.length() > maxChars) { + throw new IllegalArgumentException("String exceeds " + maxChars + " characters"); + } + byte[] encoded = safe.getBytes(StandardCharsets.UTF_8); + writeVarInt(output, encoded.length); + output.writeBytes(encoded); + } + + private static void writeVarInt(ByteArrayOutputStream output, int value) { + while ((value & -128) != 0) { + output.write(value & 127 | 128); + value >>>= 7; + } + output.write(value); + } + + record AdminRequest(String action, String pluginId) { + } + + private static final class Cursor { + private final byte[] bytes; + private int offset; + + private Cursor(byte[] bytes) { + this.bytes = bytes; + } + + private String readString(int maxChars) { + int byteLength = readVarInt(); + if (byteLength < 0 || byteLength > maxChars * 4 || this.offset + byteLength > this.bytes.length) { + throw new IllegalArgumentException("Invalid UTF-8 byte length " + byteLength); + } + String value = new String(this.bytes, this.offset, byteLength, StandardCharsets.UTF_8); + this.offset += byteLength; + if (value.length() > maxChars) { + throw new IllegalArgumentException("Decoded string exceeds " + maxChars + " characters"); + } + return value; + } + + private int readVarInt() { + int result = 0; + for (int position = 0; position < 35; position += 7) { + if (this.offset >= this.bytes.length) { + throw new IllegalArgumentException("Truncated VarInt"); + } + int current = this.bytes[this.offset++] & 0xFF; + result |= (current & 0x7F) << position; + if ((current & 0x80) == 0) { + return result; + } + } + throw new IllegalArgumentException("VarInt is too large"); + } + + private boolean atEnd() { + return this.offset == this.bytes.length; + } + } +} diff --git a/platforms/paper/src/main/java/dev/yawaflua/gominecraftbridge/paper/PaperBridgePlugin.java b/platforms/paper/src/main/java/dev/yawaflua/gominecraftbridge/paper/PaperBridgePlugin.java new file mode 100644 index 0000000..200ea88 --- /dev/null +++ b/platforms/paper/src/main/java/dev/yawaflua/gominecraftbridge/paper/PaperBridgePlugin.java @@ -0,0 +1,106 @@ +package dev.yawaflua.gominecraftbridge.paper; + +import dev.yawaflua.gominecraftbridge.protocol.ChatEvent; +import dev.yawaflua.gominecraftbridge.protocol.DeathEvent; +import org.bukkit.Bukkit; +import org.bukkit.command.PluginCommand; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Projectile; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.entity.EntityDeathEvent; +import org.bukkit.event.player.AsyncPlayerChatEvent; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.projectiles.ProjectileSource; +import org.bukkit.scheduler.BukkitTask; + +import java.time.Instant; + +public final class PaperBridgePlugin extends JavaPlugin implements Listener { + private final PaperSnapshotFactory snapshots = new PaperSnapshotFactory(); + private PaperGoPluginManager plugins; + private PaperAdminMessaging adminMessaging; + private BukkitTask tickTask; + + @Override + public void onEnable() { + this.plugins = new PaperGoPluginManager(getLogger(), getDataFolder().toPath()); + this.plugins.discover(); + this.plugins.start(); + + Bukkit.getPluginManager().registerEvents(this, this); + PluginCommand command = getCommand("go-minecraft-bridge"); + if (command == null) { + throw new IllegalStateException("go-minecraft-bridge command is missing from plugin.yml"); + } + PaperAdminCommand administrator = new PaperAdminCommand(this.plugins); + command.setExecutor(administrator); + command.setTabCompleter(administrator); + this.adminMessaging = new PaperAdminMessaging(this, this.plugins); + getServer().getMessenger().registerIncomingPluginChannel( + this, PaperAdminMessaging.REQUEST_CHANNEL, this.adminMessaging + ); + getServer().getMessenger().registerOutgoingPluginChannel( + this, PaperAdminMessaging.RESPONSE_CHANNEL + ); + this.tickTask = Bukkit.getScheduler().runTaskTimer(this, this.plugins::tick, 1L, 1L); + + getLogger().info("Go Minecraft Bridge Paper/Purpur runtime enabled"); + } + + @Override + public void onDisable() { + if (this.tickTask != null) { + this.tickTask.cancel(); + } + if (this.plugins != null) { + this.plugins.stop(); + } + getServer().getMessenger().unregisterIncomingPluginChannel(this); + getServer().getMessenger().unregisterOutgoingPluginChannel(this); + } + + @SuppressWarnings("deprecation") + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onChat(AsyncPlayerChatEvent event) { + ChatEvent chat = new ChatEvent( + event.getPlayer().getUniqueId().toString(), + event.getPlayer().getName(), + event.getMessage(), + Instant.now().toEpochMilli() + ); + if (event.isAsynchronous()) { + Bukkit.getScheduler().runTask(this, () -> this.plugins.chat(chat)); + } else { + this.plugins.chat(chat); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onDeath(EntityDeathEvent event) { + EntityDamageEvent damage = event.getEntity().getLastDamageCause(); + String damageType = damage == null ? "unknown" : damage.getCause().name().toLowerCase(); + String attackerUuid = attacker(damage); + this.plugins.death(new DeathEvent( + this.snapshots.entity(event.getEntity()), damageType, attackerUuid, + Instant.now().toEpochMilli() + )); + } + + private static String attacker(EntityDamageEvent damage) { + if (!(damage instanceof EntityDamageByEntityEvent entityDamage)) { + return null; + } + Entity damager = entityDamage.getDamager(); + if (damager instanceof Projectile projectile) { + ProjectileSource shooter = projectile.getShooter(); + if (shooter instanceof Entity entity) { + damager = entity; + } + } + return damager.getUniqueId().toString(); + } +} diff --git a/platforms/paper/src/main/java/dev/yawaflua/gominecraftbridge/paper/PaperGoPluginManager.java b/platforms/paper/src/main/java/dev/yawaflua/gominecraftbridge/paper/PaperGoPluginManager.java new file mode 100644 index 0000000..82ee768 --- /dev/null +++ b/platforms/paper/src/main/java/dev/yawaflua/gominecraftbridge/paper/PaperGoPluginManager.java @@ -0,0 +1,357 @@ +package dev.yawaflua.gominecraftbridge.paper; + +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import dev.yawaflua.gominecraftbridge.backend.nativeffi.NativePluginBackend; +import dev.yawaflua.gominecraftbridge.host.LoadedPlugin; +import dev.yawaflua.gominecraftbridge.host.PluginState; +import dev.yawaflua.gominecraftbridge.management.BridgeManagementSnapshot; +import dev.yawaflua.gominecraftbridge.management.ManagedPluginSnapshot; +import dev.yawaflua.gominecraftbridge.management.PackageInspection; +import dev.yawaflua.gominecraftbridge.management.ReloadResult; +import dev.yawaflua.gominecraftbridge.protocol.ChatEvent; +import dev.yawaflua.gominecraftbridge.protocol.DeathEvent; +import dev.yawaflua.gominecraftbridge.protocol.DeinitEvent; +import dev.yawaflua.gominecraftbridge.protocol.InitEvent; +import dev.yawaflua.gominecraftbridge.protocol.PluginEnvironment; +import dev.yawaflua.gominecraftbridge.protocol.PluginLog; +import dev.yawaflua.gominecraftbridge.protocol.PluginResponse; +import dev.yawaflua.gominecraftbridge.protocol.Protocol; +import dev.yawaflua.gominecraftbridge.protocol.SystemCallRequest; +import dev.yawaflua.gominecraftbridge.protocol.SystemCallResult; +import org.bukkit.Bukkit; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; + +final class PaperGoPluginManager { + private static final int MAX_SYSTEM_CALL_CHAIN = 32; + + private final Logger logger; + private final Path pluginDirectory; + private final Path dataDirectory; + private final Map plugins = new LinkedHashMap<>(); + private final List packageInspections = new ArrayList<>(); + private final PaperSnapshotFactory snapshots = new PaperSnapshotFactory(); + private final PaperActionExecutor actions = new PaperActionExecutor(); + private final PaperSystemCalls systemCalls = new PaperSystemCalls(this.snapshots); + private boolean running; + private long tick; + + PaperGoPluginManager(Logger logger, Path pluginDataDirectory) { + this.logger = logger; + this.pluginDirectory = pluginDataDirectory.resolve("go-plugins"); + this.dataDirectory = pluginDataDirectory.resolve("data"); + } + + synchronized void discover() { + scanCandidates(); + } + + synchronized ReloadResult rescan() { + int before = this.plugins.size(); + scanCandidates(); + return new ReloadResult(true, "Package check completed; new plugins: " + (this.plugins.size() - before)); + } + + private void scanCandidates() { + this.packageInspections.clear(); + try { + Files.createDirectories(this.pluginDirectory); + Files.createDirectories(this.dataDirectory); + } catch (IOException exception) { + throw new IllegalStateException("Cannot create Paper Go plugin directories", exception); + } + + for (Path candidate : nativeCandidates()) { + Path normalized = candidate.toAbsolutePath().normalize(); + try { + LoadedPlugin existing = this.plugins.values().stream() + .filter(plugin -> plugin.backend().origin().equals(normalized)) + .findFirst() + .orElse(null); + if (existing != null) { + this.packageInspections.add(new PackageInspection( + normalized.toString(), true, existing.metadata().id(), null + )); + continue; + } + + LoadedPlugin plugin = new LoadedPlugin(new NativePluginBackend(candidate)); + if (!plugin.metadata().environment().supportsServer()) { + this.packageInspections.add(new PackageInspection( + normalized.toString(), true, plugin.metadata().id(), null + )); + this.logger.info("Skipping client-only Go plugin " + plugin.metadata().id()); + continue; + } + if (this.plugins.putIfAbsent(plugin.metadata().id(), plugin) != null) { + throw new IllegalArgumentException("Duplicate plugin id " + plugin.metadata().id()); + } + this.packageInspections.add(new PackageInspection( + normalized.toString(), true, plugin.metadata().id(), null + )); + bridgeLog(plugin, "info", "Package check passed: " + normalized); + this.logger.info("Discovered Go plugin " + plugin.metadata().name() + + " " + plugin.metadata().version() + " from " + candidate.getFileName()); + if (this.running) { + startPlugin(plugin); + } + } catch (RuntimeException exception) { + this.packageInspections.add(new PackageInspection( + normalized.toString(), false, null, rootMessage(exception) + )); + this.logger.log(Level.SEVERE, "Cannot load Go plugin " + candidate, exception); + } + } + } + + synchronized void start() { + this.running = true; + for (LoadedPlugin plugin : this.plugins.values()) { + startPlugin(plugin); + } + } + + synchronized void tick() { + this.tick++; + for (LoadedPlugin plugin : runningPlugins()) { + invoke(plugin, Protocol.Operation.TICK, + this.snapshots.create(this.tick, plugin.snapshotSubscription())); + } + } + + synchronized void chat(ChatEvent event) { + for (LoadedPlugin plugin : runningPlugins()) { + invoke(plugin, Protocol.Operation.CHAT, event); + } + } + + synchronized void death(DeathEvent event) { + for (LoadedPlugin plugin : runningPlugins()) { + invoke(plugin, Protocol.Operation.DEATH, event); + } + } + + synchronized void stop() { + for (LoadedPlugin plugin : runningPlugins()) { + try { + processResponse(plugin, plugin.invoke( + Protocol.Operation.DEINIT, new DeinitEvent("paper_plugin_disabling") + ), 0); + } catch (RuntimeException exception) { + bridgeLog(plugin, "error", "Deinit failed: " + rootMessage(exception)); + this.logger.log(Level.SEVERE, "Go plugin " + plugin.metadata().id() + " failed during deinit", exception); + } finally { + plugin.markStopped(); + } + } + this.running = false; + } + + synchronized ReloadResult reload(String pluginId) { + if (!this.running) { + return new ReloadResult(false, "Paper plugin runtime is stopped"); + } + LoadedPlugin plugin = this.plugins.get(pluginId); + if (plugin == null) { + return new ReloadResult(false, "Unknown Go plugin: " + pluginId); + } + if (plugin.state() == PluginState.RUNNING) { + try { + processResponse(plugin, plugin.invoke( + Protocol.Operation.DEINIT, new DeinitEvent("paper_admin_reload") + ), 0); + } catch (RuntimeException exception) { + bridgeLog(plugin, "error", "Reload deinit failed: " + rootMessage(exception)); + } + } + plugin.prepareReload(); + boolean started = startPlugin(plugin); + return new ReloadResult(started, started + ? "Go plugin " + pluginId + " restarted (the native binary remains loaded)" + : "Go plugin " + pluginId + " failed to restart"); + } + + synchronized BridgeManagementSnapshot managementSnapshot(String message) { + List pluginSnapshots = this.plugins.values().stream() + .map(plugin -> new ManagedPluginSnapshot( + plugin.metadata(), plugin.state().name().toLowerCase(Locale.ROOT), + plugin.backend().getClass().getSimpleName(), + plugin.backend().origin().toString(), plugin.logs() + )) + .toList(); + return new BridgeManagementSnapshot( + Instant.now().toEpochMilli(), this.running, this.running, message, + List.copyOf(this.packageInspections), pluginSnapshots + ); + } + + private boolean startPlugin(LoadedPlugin plugin) { + try { + Path pluginData = this.dataDirectory.resolve(plugin.metadata().id()); + Files.createDirectories(pluginData); + PluginResponse response = plugin.invoke( + Protocol.Operation.INIT, + new InitEvent( + Bukkit.getMinecraftVersion(), true, + pluginData.toAbsolutePath().toString(), PluginEnvironment.SERVER + ) + ); + processResponse(plugin, response, 0); + if (response.isError()) { + plugin.disable(); + bridgeLog(plugin, "error", "Initialization failed: " + response.error()); + return false; + } + plugin.markRunning(); + bridgeLog(plugin, "info", "Plugin started on Paper/Purpur"); + return true; + } catch (Exception exception) { + disable(plugin, "initialization failed", exception); + return false; + } + } + + private void invoke(LoadedPlugin plugin, Protocol.Operation operation, Object event) { + try { + processResponse(plugin, plugin.invoke(operation, event), 0); + } catch (RuntimeException exception) { + disable(plugin, operation + " failed", exception); + } + } + + private void processResponse(LoadedPlugin plugin, PluginResponse response, int systemCallDepth) { + for (PluginLog log : response.logs()) { + writeLog(plugin, log); + } + if (response.snapshot() != null) { + plugin.snapshotSubscription(response.snapshot()); + } + if (response.isPanic()) { + disable(plugin, "callback panicked: " + response.error(), null); + return; + } + if (response.isError()) { + bridgeLog(plugin, "error", "Callback returned an error: " + response.error()); + } + + response.actions().forEach(action -> { + try { + this.actions.execute(action); + } catch (RuntimeException exception) { + this.logger.log(Level.SEVERE, + "Action " + action.type() + " from " + plugin.metadata().id() + " failed", exception); + } + }); + + if (response.systemCalls().isEmpty()) { + return; + } + if (systemCallDepth >= MAX_SYSTEM_CALL_CHAIN) { + disable(plugin, "system call chain exceeded " + MAX_SYSTEM_CALL_CHAIN, null); + return; + } + for (SystemCallRequest request : response.systemCalls()) { + SystemCallResult result = executeSystemCall(request); + try { + processResponse(plugin, plugin.invoke(Protocol.Operation.SYSTEM_CALL_RESULT, result), systemCallDepth + 1); + } catch (RuntimeException exception) { + disable(plugin, "system call result callback failed", exception); + return; + } + } + } + + private SystemCallResult executeSystemCall(SystemCallRequest request) { + try { + JsonElement result = this.systemCalls.execute( + request.name(), request.payload() == null ? JsonNull.INSTANCE : request.payload(), this.tick + ); + return new SystemCallResult( + request.id(), request.name(), true, result == null ? JsonNull.INSTANCE : result, null + ); + } catch (Exception exception) { + return new SystemCallResult( + request.id(), request.name(), false, JsonNull.INSTANCE, rootMessage(exception) + ); + } + } + + private List runningPlugins() { + return this.plugins.values().stream() + .filter(plugin -> plugin.state() == PluginState.RUNNING) + .toList(); + } + + private List nativeCandidates() { + if (!Files.isDirectory(this.pluginDirectory)) { + return List.of(); + } + try (var files = Files.list(this.pluginDirectory)) { + return files.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(nativeExtension())) + .sorted(Comparator.comparing(Path::toString)) + .toList(); + } catch (IOException exception) { + this.logger.log(Level.SEVERE, "Cannot scan native plugin directory " + this.pluginDirectory, exception); + return List.of(); + } + } + + private static String nativeExtension() { + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + if (os.contains("win")) { + return ".dll"; + } + if (os.contains("mac")) { + return ".dylib"; + } + return ".so"; + } + + private void disable(LoadedPlugin plugin, String reason, Throwable throwable) { + plugin.disable(); + bridgeLog(plugin, "error", "Plugin disabled: " + reason + + (throwable == null ? "" : " — " + rootMessage(throwable))); + if (throwable == null) { + this.logger.severe("Disabled Go plugin " + plugin.metadata().id() + ": " + reason); + } else { + this.logger.log(Level.SEVERE, "Disabled Go plugin " + plugin.metadata().id() + ": " + reason, throwable); + } + } + + private void writeLog(LoadedPlugin plugin, PluginLog log) { + plugin.appendLog(log); + String message = "[Go/" + plugin.metadata().id() + "/" + log.stream() + "] " + log.message(); + Level level = switch (log.level() == null ? "info" : log.level()) { + case "trace", "debug" -> Level.FINE; + case "warn" -> Level.WARNING; + case "error" -> Level.SEVERE; + default -> Level.INFO; + }; + this.logger.log(level, message); + } + + private void bridgeLog(LoadedPlugin plugin, String level, String message) { + plugin.appendLog(new PluginLog("paper-bridge", level, message, Instant.now().toEpochMilli())); + } + + private static String rootMessage(Throwable throwable) { + Throwable current = throwable; + while (current.getCause() != null) { + current = current.getCause(); + } + return current.getMessage() == null ? current.getClass().getSimpleName() : current.getMessage(); + } +} diff --git a/platforms/paper/src/main/java/dev/yawaflua/gominecraftbridge/paper/PaperSnapshotFactory.java b/platforms/paper/src/main/java/dev/yawaflua/gominecraftbridge/paper/PaperSnapshotFactory.java new file mode 100644 index 0000000..49ab2a2 --- /dev/null +++ b/platforms/paper/src/main/java/dev/yawaflua/gominecraftbridge/paper/PaperSnapshotFactory.java @@ -0,0 +1,108 @@ +package dev.yawaflua.gominecraftbridge.paper; + +import dev.yawaflua.gominecraftbridge.protocol.BlockReference; +import dev.yawaflua.gominecraftbridge.protocol.BlockSnapshot; +import dev.yawaflua.gominecraftbridge.protocol.EntitySnapshot; +import dev.yawaflua.gominecraftbridge.protocol.LevelSnapshot; +import dev.yawaflua.gominecraftbridge.protocol.ServerSnapshot; +import dev.yawaflua.gominecraftbridge.protocol.SnapshotSubscription; +import org.bukkit.Bukkit; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.entity.Entity; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.util.Vector; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +final class PaperSnapshotFactory { + ServerSnapshot create(long tick, SnapshotSubscription subscription) { + List levels = Bukkit.getWorlds().stream() + .map(this::level) + .toList(); + List entities = subscription.includesEntities() + ? Bukkit.getWorlds().stream() + .flatMap(world -> world.getEntities().stream()) + .map(this::entity) + .toList() + : List.of(); + List blocks = new ArrayList<>(); + for (BlockReference reference : subscription.blocks()) { + BlockSnapshot snapshot = block(reference); + if (snapshot != null) { + blocks.add(snapshot); + } + } + return new ServerSnapshot(tick, Instant.now().toEpochMilli(), levels, entities, blocks); + } + + EntitySnapshot entity(Entity entity) { + var location = entity.getLocation(); + Vector velocity = entity.getVelocity(); + Float health = null; + Float maxHealth = null; + if (entity instanceof LivingEntity living) { + health = (float) living.getHealth(); + // Kept deliberately: Attribute.GENERIC_MAX_HEALTH was renamed to + // Attribute.MAX_HEALTH after 1.21.1, while this Bukkit method remains + // binary-compatible across the three supported Paper lines. + maxHealth = (float) living.getMaxHealth(); + } + return new EntitySnapshot( + entity.getEntityId(), + entity.getUniqueId().toString(), + entity.getType().getKey().toString(), + entity.getName(), + entity.getWorld().getKey().toString(), + location.getX(), location.getY(), location.getZ(), + location.getYaw(), location.getPitch(), + velocity.getX(), velocity.getY(), velocity.getZ(), + !entity.isDead(), entity instanceof Player, health, maxHealth + ); + } + + private LevelSnapshot level(World world) { + return new LevelSnapshot( + world.getKey().toString(), + world.getFullTime(), + world.getTime(), + world.hasStorm(), + world.isThundering() + ); + } + + private BlockSnapshot block(BlockReference reference) { + NamespacedKey key = NamespacedKey.fromString(reference.dimension()); + World world = key == null ? null : Bukkit.getWorld(key); + if (world == null || !world.isChunkLoaded(reference.x() >> 4, reference.z() >> 4)) { + return null; + } + Block block = world.getBlockAt(reference.x(), reference.y(), reference.z()); + return new BlockSnapshot( + reference.dimension(), reference.x(), reference.y(), reference.z(), + block.getType().getKey().toString(), blockProperties(block.getBlockData().getAsString()) + ); + } + + private static Map blockProperties(String blockData) { + int open = blockData.indexOf('['); + int close = blockData.lastIndexOf(']'); + if (open < 0 || close <= open + 1) { + return Map.of(); + } + Map properties = new LinkedHashMap<>(); + for (String assignment : blockData.substring(open + 1, close).split(",")) { + int equals = assignment.indexOf('='); + if (equals > 0) { + properties.put(assignment.substring(0, equals), assignment.substring(equals + 1)); + } + } + return Map.copyOf(properties); + } +} diff --git a/platforms/paper/src/main/java/dev/yawaflua/gominecraftbridge/paper/PaperSystemCalls.java b/platforms/paper/src/main/java/dev/yawaflua/gominecraftbridge/paper/PaperSystemCalls.java new file mode 100644 index 0000000..ed3841b --- /dev/null +++ b/platforms/paper/src/main/java/dev/yawaflua/gominecraftbridge/paper/PaperSystemCalls.java @@ -0,0 +1,162 @@ +package dev.yawaflua.gominecraftbridge.paper; + +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import dev.yawaflua.gominecraftbridge.protocol.ProtocolJson; +import org.bukkit.Bukkit; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; + +import java.math.BigDecimal; +import java.util.UUID; + +final class PaperSystemCalls { + private final PaperSnapshotFactory snapshots; + + PaperSystemCalls(PaperSnapshotFactory snapshots) { + this.snapshots = snapshots; + } + + JsonElement execute(String name, JsonElement payload, long tick) { + return switch (name) { + case "minecraft:server.info" -> serverInfo(tick); + case "minecraft:player.get" -> playerGet(payload); + case "minecraft:block.get" -> blockGet(payload); + case "minecraft:get_entity" -> entityGet(payload); + default -> throw new IllegalArgumentException("Unknown system call " + name); + }; + } + + private JsonElement serverInfo(long tick) { + JsonObject result = new JsonObject(); + result.addProperty("tick", tick); + result.addProperty("dedicated", true); + result.addProperty("onlinePlayers", Bukkit.getOnlinePlayers().size()); + result.addProperty("maxPlayers", Bukkit.getMaxPlayers()); + result.addProperty("minecraftVersion", Bukkit.getMinecraftVersion()); + result.addProperty("server", Bukkit.getVersion()); + return result; + } + + private JsonElement playerGet(JsonElement payload) { + JsonObject request = object(payload, "minecraft:player.get"); + UUID playerId = UUID.fromString(requiredString(request, "playerUuid")); + Player player = Bukkit.getPlayer(playerId); + if (player == null) { + return JsonNull.INSTANCE; + } + JsonObject result = new JsonObject(); + result.addProperty("uuid", player.getUniqueId().toString()); + result.addProperty("name", player.getName()); + result.addProperty("dimension", player.getWorld().getKey().toString()); + result.addProperty("x", player.getLocation().getX()); + result.addProperty("y", player.getLocation().getY()); + result.addProperty("z", player.getLocation().getZ()); + return result; + } + + private JsonElement blockGet(JsonElement payload) { + JsonObject request = object(payload, "minecraft:block.get"); + String dimension = requiredString(request, "dimension"); + NamespacedKey key = NamespacedKey.fromString(dimension); + World world = key == null ? null : Bukkit.getWorld(key); + if (world == null) { + throw new IllegalArgumentException("Unknown dimension " + dimension); + } + int x = requiredInt(request, "x"); + int y = requiredInt(request, "y"); + int z = requiredInt(request, "z"); + boolean loaded = world.isChunkLoaded(x >> 4, z >> 4); + JsonObject result = new JsonObject(); + result.addProperty("loaded", loaded); + if (loaded) { + Block block = world.getBlockAt(x, y, z); + result.addProperty("block", block.getType().getKey().toString()); + } + return result; + } + + private JsonElement entityGet(JsonElement payload) { + JsonObject request = object(payload, "minecraft:get_entity"); + boolean hasUuid = request.has("uuid"); + boolean hasRuntimeId = request.has("runtimeId"); + if (hasUuid == hasRuntimeId) { + throw new IllegalArgumentException( + "minecraft:get_entity payload must contain exactly one of uuid or runtimeId" + ); + } + + Entity selected = null; + if (hasUuid) { + JsonElement value = request.get("uuid"); + if (!(value instanceof JsonPrimitive primitive) || !primitive.isString()) { + throw new IllegalArgumentException("minecraft:get_entity uuid must be a string"); + } + UUID uuid; + try { + String raw = primitive.getAsString(); + uuid = UUID.fromString(raw); + if (!uuid.toString().equalsIgnoreCase(raw)) { + throw new IllegalArgumentException("non-canonical UUID"); + } + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("minecraft:get_entity uuid is not a valid UUID", exception); + } + for (World world : Bukkit.getWorlds()) { + selected = world.getEntity(uuid); + if (selected != null) { + break; + } + } + } else { + int runtimeId = exactInt(request.get("runtimeId"), "minecraft:get_entity runtimeId"); + for (World world : Bukkit.getWorlds()) { + for (Entity entity : world.getEntities()) { + if (entity.getEntityId() == runtimeId) { + selected = entity; + break; + } + } + if (selected != null) { + break; + } + } + } + return selected == null ? JsonNull.INSTANCE : ProtocolJson.tree(this.snapshots.entity(selected)); + } + + private static JsonObject object(JsonElement payload, String call) { + if (payload == null || !payload.isJsonObject()) { + throw new IllegalArgumentException(call + " payload must be a JSON object"); + } + return payload.getAsJsonObject(); + } + + private static String requiredString(JsonObject object, String field) { + JsonElement value = object.get(field); + if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) { + throw new IllegalArgumentException(field + " must be a string"); + } + return value.getAsString(); + } + + private static int requiredInt(JsonObject object, String field) { + return exactInt(object.get(field), field); + } + + private static int exactInt(JsonElement value, String field) { + if (!(value instanceof JsonPrimitive primitive) || !primitive.isNumber()) { + throw new IllegalArgumentException(field + " must be an integer"); + } + try { + return new BigDecimal(primitive.getAsString()).intValueExact(); + } catch (ArithmeticException | NumberFormatException exception) { + throw new IllegalArgumentException(field + " must be a 32-bit integer", exception); + } + } +} diff --git a/platforms/paper/src/main/resources/plugin.yml b/platforms/paper/src/main/resources/plugin.yml new file mode 100644 index 0000000..abd7b6c --- /dev/null +++ b/platforms/paper/src/main/resources/plugin.yml @@ -0,0 +1,15 @@ +name: GoMinecraftBridge +version: '${version}' +main: dev.yawaflua.gominecraftbridge.paper.PaperBridgePlugin +api-version: '1.21' +description: Native Go plugin host for Paper and Purpur servers +author: yawaflua +commands: + go-minecraft-bridge: + description: Inspect and manage native Go plugins + usage: /gmb + aliases: [gmb, gobridge] +permissions: + go_minecraft_bridge.admin: + description: Allows management of native Go plugins + default: op diff --git a/platforms/paper/src/test/java/dev/yawaflua/gominecraftbridge/paper/PaperAdminMessagingTest.java b/platforms/paper/src/test/java/dev/yawaflua/gominecraftbridge/paper/PaperAdminMessagingTest.java new file mode 100644 index 0000000..43bf758 --- /dev/null +++ b/platforms/paper/src/test/java/dev/yawaflua/gominecraftbridge/paper/PaperAdminMessagingTest.java @@ -0,0 +1,53 @@ +package dev.yawaflua.gominecraftbridge.paper; + +import dev.yawaflua.gominecraftbridge.management.BridgeManagementSnapshot; +import dev.yawaflua.gominecraftbridge.management.ManagedPluginSnapshot; +import dev.yawaflua.gominecraftbridge.protocol.PluginEnvironment; +import dev.yawaflua.gominecraftbridge.protocol.PluginLog; +import dev.yawaflua.gominecraftbridge.protocol.PluginMetadata; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PaperAdminMessagingTest { + @Test + void requestCodecMatchesMinecraftVarIntUtfFormat() { + byte[] encoded = PaperAdminMessaging.encodeRequest("reload", "пример"); + + assertEquals( + new PaperAdminMessaging.AdminRequest("reload", "пример"), + PaperAdminMessaging.decodeRequest(encoded) + ); + assertThrows(IllegalArgumentException.class, () -> + PaperAdminMessaging.decodeRequest(new byte[]{1, 'x', 0, 1})); + } + + @Test + void responseCodecRoundTripsUnicode() { + String input = "Привет from Paper"; + assertEquals(input, PaperAdminMessaging.decodeString(PaperAdminMessaging.encodeString(input))); + } + + @Test + void managementResponseIsTrimmedBelowBukkitMessageLimit() { + PluginMetadata metadata = new PluginMetadata( + "large", "Large", "1", "", List.of(), null, 2, null, PluginEnvironment.SERVER + ); + List logs = java.util.stream.IntStream.range(0, 500) + .mapToObj(index -> new PluginLog("sdk", "info", "Ж".repeat(8192), index)) + .toList(); + BridgeManagementSnapshot snapshot = new BridgeManagementSnapshot( + 1, true, true, null, List.of(), + List.of(new ManagedPluginSnapshot(metadata, "running", "native", "/plugin.so", logs)) + ); + + String json = PaperAdminMessaging.boundedJson(snapshot); + + assertTrue(json.getBytes(StandardCharsets.UTF_8).length + 3 <= 30_000); + } +} diff --git a/platforms/paper/src/test/java/dev/yawaflua/gominecraftbridge/paper/PaperShadedNativeBackendTest.java b/platforms/paper/src/test/java/dev/yawaflua/gominecraftbridge/paper/PaperShadedNativeBackendTest.java new file mode 100644 index 0000000..0ce6c21 --- /dev/null +++ b/platforms/paper/src/test/java/dev/yawaflua/gominecraftbridge/paper/PaperShadedNativeBackendTest.java @@ -0,0 +1,38 @@ +package dev.yawaflua.gominecraftbridge.paper; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@EnabledIfEnvironmentVariable(named = "GMB_TEST_LIBRARY", matches = ".+") +class PaperShadedNativeBackendTest { + @Test + void shadedPaperJarLoadsTheRealGoLibrary() throws Exception { + URL jar = Path.of(System.getProperty("gmb.paper.shadowJar")).toUri().toURL(); + try (URLClassLoader loader = new URLClassLoader( + new URL[]{jar}, ClassLoader.getPlatformClassLoader() + )) { + Class backendType = loader.loadClass( + "dev.yawaflua.gominecraftbridge.backend.nativeffi.NativePluginBackend" + ); + Object backend = backendType.getConstructor(Path.class) + .newInstance(Path.of(System.getenv("GMB_TEST_LIBRARY"))); + Class pluginBackendType = loader.loadClass( + "dev.yawaflua.gominecraftbridge.backend.PluginBackend" + ); + Class loadedPluginType = loader.loadClass( + "dev.yawaflua.gominecraftbridge.host.LoadedPlugin" + ); + Object plugin = loadedPluginType.getConstructor(pluginBackendType).newInstance(backend); + Object metadata = loadedPluginType.getMethod("metadata").invoke(plugin); + String id = (String) metadata.getClass().getMethod("id").invoke(metadata); + + assertEquals("hello_native", id); + } + } +} diff --git a/platforms/paper/src/test/java/dev/yawaflua/gominecraftbridge/paper/PaperSystemCallsTest.java b/platforms/paper/src/test/java/dev/yawaflua/gominecraftbridge/paper/PaperSystemCallsTest.java new file mode 100644 index 0000000..7fa9b8d --- /dev/null +++ b/platforms/paper/src/test/java/dev/yawaflua/gominecraftbridge/paper/PaperSystemCallsTest.java @@ -0,0 +1,31 @@ +package dev.yawaflua.gominecraftbridge.paper; + +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class PaperSystemCallsTest { + private final PaperSystemCalls calls = new PaperSystemCalls(new PaperSnapshotFactory()); + + @Test + void getEntityRequiresExactlyOneSelector() { + assertThrows(IllegalArgumentException.class, () -> + this.calls.execute("minecraft:get_entity", new JsonObject(), 0)); + + JsonObject both = new JsonObject(); + both.addProperty("uuid", "00000000-0000-0000-0000-000000000001"); + both.addProperty("runtimeId", 1); + assertThrows(IllegalArgumentException.class, () -> + this.calls.execute("minecraft:get_entity", both, 0)); + } + + @Test + void rejectsUnknownCallsAndMalformedPayloads() { + assertThrows(IllegalArgumentException.class, () -> + this.calls.execute("example:missing", JsonNull.INSTANCE, 0)); + assertThrows(IllegalArgumentException.class, () -> + this.calls.execute("minecraft:block.get", JsonNull.INSTANCE, 0)); + } +} diff --git a/schema/tick_snapshot.fbs b/schema/tick_snapshot.fbs new file mode 100644 index 0000000..e008259 --- /dev/null +++ b/schema/tick_snapshot.fbs @@ -0,0 +1,55 @@ +namespace gmb; + +table LevelSnapshot { + dimension:string; + game_time:long; + day_time:long; + raining:bool; + thundering:bool; +} + +table EntitySnapshot { + runtime_id:int; + uuid:string; + type:string; + name:string; + dimension:string; + x:double; + y:double; + z:double; + yaw:float; + pitch:float; + velocity_x:double; + velocity_y:double; + velocity_z:double; + alive:bool; + player:bool; + has_health:bool; + health:float; + max_health:float; +} + +table BlockProperty { + key:string; + value:string; +} + +table BlockSnapshot { + dimension:string; + x:int; + y:int; + z:int; + block:string; + properties:[BlockProperty]; +} + +table ServerSnapshot { + tick:long; + timestamp_unix_milli:long; + levels:[LevelSnapshot]; + entities:[EntitySnapshot]; + blocks:[BlockSnapshot]; +} + +root_type ServerSnapshot; +file_identifier "GMBS"; diff --git a/sdk/context.go b/sdk/context.go index 0f2d31d..abd424b 100644 --- a/sdk/context.go +++ b/sdk/context.go @@ -15,6 +15,7 @@ type Context struct { snapshot *SnapshotSubscription } +// Broadcast queues a message to be sent to all players. func (context *Context) Broadcast(message string) { context.actions = append(context.actions, ActionRequest{ Type: "minecraft:chat.broadcast", @@ -24,6 +25,7 @@ func (context *Context) Broadcast(message string) { }) } +// SendMessage queues a message to be sent to a player. func (context *Context) SendMessage(playerUUID, message string) { context.actions = append(context.actions, ActionRequest{ Type: "minecraft:chat.player", @@ -34,7 +36,29 @@ func (context *Context) SendMessage(playerUUID, message string) { }) } -func (context *Context) SystemCall(name string, payload any) string { +// DisplayClientMessage appends a local-only message to the Minecraft client +// chat. Client runtimes reject server action types such as SendMessage. +func (context *Context) DisplayClientMessage(message string) { + context.actions = append(context.actions, ActionRequest{ + Type: "minecraft:client.chat.display", + Payload: map[string]any{ + "message": message, + }, + }) +} + +// SystemCall queues one of the system calls built into the bridge. +func (context *Context) SystemCall(callType SystemCallType, payload any) string { + return context.queueSystemCall(string(callType), payload) +} + +// CustomSystemCall queues a system call registered by another mod. +func (context *Context) CustomSystemCall(name string, payload any) string { + return context.queueSystemCall(name, payload) +} + +// queueSystemCall queues a system call to be executed by the bridge. +func (context *Context) queueSystemCall(name string, payload any) string { id := fmt.Sprintf("call-%d", callSequence.Add(1)) context.systemCalls = append(context.systemCalls, SystemCallRequest{ ID: id, @@ -44,6 +68,7 @@ func (context *Context) SystemCall(name string, payload any) string { return id } +// SubscribeSnapshot queues a snapshot subscription to be executed by the bridge. func (context *Context) SubscribeSnapshot(entities bool, blocks ...BlockReference) { context.snapshot = &SnapshotSubscription{ Entities: entities, @@ -51,6 +76,7 @@ func (context *Context) SubscribeSnapshot(entities bool, blocks ...BlockReferenc } } +// Log queues a log message to be sent to the server. func (context *Context) Log(level, message string) { context.logs = append(context.logs, LogEntry{ Stream: "sdk", diff --git a/sdk/context_test.go b/sdk/context_test.go new file mode 100644 index 0000000..796b319 --- /dev/null +++ b/sdk/context_test.go @@ -0,0 +1,31 @@ +package sdk + +import "testing" + +func TestSystemCallUsesBuiltInType(t *testing.T) { + context := &Context{} + runtimeID := 42 + id := context.SystemCall(SystemCallGetEntity, GetEntityRequest{RuntimeID: &runtimeID}) + + if id == "" { + t.Fatal("SystemCall returned an empty id") + } + if len(context.systemCalls) != 1 { + t.Fatalf("got %d queued calls, want 1", len(context.systemCalls)) + } + if got := context.systemCalls[0].Name; got != "minecraft:get_entity" { + t.Fatalf("queued call name = %q, want %q", got, "minecraft:get_entity") + } +} + +func TestCustomSystemCallKeepsExtensionPoint(t *testing.T) { + context := &Context{} + context.CustomSystemCall("example:claim.owner", nil) + + if len(context.systemCalls) != 1 { + t.Fatalf("got %d queued calls, want 1", len(context.systemCalls)) + } + if got := context.systemCalls[0].Name; got != "example:claim.owner" { + t.Fatalf("queued call name = %q, want %q", got, "example:claim.owner") + } +} diff --git a/sdk/flatbuffers.go b/sdk/flatbuffers.go new file mode 100644 index 0000000..7ed2b56 --- /dev/null +++ b/sdk/flatbuffers.go @@ -0,0 +1,95 @@ +package sdk + +import ( + "fmt" + + flat "github.com/yawaflua/GoMinecraftBridge/sdk/internal/fbs/gmb" +) + +func decodeTickSnapshot(input []byte) (snapshot ServerSnapshot, err error) { + defer func() { + if recovered := recover(); recovered != nil { + err = fmt.Errorf("sdk: invalid FlatBuffers tick snapshot: %v", recovered) + } + }() + + if len(input) < 8 || !flat.ServerSnapshotBufferHasIdentifier(input) { + return snapshot, fmt.Errorf("sdk: tick snapshot is not a GMBS FlatBuffer") + } + + root := flat.GetRootAsServerSnapshot(input, 0) + snapshot.Tick = root.Tick() + snapshot.TimestampUnixMilli = root.TimestampUnixMilli() + + snapshot.Levels = make([]LevelSnapshot, root.LevelsLength()) + var level flat.LevelSnapshot + for index := range snapshot.Levels { + if !root.Levels(&level, index) { + return ServerSnapshot{}, fmt.Errorf("sdk: missing level %d", index) + } + snapshot.Levels[index] = LevelSnapshot{ + Dimension: string(level.Dimension()), + GameTime: level.GameTime(), + DayTime: level.DayTime(), + Raining: level.Raining(), + Thundering: level.Thundering(), + } + } + + snapshot.Entities = make([]EntitySnapshot, root.EntitiesLength()) + var entity flat.EntitySnapshot + for index := range snapshot.Entities { + if !root.Entities(&entity, index) { + return ServerSnapshot{}, fmt.Errorf("sdk: missing entity %d", index) + } + converted := EntitySnapshot{ + RuntimeID: int(entity.RuntimeId()), + UUID: string(entity.Uuid()), + Type: string(entity.Type()), + Name: string(entity.Name()), + Dimension: string(entity.Dimension()), + X: entity.X(), + Y: entity.Y(), + Z: entity.Z(), + Yaw: entity.Yaw(), + Pitch: entity.Pitch(), + VelocityX: entity.VelocityX(), + VelocityY: entity.VelocityY(), + VelocityZ: entity.VelocityZ(), + Alive: entity.Alive(), + Player: entity.Player(), + } + if entity.HasHealth() { + health := entity.Health() + maxHealth := entity.MaxHealth() + converted.Health = &health + converted.MaxHealth = &maxHealth + } + snapshot.Entities[index] = converted + } + + snapshot.Blocks = make([]BlockSnapshot, root.BlocksLength()) + var block flat.BlockSnapshot + var property flat.BlockProperty + for index := range snapshot.Blocks { + if !root.Blocks(&block, index) { + return ServerSnapshot{}, fmt.Errorf("sdk: missing block %d", index) + } + properties := make(map[string]string, block.PropertiesLength()) + for propertyIndex := 0; propertyIndex < block.PropertiesLength(); propertyIndex++ { + if block.Properties(&property, propertyIndex) { + properties[string(property.Key())] = string(property.Value()) + } + } + snapshot.Blocks[index] = BlockSnapshot{ + Dimension: string(block.Dimension()), + X: int(block.X()), + Y: int(block.Y()), + Z: int(block.Z()), + Block: string(block.Block()), + Properties: properties, + } + } + + return snapshot, nil +} diff --git a/sdk/flatbuffers_test.go b/sdk/flatbuffers_test.go new file mode 100644 index 0000000..1a0c018 --- /dev/null +++ b/sdk/flatbuffers_test.go @@ -0,0 +1,103 @@ +package sdk + +import ( + "testing" + + flatbuffers "github.com/google/flatbuffers/go" + flat "github.com/yawaflua/GoMinecraftBridge/sdk/internal/fbs/gmb" +) + +func TestDecodeTickSnapshot(t *testing.T) { + want := benchmarkSnapshot(3) + encoded := encodeBenchmarkFlatBuffer(want) + got, err := decodeTickSnapshot(encoded) + if err != nil { + t.Fatal(err) + } + if got.Tick != want.Tick || len(got.Entities) != 3 || got.Entities[2].UUID != want.Entities[2].UUID { + t.Fatalf("unexpected decoded snapshot: %#v", got) + } +} + +func BenchmarkFlatBuffersSnapshot1000Entities(b *testing.B) { + encoded := encodeBenchmarkFlatBuffer(benchmarkSnapshot(1000)) + b.ReportAllocs() + for b.Loop() { + if _, err := decodeTickSnapshot(encoded); err != nil { + b.Fatal(err) + } + } + b.ReportMetric(float64(len(encoded)), "bytes/snapshot") +} + +func encodeBenchmarkFlatBuffer(snapshot ServerSnapshot) []byte { + builder := flatbuffers.NewBuilder(16 * 1024) + levels := make([]flatbuffers.UOffsetT, len(snapshot.Levels)) + for index, level := range snapshot.Levels { + dimension := builder.CreateString(level.Dimension) + flat.LevelSnapshotStart(builder) + flat.LevelSnapshotAddDimension(builder, dimension) + flat.LevelSnapshotAddGameTime(builder, level.GameTime) + flat.LevelSnapshotAddDayTime(builder, level.DayTime) + flat.LevelSnapshotAddRaining(builder, level.Raining) + flat.LevelSnapshotAddThundering(builder, level.Thundering) + levels[index] = flat.LevelSnapshotEnd(builder) + } + + entities := make([]flatbuffers.UOffsetT, len(snapshot.Entities)) + for index, entity := range snapshot.Entities { + uuid := builder.CreateString(entity.UUID) + entityType := builder.CreateString(entity.Type) + name := builder.CreateString(entity.Name) + dimension := builder.CreateString(entity.Dimension) + hasHealth := entity.Health != nil && entity.MaxHealth != nil + var health, maxHealth float32 + if hasHealth { + health = *entity.Health + maxHealth = *entity.MaxHealth + } + flat.EntitySnapshotStart(builder) + flat.EntitySnapshotAddRuntimeId(builder, int32(entity.RuntimeID)) + flat.EntitySnapshotAddUuid(builder, uuid) + flat.EntitySnapshotAddType(builder, entityType) + flat.EntitySnapshotAddName(builder, name) + flat.EntitySnapshotAddDimension(builder, dimension) + flat.EntitySnapshotAddX(builder, entity.X) + flat.EntitySnapshotAddY(builder, entity.Y) + flat.EntitySnapshotAddZ(builder, entity.Z) + flat.EntitySnapshotAddYaw(builder, entity.Yaw) + flat.EntitySnapshotAddPitch(builder, entity.Pitch) + flat.EntitySnapshotAddVelocityX(builder, entity.VelocityX) + flat.EntitySnapshotAddVelocityY(builder, entity.VelocityY) + flat.EntitySnapshotAddVelocityZ(builder, entity.VelocityZ) + flat.EntitySnapshotAddAlive(builder, entity.Alive) + flat.EntitySnapshotAddPlayer(builder, entity.Player) + flat.EntitySnapshotAddHasHealth(builder, hasHealth) + flat.EntitySnapshotAddHealth(builder, health) + flat.EntitySnapshotAddMaxHealth(builder, maxHealth) + entities[index] = flat.EntitySnapshotEnd(builder) + } + + flat.ServerSnapshotStartLevelsVector(builder, len(levels)) + for index := len(levels) - 1; index >= 0; index-- { + builder.PrependUOffsetT(levels[index]) + } + levelVector := builder.EndVector(len(levels)) + flat.ServerSnapshotStartEntitiesVector(builder, len(entities)) + for index := len(entities) - 1; index >= 0; index-- { + builder.PrependUOffsetT(entities[index]) + } + entityVector := builder.EndVector(len(entities)) + flat.ServerSnapshotStartBlocksVector(builder, 0) + blockVector := builder.EndVector(0) + + flat.ServerSnapshotStart(builder) + flat.ServerSnapshotAddTick(builder, snapshot.Tick) + flat.ServerSnapshotAddTimestampUnixMilli(builder, snapshot.TimestampUnixMilli) + flat.ServerSnapshotAddLevels(builder, levelVector) + flat.ServerSnapshotAddEntities(builder, entityVector) + flat.ServerSnapshotAddBlocks(builder, blockVector) + root := flat.ServerSnapshotEnd(builder) + flat.FinishServerSnapshotBuffer(builder, root) + return builder.FinishedBytes() +} diff --git a/sdk/go.mod b/sdk/go.mod index f8521b2..d73fc93 100644 --- a/sdk/go.mod +++ b/sdk/go.mod @@ -1,3 +1,5 @@ module github.com/yawaflua/GoMinecraftBridge/sdk go 1.24.0 + +require github.com/google/flatbuffers v25.2.10+incompatible diff --git a/sdk/internal/fbs/gmb/BlockProperty.go b/sdk/internal/fbs/gmb/BlockProperty.go new file mode 100644 index 0000000..6f88f41 --- /dev/null +++ b/sdk/internal/fbs/gmb/BlockProperty.go @@ -0,0 +1,71 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package gmb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type BlockProperty struct { + _tab flatbuffers.Table +} + +func GetRootAsBlockProperty(buf []byte, offset flatbuffers.UOffsetT) *BlockProperty { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &BlockProperty{} + x.Init(buf, n+offset) + return x +} + +func FinishBlockPropertyBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsBlockProperty(buf []byte, offset flatbuffers.UOffsetT) *BlockProperty { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &BlockProperty{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedBlockPropertyBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *BlockProperty) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *BlockProperty) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *BlockProperty) Key() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *BlockProperty) Value() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func BlockPropertyStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func BlockPropertyAddKey(builder *flatbuffers.Builder, key flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(key), 0) +} +func BlockPropertyAddValue(builder *flatbuffers.Builder, value flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(value), 0) +} +func BlockPropertyEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/sdk/internal/fbs/gmb/BlockSnapshot.go b/sdk/internal/fbs/gmb/BlockSnapshot.go new file mode 100644 index 0000000..f07a8f4 --- /dev/null +++ b/sdk/internal/fbs/gmb/BlockSnapshot.go @@ -0,0 +1,142 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package gmb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type BlockSnapshot struct { + _tab flatbuffers.Table +} + +func GetRootAsBlockSnapshot(buf []byte, offset flatbuffers.UOffsetT) *BlockSnapshot { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &BlockSnapshot{} + x.Init(buf, n+offset) + return x +} + +func FinishBlockSnapshotBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsBlockSnapshot(buf []byte, offset flatbuffers.UOffsetT) *BlockSnapshot { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &BlockSnapshot{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedBlockSnapshotBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *BlockSnapshot) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *BlockSnapshot) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *BlockSnapshot) Dimension() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *BlockSnapshot) X() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *BlockSnapshot) MutateX(n int32) bool { + return rcv._tab.MutateInt32Slot(6, n) +} + +func (rcv *BlockSnapshot) Y() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *BlockSnapshot) MutateY(n int32) bool { + return rcv._tab.MutateInt32Slot(8, n) +} + +func (rcv *BlockSnapshot) Z() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *BlockSnapshot) MutateZ(n int32) bool { + return rcv._tab.MutateInt32Slot(10, n) +} + +func (rcv *BlockSnapshot) Block() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *BlockSnapshot) Properties(obj *BlockProperty, j int) bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + if o != 0 { + x := rcv._tab.Vector(o) + x += flatbuffers.UOffsetT(j) * 4 + x = rcv._tab.Indirect(x) + obj.Init(rcv._tab.Bytes, x) + return true + } + return false +} + +func (rcv *BlockSnapshot) PropertiesLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func BlockSnapshotStart(builder *flatbuffers.Builder) { + builder.StartObject(6) +} +func BlockSnapshotAddDimension(builder *flatbuffers.Builder, dimension flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(dimension), 0) +} +func BlockSnapshotAddX(builder *flatbuffers.Builder, x int32) { + builder.PrependInt32Slot(1, x, 0) +} +func BlockSnapshotAddY(builder *flatbuffers.Builder, y int32) { + builder.PrependInt32Slot(2, y, 0) +} +func BlockSnapshotAddZ(builder *flatbuffers.Builder, z int32) { + builder.PrependInt32Slot(3, z, 0) +} +func BlockSnapshotAddBlock(builder *flatbuffers.Builder, block flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(block), 0) +} +func BlockSnapshotAddProperties(builder *flatbuffers.Builder, properties flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(properties), 0) +} +func BlockSnapshotStartPropertiesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func BlockSnapshotEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/sdk/internal/fbs/gmb/EntitySnapshot.go b/sdk/internal/fbs/gmb/EntitySnapshot.go new file mode 100644 index 0000000..2059632 --- /dev/null +++ b/sdk/internal/fbs/gmb/EntitySnapshot.go @@ -0,0 +1,303 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package gmb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type EntitySnapshot struct { + _tab flatbuffers.Table +} + +func GetRootAsEntitySnapshot(buf []byte, offset flatbuffers.UOffsetT) *EntitySnapshot { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &EntitySnapshot{} + x.Init(buf, n+offset) + return x +} + +func FinishEntitySnapshotBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsEntitySnapshot(buf []byte, offset flatbuffers.UOffsetT) *EntitySnapshot { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &EntitySnapshot{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedEntitySnapshotBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *EntitySnapshot) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *EntitySnapshot) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *EntitySnapshot) RuntimeId() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *EntitySnapshot) MutateRuntimeId(n int32) bool { + return rcv._tab.MutateInt32Slot(4, n) +} + +func (rcv *EntitySnapshot) Uuid() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *EntitySnapshot) Type() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *EntitySnapshot) Name() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *EntitySnapshot) Dimension() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *EntitySnapshot) X() float64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + if o != 0 { + return rcv._tab.GetFloat64(o + rcv._tab.Pos) + } + return 0.0 +} + +func (rcv *EntitySnapshot) MutateX(n float64) bool { + return rcv._tab.MutateFloat64Slot(14, n) +} + +func (rcv *EntitySnapshot) Y() float64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(16)) + if o != 0 { + return rcv._tab.GetFloat64(o + rcv._tab.Pos) + } + return 0.0 +} + +func (rcv *EntitySnapshot) MutateY(n float64) bool { + return rcv._tab.MutateFloat64Slot(16, n) +} + +func (rcv *EntitySnapshot) Z() float64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(18)) + if o != 0 { + return rcv._tab.GetFloat64(o + rcv._tab.Pos) + } + return 0.0 +} + +func (rcv *EntitySnapshot) MutateZ(n float64) bool { + return rcv._tab.MutateFloat64Slot(18, n) +} + +func (rcv *EntitySnapshot) Yaw() float32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(20)) + if o != 0 { + return rcv._tab.GetFloat32(o + rcv._tab.Pos) + } + return 0.0 +} + +func (rcv *EntitySnapshot) MutateYaw(n float32) bool { + return rcv._tab.MutateFloat32Slot(20, n) +} + +func (rcv *EntitySnapshot) Pitch() float32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(22)) + if o != 0 { + return rcv._tab.GetFloat32(o + rcv._tab.Pos) + } + return 0.0 +} + +func (rcv *EntitySnapshot) MutatePitch(n float32) bool { + return rcv._tab.MutateFloat32Slot(22, n) +} + +func (rcv *EntitySnapshot) VelocityX() float64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(24)) + if o != 0 { + return rcv._tab.GetFloat64(o + rcv._tab.Pos) + } + return 0.0 +} + +func (rcv *EntitySnapshot) MutateVelocityX(n float64) bool { + return rcv._tab.MutateFloat64Slot(24, n) +} + +func (rcv *EntitySnapshot) VelocityY() float64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(26)) + if o != 0 { + return rcv._tab.GetFloat64(o + rcv._tab.Pos) + } + return 0.0 +} + +func (rcv *EntitySnapshot) MutateVelocityY(n float64) bool { + return rcv._tab.MutateFloat64Slot(26, n) +} + +func (rcv *EntitySnapshot) VelocityZ() float64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(28)) + if o != 0 { + return rcv._tab.GetFloat64(o + rcv._tab.Pos) + } + return 0.0 +} + +func (rcv *EntitySnapshot) MutateVelocityZ(n float64) bool { + return rcv._tab.MutateFloat64Slot(28, n) +} + +func (rcv *EntitySnapshot) Alive() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(30)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *EntitySnapshot) MutateAlive(n bool) bool { + return rcv._tab.MutateBoolSlot(30, n) +} + +func (rcv *EntitySnapshot) Player() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(32)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *EntitySnapshot) MutatePlayer(n bool) bool { + return rcv._tab.MutateBoolSlot(32, n) +} + +func (rcv *EntitySnapshot) HasHealth() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(34)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *EntitySnapshot) MutateHasHealth(n bool) bool { + return rcv._tab.MutateBoolSlot(34, n) +} + +func (rcv *EntitySnapshot) Health() float32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(36)) + if o != 0 { + return rcv._tab.GetFloat32(o + rcv._tab.Pos) + } + return 0.0 +} + +func (rcv *EntitySnapshot) MutateHealth(n float32) bool { + return rcv._tab.MutateFloat32Slot(36, n) +} + +func (rcv *EntitySnapshot) MaxHealth() float32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(38)) + if o != 0 { + return rcv._tab.GetFloat32(o + rcv._tab.Pos) + } + return 0.0 +} + +func (rcv *EntitySnapshot) MutateMaxHealth(n float32) bool { + return rcv._tab.MutateFloat32Slot(38, n) +} + +func EntitySnapshotStart(builder *flatbuffers.Builder) { + builder.StartObject(18) +} +func EntitySnapshotAddRuntimeId(builder *flatbuffers.Builder, runtimeId int32) { + builder.PrependInt32Slot(0, runtimeId, 0) +} +func EntitySnapshotAddUuid(builder *flatbuffers.Builder, uuid flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(uuid), 0) +} +func EntitySnapshotAddType(builder *flatbuffers.Builder, type_ flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(type_), 0) +} +func EntitySnapshotAddName(builder *flatbuffers.Builder, name flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(name), 0) +} +func EntitySnapshotAddDimension(builder *flatbuffers.Builder, dimension flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(dimension), 0) +} +func EntitySnapshotAddX(builder *flatbuffers.Builder, x float64) { + builder.PrependFloat64Slot(5, x, 0.0) +} +func EntitySnapshotAddY(builder *flatbuffers.Builder, y float64) { + builder.PrependFloat64Slot(6, y, 0.0) +} +func EntitySnapshotAddZ(builder *flatbuffers.Builder, z float64) { + builder.PrependFloat64Slot(7, z, 0.0) +} +func EntitySnapshotAddYaw(builder *flatbuffers.Builder, yaw float32) { + builder.PrependFloat32Slot(8, yaw, 0.0) +} +func EntitySnapshotAddPitch(builder *flatbuffers.Builder, pitch float32) { + builder.PrependFloat32Slot(9, pitch, 0.0) +} +func EntitySnapshotAddVelocityX(builder *flatbuffers.Builder, velocityX float64) { + builder.PrependFloat64Slot(10, velocityX, 0.0) +} +func EntitySnapshotAddVelocityY(builder *flatbuffers.Builder, velocityY float64) { + builder.PrependFloat64Slot(11, velocityY, 0.0) +} +func EntitySnapshotAddVelocityZ(builder *flatbuffers.Builder, velocityZ float64) { + builder.PrependFloat64Slot(12, velocityZ, 0.0) +} +func EntitySnapshotAddAlive(builder *flatbuffers.Builder, alive bool) { + builder.PrependBoolSlot(13, alive, false) +} +func EntitySnapshotAddPlayer(builder *flatbuffers.Builder, player bool) { + builder.PrependBoolSlot(14, player, false) +} +func EntitySnapshotAddHasHealth(builder *flatbuffers.Builder, hasHealth bool) { + builder.PrependBoolSlot(15, hasHealth, false) +} +func EntitySnapshotAddHealth(builder *flatbuffers.Builder, health float32) { + builder.PrependFloat32Slot(16, health, 0.0) +} +func EntitySnapshotAddMaxHealth(builder *flatbuffers.Builder, maxHealth float32) { + builder.PrependFloat32Slot(17, maxHealth, 0.0) +} +func EntitySnapshotEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/sdk/internal/fbs/gmb/LevelSnapshot.go b/sdk/internal/fbs/gmb/LevelSnapshot.go new file mode 100644 index 0000000..ae83937 --- /dev/null +++ b/sdk/internal/fbs/gmb/LevelSnapshot.go @@ -0,0 +1,120 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package gmb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type LevelSnapshot struct { + _tab flatbuffers.Table +} + +func GetRootAsLevelSnapshot(buf []byte, offset flatbuffers.UOffsetT) *LevelSnapshot { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &LevelSnapshot{} + x.Init(buf, n+offset) + return x +} + +func FinishLevelSnapshotBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsLevelSnapshot(buf []byte, offset flatbuffers.UOffsetT) *LevelSnapshot { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &LevelSnapshot{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedLevelSnapshotBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *LevelSnapshot) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *LevelSnapshot) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *LevelSnapshot) Dimension() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *LevelSnapshot) GameTime() int64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.GetInt64(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *LevelSnapshot) MutateGameTime(n int64) bool { + return rcv._tab.MutateInt64Slot(6, n) +} + +func (rcv *LevelSnapshot) DayTime() int64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.GetInt64(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *LevelSnapshot) MutateDayTime(n int64) bool { + return rcv._tab.MutateInt64Slot(8, n) +} + +func (rcv *LevelSnapshot) Raining() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *LevelSnapshot) MutateRaining(n bool) bool { + return rcv._tab.MutateBoolSlot(10, n) +} + +func (rcv *LevelSnapshot) Thundering() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *LevelSnapshot) MutateThundering(n bool) bool { + return rcv._tab.MutateBoolSlot(12, n) +} + +func LevelSnapshotStart(builder *flatbuffers.Builder) { + builder.StartObject(5) +} +func LevelSnapshotAddDimension(builder *flatbuffers.Builder, dimension flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(dimension), 0) +} +func LevelSnapshotAddGameTime(builder *flatbuffers.Builder, gameTime int64) { + builder.PrependInt64Slot(1, gameTime, 0) +} +func LevelSnapshotAddDayTime(builder *flatbuffers.Builder, dayTime int64) { + builder.PrependInt64Slot(2, dayTime, 0) +} +func LevelSnapshotAddRaining(builder *flatbuffers.Builder, raining bool) { + builder.PrependBoolSlot(3, raining, false) +} +func LevelSnapshotAddThundering(builder *flatbuffers.Builder, thundering bool) { + builder.PrependBoolSlot(4, thundering, false) +} +func LevelSnapshotEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/sdk/internal/fbs/gmb/ServerSnapshot.go b/sdk/internal/fbs/gmb/ServerSnapshot.go new file mode 100644 index 0000000..ea37ae8 --- /dev/null +++ b/sdk/internal/fbs/gmb/ServerSnapshot.go @@ -0,0 +1,169 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package gmb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type ServerSnapshot struct { + _tab flatbuffers.Table +} + +const ServerSnapshotIdentifier = "GMBS" + +func GetRootAsServerSnapshot(buf []byte, offset flatbuffers.UOffsetT) *ServerSnapshot { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &ServerSnapshot{} + x.Init(buf, n+offset) + return x +} + +func FinishServerSnapshotBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + identifierBytes := []byte(ServerSnapshotIdentifier) + builder.FinishWithFileIdentifier(offset, identifierBytes) +} + +func ServerSnapshotBufferHasIdentifier(buf []byte) bool { + return flatbuffers.BufferHasIdentifier(buf, ServerSnapshotIdentifier) +} + +func GetSizePrefixedRootAsServerSnapshot(buf []byte, offset flatbuffers.UOffsetT) *ServerSnapshot { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &ServerSnapshot{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedServerSnapshotBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + identifierBytes := []byte(ServerSnapshotIdentifier) + builder.FinishSizePrefixedWithFileIdentifier(offset, identifierBytes) +} + +func SizePrefixedServerSnapshotBufferHasIdentifier(buf []byte) bool { + return flatbuffers.SizePrefixedBufferHasIdentifier(buf, ServerSnapshotIdentifier) +} + +func (rcv *ServerSnapshot) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *ServerSnapshot) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *ServerSnapshot) Tick() int64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.GetInt64(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *ServerSnapshot) MutateTick(n int64) bool { + return rcv._tab.MutateInt64Slot(4, n) +} + +func (rcv *ServerSnapshot) TimestampUnixMilli() int64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.GetInt64(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *ServerSnapshot) MutateTimestampUnixMilli(n int64) bool { + return rcv._tab.MutateInt64Slot(6, n) +} + +func (rcv *ServerSnapshot) Levels(obj *LevelSnapshot, j int) bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + x := rcv._tab.Vector(o) + x += flatbuffers.UOffsetT(j) * 4 + x = rcv._tab.Indirect(x) + obj.Init(rcv._tab.Bytes, x) + return true + } + return false +} + +func (rcv *ServerSnapshot) LevelsLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func (rcv *ServerSnapshot) Entities(obj *EntitySnapshot, j int) bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + x := rcv._tab.Vector(o) + x += flatbuffers.UOffsetT(j) * 4 + x = rcv._tab.Indirect(x) + obj.Init(rcv._tab.Bytes, x) + return true + } + return false +} + +func (rcv *ServerSnapshot) EntitiesLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func (rcv *ServerSnapshot) Blocks(obj *BlockSnapshot, j int) bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + x := rcv._tab.Vector(o) + x += flatbuffers.UOffsetT(j) * 4 + x = rcv._tab.Indirect(x) + obj.Init(rcv._tab.Bytes, x) + return true + } + return false +} + +func (rcv *ServerSnapshot) BlocksLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func ServerSnapshotStart(builder *flatbuffers.Builder) { + builder.StartObject(5) +} +func ServerSnapshotAddTick(builder *flatbuffers.Builder, tick int64) { + builder.PrependInt64Slot(0, tick, 0) +} +func ServerSnapshotAddTimestampUnixMilli(builder *flatbuffers.Builder, timestampUnixMilli int64) { + builder.PrependInt64Slot(1, timestampUnixMilli, 0) +} +func ServerSnapshotAddLevels(builder *flatbuffers.Builder, levels flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(levels), 0) +} +func ServerSnapshotStartLevelsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func ServerSnapshotAddEntities(builder *flatbuffers.Builder, entities flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(entities), 0) +} +func ServerSnapshotStartEntitiesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func ServerSnapshotAddBlocks(builder *flatbuffers.Builder, blocks flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(blocks), 0) +} +func ServerSnapshotStartBlocksVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func ServerSnapshotEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/sdk/plugin.go b/sdk/plugin.go index 171b133..4fc820b 100644 --- a/sdk/plugin.go +++ b/sdk/plugin.go @@ -12,6 +12,12 @@ type TickHandler interface { Tick(context *Context, snapshot ServerSnapshot) error } +// ClientTickHandler is invoked only by a client native runtime. A plugin with +// environment "both" may implement TickHandler, ClientTickHandler, or both. +type ClientTickHandler interface { + ClientTick(context *Context, event ClientTickEvent) error +} + type ChatHandler interface { Chat(context *Context, event ChatEvent) error } diff --git a/sdk/runtime.go b/sdk/runtime.go index aad3189..f2a4cc8 100644 --- a/sdk/runtime.go +++ b/sdk/runtime.go @@ -13,6 +13,7 @@ var ( registeredPlugin Plugin ) +// Register registers a plugin with the server. func Register(plugin Plugin) { if plugin == nil { panic("sdk: cannot register a nil plugin") @@ -27,6 +28,7 @@ func Register(plugin Plugin) { enableOutputCapture() } +// Dispatch dispatches a plugin operation to the registered plugin. func Dispatch(operation int, input []byte) (output []byte) { context := &Context{} result := response{Status: "ok"} @@ -61,6 +63,9 @@ func Dispatch(operation int, input []byte) (output []byte) { if metadata.APIVersion == 0 { metadata.APIVersion = ABIVersion } + if metadata.Environment == "" { + metadata.Environment = PluginEnvironmentServer + } result.Data = metadata case OperationInit: var event InitEvent @@ -72,7 +77,7 @@ func Dispatch(operation int, input []byte) (output []byte) { } case OperationTick: var snapshot ServerSnapshot - err = decode(input, &snapshot) + snapshot, err = decodeTickSnapshot(input) if err == nil { if handler, ok := plugin.(TickHandler); ok { err = handler.Tick(context, snapshot) @@ -110,6 +115,14 @@ func Dispatch(operation int, input []byte) (output []byte) { err = handler.Deinit(context, event) } } + case OperationClientTick: + var event ClientTickEvent + err = decode(input, &event) + if err == nil { + if handler, ok := plugin.(ClientTickHandler); ok { + err = handler.ClientTick(context, event) + } + } default: err = fmt.Errorf("sdk: unknown operation %d", operation) } diff --git a/sdk/runtime_test.go b/sdk/runtime_test.go index 94ddd1c..6de99a1 100644 --- a/sdk/runtime_test.go +++ b/sdk/runtime_test.go @@ -23,6 +23,13 @@ func (testPlugin) Chat(context *Context, event ChatEvent) error { return nil } +func (testPlugin) ClientTick(context *Context, event ClientTickEvent) error { + if event.Connected { + context.DisplayClientMessage(event.PlayerName) + } + return nil +} + func TestDispatch(t *testing.T) { pluginMu.Lock() registeredPlugin = testPlugin{} @@ -38,6 +45,40 @@ func TestDispatch(t *testing.T) { } } +func TestMetadataDefaultsToServerEnvironment(t *testing.T) { + pluginMu.Lock() + registeredPlugin = testPlugin{} + pluginMu.Unlock() + + var got struct { + Data Metadata `json:"data"` + } + if err := json.Unmarshal(Dispatch(OperationMetadata, nil), &got); err != nil { + t.Fatal(err) + } + if got.Data.Environment != PluginEnvironmentServer { + t.Fatalf("environment = %q, want %q", got.Data.Environment, PluginEnvironmentServer) + } +} + +func TestDispatchClientTick(t *testing.T) { + pluginMu.Lock() + registeredPlugin = testPlugin{} + pluginMu.Unlock() + + input, _ := json.Marshal(ClientTickEvent{Connected: true, PlayerName: "Client player"}) + var got response + if err := json.Unmarshal(Dispatch(OperationClientTick, input), &got); err != nil { + t.Fatal(err) + } + if got.Status != "ok" || len(got.Actions) != 1 { + t.Fatalf("unexpected client tick response: %#v", got) + } + if got.Actions[0].Type != "minecraft:client.chat.display" { + t.Fatalf("action type = %q", got.Actions[0].Type) + } +} + func TestDispatchRecoversPanic(t *testing.T) { pluginMu.Lock() registeredPlugin = testPlugin{} diff --git a/sdk/types.go b/sdk/types.go index 15a3e57..f43f22d 100644 --- a/sdk/types.go +++ b/sdk/types.go @@ -2,7 +2,7 @@ package sdk import "encoding/json" -const ABIVersion = 1 +const ABIVersion = 2 const ( OperationMetadata = 1 @@ -12,23 +12,47 @@ const ( OperationDeath = 5 OperationSystemCallResult = 6 OperationDeinit = 7 + OperationClientTick = 8 +) + +// PluginEnvironment declares which Minecraft process may execute a plugin. +type PluginEnvironment string + +const ( + PluginEnvironmentServer PluginEnvironment = "server" + PluginEnvironmentClient PluginEnvironment = "client" + PluginEnvironmentBoth PluginEnvironment = "both" ) type Metadata struct { - ID string `json:"id"` - Name string `json:"name"` - Version string `json:"version"` - Description string `json:"description,omitempty"` - Authors []string `json:"authors,omitempty"` - Website string `json:"website,omitempty"` - APIVersion int `json:"apiVersion"` - ConfigSchema map[string]any `json:"configSchema,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description,omitempty"` + Authors []string `json:"authors,omitempty"` + Website string `json:"website,omitempty"` + APIVersion int `json:"apiVersion"` + ConfigSchema map[string]any `json:"configSchema,omitempty"` + Environment PluginEnvironment `json:"environment"` } type InitEvent struct { - MinecraftVersion string `json:"minecraftVersion"` - Dedicated bool `json:"dedicated"` - DataDirectory string `json:"dataDirectory"` + MinecraftVersion string `json:"minecraftVersion"` + Dedicated bool `json:"dedicated"` + DataDirectory string `json:"dataDirectory"` + RuntimeEnvironment PluginEnvironment `json:"runtimeEnvironment"` +} + +// ClientTickEvent contains client-local state. Pointer-like values are empty +// when the client is at the title screen or is not connected to a world. +type ClientTickEvent struct { + Tick int64 `json:"tick"` + TimestampUnixMilli int64 `json:"timestampUnixMilli"` + Connected bool `json:"connected"` + ServerAddress string `json:"serverAddress,omitempty"` + PlayerUUID string `json:"playerUuid,omitempty"` + PlayerName string `json:"playerName,omitempty"` + Dimension string `json:"dimension,omitempty"` } type ServerSnapshot struct { @@ -105,6 +129,24 @@ type SystemCallResult struct { Error string `json:"error"` } +// SystemCallType identifies a system call provided by the bridge itself. +// Use Context.CustomSystemCall for calls registered by another mod. +type SystemCallType string + +const ( + SystemCallServerInfo SystemCallType = "minecraft:server.info" + SystemCallPlayerGet SystemCallType = "minecraft:player.get" + SystemCallBlockGet SystemCallType = "minecraft:block.get" + SystemCallGetEntity SystemCallType = "minecraft:get_entity" +) + +// GetEntityRequest selects an entity by UUID or by its runtime ID. +// Exactly one field must be set. +type GetEntityRequest struct { + UUID string `json:"uuid,omitempty"` + RuntimeID *int `json:"runtimeId,omitempty"` +} + type DeinitEvent struct { Reason string `json:"reason"` } diff --git a/settings.gradle b/settings.gradle index 0c789bc..1a23529 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,5 +1,13 @@ pluginManagement { repositories { + maven { + name = 'Architectury' + url = 'https://maven.architectury.dev/' + } + maven { + name = 'Forge' + url = 'https://maven.minecraftforge.net/' + } maven { name = 'Fabric' url = 'https://maven.fabricmc.net/' @@ -10,3 +18,12 @@ pluginManagement { } rootProject.name = 'go-minecraft-bridge' + +include 'mc1211' +project(':mc1211').projectDir = file('versions/1.21.1') + +include 'mc2612' +project(':mc2612').projectDir = file('versions/26.1.2') + +include 'paper' +project(':paper').projectDir = file('platforms/paper') diff --git a/src/client/java/dev/yawaflua/gominecraftbridge/client/ClientGoPluginManager.java b/src/client/java/dev/yawaflua/gominecraftbridge/client/ClientGoPluginManager.java new file mode 100644 index 0000000..10f12c6 --- /dev/null +++ b/src/client/java/dev/yawaflua/gominecraftbridge/client/ClientGoPluginManager.java @@ -0,0 +1,374 @@ +package dev.yawaflua.gominecraftbridge.client; + +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import dev.yawaflua.gominecraftbridge.backend.nativeffi.NativePluginBackend; +import dev.yawaflua.gominecraftbridge.compat.MinecraftVersionAdapter; +import dev.yawaflua.gominecraftbridge.host.LoadedPlugin; +import dev.yawaflua.gominecraftbridge.host.PluginState; +import dev.yawaflua.gominecraftbridge.management.BridgeManagementSnapshot; +import dev.yawaflua.gominecraftbridge.management.ManagedPluginSnapshot; +import dev.yawaflua.gominecraftbridge.management.PackageInspection; +import dev.yawaflua.gominecraftbridge.management.ReloadResult; +import dev.yawaflua.gominecraftbridge.protocol.ActionRequest; +import dev.yawaflua.gominecraftbridge.protocol.ClientTickEvent; +import dev.yawaflua.gominecraftbridge.protocol.DeinitEvent; +import dev.yawaflua.gominecraftbridge.protocol.InitEvent; +import dev.yawaflua.gominecraftbridge.protocol.PluginEnvironment; +import dev.yawaflua.gominecraftbridge.protocol.PluginLog; +import dev.yawaflua.gominecraftbridge.protocol.PluginResponse; +import dev.yawaflua.gominecraftbridge.protocol.Protocol; +import dev.yawaflua.gominecraftbridge.protocol.SystemCallRequest; +import dev.yawaflua.gominecraftbridge.protocol.SystemCallResult; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; +import org.slf4j.Logger; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Client-process native host. It intentionally has its own package and data + * directories and never delegates actions or system calls to a remote server. + */ +public final class ClientGoPluginManager { + private static final int MAX_SYSTEM_CALL_CHAIN = 32; + + private final Logger logger; + private final Path pluginDirectory; + private final Path dataDirectory; + private final Map plugins = new LinkedHashMap<>(); + private final List packageInspections = new ArrayList<>(); + private long tick; + private boolean running; + + public ClientGoPluginManager(Logger logger) { + this.logger = logger; + Path root = FabricLoader.getInstance().getConfigDir().resolve("go-minecraft-bridge"); + this.pluginDirectory = root.resolve("client-plugins"); + this.dataDirectory = root.resolve("client-data"); + } + + public synchronized void discover() { + this.packageInspections.clear(); + try { + Files.createDirectories(this.pluginDirectory); + Files.createDirectories(this.dataDirectory); + } catch (IOException exception) { + throw new IllegalStateException("Cannot create client Go plugin directories", exception); + } + + for (Path candidate : nativeCandidates()) { + Path normalized = candidate.toAbsolutePath().normalize(); + try { + LoadedPlugin existing = this.plugins.values().stream() + .filter(plugin -> plugin.backend().origin().equals(normalized)) + .findFirst() + .orElse(null); + if (existing != null) { + this.packageInspections.add(new PackageInspection( + normalized.toString(), true, existing.metadata().id(), null + )); + continue; + } + + LoadedPlugin plugin = new LoadedPlugin(new NativePluginBackend(candidate)); + if (!plugin.metadata().environment().supportsClient()) { + this.packageInspections.add(new PackageInspection( + normalized.toString(), true, plugin.metadata().id(), null + )); + this.logger.info( + "Skipping server-only Go plugin {} in the client runtime", + plugin.metadata().id() + ); + continue; + } + if (this.plugins.putIfAbsent(plugin.metadata().id(), plugin) != null) { + throw new IllegalArgumentException("Duplicate client plugin id " + plugin.metadata().id()); + } + this.packageInspections.add(new PackageInspection( + normalized.toString(), true, plugin.metadata().id(), null + )); + bridgeLog(plugin, "info", "Client package check passed: " + normalized); + this.logger.info( + "Discovered client Go plugin {} {} from {}", + plugin.metadata().name(), plugin.metadata().version(), candidate.getFileName() + ); + if (this.running) { + startPlugin(plugin); + } + } catch (RuntimeException exception) { + this.packageInspections.add(new PackageInspection( + normalized.toString(), false, null, rootMessage(exception) + )); + this.logger.error("Cannot load client Go plugin {}", candidate, exception); + } + } + } + + public synchronized void start(Minecraft client) { + this.running = true; + for (LoadedPlugin plugin : this.plugins.values()) { + startPlugin(plugin); + } + } + + public synchronized void tick(Minecraft client) { + this.tick++; + for (LoadedPlugin plugin : runningPlugins()) { + invoke(plugin, Protocol.Operation.CLIENT_TICK, createTick(client), client); + } + } + + public synchronized void stop(Minecraft client) { + for (LoadedPlugin plugin : runningPlugins()) { + try { + PluginResponse response = plugin.invoke( + Protocol.Operation.DEINIT, + new DeinitEvent("client_stopping") + ); + processResponse(plugin, response, client, 0); + } catch (RuntimeException exception) { + bridgeLog(plugin, "error", "Client deinit failed: " + rootMessage(exception)); + this.logger.error("Client Go plugin {} failed during deinit", plugin.metadata().id(), exception); + } finally { + plugin.markStopped(); + } + } + this.running = false; + } + + public synchronized ReloadResult rescan() { + int before = this.plugins.size(); + discover(); + return new ReloadResult(true, "Client package check completed; new plugins: " + (this.plugins.size() - before)); + } + + public synchronized ReloadResult reload(String pluginId, Minecraft client) { + LoadedPlugin plugin = this.plugins.get(pluginId); + if (plugin == null) { + return new ReloadResult(false, "Unknown client plugin: " + pluginId); + } + if (plugin.state() == PluginState.RUNNING) { + try { + processResponse(plugin, plugin.invoke( + Protocol.Operation.DEINIT, new DeinitEvent("client_admin_reload") + ), client, 0); + } catch (RuntimeException exception) { + bridgeLog(plugin, "error", "Client reload deinit failed: " + rootMessage(exception)); + } + } + plugin.prepareReload(); + boolean started = startPlugin(plugin); + return new ReloadResult(started, started + ? "Client plugin " + pluginId + " restarted (the native binary remains loaded)" + : "Client plugin " + pluginId + " failed to restart"); + } + + public synchronized BridgeManagementSnapshot managementSnapshot(String message) { + List snapshots = this.plugins.values().stream() + .map(plugin -> new ManagedPluginSnapshot( + plugin.metadata(), + plugin.state().name().toLowerCase(Locale.ROOT), + plugin.backend().getClass().getSimpleName(), + plugin.backend().origin().toString(), + plugin.logs() + )) + .toList(); + return new BridgeManagementSnapshot( + Instant.now().toEpochMilli(), this.running, this.running, message, + List.copyOf(this.packageInspections), snapshots + ); + } + + private boolean startPlugin(LoadedPlugin plugin) { + try { + Path pluginData = this.dataDirectory.resolve(plugin.metadata().id()); + Files.createDirectories(pluginData); + PluginResponse response = plugin.invoke( + Protocol.Operation.INIT, + new InitEvent( + MinecraftVersionAdapter.gameVersion(), + false, + pluginData.toAbsolutePath().toString(), + PluginEnvironment.CLIENT + ) + ); + processResponse(plugin, response, Minecraft.getInstance(), 0); + if (response.isError()) { + plugin.disable(); + bridgeLog(plugin, "error", "Client initialization failed: " + response.error()); + return false; + } + plugin.markRunning(); + bridgeLog(plugin, "info", "Client plugin started"); + return true; + } catch (Exception exception) { + disable(plugin, "client initialization failed", exception); + return false; + } + } + + private ClientTickEvent createTick(Minecraft client) { + boolean connected = client.getConnection() != null; + String address = connected + ? String.valueOf(client.getConnection().getConnection().getRemoteAddress()) + : null; + String playerUuid = client.player == null ? null : client.player.getUUID().toString(); + String playerName = client.player == null ? null : client.player.getName().getString(); + String dimension = client.level == null ? null : MinecraftVersionAdapter.dimension(client.level); + return new ClientTickEvent( + this.tick, Instant.now().toEpochMilli(), connected, address, + playerUuid, playerName, dimension + ); + } + + private void invoke(LoadedPlugin plugin, Protocol.Operation operation, Object input, Minecraft client) { + try { + processResponse(plugin, plugin.invoke(operation, input), client, 0); + } catch (RuntimeException exception) { + disable(plugin, operation + " failed", exception); + } + } + + private void processResponse( + LoadedPlugin plugin, + PluginResponse response, + Minecraft client, + int systemCallDepth + ) { + for (PluginLog log : response.logs()) { + writeLog(plugin, log); + } + if (response.snapshot() != null) { + bridgeLog(plugin, "warn", "Snapshot subscriptions are unavailable in the client runtime"); + } + if (response.isPanic()) { + disable(plugin, "client callback panicked: " + response.error(), null); + return; + } + if (response.isError()) { + bridgeLog(plugin, "error", "Client callback returned an error: " + response.error()); + } + + for (ActionRequest action : response.actions()) { + executeAction(plugin, action, client); + } + + if (response.systemCalls().isEmpty()) { + return; + } + if (systemCallDepth >= MAX_SYSTEM_CALL_CHAIN) { + disable(plugin, "client system call chain exceeded " + MAX_SYSTEM_CALL_CHAIN, null); + return; + } + for (SystemCallRequest request : response.systemCalls()) { + SystemCallResult unavailable = new SystemCallResult( + request.id(), request.name(), false, JsonNull.INSTANCE, + "System calls are unavailable in the client runtime" + ); + try { + processResponse(plugin, plugin.invoke( + Protocol.Operation.SYSTEM_CALL_RESULT, unavailable + ), client, systemCallDepth + 1); + } catch (RuntimeException exception) { + disable(plugin, "client system call result callback failed", exception); + return; + } + } + } + + private void executeAction(LoadedPlugin plugin, ActionRequest action, Minecraft client) { + if (!"minecraft:client.chat.display".equals(action.type())) { + bridgeLog(plugin, "warn", "Rejected action in client runtime: " + action.type()); + return; + } + JsonElement message = action.payload() == null ? null : action.payload().get("message"); + if (message == null || !message.isJsonPrimitive() || !message.getAsJsonPrimitive().isString()) { + bridgeLog(plugin, "warn", "Rejected malformed client chat display action"); + return; + } + if (client.player == null) { + bridgeLog(plugin, "warn", "Cannot display client message outside a world"); + return; + } + client.player.sendSystemMessage(Component.literal(message.getAsString())); + } + + private List runningPlugins() { + return this.plugins.values().stream() + .filter(plugin -> plugin.state() == PluginState.RUNNING) + .toList(); + } + + private List nativeCandidates() { + if (!Files.isDirectory(this.pluginDirectory)) { + return List.of(); + } + try (var files = Files.list(this.pluginDirectory)) { + return files + .filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(nativeExtension())) + .sorted(Comparator.comparing(Path::toString)) + .toList(); + } catch (IOException exception) { + this.logger.error("Cannot scan client plugin directory {}", this.pluginDirectory, exception); + return List.of(); + } + } + + private static String nativeExtension() { + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + if (os.contains("win")) { + return ".dll"; + } + if (os.contains("mac")) { + return ".dylib"; + } + return ".so"; + } + + private void disable(LoadedPlugin plugin, String reason, Throwable throwable) { + plugin.disable(); + bridgeLog(plugin, "error", "Plugin disabled: " + reason + + (throwable == null ? "" : " — " + rootMessage(throwable))); + if (throwable == null) { + this.logger.error("Disabled client Go plugin {}: {}", plugin.metadata().id(), reason); + } else { + this.logger.error("Disabled client Go plugin {}: {}", plugin.metadata().id(), reason, throwable); + } + } + + private void writeLog(LoadedPlugin plugin, PluginLog log) { + plugin.appendLog(log); + String message = "[Go/client/" + plugin.metadata().id() + "/" + log.stream() + "] " + log.message(); + switch (log.level() == null ? "info" : log.level()) { + case "trace" -> this.logger.trace(message); + case "debug" -> this.logger.debug(message); + case "warn" -> this.logger.warn(message); + case "error" -> this.logger.error(message); + default -> this.logger.info(message); + } + } + + private void bridgeLog(LoadedPlugin plugin, String level, String message) { + plugin.appendLog(new PluginLog("client-bridge", level, message, Instant.now().toEpochMilli())); + } + + private static String rootMessage(Throwable throwable) { + Throwable current = throwable; + while (current.getCause() != null) { + current = current.getCause(); + } + return current.getMessage() == null ? current.getClass().getSimpleName() : current.getMessage(); + } +} diff --git a/src/client/java/dev/yawaflua/gominecraftbridge/client/ClothManagementScreen.java b/src/client/java/dev/yawaflua/gominecraftbridge/client/ClothManagementScreen.java index 48e4090..ead7ae6 100644 --- a/src/client/java/dev/yawaflua/gominecraftbridge/client/ClothManagementScreen.java +++ b/src/client/java/dev/yawaflua/gominecraftbridge/client/ClothManagementScreen.java @@ -4,6 +4,7 @@ import dev.yawaflua.gominecraftbridge.management.BridgeManagementSnapshot; import dev.yawaflua.gominecraftbridge.management.ManagedPluginSnapshot; import dev.yawaflua.gominecraftbridge.management.PackageInspection; import dev.yawaflua.gominecraftbridge.protocol.PluginLog; +import dev.yawaflua.gominecraftbridge.protocol.PluginEnvironment; import dev.yawaflua.gominecraftbridge.protocol.PluginMetadata; import dev.yawaflua.gominecraftbridge.protocol.ProtocolJson; import me.shedaniel.clothconfig2.api.ConfigBuilder; @@ -33,34 +34,58 @@ public final class ClothManagementScreen { GoMinecraftBridgeClient.requestRefresh(); } - BridgeManagementSnapshot snapshot = GoMinecraftBridgeClient.latest(); - Set reloads = new LinkedHashSet<>(); - boolean[] rescan = {false}; + GoMinecraftBridgeClient.ConnectionStatus connectionStatus = + GoMinecraftBridgeClient.connectionStatus(); + BridgeManagementSnapshot snapshot = connectionStatus + == GoMinecraftBridgeClient.ConnectionStatus.AVAILABLE + ? GoMinecraftBridgeClient.latest() + : null; + BridgeManagementSnapshot localSnapshot = GoMinecraftBridgeClient.localPlugins(); + Set serverReloads = new LinkedHashSet<>(); + Set clientReloads = new LinkedHashSet<>(); + boolean[] serverRescan = {false}; + boolean[] clientRescan = {false}; ConfigBuilder builder = ConfigBuilder.create() .setParentScreen(parent) .setTitle(Component.literal("Go Minecraft Bridge")); ConfigEntryBuilder entries = builder.entryBuilder(); - ConfigCategory packages = builder.getOrCreateCategory(Component.literal("Пакеты")); + ConfigCategory clientPackages = builder.getOrCreateCategory(Component.literal("Client packages")); + addOverview(clientPackages, entries, localSnapshot, clientRescan, "Client", "client-plugins"); + for (ManagedPluginSnapshot plugin : localSnapshot.plugins()) { + addPluginCategory( + builder, entries, plugin, localSnapshot.canReload(), clientReloads, + "Client", "Reload unavailable: the client native runtime is stopped." + ); + } + + ConfigCategory serverPackages = builder.getOrCreateCategory(Component.literal("Server packages")); if (snapshot == null) { - packages.addEntry(entries.startTextDescription(Component.literal( - "Подключитесь к серверу с Go Minecraft Bridge. Запрос состояния отправлен." - )).build()); + addConnectionStatus(serverPackages, entries, connectionStatus); } else { - addOverview(packages, entries, snapshot, rescan); + addOverview(serverPackages, entries, snapshot, serverRescan, "Server", "plugins or mods"); for (ManagedPluginSnapshot plugin : snapshot.plugins()) { - addPluginCategory(builder, entries, plugin, snapshot.canReload(), reloads); + addPluginCategory( + builder, entries, plugin, snapshot.canReload(), serverReloads, + "Server", "Reload unavailable: server operator permission is required." + ); } } builder.setSavingRunnable(() -> { - if (rescan[0]) { + if (clientRescan[0]) { + GoMinecraftBridgeClient.requestLocalRescan(); + } + for (String pluginId : clientReloads) { + GoMinecraftBridgeClient.requestLocalReload(pluginId); + } + if (serverRescan[0]) { GoMinecraftBridgeClient.requestRescan(); } - for (String pluginId : reloads) { + for (String pluginId : serverReloads) { GoMinecraftBridgeClient.requestReload(pluginId); } - if (reloads.isEmpty() && !rescan[0]) { + if (serverReloads.isEmpty() && !serverRescan[0] && GoMinecraftBridgeClient.channelAvailable()) { GoMinecraftBridgeClient.requestRefresh(); } }); @@ -75,23 +100,49 @@ public final class ClothManagementScreen { return screen; } + private static void addConnectionStatus( + ConfigCategory category, + ConfigEntryBuilder entries, + GoMinecraftBridgeClient.ConnectionStatus status + ) { + String message = switch (status) { + case DISCONNECTED -> "Join a server to inspect its Go packages and plugins."; + case CONNECTING -> "Connecting to the server and checking Go Minecraft Bridge support…"; + case UNSUPPORTED -> "This server does not have Go Minecraft Bridge. " + + "You can keep this client mod installed, but server package and plugin " + + "management is unavailable here."; + case AVAILABLE -> "Requesting package and plugin state from the server…"; + }; + category.addEntry(entries.startTextDescription(Component.literal(message)).build()); + } + private static void addOverview( ConfigCategory category, ConfigEntryBuilder entries, BridgeManagementSnapshot snapshot, - boolean[] rescan + boolean[] rescan, + String scope, + String directories ) { category.addEntry(entries.startTextDescription(Component.literal( - "Сервер: " + (snapshot.serverRunning() ? "работает" : "остановлен") - + " • пакетов: " + snapshot.packages().size() - + " • плагинов: " + snapshot.plugins().size() + scope + " runtime: " + (snapshot.serverRunning() ? "running" : "stopped") + + " • packages: " + snapshot.packages().size() + + " • plugins: " + snapshot.plugins().size() )).build()); if (snapshot.message() != null && !snapshot.message().isBlank()) { category.addEntry(entries.startTextDescription(Component.literal(snapshot.message())).build()); } + if (!snapshot.canReload()) { + category.addEntry(entries.startTextDescription(Component.literal( + scope.equals("Server") + ? "Package details, plugin logs, reload, and rescan require server operator permission." + : "Client package reload and rescan are unavailable while the runtime is stopped." + )).build()); + return; + } for (PackageInspection inspected : snapshot.packages()) { - String status = inspected.valid() ? "✓ корректен" : "✗ ошибка"; + String status = inspected.valid() ? "✓ valid" : "✗ invalid"; String details = inspected.valid() ? "plugin id: " + inspected.pluginId() : inspected.error(); @@ -101,16 +152,16 @@ public final class ClothManagementScreen { } if (snapshot.packages().isEmpty()) { category.addEntry(entries.startTextDescription(Component.literal( - "Нативные пакеты не найдены в config/go-minecraft-bridge/plugins или mods." + "No native packages found in config/go-minecraft-bridge/" + directories + "." )).build()); } category.addEntry(entries.startBooleanToggle( - Component.literal("Проверить и подключить новые пакеты при сохранении"), false + Component.literal("Rescan package folders on save"), false ) .setDefaultValue(false) .setSaveConsumer(enabled -> rescan[0] = enabled) .setTooltip(Component.literal( - "Повторно сканирует папки plugins и mods. Уже загруженные native-библиотеки не выгружаются." + "Rescan " + directories + ". Already loaded native packages are not unloaded." )) .build()); } @@ -120,10 +171,14 @@ public final class ClothManagementScreen { ConfigEntryBuilder entries, ManagedPluginSnapshot plugin, boolean canReload, - Set reloads + Set reloads, + String scope, + String unavailableMessage ) { PluginMetadata metadata = plugin.metadata(); - ConfigCategory category = builder.getOrCreateCategory(Component.literal(metadata.name())); + ConfigCategory category = builder.getOrCreateCategory(Component.literal( + scope + " — " + metadata.name() + " [" + metadata.id() + "]" + )); category.addEntry(entries.startTextDescription(Component.literal( metadata.id() + " " + metadata.version() + " • " + plugin.state() )).build()); @@ -132,10 +187,11 @@ public final class ClothManagementScreen { )).build()); category.addEntry(entries.startTextDescription(Component.literal( "ABI/API: " + metadata.apiVersion() - + "\nАвторы: " + String.join(", ", metadata.authors()) - + "\nСайт: " + value(metadata.website()) + + "\nEnvironment: " + environment(metadata.environment()) + + "\nAuthors: " + String.join(", ", metadata.authors()) + + "\nSite: " + value(metadata.website()) + "\nBackend: " + plugin.backend() - + "\nПуть: " + plugin.origin() + + "\nPath: " + plugin.origin() )).build()); if (metadata.configSchema() != null) { @@ -144,35 +200,45 @@ public final class ClothManagementScreen { )).build()); } - category.addEntry(entries.startBooleanToggle( - Component.literal("Перезапустить lifecycle при сохранении"), false - ) - .setDefaultValue(false) - .setSaveConsumer(enabled -> { - if (enabled) { - reloads.add(metadata.id()); - } - }) - .setTooltip(Component.literal( - "Вызывает Deinit → Init. Изменённый native-бинарник загружается только после рестарта JVM." - )) - .build()); + if (canReload) { + category.addEntry(entries.startBooleanToggle( + Component.literal("Reinitialize lifecycle on save"), false + ) + .setDefaultValue(false) + .setSaveConsumer(enabled -> { + if (enabled) { + reloads.add(metadata.id()); + } + }) + .setTooltip(Component.literal( + "Calls Deinit → Init. A changed native binary is loaded only after a JVM restart." + )) + .build()); + } List logs = plugin.logs(); int from = Math.max(0, logs.size() - VISIBLE_LOG_LINES); category.addEntry(entries.startTextDescription(Component.literal( - "Последние логи (" + (logs.size() - from) + " из " + logs.size() + "):" + "Last logs (" + (logs.size() - from) + " from " + logs.size() + "):" )).build()); for (PluginLog log : logs.subList(from, logs.size())) { category.addEntry(entries.startTextDescription(Component.literal(format(log))).build()); } if (!canReload) { category.addEntry(entries.startTextDescription(Component.literal( - "Reload недоступен: нужны права администратора и запущенный сервер." + unavailableMessage )).build()); } } + private static String environment(PluginEnvironment environment) { + return switch (environment) { + case SERVER -> "server"; + case CLIENT -> "client"; + case BOTH -> "client + server"; + }; + } + private static String format(PluginLog log) { return "[" + LOG_TIME.format(Instant.ofEpochMilli(log.timestampUnixMilli())) + "]" + " [" + value(log.level()) + "/" + value(log.stream()) + "] " + value(log.message()); diff --git a/src/client/java/dev/yawaflua/gominecraftbridge/client/GoMinecraftBridgeClient.java b/src/client/java/dev/yawaflua/gominecraftbridge/client/GoMinecraftBridgeClient.java index a4cc2a6..2cffbb6 100644 --- a/src/client/java/dev/yawaflua/gominecraftbridge/client/GoMinecraftBridgeClient.java +++ b/src/client/java/dev/yawaflua/gominecraftbridge/client/GoMinecraftBridgeClient.java @@ -6,14 +6,34 @@ import dev.yawaflua.gominecraftbridge.network.AdminRequestPayload; import dev.yawaflua.gominecraftbridge.network.AdminResponsePayload; import dev.yawaflua.gominecraftbridge.protocol.ProtocolJson; import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import org.slf4j.LoggerFactory; public final class GoMinecraftBridgeClient implements ClientModInitializer { - private static BridgeManagementSnapshot latest; + public enum ConnectionStatus { + DISCONNECTED, + CONNECTING, + AVAILABLE, + UNSUPPORTED + } + + private static volatile BridgeManagementSnapshot latest; + private static final ClientGoPluginManager CLIENT_PLUGINS = new ClientGoPluginManager( + LoggerFactory.getLogger("go_minecraft_bridge/client") + ); + private static volatile ConnectionStatus connectionStatus = ConnectionStatus.DISCONNECTED; private static Runnable updateListener; @Override public void onInitializeClient() { + CLIENT_PLUGINS.discover(); + ClientLifecycleEvents.CLIENT_STARTED.register(CLIENT_PLUGINS::start); + ClientTickEvents.END_CLIENT_TICK.register(CLIENT_PLUGINS::tick); + ClientLifecycleEvents.CLIENT_STOPPING.register(CLIENT_PLUGINS::stop); + ClientPlayNetworking.registerGlobalReceiver(AdminResponsePayload.TYPE, (payload, context) -> { try { latest = ProtocolJson.GSON.fromJson(payload.json(), BridgeManagementSnapshot.class); @@ -23,16 +43,52 @@ public final class GoMinecraftBridgeClient implements ClientModInitializer { "Invalid management response: " + exception.getMessage(), null, null ); } - if (updateListener != null) { - updateListener.run(); + connectionStatus = ConnectionStatus.AVAILABLE; + notifyUpdate(); + }); + + ClientPlayConnectionEvents.INIT.register((listener, client) -> reset(ConnectionStatus.CONNECTING)); + ClientPlayConnectionEvents.JOIN.register((listener, sender, client) -> { + latest = null; + connectionStatus = ClientPlayNetworking.canSend(AdminRequestPayload.TYPE) + ? ConnectionStatus.AVAILABLE + : ConnectionStatus.UNSUPPORTED; + notifyUpdate(); + if (connectionStatus == ConnectionStatus.AVAILABLE) { + requestRefresh(); } }); + ClientPlayConnectionEvents.DISCONNECT.register((listener, client) -> + reset(ConnectionStatus.DISCONNECTED) + ); } public static BridgeManagementSnapshot latest() { return latest; } + public static BridgeManagementSnapshot localPlugins() { + return CLIENT_PLUGINS.managementSnapshot("Client native runtime"); + } + + public static void requestLocalRescan() { + CLIENT_PLUGINS.rescan(); + notifyUpdate(); + } + + public static void requestLocalReload(String pluginId) { + CLIENT_PLUGINS.reload(pluginId, net.minecraft.client.Minecraft.getInstance()); + notifyUpdate(); + } + + public static ConnectionStatus connectionStatus() { + return connectionStatus; + } + + public static boolean channelAvailable() { + return connectionStatus == ConnectionStatus.AVAILABLE; + } + public static void onUpdate(Runnable listener) { updateListener = listener; } @@ -51,9 +107,25 @@ public final class GoMinecraftBridgeClient implements ClientModInitializer { private static boolean send(String action, String pluginId) { if (!ClientPlayNetworking.canSend(AdminRequestPayload.TYPE)) { + if (connectionStatus != ConnectionStatus.DISCONNECTED) { + connectionStatus = ConnectionStatus.UNSUPPORTED; + } return false; } ClientPlayNetworking.send(new AdminRequestPayload(action, pluginId)); return true; } + + private static void reset(ConnectionStatus status) { + latest = null; + connectionStatus = status; + notifyUpdate(); + } + + private static void notifyUpdate() { + Runnable listener = updateListener; + if (listener != null) { + listener.run(); + } + } } diff --git a/src/main/generated/gmb/BlockProperty.java b/src/main/generated/gmb/BlockProperty.java new file mode 100644 index 0000000..d1a5e93 --- /dev/null +++ b/src/main/generated/gmb/BlockProperty.java @@ -0,0 +1,61 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package gmb; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.BooleanVector; +import com.google.flatbuffers.ByteVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.DoubleVector; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.FloatVector; +import com.google.flatbuffers.IntVector; +import com.google.flatbuffers.LongVector; +import com.google.flatbuffers.ShortVector; +import com.google.flatbuffers.StringVector; +import com.google.flatbuffers.Struct; +import com.google.flatbuffers.Table; +import com.google.flatbuffers.UnionVector; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +@SuppressWarnings("unused") +public final class BlockProperty extends Table { + public static void ValidateVersion() { Constants.FLATBUFFERS_25_2_10(); } + public static BlockProperty getRootAsBlockProperty(ByteBuffer _bb) { return getRootAsBlockProperty(_bb, new BlockProperty()); } + public static BlockProperty getRootAsBlockProperty(ByteBuffer _bb, BlockProperty obj) { _bb.order(ByteOrder.LITTLE_ENDIAN); return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); } + public void __init(int _i, ByteBuffer _bb) { __reset(_i, _bb); } + public BlockProperty __assign(int _i, ByteBuffer _bb) { __init(_i, _bb); return this; } + + public String key() { int o = __offset(4); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer keyAsByteBuffer() { return __vector_as_bytebuffer(4, 1); } + public ByteBuffer keyInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 4, 1); } + public String value() { int o = __offset(6); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer valueAsByteBuffer() { return __vector_as_bytebuffer(6, 1); } + public ByteBuffer valueInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 6, 1); } + + public static int createBlockProperty(FlatBufferBuilder builder, + int keyOffset, + int valueOffset) { + builder.startTable(2); + BlockProperty.addValue(builder, valueOffset); + BlockProperty.addKey(builder, keyOffset); + return BlockProperty.endBlockProperty(builder); + } + + public static void startBlockProperty(FlatBufferBuilder builder) { builder.startTable(2); } + public static void addKey(FlatBufferBuilder builder, int keyOffset) { builder.addOffset(0, keyOffset, 0); } + public static void addValue(FlatBufferBuilder builder, int valueOffset) { builder.addOffset(1, valueOffset, 0); } + public static int endBlockProperty(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { __reset(_vector, _element_size, _bb); return this; } + + public BlockProperty get(int j) { return get(new BlockProperty(), j); } + public BlockProperty get(BlockProperty obj, int j) { return obj.__assign(__indirect(__element(j), bb), bb); } + } +} + diff --git a/src/main/generated/gmb/BlockSnapshot.java b/src/main/generated/gmb/BlockSnapshot.java new file mode 100644 index 0000000..cc66cec --- /dev/null +++ b/src/main/generated/gmb/BlockSnapshot.java @@ -0,0 +1,83 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package gmb; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.BooleanVector; +import com.google.flatbuffers.ByteVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.DoubleVector; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.FloatVector; +import com.google.flatbuffers.IntVector; +import com.google.flatbuffers.LongVector; +import com.google.flatbuffers.ShortVector; +import com.google.flatbuffers.StringVector; +import com.google.flatbuffers.Struct; +import com.google.flatbuffers.Table; +import com.google.flatbuffers.UnionVector; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +@SuppressWarnings("unused") +public final class BlockSnapshot extends Table { + public static void ValidateVersion() { Constants.FLATBUFFERS_25_2_10(); } + public static BlockSnapshot getRootAsBlockSnapshot(ByteBuffer _bb) { return getRootAsBlockSnapshot(_bb, new BlockSnapshot()); } + public static BlockSnapshot getRootAsBlockSnapshot(ByteBuffer _bb, BlockSnapshot obj) { _bb.order(ByteOrder.LITTLE_ENDIAN); return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); } + public void __init(int _i, ByteBuffer _bb) { __reset(_i, _bb); } + public BlockSnapshot __assign(int _i, ByteBuffer _bb) { __init(_i, _bb); return this; } + + public String dimension() { int o = __offset(4); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer dimensionAsByteBuffer() { return __vector_as_bytebuffer(4, 1); } + public ByteBuffer dimensionInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 4, 1); } + public int x() { int o = __offset(6); return o != 0 ? bb.getInt(o + bb_pos) : 0; } + public int y() { int o = __offset(8); return o != 0 ? bb.getInt(o + bb_pos) : 0; } + public int z() { int o = __offset(10); return o != 0 ? bb.getInt(o + bb_pos) : 0; } + public String block() { int o = __offset(12); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer blockAsByteBuffer() { return __vector_as_bytebuffer(12, 1); } + public ByteBuffer blockInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 12, 1); } + public gmb.BlockProperty properties(int j) { return properties(new gmb.BlockProperty(), j); } + public gmb.BlockProperty properties(gmb.BlockProperty obj, int j) { int o = __offset(14); return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; } + public int propertiesLength() { int o = __offset(14); return o != 0 ? __vector_len(o) : 0; } + public gmb.BlockProperty.Vector propertiesVector() { return propertiesVector(new gmb.BlockProperty.Vector()); } + public gmb.BlockProperty.Vector propertiesVector(gmb.BlockProperty.Vector obj) { int o = __offset(14); return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; } + + public static int createBlockSnapshot(FlatBufferBuilder builder, + int dimensionOffset, + int x, + int y, + int z, + int blockOffset, + int propertiesOffset) { + builder.startTable(6); + BlockSnapshot.addProperties(builder, propertiesOffset); + BlockSnapshot.addBlock(builder, blockOffset); + BlockSnapshot.addZ(builder, z); + BlockSnapshot.addY(builder, y); + BlockSnapshot.addX(builder, x); + BlockSnapshot.addDimension(builder, dimensionOffset); + return BlockSnapshot.endBlockSnapshot(builder); + } + + public static void startBlockSnapshot(FlatBufferBuilder builder) { builder.startTable(6); } + public static void addDimension(FlatBufferBuilder builder, int dimensionOffset) { builder.addOffset(0, dimensionOffset, 0); } + public static void addX(FlatBufferBuilder builder, int x) { builder.addInt(1, x, 0); } + public static void addY(FlatBufferBuilder builder, int y) { builder.addInt(2, y, 0); } + public static void addZ(FlatBufferBuilder builder, int z) { builder.addInt(3, z, 0); } + public static void addBlock(FlatBufferBuilder builder, int blockOffset) { builder.addOffset(4, blockOffset, 0); } + public static void addProperties(FlatBufferBuilder builder, int propertiesOffset) { builder.addOffset(5, propertiesOffset, 0); } + public static int createPropertiesVector(FlatBufferBuilder builder, int[] data) { builder.startVector(4, data.length, 4); for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); return builder.endVector(); } + public static void startPropertiesVector(FlatBufferBuilder builder, int numElems) { builder.startVector(4, numElems, 4); } + public static int endBlockSnapshot(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { __reset(_vector, _element_size, _bb); return this; } + + public BlockSnapshot get(int j) { return get(new BlockSnapshot(), j); } + public BlockSnapshot get(BlockSnapshot obj, int j) { return obj.__assign(__indirect(__element(j), bb), bb); } + } +} + diff --git a/src/main/generated/gmb/EntitySnapshot.java b/src/main/generated/gmb/EntitySnapshot.java new file mode 100644 index 0000000..29ec640 --- /dev/null +++ b/src/main/generated/gmb/EntitySnapshot.java @@ -0,0 +1,129 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package gmb; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.BooleanVector; +import com.google.flatbuffers.ByteVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.DoubleVector; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.FloatVector; +import com.google.flatbuffers.IntVector; +import com.google.flatbuffers.LongVector; +import com.google.flatbuffers.ShortVector; +import com.google.flatbuffers.StringVector; +import com.google.flatbuffers.Struct; +import com.google.flatbuffers.Table; +import com.google.flatbuffers.UnionVector; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +@SuppressWarnings("unused") +public final class EntitySnapshot extends Table { + public static void ValidateVersion() { Constants.FLATBUFFERS_25_2_10(); } + public static EntitySnapshot getRootAsEntitySnapshot(ByteBuffer _bb) { return getRootAsEntitySnapshot(_bb, new EntitySnapshot()); } + public static EntitySnapshot getRootAsEntitySnapshot(ByteBuffer _bb, EntitySnapshot obj) { _bb.order(ByteOrder.LITTLE_ENDIAN); return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); } + public void __init(int _i, ByteBuffer _bb) { __reset(_i, _bb); } + public EntitySnapshot __assign(int _i, ByteBuffer _bb) { __init(_i, _bb); return this; } + + public int runtimeId() { int o = __offset(4); return o != 0 ? bb.getInt(o + bb_pos) : 0; } + public String uuid() { int o = __offset(6); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer uuidAsByteBuffer() { return __vector_as_bytebuffer(6, 1); } + public ByteBuffer uuidInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 6, 1); } + public String type() { int o = __offset(8); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer typeAsByteBuffer() { return __vector_as_bytebuffer(8, 1); } + public ByteBuffer typeInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 8, 1); } + public String name() { int o = __offset(10); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer nameAsByteBuffer() { return __vector_as_bytebuffer(10, 1); } + public ByteBuffer nameInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 10, 1); } + public String dimension() { int o = __offset(12); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer dimensionAsByteBuffer() { return __vector_as_bytebuffer(12, 1); } + public ByteBuffer dimensionInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 12, 1); } + public double x() { int o = __offset(14); return o != 0 ? bb.getDouble(o + bb_pos) : 0.0; } + public double y() { int o = __offset(16); return o != 0 ? bb.getDouble(o + bb_pos) : 0.0; } + public double z() { int o = __offset(18); return o != 0 ? bb.getDouble(o + bb_pos) : 0.0; } + public float yaw() { int o = __offset(20); return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; } + public float pitch() { int o = __offset(22); return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; } + public double velocityX() { int o = __offset(24); return o != 0 ? bb.getDouble(o + bb_pos) : 0.0; } + public double velocityY() { int o = __offset(26); return o != 0 ? bb.getDouble(o + bb_pos) : 0.0; } + public double velocityZ() { int o = __offset(28); return o != 0 ? bb.getDouble(o + bb_pos) : 0.0; } + public boolean alive() { int o = __offset(30); return o != 0 ? 0!=bb.get(o + bb_pos) : false; } + public boolean player() { int o = __offset(32); return o != 0 ? 0!=bb.get(o + bb_pos) : false; } + public boolean hasHealth() { int o = __offset(34); return o != 0 ? 0!=bb.get(o + bb_pos) : false; } + public float health() { int o = __offset(36); return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; } + public float maxHealth() { int o = __offset(38); return o != 0 ? bb.getFloat(o + bb_pos) : 0.0f; } + + public static int createEntitySnapshot(FlatBufferBuilder builder, + int runtimeId, + int uuidOffset, + int typeOffset, + int nameOffset, + int dimensionOffset, + double x, + double y, + double z, + float yaw, + float pitch, + double velocityX, + double velocityY, + double velocityZ, + boolean alive, + boolean player, + boolean hasHealth, + float health, + float maxHealth) { + builder.startTable(18); + EntitySnapshot.addVelocityZ(builder, velocityZ); + EntitySnapshot.addVelocityY(builder, velocityY); + EntitySnapshot.addVelocityX(builder, velocityX); + EntitySnapshot.addZ(builder, z); + EntitySnapshot.addY(builder, y); + EntitySnapshot.addX(builder, x); + EntitySnapshot.addMaxHealth(builder, maxHealth); + EntitySnapshot.addHealth(builder, health); + EntitySnapshot.addPitch(builder, pitch); + EntitySnapshot.addYaw(builder, yaw); + EntitySnapshot.addDimension(builder, dimensionOffset); + EntitySnapshot.addName(builder, nameOffset); + EntitySnapshot.addType(builder, typeOffset); + EntitySnapshot.addUuid(builder, uuidOffset); + EntitySnapshot.addRuntimeId(builder, runtimeId); + EntitySnapshot.addHasHealth(builder, hasHealth); + EntitySnapshot.addPlayer(builder, player); + EntitySnapshot.addAlive(builder, alive); + return EntitySnapshot.endEntitySnapshot(builder); + } + + public static void startEntitySnapshot(FlatBufferBuilder builder) { builder.startTable(18); } + public static void addRuntimeId(FlatBufferBuilder builder, int runtimeId) { builder.addInt(0, runtimeId, 0); } + public static void addUuid(FlatBufferBuilder builder, int uuidOffset) { builder.addOffset(1, uuidOffset, 0); } + public static void addType(FlatBufferBuilder builder, int typeOffset) { builder.addOffset(2, typeOffset, 0); } + public static void addName(FlatBufferBuilder builder, int nameOffset) { builder.addOffset(3, nameOffset, 0); } + public static void addDimension(FlatBufferBuilder builder, int dimensionOffset) { builder.addOffset(4, dimensionOffset, 0); } + public static void addX(FlatBufferBuilder builder, double x) { builder.addDouble(5, x, 0.0); } + public static void addY(FlatBufferBuilder builder, double y) { builder.addDouble(6, y, 0.0); } + public static void addZ(FlatBufferBuilder builder, double z) { builder.addDouble(7, z, 0.0); } + public static void addYaw(FlatBufferBuilder builder, float yaw) { builder.addFloat(8, yaw, 0.0f); } + public static void addPitch(FlatBufferBuilder builder, float pitch) { builder.addFloat(9, pitch, 0.0f); } + public static void addVelocityX(FlatBufferBuilder builder, double velocityX) { builder.addDouble(10, velocityX, 0.0); } + public static void addVelocityY(FlatBufferBuilder builder, double velocityY) { builder.addDouble(11, velocityY, 0.0); } + public static void addVelocityZ(FlatBufferBuilder builder, double velocityZ) { builder.addDouble(12, velocityZ, 0.0); } + public static void addAlive(FlatBufferBuilder builder, boolean alive) { builder.addBoolean(13, alive, false); } + public static void addPlayer(FlatBufferBuilder builder, boolean player) { builder.addBoolean(14, player, false); } + public static void addHasHealth(FlatBufferBuilder builder, boolean hasHealth) { builder.addBoolean(15, hasHealth, false); } + public static void addHealth(FlatBufferBuilder builder, float health) { builder.addFloat(16, health, 0.0f); } + public static void addMaxHealth(FlatBufferBuilder builder, float maxHealth) { builder.addFloat(17, maxHealth, 0.0f); } + public static int endEntitySnapshot(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { __reset(_vector, _element_size, _bb); return this; } + + public EntitySnapshot get(int j) { return get(new EntitySnapshot(), j); } + public EntitySnapshot get(EntitySnapshot obj, int j) { return obj.__assign(__indirect(__element(j), bb), bb); } + } +} + diff --git a/src/main/generated/gmb/LevelSnapshot.java b/src/main/generated/gmb/LevelSnapshot.java new file mode 100644 index 0000000..f0c2bd2 --- /dev/null +++ b/src/main/generated/gmb/LevelSnapshot.java @@ -0,0 +1,71 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package gmb; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.BooleanVector; +import com.google.flatbuffers.ByteVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.DoubleVector; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.FloatVector; +import com.google.flatbuffers.IntVector; +import com.google.flatbuffers.LongVector; +import com.google.flatbuffers.ShortVector; +import com.google.flatbuffers.StringVector; +import com.google.flatbuffers.Struct; +import com.google.flatbuffers.Table; +import com.google.flatbuffers.UnionVector; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +@SuppressWarnings("unused") +public final class LevelSnapshot extends Table { + public static void ValidateVersion() { Constants.FLATBUFFERS_25_2_10(); } + public static LevelSnapshot getRootAsLevelSnapshot(ByteBuffer _bb) { return getRootAsLevelSnapshot(_bb, new LevelSnapshot()); } + public static LevelSnapshot getRootAsLevelSnapshot(ByteBuffer _bb, LevelSnapshot obj) { _bb.order(ByteOrder.LITTLE_ENDIAN); return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); } + public void __init(int _i, ByteBuffer _bb) { __reset(_i, _bb); } + public LevelSnapshot __assign(int _i, ByteBuffer _bb) { __init(_i, _bb); return this; } + + public String dimension() { int o = __offset(4); return o != 0 ? __string(o + bb_pos) : null; } + public ByteBuffer dimensionAsByteBuffer() { return __vector_as_bytebuffer(4, 1); } + public ByteBuffer dimensionInByteBuffer(ByteBuffer _bb) { return __vector_in_bytebuffer(_bb, 4, 1); } + public long gameTime() { int o = __offset(6); return o != 0 ? bb.getLong(o + bb_pos) : 0L; } + public long dayTime() { int o = __offset(8); return o != 0 ? bb.getLong(o + bb_pos) : 0L; } + public boolean raining() { int o = __offset(10); return o != 0 ? 0!=bb.get(o + bb_pos) : false; } + public boolean thundering() { int o = __offset(12); return o != 0 ? 0!=bb.get(o + bb_pos) : false; } + + public static int createLevelSnapshot(FlatBufferBuilder builder, + int dimensionOffset, + long gameTime, + long dayTime, + boolean raining, + boolean thundering) { + builder.startTable(5); + LevelSnapshot.addDayTime(builder, dayTime); + LevelSnapshot.addGameTime(builder, gameTime); + LevelSnapshot.addDimension(builder, dimensionOffset); + LevelSnapshot.addThundering(builder, thundering); + LevelSnapshot.addRaining(builder, raining); + return LevelSnapshot.endLevelSnapshot(builder); + } + + public static void startLevelSnapshot(FlatBufferBuilder builder) { builder.startTable(5); } + public static void addDimension(FlatBufferBuilder builder, int dimensionOffset) { builder.addOffset(0, dimensionOffset, 0); } + public static void addGameTime(FlatBufferBuilder builder, long gameTime) { builder.addLong(1, gameTime, 0L); } + public static void addDayTime(FlatBufferBuilder builder, long dayTime) { builder.addLong(2, dayTime, 0L); } + public static void addRaining(FlatBufferBuilder builder, boolean raining) { builder.addBoolean(3, raining, false); } + public static void addThundering(FlatBufferBuilder builder, boolean thundering) { builder.addBoolean(4, thundering, false); } + public static int endLevelSnapshot(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + + public static final class Vector extends BaseVector { + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { __reset(_vector, _element_size, _bb); return this; } + + public LevelSnapshot get(int j) { return get(new LevelSnapshot(), j); } + public LevelSnapshot get(LevelSnapshot obj, int j) { return obj.__assign(__indirect(__element(j), bb), bb); } + } +} + diff --git a/src/main/generated/gmb/ServerSnapshot.java b/src/main/generated/gmb/ServerSnapshot.java new file mode 100644 index 0000000..40fe144 --- /dev/null +++ b/src/main/generated/gmb/ServerSnapshot.java @@ -0,0 +1,90 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +package gmb; + +import com.google.flatbuffers.BaseVector; +import com.google.flatbuffers.BooleanVector; +import com.google.flatbuffers.ByteVector; +import com.google.flatbuffers.Constants; +import com.google.flatbuffers.DoubleVector; +import com.google.flatbuffers.FlatBufferBuilder; +import com.google.flatbuffers.FloatVector; +import com.google.flatbuffers.IntVector; +import com.google.flatbuffers.LongVector; +import com.google.flatbuffers.ShortVector; +import com.google.flatbuffers.StringVector; +import com.google.flatbuffers.Struct; +import com.google.flatbuffers.Table; +import com.google.flatbuffers.UnionVector; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +@SuppressWarnings("unused") +public final class ServerSnapshot extends Table { + public static void ValidateVersion() { Constants.FLATBUFFERS_25_2_10(); } + public static ServerSnapshot getRootAsServerSnapshot(ByteBuffer _bb) { return getRootAsServerSnapshot(_bb, new ServerSnapshot()); } + public static ServerSnapshot getRootAsServerSnapshot(ByteBuffer _bb, ServerSnapshot obj) { _bb.order(ByteOrder.LITTLE_ENDIAN); return (obj.__assign(_bb.getInt(_bb.position()) + _bb.position(), _bb)); } + public static boolean ServerSnapshotBufferHasIdentifier(ByteBuffer _bb) { return __has_identifier(_bb, "GMBS"); } + public void __init(int _i, ByteBuffer _bb) { __reset(_i, _bb); } + public ServerSnapshot __assign(int _i, ByteBuffer _bb) { __init(_i, _bb); return this; } + + public long tick() { int o = __offset(4); return o != 0 ? bb.getLong(o + bb_pos) : 0L; } + public long timestampUnixMilli() { int o = __offset(6); return o != 0 ? bb.getLong(o + bb_pos) : 0L; } + public gmb.LevelSnapshot levels(int j) { return levels(new gmb.LevelSnapshot(), j); } + public gmb.LevelSnapshot levels(gmb.LevelSnapshot obj, int j) { int o = __offset(8); return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; } + public int levelsLength() { int o = __offset(8); return o != 0 ? __vector_len(o) : 0; } + public gmb.LevelSnapshot.Vector levelsVector() { return levelsVector(new gmb.LevelSnapshot.Vector()); } + public gmb.LevelSnapshot.Vector levelsVector(gmb.LevelSnapshot.Vector obj) { int o = __offset(8); return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; } + public gmb.EntitySnapshot entities(int j) { return entities(new gmb.EntitySnapshot(), j); } + public gmb.EntitySnapshot entities(gmb.EntitySnapshot obj, int j) { int o = __offset(10); return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; } + public int entitiesLength() { int o = __offset(10); return o != 0 ? __vector_len(o) : 0; } + public gmb.EntitySnapshot.Vector entitiesVector() { return entitiesVector(new gmb.EntitySnapshot.Vector()); } + public gmb.EntitySnapshot.Vector entitiesVector(gmb.EntitySnapshot.Vector obj) { int o = __offset(10); return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; } + public gmb.BlockSnapshot blocks(int j) { return blocks(new gmb.BlockSnapshot(), j); } + public gmb.BlockSnapshot blocks(gmb.BlockSnapshot obj, int j) { int o = __offset(12); return o != 0 ? obj.__assign(__indirect(__vector(o) + j * 4), bb) : null; } + public int blocksLength() { int o = __offset(12); return o != 0 ? __vector_len(o) : 0; } + public gmb.BlockSnapshot.Vector blocksVector() { return blocksVector(new gmb.BlockSnapshot.Vector()); } + public gmb.BlockSnapshot.Vector blocksVector(gmb.BlockSnapshot.Vector obj) { int o = __offset(12); return o != 0 ? obj.__assign(__vector(o), 4, bb) : null; } + + public static int createServerSnapshot(FlatBufferBuilder builder, + long tick, + long timestampUnixMilli, + int levelsOffset, + int entitiesOffset, + int blocksOffset) { + builder.startTable(5); + ServerSnapshot.addTimestampUnixMilli(builder, timestampUnixMilli); + ServerSnapshot.addTick(builder, tick); + ServerSnapshot.addBlocks(builder, blocksOffset); + ServerSnapshot.addEntities(builder, entitiesOffset); + ServerSnapshot.addLevels(builder, levelsOffset); + return ServerSnapshot.endServerSnapshot(builder); + } + + public static void startServerSnapshot(FlatBufferBuilder builder) { builder.startTable(5); } + public static void addTick(FlatBufferBuilder builder, long tick) { builder.addLong(0, tick, 0L); } + public static void addTimestampUnixMilli(FlatBufferBuilder builder, long timestampUnixMilli) { builder.addLong(1, timestampUnixMilli, 0L); } + public static void addLevels(FlatBufferBuilder builder, int levelsOffset) { builder.addOffset(2, levelsOffset, 0); } + public static int createLevelsVector(FlatBufferBuilder builder, int[] data) { builder.startVector(4, data.length, 4); for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); return builder.endVector(); } + public static void startLevelsVector(FlatBufferBuilder builder, int numElems) { builder.startVector(4, numElems, 4); } + public static void addEntities(FlatBufferBuilder builder, int entitiesOffset) { builder.addOffset(3, entitiesOffset, 0); } + public static int createEntitiesVector(FlatBufferBuilder builder, int[] data) { builder.startVector(4, data.length, 4); for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); return builder.endVector(); } + public static void startEntitiesVector(FlatBufferBuilder builder, int numElems) { builder.startVector(4, numElems, 4); } + public static void addBlocks(FlatBufferBuilder builder, int blocksOffset) { builder.addOffset(4, blocksOffset, 0); } + public static int createBlocksVector(FlatBufferBuilder builder, int[] data) { builder.startVector(4, data.length, 4); for (int i = data.length - 1; i >= 0; i--) builder.addOffset(data[i]); return builder.endVector(); } + public static void startBlocksVector(FlatBufferBuilder builder, int numElems) { builder.startVector(4, numElems, 4); } + public static int endServerSnapshot(FlatBufferBuilder builder) { + int o = builder.endTable(); + return o; + } + public static void finishServerSnapshotBuffer(FlatBufferBuilder builder, int offset) { builder.finish(offset, "GMBS"); } + public static void finishSizePrefixedServerSnapshotBuffer(FlatBufferBuilder builder, int offset) { builder.finishSizePrefixed(offset, "GMBS"); } + + public static final class Vector extends BaseVector { + public Vector __assign(int _vector, int _element_size, ByteBuffer _bb) { __reset(_vector, _element_size, _bb); return this; } + + public ServerSnapshot get(int j) { return get(new ServerSnapshot(), j); } + public ServerSnapshot get(ServerSnapshot obj, int j) { return obj.__assign(__indirect(__element(j), bb), bb); } + } +} + diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/GoMinecraftBridgeMod.java b/src/main/java/dev/yawaflua/gominecraftbridge/GoMinecraftBridgeMod.java index 43737fe..82c87e9 100644 --- a/src/main/java/dev/yawaflua/gominecraftbridge/GoMinecraftBridgeMod.java +++ b/src/main/java/dev/yawaflua/gominecraftbridge/GoMinecraftBridgeMod.java @@ -27,8 +27,10 @@ public final class GoMinecraftBridgeMod implements ModInitializer { GoPluginManager plugins = new GoPluginManager(LOGGER); BridgeAdminNetworking.register(plugins); MinecraftSnapshotFactory snapshots = new MinecraftSnapshotFactory(); - plugins.discover(); + // The common initializer also runs in a multiplayer client process. Delay + // server plugin discovery until a real dedicated/integrated server exists. + ServerLifecycleEvents.SERVER_STARTING.register(server -> plugins.discover()); ServerLifecycleEvents.SERVER_STARTED.register(plugins::start); ServerTickEvents.END_SERVER_TICK.register(plugins::tick); ServerLifecycleEvents.SERVER_STOPPING.register(plugins::stop); diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/backend/nativeffi/NativePluginBackend.java b/src/main/java/dev/yawaflua/gominecraftbridge/backend/nativeffi/NativePluginBackend.java index 5a6f874..9fd946f 100644 --- a/src/main/java/dev/yawaflua/gominecraftbridge/backend/nativeffi/NativePluginBackend.java +++ b/src/main/java/dev/yawaflua/gominecraftbridge/backend/nativeffi/NativePluginBackend.java @@ -1,51 +1,46 @@ package dev.yawaflua.gominecraftbridge.backend.nativeffi; +import com.sun.jna.Function; +import com.sun.jna.Memory; +import com.sun.jna.NativeLibrary; +import com.sun.jna.Pointer; +import com.sun.jna.ptr.LongByReference; +import com.sun.jna.ptr.PointerByReference; import dev.yawaflua.gominecraftbridge.backend.PluginBackend; import dev.yawaflua.gominecraftbridge.backend.PluginInvocationException; import dev.yawaflua.gominecraftbridge.protocol.Protocol; -import java.lang.foreign.Arena; -import java.lang.foreign.FunctionDescriptor; -import java.lang.foreign.Linker; -import java.lang.foreign.MemorySegment; -import java.lang.foreign.SymbolLookup; -import java.lang.foreign.ValueLayout; -import java.lang.invoke.MethodHandle; import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +/** + * Java 21+ native backend implemented through JNA. + * + *

Minecraft 1.21.1 runs on Java 21, where the Foreign Function & Memory API + * is still preview-only. JNA keeps the same backend usable on both Java 21 and + * Java 25 without preview flags. Libraries are deliberately never disposed: + * unloading a live Go runtime is unsafe.

+ */ public final class NativePluginBackend implements PluginBackend { - private static final FunctionDescriptor ABI_VERSION_DESCRIPTOR = FunctionDescriptor.of(ValueLayout.JAVA_INT); - private static final FunctionDescriptor CALL_DESCRIPTOR = FunctionDescriptor.of( - ValueLayout.JAVA_INT, - ValueLayout.JAVA_INT, - ValueLayout.ADDRESS, - ValueLayout.JAVA_LONG, - ValueLayout.ADDRESS, - ValueLayout.ADDRESS - ); - private static final FunctionDescriptor FREE_DESCRIPTOR = FunctionDescriptor.ofVoid(ValueLayout.ADDRESS); + private static final Map LIBRARY_LOCKS = new ConcurrentHashMap<>(); private final Path origin; - private final MethodHandle abiVersionHandle; - private final MethodHandle callHandle; - private final MethodHandle freeHandle; + private final Object libraryLock; + private final NativeLibrary library; + private final Function abiVersionFunction; + private final Function callFunction; + private final Function freeFunction; - public NativePluginBackend(Path library) { - this.origin = library.toAbsolutePath().normalize(); + public NativePluginBackend(Path libraryPath) { + this.origin = libraryPath.toAbsolutePath().normalize(); + this.libraryLock = LIBRARY_LOCKS.computeIfAbsent(this.origin, ignored -> new Object()); try { - Linker linker = Linker.nativeLinker(); - // Native Go runtimes are not safely unloadable. The global arena deliberately keeps - // the library resident until JVM shutdown; deinit is a logical lifecycle operation. - SymbolLookup symbols = SymbolLookup.libraryLookup(this.origin, Arena.global()); - this.abiVersionHandle = linker.downcallHandle(required(symbols, "gmb_abi_version"), ABI_VERSION_DESCRIPTOR); - this.callHandle = linker.downcallHandle(required(symbols, "gmb_call"), CALL_DESCRIPTOR); - this.freeHandle = linker.downcallHandle(required(symbols, "gmb_free"), FREE_DESCRIPTOR); - } catch (IllegalCallerException exception) { - throw new PluginInvocationException( - "Native access is disabled. Start Minecraft with --enable-native-access=ALL-UNNAMED", - exception - ); + this.library = NativeLibrary.getInstance(this.origin.toString()); + this.abiVersionFunction = required("gmb_abi_version"); + this.callFunction = required("gmb_call"); + this.freeFunction = required("gmb_free"); } catch (RuntimeException exception) { throw new PluginInvocationException("Cannot load native plugin " + this.origin, exception); } @@ -53,64 +48,66 @@ public final class NativePluginBackend implements PluginBackend { @Override public int abiVersion() { - try { - return (int) this.abiVersionHandle.invokeExact(); - } catch (Throwable throwable) { - throw new PluginInvocationException("Cannot read ABI version from " + this.origin, throwable); + synchronized (this.libraryLock) { + try { + return this.abiVersionFunction.invokeInt(new Object[0]); + } catch (RuntimeException exception) { + throw new PluginInvocationException("Cannot read ABI version from " + this.origin, exception); + } } } @Override - public synchronized byte[] call(Protocol.Operation operation, byte[] input) { - MemorySegment output = MemorySegment.NULL; + public byte[] call(Protocol.Operation operation, byte[] input) { + synchronized (this.libraryLock) { + Memory inputMemory = null; + Pointer output = Pointer.NULL; - try (Arena arena = Arena.ofConfined()) { - MemorySegment inputMemory = MemorySegment.NULL; + try { + if (input.length > 0) { + inputMemory = new Memory(input.length); + inputMemory.write(0, input, 0, input.length); + } - if (input.length > 0) { - inputMemory = arena.allocate(input.length); - inputMemory.copyFrom(MemorySegment.ofArray(input)); - } + PointerByReference outputPointer = new PointerByReference(Pointer.NULL); + LongByReference outputLength = new LongByReference(0L); + int status = this.callFunction.invokeInt(new Object[]{ + operation.code(), + inputMemory == null ? Pointer.NULL : inputMemory, + (long) input.length, + outputPointer, + outputLength + }); - MemorySegment outputPointer = arena.allocate(ValueLayout.ADDRESS); - MemorySegment outputLength = arena.allocate(ValueLayout.JAVA_LONG); - outputPointer.set(ValueLayout.ADDRESS, 0, MemorySegment.NULL); - outputLength.set(ValueLayout.JAVA_LONG, 0, 0L); - int status = (int) this.callHandle.invokeExact( - operation.code(), - inputMemory, - (long) input.length, - outputPointer, - outputLength - ); + output = outputPointer.getValue(); + long length = outputLength.getValue(); + if (status != 0) { + throw new PluginInvocationException("Native entrypoint returned status " + status); + } + if (length < 0 || length > Protocol.MAX_RESPONSE_BYTES) { + throw new PluginInvocationException("Native response has invalid length " + length); + } + if (length == 0) { + return new byte[0]; + } + if (output == null) { + throw new PluginInvocationException( + "Native response pointer is null for " + length + " bytes" + ); + } - output = outputPointer.get(ValueLayout.ADDRESS, 0); - long length = outputLength.get(ValueLayout.JAVA_LONG, 0); - - if (status != 0) { - throw new PluginInvocationException("Native entrypoint returned status " + status); - } - if (length < 0 || length > Protocol.MAX_RESPONSE_BYTES) { - throw new PluginInvocationException("Native response has invalid length " + length); - } - if (length == 0) { - return new byte[0]; - } - if (output.equals(MemorySegment.NULL)) { - throw new PluginInvocationException("Native response pointer is null for " + length + " bytes"); - } - - return output.reinterpret(length).toArray(ValueLayout.JAVA_BYTE); - } catch (PluginInvocationException exception) { - throw exception; - } catch (Throwable throwable) { - throw new PluginInvocationException("Native call failed for " + this.origin, throwable); - } finally { - if (!output.equals(MemorySegment.NULL)) { - try { - this.freeHandle.invokeExact(output); - } catch (Throwable throwable) { - throw new PluginInvocationException("Cannot free native response from " + this.origin, throwable); + return output.getByteArray(0, Math.toIntExact(length)); + } catch (PluginInvocationException exception) { + throw exception; + } catch (RuntimeException exception) { + throw new PluginInvocationException("Native call failed for " + this.origin, exception); + } finally { + if (output != null) { + try { + this.freeFunction.invokeVoid(new Object[]{output}); + } catch (RuntimeException exception) { + throw new PluginInvocationException("Cannot free native response from " + this.origin, exception); + } } } } @@ -121,7 +118,11 @@ public final class NativePluginBackend implements PluginBackend { return this.origin; } - private static MemorySegment required(SymbolLookup symbols, String name) { - return symbols.find(name).orElseThrow(() -> new PluginInvocationException("Missing native symbol " + name)); + private Function required(String name) { + try { + return this.library.getFunction(name); + } catch (UnsatisfiedLinkError exception) { + throw new PluginInvocationException("Missing native symbol " + name, exception); + } } } diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/host/BuiltInSystemCall.java b/src/main/java/dev/yawaflua/gominecraftbridge/host/BuiltInSystemCall.java new file mode 100644 index 0000000..25aef93 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/host/BuiltInSystemCall.java @@ -0,0 +1,19 @@ +package dev.yawaflua.gominecraftbridge.host; + +/** Names of the system calls supplied by the bridge itself. */ +public enum BuiltInSystemCall { + SERVER_INFO("minecraft:server.info"), + PLAYER_GET("minecraft:player.get"), + BLOCK_GET("minecraft:block.get"), + GET_ENTITY("minecraft:get_entity"); + + private final String id; + + BuiltInSystemCall(String id) { + this.id = id; + } + + public String id() { + return this.id; + } +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/host/BuiltInSystemCalls.java b/src/main/java/dev/yawaflua/gominecraftbridge/host/BuiltInSystemCalls.java index 7ee4695..28f3087 100644 --- a/src/main/java/dev/yawaflua/gominecraftbridge/host/BuiltInSystemCalls.java +++ b/src/main/java/dev/yawaflua/gominecraftbridge/host/BuiltInSystemCalls.java @@ -1,20 +1,27 @@ package dev.yawaflua.gominecraftbridge.host; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; import dev.yawaflua.gominecraftbridge.api.SystemCallRegistry; +import dev.yawaflua.gominecraftbridge.compat.MinecraftVersionAdapter; import dev.yawaflua.gominecraftbridge.protocol.ProtocolJson; import net.minecraft.core.BlockPos; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import java.math.BigDecimal; import java.util.UUID; public final class BuiltInSystemCalls { + private static final MinecraftSnapshotFactory SNAPSHOTS = new MinecraftSnapshotFactory(); + private BuiltInSystemCalls() { } public static void register(SystemCallRegistry registry) { - registry.register("minecraft:server.info", (context, payload) -> { + registry.register(BuiltInSystemCall.SERVER_INFO.id(), (context, payload) -> { JsonObject result = new JsonObject(); result.addProperty("tick", context.server().getTickCount()); result.addProperty("dedicated", context.server().isDedicatedServer()); @@ -22,7 +29,7 @@ public final class BuiltInSystemCalls { return result; }); - registry.register("minecraft:player.get", (context, payload) -> { + registry.register(BuiltInSystemCall.PLAYER_GET.id(), (context, payload) -> { JsonObject request = payload.getAsJsonObject(); UUID playerId = UUID.fromString(request.get("playerUuid").getAsString()); var player = context.server().getPlayerList().getPlayer(playerId); @@ -33,20 +40,20 @@ public final class BuiltInSystemCalls { JsonObject result = new JsonObject(); result.addProperty("uuid", player.getUUID().toString()); result.addProperty("name", player.getName().getString()); - result.addProperty("dimension", player.level().dimension().identifier().toString()); + result.addProperty("dimension", MinecraftVersionAdapter.dimension(player.level())); result.addProperty("x", player.getX()); result.addProperty("y", player.getY()); result.addProperty("z", player.getZ()); return result; }); - registry.register("minecraft:block.get", (context, payload) -> { + registry.register(BuiltInSystemCall.BLOCK_GET.id(), (context, payload) -> { JsonObject request = payload.getAsJsonObject(); String dimension = request.get("dimension").getAsString(); ServerLevel selected = null; for (ServerLevel level : context.server().getAllLevels()) { - if (level.dimension().identifier().toString().equals(dimension)) { + if (MinecraftVersionAdapter.dimension(level).equals(dimension)) { selected = level; break; } @@ -71,5 +78,73 @@ public final class BuiltInSystemCalls { } return result; }); + + registry.register(BuiltInSystemCall.GET_ENTITY.id(), (context, payload) -> { + EntityLookup lookup = parseEntityLookup(payload); + Entity entity = findEntity(context.server().getAllLevels(), lookup); + return entity == null ? ProtocolJson.tree(null) : ProtocolJson.tree(SNAPSHOTS.entity(entity)); + }); + } + + static EntityLookup parseEntityLookup(JsonElement payload) { + if (payload == null || !payload.isJsonObject()) { + throw new IllegalArgumentException("minecraft:get_entity payload must be a JSON object"); + } + + JsonObject request = payload.getAsJsonObject(); + boolean hasUuid = request.has("uuid"); + boolean hasRuntimeId = request.has("runtimeId"); + if (hasUuid == hasRuntimeId) { + throw new IllegalArgumentException( + "minecraft:get_entity payload must contain exactly one of uuid or runtimeId" + ); + } + + if (hasUuid) { + var value = request.get("uuid"); + if (!(value instanceof JsonPrimitive primitive) || !primitive.isString()) { + throw new IllegalArgumentException("minecraft:get_entity uuid must be a string"); + } + + try { + String rawUuid = primitive.getAsString(); + UUID uuid = UUID.fromString(rawUuid); + if (!uuid.toString().equalsIgnoreCase(rawUuid)) { + throw new IllegalArgumentException("UUID must use the canonical 8-4-4-4-12 form"); + } + return new EntityLookup(uuid, null); + } catch (IllegalArgumentException exception) { + throw new IllegalArgumentException("minecraft:get_entity uuid is not a valid UUID", exception); + } + } + + var value = request.get("runtimeId"); + if (!(value instanceof JsonPrimitive primitive) || !primitive.isNumber()) { + throw new IllegalArgumentException("minecraft:get_entity runtimeId must be an integer"); + } + + try { + int runtimeId = new BigDecimal(primitive.getAsString()).intValueExact(); + return new EntityLookup(null, runtimeId); + } catch (ArithmeticException | NumberFormatException exception) { + throw new IllegalArgumentException("minecraft:get_entity runtimeId must be a 32-bit integer", exception); + } + } + + private static Entity findEntity(Iterable levels, EntityLookup lookup) { + for (ServerLevel level : levels) { + for (Entity entity : level.getAllEntities()) { + if (lookup.uuid() != null && lookup.uuid().equals(entity.getUUID())) { + return entity; + } + if (lookup.runtimeId() != null && lookup.runtimeId() == entity.getId()) { + return entity; + } + } + } + return null; + } + + record EntityLookup(UUID uuid, Integer runtimeId) { } } diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/host/GoPluginManager.java b/src/main/java/dev/yawaflua/gominecraftbridge/host/GoPluginManager.java index 7a9b443..5b663e8 100644 --- a/src/main/java/dev/yawaflua/gominecraftbridge/host/GoPluginManager.java +++ b/src/main/java/dev/yawaflua/gominecraftbridge/host/GoPluginManager.java @@ -6,6 +6,7 @@ import dev.yawaflua.gominecraftbridge.api.GoMinecraftBridgeApi; import dev.yawaflua.gominecraftbridge.api.SystemCallContext; import dev.yawaflua.gominecraftbridge.api.SystemCallHandler; import dev.yawaflua.gominecraftbridge.backend.nativeffi.NativePluginBackend; +import dev.yawaflua.gominecraftbridge.compat.MinecraftVersionAdapter; import dev.yawaflua.gominecraftbridge.management.BridgeManagementSnapshot; import dev.yawaflua.gominecraftbridge.management.ManagedPluginSnapshot; import dev.yawaflua.gominecraftbridge.management.PackageInspection; @@ -21,7 +22,6 @@ import dev.yawaflua.gominecraftbridge.protocol.ServerSnapshot; import dev.yawaflua.gominecraftbridge.protocol.SystemCallRequest; import dev.yawaflua.gominecraftbridge.protocol.SystemCallResult; import net.fabricmc.loader.api.FabricLoader; -import net.minecraft.SharedConstants; import net.minecraft.server.MinecraftServer; import org.slf4j.Logger; @@ -91,6 +91,16 @@ public final class GoPluginManager { } LoadedPlugin plugin = new LoadedPlugin(new NativePluginBackend(candidate)); + if (!plugin.metadata().environment().supportsServer()) { + this.packageInspections.add(new PackageInspection( + normalized.toString(), true, plugin.metadata().id(), null + )); + this.logger.info( + "Skipping client-only Go plugin {} in the server runtime", + plugin.metadata().id() + ); + continue; + } LoadedPlugin duplicate = this.plugins.putIfAbsent(plugin.metadata().id(), plugin); if (duplicate != null) { throw new IllegalArgumentException("Duplicate plugin id " + plugin.metadata().id()); @@ -240,7 +250,7 @@ public final class GoPluginManager { PluginResponse response = plugin.invoke( Protocol.Operation.INIT, new InitEvent( - SharedConstants.getCurrentVersion().name(), + MinecraftVersionAdapter.gameVersion(), server.isDedicatedServer(), pluginData.toAbsolutePath().toString() ) diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/host/LoadedPlugin.java b/src/main/java/dev/yawaflua/gominecraftbridge/host/LoadedPlugin.java index 2ddd31b..7a7b6ce 100644 --- a/src/main/java/dev/yawaflua/gominecraftbridge/host/LoadedPlugin.java +++ b/src/main/java/dev/yawaflua/gominecraftbridge/host/LoadedPlugin.java @@ -6,12 +6,16 @@ import dev.yawaflua.gominecraftbridge.protocol.PluginResponse; import dev.yawaflua.gominecraftbridge.protocol.Protocol; import dev.yawaflua.gominecraftbridge.protocol.ProtocolJson; import dev.yawaflua.gominecraftbridge.protocol.SnapshotSubscription; +import dev.yawaflua.gominecraftbridge.protocol.ServerSnapshot; +import dev.yawaflua.gominecraftbridge.protocol.TickSnapshotFlatBuffer; import java.util.Objects; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.List; import dev.yawaflua.gominecraftbridge.protocol.PluginLog; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; public final class LoadedPlugin { private static final int MAX_RETAINED_LOGS = 500; @@ -36,6 +40,7 @@ public final class LoadedPlugin { throw new IllegalArgumentException("Cannot read plugin metadata: " + response.error()); } + validateEnvironment(response.data()); this.metadata = ProtocolJson.GSON.fromJson(response.data(), PluginMetadata.class); validateMetadata(this.metadata); } @@ -49,7 +54,13 @@ public final class LoadedPlugin { } private PluginResponse invokeUnchecked(Protocol.Operation operation, Object input) { - byte[] output = this.backend.call(operation, ProtocolJson.encode(input)); + byte[] encodedInput; + if (operation == Protocol.Operation.TICK && input instanceof ServerSnapshot snapshot) { + encodedInput = TickSnapshotFlatBuffer.encode(snapshot); + } else { + encodedInput = ProtocolJson.encode(input); + } + byte[] output = this.backend.call(operation, encodedInput); if (output.length == 0) { throw new IllegalArgumentException("Plugin returned an empty response for " + operation); } @@ -95,6 +106,11 @@ public final class LoadedPlugin { } public synchronized void appendLog(PluginLog log) { + String message = log.message() == null ? "" : log.message(); + if (message.length() > 8_192) { + message = message.substring(0, 8_192) + "… [truncated]"; + } + log = new PluginLog(log.stream(), log.level(), message, log.timestampUnixMilli()); while (this.logs.size() >= MAX_RETAINED_LOGS) { this.logs.removeFirst(); } @@ -122,4 +138,22 @@ public final class LoadedPlugin { throw new IllegalArgumentException("Unsupported plugin API version " + metadata.apiVersion()); } } + + private static void validateEnvironment(JsonElement rawMetadata) { + if (!rawMetadata.isJsonObject()) { + throw new IllegalArgumentException("Plugin metadata must be a JSON object"); + } + JsonObject object = rawMetadata.getAsJsonObject(); + if (!object.has("environment") || object.get("environment").isJsonNull()) { + return; + } + JsonElement value = object.get("environment"); + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) { + throw new IllegalArgumentException("Plugin environment must be server, client, or both"); + } + String environment = value.getAsString(); + if (!environment.equals("server") && !environment.equals("client") && !environment.equals("both")) { + throw new IllegalArgumentException("Invalid plugin environment: " + environment); + } + } } diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/host/MinecraftSnapshotFactory.java b/src/main/java/dev/yawaflua/gominecraftbridge/host/MinecraftSnapshotFactory.java index 66425ee..89a6ace 100644 --- a/src/main/java/dev/yawaflua/gominecraftbridge/host/MinecraftSnapshotFactory.java +++ b/src/main/java/dev/yawaflua/gominecraftbridge/host/MinecraftSnapshotFactory.java @@ -6,6 +6,7 @@ import dev.yawaflua.gominecraftbridge.protocol.EntitySnapshot; import dev.yawaflua.gominecraftbridge.protocol.LevelSnapshot; import dev.yawaflua.gominecraftbridge.protocol.ServerSnapshot; import dev.yawaflua.gominecraftbridge.protocol.SnapshotSubscription; +import dev.yawaflua.gominecraftbridge.compat.MinecraftVersionAdapter; import net.minecraft.core.BlockPos; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.server.MinecraftServer; @@ -35,7 +36,7 @@ public final class MinecraftSnapshotFactory { levels.add(new LevelSnapshot( dimension, level.getGameTime(), - level.getDefaultClockTime(), + MinecraftVersionAdapter.dayTime(level), level.isRaining(), level.isThundering() )); @@ -111,7 +112,7 @@ public final class MinecraftSnapshotFactory { } private static String dimension(net.minecraft.world.level.Level level) { - return level.dimension().identifier().toString(); + return MinecraftVersionAdapter.dimension(level); } private static Map properties(BlockState state) { diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/ClientTickEvent.java b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/ClientTickEvent.java new file mode 100644 index 0000000..938f41b --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/ClientTickEvent.java @@ -0,0 +1,13 @@ +package dev.yawaflua.gominecraftbridge.protocol; + +/** A deliberately small snapshot that is safe to produce on the client thread. */ +public record ClientTickEvent( + long tick, + long timestampUnixMilli, + boolean connected, + String serverAddress, + String playerUuid, + String playerName, + String dimension +) { +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/InitEvent.java b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/InitEvent.java index 1e0cad1..afb27c5 100644 --- a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/InitEvent.java +++ b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/InitEvent.java @@ -3,6 +3,11 @@ package dev.yawaflua.gominecraftbridge.protocol; public record InitEvent( String minecraftVersion, boolean dedicated, - String dataDirectory + String dataDirectory, + PluginEnvironment runtimeEnvironment ) { + /** Source-compatible constructor for server hosts using the original ABI v2 event. */ + public InitEvent(String minecraftVersion, boolean dedicated, String dataDirectory) { + this(minecraftVersion, dedicated, dataDirectory, PluginEnvironment.SERVER); + } } diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/PluginEnvironment.java b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/PluginEnvironment.java new file mode 100644 index 0000000..9e9c71d --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/PluginEnvironment.java @@ -0,0 +1,21 @@ +package dev.yawaflua.gominecraftbridge.protocol; + +import com.google.gson.annotations.SerializedName; + +/** Declares the Minecraft process in which a Go plugin is allowed to run. */ +public enum PluginEnvironment { + @SerializedName("server") + SERVER, + @SerializedName("client") + CLIENT, + @SerializedName("both") + BOTH; + + public boolean supportsServer() { + return this == SERVER || this == BOTH; + } + + public boolean supportsClient() { + return this == CLIENT || this == BOTH; + } +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/PluginMetadata.java b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/PluginMetadata.java index f539008..064b40a 100644 --- a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/PluginMetadata.java +++ b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/PluginMetadata.java @@ -12,9 +12,27 @@ public record PluginMetadata( List authors, String website, int apiVersion, - JsonObject configSchema + JsonObject configSchema, + PluginEnvironment environment ) { public PluginMetadata { authors = authors == null ? List.of() : List.copyOf(authors); + // Metadata produced before the environment field was introduced is + // server-side by definition and remains valid without an ABI bump. + environment = environment == null ? PluginEnvironment.SERVER : environment; + } + + /** Source-compatible constructor for metadata created before environment was introduced. */ + public PluginMetadata( + String id, + String name, + String version, + String description, + List authors, + String website, + int apiVersion, + JsonObject configSchema + ) { + this(id, name, version, description, authors, website, apiVersion, configSchema, PluginEnvironment.SERVER); } } diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/Protocol.java b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/Protocol.java index 11cdd94..4ab7729 100644 --- a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/Protocol.java +++ b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/Protocol.java @@ -1,7 +1,7 @@ package dev.yawaflua.gominecraftbridge.protocol; public final class Protocol { - public static final int ABI_VERSION = 1; + public static final int ABI_VERSION = 2; public static final int MAX_RESPONSE_BYTES = 64 * 1024 * 1024; private Protocol() { @@ -14,7 +14,9 @@ public final class Protocol { CHAT(4), DEATH(5), SYSTEM_CALL_RESULT(6), - DEINIT(7); + DEINIT(7), + /** Client-process tick. Added as an optional ABI v2 operation. */ + CLIENT_TICK(8); private final int code; diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/TickSnapshotFlatBuffer.java b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/TickSnapshotFlatBuffer.java new file mode 100644 index 0000000..b0a0462 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/TickSnapshotFlatBuffer.java @@ -0,0 +1,92 @@ +package dev.yawaflua.gominecraftbridge.protocol; + +import com.google.flatbuffers.FlatBufferBuilder; + +import java.util.Map; + +public final class TickSnapshotFlatBuffer { + private TickSnapshotFlatBuffer() { + } + + public static byte[] encode(ServerSnapshot snapshot) { + FlatBufferBuilder builder = new FlatBufferBuilder(16 * 1024); + + int[] levelOffsets = new int[snapshot.levels().size()]; + for (int index = 0; index < snapshot.levels().size(); index++) { + LevelSnapshot level = snapshot.levels().get(index); + levelOffsets[index] = gmb.LevelSnapshot.createLevelSnapshot( + builder, + builder.createSharedString(level.dimension()), + level.gameTime(), + level.dayTime(), + level.raining(), + level.thundering() + ); + } + + int[] entityOffsets = new int[snapshot.entities().size()]; + for (int index = 0; index < snapshot.entities().size(); index++) { + EntitySnapshot entity = snapshot.entities().get(index); + boolean hasHealth = entity.health() != null && entity.maxHealth() != null; + entityOffsets[index] = gmb.EntitySnapshot.createEntitySnapshot( + builder, + entity.runtimeId(), + builder.createSharedString(entity.uuid()), + builder.createSharedString(entity.type()), + builder.createSharedString(entity.name()), + builder.createSharedString(entity.dimension()), + entity.x(), + entity.y(), + entity.z(), + entity.yaw(), + entity.pitch(), + entity.velocityX(), + entity.velocityY(), + entity.velocityZ(), + entity.alive(), + entity.player(), + hasHealth, + hasHealth ? entity.health() : 0.0F, + hasHealth ? entity.maxHealth() : 0.0F + ); + } + + int[] blockOffsets = new int[snapshot.blocks().size()]; + for (int index = 0; index < snapshot.blocks().size(); index++) { + BlockSnapshot block = snapshot.blocks().get(index); + int[] propertyOffsets = new int[block.properties().size()]; + int propertyIndex = 0; + for (Map.Entry property : block.properties().entrySet()) { + propertyOffsets[propertyIndex++] = gmb.BlockProperty.createBlockProperty( + builder, + builder.createSharedString(property.getKey()), + builder.createSharedString(property.getValue()) + ); + } + int properties = gmb.BlockSnapshot.createPropertiesVector(builder, propertyOffsets); + blockOffsets[index] = gmb.BlockSnapshot.createBlockSnapshot( + builder, + builder.createSharedString(block.dimension()), + block.x(), + block.y(), + block.z(), + builder.createSharedString(block.block()), + properties + ); + } + + int levels = gmb.ServerSnapshot.createLevelsVector(builder, levelOffsets); + int entities = gmb.ServerSnapshot.createEntitiesVector(builder, entityOffsets); + int blocks = gmb.ServerSnapshot.createBlocksVector(builder, blockOffsets); + int root = gmb.ServerSnapshot.createServerSnapshot( + builder, + snapshot.tick(), + snapshot.timestampUnixMilli(), + levels, + entities, + blocks + ); + gmb.ServerSnapshot.finishServerSnapshotBuffer(builder, root); + return builder.sizedByteArray(); + } +} diff --git a/src/test/java/dev/yawaflua/gominecraftbridge/backend/NativePluginBackendTest.java b/src/test/java/dev/yawaflua/gominecraftbridge/backend/NativePluginBackendTest.java index 3d1a1f6..2932db9 100644 --- a/src/test/java/dev/yawaflua/gominecraftbridge/backend/NativePluginBackendTest.java +++ b/src/test/java/dev/yawaflua/gominecraftbridge/backend/NativePluginBackendTest.java @@ -3,13 +3,19 @@ package dev.yawaflua.gominecraftbridge.backend; import dev.yawaflua.gominecraftbridge.backend.nativeffi.NativePluginBackend; import dev.yawaflua.gominecraftbridge.host.LoadedPlugin; import dev.yawaflua.gominecraftbridge.protocol.ChatEvent; +import dev.yawaflua.gominecraftbridge.protocol.ClientTickEvent; import dev.yawaflua.gominecraftbridge.protocol.DeinitEvent; import dev.yawaflua.gominecraftbridge.protocol.PluginResponse; import dev.yawaflua.gominecraftbridge.protocol.Protocol; +import dev.yawaflua.gominecraftbridge.protocol.PluginEnvironment; +import dev.yawaflua.gominecraftbridge.protocol.ServerSnapshot; +import dev.yawaflua.gominecraftbridge.protocol.LevelSnapshot; +import dev.yawaflua.gominecraftbridge.protocol.EntitySnapshot; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import java.nio.file.Path; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -22,6 +28,7 @@ final class NativePluginBackendTest { assertEquals("hello_native", plugin.metadata().id()); assertEquals(Protocol.ABI_VERSION, plugin.metadata().apiVersion()); + assertEquals(PluginEnvironment.BOTH, plugin.metadata().environment()); PluginResponse response = plugin.invoke( Protocol.Operation.CHAT, @@ -33,6 +40,33 @@ final class NativePluginBackendTest { assertEquals(1, response.systemCalls().size()); assertEquals("minecraft:server.info", response.systemCalls().getFirst().name()); + PluginResponse tick = plugin.invoke( + Protocol.Operation.TICK, + new ServerSnapshot( + 200, + 1, + List.of(new LevelSnapshot("minecraft:overworld", 200, 200, false, false)), + List.of(new EntitySnapshot( + 1, + "00000000-0000-0000-0000-000000000001", + "minecraft:player", + "Test", + "minecraft:overworld", + 1, 64, 1, 0, 0, 0, 0, 0, true, true, 20F, 20F + )), + List.of() + ) + ); + assertEquals("ok", tick.status()); + assertEquals("tick=200 entities=1 watched_blocks=0", tick.logs().getFirst().message()); + + PluginResponse clientTick = plugin.invoke( + Protocol.Operation.CLIENT_TICK, + new ClientTickEvent(1200, 1, true, "localhost", "uuid", "Test", "minecraft:overworld") + ); + assertEquals("ok", clientTick.status()); + assertEquals("minecraft:client.chat.display", clientTick.actions().getFirst().type()); + PluginResponse deinit = plugin.invoke( Protocol.Operation.DEINIT, new DeinitEvent("integration_test") diff --git a/src/test/java/dev/yawaflua/gominecraftbridge/host/BuiltInSystemCallsTest.java b/src/test/java/dev/yawaflua/gominecraftbridge/host/BuiltInSystemCallsTest.java new file mode 100644 index 0000000..53a71ac --- /dev/null +++ b/src/test/java/dev/yawaflua/gominecraftbridge/host/BuiltInSystemCallsTest.java @@ -0,0 +1,81 @@ +package dev.yawaflua.gominecraftbridge.host; + +import com.google.gson.JsonNull; +import com.google.gson.JsonParser; +import dev.yawaflua.gominecraftbridge.api.SystemCallRegistry; +import org.junit.jupiter.api.Test; + +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +final class BuiltInSystemCallsTest { + @Test + void registersEveryBuiltInSystemCallByItsEnumId() { + SystemCallRegistry registry = new SystemCallRegistry(); + BuiltInSystemCalls.register(registry); + + assertEquals( + Set.of( + BuiltInSystemCall.SERVER_INFO.id(), + BuiltInSystemCall.PLAYER_GET.id(), + BuiltInSystemCall.BLOCK_GET.id(), + BuiltInSystemCall.GET_ENTITY.id() + ), + registry.entries().keySet() + ); + } + + @Test + void parsesUuidEntityLookup() { + UUID uuid = UUID.fromString("a4b505b8-4379-42ce-aed1-192b7698b20f"); + var lookup = BuiltInSystemCalls.parseEntityLookup( + JsonParser.parseString("{\"uuid\":\"" + uuid + "\"}") + ); + + assertEquals(uuid, lookup.uuid()); + assertNull(lookup.runtimeId()); + } + + @Test + void parsesRuntimeIdEntityLookup() { + var lookup = BuiltInSystemCalls.parseEntityLookup(JsonParser.parseString("{\"runtimeId\":42}")); + + assertNull(lookup.uuid()); + assertEquals(42, lookup.runtimeId()); + } + + @Test + void rejectsMissingAmbiguousOrMalformedSelectors() { + assertThrows(IllegalArgumentException.class, () -> BuiltInSystemCalls.parseEntityLookup(JsonNull.INSTANCE)); + assertThrows( + IllegalArgumentException.class, + () -> BuiltInSystemCalls.parseEntityLookup(JsonParser.parseString("{}")) + ); + assertThrows( + IllegalArgumentException.class, + () -> BuiltInSystemCalls.parseEntityLookup( + JsonParser.parseString("{\"uuid\":\"a4b505b8-4379-42ce-aed1-192b7698b20f\",\"runtimeId\":42}") + ) + ); + assertThrows( + IllegalArgumentException.class, + () -> BuiltInSystemCalls.parseEntityLookup(JsonParser.parseString("{\"uuid\":\"invalid\"}")) + ); + assertThrows( + IllegalArgumentException.class, + () -> BuiltInSystemCalls.parseEntityLookup(JsonParser.parseString("{\"uuid\":\"1-1-1-1-1\"}")) + ); + assertThrows( + IllegalArgumentException.class, + () -> BuiltInSystemCalls.parseEntityLookup(JsonParser.parseString("{\"runtimeId\":1.5}")) + ); + assertThrows( + IllegalArgumentException.class, + () -> BuiltInSystemCalls.parseEntityLookup(JsonParser.parseString("{\"runtimeId\":2147483648}")) + ); + } +} diff --git a/src/test/java/dev/yawaflua/gominecraftbridge/host/LoadedPluginMetadataTest.java b/src/test/java/dev/yawaflua/gominecraftbridge/host/LoadedPluginMetadataTest.java new file mode 100644 index 0000000..966ed6b --- /dev/null +++ b/src/test/java/dev/yawaflua/gominecraftbridge/host/LoadedPluginMetadataTest.java @@ -0,0 +1,65 @@ +package dev.yawaflua.gominecraftbridge.host; + +import dev.yawaflua.gominecraftbridge.backend.PluginBackend; +import dev.yawaflua.gominecraftbridge.protocol.PluginEnvironment; +import dev.yawaflua.gominecraftbridge.protocol.Protocol; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +final class LoadedPluginMetadataTest { + @Test + void defaultsLegacyMetadataToServer() { + LoadedPlugin plugin = new LoadedPlugin(new MetadataBackend("")); + + assertEquals(PluginEnvironment.SERVER, plugin.metadata().environment()); + } + + @Test + void acceptsAllDeclaredEnvironments() { + assertEquals( + PluginEnvironment.SERVER, + new LoadedPlugin(new MetadataBackend(",\"environment\":\"server\"")).metadata().environment() + ); + assertEquals( + PluginEnvironment.CLIENT, + new LoadedPlugin(new MetadataBackend(",\"environment\":\"client\"")).metadata().environment() + ); + assertEquals( + PluginEnvironment.BOTH, + new LoadedPlugin(new MetadataBackend(",\"environment\":\"both\"")).metadata().environment() + ); + } + + @Test + void rejectsUnknownEnvironment() { + assertThrows( + IllegalArgumentException.class, + () -> new LoadedPlugin(new MetadataBackend(",\"environment\":\"proxy\"")) + ); + } + + private record MetadataBackend(String environmentField) implements PluginBackend { + @Override + public int abiVersion() { + return Protocol.ABI_VERSION; + } + + @Override + public byte[] call(Protocol.Operation operation, byte[] input) { + String json = "{\"status\":\"ok\",\"data\":{" + + "\"id\":\"test_plugin\",\"name\":\"Test\",\"version\":\"1.0.0\",\"apiVersion\":2" + + environmentField + "}}"; + return json.getBytes(StandardCharsets.UTF_8); + } + + @Override + public Path origin() { + return Path.of("test-plugin"); + } + } +} diff --git a/src/test/java/dev/yawaflua/gominecraftbridge/network/BridgeAdminNetworkingTest.java b/src/test/java/dev/yawaflua/gominecraftbridge/network/BridgeAdminNetworkingTest.java new file mode 100644 index 0000000..000ab98 --- /dev/null +++ b/src/test/java/dev/yawaflua/gominecraftbridge/network/BridgeAdminNetworkingTest.java @@ -0,0 +1,34 @@ +package dev.yawaflua.gominecraftbridge.network; + +import dev.yawaflua.gominecraftbridge.management.BridgeManagementSnapshot; +import dev.yawaflua.gominecraftbridge.management.PackageInspection; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class BridgeAdminNetworkingTest { + @Test + void deniedSnapshotDoesNotExposeManagementDetails() { + BridgeManagementSnapshot source = new BridgeManagementSnapshot( + 123L, + true, + true, + "Administrator permission is required", + List.of(new PackageInspection("/secret/plugin.so", false, null, "load error")), + List.of() + ); + + BridgeManagementSnapshot denied = BridgeAdminNetworking.withoutDetails(source); + + assertEquals(123L, denied.generatedAtUnixMilli()); + assertTrue(denied.serverRunning()); + assertFalse(denied.canReload()); + assertEquals("Administrator permission is required", denied.message()); + assertTrue(denied.packages().isEmpty()); + assertTrue(denied.plugins().isEmpty()); + } +} diff --git a/versions/1.21.1/build.gradle b/versions/1.21.1/build.gradle new file mode 100644 index 0000000..06271df --- /dev/null +++ b/versions/1.21.1/build.gradle @@ -0,0 +1,117 @@ +plugins { + id 'dev.architectury.loom' + id 'maven-publish' +} + +version = rootProject.mod_version +group = rootProject.maven_group + +base { + archivesName = "${rootProject.archives_base_name}-1.21.1" +} + +repositories { + mavenCentral() + maven { url 'https://maven.shedaniel.me/' } + maven { url 'https://maven.terraformersmc.com/releases/' } + maven { url 'https://api.modrinth.com/maven/' } +} + +loom { + splitEnvironmentSourceSets() + + mods { + go_minecraft_bridge { + sourceSet sourceSets.main + sourceSet sourceSets.client + } + } + + runs { + configureEach { + vmArg '--enable-native-access=ALL-UNNAMED' + } + } +} + +def sharedRoot = file('../..') +def prepareMainSources = tasks.register('prepareMainSources', Sync) { + from(sharedRoot.toPath().resolve('src/main/java')) { + exclude 'dev/yawaflua/gominecraftbridge/compat/MinecraftVersionAdapter.java' + exclude 'dev/yawaflua/gominecraftbridge/network/AdminRequestPayload.java' + exclude 'dev/yawaflua/gominecraftbridge/network/AdminResponsePayload.java' + exclude 'dev/yawaflua/gominecraftbridge/network/BridgeAdminNetworking.java' + } + from(sharedRoot.toPath().resolve('src/main/generated')) + from(file('src/main/java')) + into(layout.buildDirectory.dir('version-sources/main')) +} + +sourceSets { + main { + java.setSrcDirs([layout.buildDirectory.dir('version-sources/main')]) + resources.setSrcDirs([file('src/main/resources')]) + } + client { + java.setSrcDirs([sharedRoot.toPath().resolve('src/client/java')]) + } + test { + java.setSrcDirs([sharedRoot.toPath().resolve('src/test/java'), file('src/test/java')]) + } +} + +dependencies { + minecraft 'com.mojang:minecraft:1.21.1' + mappings loom.officialMojangMappings() + modImplementation 'net.fabricmc:fabric-loader:0.16.14' + modImplementation 'net.fabricmc.fabric-api:fabric-api:0.116.14+1.21.1' + + implementation 'com.google.code.gson:gson:2.13.2' + implementation include('com.google.flatbuffers:flatbuffers-java:25.2.10') + implementation 'net.java.dev.jna:jna:5.18.1' + + modClientImplementation 'me.shedaniel.cloth:cloth-config-fabric:15.0.140' + modClientImplementation 'maven.modrinth:modmenu:11.0.3' + + testImplementation platform('org.junit:junit-bom:5.13.4') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +processResources { + inputs.property 'version', project.version + filesMatching('fabric.mod.json') { + expand 'version': project.version + } +} + +tasks.withType(JavaCompile).configureEach { + options.release = 21 +} + +tasks.named('compileJava') { + dependsOn prepareMainSources +} + +tasks.withType(Test).configureEach { + useJUnitPlatform() + jvmArgs '--enable-native-access=ALL-UNNAMED' +} + +java { + withSourcesJar() + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +tasks.named('sourcesJar') { + dependsOn prepareMainSources +} + +publishing { + publications { + create('mavenJava', MavenPublication) { + from components.java + } + } +} diff --git a/versions/1.21.1/src/main/java/dev/yawaflua/gominecraftbridge/compat/MinecraftVersionAdapter.java b/versions/1.21.1/src/main/java/dev/yawaflua/gominecraftbridge/compat/MinecraftVersionAdapter.java new file mode 100644 index 0000000..602e637 --- /dev/null +++ b/versions/1.21.1/src/main/java/dev/yawaflua/gominecraftbridge/compat/MinecraftVersionAdapter.java @@ -0,0 +1,29 @@ +package dev.yawaflua.gominecraftbridge.compat; + +import net.minecraft.SharedConstants; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.Level; + +/** Minecraft 1.21.1 implementation of the shared version adapter. */ +public final class MinecraftVersionAdapter { + private MinecraftVersionAdapter() { + } + + public static String gameVersion() { + return SharedConstants.getCurrentVersion().getName(); + } + + public static String dimension(Level level) { + return level.dimension().location().toString(); + } + + public static long dayTime(ServerLevel level) { + return level.getDayTime(); + } + + public static boolean isOperator(MinecraftServer server, ServerPlayer player) { + return server.getPlayerList().isOp(player.getGameProfile()); + } +} diff --git a/versions/1.21.1/src/main/java/dev/yawaflua/gominecraftbridge/network/AdminRequestPayload.java b/versions/1.21.1/src/main/java/dev/yawaflua/gominecraftbridge/network/AdminRequestPayload.java new file mode 100644 index 0000000..7517b14 --- /dev/null +++ b/versions/1.21.1/src/main/java/dev/yawaflua/gominecraftbridge/network/AdminRequestPayload.java @@ -0,0 +1,29 @@ +package dev.yawaflua.gominecraftbridge.network; + +import dev.yawaflua.gominecraftbridge.GoMinecraftBridgeMod; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; + +public record AdminRequestPayload(String action, String pluginId) implements CustomPacketPayload { + public static final Type TYPE = new Type<>( + ResourceLocation.fromNamespaceAndPath(GoMinecraftBridgeMod.MOD_ID, "admin_request") + ); + public static final StreamCodec CODEC = + CustomPacketPayload.codec(AdminRequestPayload::write, AdminRequestPayload::new); + + public AdminRequestPayload(RegistryFriendlyByteBuf buffer) { + this(buffer.readUtf(32), buffer.readUtf(64)); + } + + private void write(RegistryFriendlyByteBuf buffer) { + buffer.writeUtf(this.action, 32); + buffer.writeUtf(this.pluginId == null ? "" : this.pluginId, 64); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/versions/1.21.1/src/main/java/dev/yawaflua/gominecraftbridge/network/AdminResponsePayload.java b/versions/1.21.1/src/main/java/dev/yawaflua/gominecraftbridge/network/AdminResponsePayload.java new file mode 100644 index 0000000..2b231e9 --- /dev/null +++ b/versions/1.21.1/src/main/java/dev/yawaflua/gominecraftbridge/network/AdminResponsePayload.java @@ -0,0 +1,31 @@ +package dev.yawaflua.gominecraftbridge.network; + +import dev.yawaflua.gominecraftbridge.GoMinecraftBridgeMod; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; + +public record AdminResponsePayload(String json) implements CustomPacketPayload { + // 1.21.1 rejects a custom payload at 1 MiB. Leave room for the channel, + // packet framing, VarInts and UTF-8 expansion. + public static final int MAX_JSON_CHARS = 900 * 1024; + public static final Type TYPE = new Type<>( + ResourceLocation.fromNamespaceAndPath(GoMinecraftBridgeMod.MOD_ID, "admin_response") + ); + public static final StreamCodec CODEC = + CustomPacketPayload.codec(AdminResponsePayload::write, AdminResponsePayload::new); + + public AdminResponsePayload(RegistryFriendlyByteBuf buffer) { + this(buffer.readUtf(MAX_JSON_CHARS)); + } + + private void write(RegistryFriendlyByteBuf buffer) { + buffer.writeUtf(this.json, MAX_JSON_CHARS); + } + + @Override + public Type type() { + return TYPE; + } +} diff --git a/versions/1.21.1/src/main/java/dev/yawaflua/gominecraftbridge/network/BridgeAdminNetworking.java b/versions/1.21.1/src/main/java/dev/yawaflua/gominecraftbridge/network/BridgeAdminNetworking.java new file mode 100644 index 0000000..3a82ea8 --- /dev/null +++ b/versions/1.21.1/src/main/java/dev/yawaflua/gominecraftbridge/network/BridgeAdminNetworking.java @@ -0,0 +1,98 @@ +package dev.yawaflua.gominecraftbridge.network; + +import dev.yawaflua.gominecraftbridge.compat.MinecraftVersionAdapter; +import dev.yawaflua.gominecraftbridge.host.GoPluginManager; +import dev.yawaflua.gominecraftbridge.management.BridgeManagementSnapshot; +import dev.yawaflua.gominecraftbridge.management.ManagedPluginSnapshot; +import dev.yawaflua.gominecraftbridge.management.ReloadResult; +import dev.yawaflua.gominecraftbridge.protocol.ProtocolJson; +import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** Minecraft 1.21.1 networking registration overlay. */ +public final class BridgeAdminNetworking { + private BridgeAdminNetworking() { + } + + public static void register(GoPluginManager plugins) { + PayloadTypeRegistry.playC2S().register(AdminRequestPayload.TYPE, AdminRequestPayload.CODEC); + PayloadTypeRegistry.playS2C().register(AdminResponsePayload.TYPE, AdminResponsePayload.CODEC); + + ServerPlayNetworking.registerGlobalReceiver(AdminRequestPayload.TYPE, (payload, context) -> { + boolean allowed = MinecraftVersionAdapter.isOperator(context.server(), context.player()); + if (!allowed) { + send(context, withoutDetails(plugins.managementSnapshot( + false, "Administrator permission is required" + ))); + return; + } + + String message = null; + if ("reload".equals(payload.action())) { + ReloadResult result = plugins.reload(payload.pluginId(), context.server()); + message = result.message(); + } else if ("rescan".equals(payload.action())) { + ReloadResult result = plugins.rescan(context.server()); + message = result.message(); + } else if (!"refresh".equals(payload.action())) { + message = "Unknown admin action: " + payload.action(); + } + + send(context, plugins.managementSnapshot(true, message)); + }); + } + + static BridgeManagementSnapshot withoutDetails(BridgeManagementSnapshot snapshot) { + return new BridgeManagementSnapshot( + snapshot.generatedAtUnixMilli(), snapshot.serverRunning(), false, + snapshot.message(), List.of(), List.of() + ); + } + + private static void send(ServerPlayNetworking.Context context, BridgeManagementSnapshot snapshot) { + context.responseSender().sendPacket(new AdminResponsePayload(boundedJson(snapshot))); + } + + /** Keeps the encoded 1.21.1 payload below its fixed 1 MiB protocol limit. */ + static String boundedJson(BridgeManagementSnapshot snapshot) { + String json = ProtocolJson.GSON.toJson(snapshot); + if (fits(json)) { + return json; + } + + for (int retainedLogs : new int[]{64, 16, 4, 0}) { + List plugins = new ArrayList<>(snapshot.plugins().size()); + for (ManagedPluginSnapshot plugin : snapshot.plugins()) { + int from = Math.max(0, plugin.logs().size() - retainedLogs); + plugins.add(new ManagedPluginSnapshot( + plugin.metadata(), plugin.state(), plugin.backend(), plugin.origin(), + plugin.logs().subList(from, plugin.logs().size()) + )); + } + BridgeManagementSnapshot trimmed = new BridgeManagementSnapshot( + snapshot.generatedAtUnixMilli(), snapshot.serverRunning(), snapshot.canReload(), + "Response was shortened to fit the Minecraft 1.21.1 payload limit", + snapshot.packages(), plugins + ); + json = ProtocolJson.GSON.toJson(trimmed); + if (fits(json)) { + return json; + } + } + + return ProtocolJson.GSON.toJson(new BridgeManagementSnapshot( + snapshot.generatedAtUnixMilli(), snapshot.serverRunning(), snapshot.canReload(), + "Management data exceeded the Minecraft 1.21.1 payload limit; reload or inspect fewer packages", + List.of(), List.of() + )); + } + + private static boolean fits(String json) { + return json.length() <= AdminResponsePayload.MAX_JSON_CHARS + && json.getBytes(StandardCharsets.UTF_8).length <= AdminResponsePayload.MAX_JSON_CHARS; + } +} diff --git a/versions/1.21.1/src/main/resources/fabric.mod.json b/versions/1.21.1/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..f3bb9f3 --- /dev/null +++ b/versions/1.21.1/src/main/resources/fabric.mod.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "id": "go_minecraft_bridge", + "version": "${version}", + "name": "Go Minecraft Bridge", + "description": "A native Go plugin host with server and client runtimes.", + "authors": ["yawaflua"], + "license": "All-Rights-Reserved", + "environment": "*", + "entrypoints": { + "main": ["dev.yawaflua.gominecraftbridge.GoMinecraftBridgeMod"], + "client": ["dev.yawaflua.gominecraftbridge.client.GoMinecraftBridgeClient"], + "modmenu": ["dev.yawaflua.gominecraftbridge.client.GoBridgeModMenuIntegration"] + }, + "depends": { + "fabricloader": ">=0.16.14", + "minecraft": "~1.21.1", + "java": ">=21", + "fabric-api": "*" + }, + "suggests": { + "cloth-config": ">=15.0.140", + "modmenu": ">=11.0.3" + } +} diff --git a/versions/1.21.1/src/test/java/dev/yawaflua/gominecraftbridge/network/BridgeAdminNetworking1211Test.java b/versions/1.21.1/src/test/java/dev/yawaflua/gominecraftbridge/network/BridgeAdminNetworking1211Test.java new file mode 100644 index 0000000..5657aa3 --- /dev/null +++ b/versions/1.21.1/src/test/java/dev/yawaflua/gominecraftbridge/network/BridgeAdminNetworking1211Test.java @@ -0,0 +1,35 @@ +package dev.yawaflua.gominecraftbridge.network; + +import dev.yawaflua.gominecraftbridge.management.BridgeManagementSnapshot; +import dev.yawaflua.gominecraftbridge.management.ManagedPluginSnapshot; +import dev.yawaflua.gominecraftbridge.protocol.PluginEnvironment; +import dev.yawaflua.gominecraftbridge.protocol.PluginLog; +import dev.yawaflua.gominecraftbridge.protocol.PluginMetadata; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BridgeAdminNetworking1211Test { + @Test + void managementJsonIsTrimmedBelowTheLegacyPayloadLimit() { + String hugeMessage = "Ж".repeat(8192); + List logs = java.util.stream.IntStream.range(0, 500) + .mapToObj(index -> new PluginLog("sdk", "info", hugeMessage, index)) + .toList(); + PluginMetadata metadata = new PluginMetadata( + "large", "Large", "1", "", List.of(), null, 2, null, PluginEnvironment.BOTH + ); + BridgeManagementSnapshot snapshot = new BridgeManagementSnapshot( + 1, true, true, null, List.of(), + List.of(new ManagedPluginSnapshot(metadata, "running", "native", "/plugin.so", logs)) + ); + + String json = BridgeAdminNetworking.boundedJson(snapshot); + + assertTrue(json.length() <= AdminResponsePayload.MAX_JSON_CHARS); + assertTrue(json.getBytes(StandardCharsets.UTF_8).length <= AdminResponsePayload.MAX_JSON_CHARS); + } +} diff --git a/versions/26.1.2/build.gradle b/versions/26.1.2/build.gradle new file mode 100644 index 0000000..9589194 --- /dev/null +++ b/versions/26.1.2/build.gradle @@ -0,0 +1,95 @@ +plugins { + id 'net.fabricmc.fabric-loom' + id 'maven-publish' +} + +version = rootProject.mod_version +group = rootProject.maven_group + +base { + archivesName = "${rootProject.archives_base_name}-26.1.2" +} + +repositories { + mavenCentral() + maven { url 'https://maven.shedaniel.me/' } + maven { url 'https://maven.terraformersmc.com/releases/' } + maven { url 'https://api.modrinth.com/maven/' } +} + +loom { + splitEnvironmentSourceSets() + + mods { + go_minecraft_bridge { + sourceSet sourceSets.main + sourceSet sourceSets.client + } + } + + runs { + configureEach { + vmArg '--enable-native-access=ALL-UNNAMED' + } + } +} + +sourceSets { + main { + java.srcDirs rootProject.file('src/main/java'), rootProject.file('src/main/generated') + resources.srcDirs rootProject.file('src/main/resources') + } + client { + java.srcDirs rootProject.file('src/client/java') + } + test { + java.srcDirs rootProject.file('src/test/java') + } +} + +dependencies { + minecraft 'com.mojang:minecraft:26.1.2' + implementation 'net.fabricmc:fabric-loader:0.19.3' + implementation 'net.fabricmc.fabric-api:fabric-api:0.149.1+26.1.2' + + implementation 'com.google.code.gson:gson:2.13.2' + implementation include('com.google.flatbuffers:flatbuffers-java:25.2.10') + implementation 'net.java.dev.jna:jna:5.18.1' + + clientImplementation 'me.shedaniel.cloth:cloth-config-fabric:26.1.154' + clientImplementation 'maven.modrinth:modmenu:18.0.0' + + testImplementation platform('org.junit:junit-bom:5.13.4') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +processResources { + inputs.property 'version', project.version + filesMatching('fabric.mod.json') { + expand 'version': project.version + } +} + +tasks.withType(JavaCompile).configureEach { + options.release = 25 +} + +tasks.withType(Test).configureEach { + useJUnitPlatform() + jvmArgs '--enable-native-access=ALL-UNNAMED' +} + +java { + withSourcesJar() + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 +} + +publishing { + publications { + create('mavenJava', MavenPublication) { + from components.java + } + } +} diff --git a/versions/26.1.2/src/main/java/dev/yawaflua/gominecraftbridge/compat/MinecraftVersionAdapter.java b/versions/26.1.2/src/main/java/dev/yawaflua/gominecraftbridge/compat/MinecraftVersionAdapter.java new file mode 100644 index 0000000..8dc9497 --- /dev/null +++ b/versions/26.1.2/src/main/java/dev/yawaflua/gominecraftbridge/compat/MinecraftVersionAdapter.java @@ -0,0 +1,29 @@ +package dev.yawaflua.gominecraftbridge.compat; + +import net.minecraft.SharedConstants; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.Level; + +/** Minecraft-version-specific names kept behind one small source overlay. */ +public final class MinecraftVersionAdapter { + private MinecraftVersionAdapter() { + } + + public static String gameVersion() { + return SharedConstants.getCurrentVersion().name(); + } + + public static String dimension(Level level) { + return level.dimension().identifier().toString(); + } + + public static long dayTime(ServerLevel level) { + return level.getDefaultClockTime(); + } + + public static boolean isOperator(MinecraftServer server, ServerPlayer player) { + return server.getPlayerList().isOp(player.nameAndId()); + } +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/network/AdminRequestPayload.java b/versions/26.1.2/src/main/java/dev/yawaflua/gominecraftbridge/network/AdminRequestPayload.java similarity index 100% rename from src/main/java/dev/yawaflua/gominecraftbridge/network/AdminRequestPayload.java rename to versions/26.1.2/src/main/java/dev/yawaflua/gominecraftbridge/network/AdminRequestPayload.java diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/network/AdminResponsePayload.java b/versions/26.1.2/src/main/java/dev/yawaflua/gominecraftbridge/network/AdminResponsePayload.java similarity index 100% rename from src/main/java/dev/yawaflua/gominecraftbridge/network/AdminResponsePayload.java rename to versions/26.1.2/src/main/java/dev/yawaflua/gominecraftbridge/network/AdminResponsePayload.java diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/network/BridgeAdminNetworking.java b/versions/26.1.2/src/main/java/dev/yawaflua/gominecraftbridge/network/BridgeAdminNetworking.java similarity index 68% rename from src/main/java/dev/yawaflua/gominecraftbridge/network/BridgeAdminNetworking.java rename to versions/26.1.2/src/main/java/dev/yawaflua/gominecraftbridge/network/BridgeAdminNetworking.java index 0671716..e3e371f 100644 --- a/src/main/java/dev/yawaflua/gominecraftbridge/network/BridgeAdminNetworking.java +++ b/versions/26.1.2/src/main/java/dev/yawaflua/gominecraftbridge/network/BridgeAdminNetworking.java @@ -1,12 +1,14 @@ package dev.yawaflua.gominecraftbridge.network; +import dev.yawaflua.gominecraftbridge.compat.MinecraftVersionAdapter; import dev.yawaflua.gominecraftbridge.host.GoPluginManager; import dev.yawaflua.gominecraftbridge.management.BridgeManagementSnapshot; import dev.yawaflua.gominecraftbridge.management.ReloadResult; import dev.yawaflua.gominecraftbridge.protocol.ProtocolJson; import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; -import net.minecraft.server.permissions.Permissions; + +import java.util.List; public final class BridgeAdminNetworking { private BridgeAdminNetworking() { @@ -21,9 +23,12 @@ public final class BridgeAdminNetworking { ); ServerPlayNetworking.registerGlobalReceiver(AdminRequestPayload.TYPE, (payload, context) -> { - boolean allowed = context.player().permissions().hasPermission(Permissions.COMMANDS_ADMIN); + boolean allowed = MinecraftVersionAdapter.isOperator(context.server(), context.player()); if (!allowed) { - send(context, plugins.managementSnapshot(false, "Administrator permission is required")); + send(context, withoutDetails(plugins.managementSnapshot( + false, + "Administrator permission is required" + ))); return; } @@ -31,6 +36,9 @@ public final class BridgeAdminNetworking { if ("reload".equals(payload.action())) { ReloadResult result = plugins.reload(payload.pluginId(), context.server()); message = result.message(); + } else if ("rescan".equals(payload.action())) { + ReloadResult result = plugins.rescan(context.server()); + message = result.message(); } else if (!"refresh".equals(payload.action())) { message = "Unknown admin action: " + payload.action(); } @@ -39,6 +47,17 @@ public final class BridgeAdminNetworking { }); } + static BridgeManagementSnapshot withoutDetails(BridgeManagementSnapshot snapshot) { + return new BridgeManagementSnapshot( + snapshot.generatedAtUnixMilli(), + snapshot.serverRunning(), + false, + snapshot.message(), + List.of(), + List.of() + ); + } + private static void send( ServerPlayNetworking.Context context, BridgeManagementSnapshot snapshot