mirror of
https://git.huckle.dev/Huckles-Minecraft-Archive/LCE-Revelations.git
synced 2026-05-22 11:35:32 +00:00
Adds the FourKit .NET 10 plugin host as a second dedicated server
build flavour alongside the existing vanilla server. Both flavours
build from the same source tree, with FourKit gated by the
MINECRAFT_SERVER_FOURKIT_BUILD preprocessor define.
Build layout:
Minecraft.Server vanilla, no plugin support, no .NET dep
Minecraft.Server.FourKit FourKit-enabled, ships with bundled
.NET 10 self-contained runtime in runtime/
and an empty plugins/ folder
Both produce a Minecraft.Server.exe in their own per-target output
dir. The variant identity lives in the directory name, not the
binary name, so either flavour can be shipped as a drop-in.
Native bridge (Minecraft.Server/FourKit*.{cpp,h}):
* FourKitRuntime: hosts CoreCLR via hostfxr's command-line init API
(the runtime-config API does not support self-contained components)
* FourKitBridge: ~50 Fire* event entry points, with inline no-op
stubs for the standalone build so gameplay code can call them
unconditionally
* FourKitNatives: ~80 native callbacks the managed side invokes
for player/world/inventory mutations
* FourKitMappers: type and enum mapping helpers
Managed plugin host (Minecraft.Server.FourKit/):
* Bukkit-style API: Player, World, Block, Inventory, Command,
Listener, EventHandler attribute, ~54 event classes
* PluginLoader with per-plugin AssemblyLoadContext
* FourKitHost as the [UnmanagedCallersOnly] entry point table
* Runtime resolves plugins relative to the host process so they
always live next to Minecraft.Server.exe regardless of where the
managed assembly itself is loaded from
Engine hooks (Minecraft.Client/, Minecraft.World/):
* Player lifecycle (PreLogin, Login, Join, Quit, Kick, Move,
Teleport, Portal, Death) wired into PendingConnection and
PlayerConnection without disturbing the cipher handshake or
identity-token security flow
* Inventory open/click/drop hooks across every container menu type
* Block place/break/grow/burn/spread/from-to hooks across the
full tile family
* Bed enter/leave, sign change, entity damage/death, ender pearl
teleport hooks
Regression fixes preserved while applying donor diffs:
* ServerPlayer::die() retains the LCE-Revelations hardcore branch
(setGameMode(ADVENTURE) + banPlayerForHardcoreDeath) in both the
FourKit and non-FourKit code paths
* ServerLevel::entityAdded() retains the sub-entity ID reassignment
loop required by the client's handleAddMob offset, fixing Ender
Dragon and Wither boss multi-part hit detection
* LivingEntity::travel() retains the raw Player* cast and the
cached frictionTile, both Revelations perf wins that the donor
silently reverted
* ServerLogger.cpp keeps the file-logging code donor stripped
* PlayerList.cpp end portal transition fix and UIScene_EndPoem
bounds-check are intact
Build system:
* Top-level CMakeLists.txt adds the Minecraft.Server.FourKit
subdirectory and pulls in the new shared cmake/ServerTarget.cmake
helper
* Minecraft.Server/cmake/sources/Common.cmake is now location
independent (uses CMAKE_CURRENT_LIST_DIR) so the source list
can be consumed from either server target's CMakeLists.txt
* The seven FourKit*.cpp/h files live in their own
_MINECRAFT_SERVER_COMMON_SERVER_FOURKIT variable so the
standalone target omits them
* configure-time .NET 10 SDK check fails fast with a clear
download link if the SDK is missing
* global.json pins the SDK to 10.0.100 with latestFeature
rollforward
Sample plugin (samples/HelloPlugin/) demonstrates the loader and
the PlayerJoinEvent listener pattern.
CI:
* nightly.yml builds both server flavours, ships
LCE-Revelations-Server-Win64.zip and
LCE-Revelations-Server-Win64-FourKit.zip, attests both, and
updates release notes for the dual-flavour layout
* pull-request.yml pulls in actions/setup-dotnet so the FourKit
publish step works in PR validation
* All zip artifacts and the client zip are renamed from
LCREWindows64 to LCE-Revelations-{Client,Server}-Win64
Documentation:
* COMPILE.md gets a VS 2022 quick start, .NET 10 prereq section,
server flavours explanation, and a troubleshooting section
* docs/FOURKIT_PORT_RECON.md captures the file-by-file recon that
drove the port
* docs/FOURKIT_PARITY.md is the canonical reference for which
events FourKit fires
Docker:
* docker-compose.dedicated-server.yml MC_RUNTIME_DIR default points
at the vanilla CMake output. The FourKit Docker image is
intentionally NOT shipped yet because hosting .NET 10 self
contained inside Wine has not been smoke-tested
271 lines
11 KiB
C#
271 lines
11 KiB
C#
using System.Runtime.InteropServices;
|
|
using Minecraft.Server.FourKit.Entity;
|
|
using Minecraft.Server.FourKit.Event.Inventory;
|
|
using Minecraft.Server.FourKit.Inventory;
|
|
|
|
namespace Minecraft.Server.FourKit;
|
|
|
|
public static partial class FourKitHost
|
|
{
|
|
internal static PluginLoader? s_loader;
|
|
|
|
public static IReadOnlyList<Plugin.ServerPlugin> getLoadedPlugins() => s_loader?.Plugins ?? [];
|
|
|
|
[UnmanagedCallersOnly]
|
|
public static void Initialize()
|
|
{
|
|
try
|
|
{
|
|
ServerLog.Info("fourkit", "Initializing plugin system...");
|
|
|
|
// Resolve plugins relative to the host process (Minecraft.Server.exe),
|
|
// NOT the FourKit assembly. AppContext.BaseDirectory points at the
|
|
// "runtime/" subfolder where the self-contained .NET payload lives,
|
|
// so we'd otherwise create plugins inside runtime/plugins/. Use the
|
|
// host exe's directory instead so end users see a top-level plugins/.
|
|
string hostExePath = Environment.ProcessPath ?? AppContext.BaseDirectory;
|
|
string serverRoot = Path.GetDirectoryName(hostExePath) ?? AppContext.BaseDirectory;
|
|
string pluginsDir = Path.Combine(serverRoot, "plugins");
|
|
s_loader = new PluginLoader();
|
|
s_loader.LoadPlugins(pluginsDir);
|
|
s_loader.EnableAll();
|
|
|
|
ServerLog.Info("fourkit", "Plugin system ready.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ServerLog.Error("fourkit", $"Initialize failed: {ex}");
|
|
}
|
|
}
|
|
|
|
[UnmanagedCallersOnly]
|
|
public static void Shutdown()
|
|
{
|
|
try
|
|
{
|
|
ServerLog.Info("fourkit", "Shutting down plugin system...");
|
|
s_loader?.DisableAll();
|
|
s_loader = null;
|
|
ServerLog.Info("fourkit", "Plugin system shut down.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ServerLog.Error("fourkit", $"Shutdown error: {ex}");
|
|
}
|
|
}
|
|
|
|
|
|
private static Guid ParseOrHashGuid(string s)
|
|
{
|
|
if (Guid.TryParse(s, out var g)) return g;
|
|
if (string.IsNullOrEmpty(s)) return Guid.NewGuid();
|
|
return new Guid(System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(s)));
|
|
}
|
|
|
|
static double[] s_playerSnapshotBuffer = new double[27];
|
|
static GCHandle? s_playerSnapshotBuffer_Handle = null;
|
|
|
|
// double[27] = { x, y, z, health, maxHealth, fallDistance, gameMode, walkSpeed, yaw, pitch, dimension, isSleeping, sleepTimer, sneaking, sprinting, onGround, velocityX, velocityY, velocityZ, allowFlight, sleepingIgnored, experienceLevel, experienceProgress, totalExperience, foodLevel, saturation, exhaustion }
|
|
internal static void SyncPlayerFromNative(Player player)
|
|
{
|
|
if (NativeBridge.GetPlayerSnapshot == null)
|
|
return;
|
|
|
|
if (s_playerSnapshotBuffer_Handle == null)
|
|
{
|
|
s_playerSnapshotBuffer_Handle = GCHandle.Alloc(s_playerSnapshotBuffer, GCHandleType.Pinned);
|
|
}
|
|
|
|
NativeBridge.GetPlayerSnapshot(player.getEntityId(), s_playerSnapshotBuffer_Handle.GetValueOrDefault().AddrOfPinnedObject());
|
|
|
|
int dimId = (int)s_playerSnapshotBuffer[10];
|
|
player.SetDimensionInternal(dimId);
|
|
var world = FourKit.getWorld(dimId);
|
|
player.SetLocation(new Location(world, s_playerSnapshotBuffer[0], s_playerSnapshotBuffer[1], s_playerSnapshotBuffer[2], (float)s_playerSnapshotBuffer[8], (float)s_playerSnapshotBuffer[9]));
|
|
player.SetHealthInternal(s_playerSnapshotBuffer[3]);
|
|
player.SetMaxHealthInternal(s_playerSnapshotBuffer[4]);
|
|
player.SetFallDistanceInternal((float)s_playerSnapshotBuffer[5]);
|
|
player.SetGameModeInternal((GameMode)(int)s_playerSnapshotBuffer[6]);
|
|
player.SetWalkSpeedInternal((float)s_playerSnapshotBuffer[7]);
|
|
player.SetSleepingInternal(s_playerSnapshotBuffer[11] != 0.0);
|
|
player.SetSleepTicksInternal((int)s_playerSnapshotBuffer[12]);
|
|
player.SetSneakingInternal(s_playerSnapshotBuffer[13] != 0.0);
|
|
player.SetSprintingInternal(s_playerSnapshotBuffer[14] != 0.0);
|
|
player.SetOnGroundInternal(s_playerSnapshotBuffer[15] != 0.0);
|
|
player.SetVelocityInternal(s_playerSnapshotBuffer[16], s_playerSnapshotBuffer[17], s_playerSnapshotBuffer[18]);
|
|
player.SetAllowFlightInternal(s_playerSnapshotBuffer[19] != 0.0);
|
|
player.SetSleepingIgnoredInternal(s_playerSnapshotBuffer[20] != 0.0);
|
|
player.SetLevelInternal((int)s_playerSnapshotBuffer[21]);
|
|
player.SetExpInternal((float)s_playerSnapshotBuffer[22]);
|
|
player.SetTotalExperienceInternal((int)s_playerSnapshotBuffer[23]);
|
|
player.SetFoodLevelInternal((int)s_playerSnapshotBuffer[24]);
|
|
player.SetSaturationInternal((float)s_playerSnapshotBuffer[25]);
|
|
player.SetExhaustionInternal((float)s_playerSnapshotBuffer[26]);
|
|
}
|
|
|
|
internal static void BroadcastNativeMessage(string message)
|
|
{
|
|
if (string.IsNullOrEmpty(message) || NativeBridge.BroadcastMessage == null)
|
|
return;
|
|
IntPtr ptr = Marshal.StringToCoTaskMemUTF8(message);
|
|
try
|
|
{
|
|
NativeBridge.BroadcastMessage(ptr, System.Text.Encoding.UTF8.GetByteCount(message));
|
|
}
|
|
finally
|
|
{
|
|
Marshal.FreeCoTaskMem(ptr);
|
|
}
|
|
}
|
|
|
|
private static void WriteSignOutLens(IntPtr ptr, int[] lens)
|
|
{
|
|
Marshal.Copy(lens, 0, ptr, 4);
|
|
}
|
|
|
|
private static string JavaFormat(string format, params string[] args)
|
|
{
|
|
var sb = new System.Text.StringBuilder(format.Length + 64);
|
|
int seqIndex = 0;
|
|
|
|
for (int i = 0; i < format.Length; i++)
|
|
{
|
|
char c = format[i];
|
|
if (c == '%' && i + 1 < format.Length)
|
|
{
|
|
char next = format[i + 1];
|
|
|
|
if (next == '%')
|
|
{
|
|
sb.Append('%');
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (next == 's')
|
|
{
|
|
if (seqIndex < args.Length)
|
|
sb.Append(args[seqIndex]);
|
|
seqIndex++;
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (char.IsDigit(next))
|
|
{
|
|
int numStart = i + 1;
|
|
int j = numStart;
|
|
while (j < format.Length && char.IsDigit(format[j]))
|
|
j++;
|
|
|
|
if (j + 1 < format.Length && format[j] == '$' && format[j + 1] == 's')
|
|
{
|
|
int argIndex = int.Parse(format.AsSpan(numStart, j - numStart)) - 1;
|
|
if (argIndex >= 0 && argIndex < args.Length)
|
|
sb.Append(args[argIndex]);
|
|
i = j + 1;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
sb.Append(c);
|
|
}
|
|
else
|
|
{
|
|
sb.Append(c);
|
|
}
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
|
|
private static InventoryType MapNativeContainerType(int nativeType)
|
|
{
|
|
return nativeType switch
|
|
{
|
|
0 => InventoryType.CHEST, // CONTAINER
|
|
1 => InventoryType.WORKBENCH, // WORKBENCH
|
|
2 => InventoryType.FURNACE, // FURNACE
|
|
3 => InventoryType.DISPENSER, // TRAP (dispenser)
|
|
4 => InventoryType.ENCHANTING, // ENCHANTMENT
|
|
5 => InventoryType.BREWING, // BREWING_STAND
|
|
6 => InventoryType.MERCHANT, // TRADER_NPC
|
|
7 => InventoryType.BEACON, // BEACON
|
|
8 => InventoryType.ANVIL, // REPAIR_TABLE
|
|
9 => InventoryType.HOPPER, // HOPPER
|
|
10 => InventoryType.DROPPER, // DROPPER
|
|
11 => InventoryType.CHEST, // HORSE
|
|
12 => InventoryType.WORKBENCH, // FIREWORKS
|
|
13 => InventoryType.CHEST, // BONUS_CHEST
|
|
14 => InventoryType.CHEST, // LARGE_CHEST
|
|
15 => InventoryType.ENDER_CHEST,// ENDER_CHEST
|
|
16 => InventoryType.CHEST, // MINECART_CHEST
|
|
17 => InventoryType.HOPPER, // MINECART_HOPPER
|
|
_ => InventoryType.CHEST,
|
|
};
|
|
}
|
|
|
|
private static Inventory.Inventory CreateContainerInventory(InventoryType invType, int nativeType, string title, int containerSize, int entityId)
|
|
{
|
|
string name = string.IsNullOrEmpty(title) ? invType.getDefaultTitle() : title;
|
|
int size = containerSize > 0 ? containerSize : invType.getDefaultSize();
|
|
|
|
return invType switch
|
|
{
|
|
InventoryType.FURNACE => new Inventory.FurnaceInventory(name, size, entityId),
|
|
InventoryType.BEACON => new Inventory.BeaconInventory(name, size, entityId),
|
|
InventoryType.ENCHANTING => new Inventory.EnchantingInventory(name, size, entityId),
|
|
_ => nativeType switch
|
|
{
|
|
11 => new Inventory.HorseInventory(name, size, entityId), // HORSE
|
|
14 => new Inventory.DoubleChestInventory(name, size, entityId), // LARGE_CHEST
|
|
_ => new Inventory.Inventory(name, invType, size, entityId),
|
|
}
|
|
};
|
|
}
|
|
|
|
private static ClickType MapNativeClickType(int nativeClickType, int button)
|
|
{
|
|
return nativeClickType switch
|
|
{
|
|
0 => button == 0 ? ClickType.LEFT : ClickType.RIGHT,
|
|
1 => button == 0 ? ClickType.SHIFT_LEFT : ClickType.SHIFT_RIGHT,
|
|
2 => ClickType.NUMBER_KEY,
|
|
3 => ClickType.MIDDLE,
|
|
4 => button == 1 ? ClickType.CONTROL_DROP : ClickType.DROP,
|
|
5 => ClickType.UNKNOWN,
|
|
6 => ClickType.DOUBLE_CLICK,
|
|
_ => ClickType.UNKNOWN,
|
|
};
|
|
}
|
|
|
|
private static InventoryAction DetermineInventoryAction(ClickType click, int slot)
|
|
{
|
|
if (slot == InventoryView.OUTSIDE)
|
|
{
|
|
return click switch
|
|
{
|
|
ClickType.LEFT => InventoryAction.DROP_ALL_CURSOR,
|
|
ClickType.RIGHT => InventoryAction.DROP_ONE_CURSOR,
|
|
ClickType.WINDOW_BORDER_LEFT => InventoryAction.DROP_ALL_CURSOR,
|
|
ClickType.WINDOW_BORDER_RIGHT => InventoryAction.DROP_ONE_CURSOR,
|
|
_ => InventoryAction.NOTHING,
|
|
};
|
|
}
|
|
|
|
return click switch
|
|
{
|
|
ClickType.LEFT => InventoryAction.PICKUP_ALL,
|
|
ClickType.RIGHT => InventoryAction.PICKUP_HALF,
|
|
ClickType.SHIFT_LEFT or ClickType.SHIFT_RIGHT => InventoryAction.MOVE_TO_OTHER_INVENTORY,
|
|
ClickType.NUMBER_KEY => InventoryAction.HOTBAR_SWAP,
|
|
ClickType.MIDDLE => InventoryAction.CLONE_STACK,
|
|
ClickType.DROP => InventoryAction.DROP_ONE_SLOT,
|
|
ClickType.CONTROL_DROP => InventoryAction.DROP_ALL_SLOT,
|
|
ClickType.DOUBLE_CLICK => InventoryAction.COLLECT_TO_CURSOR,
|
|
_ => InventoryAction.UNKNOWN,
|
|
};
|
|
}
|
|
}
|