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