在解决多模组环境下服务器跳转导致的客户端数据包解析异常问题时(如ClientAddEntityPacket数据读取错误),我最初发现问题源于Netty管道传递机制的处理疏漏。然而进一步排查发现,另一个关联插件的工作机制才是关键因素。经深入分析其交互逻辑后,我决定将功能不完善的ForgeClientResetPacket模组整合到当前项目中。经过多次版本迭代和持续调试,最终成功实现了稳定的跨服实体同步功能。(

This commit is contained in:
叁玖领域 2025-07-25 02:03:31 +08:00
parent b4f87a7b55
commit f5591e7df3
21 changed files with 388 additions and 36 deletions

View File

@ -19,6 +19,13 @@ allprojects {
maven { url = "https://maven.izzel.io/releases/" }
maven { url = "https://maven.bawnorton.com/releases" }
maven { url 'https://repo.lucko.me/' } // LuckPerms
maven {
name = "Modrinth"
url = "https://api.modrinth.com/maven"
}
maven {
url "https://cursemaven.com"
}
}
processResources{
duplicatesStrategy = DuplicatesStrategy.EXCLUDE

View File

@ -32,11 +32,11 @@ println "Java: ${System.getProperty 'java.version'}, JVM: ${System.getProperty '
//// Mixin
//mixin {
// add sourceSets.main, "${mod_id}.refmap.json"
// config "${mod_id}.mixins.json"
//}
// Mixin
mixin {
add sourceSets.main, "${mod_id}.refmap.json"
config "${mod_id}.mixins.json"
}
// LegacyForge
legacyForge {
@ -93,6 +93,12 @@ configurations {
dependencies {
compileOnly 'org.projectlombok:lombok:1.18.24'
annotationProcessor 'org.projectlombok:lombok:1.18.24'
annotationProcessor 'org.spongepowered:mixin:0.8.5:processor'
modImplementation "curse.maven:easy-villagers-400514:3887794"
modImplementation "curse.maven:xaeros-world-map-317780:6538320"
modImplementation "curse.maven:immersive-aircraft-666014:4679496"
modImplementation "curse.maven:modern-ui-352491:5229350"
modImplementation "curse.maven:iceberg-520110:4035917"
}
//
@ -114,7 +120,7 @@ jar {
'Implementation-Version' : archiveVersion,
'Implementation-Vendor' : mod_authors,
'Implementation-Timestamp': new Date().format("yyyy-MM-dd'T'HH:mm:ssZ"),
// 'MixinConfigs' : "${mod_id}.mixin.json"
'MixinConfigs' : "${mod_id}.mixins.json"
])
}
finalizedBy 'reobfJar'

View File

@ -49,7 +49,7 @@ mod_name=Leisure Time Dock Mod
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
mod_license=MIT
# The mod version. See https://semver.org/
mod_version=0.0.0.2
mod_version=0.0.1.3
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
# This should match the base package used for the mod sources.
# See https://maven.apache.org/guides/mini/guide-naming-conventions.html

View File

@ -1,11 +1,17 @@
package com.leisuretimedock.crossmod;
import com.leisuretimedock.crossmod.reset.ClientResetManager;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.eventbus.api.IEventBus;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.IExtensionPoint;
import net.minecraftforge.fml.ModLoadingContext;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent;
import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext;
import net.minecraftforge.network.NetworkConstants;
import java.util.concurrent.atomic.AtomicBoolean;
@Mod(CrossTeleportMod.MOD_ID)
public class CrossTeleportMod {
@ -15,7 +21,9 @@ public class CrossTeleportMod {
public CrossTeleportMod() {
// 注册生命周期事件
ModLoadingContext.get().registerExtensionPoint(IExtensionPoint.DisplayTest.class,
() -> new IExtensionPoint.DisplayTest(() -> "ANY", (a, b) -> true));
() -> new IExtensionPoint.DisplayTest(() -> NetworkConstants.IGNORESERVERONLY, (a, b) -> true));
IEventBus modEventBus = FMLJavaModLoadingContext.get().getModEventBus();
modEventBus.addListener(ClientResetManager::init);
}
@Mod.EventBusSubscriber(modid = MOD_ID, value = Dist.CLIENT, bus = Mod.EventBusSubscriber.Bus.FORGE)

View File

@ -16,7 +16,6 @@ import java.util.Objects;
public class NetworkHandler {
// 自定义插件消息通道标识
public static final ResourceLocation TELEPORT_ID = new ResourceLocation(CrossTeleportMod.MOD_ID, "teleport");
public static final ResourceLocation CHANNEL_ID = new ResourceLocation(CrossTeleportMod.MOD_ID, "channel");
public static void register() {
@ -30,9 +29,8 @@ public class NetworkHandler {
* @param payload 负载数据字节数组
*/
public static void sendPluginMessage(ResourceLocation subChannel, byte[] payload) {
FriendlyByteBuf buf = new FriendlyByteBuf(Unpooled.buffer());
// buf.writeUtf(subChannel.getPath()); // 写入子通道字符串
buf.writeBytes(payload); // 写入负载字节
FriendlyByteBuf buf = new FriendlyByteBuf(Unpooled.buffer(payload.length));
buf.writeBytes(payload);
// 获取当前连接并发送自定义负载包
Objects.requireNonNull(Minecraft.getInstance().getConnection())

View File

@ -58,7 +58,7 @@ public class CrossServerGui extends Screen {
private void sendCustomPayload(String message) {
Minecraft mc = Minecraft.getInstance();
if (mc.getConnection() != null) {
FriendlyByteBuf buf = new FriendlyByteBuf(Unpooled.buffer());
FriendlyByteBuf buf = new FriendlyByteBuf(Unpooled.buffer(256));
buf.writeUtf(message);
mc.getConnection().send(new ServerboundCustomPayloadPacket(CHANNEL_ID, buf));
}

View File

@ -2,6 +2,7 @@ package com.leisuretimedock.crossmod.client;
import com.leisuretimedock.crossmod.CrossTeleportMod;
import com.leisuretimedock.crossmod.NetworkHandler;
import com.leisuretimedock.crossmod.reset.ClientResetManager;
import com.mojang.brigadier.arguments.StringArgumentType;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelPipeline;
@ -28,7 +29,8 @@ public class PluginChannelClient {
@SubscribeEvent
public static void onLogin(ClientPlayerNetworkEvent.LoggedInEvent event) {
log.debug("[CrossTeleportMod] 玩家登录事件触发");
if (ClientResetManager.isNegotiating.get())
ClientResetManager.isNegotiating.set(false);
Connection connection = Objects.requireNonNull(Minecraft.getInstance().getConnection()).getConnection();
ChannelPipeline pipeline = connection.channel().pipeline();
@ -38,13 +40,11 @@ public class PluginChannelClient {
pipeline.addBefore("packet_handler", HANDLER_NAME, new SimpleChannelInboundHandler<ClientboundCustomPayloadPacket>() {
@Override
protected void channelRead0(ChannelHandlerContext ctx, ClientboundCustomPayloadPacket packet) {
log.debug("[CrossTeleportMod] 收到插件消息包: {}", packet.getIdentifier());
if (!packet.getIdentifier().equals(NetworkHandler.CHANNEL_ID)) {
log.warn("[CrossTeleportMod] 未识别插件消息频道: {}", packet.getIdentifier());
ctx.fireChannelRead(packet);
return;
}
log.debug("[CrossTeleportMod] 收到插件消息包: {}", packet.getIdentifier());
FriendlyByteBuf buf = packet.getData();
try {
// 先读一个字符串但不使用它,出现空消息
@ -96,9 +96,11 @@ public class PluginChannelClient {
log.debug("[CrossTeleportMod] 当前管线内容: {}", pipeline.names());
if (pipeline.get(HANDLER_NAME) != null) {
pipeline.remove(HANDLER_NAME);
log.debug("[CrossTeleportMod] 成功移除插件消息处理器: {}", HANDLER_NAME);
if (pipeline.get(HANDLER_NAME) != null ) {
if (!ClientResetManager.isNegotiating.get()) {
pipeline.remove(HANDLER_NAME);
log.debug("[CrossTeleportMod] 成功移除插件消息处理器: {}", HANDLER_NAME);
} else log.debug("[CrossTeleport] 跳转中,不移除消息处理器: {}", HANDLER_NAME);
} else {
log.warn("[CrossTeleportMod] 未找到插件消息处理器: {}", HANDLER_NAME);
}

View File

@ -0,0 +1,12 @@
package com.leisuretimedock.crossmod.mixin;
import net.minecraft.client.Minecraft;
import net.minecraft.network.Connection;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.gen.Accessor;
@Mixin(Minecraft.class)
public interface AccessorMinecraft {
@Accessor("pendingConnection")
void setPendingConnection(Connection connection);
}

View File

@ -0,0 +1,26 @@
package com.leisuretimedock.crossmod.mixin;
import icyllis.modernui.mc.forge.NetworkHandler;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Pseudo;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.ModifyArg;
@Pseudo
@Mixin(value = NetworkHandler.class, remap = false)
public class MixinMUINetWorkHandler {
/**
* 修补构造 ResourceLocation("modernui", id) id 是空字符串则替换为 "default"
*/
@ModifyArg(
method = "<init>",
at = @At(
value = "INVOKE",
target = "Lnet/minecraft/resources/ResourceLocation;<init>(Ljava/lang/String;Ljava/lang/String;)V"
),
index = 1 // 修改 id 参数
)
private String fixEmptyId(String id) {
return id == null || id.isEmpty() ? "default" : id;
}
}

View File

@ -0,0 +1,23 @@
package com.leisuretimedock.crossmod.mixin;
import net.minecraftforge.network.HandshakeMessages;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import java.util.List;
@Mixin(value = HandshakeMessages.C2SModListReply.class, remap = false)
public class ModListSpoofMixin {
@Inject(method = "<init>*", at = @At("RETURN"))
private void injectFakeModList(CallbackInfo ci) {
HandshakeMessages.C2SModListReply self = HandshakeMessages.C2SModListReply.class.cast(this);
List<String> mods = self.getModList();
if (!mods.contains("clientresetpacket")) {
// "[Mixin] 模拟添加 clientresetpacket 模组到 modlist" ,以启用跳转功能
mods.add("clientresetpacket");
}
}
}

View File

@ -0,0 +1,75 @@
package com.leisuretimedock.crossmod.reset;
import lombok.extern.slf4j.Slf4j;
import net.minecraft.network.Connection;
import net.minecraftforge.fml.event.lifecycle.FMLCommonSetupEvent;
import net.minecraftforge.fml.util.ObfuscationReflectionHelper;
import net.minecraftforge.network.*;
import net.minecraftforge.network.simple.SimpleChannel;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.util.concurrent.atomic.AtomicBoolean;
@Slf4j
public class ClientResetManager {
public static final Field handshakeField;
public static final Constructor<NetworkEvent.Context> contextConstructor;
public static AtomicBoolean isNegotiating = new AtomicBoolean(false);
public static SimpleChannel handshakeChannel;
public static void init(FMLCommonSetupEvent event) {
event.enqueueWork(() -> {
if (handshakeField == null) {
log.error( "Failed to find FML's handshake channel. Disabling mod.");
return;
}
if (contextConstructor == null) {
log.error("Failed to find FML's network event context constructor. Disabling mod.");
return;
}
try {
Object handshake = handshakeField.get(null);
if (handshake instanceof SimpleChannel) {
handshakeChannel = (SimpleChannel)handshake;
log.info("Registering forge reset packet.");
handshakeChannel.messageBuilder(ResetPacket.class, 98)
.loginIndex(ResetPacket::getLoginIndex, ResetPacket::setLoginIndex)
.decoder(ResetPacket::decode)
.encoder(ResetPacket::encode)
.consumer(HandshakeHandler.biConsumerFor(ResetPacket::handler))
.add();
log.info( "Registered forge reset packet successfully.");
}
}
catch (Exception e) {
log.error("Caught exception when attempting to utilize FML's handshake. Disabling mod. Exception: {}", e.getMessage());
}
});
}
private static Field fetchHandshakeChannel() {
try {
return ObfuscationReflectionHelper.findField(NetworkConstants.class, "handshakeChannel");
}
catch (Exception e) {
log.error("Exception occurred while accessing handshakeChannel: {}", e.getMessage(), e);
return null;
}
}
private static Constructor<NetworkEvent.Context> fetchNetworkEventContext() {
try {
return ObfuscationReflectionHelper.findConstructor(NetworkEvent.Context.class, Connection.class, NetworkDirection.class, int.class);
}
catch (Exception e) {
log.error("Exception occurred while accessing getLoginIndex: {}", e.getMessage(), e);
return null;
}
}
static {
handshakeField = fetchHandshakeChannel();
contextConstructor = fetchNetworkEventContext();
}
}

View File

@ -0,0 +1,47 @@
package com.leisuretimedock.crossmod.reset;
import lombok.extern.slf4j.Slf4j;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.screens.ConnectScreen;
import net.minecraft.client.gui.screens.GenericDirtMessageScreen;
import net.minecraft.client.multiplayer.ServerData;
import net.minecraft.client.multiplayer.resolver.ServerAddress;
import net.minecraft.network.chat.TextComponent;
import net.minecraft.network.chat.TranslatableComponent;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.network.NetworkEvent;
import net.minecraftforge.registries.GameData;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import static net.minecraft.ChatFormatting.BOLD;
@Slf4j
@OnlyIn(Dist.CLIENT)
public class ResetHelper {
public static boolean clearClient(NetworkEvent.Context context) {
CompletableFuture<Void> future = context.enqueueWork(() -> {
log.debug("Clearing");
Minecraft minecraft = Minecraft.getInstance();
ServerData serverData = minecraft.getCurrentServer();
if (minecraft.level == null) {
GameData.revertToFrozen();
}
minecraft.clearLevel(new GenericDirtMessageScreen(new TranslatableComponent("ltd.mod.client.negotiating").withStyle(BOLD)));
minecraft.setCurrentServer(serverData);
});
log.debug("Waiting for Clear to complete");
try {
future.get();
log.debug("Clear complete, continuing reset");
return true;
} catch (Exception e) {
log.error("Failed to clear client connection", e);
Objects.requireNonNull(Minecraft.getInstance().getConnection()).onDisconnect(new TranslatableComponent("ltd.mod.client.failed.reset_connection"));
return false;
}
}
}

View File

@ -0,0 +1,64 @@
package com.leisuretimedock.crossmod.reset;
import com.leisuretimedock.crossmod.mixin.AccessorMinecraft;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientHandshakePacketListenerImpl;
import net.minecraft.network.Connection;
import net.minecraft.network.ConnectionProtocol;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.chat.TranslatableComponent;
import net.minecraftforge.network.*;
import java.util.function.Supplier;
@Slf4j
@Setter
@Getter
public class ResetPacket extends HandshakeMessages.C2SAcknowledge {
private int loginIndex;
public ResetPacket() {
super();
}
public static ResetPacket decode(FriendlyByteBuf buf) {
return new ResetPacket();
}
public void encode(FriendlyByteBuf buf) {
}
public static void handler(HandshakeHandler handler , ResetPacket msg, Supplier<NetworkEvent.Context> ctxSupplier) {
NetworkEvent.Context ctx = ctxSupplier.get();
ClientResetManager.isNegotiating.set(true);
Connection conn = ctx.getNetworkManager();
if (ctx.getDirection() != NetworkDirection.LOGIN_TO_CLIENT && ctx.getDirection() != NetworkDirection.PLAY_TO_CLIENT) {
conn.disconnect(new TranslatableComponent("ltd.mod.client.invalid_packet"));
return;
}
if (ResetHelper.clearClient(ctx)) {
NetworkHooks.registerClientLoginChannel(conn);
conn.setProtocol(ConnectionProtocol.LOGIN);
conn.setListener(new ClientHandshakePacketListenerImpl(
conn, Minecraft.getInstance(), null, s -> {}
));
((AccessorMinecraft) Minecraft.getInstance()).setPendingConnection(conn);
try {
ClientResetManager.handshakeChannel.reply(
new HandshakeMessages.C2SAcknowledge(),
ClientResetManager.contextConstructor.newInstance(conn, NetworkDirection.LOGIN_TO_CLIENT, 98)
);
} catch (Exception e) {
log.error("Failed to send acknowledgment", e);
conn.disconnect(new TranslatableComponent("ltd.mod.client.error.handshake"));
}
}
ctx.setPacketHandled(true);
}
}

View File

@ -0,0 +1,57 @@
package com.leisuretimedock.crossmod.util;
import io.netty.buffer.ByteBuf;
import net.minecraft.network.FriendlyByteBuf;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
public class DebugUtils {
public static void debugBuffer(FriendlyByteBuf buf) {
int readable = buf.readableBytes();
System.out.println("[Debug] Readable bytes: " + readable);
if (readable <= 0) {
System.out.println("[Debug] No extra bytes to inspect.");
return;
}
// 保存当前位置
int index = buf.readerIndex();
// 读取并打印十六进制
byte[] bytes = new byte[readable];
buf.readBytes(bytes);
String hex = HexFormat.of().formatHex(bytes);
System.out.println("[Debug] Extra bytes (hex): " + hex);
// 尝试以 UTF-8 解码仅用于辅助分析
try {
String utf8 = new String(bytes, StandardCharsets.UTF_8);
System.out.println("[Debug] Interpreted as UTF-8 string:\n" + utf8);
} catch (Exception e) {
System.out.println("[Debug] Failed to interpret as UTF-8: " + e.getMessage());
}
// 还原读取位置避免影响其他逻辑
buf.readerIndex(index);
}
public static void debugFullBuffer(FriendlyByteBuf buf) {
ByteBuf internal = buf.copy(); // 复制整个缓冲区包括所有字节
int size = internal.readableBytes();
byte[] data = new byte[size];
internal.readBytes(data);
System.out.println("[Debug] Full buffer size: " + size);
System.out.println("[Debug] Hex dump:\n" + HexFormat.of().formatHex(data));
try {
String utf8 = new String(data, StandardCharsets.UTF_8);
System.out.println("[Debug] UTF-8 decoded:\n" + utf8);
} catch (Exception e) {
System.out.println("[Debug] UTF-8 decode failed: " + e.getMessage());
}
internal.release(); // 手动释放 copy() 出来的 ByteBuf防止泄漏
}
}

View File

@ -0,0 +1 @@
public net.minecraft.client.Minecraft pendingConnection #pendingConnection

View File

@ -1,4 +1,9 @@
{
"ltd.mod.client.name.trans_server": "LTD Cross Server Mod",
"ltd.mod.client.key": "LTD Key"
"ltd.mod.client.key": "Open LTD Cross Server Menu",
"ltd.mod.client.negotiating": "Negotiating...",
"ltd.mod.client.failed.reset_connection": "Failed to reset connection.",
"ltd.mod.client.error.handshake": "Handshake error",
"ltd.mod.client.invalid_reset_packet": "Invalid reset packet",
"ltd.mod.client.invalid_packet": "Invalid packet"
}

View File

@ -1,4 +1,10 @@
{
"ltd.mod.client.name.trans_server": "LTD跨服传送模组",
"ltd.mod.client.key": "LTD跨服传送按键"
"ltd.mod.client.key": "打开LTD跨服传送菜单",
"ltd.mod.client.negotiating": "重定向中 ...",
"ltd.mod.client.failed.reset_connection": "重置链接失败。",
"ltd.mod.client.error.handshake": "握手出错",
"ltd.mod.client.invalid_reset_packet": "无效的重置链接包",
"ltd.mod.client.invalid_packet": "无效的包"
}

View File

@ -0,0 +1,11 @@
{
"required": true,
"package": "com.leisuretimedock.crossmod.mixin",
"compatibilityLevel": "JAVA_17",
"mixins": [
"AccessorMinecraft",
"MixinMUINetWorkHandler",
"ModListSpoofMixin"
],
"minVersion": "0.8"
}

View File

@ -1,3 +1,3 @@
plugin_group=com.leisuretimedock.crossplugin
plugin_version=1.0.0.2
plugin_version=1.0.0.5
plugin_name=CrossServerTeleport

View File

@ -18,7 +18,7 @@ public class ReloadConfigCommand implements SimpleCommand {
private final ConfigManager configManager;
public static final String PERMISSION_RELOAD = "ltdcrossserver.reload";
public static final String PERMISSION_HELP = "ltdcrossserver.help";
public ReloadConfigCommand(ConfigManager configManager) {
this.configManager = configManager;
}

View File

@ -15,6 +15,7 @@ import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier;
import lombok.extern.slf4j.Slf4j;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
@ -29,9 +30,11 @@ import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
/**
* 插件消息监听器负责接收客户端发来的插件消息并处理跨服传送Overlay显示等逻辑
*/
@Slf4j
public class PluginMessageListener {
// 插件消息通道标识与客户端保持一致
@ -103,13 +106,13 @@ public class PluginMessageListener {
*/
private void handlePluginChannel(Player player, byte[] data) {
// 简单日志打印字节长度和十六进制便于调试
System.out.println("Received plugin message on channel 'channel' from player " + player.getUsername());
System.out.println("Data length: " + data.length);
log.trace("Received plugin message on channel 'channel' from player {}", player.getUsername());
log.trace("Data length: {}", data.length);
StringBuilder sb = new StringBuilder();
for (byte b : data) {
sb.append(String.format("%02X ", b));
}
System.out.println("Data hex: " + sb);
log.trace("Data hex: {}", sb);
try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(data))) {
String command = in.readUTF();
logger.debug("[CrossTeleportMod] Received plugin command from {}: {}", player.getUsername(), command);
@ -117,7 +120,14 @@ public class PluginMessageListener {
if ("client_ready".equals(command)) {
if (waitingForReady.remove(player)) {
logger.debug("[CrossTeleportMod] {} is ready, sending overlay", player.getUsername());
OverlayManager.showOverlay(player);
player.getCurrentServer().ifPresent(i -> {
String name = i.getServerInfo().getName();
boolean contains = configManager.getOverlayServers().contains(name);
if (contains) {
OverlayManager.showOverlay(player);
}
else OverlayManager.hideOverlay(player);
});
// TODO: 支持发送自定义服务器列表
} else {
logger.debug("[CrossTeleportMod] Received client_ready from {}, but not in waiting set", player.getUsername());
@ -171,13 +181,7 @@ public class PluginMessageListener {
logger.debug("[CrossTeleportMod] Player {} joined server {}", player.getUsername(), currentServer);
if (configManager.getOverlayServers().contains(currentServer)) {
waitingForReady.add(player);
logger.debug("[CrossTeleportMod] Added {} to waitingForReady set", player.getUsername());
} else {
OverlayManager.hideOverlay(player);
logger.debug("[CrossTeleportMod] Hiding overlay for {}", player.getUsername());
}
waitingForReady.add(player);
}
/**