Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ allprojects {
excludeFilter.set(file("${rootDir}/config/spotbugs/suppressions.xml"))
}

tasks.withType<SpotBugsTask>() {
tasks.withType<SpotBugsTask> {
reports.create("html") {
required.set(true)
outputLocation.set(layout.buildDirectory.file("reports/spotbugs/main/spotbugs.html"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
import net.elytrium.limboapi.protocol.packets.s2c.MapDataPacket;
import net.elytrium.limboapi.protocol.packets.s2c.PlayerAbilitiesPacket;
import net.elytrium.limboapi.protocol.packets.s2c.PositionRotationPacket;
import net.elytrium.limboapi.protocol.packets.s2c.SetEntityDataPacket;
import net.elytrium.limboapi.protocol.packets.s2c.SetExperiencePacket;
import net.elytrium.limboapi.protocol.packets.s2c.SetSlotPacket;
import net.elytrium.limboapi.protocol.packets.s2c.TimeUpdatePacket;
Expand Down Expand Up @@ -242,6 +243,12 @@ public static void init() throws Throwable {
createMapping(0x21, ProtocolVersion.MINECRAFT_1_21_5, true),
createMapping(0x25, ProtocolVersion.MINECRAFT_1_21_9, true)
);
register(LIMBO_STATE_REGISTRY, PacketDirection.CLIENTBOUND,
SetEntityDataPacket.class, SetEntityDataPacket::new,
// Paper sends the local player's own entity data on join; without it recent clients keep
// their default appearance (right hand, no outer skin layers).
createMapping(0x61, ProtocolVersion.MINECRAFT_1_21_11, true)
);
register(LIMBO_STATE_REGISTRY, PacketDirection.CLIENTBOUND,
DefaultSpawnPositionPacket.class, DefaultSpawnPositionPacket::new,
createMapping(0x05, ProtocolVersion.MINECRAFT_1_7_2, true),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Copyright (C) 2021 - 2025 Elytrium
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package net.elytrium.limboapi.protocol.packets.s2c;

import com.velocitypowered.api.network.ProtocolVersion;
import com.velocitypowered.proxy.connection.MinecraftSessionHandler;
import com.velocitypowered.proxy.protocol.MinecraftPacket;
import com.velocitypowered.proxy.protocol.ProtocolUtils;
import io.netty.buffer.ByteBuf;

/**
* Encode-only ClientboundSetEntityDataPacket. Each metadata entry is a pre-encoded byte sequence of
* {@code [byte index][varint serializer id][value]}; the packet is terminated by {@code 0xFF}.
*/
public class SetEntityDataPacket implements MinecraftPacket {

private final int entityId;
private final byte[][] entries;

public SetEntityDataPacket(int entityId, byte[][] entries) {
this.entityId = entityId;
this.entries = entries;
}

public SetEntityDataPacket() {
this(0, new byte[0][]);
throw new IllegalStateException();
}

@Override
public void decode(ByteBuf buf, ProtocolUtils.Direction direction, ProtocolVersion protocolVersion) {
throw new IllegalStateException();
}

@Override
public void encode(ByteBuf buf, ProtocolUtils.Direction direction, ProtocolVersion protocolVersion) {
ProtocolUtils.writeVarInt(buf, this.entityId);
for (byte[] entry : this.entries) {
buf.writeBytes(entry);
}
buf.writeByte(0xFF); // EOF marker.
}

@Override
public boolean handle(MinecraftSessionHandler handler) {
throw new IllegalStateException();
}
}
30 changes: 30 additions & 0 deletions plugin/src/main/java/net/elytrium/limboapi/server/LimboImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
import com.velocitypowered.proxy.protocol.VelocityConnectionEvent;
import com.velocitypowered.proxy.protocol.packet.AvailableCommandsPacket;
import com.velocitypowered.proxy.protocol.packet.BossBarPacket;
import com.velocitypowered.proxy.protocol.packet.ClientSettingsPacket;
import com.velocitypowered.proxy.protocol.packet.JoinGamePacket;
import com.velocitypowered.proxy.protocol.packet.LegacyPlayerListItemPacket;
import com.velocitypowered.proxy.protocol.packet.PluginMessagePacket;
Expand Down Expand Up @@ -109,6 +110,7 @@
import net.elytrium.limboapi.protocol.packets.s2c.ChunkDataPacket;
import net.elytrium.limboapi.protocol.packets.s2c.DefaultSpawnPositionPacket;
import net.elytrium.limboapi.protocol.packets.s2c.PositionRotationPacket;
import net.elytrium.limboapi.protocol.packets.s2c.SetEntityDataPacket;
import net.elytrium.limboapi.protocol.packets.s2c.TimeUpdatePacket;
import net.elytrium.limboapi.protocol.packets.s2c.UpdateViewPositionPacket;
import net.elytrium.limboapi.server.world.SimpleTagManager;
Expand Down Expand Up @@ -601,6 +603,13 @@ protected void onSpawn(Class<? extends LimboSessionHandler> handlerClass,

connection.delayedWrite(playerInfoPacket);

// Recent clients (1.21.11) expect the server to send the local player's own entity data
// (skin parts / main arm) on join; without it they keep the default appearance (right hand,
// no outer skin layers) and ignore the local skin options.
if (connection.getProtocolVersion() == ProtocolVersion.MINECRAFT_1_21_11) {
connection.delayedWrite(this.createSelfEntityDataPacket(sessionHandler.getSettings()));
}

connection.delayedWrite(this.getBrandMessage(handlerClass));

this.plugin.setLimboJoined(player);
Expand Down Expand Up @@ -1487,6 +1496,27 @@ private PositionRotationPacket createPlayerPosAndLook(double posX, double posY,
return new PositionRotationPacket(posX, posY, posZ, yaw, pitch, false, 44, true);
}

private SetEntityDataPacket createSelfEntityDataPacket(ClientSettingsPacket clientSettings) {
int skinParts = clientSettings == null ? 0x7F : clientSettings.getSkinParts();
// Player skin-parts metadata entry: index 16, BYTE serializer (id 0). Entity id 1 is the local
// player (see createJoinGamePacket). Recent clients show the outer skin layers only after the
// server sends the local player's own entity data.
byte[] skinPartsEntry = new byte[] {16, 0, (byte) skinParts};
// Player main-arm metadata entry: index 15, HUMANOID_ARM serializer (id 38). The value is the
// client main hand (0 = left, 1 = right), which matches the HumanoidArm enum ordinal.
int mainHand = clientSettings == null ? 1 : clientSettings.getMainHand();
byte[] mainArmEntry = new byte[] {15, 38, (byte) mainHand};
return new SetEntityDataPacket(1, new byte[][] {skinPartsEntry, mainArmEntry});
}

public void sendSelfEntityData(ConnectedPlayer player, ClientSettingsPacket clientSettings) {
MinecraftConnection connection = player.getConnection();
if (connection.getState() != StateRegistry.CONFIG
&& connection.getProtocolVersion() == ProtocolVersion.MINECRAFT_1_21_11) {
connection.write(this.createSelfEntityDataPacket(clientSettings));
}
}

private UpdateViewPositionPacket createUpdateViewPosition(int posX, int posZ) {
return new UpdateViewPositionPacket(posX >> 4, posZ >> 4);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,14 @@ public LimboSessionHandlerImpl(LimboAPI plugin, LimboImpl limbo, ConnectedPlayer
this.settings = sessionHandler.getSettings();
this.brand = sessionHandler.getBrand();
}

if (this.settings == null) {
// 1.20.2+ clients don't resend ClientInformation while switching states, so the limbo session
// never receives it. Fall back to the settings the client sent at login (stored on the player
// by Velocity's config handler), otherwise main hand / skin parts default to right-hand and
// no layers.
this.settings = this.player.getClientSettingsPacket();
}
}

public void onConfig(LimboPlayer player) {
Expand All @@ -141,6 +149,12 @@ public void onConfig(LimboPlayer player) {
return;
}

// The generic/unknown packet counter is a flood guard, so it should measure traffic inside a
// single keepalive interval instead of accumulating for the whole session: otherwise a normal
// idle client (periodic keepalives, acknowledgements, ...) is eventually kicked for a "too big
// packet" after a few minutes.
this.genericBytes = 0;

if (this.keepAlivePending) {
if (++this.keepAlivesSkipped == 2) {
connection.closeWith(this.plugin.getPackets().getTimeOut(this.player.getConnection().getState()));
Expand Down Expand Up @@ -350,6 +364,11 @@ public void handleUnknown(ByteBuf packet) {
public void handleGeneric(MinecraftPacket packet) {
if (packet instanceof ClientSettingsPacket clientSettings) {
this.settings = clientSettings;
// Mirror the original Velocity behavior: keep the player-level settings (main hand, skin
// parts, ...) in sync while the player is inside the limbo.
this.player.setClientSettings(clientSettings);
// Re-send the local player's own entity data so skin layers / main hand changes apply live.
this.limbo.sendSelfEntityData(this.player, clientSettings);
} else if (packet instanceof PlayerChatSessionPacket) {
if (this.chatSessionTimeoutTask != null) {
this.chatSessionTimeoutTask.cancel(true);
Expand Down