RegionIO/internal/network/play.go
Master290 57214fbd76 Run the world clock so day and night happen
set_time was declared nowhere and sent never, so the client's sky was frozen
wherever it started: the sun did not move, night did not fall, and nothing in
the world had a time.

26.1.2 replaced the old (gameTime, dayTime, doDaylightCycle) triple with a
registry of named clocks. The packet now carries a fixed-width game time plus a
map from world clock to (totalTicks, partialTick, rate); the client advances
each clock locally at its rate and drives the minecraft:day timeline -- a
keyframe track over a 24000-tick period -- from the overworld clock. The clock
registry is already among the 28 we sync, so the id is looked up rather than
hardcoded and the server refuses to start if it is missing.

The counter rides the entity tick loop because that is the only loop already
running at 20 TPS. It belongs on the single authoritative tick the engine still
needs; putting a sixth ticker beside the five that exist would make that worse.
Broadcast every second, which is what vanilla does -- the client interpolates in
between, so the resend only corrects drift.

The clock also persists now. The world metadata file was written once and never
touched again; it is atomically rewritable, carries gameTime and dayTime, and a
file written before those fields existed still opens and resumes at dawn as it
did. Saved every 30 seconds alongside the chunk autosave, and once more on
shutdown after the final flush.
2026-07-27 02:45:28 +03:00

698 lines
20 KiB
Go

package network
import (
"errors"
"math"
"time"
"regionio/internal/nbt"
"regionio/internal/protocol"
"regionio/internal/registry"
"regionio/internal/server"
"regionio/internal/world"
)
// Spawn column. The feet-level Y is resolved from the generated surface when
// the player enters the play phase.
const (
spawnX = 8.5
spawnZ = 8.5
)
// beginPlay sends the join sequence once the client enters the Play phase and
// hands chunk streaming off to the background streamer. The streamer stops when
// h.ctx (the connection lifetime context) is cancelled.
func (h *handler) beginPlay() error {
session, err := h.srv.RegisterPlayer(h.conn.Profile, h.conn.Send)
if err != nil {
return err
}
h.session = session
spawnY, ok := h.srv.Chunks().SafeSpawnY(int(math.Floor(spawnX)), int(math.Floor(spawnZ)))
if !ok {
spawnY = world.SeaLevel + 1
}
h.spawnY = float64(spawnY)
h.srv.SetPlayerTransform(session, spawnX, h.spawnY, spawnZ, 0, 0, true)
h.srv.SetPlayerViewDistance(session, h.visibilityRadius())
for i := range h.hotbar {
h.hotbar[i] = -1 // empty
}
if err := h.sendPlayLogin(); err != nil {
return err
}
// "Start waiting for level chunks": tells the client to show the loading
// screen until chunks arrive.
if err := h.sendGameEvent(protocol.GameEventStartWaitingChunks, 0); err != nil {
return err
}
if err := h.sendPlayerPosition(1); err != nil {
return err
}
if err := h.sendChunkCacheCenter(0, 0); err != nil {
return err
}
if err := h.sendDefaultSpawnPosition(); err != nil {
return err
}
if err := h.sendPlayerAbilities(); err != nil {
return err
}
// The client's sky stays where it started until it is told the time.
if err := h.conn.Send(protocol.PlaySetTime, h.srv.SetTimePacket()); err != nil {
return err
}
// Launch the background chunk streamer. It owns generation + sending so the
// read loop stays free; requestRecenter is a non-blocking push.
h.streamer = newStreamer(h.srv.Chunks(), h.conn, h.log, h.visibilityRadius())
go h.streamer.run(h.ctx)
h.streamer.requestRecenter(0, 0)
go h.keepAliveLoop()
go h.entitySyncLoop()
return nil
}
func (h *handler) sendChunkCacheCenter(cx, cz int32) error {
w := protocol.NewWriter(8)
w.VarInt(cx)
w.VarInt(cz)
return h.conn.SendWriter(protocol.PlayChunkCacheCenter, w)
}
func (h *handler) sendDefaultSpawnPosition() error {
// 26.1.2 wraps the spawn in LevelData.RespawnData: GlobalPos followed by
// yaw and pitch. GlobalPos starts with the dimension resource key.
w := protocol.NewWriter(40)
w.String("minecraft:overworld")
w.Position(8, int(math.Floor(h.spawnY)), 8)
w.Float32(0.0)
w.Float32(0.0)
return h.conn.SendWriter(protocol.PlayDefaultSpawnPos, w)
}
func (h *handler) sendPlayerAbilities() error {
w := protocol.NewWriter(9)
w.Byte(0x0F)
w.Float32(0.05)
w.Float32(0.1)
return h.conn.SendWriter(protocol.PlayAbilities, w)
}
// onPlayerMove recenters the streamer when the player crosses into a new chunk.
// It is a non-blocking push; the read loop never waits on generation.
func (h *handler) onPlayerMove(x, y, z float64, yaw, pitch float32, onGround bool) error {
if math.IsNaN(x) || math.IsNaN(y) || math.IsNaN(z) ||
math.IsInf(x, 0) || math.IsInf(y, 0) || math.IsInf(z, 0) ||
math.IsNaN(float64(yaw)) || math.IsNaN(float64(pitch)) ||
math.IsInf(float64(yaw), 0) || math.IsInf(float64(pitch), 0) {
return errors.New("invalid player position")
}
h.srv.SetPlayerTransform(h.session, x, y, z, yaw, pitch, onGround)
cx := int32(int64(math.Floor(x)) >> 4)
cz := int32(int64(math.Floor(z)) >> 4)
if h.streamer != nil {
h.streamer.requestRecenter(cx, cz)
}
return nil
}
func (h *handler) visibilityRadius() int {
distance := h.viewDistance
if distance < 2 {
distance = defaultViewRadius
}
if max := h.srv.Config().MaxViewDistance; distance > max {
distance = max
}
return distance
}
// sendPlayLogin writes the clientbound play "login" packet. Field layout was
// confirmed against the 26.1.2 vanilla server capture.
func (h *handler) sendPlayLogin() error {
dimTypeIdx := registry.Index("minecraft:dimension_type", "minecraft:overworld")
if dimTypeIdx < 0 {
dimTypeIdx = 0
}
w := protocol.NewWriter(128)
entityID := int32(1)
if h.session != nil {
entityID = h.session.EntityID
}
w.Int32(entityID) // entity ID
w.Bool(false) // is hardcore
// Dimension names: the worlds available on this server.
dims := []string{"minecraft:overworld", "minecraft:the_end", "minecraft:the_nether"}
w.VarInt(int32(len(dims)))
for _, d := range dims {
w.String(d)
}
w.VarInt(int32(h.srv.Config().MaxPlayers)) // max players (legacy)
w.VarInt(int32(h.visibilityRadius())) // view distance
w.VarInt(int32(h.visibilityRadius())) // simulation distance
w.Bool(false) // reduced debug info
w.Bool(true) // enable respawn screen
w.Bool(false) // do limited crafting
w.VarInt(int32(dimTypeIdx)) // dimension type (registry index)
w.String("minecraft:overworld") // dimension name (this world)
w.Int64(0) // hashed seed
w.Byte(1) // game mode: creative (instant break, creative inventory)
w.Byte(0xFF) // previous game mode: -1 (none)
w.Bool(false) // is debug
w.Bool(false) // is flat
w.Bool(false) // has death location
w.VarInt(0) // portal cooldown
w.VarInt(63) // sea level (overworld)
w.Bool(false) // enforces secure chat
return h.conn.SendWriter(protocol.PlayLogin, w)
}
// sendGameEvent writes a game_event packet (event id + float value).
func (h *handler) sendGameEvent(event byte, value float32) error {
w := protocol.NewWriter(5)
w.Byte(event)
w.Float32(value)
return h.conn.SendWriter(protocol.PlayGameEvent, w)
}
// sendPlayerPosition teleports the player to spawn. The client must echo the
// teleport ID back via accept_teleportation.
func (h *handler) sendPlayerPosition(teleportID int32) error {
w := protocol.NewWriter(64)
w.VarInt(teleportID)
w.Float64(spawnX).Float64(h.spawnY).Float64(spawnZ) // position
w.Float64(0).Float64(0).Float64(0) // velocity
w.Float32(0) // yaw
w.Float32(0) // pitch
w.Int32(0) // relative flags
return h.conn.SendWriter(protocol.PlayPlayerPosition, w)
}
// keepAliveLoop sends a keep-alive every 15 seconds. It exits as soon as a send
// fails, which happens when the connection closes.
func (h *handler) keepAliveLoop() {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-h.ctx.Done():
return
case <-ticker.C:
id := time.Now().UnixMilli()
w := protocol.NewWriter(8)
w.Int64(id)
if err := h.conn.SendWriter(protocol.PlayKeepAliveCB, w); err != nil {
return
}
}
}
}
// visibleEntity is the comparable state retained by one client's visibility
// tracker. OnGround is separate because mobs currently always use true.
type visibleEntity struct {
entity world.Entity
onGround bool
}
// entitySyncLoop maintains tab-list membership and chunk-scoped entity state.
func (h *handler) entitySyncLoop() {
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-h.ctx.Done():
return
case <-ticker.C:
if err := h.syncVisibleEntities(); err != nil {
return
}
}
}
}
// syncVisibleEntities performs one deterministic visibility pass. Keeping it
// separate from the ticker makes the four-client workflow integration-testable.
func (h *handler) syncVisibleEntities() error {
if h.session == nil {
return nil
}
if h.knownPlayers == nil {
h.knownPlayers = make(map[[16]byte]bool)
}
if h.knownEntities == nil {
h.knownEntities = make(map[int32]visibleEntity)
}
viewer := h.session.Snapshot()
players := h.srv.PlayerSnapshots()
currentPlayers := make(map[[16]byte]bool, len(players))
for _, player := range players {
currentPlayers[player.Profile.UUID] = true
if !h.knownPlayers[player.Profile.UUID] {
if err := h.sendPlayerInfoAdd(player.Profile); err != nil {
return err
}
h.knownPlayers[player.Profile.UUID] = true
}
}
currentEntities := make(map[int32]visibleEntity)
for _, player := range players {
if player.EntityID == viewer.EntityID || !playerVisible(viewer, player, h.visibilityRadius()) ||
!h.entityChunkResident(player.X, player.Z) {
continue
}
currentEntities[player.EntityID] = visibleEntity{
entity: world.Entity{
ID: player.EntityID, UUID: player.Profile.UUID,
TypeID: registry.EntityTypeIndex("minecraft:player"), TypeName: "minecraft:player",
X: player.X, Y: player.Y, Z: player.Z,
Yaw: player.Yaw, Pitch: player.Pitch, HeadYaw: player.Yaw,
},
onGround: player.OnGround,
}
}
for _, entity := range h.srv.Entities().All() {
if entityVisible(viewer, entity, h.visibilityRadius()) && h.entityChunkResident(entity.X, entity.Z) {
currentEntities[entity.ID] = visibleEntity{entity: entity, onGround: true}
}
}
for id, current := range currentEntities {
known, exists := h.knownEntities[id]
if !exists {
entity := current.entity
if err := h.sendAddEntity(&entity); err != nil {
return err
}
} else if known != current {
entity := current.entity
if err := h.sendEntityTeleportState(&entity, current.onGround); err != nil {
return err
}
}
}
for id := range h.knownEntities {
if _, visible := currentEntities[id]; !visible {
if err := h.sendRemoveEntity(id); err != nil {
return err
}
}
}
h.knownEntities = currentEntities
for uuid := range h.knownPlayers {
if !currentPlayers[uuid] {
if err := h.sendPlayerInfoRemove(uuid); err != nil {
return err
}
delete(h.knownPlayers, uuid)
}
}
return nil
}
func (h *handler) entityChunkResident(x, z float64) bool {
if h.streamer == nil {
return true
}
cx := int32(int64(math.Floor(x)) >> 4)
cz := int32(int64(math.Floor(z)) >> 4)
return h.streamer.isResident(cx, cz)
}
func playerVisible(viewer, target server.PlayerSnapshot, radius int) bool {
return chunksWithin(viewer.X, viewer.Z, target.X, target.Z, radius)
}
func entityVisible(viewer server.PlayerSnapshot, target world.Entity, radius int) bool {
return chunksWithin(viewer.X, viewer.Z, target.X, target.Z, radius)
}
func chunksWithin(ax, az, bx, bz float64, radius int) bool {
acx := int32(int64(math.Floor(ax)) >> 4)
acz := int32(int64(math.Floor(az)) >> 4)
bcx := int32(int64(math.Floor(bx)) >> 4)
bcz := int32(int64(math.Floor(bz)) >> 4)
dx := acx - bcx
if dx < 0 {
dx = -dx
}
dz := acz - bcz
if dz < 0 {
dz = -dz
}
return dx <= int32(radius) && dz <= int32(radius)
}
func (h *handler) sendPlayerInfoAdd(profile server.Profile) error {
w := protocol.NewWriter(64)
w.Byte(0xff) // All eight initialization actions, fixed 8-bit EnumSet.
w.VarInt(1)
w.UUID(profile.UUID)
w.String(profile.Name)
w.VarInt(0) // profile properties
w.Bool(false) // no signed chat session
w.VarInt(1) // creative game mode
w.Bool(true) // listed
w.VarInt(0) // latency
w.Bool(false) // no custom display name
w.VarInt(0) // list order
w.Bool(true) // show hat
return h.conn.SendWriter(protocol.PlayPlayerInfoUpdate, w)
}
func (h *handler) sendPlayerInfoRemove(uuid [16]byte) error {
w := protocol.NewWriter(20)
w.VarInt(1)
w.UUID(uuid)
return h.conn.SendWriter(protocol.PlayPlayerInfoRemove, w)
}
// sendAddEntity sends the minecraft:add_entity packet.
func (h *handler) sendAddEntity(e *world.Entity) error {
w := protocol.NewWriter(64)
w.VarInt(e.ID)
w.UUID(e.UUID)
w.VarInt(int32(e.TypeID))
w.Float64(e.X).Float64(e.Y).Float64(e.Z)
w.LPVec3(
float64(e.VelocityX)/8000.0,
float64(e.VelocityY)/8000.0,
float64(e.VelocityZ)/8000.0,
)
w.Byte(byte(e.Pitch * 256.0 / 360.0))
w.Byte(byte(e.Yaw * 256.0 / 360.0))
w.Byte(byte(e.HeadYaw * 256.0 / 360.0))
w.VarInt(0) // Data
return h.conn.SendWriter(protocol.PlayAddEntity, w)
}
func (h *handler) sendEntityTeleport(e *world.Entity) error {
return h.sendEntityTeleportState(e, true)
}
func (h *handler) sendEntityTeleportState(e *world.Entity, onGround bool) error {
w := protocol.NewWriter(64)
w.VarInt(e.ID)
// PositionMoveRotation: position, deltaMovement, yRot, xRot.
w.Float64(e.X).Float64(e.Y).Float64(e.Z)
w.Float64(float64(e.VelocityX) / 8000.0)
w.Float64(float64(e.VelocityY) / 8000.0)
w.Float64(float64(e.VelocityZ) / 8000.0)
w.Float32(e.Yaw)
w.Float32(e.Pitch)
w.Int32(0) // Relative.SET_STREAM_CODEC uses ByteBufCodecs.INT; no relative flags.
w.Bool(onGround)
return h.conn.SendWriter(protocol.PlayTeleportEntity, w)
}
func (h *handler) sendRemoveEntity(id int32) error {
w := protocol.NewWriter(16)
w.VarInt(1) // count
w.VarInt(id)
return h.conn.SendWriter(protocol.PlayRemoveEntities, w)
}
// handlePlay dispatches serverbound play packets. Most are tolerated for now;
// teleport and keep-alive are acknowledged/logged.
func (h *handler) handlePlay(pkt protocol.Packet) error {
switch pkt.ID {
case protocol.PlayAcceptTeleport:
id, err := pkt.Body().VarInt()
if err != nil {
return err
}
h.log.Debug("teleport confirmed", "id", id)
return nil
case protocol.PlayKeepAliveServer:
// A response to our keep-alive; presence is enough for liveness.
h.log.Debug("keep-alive ack")
return nil
case protocol.PlayPlayerLoaded:
h.log.Info("player loaded into world", "name", h.conn.Profile.Name)
return nil
case protocol.PlayMovePos, protocol.PlayMovePosRot:
r := pkt.Body()
x, err := r.Float64()
if err != nil {
return err
}
y, err := r.Float64() // feet Y
if err != nil {
return err
}
z, err := r.Float64()
if err != nil {
return err
}
snapshot := h.session.Snapshot()
yaw, pitch := snapshot.Yaw, snapshot.Pitch
if pkt.ID == protocol.PlayMovePosRot {
yaw, err = r.Float32()
if err != nil {
return err
}
pitch, err = r.Float32()
if err != nil {
return err
}
}
flags, err := r.ReadByte()
if err != nil {
return err
}
return h.onPlayerMove(x, y, z, yaw, pitch, flags&1 != 0)
case protocol.PlayMoveRot:
r := pkt.Body()
yaw, err := r.Float32()
if err != nil {
return err
}
pitch, err := r.Float32()
if err != nil {
return err
}
flags, err := r.ReadByte()
if err != nil {
return err
}
snapshot := h.session.Snapshot()
return h.onPlayerMove(snapshot.X, snapshot.Y, snapshot.Z, yaw, pitch, flags&1 != 0)
case protocol.PlayMoveStatusOnly:
flags, err := pkt.Body().ReadByte()
if err != nil {
return err
}
snapshot := h.session.Snapshot()
return h.onPlayerMove(snapshot.X, snapshot.Y, snapshot.Z, snapshot.Yaw, snapshot.Pitch, flags&1 != 0)
case protocol.PlayPlayerAction:
return h.handlePlayerAction(pkt)
case protocol.PlayChatMessage:
return h.handleChat(pkt)
case protocol.PlayUseItemOn:
return h.handleUseItemOn(pkt)
case protocol.PlaySetCarriedItem:
slot, err := pkt.Body().Uint16()
if err != nil {
return err
}
if slot < 9 {
h.heldSlot = int32(slot)
}
return nil
case protocol.PlaySetCreativeSlot:
return h.handleCreativeSlot(pkt)
default:
h.log.Debug("play packet ignored", "id", pkt.ID)
return nil
}
}
// handleChat reads a chat message (only the leading text field is needed) and
// echoes it to the player as a system message prefixed with their name. Once a
// player registry exists this will broadcast to everyone.
func (h *handler) handleChat(pkt protocol.Packet) error {
msg, err := pkt.Body().String()
if err != nil {
return err
}
line := "<" + h.conn.Profile.Name + "> " + msg
h.log.Info("chat", "msg", line)
return h.broadcastSystemChat(line)
}
func (h *handler) broadcastSystemChat(text string) error {
w := protocol.NewWriter(len(text) + 8)
w.Raw(nbt.Marshal(nbt.String(text)))
w.Bool(false)
h.srv.Broadcast(protocol.PlaySystemChat, w.Bytes())
return nil
}
// handlePlayerAction processes digging. In creative the client sends
// START_DESTROY_BLOCK (status 0) for an instant break; survival also sends
// STOP/FINISH (status 2). Either way we clear the block, push a block_update,
// and acknowledge the sequence so the client keeps its predicted change.
func (h *handler) handlePlayerAction(pkt protocol.Packet) error {
r := pkt.Body()
status, err := r.VarInt()
if err != nil {
return err
}
x, y, z, err := r.Position()
if err != nil {
return err
}
if _, err := r.ReadByte(); err != nil { // face
return err
}
seq, err := r.VarInt()
if err != nil {
return err
}
const startDig, finishDig = 0, 2
if status == startDig || status == finishDig {
if valid, lightChunks := h.srv.Chunks().SetBlockWithLight(x, y, z, world.StateAir); valid {
h.broadcastBlockUpdate(x, y, z, world.StateAir, lightChunks)
h.log.Debug("block broken", "x", x, "y", y, "z", z)
}
}
return h.sendBlockChangedAck(seq)
}
// hotbarInvStart is the inventory slot index of hotbar slot 0.
const hotbarInvStart = 36
// handleCreativeSlot records the item a creative player placed into a slot so we
// know what block to place. The packet is: Short slot, then an item stack
// (VarInt count; if non-empty, VarInt item id followed by components we ignore).
func (h *handler) handleCreativeSlot(pkt protocol.Packet) error {
r := pkt.Body()
slot, err := r.Uint16()
if err != nil {
return err
}
hotbarIdx := int(slot) - hotbarInvStart
if hotbarIdx < 0 || hotbarIdx >= len(h.hotbar) {
return nil // not a hotbar slot; ignored
}
count, err := r.VarInt()
if err != nil {
return err
}
if count <= 0 {
h.hotbar[hotbarIdx] = -1 // emptied
return nil
}
itemID, err := r.VarInt()
if err != nil {
return err
}
h.hotbar[hotbarIdx] = itemID // remaining component data is not needed
return nil
}
// faceOffsets maps a Direction (block face) to the unit offset of the block
// placed against it: DOWN, UP, NORTH, SOUTH, WEST, EAST.
var faceOffsets = [6][3]int{
{0, -1, 0}, {0, 1, 0}, {0, 0, -1}, {0, 0, 1}, {-1, 0, 0}, {1, 0, 0},
}
// handleUseItemOn places the held block against the clicked face. Layout
// (captured from the client): Hand, Position, Face, cursor XYZ floats,
// insideBlock bool, worldBorderHit bool, sequence.
func (h *handler) handleUseItemOn(pkt protocol.Packet) error {
r := pkt.Body()
if _, err := r.VarInt(); err != nil { // hand
return err
}
x, y, z, err := r.Position()
if err != nil {
return err
}
face, err := r.VarInt()
if err != nil {
return err
}
// Skip cursor (3 floats) + insideBlock + worldBorderHit, then read sequence.
for i := 0; i < 3; i++ {
if _, err := r.Float32(); err != nil {
return err
}
}
if _, err := r.Bool(); err != nil {
return err
}
if _, err := r.Bool(); err != nil {
return err
}
seq, err := r.VarInt()
if err != nil {
return err
}
if face >= 0 && int(face) < len(faceOffsets) {
if state, ok := h.heldBlock(); ok {
off := faceOffsets[face]
px, py, pz := x+off[0], y+off[1], z+off[2]
if valid, lightChunks := h.srv.Chunks().SetBlockWithLight(px, py, pz, state); valid {
h.broadcastBlockUpdate(px, py, pz, state, lightChunks)
h.log.Debug("block placed", "x", px, "y", py, "z", pz, "state", state)
}
}
}
return h.sendBlockChangedAck(seq)
}
// heldBlock returns the block state for the currently held item, if it is a
// placeable block.
func (h *handler) heldBlock() (uint16, bool) {
itemID := h.hotbar[h.heldSlot]
if itemID < 0 {
return 0, false
}
return world.ItemToBlock(itemID)
}
func (h *handler) broadcastBlockUpdate(x, y, z int, state uint16, lightChunks []world.ChunkPos) {
w := protocol.NewWriter(12)
w.Position(x, y, z)
w.VarInt(int32(state))
cx := int32(x >> 4)
cz := int32(z >> 4)
h.srv.BroadcastChunk(cx, cz, protocol.PlayBlockUpdate, w.Bytes())
for _, chunk := range lightChunks {
if light, err := h.srv.Chunks().LightUpdate(chunk.X, chunk.Z); err == nil {
h.srv.BroadcastChunk(chunk.X, chunk.Z, protocol.PlayLightUpdate, light)
}
}
}
// sendBlockChangedAck confirms a block-action sequence so the client does not
// roll back its predicted change.
func (h *handler) sendBlockChangedAck(sequence int32) error {
w := protocol.NewWriter(4)
w.VarInt(sequence)
return h.conn.SendWriter(protocol.PlayBlockChangedAck, w)
}