From 020da2847015156a7716e6f57d2916a44717a203 Mon Sep 17 00:00:00 2001 From: Dmitri Shimanski Date: Mon, 20 Jul 2026 04:15:57 +0300 Subject: [PATCH] Add Cloth Config plugin management screen --- .gitignore | 11 + README.md | 200 +++++++++ build.gradle | 83 ++++ docs/native-abi.md | 61 +++ examples/hello-native/build.sh | 14 + examples/hello-native/go.mod | 7 + examples/hello-native/main.go | 88 ++++ gradle.properties | 12 + gradlew | 248 +++++++++++ gradlew.bat | 82 ++++ sdk/context.go | 61 +++ sdk/go.mod | 3 + sdk/json_benchmark_test.go | 63 +++ sdk/native_exports.go | 68 +++ sdk/output.go | 115 +++++ sdk/plugin.go | 29 ++ sdk/runtime.go | 141 ++++++ sdk/runtime_test.go | 54 +++ sdk/types.go | 144 +++++++ settings.gradle | 12 + .../client/ClothManagementScreen.java | 184 ++++++++ .../client/GoBridgeModMenuIntegration.java | 17 + .../client/GoMinecraftBridgeClient.java | 59 +++ .../GoMinecraftBridgeMod.java | 61 +++ .../api/GoMinecraftBridgeApi.java | 17 + .../api/GoPluginRegistry.java | 26 ++ .../api/SystemCallContext.java | 7 + .../api/SystemCallHandler.java | 8 + .../api/SystemCallRegistry.java | 33 ++ .../backend/PluginBackend.java | 13 + .../backend/PluginInvocationException.java | 11 + .../nativeffi/NativePluginBackend.java | 127 ++++++ .../host/ActionExecutor.java | 39 ++ .../host/BuiltInSystemCalls.java | 75 ++++ .../host/GoPluginManager.java | 407 ++++++++++++++++++ .../gominecraftbridge/host/LoadedPlugin.java | 125 ++++++ .../host/MinecraftSnapshotFactory.java | 132 ++++++ .../gominecraftbridge/host/PluginState.java | 8 + .../management/BridgeManagementSnapshot.java | 17 + .../management/ManagedPluginSnapshot.java | 18 + .../management/PackageInspection.java | 9 + .../management/ReloadResult.java | 4 + .../network/AdminRequestPayload.java | 29 ++ .../network/AdminResponsePayload.java | 29 ++ .../network/BridgeAdminNetworking.java | 49 +++ .../protocol/ActionRequest.java | 6 + .../protocol/BlockReference.java | 4 + .../protocol/BlockSnapshot.java | 16 + .../gominecraftbridge/protocol/ChatEvent.java | 9 + .../protocol/DeathEvent.java | 9 + .../protocol/DeinitEvent.java | 4 + .../protocol/EntitySnapshot.java | 22 + .../gominecraftbridge/protocol/InitEvent.java | 8 + .../protocol/LevelSnapshot.java | 10 + .../gominecraftbridge/protocol/PluginLog.java | 4 + .../protocol/PluginMetadata.java | 20 + .../protocol/PluginResponse.java | 30 ++ .../gominecraftbridge/protocol/Protocol.java | 29 ++ .../protocol/ProtocolJson.java | 34 ++ .../protocol/ServerSnapshot.java | 17 + .../protocol/SnapshotSubscription.java | 17 + .../protocol/SystemCallRequest.java | 6 + .../protocol/SystemCallResult.java | 6 + src/main/resources/fabric.mod.json | 33 ++ .../api/SystemCallRegistryTest.java | 24 ++ .../backend/NativePluginBackendTest.java | 43 ++ 66 files changed, 3351 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build.gradle create mode 100644 docs/native-abi.md create mode 100755 examples/hello-native/build.sh create mode 100644 examples/hello-native/go.mod create mode 100644 examples/hello-native/main.go create mode 100644 gradle.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 sdk/context.go create mode 100644 sdk/go.mod create mode 100644 sdk/json_benchmark_test.go create mode 100644 sdk/native_exports.go create mode 100644 sdk/output.go create mode 100644 sdk/plugin.go create mode 100644 sdk/runtime.go create mode 100644 sdk/runtime_test.go create mode 100644 sdk/types.go create mode 100644 settings.gradle create mode 100644 src/client/java/dev/yawaflua/gominecraftbridge/client/ClothManagementScreen.java create mode 100644 src/client/java/dev/yawaflua/gominecraftbridge/client/GoBridgeModMenuIntegration.java create mode 100644 src/client/java/dev/yawaflua/gominecraftbridge/client/GoMinecraftBridgeClient.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/GoMinecraftBridgeMod.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/api/GoMinecraftBridgeApi.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/api/GoPluginRegistry.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/api/SystemCallContext.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/api/SystemCallHandler.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/api/SystemCallRegistry.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/backend/PluginBackend.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/backend/PluginInvocationException.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/backend/nativeffi/NativePluginBackend.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/host/ActionExecutor.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/host/BuiltInSystemCalls.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/host/GoPluginManager.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/host/LoadedPlugin.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/host/MinecraftSnapshotFactory.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/host/PluginState.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/management/BridgeManagementSnapshot.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/management/ManagedPluginSnapshot.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/management/PackageInspection.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/management/ReloadResult.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/network/AdminRequestPayload.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/network/AdminResponsePayload.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/network/BridgeAdminNetworking.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/protocol/ActionRequest.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/protocol/BlockReference.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/protocol/BlockSnapshot.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/protocol/ChatEvent.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/protocol/DeathEvent.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/protocol/DeinitEvent.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/protocol/EntitySnapshot.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/protocol/InitEvent.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/protocol/LevelSnapshot.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/protocol/PluginLog.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/protocol/PluginMetadata.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/protocol/PluginResponse.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/protocol/Protocol.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/protocol/ProtocolJson.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/protocol/ServerSnapshot.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/protocol/SnapshotSubscription.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/protocol/SystemCallRequest.java create mode 100644 src/main/java/dev/yawaflua/gominecraftbridge/protocol/SystemCallResult.java create mode 100644 src/main/resources/fabric.mod.json create mode 100644 src/test/java/dev/yawaflua/gominecraftbridge/api/SystemCallRegistryTest.java create mode 100644 src/test/java/dev/yawaflua/gominecraftbridge/backend/NativePluginBackendTest.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..525c064 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.gradle/ +build/ +run/ +out/ +.idea/ +*.iml +*.so +*.dll +*.dylib +*.wasm +examples/**/dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf8867d --- /dev/null +++ b/README.md @@ -0,0 +1,200 @@ +# 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. + +The current MVP targets: + +- 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; +- native Go plugins built with `-buildmode=c-shared`; +- initialization, 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. + +The backend interface is deliberately independent from Fabric and native FFI so +that a WASI/WASM backend can implement the same protocol later. + +## Build and try the example + +Build the Fabric mod: + +```bash +./gradlew build +``` + +Build the example Go plugin: + +```bash +./examples/hello-native/build.sh +``` + +Copy the resulting library to either location: + +```text +mods/libhello_native.so +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. + +Native access must be enabled for the unnamed Java module: + +```text +--enable-native-access=ALL-UNNAMED +``` + +The development run configurations add this automatically. A production server +must add it to its JVM arguments. + +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. + +## Plugin programming model + +A Go project only imports the SDK and registers one value. The native C exports, +panic boundary, output allocation, and `gmb_free` implementation live inside the +SDK and are linked into the final library automatically: + +```go +package main + +import "github.com/yawaflua/GoMinecraftBridge/sdk" + +type myPlugin struct{} + +func init() { + sdk.Register(myPlugin{}) +} + +func main() {} +``` + +Until the SDK is published as a tagged Go module, reference the local checkout: + +```go +require github.com/yawaflua/GoMinecraftBridge/sdk v0.0.0 + +replace github.com/yawaflua/GoMinecraftBridge/sdk => /path/to/GoMinecraftBridge/sdk +``` + +Build it normally: + +```bash +go build -buildmode=c-shared -o dist/my_plugin.so . +``` + +It implements only the callbacks it needs: + +```go +func (myPlugin) Tick(ctx *sdk.Context, snapshot sdk.ServerSnapshot) error { + for _, entity := range snapshot.Entities { + // Read the immutable snapshot. + _ = entity + } + return nil +} + +func (myPlugin) Chat(ctx *sdk.Context, event sdk.ChatEvent) error { + ctx.SendMessage(event.PlayerUUID, "Hello from Go") + return nil +} +``` + +See [`examples/hello-native/main.go`](examples/hello-native/main.go) for a +complete native entrypoint and every currently supported callback. + +### Snapshots + +Entity snapshots are enabled by default. Blocks are opt-in because walking all +loaded blocks every tick would be prohibitively expensive: + +```go +ctx.SubscribeSnapshot(true, + sdk.BlockReference{ + Dimension: "minecraft:overworld", + X: 0, + Y: 64, + Z: 0, + }, +) +``` + +The subscription returned by a callback applies to subsequent ticks for that +plugin. + +### Actions and system calls + +Actions are fire-and-forget operations implemented by the bridge: + +- `minecraft:chat.broadcast`; +- `minecraft:chat.player`. + +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`. + +Another Java mod can expose a custom call without changing the bridge: + +```java +GoMinecraftBridgeApi.systemCalls().register("example:claim.owner", (context, payload) -> { + JsonObject result = new JsonObject(); + result.addProperty("owner", "player uuid"); + return result; +}); +``` + +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. + +## Failure model + +The SDK converts callback errors into `status=error` and recovers ordinary Go +panics at the ABI boundary. A panic disables that plugin logically. Native Go +libraries remain loaded until JVM shutdown because unloading a live Go runtime is +unsafe. + +Native memory corruption, a C/Go runtime fatal error, or a segmentation fault can +still terminate Minecraft. The planned WASM backend is the isolated option for +untrusted plugins. + +## Verification + +Run all Java and Go tests, including the real Java-to-Go FFI test: + +```bash +./examples/hello-native/build.sh +GMB_TEST_LIBRARY="$PWD/examples/hello-native/dist/libhello_native.so" ./gradlew test +(cd sdk && go test ./...) +``` + +The wire-level contract is documented in [`docs/native-abi.md`](docs/native-abi.md). + +## 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. + +The planned split is: + +- 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. + +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. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..d652f11 --- /dev/null +++ b/build.gradle @@ -0,0 +1,83 @@ +plugins { + id 'net.fabricmc.fabric-loom' version "${loom_version}" + id 'maven-publish' +} + +version = project.mod_version +group = project.maven_group + +base { + archivesName = project.archives_base_name +} + +repositories { + mavenCentral() + maven { url 'https://maven.shedaniel.me/' } + maven { url 'https://maven.terraformersmc.com/releases/' } +} + +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 + } + } +} diff --git a/docs/native-abi.md b/docs/native-abi.md new file mode 100644 index 0000000..d928d32 --- /dev/null +++ b/docs/native-abi.md @@ -0,0 +1,61 @@ +# Native ABI v1 + +Every native plugin is a Go `main` package built with `-buildmode=c-shared` and +exports three C symbols: + +```c +int32_t gmb_abi_version(void); + +int32_t gmb_call( + int32_t operation, + const uint8_t *input, + uint64_t input_length, + uint8_t **output, + uint64_t *output_length +); + +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. + +## Operations + +| Code | Name | Input | +|---:|---|---| +| 1 | metadata | empty | +| 2 | init | `InitEvent` | +| 3 | tick | `ServerSnapshot` | +| 4 | chat | `ChatEvent` | +| 5 | death | `DeathEvent` | +| 6 | system call result | `SystemCallResult` | +| 7 | deinit | `DeinitEvent` | + +Every successful transport response uses this envelope: + +```json +{ + "status": "ok", + "error": "", + "stack": "", + "data": null, + "logs": [], + "actions": [], + "systemCalls": [], + "snapshot": null +} +``` + +`status` is one of `ok`, `error`, or `panic`. The host disables a plugin after a +panic. An ordinary handler error is logged but does not disable an already-running +plugin. + +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. diff --git a/examples/hello-native/build.sh b/examples/hello-native/build.sh new file mode 100755 index 0000000..3cdad8c --- /dev/null +++ b/examples/hello-native/build.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +mkdir -p "$script_dir/dist" + +case "$(go env GOOS)" in + windows) output="$script_dir/dist/hello_native.dll" ;; + darwin) output="$script_dir/dist/libhello_native.dylib" ;; + *) output="$script_dir/dist/libhello_native.so" ;; +esac + +(cd "$script_dir" && go build -buildmode=c-shared -o "$output" .) +echo "$output" diff --git a/examples/hello-native/go.mod b/examples/hello-native/go.mod new file mode 100644 index 0000000..16596cb --- /dev/null +++ b/examples/hello-native/go.mod @@ -0,0 +1,7 @@ +module github.com/yawaflua/GoMinecraftBridge/examples/hello-native + +go 1.24.0 + +require github.com/yawaflua/GoMinecraftBridge/sdk v0.0.0 + +replace github.com/yawaflua/GoMinecraftBridge/sdk => ../../sdk diff --git a/examples/hello-native/main.go b/examples/hello-native/main.go new file mode 100644 index 0000000..4328d45 --- /dev/null +++ b/examples/hello-native/main.go @@ -0,0 +1,88 @@ +package main + +import ( + "encoding/json" + "fmt" + + "github.com/yawaflua/GoMinecraftBridge/sdk" +) + +type helloPlugin struct{} + +func (helloPlugin) Metadata() sdk.Metadata { + return sdk.Metadata{ + ID: "hello_native", + Name: "Hello Native", + Version: "0.1.0", + Description: "Native Go plugin example for Go Minecraft Bridge", + Authors: []string{"yawaflua"}, + ConfigSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "greeting": map[string]any{ + "type": "string", + "default": "Hello from Go!", + }, + }, + }, + } +} + +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, + }) + 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)) + } + return nil +} + +func (helloPlugin) Chat(context *sdk.Context, event sdk.ChatEvent) error { + if event.Message != "!go" { + return nil + } + + context.SendMessage(event.PlayerUUID, "Hello from a native Go plugin!") + context.SystemCall("minecraft:server.info", map[string]any{}) + return nil +} + +func (helloPlugin) Death(context *sdk.Context, event sdk.DeathEvent) error { + context.Broadcast(fmt.Sprintf("[Go] %s died (%s)", event.Entity.Name, event.DamageType)) + return nil +} + +func (helloPlugin) SystemCallResult(context *sdk.Context, result sdk.SystemCallResult) error { + if !result.Success { + return fmt.Errorf("system call %s failed: %s", result.Name, result.Error) + } + + var value any + if err := json.Unmarshal(result.Data, &value); err != nil { + return err + } + fmt.Printf("system call %s result: %v\n", result.Name, value) + return nil +} + +func (helloPlugin) Deinit(context *sdk.Context, event sdk.DeinitEvent) error { + // Intentionally omit the newline: the SDK flush barrier still assigns this + // partial stdout line to the deinit response. + fmt.Printf("deinit: %s", event.Reason) + return nil +} + +func init() { + sdk.Register(helloPlugin{}) +} + +func main() {} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..5d18865 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,12 @@ +org.gradle.jvmargs=-Xmx2G +org.gradle.parallel=true +org.gradle.configuration-cache=false + +minecraft_version=26.1.2 +loader_version=0.19.3 +loom_version=1.16-SNAPSHOT +fabric_api_version=0.149.1+26.1.2 + +mod_version=0.1.0 +maven_group=dev.yawaflua +archives_base_name=go-minecraft-bridge diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..b9bb139 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..aa5f10b --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/sdk/context.go b/sdk/context.go new file mode 100644 index 0000000..0f2d31d --- /dev/null +++ b/sdk/context.go @@ -0,0 +1,61 @@ +package sdk + +import ( + "fmt" + "sync/atomic" + "time" +) + +var callSequence atomic.Uint64 + +type Context struct { + actions []ActionRequest + systemCalls []SystemCallRequest + logs []LogEntry + snapshot *SnapshotSubscription +} + +func (context *Context) Broadcast(message string) { + context.actions = append(context.actions, ActionRequest{ + Type: "minecraft:chat.broadcast", + Payload: map[string]any{ + "message": message, + }, + }) +} + +func (context *Context) SendMessage(playerUUID, message string) { + context.actions = append(context.actions, ActionRequest{ + Type: "minecraft:chat.player", + Payload: map[string]any{ + "playerUuid": playerUUID, + "message": message, + }, + }) +} + +func (context *Context) SystemCall(name string, payload any) string { + id := fmt.Sprintf("call-%d", callSequence.Add(1)) + context.systemCalls = append(context.systemCalls, SystemCallRequest{ + ID: id, + Name: name, + Payload: payload, + }) + return id +} + +func (context *Context) SubscribeSnapshot(entities bool, blocks ...BlockReference) { + context.snapshot = &SnapshotSubscription{ + Entities: entities, + Blocks: append([]BlockReference(nil), blocks...), + } +} + +func (context *Context) Log(level, message string) { + context.logs = append(context.logs, LogEntry{ + Stream: "sdk", + Level: level, + Message: message, + TimestampUnixMilli: time.Now().UnixMilli(), + }) +} diff --git a/sdk/go.mod b/sdk/go.mod new file mode 100644 index 0000000..f8521b2 --- /dev/null +++ b/sdk/go.mod @@ -0,0 +1,3 @@ +module github.com/yawaflua/GoMinecraftBridge/sdk + +go 1.24.0 diff --git a/sdk/json_benchmark_test.go b/sdk/json_benchmark_test.go new file mode 100644 index 0000000..22cd7e1 --- /dev/null +++ b/sdk/json_benchmark_test.go @@ -0,0 +1,63 @@ +package sdk + +import ( + "encoding/json" + "fmt" + "testing" +) + +func BenchmarkJSONSnapshot1000Entities(b *testing.B) { + snapshot := benchmarkSnapshot(1000) + encoded, err := json.Marshal(snapshot) + if err != nil { + b.Fatal(err) + } + b.Run("encode", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + if _, err := json.Marshal(snapshot); err != nil { + b.Fatal(err) + } + } + b.ReportMetric(float64(len(encoded)), "bytes/snapshot") + }) + + b.Run("decode", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + var decoded ServerSnapshot + if err := json.Unmarshal(encoded, &decoded); err != nil { + b.Fatal(err) + } + } + b.ReportMetric(float64(len(encoded)), "bytes/snapshot") + }) +} + +func benchmarkSnapshot(entityCount int) ServerSnapshot { + entities := make([]EntitySnapshot, entityCount) + for index := range entities { + health := float32(20) + entities[index] = EntitySnapshot{ + RuntimeID: index, + UUID: fmt.Sprintf("00000000-0000-0000-0000-%012d", index), + Type: "minecraft:zombie", + Name: "Zombie", + Dimension: "minecraft:overworld", + X: float64(index), + Y: 64, + Z: float64(-index), + Alive: true, + Health: &health, + MaxHealth: &health, + } + } + return ServerSnapshot{ + Tick: 1, + TimestampUnixMilli: 1, + Levels: []LevelSnapshot{{ + Dimension: "minecraft:overworld", + }}, + Entities: entities, + } +} diff --git a/sdk/native_exports.go b/sdk/native_exports.go new file mode 100644 index 0000000..aeba13a --- /dev/null +++ b/sdk/native_exports.go @@ -0,0 +1,68 @@ +//go:build cgo && !wasm + +package sdk + +/* +#include +#include +*/ +import "C" + +import ( + "fmt" + "unsafe" +) + +//export gmb_abi_version +func gmb_abi_version() C.int32_t { + return C.int32_t(ABIVersion) +} + +//export gmb_call +func gmb_call( + operation C.int32_t, + input *C.uint8_t, + inputLength C.uint64_t, + output **C.uint8_t, + outputLength *C.uint64_t, +) (status C.int32_t) { + *output = nil + *outputLength = 0 + + defer func() { + if recovered := recover(); recovered != nil { + fallback := []byte(fmt.Sprintf(`{"status":"panic","error":%q}`, fmt.Sprint(recovered))) + writeNativeOutput(fallback, output, outputLength) + status = 0 + } + }() + + if inputLength > 64*1024*1024 { + return 2 + } + + var payload []byte + if inputLength > 0 { + if input == nil { + return 3 + } + payload = C.GoBytes(unsafe.Pointer(input), C.int(inputLength)) + } + + writeNativeOutput(Dispatch(int(operation), payload), output, outputLength) + return 0 +} + +//export gmb_free +func gmb_free(pointer unsafe.Pointer) { + C.free(pointer) +} + +func writeNativeOutput(payload []byte, output **C.uint8_t, outputLength *C.uint64_t) { + if len(payload) == 0 { + return + } + pointer := C.CBytes(payload) + *output = (*C.uint8_t)(pointer) + *outputLength = C.uint64_t(len(payload)) +} diff --git a/sdk/output.go b/sdk/output.go new file mode 100644 index 0000000..967110f --- /dev/null +++ b/sdk/output.go @@ -0,0 +1,115 @@ +package sdk + +import ( + "bufio" + "fmt" + "io" + "log" + "os" + "strings" + "sync" + "time" +) + +var captured = struct { + sync.Mutex + logs []LogEntry +}{} + +var outputCaptureOnce sync.Once + +type streamCapture struct { + writer *os.File + marker string + ack chan struct{} + mu sync.Mutex +} + +var outputStreams []*streamCapture + +func enableOutputCapture() { + outputCaptureOnce.Do(func() { + outputStreams = append(outputStreams, captureStream("stdout", &os.Stdout)) + outputStreams = append(outputStreams, captureStream("stderr", &os.Stderr)) + log.SetOutput(os.Stderr) + }) +} + +func captureStream(name string, destination **os.File) *streamCapture { + reader, writer, err := os.Pipe() + if err != nil { + return nil + } + + stream := &streamCapture{ + writer: writer, + marker: fmt.Sprintf("\x00gmb-flush-%s-%d\x00", name, time.Now().UnixNano()), + ack: make(chan struct{}), + } + *destination = writer + go func() { + buffered := bufio.NewReader(reader) + for { + line, readErr := buffered.ReadString('\n') + line = strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r") + if markerIndex := strings.Index(line, stream.marker); markerIndex >= 0 { + captureLine(name, line[:markerIndex]) + captureLine(name, line[markerIndex+len(stream.marker):]) + stream.ack <- struct{}{} + } else { + captureLine(name, line) + } + if readErr != nil { + if readErr != io.EOF { + captureLine("stderr", "stdout capture failed: "+readErr.Error()) + } + return + } + } + }() + return stream +} + +func captureLine(stream, message string) { + if message == "" { + return + } + captured.Lock() + captured.logs = append(captured.logs, LogEntry{ + Stream: stream, + Level: streamLevel(stream), + Message: message, + TimestampUnixMilli: time.Now().UnixMilli(), + }) + captured.Unlock() +} + +func drainCapturedLogs() []LogEntry { + for _, stream := range outputStreams { + if stream != nil { + stream.flush() + } + } + + captured.Lock() + defer captured.Unlock() + logs := append([]LogEntry(nil), captured.logs...) + captured.logs = captured.logs[:0] + return logs +} + +func (stream *streamCapture) flush() { + stream.mu.Lock() + defer stream.mu.Unlock() + if _, err := stream.writer.WriteString(stream.marker + "\n"); err != nil { + return + } + <-stream.ack +} + +func streamLevel(stream string) string { + if stream == "stderr" { + return "error" + } + return "info" +} diff --git a/sdk/plugin.go b/sdk/plugin.go new file mode 100644 index 0000000..171b133 --- /dev/null +++ b/sdk/plugin.go @@ -0,0 +1,29 @@ +package sdk + +type Plugin interface { + Metadata() Metadata +} + +type Initializer interface { + Init(context *Context, event InitEvent) error +} + +type TickHandler interface { + Tick(context *Context, snapshot ServerSnapshot) error +} + +type ChatHandler interface { + Chat(context *Context, event ChatEvent) error +} + +type DeathHandler interface { + Death(context *Context, event DeathEvent) error +} + +type SystemCallResultHandler interface { + SystemCallResult(context *Context, result SystemCallResult) error +} + +type Deinitializer interface { + Deinit(context *Context, event DeinitEvent) error +} diff --git a/sdk/runtime.go b/sdk/runtime.go new file mode 100644 index 0000000..aad3189 --- /dev/null +++ b/sdk/runtime.go @@ -0,0 +1,141 @@ +package sdk + +import ( + "encoding/json" + "errors" + "fmt" + "runtime/debug" + "sync" +) + +var ( + pluginMu sync.RWMutex + registeredPlugin Plugin +) + +func Register(plugin Plugin) { + if plugin == nil { + panic("sdk: cannot register a nil plugin") + } + + pluginMu.Lock() + defer pluginMu.Unlock() + if registeredPlugin != nil { + panic("sdk: a plugin is already registered") + } + registeredPlugin = plugin + enableOutputCapture() +} + +func Dispatch(operation int, input []byte) (output []byte) { + context := &Context{} + result := response{Status: "ok"} + + defer func() { + if recovered := recover(); recovered != nil { + result.Status = "panic" + result.Error = fmt.Sprint(recovered) + result.Stack = string(debug.Stack()) + } + + result.Logs = append(result.Logs, context.logs...) + result.Logs = append(result.Logs, drainCapturedLogs()...) + result.Actions = context.actions + result.SystemCalls = context.systemCalls + result.Snapshot = context.snapshot + + encoded, err := json.Marshal(result) + if err != nil { + output = []byte(`{"status":"panic","error":"cannot encode plugin response"}`) + return + } + output = encoded + }() + + plugin := currentPlugin() + var err error + + switch operation { + case OperationMetadata: + metadata := plugin.Metadata() + if metadata.APIVersion == 0 { + metadata.APIVersion = ABIVersion + } + result.Data = metadata + case OperationInit: + var event InitEvent + err = decode(input, &event) + if err == nil { + if handler, ok := plugin.(Initializer); ok { + err = handler.Init(context, event) + } + } + case OperationTick: + var snapshot ServerSnapshot + err = decode(input, &snapshot) + if err == nil { + if handler, ok := plugin.(TickHandler); ok { + err = handler.Tick(context, snapshot) + } + } + case OperationChat: + var event ChatEvent + err = decode(input, &event) + if err == nil { + if handler, ok := plugin.(ChatHandler); ok { + err = handler.Chat(context, event) + } + } + case OperationDeath: + var event DeathEvent + err = decode(input, &event) + if err == nil { + if handler, ok := plugin.(DeathHandler); ok { + err = handler.Death(context, event) + } + } + case OperationSystemCallResult: + var event SystemCallResult + err = decode(input, &event) + if err == nil { + if handler, ok := plugin.(SystemCallResultHandler); ok { + err = handler.SystemCallResult(context, event) + } + } + case OperationDeinit: + var event DeinitEvent + err = decode(input, &event) + if err == nil { + if handler, ok := plugin.(Deinitializer); ok { + err = handler.Deinit(context, event) + } + } + default: + err = fmt.Errorf("sdk: unknown operation %d", operation) + } + + if err != nil { + result.Status = "error" + result.Error = err.Error() + } + return nil +} + +func currentPlugin() Plugin { + pluginMu.RLock() + defer pluginMu.RUnlock() + if registeredPlugin == nil { + panic("sdk: plugin was not registered; call sdk.Register from init") + } + return registeredPlugin +} + +func decode(input []byte, target any) error { + if len(input) == 0 { + return errors.New("sdk: operation requires an input payload") + } + if err := json.Unmarshal(input, target); err != nil { + return fmt.Errorf("sdk: decode input: %w", err) + } + return nil +} diff --git a/sdk/runtime_test.go b/sdk/runtime_test.go new file mode 100644 index 0000000..94ddd1c --- /dev/null +++ b/sdk/runtime_test.go @@ -0,0 +1,54 @@ +package sdk + +import ( + "encoding/json" + "errors" + "testing" +) + +type testPlugin struct{} + +func (testPlugin) Metadata() Metadata { + return Metadata{ID: "test_plugin", Name: "Test", Version: "1.0.0"} +} + +func (testPlugin) Chat(context *Context, event ChatEvent) error { + if event.Message == "panic" { + panic("expected panic") + } + if event.Message == "error" { + return errors.New("expected error") + } + context.SendMessage(event.PlayerUUID, "received") + return nil +} + +func TestDispatch(t *testing.T) { + pluginMu.Lock() + registeredPlugin = testPlugin{} + pluginMu.Unlock() + + input, _ := json.Marshal(ChatEvent{PlayerUUID: "player", Message: "hello"}) + var got response + if err := json.Unmarshal(Dispatch(OperationChat, input), &got); err != nil { + t.Fatal(err) + } + if got.Status != "ok" || len(got.Actions) != 1 { + t.Fatalf("unexpected response: %#v", got) + } +} + +func TestDispatchRecoversPanic(t *testing.T) { + pluginMu.Lock() + registeredPlugin = testPlugin{} + pluginMu.Unlock() + + input, _ := json.Marshal(ChatEvent{Message: "panic"}) + var got response + if err := json.Unmarshal(Dispatch(OperationChat, input), &got); err != nil { + t.Fatal(err) + } + if got.Status != "panic" || got.Stack == "" { + t.Fatalf("panic was not captured: %#v", got) + } +} diff --git a/sdk/types.go b/sdk/types.go new file mode 100644 index 0000000..15a3e57 --- /dev/null +++ b/sdk/types.go @@ -0,0 +1,144 @@ +package sdk + +import "encoding/json" + +const ABIVersion = 1 + +const ( + OperationMetadata = 1 + OperationInit = 2 + OperationTick = 3 + OperationChat = 4 + OperationDeath = 5 + OperationSystemCallResult = 6 + OperationDeinit = 7 +) + +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"` +} + +type InitEvent struct { + MinecraftVersion string `json:"minecraftVersion"` + Dedicated bool `json:"dedicated"` + DataDirectory string `json:"dataDirectory"` +} + +type ServerSnapshot struct { + Tick int64 `json:"tick"` + TimestampUnixMilli int64 `json:"timestampUnixMilli"` + Levels []LevelSnapshot `json:"levels"` + Entities []EntitySnapshot `json:"entities"` + Blocks []BlockSnapshot `json:"blocks"` +} + +type LevelSnapshot struct { + Dimension string `json:"dimension"` + GameTime int64 `json:"gameTime"` + DayTime int64 `json:"dayTime"` + Raining bool `json:"raining"` + Thundering bool `json:"thundering"` +} + +type EntitySnapshot struct { + RuntimeID int `json:"runtimeId"` + UUID string `json:"uuid"` + Type string `json:"type"` + Name string `json:"name"` + Dimension string `json:"dimension"` + X float64 `json:"x"` + Y float64 `json:"y"` + Z float64 `json:"z"` + Yaw float32 `json:"yaw"` + Pitch float32 `json:"pitch"` + VelocityX float64 `json:"velocityX"` + VelocityY float64 `json:"velocityY"` + VelocityZ float64 `json:"velocityZ"` + Alive bool `json:"alive"` + Player bool `json:"player"` + Health *float32 `json:"health"` + MaxHealth *float32 `json:"maxHealth"` +} + +type BlockSnapshot struct { + Dimension string `json:"dimension"` + X int `json:"x"` + Y int `json:"y"` + Z int `json:"z"` + Block string `json:"block"` + Properties map[string]string `json:"properties"` +} + +type BlockReference struct { + Dimension string `json:"dimension"` + X int `json:"x"` + Y int `json:"y"` + Z int `json:"z"` +} + +type ChatEvent struct { + PlayerUUID string `json:"playerUuid"` + PlayerName string `json:"playerName"` + Message string `json:"message"` + TimestampUnixMilli int64 `json:"timestampUnixMilli"` +} + +type DeathEvent struct { + Entity EntitySnapshot `json:"entity"` + DamageType string `json:"damageType"` + AttackerUUID *string `json:"attackerUuid"` + TimestampUnixMilli int64 `json:"timestampUnixMilli"` +} + +type SystemCallResult struct { + ID string `json:"id"` + Name string `json:"name"` + Success bool `json:"success"` + Data json.RawMessage `json:"data"` + Error string `json:"error"` +} + +type DeinitEvent struct { + Reason string `json:"reason"` +} + +type ActionRequest struct { + Type string `json:"type"` + Payload any `json:"payload"` +} + +type SystemCallRequest struct { + ID string `json:"id"` + Name string `json:"name"` + Payload any `json:"payload"` +} + +type SnapshotSubscription struct { + Entities bool `json:"entities"` + Blocks []BlockReference `json:"blocks"` +} + +type LogEntry struct { + Stream string `json:"stream"` + Level string `json:"level"` + Message string `json:"message"` + TimestampUnixMilli int64 `json:"timestampUnixMilli"` +} + +type response struct { + Status string `json:"status"` + Error string `json:"error,omitempty"` + Stack string `json:"stack,omitempty"` + Data any `json:"data,omitempty"` + Logs []LogEntry `json:"logs,omitempty"` + Actions []ActionRequest `json:"actions,omitempty"` + SystemCalls []SystemCallRequest `json:"systemCalls,omitempty"` + Snapshot *SnapshotSubscription `json:"snapshot,omitempty"` +} diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..0c789bc --- /dev/null +++ b/settings.gradle @@ -0,0 +1,12 @@ +pluginManagement { + repositories { + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = 'go-minecraft-bridge' diff --git a/src/client/java/dev/yawaflua/gominecraftbridge/client/ClothManagementScreen.java b/src/client/java/dev/yawaflua/gominecraftbridge/client/ClothManagementScreen.java new file mode 100644 index 0000000..48e4090 --- /dev/null +++ b/src/client/java/dev/yawaflua/gominecraftbridge/client/ClothManagementScreen.java @@ -0,0 +1,184 @@ +package dev.yawaflua.gominecraftbridge.client; + +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.PluginMetadata; +import dev.yawaflua.gominecraftbridge.protocol.ProtocolJson; +import me.shedaniel.clothconfig2.api.ConfigBuilder; +import me.shedaniel.clothconfig2.api.ConfigCategory; +import me.shedaniel.clothconfig2.api.ConfigEntryBuilder; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +public final class ClothManagementScreen { + private static final int VISIBLE_LOG_LINES = 100; + private static final DateTimeFormatter LOG_TIME = DateTimeFormatter.ofPattern("HH:mm:ss") + .withZone(ZoneId.systemDefault()); + + private ClothManagementScreen() { + } + + public static Screen create(Screen parent, boolean refresh) { + if (refresh) { + GoMinecraftBridgeClient.requestRefresh(); + } + + BridgeManagementSnapshot snapshot = GoMinecraftBridgeClient.latest(); + Set reloads = new LinkedHashSet<>(); + boolean[] rescan = {false}; + ConfigBuilder builder = ConfigBuilder.create() + .setParentScreen(parent) + .setTitle(Component.literal("Go Minecraft Bridge")); + ConfigEntryBuilder entries = builder.entryBuilder(); + ConfigCategory packages = builder.getOrCreateCategory(Component.literal("Пакеты")); + + if (snapshot == null) { + packages.addEntry(entries.startTextDescription(Component.literal( + "Подключитесь к серверу с Go Minecraft Bridge. Запрос состояния отправлен." + )).build()); + } else { + addOverview(packages, entries, snapshot, rescan); + for (ManagedPluginSnapshot plugin : snapshot.plugins()) { + addPluginCategory(builder, entries, plugin, snapshot.canReload(), reloads); + } + } + + builder.setSavingRunnable(() -> { + if (rescan[0]) { + GoMinecraftBridgeClient.requestRescan(); + } + for (String pluginId : reloads) { + GoMinecraftBridgeClient.requestReload(pluginId); + } + if (reloads.isEmpty() && !rescan[0]) { + GoMinecraftBridgeClient.requestRefresh(); + } + }); + + Screen screen = builder.build(); + GoMinecraftBridgeClient.onUpdate(() -> { + Minecraft client = Minecraft.getInstance(); + if (client.screen == screen) { + client.setScreen(create(parent, false)); + } + }); + return screen; + } + + private static void addOverview( + ConfigCategory category, + ConfigEntryBuilder entries, + BridgeManagementSnapshot snapshot, + boolean[] rescan + ) { + category.addEntry(entries.startTextDescription(Component.literal( + "Сервер: " + (snapshot.serverRunning() ? "работает" : "остановлен") + + " • пакетов: " + snapshot.packages().size() + + " • плагинов: " + snapshot.plugins().size() + )).build()); + if (snapshot.message() != null && !snapshot.message().isBlank()) { + category.addEntry(entries.startTextDescription(Component.literal(snapshot.message())).build()); + } + + for (PackageInspection inspected : snapshot.packages()) { + String status = inspected.valid() ? "✓ корректен" : "✗ ошибка"; + String details = inspected.valid() + ? "plugin id: " + inspected.pluginId() + : inspected.error(); + category.addEntry(entries.startTextDescription(Component.literal( + status + " — " + inspected.path() + "\n" + details + )).build()); + } + if (snapshot.packages().isEmpty()) { + category.addEntry(entries.startTextDescription(Component.literal( + "Нативные пакеты не найдены в config/go-minecraft-bridge/plugins или mods." + )).build()); + } + category.addEntry(entries.startBooleanToggle( + Component.literal("Проверить и подключить новые пакеты при сохранении"), false + ) + .setDefaultValue(false) + .setSaveConsumer(enabled -> rescan[0] = enabled) + .setTooltip(Component.literal( + "Повторно сканирует папки plugins и mods. Уже загруженные native-библиотеки не выгружаются." + )) + .build()); + } + + private static void addPluginCategory( + ConfigBuilder builder, + ConfigEntryBuilder entries, + ManagedPluginSnapshot plugin, + boolean canReload, + Set reloads + ) { + PluginMetadata metadata = plugin.metadata(); + ConfigCategory category = builder.getOrCreateCategory(Component.literal(metadata.name())); + category.addEntry(entries.startTextDescription(Component.literal( + metadata.id() + " " + metadata.version() + " • " + plugin.state() + )).build()); + category.addEntry(entries.startTextDescription(Component.literal( + (metadata.description() == null ? "" : metadata.description()) + )).build()); + category.addEntry(entries.startTextDescription(Component.literal( + "ABI/API: " + metadata.apiVersion() + + "\nАвторы: " + String.join(", ", metadata.authors()) + + "\nСайт: " + value(metadata.website()) + + "\nBackend: " + plugin.backend() + + "\nПуть: " + plugin.origin() + )).build()); + + if (metadata.configSchema() != null) { + category.addEntry(entries.startTextDescription(Component.literal( + "Config schema:\n" + ProtocolJson.GSON.toJson(metadata.configSchema()) + )).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()); + + 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() + "):" + )).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 недоступен: нужны права администратора и запущенный сервер." + )).build()); + } + } + + private static String format(PluginLog log) { + return "[" + LOG_TIME.format(Instant.ofEpochMilli(log.timestampUnixMilli())) + "]" + + " [" + value(log.level()) + "/" + value(log.stream()) + "] " + value(log.message()); + } + + private static String value(String value) { + return value == null || value.isBlank() ? "—" : value; + } +} diff --git a/src/client/java/dev/yawaflua/gominecraftbridge/client/GoBridgeModMenuIntegration.java b/src/client/java/dev/yawaflua/gominecraftbridge/client/GoBridgeModMenuIntegration.java new file mode 100644 index 0000000..b702e89 --- /dev/null +++ b/src/client/java/dev/yawaflua/gominecraftbridge/client/GoBridgeModMenuIntegration.java @@ -0,0 +1,17 @@ +package dev.yawaflua.gominecraftbridge.client; + +import com.terraformersmc.modmenu.api.ConfigScreenFactory; +import com.terraformersmc.modmenu.api.ModMenuApi; +import net.fabricmc.loader.api.FabricLoader; + +public final class GoBridgeModMenuIntegration implements ModMenuApi { + @Override + public ConfigScreenFactory getModConfigScreenFactory() { + return parent -> { + if (!FabricLoader.getInstance().isModLoaded("cloth-config")) { + return parent; + } + return ClothManagementScreen.create(parent, true); + }; + } +} diff --git a/src/client/java/dev/yawaflua/gominecraftbridge/client/GoMinecraftBridgeClient.java b/src/client/java/dev/yawaflua/gominecraftbridge/client/GoMinecraftBridgeClient.java new file mode 100644 index 0000000..a4cc2a6 --- /dev/null +++ b/src/client/java/dev/yawaflua/gominecraftbridge/client/GoMinecraftBridgeClient.java @@ -0,0 +1,59 @@ +package dev.yawaflua.gominecraftbridge.client; + +import com.google.gson.JsonParseException; +import dev.yawaflua.gominecraftbridge.management.BridgeManagementSnapshot; +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.ClientPlayNetworking; + +public final class GoMinecraftBridgeClient implements ClientModInitializer { + private static BridgeManagementSnapshot latest; + private static Runnable updateListener; + + @Override + public void onInitializeClient() { + ClientPlayNetworking.registerGlobalReceiver(AdminResponsePayload.TYPE, (payload, context) -> { + try { + latest = ProtocolJson.GSON.fromJson(payload.json(), BridgeManagementSnapshot.class); + } catch (JsonParseException exception) { + latest = new BridgeManagementSnapshot( + System.currentTimeMillis(), false, false, + "Invalid management response: " + exception.getMessage(), null, null + ); + } + if (updateListener != null) { + updateListener.run(); + } + }); + } + + public static BridgeManagementSnapshot latest() { + return latest; + } + + public static void onUpdate(Runnable listener) { + updateListener = listener; + } + + public static boolean requestRefresh() { + return send("refresh", ""); + } + + public static boolean requestReload(String pluginId) { + return send("reload", pluginId); + } + + public static boolean requestRescan() { + return send("rescan", ""); + } + + private static boolean send(String action, String pluginId) { + if (!ClientPlayNetworking.canSend(AdminRequestPayload.TYPE)) { + return false; + } + ClientPlayNetworking.send(new AdminRequestPayload(action, pluginId)); + return true; + } +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/GoMinecraftBridgeMod.java b/src/main/java/dev/yawaflua/gominecraftbridge/GoMinecraftBridgeMod.java new file mode 100644 index 0000000..43737fe --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/GoMinecraftBridgeMod.java @@ -0,0 +1,61 @@ +package dev.yawaflua.gominecraftbridge; + +import dev.yawaflua.gominecraftbridge.api.GoMinecraftBridgeApi; +import dev.yawaflua.gominecraftbridge.host.BuiltInSystemCalls; +import dev.yawaflua.gominecraftbridge.host.GoPluginManager; +import dev.yawaflua.gominecraftbridge.host.MinecraftSnapshotFactory; +import dev.yawaflua.gominecraftbridge.protocol.ChatEvent; +import dev.yawaflua.gominecraftbridge.protocol.DeathEvent; +import dev.yawaflua.gominecraftbridge.network.BridgeAdminNetworking; +import net.fabricmc.api.ModInitializer; +import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.fabricmc.fabric.api.message.v1.ServerMessageEvents; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; + +public final class GoMinecraftBridgeMod implements ModInitializer { + public static final String MOD_ID = "go_minecraft_bridge"; + private static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); + + @Override + public void onInitialize() { + BuiltInSystemCalls.register(GoMinecraftBridgeApi.systemCalls()); + GoPluginManager plugins = new GoPluginManager(LOGGER); + BridgeAdminNetworking.register(plugins); + MinecraftSnapshotFactory snapshots = new MinecraftSnapshotFactory(); + plugins.discover(); + + ServerLifecycleEvents.SERVER_STARTED.register(plugins::start); + ServerTickEvents.END_SERVER_TICK.register(plugins::tick); + ServerLifecycleEvents.SERVER_STOPPING.register(plugins::stop); + + ServerMessageEvents.CHAT_MESSAGE.register((message, sender, boundChatType) -> plugins.chat( + new ChatEvent( + sender.getUUID().toString(), + sender.getName().getString(), + message.signedContent(), + Instant.now().toEpochMilli() + ), + sender.level().getServer() + )); + + ServerLivingEntityEvents.AFTER_DEATH.register((entity, source) -> { + var attacker = source.getEntity(); + plugins.death( + new DeathEvent( + snapshots.entity(entity), + source.getMsgId(), + attacker == null ? null : attacker.getUUID().toString(), + Instant.now().toEpochMilli() + ), + entity.level().getServer() + ); + }); + + LOGGER.info("Go Minecraft Bridge initialized"); + } +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/api/GoMinecraftBridgeApi.java b/src/main/java/dev/yawaflua/gominecraftbridge/api/GoMinecraftBridgeApi.java new file mode 100644 index 0000000..8672a34 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/api/GoMinecraftBridgeApi.java @@ -0,0 +1,17 @@ +package dev.yawaflua.gominecraftbridge.api; + +public final class GoMinecraftBridgeApi { + private static final SystemCallRegistry SYSTEM_CALLS = new SystemCallRegistry(); + private static final GoPluginRegistry PLUGINS = new GoPluginRegistry(); + + private GoMinecraftBridgeApi() { + } + + public static SystemCallRegistry systemCalls() { + return SYSTEM_CALLS; + } + + public static GoPluginRegistry plugins() { + return PLUGINS; + } +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/api/GoPluginRegistry.java b/src/main/java/dev/yawaflua/gominecraftbridge/api/GoPluginRegistry.java new file mode 100644 index 0000000..6a343b4 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/api/GoPluginRegistry.java @@ -0,0 +1,26 @@ +package dev.yawaflua.gominecraftbridge.api; + +import dev.yawaflua.gominecraftbridge.protocol.PluginMetadata; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public final class GoPluginRegistry { + private final Map plugins = new LinkedHashMap<>(); + + public synchronized void register(PluginMetadata metadata) { + if (this.plugins.putIfAbsent(metadata.id(), metadata) != null) { + throw new IllegalArgumentException("Plugin metadata is already registered: " + metadata.id()); + } + } + + public synchronized Optional find(String id) { + return Optional.ofNullable(this.plugins.get(id)); + } + + public synchronized List all() { + return List.copyOf(this.plugins.values()); + } +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/api/SystemCallContext.java b/src/main/java/dev/yawaflua/gominecraftbridge/api/SystemCallContext.java new file mode 100644 index 0000000..e52017b --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/api/SystemCallContext.java @@ -0,0 +1,7 @@ +package dev.yawaflua.gominecraftbridge.api; + +import dev.yawaflua.gominecraftbridge.protocol.PluginMetadata; +import net.minecraft.server.MinecraftServer; + +public record SystemCallContext(MinecraftServer server, PluginMetadata plugin) { +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/api/SystemCallHandler.java b/src/main/java/dev/yawaflua/gominecraftbridge/api/SystemCallHandler.java new file mode 100644 index 0000000..56fe44c --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/api/SystemCallHandler.java @@ -0,0 +1,8 @@ +package dev.yawaflua.gominecraftbridge.api; + +import com.google.gson.JsonElement; + +@FunctionalInterface +public interface SystemCallHandler { + JsonElement handle(SystemCallContext context, JsonElement payload) throws Exception; +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/api/SystemCallRegistry.java b/src/main/java/dev/yawaflua/gominecraftbridge/api/SystemCallRegistry.java new file mode 100644 index 0000000..fd230cc --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/api/SystemCallRegistry.java @@ -0,0 +1,33 @@ +package dev.yawaflua.gominecraftbridge.api; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +public final class SystemCallRegistry { + private final Map handlers = new LinkedHashMap<>(); + + public synchronized void register(String name, SystemCallHandler handler) { + validateName(name); + Objects.requireNonNull(handler, "handler"); + + if (this.handlers.putIfAbsent(name, handler) != null) { + throw new IllegalArgumentException("System call is already registered: " + name); + } + } + + public synchronized Optional find(String name) { + return Optional.ofNullable(this.handlers.get(name)); + } + + public synchronized Map entries() { + return Map.copyOf(this.handlers); + } + + private static void validateName(String name) { + if (name == null || !name.matches("[a-z0-9_.-]+:[a-z0-9_./-]+")) { + throw new IllegalArgumentException("System call must be a namespaced identifier: " + name); + } + } +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/backend/PluginBackend.java b/src/main/java/dev/yawaflua/gominecraftbridge/backend/PluginBackend.java new file mode 100644 index 0000000..b606dc7 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/backend/PluginBackend.java @@ -0,0 +1,13 @@ +package dev.yawaflua.gominecraftbridge.backend; + +import dev.yawaflua.gominecraftbridge.protocol.Protocol; + +import java.nio.file.Path; + +public interface PluginBackend { + int abiVersion(); + + byte[] call(Protocol.Operation operation, byte[] input); + + Path origin(); +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/backend/PluginInvocationException.java b/src/main/java/dev/yawaflua/gominecraftbridge/backend/PluginInvocationException.java new file mode 100644 index 0000000..409b3bf --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/backend/PluginInvocationException.java @@ -0,0 +1,11 @@ +package dev.yawaflua.gominecraftbridge.backend; + +public final class PluginInvocationException extends RuntimeException { + public PluginInvocationException(String message) { + super(message); + } + + public PluginInvocationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/backend/nativeffi/NativePluginBackend.java b/src/main/java/dev/yawaflua/gominecraftbridge/backend/nativeffi/NativePluginBackend.java new file mode 100644 index 0000000..5a6f874 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/backend/nativeffi/NativePluginBackend.java @@ -0,0 +1,127 @@ +package dev.yawaflua.gominecraftbridge.backend.nativeffi; + +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; + +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 final Path origin; + private final MethodHandle abiVersionHandle; + private final MethodHandle callHandle; + private final MethodHandle freeHandle; + + public NativePluginBackend(Path library) { + this.origin = library.toAbsolutePath().normalize(); + + 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 + ); + } catch (RuntimeException exception) { + throw new PluginInvocationException("Cannot load native plugin " + this.origin, exception); + } + } + + @Override + public int abiVersion() { + try { + return (int) this.abiVersionHandle.invokeExact(); + } catch (Throwable throwable) { + throw new PluginInvocationException("Cannot read ABI version from " + this.origin, throwable); + } + } + + @Override + public synchronized byte[] call(Protocol.Operation operation, byte[] input) { + MemorySegment output = MemorySegment.NULL; + + try (Arena arena = Arena.ofConfined()) { + MemorySegment inputMemory = MemorySegment.NULL; + + if (input.length > 0) { + inputMemory = arena.allocate(input.length); + inputMemory.copyFrom(MemorySegment.ofArray(input)); + } + + 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.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); + } + } + } + } + + @Override + public Path origin() { + return this.origin; + } + + private static MemorySegment required(SymbolLookup symbols, String name) { + return symbols.find(name).orElseThrow(() -> new PluginInvocationException("Missing native symbol " + name)); + } +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/host/ActionExecutor.java b/src/main/java/dev/yawaflua/gominecraftbridge/host/ActionExecutor.java new file mode 100644 index 0000000..36e568c --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/host/ActionExecutor.java @@ -0,0 +1,39 @@ +package dev.yawaflua.gominecraftbridge.host; + +import com.google.gson.JsonObject; +import dev.yawaflua.gominecraftbridge.protocol.ActionRequest; +import net.minecraft.network.chat.Component; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; + +import java.util.UUID; + +public final class ActionExecutor { + public void execute(MinecraftServer server, ActionRequest action) { + JsonObject payload = action.payload(); + + switch (action.type()) { + case "minecraft:chat.broadcast" -> { + String message = requiredString(payload, "message"); + server.getPlayerList().broadcastSystemMessage(Component.literal(message), false); + } + case "minecraft:chat.player" -> { + UUID playerId = UUID.fromString(requiredString(payload, "playerUuid")); + String message = requiredString(payload, "message"); + ServerPlayer player = server.getPlayerList().getPlayer(playerId); + if (player == null) { + throw new IllegalArgumentException("Player is not online: " + playerId); + } + player.sendSystemMessage(Component.literal(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/src/main/java/dev/yawaflua/gominecraftbridge/host/BuiltInSystemCalls.java b/src/main/java/dev/yawaflua/gominecraftbridge/host/BuiltInSystemCalls.java new file mode 100644 index 0000000..7ee4695 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/host/BuiltInSystemCalls.java @@ -0,0 +1,75 @@ +package dev.yawaflua.gominecraftbridge.host; + +import com.google.gson.JsonObject; +import dev.yawaflua.gominecraftbridge.api.SystemCallRegistry; +import dev.yawaflua.gominecraftbridge.protocol.ProtocolJson; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.server.level.ServerLevel; + +import java.util.UUID; + +public final class BuiltInSystemCalls { + private BuiltInSystemCalls() { + } + + public static void register(SystemCallRegistry registry) { + registry.register("minecraft:server.info", (context, payload) -> { + JsonObject result = new JsonObject(); + result.addProperty("tick", context.server().getTickCount()); + result.addProperty("dedicated", context.server().isDedicatedServer()); + result.addProperty("onlinePlayers", context.server().getPlayerCount()); + return result; + }); + + registry.register("minecraft:player.get", (context, payload) -> { + JsonObject request = payload.getAsJsonObject(); + UUID playerId = UUID.fromString(request.get("playerUuid").getAsString()); + var player = context.server().getPlayerList().getPlayer(playerId); + if (player == null) { + return ProtocolJson.tree(null); + } + + 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("x", player.getX()); + result.addProperty("y", player.getY()); + result.addProperty("z", player.getZ()); + return result; + }); + + registry.register("minecraft:block.get", (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)) { + selected = level; + break; + } + } + + if (selected == null) { + throw new IllegalArgumentException("Unknown dimension " + dimension); + } + + BlockPos position = new BlockPos( + request.get("x").getAsInt(), + request.get("y").getAsInt(), + request.get("z").getAsInt() + ); + JsonObject result = new JsonObject(); + result.addProperty("loaded", selected.isLoaded(position)); + if (selected.isLoaded(position)) { + result.addProperty( + "block", + BuiltInRegistries.BLOCK.getKey(selected.getBlockState(position).getBlock()).toString() + ); + } + return result; + }); + } +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/host/GoPluginManager.java b/src/main/java/dev/yawaflua/gominecraftbridge/host/GoPluginManager.java new file mode 100644 index 0000000..d8e6459 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/host/GoPluginManager.java @@ -0,0 +1,407 @@ +package dev.yawaflua.gominecraftbridge.host; + +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +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.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.PluginLog; +import dev.yawaflua.gominecraftbridge.protocol.PluginResponse; +import dev.yawaflua.gominecraftbridge.protocol.Protocol; +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; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +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.time.Instant; + +public final class GoPluginManager { + private static final int MAX_SYSTEM_CALL_CHAIN = 32; + + private final Logger logger; + private final Path pluginDirectory; + private final Path dataDirectory; + private final MinecraftSnapshotFactory snapshots = new MinecraftSnapshotFactory(); + private final ActionExecutor actions = new ActionExecutor(); + private final Map plugins = new LinkedHashMap<>(); + private final List packageInspections = new ArrayList<>(); + private boolean serverRunning; + + public GoPluginManager(Logger logger) { + this.logger = logger; + Path root = FabricLoader.getInstance().getConfigDir().resolve("go-minecraft-bridge"); + this.pluginDirectory = root.resolve("plugins"); + this.dataDirectory = root.resolve("data"); + } + + public void discover() { + this.packageInspections.clear(); + try { + Files.createDirectories(this.pluginDirectory); + Files.createDirectories(this.dataDirectory); + } catch (IOException exception) { + throw new IllegalStateException("Cannot create Go plugin directories", exception); + } + + for (Path candidate : nativeCandidates()) { + try { + LoadedPlugin plugin = new LoadedPlugin(new NativePluginBackend(candidate)); + LoadedPlugin duplicate = this.plugins.putIfAbsent(plugin.metadata().id(), plugin); + if (duplicate != null) { + throw new IllegalArgumentException("Duplicate plugin id " + plugin.metadata().id()); + } + GoMinecraftBridgeApi.plugins().register(plugin.metadata()); + this.packageInspections.add(new PackageInspection( + candidate.toAbsolutePath().normalize().toString(), + true, + plugin.metadata().id(), + null + )); + bridgeLog(plugin, "info", "Package check passed: " + candidate.toAbsolutePath().normalize()); + this.logger.info( + "Discovered Go plugin {} {} from {}", + plugin.metadata().name(), + plugin.metadata().version(), + candidate.getFileName() + ); + } catch (RuntimeException exception) { + this.packageInspections.add(new PackageInspection( + candidate.toAbsolutePath().normalize().toString(), + false, + null, + rootMessage(exception) + )); + this.logger.error("Cannot load Go plugin {}", candidate, exception); + } + } + } + + public void start(MinecraftServer server) { + this.serverRunning = true; + for (LoadedPlugin plugin : this.plugins.values()) { + startPlugin(plugin, server); + } + } + + public void tick(MinecraftServer server) { + for (LoadedPlugin plugin : runningPlugins()) { + ServerSnapshot snapshot = this.snapshots.create(server, plugin.snapshotSubscription()); + invoke(plugin, Protocol.Operation.TICK, snapshot, server); + } + } + + public void chat(ChatEvent event, MinecraftServer server) { + for (LoadedPlugin plugin : runningPlugins()) { + invoke(plugin, Protocol.Operation.CHAT, event, server); + } + } + + public void death(DeathEvent event, MinecraftServer server) { + for (LoadedPlugin plugin : runningPlugins()) { + invoke(plugin, Protocol.Operation.DEATH, event, server); + } + } + + public void stop(MinecraftServer server) { + for (LoadedPlugin plugin : runningPlugins()) { + try { + PluginResponse response = plugin.invoke( + Protocol.Operation.DEINIT, + new DeinitEvent("server_stopping") + ); + processResponse(plugin, response, server, 0); + } catch (RuntimeException exception) { + bridgeLog(plugin, "error", "Deinit failed: " + rootMessage(exception)); + this.logger.error("Go plugin {} failed during deinit", plugin.metadata().id(), exception); + } finally { + plugin.markStopped(); + } + } + this.serverRunning = false; + } + + public List plugins() { + return List.copyOf(this.plugins.values()); + } + + public synchronized BridgeManagementSnapshot managementSnapshot(boolean canReload, 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.serverRunning, + canReload && this.serverRunning, + message, + List.copyOf(this.packageInspections), + pluginSnapshots + ); + } + + public synchronized ReloadResult reload(String pluginId, MinecraftServer server) { + if (!this.serverRunning) { + return new ReloadResult(false, "Server is not running"); + } + LoadedPlugin plugin = this.plugins.get(pluginId); + if (plugin == null) { + return new ReloadResult(false, "Unknown plugin: " + pluginId); + } + + if (plugin.state() == PluginState.RUNNING) { + try { + PluginResponse response = plugin.invoke( + Protocol.Operation.DEINIT, + new DeinitEvent("admin_reload") + ); + processResponse(plugin, response, server, 0); + } catch (RuntimeException exception) { + bridgeLog(plugin, "error", "Reload deinit failed: " + rootMessage(exception)); + } + } + + plugin.prepareReload(); + bridgeLog(plugin, "info", "Lifecycle reload requested"); + boolean started = startPlugin(plugin, server); + String message = started + ? "Plugin " + pluginId + " restarted (the native binary remains loaded)" + : "Plugin " + pluginId + " failed to restart"; + return new ReloadResult(started, message); + } + + private boolean startPlugin(LoadedPlugin plugin, MinecraftServer server) { + try { + Path pluginData = this.dataDirectory.resolve(plugin.metadata().id()); + Files.createDirectories(pluginData); + PluginResponse response = plugin.invoke( + Protocol.Operation.INIT, + new InitEvent( + SharedConstants.getCurrentVersion().name(), + server.isDedicatedServer(), + pluginData.toAbsolutePath().toString() + ) + ); + processResponse(plugin, response, server, 0); + if (response.isError()) { + plugin.disable(); + bridgeLog(plugin, "error", "Initialization failed: " + response.error()); + this.logger.error("Go plugin {} failed to initialize: {}", plugin.metadata().id(), response.error()); + return false; + } + plugin.markRunning(); + bridgeLog(plugin, "info", "Plugin started"); + this.logger.info("Started Go plugin {}", plugin.metadata().id()); + return true; + } catch (Exception exception) { + disable(plugin, "initialization failed", exception); + return false; + } + } + + private void invoke(LoadedPlugin plugin, Protocol.Operation operation, Object event, MinecraftServer server) { + try { + PluginResponse response = plugin.invoke(operation, event); + processResponse(plugin, response, server, 0); + } catch (RuntimeException exception) { + disable(plugin, operation + " failed", exception); + } + } + + private void processResponse( + LoadedPlugin plugin, + PluginResponse response, + MinecraftServer server, + int systemCallDepth + ) { + for (PluginLog log : response.logs()) { + writeLog(plugin, log); + } + + if (response.snapshot() != null) { + plugin.snapshotSubscription(response.snapshot()); + } + + if (response.isPanic()) { + plugin.disable(); + this.logger.error( + "Go plugin {} panicked: {}\n{}", + plugin.metadata().id(), + response.error(), + response.stack() + ); + return; + } + if (response.isError()) { + this.logger.error("Go plugin {} returned an error: {}", plugin.metadata().id(), response.error()); + } + + response.actions().forEach(action -> { + try { + this.actions.execute(server, action); + } catch (RuntimeException exception) { + this.logger.error("Action {} from plugin {} failed", action.type(), plugin.metadata().id(), 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(plugin, request, server); + try { + PluginResponse nested = plugin.invoke(Protocol.Operation.SYSTEM_CALL_RESULT, result); + processResponse(plugin, nested, server, systemCallDepth + 1); + } catch (RuntimeException exception) { + disable(plugin, "system call result callback failed", exception); + return; + } + } + } + + private SystemCallResult executeSystemCall( + LoadedPlugin plugin, + SystemCallRequest request, + MinecraftServer server + ) { + SystemCallHandler handler = GoMinecraftBridgeApi.systemCalls().find(request.name()).orElse(null); + if (handler == null) { + return new SystemCallResult( + request.id(), + request.name(), + false, + JsonNull.INSTANCE, + "Unknown system call " + request.name() + ); + } + + try { + JsonElement result = handler.handle( + new SystemCallContext(server, plugin.metadata()), + request.payload() == null ? JsonNull.INSTANCE : request.payload() + ); + 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, + exception.getMessage() + ); + } + } + + private List runningPlugins() { + return this.plugins.values().stream() + .filter(plugin -> plugin.state() == PluginState.RUNNING) + .toList(); + } + + private List nativeCandidates() { + String extension = nativeExtension(); + List result = new ArrayList<>(); + List directories = List.of( + this.pluginDirectory, + FabricLoader.getInstance().getGameDir().resolve("mods") + ); + + for (Path directory : directories) { + if (!Files.isDirectory(directory)) { + continue; + } + try (var files = Files.list(directory)) { + files + .filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(extension)) + .forEach(result::add); + } catch (IOException exception) { + this.logger.error("Cannot scan {}", directory, exception); + } + } + + result.sort(Comparator.comparing(Path::toString)); + return List.copyOf(result); + } + + 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 Go plugin {}: {}", plugin.metadata().id(), reason); + } else { + this.logger.error("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(); + 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("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/main/java/dev/yawaflua/gominecraftbridge/host/LoadedPlugin.java b/src/main/java/dev/yawaflua/gominecraftbridge/host/LoadedPlugin.java new file mode 100644 index 0000000..2ddd31b --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/host/LoadedPlugin.java @@ -0,0 +1,125 @@ +package dev.yawaflua.gominecraftbridge.host; + +import dev.yawaflua.gominecraftbridge.backend.PluginBackend; +import dev.yawaflua.gominecraftbridge.protocol.PluginMetadata; +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 java.util.Objects; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import dev.yawaflua.gominecraftbridge.protocol.PluginLog; + +public final class LoadedPlugin { + private static final int MAX_RETAINED_LOGS = 500; + + private final PluginBackend backend; + private final PluginMetadata metadata; + private final ArrayDeque logs = new ArrayDeque<>(); + private PluginState state = PluginState.DISCOVERED; + private SnapshotSubscription snapshotSubscription = SnapshotSubscription.defaults(); + + public LoadedPlugin(PluginBackend backend) { + this.backend = Objects.requireNonNull(backend, "backend"); + + if (backend.abiVersion() != Protocol.ABI_VERSION) { + throw new IllegalArgumentException( + "Unsupported ABI " + backend.abiVersion() + " in " + backend.origin() + ); + } + + PluginResponse response = invokeUnchecked(Protocol.Operation.METADATA, null); + if (response.isError() || response.data() == null) { + throw new IllegalArgumentException("Cannot read plugin metadata: " + response.error()); + } + + this.metadata = ProtocolJson.GSON.fromJson(response.data(), PluginMetadata.class); + validateMetadata(this.metadata); + } + + public synchronized PluginResponse invoke(Protocol.Operation operation, Object input) { + if (this.state == PluginState.DISABLED || this.state == PluginState.STOPPED) { + throw new IllegalStateException("Plugin " + this.metadata.id() + " is not active"); + } + + return invokeUnchecked(operation, input); + } + + private PluginResponse invokeUnchecked(Protocol.Operation operation, Object input) { + byte[] output = this.backend.call(operation, ProtocolJson.encode(input)); + if (output.length == 0) { + throw new IllegalArgumentException("Plugin returned an empty response for " + operation); + } + + return ProtocolJson.decode(output, PluginResponse.class); + } + + public PluginBackend backend() { + return this.backend; + } + + public PluginMetadata metadata() { + return this.metadata; + } + + public synchronized PluginState state() { + return this.state; + } + + public synchronized void markRunning() { + this.state = PluginState.RUNNING; + } + + public synchronized void disable() { + this.state = PluginState.DISABLED; + } + + public synchronized void markStopped() { + this.state = PluginState.STOPPED; + } + + public synchronized void prepareReload() { + this.state = PluginState.DISCOVERED; + this.snapshotSubscription = SnapshotSubscription.defaults(); + } + + public synchronized SnapshotSubscription snapshotSubscription() { + return this.snapshotSubscription; + } + + public synchronized void snapshotSubscription(SnapshotSubscription snapshotSubscription) { + this.snapshotSubscription = snapshotSubscription; + } + + public synchronized void appendLog(PluginLog log) { + while (this.logs.size() >= MAX_RETAINED_LOGS) { + this.logs.removeFirst(); + } + this.logs.addLast(log); + } + + public synchronized List logs() { + return List.copyOf(new ArrayList<>(this.logs)); + } + + private static void validateMetadata(PluginMetadata metadata) { + if (metadata == null) { + throw new IllegalArgumentException("Plugin metadata is null"); + } + if (metadata.id() == null || !metadata.id().matches("[a-z][a-z0-9_-]{1,63}")) { + throw new IllegalArgumentException("Invalid plugin id: " + metadata.id()); + } + if (metadata.name() == null || metadata.name().isBlank()) { + throw new IllegalArgumentException("Plugin name is required"); + } + if (metadata.version() == null || metadata.version().isBlank()) { + throw new IllegalArgumentException("Plugin version is required"); + } + if (metadata.apiVersion() != Protocol.ABI_VERSION) { + throw new IllegalArgumentException("Unsupported plugin API version " + metadata.apiVersion()); + } + } +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/host/MinecraftSnapshotFactory.java b/src/main/java/dev/yawaflua/gominecraftbridge/host/MinecraftSnapshotFactory.java new file mode 100644 index 0000000..66425ee --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/host/MinecraftSnapshotFactory.java @@ -0,0 +1,132 @@ +package dev.yawaflua.gominecraftbridge.host; + +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 net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.Property; +import net.minecraft.world.phys.Vec3; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class MinecraftSnapshotFactory { + public ServerSnapshot create(MinecraftServer server, SnapshotSubscription subscription) { + List levels = new ArrayList<>(); + List entities = new ArrayList<>(); + Map levelsById = new LinkedHashMap<>(); + + for (ServerLevel level : server.getAllLevels()) { + String dimension = dimension(level); + levelsById.put(dimension, level); + levels.add(new LevelSnapshot( + dimension, + level.getGameTime(), + level.getDefaultClockTime(), + level.isRaining(), + level.isThundering() + )); + + if (subscription.includesEntities()) { + for (Entity entity : level.getAllEntities()) { + entities.add(entity(entity)); + } + } + } + + List blocks = new ArrayList<>(); + for (BlockReference reference : subscription.blocks()) { + ServerLevel level = levelsById.get(reference.dimension()); + if (level == null) { + continue; + } + + BlockPos position = new BlockPos(reference.x(), reference.y(), reference.z()); + if (!level.isLoaded(position)) { + continue; + } + + BlockState state = level.getBlockState(position); + blocks.add(new BlockSnapshot( + reference.dimension(), + reference.x(), + reference.y(), + reference.z(), + BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString(), + properties(state) + )); + } + + return new ServerSnapshot( + server.getTickCount(), + Instant.now().toEpochMilli(), + levels, + entities, + blocks + ); + } + + public EntitySnapshot entity(Entity entity) { + Vec3 velocity = entity.getDeltaMovement(); + Float health = null; + Float maxHealth = null; + + if (entity instanceof LivingEntity living) { + health = living.getHealth(); + maxHealth = living.getMaxHealth(); + } + + return new EntitySnapshot( + entity.getId(), + entity.getUUID().toString(), + BuiltInRegistries.ENTITY_TYPE.getKey(entity.getType()).toString(), + entity.getName().getString(), + dimension(entity.level()), + entity.getX(), + entity.getY(), + entity.getZ(), + entity.getYRot(), + entity.getXRot(), + velocity.x, + velocity.y, + velocity.z, + entity.isAlive(), + entity instanceof Player, + health, + maxHealth + ); + } + + private static String dimension(net.minecraft.world.level.Level level) { + return level.dimension().identifier().toString(); + } + + private static Map properties(BlockState state) { + Map result = new LinkedHashMap<>(); + for (Property property : state.getProperties()) { + addProperty(result, state, property); + } + return result; + } + + private static > void addProperty( + Map target, + BlockState state, + Property property + ) { + target.put(property.getName(), property.getName(state.getValue(property))); + } +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/host/PluginState.java b/src/main/java/dev/yawaflua/gominecraftbridge/host/PluginState.java new file mode 100644 index 0000000..a03f8cf --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/host/PluginState.java @@ -0,0 +1,8 @@ +package dev.yawaflua.gominecraftbridge.host; + +public enum PluginState { + DISCOVERED, + RUNNING, + DISABLED, + STOPPED +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/management/BridgeManagementSnapshot.java b/src/main/java/dev/yawaflua/gominecraftbridge/management/BridgeManagementSnapshot.java new file mode 100644 index 0000000..194e444 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/management/BridgeManagementSnapshot.java @@ -0,0 +1,17 @@ +package dev.yawaflua.gominecraftbridge.management; + +import java.util.List; + +public record BridgeManagementSnapshot( + long generatedAtUnixMilli, + boolean serverRunning, + boolean canReload, + String message, + List packages, + List plugins +) { + public BridgeManagementSnapshot { + packages = packages == null ? List.of() : List.copyOf(packages); + plugins = plugins == null ? List.of() : List.copyOf(plugins); + } +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/management/ManagedPluginSnapshot.java b/src/main/java/dev/yawaflua/gominecraftbridge/management/ManagedPluginSnapshot.java new file mode 100644 index 0000000..225c414 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/management/ManagedPluginSnapshot.java @@ -0,0 +1,18 @@ +package dev.yawaflua.gominecraftbridge.management; + +import dev.yawaflua.gominecraftbridge.protocol.PluginLog; +import dev.yawaflua.gominecraftbridge.protocol.PluginMetadata; + +import java.util.List; + +public record ManagedPluginSnapshot( + PluginMetadata metadata, + String state, + String backend, + String origin, + List logs +) { + public ManagedPluginSnapshot { + logs = logs == null ? List.of() : List.copyOf(logs); + } +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/management/PackageInspection.java b/src/main/java/dev/yawaflua/gominecraftbridge/management/PackageInspection.java new file mode 100644 index 0000000..fd48379 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/management/PackageInspection.java @@ -0,0 +1,9 @@ +package dev.yawaflua.gominecraftbridge.management; + +public record PackageInspection( + String path, + boolean valid, + String pluginId, + String error +) { +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/management/ReloadResult.java b/src/main/java/dev/yawaflua/gominecraftbridge/management/ReloadResult.java new file mode 100644 index 0000000..f2a912d --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/management/ReloadResult.java @@ -0,0 +1,4 @@ +package dev.yawaflua.gominecraftbridge.management; + +public record ReloadResult(boolean success, String message) { +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/network/AdminRequestPayload.java b/src/main/java/dev/yawaflua/gominecraftbridge/network/AdminRequestPayload.java new file mode 100644 index 0000000..2461c9d --- /dev/null +++ b/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.Identifier; + +public record AdminRequestPayload(String action, String pluginId) implements CustomPacketPayload { + public static final Type TYPE = new Type<>( + Identifier.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/src/main/java/dev/yawaflua/gominecraftbridge/network/AdminResponsePayload.java b/src/main/java/dev/yawaflua/gominecraftbridge/network/AdminResponsePayload.java new file mode 100644 index 0000000..53d4cfe --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/network/AdminResponsePayload.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.Identifier; + +public record AdminResponsePayload(String json) implements CustomPacketPayload { + public static final int MAX_JSON_CHARS = 2 * 1024 * 1024; + public static final Type TYPE = new Type<>( + Identifier.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/src/main/java/dev/yawaflua/gominecraftbridge/network/BridgeAdminNetworking.java b/src/main/java/dev/yawaflua/gominecraftbridge/network/BridgeAdminNetworking.java new file mode 100644 index 0000000..0671716 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/network/BridgeAdminNetworking.java @@ -0,0 +1,49 @@ +package dev.yawaflua.gominecraftbridge.network; + +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; + +public final class BridgeAdminNetworking { + private BridgeAdminNetworking() { + } + + public static void register(GoPluginManager plugins) { + PayloadTypeRegistry.serverboundPlay().register(AdminRequestPayload.TYPE, AdminRequestPayload.CODEC); + PayloadTypeRegistry.clientboundPlay().registerLarge( + AdminResponsePayload.TYPE, + AdminResponsePayload.CODEC, + AdminResponsePayload.MAX_JSON_CHARS * 4 + 256 + ); + + ServerPlayNetworking.registerGlobalReceiver(AdminRequestPayload.TYPE, (payload, context) -> { + boolean allowed = context.player().permissions().hasPermission(Permissions.COMMANDS_ADMIN); + if (!allowed) { + send(context, 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 (!"refresh".equals(payload.action())) { + message = "Unknown admin action: " + payload.action(); + } + + send(context, plugins.managementSnapshot(true, message)); + }); + } + + private static void send( + ServerPlayNetworking.Context context, + BridgeManagementSnapshot snapshot + ) { + String json = ProtocolJson.GSON.toJson(snapshot); + context.responseSender().sendPacket(new AdminResponsePayload(json)); + } +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/ActionRequest.java b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/ActionRequest.java new file mode 100644 index 0000000..83642c6 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/ActionRequest.java @@ -0,0 +1,6 @@ +package dev.yawaflua.gominecraftbridge.protocol; + +import com.google.gson.JsonObject; + +public record ActionRequest(String type, JsonObject payload) { +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/BlockReference.java b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/BlockReference.java new file mode 100644 index 0000000..037f0fe --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/BlockReference.java @@ -0,0 +1,4 @@ +package dev.yawaflua.gominecraftbridge.protocol; + +public record BlockReference(String dimension, int x, int y, int z) { +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/BlockSnapshot.java b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/BlockSnapshot.java new file mode 100644 index 0000000..76c7bc2 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/BlockSnapshot.java @@ -0,0 +1,16 @@ +package dev.yawaflua.gominecraftbridge.protocol; + +import java.util.Map; + +public record BlockSnapshot( + String dimension, + int x, + int y, + int z, + String block, + Map properties +) { + public BlockSnapshot { + properties = Map.copyOf(properties); + } +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/ChatEvent.java b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/ChatEvent.java new file mode 100644 index 0000000..69cbc58 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/ChatEvent.java @@ -0,0 +1,9 @@ +package dev.yawaflua.gominecraftbridge.protocol; + +public record ChatEvent( + String playerUuid, + String playerName, + String message, + long timestampUnixMilli +) { +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/DeathEvent.java b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/DeathEvent.java new file mode 100644 index 0000000..140690c --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/DeathEvent.java @@ -0,0 +1,9 @@ +package dev.yawaflua.gominecraftbridge.protocol; + +public record DeathEvent( + EntitySnapshot entity, + String damageType, + String attackerUuid, + long timestampUnixMilli +) { +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/DeinitEvent.java b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/DeinitEvent.java new file mode 100644 index 0000000..1dfeb85 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/DeinitEvent.java @@ -0,0 +1,4 @@ +package dev.yawaflua.gominecraftbridge.protocol; + +public record DeinitEvent(String reason) { +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/EntitySnapshot.java b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/EntitySnapshot.java new file mode 100644 index 0000000..82355e6 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/EntitySnapshot.java @@ -0,0 +1,22 @@ +package dev.yawaflua.gominecraftbridge.protocol; + +public record EntitySnapshot( + int runtimeId, + String uuid, + String type, + String name, + String dimension, + double x, + double y, + double z, + float yaw, + float pitch, + double velocityX, + double velocityY, + double velocityZ, + boolean alive, + boolean player, + Float health, + Float maxHealth +) { +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/InitEvent.java b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/InitEvent.java new file mode 100644 index 0000000..1e0cad1 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/InitEvent.java @@ -0,0 +1,8 @@ +package dev.yawaflua.gominecraftbridge.protocol; + +public record InitEvent( + String minecraftVersion, + boolean dedicated, + String dataDirectory +) { +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/LevelSnapshot.java b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/LevelSnapshot.java new file mode 100644 index 0000000..7bd1e3e --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/LevelSnapshot.java @@ -0,0 +1,10 @@ +package dev.yawaflua.gominecraftbridge.protocol; + +public record LevelSnapshot( + String dimension, + long gameTime, + long dayTime, + boolean raining, + boolean thundering +) { +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/PluginLog.java b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/PluginLog.java new file mode 100644 index 0000000..1826e2c --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/PluginLog.java @@ -0,0 +1,4 @@ +package dev.yawaflua.gominecraftbridge.protocol; + +public record PluginLog(String stream, String level, String message, long timestampUnixMilli) { +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/PluginMetadata.java b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/PluginMetadata.java new file mode 100644 index 0000000..f539008 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/PluginMetadata.java @@ -0,0 +1,20 @@ +package dev.yawaflua.gominecraftbridge.protocol; + +import com.google.gson.JsonObject; + +import java.util.List; + +public record PluginMetadata( + String id, + String name, + String version, + String description, + List authors, + String website, + int apiVersion, + JsonObject configSchema +) { + public PluginMetadata { + authors = authors == null ? List.of() : List.copyOf(authors); + } +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/PluginResponse.java b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/PluginResponse.java new file mode 100644 index 0000000..b592349 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/PluginResponse.java @@ -0,0 +1,30 @@ +package dev.yawaflua.gominecraftbridge.protocol; + +import com.google.gson.JsonElement; + +import java.util.List; + +public record PluginResponse( + String status, + String error, + String stack, + JsonElement data, + List logs, + List actions, + List systemCalls, + SnapshotSubscription snapshot +) { + public PluginResponse { + logs = logs == null ? List.of() : List.copyOf(logs); + actions = actions == null ? List.of() : List.copyOf(actions); + systemCalls = systemCalls == null ? List.of() : List.copyOf(systemCalls); + } + + public boolean isPanic() { + return "panic".equals(status); + } + + public boolean isError() { + return !"ok".equals(status); + } +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/Protocol.java b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/Protocol.java new file mode 100644 index 0000000..11cdd94 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/Protocol.java @@ -0,0 +1,29 @@ +package dev.yawaflua.gominecraftbridge.protocol; + +public final class Protocol { + public static final int ABI_VERSION = 1; + public static final int MAX_RESPONSE_BYTES = 64 * 1024 * 1024; + + private Protocol() { + } + + public enum Operation { + METADATA(1), + INIT(2), + TICK(3), + CHAT(4), + DEATH(5), + SYSTEM_CALL_RESULT(6), + DEINIT(7); + + private final int code; + + Operation(int code) { + this.code = code; + } + + public int code() { + return code; + } + } +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/ProtocolJson.java b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/ProtocolJson.java new file mode 100644 index 0000000..57e2502 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/ProtocolJson.java @@ -0,0 +1,34 @@ +package dev.yawaflua.gominecraftbridge.protocol; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; + +import java.nio.charset.StandardCharsets; + +public final class ProtocolJson { + public static final Gson GSON = new GsonBuilder() + .disableHtmlEscaping() + .serializeNulls() + .create(); + + private ProtocolJson() { + } + + public static byte[] encode(Object value) { + if (value == null) { + return new byte[0]; + } + + return GSON.toJson(value).getBytes(StandardCharsets.UTF_8); + } + + public static T decode(byte[] value, Class type) { + return GSON.fromJson(new String(value, StandardCharsets.UTF_8), type); + } + + public static JsonElement tree(Object value) { + return value == null ? JsonNull.INSTANCE : GSON.toJsonTree(value); + } +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/ServerSnapshot.java b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/ServerSnapshot.java new file mode 100644 index 0000000..3eb88e6 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/ServerSnapshot.java @@ -0,0 +1,17 @@ +package dev.yawaflua.gominecraftbridge.protocol; + +import java.util.List; + +public record ServerSnapshot( + long tick, + long timestampUnixMilli, + List levels, + List entities, + List blocks +) { + public ServerSnapshot { + levels = List.copyOf(levels); + entities = List.copyOf(entities); + blocks = List.copyOf(blocks); + } +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/SnapshotSubscription.java b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/SnapshotSubscription.java new file mode 100644 index 0000000..965e347 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/SnapshotSubscription.java @@ -0,0 +1,17 @@ +package dev.yawaflua.gominecraftbridge.protocol; + +import java.util.List; + +public record SnapshotSubscription(Boolean entities, List blocks) { + public SnapshotSubscription { + blocks = blocks == null ? List.of() : List.copyOf(blocks); + } + + public boolean includesEntities() { + return entities == null || entities; + } + + public static SnapshotSubscription defaults() { + return new SnapshotSubscription(true, List.of()); + } +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/SystemCallRequest.java b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/SystemCallRequest.java new file mode 100644 index 0000000..cf05f69 --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/SystemCallRequest.java @@ -0,0 +1,6 @@ +package dev.yawaflua.gominecraftbridge.protocol; + +import com.google.gson.JsonElement; + +public record SystemCallRequest(String id, String name, JsonElement payload) { +} diff --git a/src/main/java/dev/yawaflua/gominecraftbridge/protocol/SystemCallResult.java b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/SystemCallResult.java new file mode 100644 index 0000000..5ef7e3e --- /dev/null +++ b/src/main/java/dev/yawaflua/gominecraftbridge/protocol/SystemCallResult.java @@ -0,0 +1,6 @@ +package dev.yawaflua.gominecraftbridge.protocol; + +import com.google.gson.JsonElement; + +public record SystemCallResult(String id, String name, boolean success, JsonElement data, String error) { +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..ff1f307 --- /dev/null +++ b/src/main/resources/fabric.mod.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": 1, + "id": "go_minecraft_bridge", + "version": "${version}", + "name": "Go Minecraft Bridge", + "description": "A server-side host for native Go and WebAssembly plugins.", + "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.19.2", + "minecraft": "~26.1.2", + "java": ">=25", + "fabric-api": "*" + }, + "suggests": { + "cloth-config": ">=26.1.154", + "modmenu": ">=18.0.0" + } +} diff --git a/src/test/java/dev/yawaflua/gominecraftbridge/api/SystemCallRegistryTest.java b/src/test/java/dev/yawaflua/gominecraftbridge/api/SystemCallRegistryTest.java new file mode 100644 index 0000000..e74801e --- /dev/null +++ b/src/test/java/dev/yawaflua/gominecraftbridge/api/SystemCallRegistryTest.java @@ -0,0 +1,24 @@ +package dev.yawaflua.gominecraftbridge.api; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class SystemCallRegistryTest { + @Test + void requiresNamespacedUniqueNames() { + SystemCallRegistry registry = new SystemCallRegistry(); + registry.register("example:test", (context, payload) -> payload); + + assertTrue(registry.find("example:test").isPresent()); + assertThrows( + IllegalArgumentException.class, + () -> registry.register("example:test", (context, payload) -> payload) + ); + assertThrows( + IllegalArgumentException.class, + () -> registry.register("not-namespaced", (context, payload) -> payload) + ); + } +} diff --git a/src/test/java/dev/yawaflua/gominecraftbridge/backend/NativePluginBackendTest.java b/src/test/java/dev/yawaflua/gominecraftbridge/backend/NativePluginBackendTest.java new file mode 100644 index 0000000..3d1a1f6 --- /dev/null +++ b/src/test/java/dev/yawaflua/gominecraftbridge/backend/NativePluginBackendTest.java @@ -0,0 +1,43 @@ +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.DeinitEvent; +import dev.yawaflua.gominecraftbridge.protocol.PluginResponse; +import dev.yawaflua.gominecraftbridge.protocol.Protocol; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@EnabledIfEnvironmentVariable(named = "GMB_TEST_LIBRARY", matches = ".+") +final class NativePluginBackendTest { + @Test + void loadsMetadataAndDispatchesChat() { + Path library = Path.of(System.getenv("GMB_TEST_LIBRARY")); + LoadedPlugin plugin = new LoadedPlugin(new NativePluginBackend(library)); + + assertEquals("hello_native", plugin.metadata().id()); + assertEquals(Protocol.ABI_VERSION, plugin.metadata().apiVersion()); + + PluginResponse response = plugin.invoke( + Protocol.Operation.CHAT, + new ChatEvent("00000000-0000-0000-0000-000000000001", "Test", "!go", 1) + ); + assertEquals("ok", response.status()); + assertEquals(1, response.actions().size()); + assertEquals("minecraft:chat.player", response.actions().getFirst().type()); + assertEquals(1, response.systemCalls().size()); + assertEquals("minecraft:server.info", response.systemCalls().getFirst().name()); + + PluginResponse deinit = plugin.invoke( + Protocol.Operation.DEINIT, + new DeinitEvent("integration_test") + ); + assertEquals(1, deinit.logs().size()); + assertEquals("deinit: integration_test", deinit.logs().getFirst().message()); + } +}