Files
RustNotMC/game-fork/src-overlay/net/rustnotmc/client/net/RsxWorldBridge.java
T
sasheg dev 5b451c38da Мир перестаёт стучить при ходьбе
Статтеры были не на сервере, а в клиенте, и стоили дорого.

Измерено: перевод чанка в игровой формат шел через setBlockState, а он
каждый раз обновляет карту высот и карту света. Чанк стоил 10-156 мс на
главном потоке, их было 208, и переход через границу чанка упирался в
325 мс за один тик.

Сделано:

- Секции чанка пишутся прямо в формате игры. Палитра и упакованные
  индексы строятся игровым же классом, так что формат совпадает по
  определению, а заливка по блокам и карты высот и света не нужны.
  Чанк подешевел с 25 мс до 1-2 мс, это в 15-25 раз.
- Обработка входящих пакетов ограничена тремя миллисекундами на тик.
  Колонка чанков, пришедшая за один раз, теперь размазывается по тикам:
  не влезшее ждет следующего, это 0.2 секунды, и глазом не видно.
- Сгенерированный чанк больше не считается грязным. Террейн есть чистая
  функция от зерна и координат, он и так восстановится за пару
  миллисекунд, а писать его стоило 64 килобайта на чанк. Ходьба по миру
  больше не гоняет двадцать мегабайт по диску. На диск пишется только то,
  во что игрок построился.

Проверено: 169 чанков при заходе, медленных (больше 4 мс) три, остальные
быстрее; на диске ноль файлов, потому что игрок ничего не строил.
2026-10-19 20:36:42 +03:00

275 lines
11 KiB
Java

package net.rustnotmc.client.net;
import io.netty.buffer.Unpooled;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.UUID;
import net.minecraft.block.Block;
import net.minecraft.network.Packet;
import net.minecraft.network.PacketBuffer;
import net.minecraft.network.play.INetHandlerPlayClient;
import net.minecraft.network.play.server.SPacketBlockChange;
import net.minecraft.network.play.server.SPacketChunkData;
import net.minecraft.network.play.server.SPacketDestroyEntities;
import net.minecraft.network.play.server.SPacketEntityTeleport;
import net.minecraft.network.play.server.SPacketJoinGame;
import net.minecraft.network.play.server.SPacketPlayerListItem;
import net.minecraft.network.play.server.SPacketPlayerPosLook;
import net.minecraft.network.play.server.SPacketSpawnPlayer;
import net.minecraft.network.play.server.SPacketSpawnPosition;
import net.minecraft.util.math.BlockPos;
import net.minecraft.world.EnumDifficulty;
import net.minecraft.world.GameType;
import net.minecraft.world.World;
import net.minecraft.world.WorldType;
import net.minecraft.world.chunk.BlockStateContainer;
import net.rustnotmc.protocol.ChunkFormat;
import net.rustnotmc.protocol.Frame;
import net.rustnotmc.protocol.Packets;
/**
* Turns the server's packets into the game's own.
*
* The client already knows how to be a Minecraft client: it has a world, a
* player, entities and a renderer, and all of that works. The only thing it
* does not have is the server's packets. So instead of writing a second
* renderer and a second world, the server's packets are translated into the
* game's own and handed to the game, which does what it already knows how to
* do.
*
* That is the whole reason the wire format is ours: it lets the client keep
* the parts that are worth keeping.
*/
public final class RsxWorldBridge {
private RsxWorldBridge() {
}
/**
* The packets that put a client into a world.
*
* Three of them, in this order: the world is created, the spawn point is
* set, and the player is put on it. The player cannot be moved before the
* world exists, because the player belongs to the world.
*/
public static List<Packet<INetHandlerPlayClient>> join(int entityId, int maxPlayers,
double spawnX, double spawnY, double spawnZ) {
List<Packet<INetHandlerPlayClient>> packets =
new ArrayList<Packet<INetHandlerPlayClient>>();
packets.add(new SPacketJoinGame(entityId, GameType.SURVIVAL, false, 0,
EnumDifficulty.NORMAL, maxPlayers, WorldType.DEFAULT, false));
packets.add(new SPacketSpawnPosition(
new BlockPos((int) Math.floor(spawnX), (int) Math.floor(spawnY),
(int) Math.floor(spawnZ))));
packets.add(new SPacketPlayerPosLook(spawnX, spawnY, spawnZ, 0.0F, 0.0F,
EnumSet.noneOf(SPacketPlayerPosLook.EnumFlags.class), 0));
return packets;
}
/**
* One chunk, as the game wants it.
*
* The game reads a chunk as sixteen sections of sixteen by sixteen by
* sixteen, each with a palette of block states, a light array and a sky
* light array, and a biome for every column.
*
* The section data is written straight into the game's own format instead
* of being built by filling a game chunk block by block. Filling one takes
* sixteen thousand calls, and every one of them updates a height map and a
* light map: it cost the client a quarter of a second for every chunk it
* walked into, which is the difference between a world that streams and a
* world that stutters.
*
* The light is set to full. The server does not compute light yet, and a
* world with no light in it is a world nobody can see.
*/
public static SPacketChunkData chunk(World world, int chunkX, int chunkZ, byte[] blocks)
throws java.io.IOException {
boolean skyLight = world.provider.hasSkyLight();
PacketBuffer sections = new PacketBuffer(Unpooled.buffer(128 * 1024));
int mask = 0;
for (int section = 0; section < 16; section++) {
BlockStateContainer container = new BlockStateContainer();
boolean any = false;
int base = section * 16;
for (int y = 0; y < 16; y++) {
for (int z = 0; z < 16; z++) {
for (int x = 0; x < 16; x++) {
byte id = blocks[ChunkFormat.index(x, base + y, z)];
if (id == net.rustnotmc.protocol.Blocks.AIR) {
// the palette starts with air and the storage
// starts at zero, so air is already there
continue;
}
container.set(x, y, z, RsxBlockMap.stateFor(id));
any = true;
}
}
}
if (!any) {
continue;
}
mask |= 1 << section;
container.write(sections);
sections.writeBytes(LIGHT);
if (skyLight) {
sections.writeBytes(LIGHT);
}
}
// one biome per column, and the same one for now
sections.writeBytes(BIOMES);
byte[] data = new byte[sections.writerIndex()];
sections.readBytes(data);
PacketBuffer body = new PacketBuffer(Unpooled.buffer(data.length + 64));
body.writeInt(chunkX);
body.writeInt(chunkZ);
body.writeBoolean(true);
body.writeVarInt(mask);
body.writeVarInt(data.length);
body.writeBytes(data);
body.writeVarInt(0); // no tile entities
SPacketChunkData packet = new SPacketChunkData();
packet.readPacketData(body);
return packet;
}
/** A section's light, all of it on. */
private static final byte[] LIGHT = new byte[2048];
/** The biomes of a chunk: one byte per column. */
private static final byte[] BIOMES = new byte[256];
static {
java.util.Arrays.fill(LIGHT, (byte) 0xFF);
java.util.Arrays.fill(BIOMES, (byte) 1); // plains
}
/** One block, changed. */
public static SPacketBlockChange blockChange(int x, int y, int z, byte id) throws java.io.IOException {
// the game's packet has no constructor that takes the values, so the
// bytes are written the way the game writes them and read back by the
// game's own reader. One format, one implementation of it.
PacketBuffer buffer = new PacketBuffer(Unpooled.buffer());
buffer.writeBlockPos(new BlockPos(x, y, z));
buffer.writeVarInt(Block.BLOCK_STATE_IDS.get(RsxBlockMap.stateFor(id)));
SPacketBlockChange packet = new SPacketBlockChange();
packet.readPacketData(buffer);
return packet;
}
/** Somebody else is here. */
public static SPacketSpawnPlayer spawnPlayer(int entityId, String name,
double x, double y, double z,
float yaw, float pitch) throws java.io.IOException {
// the game's packet has no constructor that takes the values, so the
// bytes are written the way the game writes them and read back by the
// game's own reader. One format, one implementation of it.
PacketBuffer buffer = new PacketBuffer(Unpooled.buffer());
buffer.writeVarInt(entityId);
buffer.writeUniqueId(uuidFor(name));
buffer.writeDouble(x);
buffer.writeDouble(y);
buffer.writeDouble(z);
buffer.writeByte(angle(yaw));
buffer.writeByte(angle(pitch));
buffer.writeVarInt(0); // no entity data entries
SPacketSpawnPlayer packet = new SPacketSpawnPlayer();
packet.readPacketData(buffer);
return packet;
}
/** Somebody else moved. */
public static SPacketEntityTeleport entityMove(int entityId, double x, double y,
double z, float yaw, float pitch)
throws java.io.IOException {
PacketBuffer buffer = new PacketBuffer(Unpooled.buffer());
buffer.writeVarInt(entityId);
buffer.writeDouble(x);
buffer.writeDouble(y);
buffer.writeDouble(z);
buffer.writeByte(angle(yaw));
buffer.writeByte(angle(pitch));
buffer.writeBoolean(true);
SPacketEntityTeleport packet = new SPacketEntityTeleport();
packet.readPacketData(buffer);
return packet;
}
/** Somebody else has left. */
public static SPacketDestroyEntities entityRemove(int entityId) {
return new SPacketDestroyEntities(new int[]{entityId});
}
/**
* The entry that tells the game who a player is.
*
* The game looks a player up by their identity when one appears, and
* refuses to draw a player it has never heard of. The server sends a name
* and no identity, so the identity is derived the same way the game
* derives it for an offline account.
*/
public static SPacketPlayerListItem playerList(String name) throws java.io.IOException {
PacketBuffer buffer = new PacketBuffer(Unpooled.buffer());
buffer.writeEnumValue(SPacketPlayerListItem.Action.ADD_PLAYER);
buffer.writeVarInt(1);
buffer.writeUniqueId(uuidFor(name));
buffer.writeString(name);
buffer.writeVarInt(0); // no profile properties
buffer.writeVarInt(GameType.SURVIVAL.getID());
buffer.writeVarInt(0); // latency
buffer.writeBoolean(false); // no display name
SPacketPlayerListItem packet = new SPacketPlayerListItem();
packet.readPacketData(buffer);
return packet;
}
/** The identity an offline account gets. */
public static UUID uuidFor(String name) {
return UUID.nameUUIDFromBytes(
("OfflinePlayer:" + name).getBytes(StandardCharsets.UTF_8));
}
/** An angle in the eighths of a turn the game uses. */
private static byte angle(float degrees) {
return (byte) (int) (degrees * 256.0F / 360.0F);
}
/**
* The game's packet for one of ours, or null when we have no counterpart.
*
* A packet we cannot translate is dropped rather than guessed at.
*/
public static Packet<INetHandlerPlayClient> translate(Frame frame, World world)
throws java.io.IOException {
switch (frame.packetId()) {
case Packets.BLOCK_CHANGE: {
Packets.BlockChange change =
Packets.BlockChange.read(new net.rustnotmc.protocol.Buf(frame.body()));
return blockChange(change.x, change.y, change.z, change.blockId);
}
case Packets.ENTITY_MOVE: {
Packets.EntityMove move =
Packets.EntityMove.read(new net.rustnotmc.protocol.Buf(frame.body()));
return entityMove(move.entityId, move.x, move.y, move.z, move.yaw, move.pitch);
}
case Packets.ENTITY_REMOVE: {
Packets.EntityRemove remove =
Packets.EntityRemove.read(new net.rustnotmc.protocol.Buf(frame.body()));
return entityRemove(remove.entityId);
}
default:
return null;
}
}
}