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