Adding client-side and server-side interactions. Also adds purpur/paper support

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