Add Cloth Config plugin management screen

This commit is contained in:
Dmitri Shimanski
2026-07-20 04:15:57 +03:00
commit 020da28470
66 changed files with 3351 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
.gradle/
build/
run/
out/
.idea/
*.iml
*.so
*.dll
*.dylib
*.wasm
examples/**/dist/
+200
View File
@@ -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.
+83
View File
@@ -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
}
}
}
+61
View File
@@ -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.
+14
View File
@@ -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"
+7
View File
@@ -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
+88
View File
@@ -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() {}
+12
View File
@@ -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
Vendored Executable
+248
View File
@@ -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" "$@"
Vendored
+82
View File
@@ -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%
+61
View File
@@ -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(),
})
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/yawaflua/GoMinecraftBridge/sdk
go 1.24.0
+63
View File
@@ -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,
}
}
+68
View File
@@ -0,0 +1,68 @@
//go:build cgo && !wasm
package sdk
/*
#include <stdint.h>
#include <stdlib.h>
*/
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))
}
+115
View File
@@ -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"
}
+29
View File
@@ -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
}
+141
View File
@@ -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
}
+54
View File
@@ -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)
}
}
+144
View File
@@ -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"`
}
+12
View File
@@ -0,0 +1,12 @@
pluginManagement {
repositories {
maven {
name = 'Fabric'
url = 'https://maven.fabricmc.net/'
}
mavenCentral()
gradlePluginPortal()
}
}
rootProject.name = 'go-minecraft-bridge'
@@ -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<String> 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<String> 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<PluginLog> 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;
}
}
@@ -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);
};
}
}
@@ -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;
}
}
@@ -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");
}
}
@@ -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;
}
}
@@ -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<String, PluginMetadata> 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<PluginMetadata> find(String id) {
return Optional.ofNullable(this.plugins.get(id));
}
public synchronized List<PluginMetadata> all() {
return List.copyOf(this.plugins.values());
}
}
@@ -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) {
}
@@ -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;
}
@@ -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<String, SystemCallHandler> 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<SystemCallHandler> find(String name) {
return Optional.ofNullable(this.handlers.get(name));
}
public synchronized Map<String, SystemCallHandler> 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);
}
}
}
@@ -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();
}
@@ -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);
}
}
@@ -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));
}
}
@@ -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();
}
}
@@ -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;
});
}
}
@@ -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<String, LoadedPlugin> plugins = new LinkedHashMap<>();
private final List<PackageInspection> 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<LoadedPlugin> plugins() {
return List.copyOf(this.plugins.values());
}
public synchronized BridgeManagementSnapshot managementSnapshot(boolean canReload, String message) {
List<ManagedPluginSnapshot> 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<LoadedPlugin> runningPlugins() {
return this.plugins.values().stream()
.filter(plugin -> plugin.state() == PluginState.RUNNING)
.toList();
}
private List<Path> nativeCandidates() {
String extension = nativeExtension();
List<Path> result = new ArrayList<>();
List<Path> 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();
}
}
@@ -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<PluginLog> 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<PluginLog> 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());
}
}
}
@@ -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<LevelSnapshot> levels = new ArrayList<>();
List<EntitySnapshot> entities = new ArrayList<>();
Map<String, ServerLevel> 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<BlockSnapshot> 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<String, String> properties(BlockState state) {
Map<String, String> result = new LinkedHashMap<>();
for (Property<?> property : state.getProperties()) {
addProperty(result, state, property);
}
return result;
}
private static <T extends Comparable<T>> void addProperty(
Map<String, String> target,
BlockState state,
Property<T> property
) {
target.put(property.getName(), property.getName(state.getValue(property)));
}
}
@@ -0,0 +1,8 @@
package dev.yawaflua.gominecraftbridge.host;
public enum PluginState {
DISCOVERED,
RUNNING,
DISABLED,
STOPPED
}
@@ -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<PackageInspection> packages,
List<ManagedPluginSnapshot> plugins
) {
public BridgeManagementSnapshot {
packages = packages == null ? List.of() : List.copyOf(packages);
plugins = plugins == null ? List.of() : List.copyOf(plugins);
}
}
@@ -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<PluginLog> logs
) {
public ManagedPluginSnapshot {
logs = logs == null ? List.of() : List.copyOf(logs);
}
}
@@ -0,0 +1,9 @@
package dev.yawaflua.gominecraftbridge.management;
public record PackageInspection(
String path,
boolean valid,
String pluginId,
String error
) {
}
@@ -0,0 +1,4 @@
package dev.yawaflua.gominecraftbridge.management;
public record ReloadResult(boolean success, String message) {
}
@@ -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<AdminRequestPayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(GoMinecraftBridgeMod.MOD_ID, "admin_request")
);
public static final StreamCodec<RegistryFriendlyByteBuf, AdminRequestPayload> 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<? extends CustomPacketPayload> type() {
return TYPE;
}
}
@@ -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<AdminResponsePayload> TYPE = new Type<>(
Identifier.fromNamespaceAndPath(GoMinecraftBridgeMod.MOD_ID, "admin_response")
);
public static final StreamCodec<RegistryFriendlyByteBuf, AdminResponsePayload> 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<? extends CustomPacketPayload> type() {
return TYPE;
}
}
@@ -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));
}
}
@@ -0,0 +1,6 @@
package dev.yawaflua.gominecraftbridge.protocol;
import com.google.gson.JsonObject;
public record ActionRequest(String type, JsonObject payload) {
}
@@ -0,0 +1,4 @@
package dev.yawaflua.gominecraftbridge.protocol;
public record BlockReference(String dimension, int x, int y, int z) {
}
@@ -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<String, String> properties
) {
public BlockSnapshot {
properties = Map.copyOf(properties);
}
}
@@ -0,0 +1,9 @@
package dev.yawaflua.gominecraftbridge.protocol;
public record ChatEvent(
String playerUuid,
String playerName,
String message,
long timestampUnixMilli
) {
}
@@ -0,0 +1,9 @@
package dev.yawaflua.gominecraftbridge.protocol;
public record DeathEvent(
EntitySnapshot entity,
String damageType,
String attackerUuid,
long timestampUnixMilli
) {
}
@@ -0,0 +1,4 @@
package dev.yawaflua.gominecraftbridge.protocol;
public record DeinitEvent(String reason) {
}
@@ -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
) {
}
@@ -0,0 +1,8 @@
package dev.yawaflua.gominecraftbridge.protocol;
public record InitEvent(
String minecraftVersion,
boolean dedicated,
String dataDirectory
) {
}
@@ -0,0 +1,10 @@
package dev.yawaflua.gominecraftbridge.protocol;
public record LevelSnapshot(
String dimension,
long gameTime,
long dayTime,
boolean raining,
boolean thundering
) {
}
@@ -0,0 +1,4 @@
package dev.yawaflua.gominecraftbridge.protocol;
public record PluginLog(String stream, String level, String message, long timestampUnixMilli) {
}
@@ -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<String> authors,
String website,
int apiVersion,
JsonObject configSchema
) {
public PluginMetadata {
authors = authors == null ? List.of() : List.copyOf(authors);
}
}
@@ -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<PluginLog> logs,
List<ActionRequest> actions,
List<SystemCallRequest> 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);
}
}
@@ -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;
}
}
}
@@ -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> T decode(byte[] value, Class<T> 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);
}
}
@@ -0,0 +1,17 @@
package dev.yawaflua.gominecraftbridge.protocol;
import java.util.List;
public record ServerSnapshot(
long tick,
long timestampUnixMilli,
List<LevelSnapshot> levels,
List<EntitySnapshot> entities,
List<BlockSnapshot> blocks
) {
public ServerSnapshot {
levels = List.copyOf(levels);
entities = List.copyOf(entities);
blocks = List.copyOf(blocks);
}
}
@@ -0,0 +1,17 @@
package dev.yawaflua.gominecraftbridge.protocol;
import java.util.List;
public record SnapshotSubscription(Boolean entities, List<BlockReference> 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());
}
}
@@ -0,0 +1,6 @@
package dev.yawaflua.gominecraftbridge.protocol;
import com.google.gson.JsonElement;
public record SystemCallRequest(String id, String name, JsonElement payload) {
}
@@ -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) {
}
+33
View File
@@ -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"
}
}
@@ -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)
);
}
}
@@ -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());
}
}