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.
This commit is contained in:
Master290 2026-07-27 02:45:28 +03:00
parent 4db7638377
commit 57214fbd76
9 changed files with 386 additions and 15 deletions

View file

@ -51,7 +51,7 @@ func main() {
saveCtx, saveStop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
autosaveDone := srv.Chunks().StartAutosave(saveCtx, log, 30*time.Second)
srv.StartSpawning(saveCtx)
srv.StartSpawning(saveCtx, log)
ln := network.NewListener(srv, log)
@ -64,6 +64,9 @@ func main() {
}
saveStop()
<-autosaveDone
if err := srv.SaveWorldTime(); err != nil {
log.Error("saving world clock", "err", err)
}
if srv.Store() != nil {
srv.Store().Close()
}

View file

@ -58,6 +58,10 @@ func (h *handler) beginPlay() error {
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())

View file

@ -84,6 +84,7 @@ const (
PlayLightUpdate = 0x30
PlayPlayerInfoRemove = 0x45
PlayPlayerInfoUpdate = 0x46
PlaySetTime = 0x71
// Entity packets
PlayAddEntity = 0x01

90
internal/server/clock.go Normal file
View file

@ -0,0 +1,90 @@
package server
import (
"sync/atomic"
"regionio/internal/protocol"
"regionio/internal/registry"
)
// clock.go drives the world clock. Without it the client's sky is frozen: it
// never receives a time, so the sun sits wherever it started and night never
// falls.
//
// 26.1.2 replaced the old (gameTime, dayTime, doDaylightCycle) triple with a
// registry of named clocks. set_time carries the world's game time plus a map
// of 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.
// worldClockOverworld is the network ID the client assigns
// minecraft:overworld in the synced minecraft:world_clock registry. Looked up
// rather than hardcoded so it cannot drift from the registry we actually send.
var worldClockOverworld = int32(registry.Index("minecraft:world_clock", "minecraft:overworld"))
func init() {
if worldClockOverworld < 0 {
panic("server: minecraft:overworld missing from the synced minecraft:world_clock registry")
}
}
const (
// TicksPerDay is period_ticks from data/minecraft/timeline/day.json.
TicksPerDay = 24000
// timeSyncTicks is how often the clock is rebroadcast. The client
// interpolates in between, so this only has to correct drift; vanilla
// resends on the same one-second cadence.
timeSyncTicks = 20
// clockRateNormal is the client-side advance rate, in ticks per tick.
clockRateNormal = 1.0
// timePersistTicks is how often the clock is written back to the world
// metadata: every 30 seconds, matching the chunk autosave interval.
timePersistTicks = 600
)
// worldClock is the tick counter behind the sky. gameTime counts every tick the
// world has ever run; dayTime is what the sky is drawn from, and vanilla lets
// the two diverge (a time command moves one and not the other).
type worldClock struct {
gameTime atomic.Int64
dayTime atomic.Int64
}
// WorldTime returns the current game time and time of day.
func (s *Server) WorldTime() (gameTime, dayTime int64) {
return s.clock.gameTime.Load(), s.clock.dayTime.Load()
}
// SetWorldTime replaces both counters, for restoring a saved world.
func (s *Server) SetWorldTime(gameTime, dayTime int64) {
s.clock.gameTime.Store(gameTime)
s.clock.dayTime.Store(dayTime)
}
// advanceWorldTime moves the clock on by one tick and returns the new values.
func (s *Server) advanceWorldTime() (gameTime, dayTime int64) {
return s.clock.gameTime.Add(1), s.clock.dayTime.Add(1)
}
// SetTimePacket encodes a set_time body for the current clock.
func (s *Server) SetTimePacket() []byte {
gameTime, dayTime := s.WorldTime()
return encodeSetTime(gameTime, dayTime)
}
// encodeSetTime writes ClientboundSetTimePacket: a fixed-width game time, then
// a map from clock to clock state. Only the overworld clock is sent — it is the
// only dimension the server has.
func encodeSetTime(gameTime, dayTime int64) []byte {
w := protocol.NewWriter(24)
w.Int64(gameTime)
w.VarInt(1) // one entry in the clock map
w.VarInt(worldClockOverworld)
w.VarLong(dayTime)
w.Float32(0) // partialTick: we are exactly on a tick boundary
w.Float32(clockRateNormal) // rate: the daylight cycle always runs
return w.Bytes()
}

View file

@ -0,0 +1,117 @@
package server
import (
"bytes"
"context"
"encoding/binary"
"sync"
"testing"
"time"
"regionio/internal/protocol"
"regionio/internal/world"
)
// TestEncodeSetTime pins the wire layout of ClientboundSetTimePacket. 26.1.2
// replaced the old three-field packet with a game time plus a map of world
// clock to clock state, so there is no older shape to fall back on if this
// drifts — the client simply reads garbage.
func TestEncodeSetTime(t *testing.T) {
body := encodeSetTime(0x0102030405060708, 300)
var want bytes.Buffer
// gameTime is a fixed-width long, not a VarLong.
binary.Write(&want, binary.BigEndian, int64(0x0102030405060708))
want.WriteByte(1) // map size, VarInt
want.WriteByte(byte(worldClockOverworld))
want.Write([]byte{0xac, 0x02}) // dayTime 300 as a VarLong
binary.Write(&want, binary.BigEndian, float32(0)) // partialTick
binary.Write(&want, binary.BigEndian, float32(1)) // rate
if !bytes.Equal(body, want.Bytes()) {
t.Errorf("encodeSetTime =\n%x\nwant\n%x", body, want.Bytes())
}
}
// TestWorldClockAdvances checks the counters move together and that the sync
// cadence lands on whole seconds.
func TestWorldClockAdvances(t *testing.T) {
s := &Server{}
if gameTime, dayTime := s.WorldTime(); gameTime != 0 || dayTime != 0 {
t.Fatalf("a fresh world starts at %d/%d, want 0/0", gameTime, dayTime)
}
syncs := 0
for i := 0; i < TicksPerDay; i++ {
gameTime, dayTime := s.advanceWorldTime()
if gameTime != dayTime {
t.Fatalf("tick %d: gameTime %d and dayTime %d diverged with no command to move them", i, gameTime, dayTime)
}
if gameTime%timeSyncTicks == 0 {
syncs++
}
}
if want := TicksPerDay / timeSyncTicks; syncs != want {
t.Errorf("%d clock broadcasts over a full day, want %d", syncs, want)
}
gameTime, _ := s.WorldTime()
if gameTime != TicksPerDay {
t.Errorf("after a full day the clock reads %d, want %d", gameTime, TicksPerDay)
}
s.SetWorldTime(500, 18000)
if gameTime, dayTime := s.WorldTime(); gameTime != 500 || dayTime != 18000 {
t.Errorf("SetWorldTime gave %d/%d, want 500/18000", gameTime, dayTime)
}
}
// TestTickLoopBroadcastsTime drives the real 20 TPS loop and checks the clock
// actually reaches a connected player. The encoding test above cannot catch a
// clock that never gets sent.
func TestTickLoopBroadcastsTime(t *testing.T) {
cfg := DefaultConfig()
cfg.WorldDir = ""
srv, err := NewWithCache(cfg, world.NewCache(-1, world.GenerateFlat))
if err != nil {
t.Fatal(err)
}
var mu sync.Mutex
var bodies [][]byte
profile := Profile{Name: "Clockwatcher", UUID: OfflineUUID("Clockwatcher")}
session, err := srv.RegisterPlayer(profile, func(id int32, body []byte) error {
if id != protocol.PlaySetTime {
return nil
}
mu.Lock()
bodies = append(bodies, append([]byte(nil), body...))
mu.Unlock()
return nil
})
if err != nil {
t.Fatal(err)
}
defer srv.UnregisterPlayer(session)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
srv.StartSpawning(ctx, nil)
// Two sync intervals plus slack: the loop broadcasts once per 20 ticks.
time.Sleep(time.Duration(2*timeSyncTicks+8) * 50 * time.Millisecond)
cancel()
mu.Lock()
got := len(bodies)
var first []byte
if got > 0 {
first = bodies[0]
}
mu.Unlock()
if got < 1 {
t.Fatal("no set_time reached the player in two sync intervals")
}
if len(first) != 8+1+1+len(protocol.AppendVarLong(nil, int64(timeSyncTicks)))+4+4 {
t.Errorf("set_time body is %d bytes: %x", len(first), first)
}
if gameTime, _ := srv.WorldTime(); gameTime < timeSyncTicks {
t.Errorf("clock only reached %d ticks; the loop is not advancing it", gameTime)
}
}

View file

@ -70,6 +70,8 @@ type Server struct {
players map[[16]byte]*PlayerSession
playerNames map[string][16]byte
nextPlayerID int32
clock worldClock
}
// PacketSender is the connection capability retained by the session registry.
@ -155,10 +157,26 @@ func validateConfig(cfg Config) error {
}
func newServerState(cfg Config, chunks *world.Cache, store *world.Store, entities *world.EntityManager) *Server {
return &Server{
s := &Server{
cfg: cfg, chunks: chunks, store: store, entities: entities,
players: make(map[[16]byte]*PlayerSession), playerNames: make(map[string][16]byte),
}
// Resume the world clock where it was saved, so a restart does not throw
// the sky back to dawn.
if store != nil {
gameTime, dayTime := store.WorldTime()
s.SetWorldTime(gameTime, dayTime)
}
return s
}
// SaveWorldTime persists the current clock. It is a no-op without a store.
func (s *Server) SaveWorldTime() error {
if s.store == nil {
return nil
}
gameTime, dayTime := s.WorldTime()
return s.store.SaveWorldTime(gameTime, dayTime)
}
// Config returns the active configuration.

View file

@ -2,21 +2,25 @@ package server
import (
"context"
"log/slog"
"math"
"math/rand"
"time"
"regionio/internal/protocol"
"regionio/internal/registry"
"regionio/internal/world"
)
// StartSpawning begins the entity tick and spawn loops.
func (s *Server) StartSpawning(ctx context.Context) {
go s.entityTickLoop(ctx)
// StartSpawning begins the entity tick and spawn loops. log matches
// Cache.StartAutosave's convention: these are background loops that have to be
// able to report a failure nobody is waiting on.
func (s *Server) StartSpawning(ctx context.Context, log *slog.Logger) {
go s.entityTickLoop(ctx, log)
go s.mobSpawnLoop(ctx)
}
func (s *Server) entityTickLoop(ctx context.Context) {
func (s *Server) entityTickLoop(ctx context.Context, log *slog.Logger) {
ticker := time.NewTicker(50 * time.Millisecond) // 20 TPS
defer ticker.Stop()
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
@ -25,6 +29,17 @@ func (s *Server) entityTickLoop(ctx context.Context) {
case <-ctx.Done():
return
case <-ticker.C:
// The world clock rides this loop because it is the only one
// already running at 20 TPS. It belongs on the single authoritative
// tick the engine still needs.
if gameTime, dayTime := s.advanceWorldTime(); gameTime%timeSyncTicks == 0 {
s.Broadcast(protocol.PlaySetTime, encodeSetTime(gameTime, dayTime))
if gameTime%timePersistTicks == 0 {
if err := s.SaveWorldTime(); err != nil && log != nil {
log.Warn("saving world clock", "err", err)
}
}
}
all := s.entities.All()
for i := range all {
snapshot := all[i]

View file

@ -76,6 +76,9 @@ type Store struct {
dir string
mu sync.Mutex
regions map[[2]int]*RegionFile
metaMu sync.Mutex
meta worldMetadata
}
const worldMetadataFile = "regionio-world.json"
@ -83,6 +86,10 @@ const worldMetadataFile = "regionio-world.json"
type worldMetadata struct {
Format int `json:"format"`
Seed int64 `json:"seed"`
// GameTime and DayTime persist the world clock. A file written before they
// existed simply lacks them, and the world resumes at dawn as it used to.
GameTime int64 `json:"gameTime"`
DayTime int64 `json:"dayTime"`
}
// NewStore opens (or creates) the world directory at dir, ensuring region/
@ -103,35 +110,74 @@ func newStore(dir string, seed *int64) (*Store, error) {
if err := mkdirAll(regionDir); err != nil {
return nil, err
}
store := &Store{dir: dir, regions: make(map[[2]int]*RegionFile)}
if seed != nil {
if err := validateWorldMetadata(dir, *seed); err != nil {
meta, err := validateWorldMetadata(dir, *seed)
if err != nil {
return nil, err
}
store.meta = meta
}
return &Store{dir: dir, regions: make(map[[2]int]*RegionFile)}, nil
return store, nil
}
func validateWorldMetadata(dir string, seed int64) error {
// WorldTime returns the clock stored with the world. It is zero for a world
// opened without a seed (which skips the metadata file) or written before the
// clock was persisted.
func (s *Store) WorldTime() (gameTime, dayTime int64) {
s.metaMu.Lock()
defer s.metaMu.Unlock()
return s.meta.GameTime, s.meta.DayTime
}
// SaveWorldTime rewrites the metadata file with a new clock. It is a no-op for
// a world with no metadata file, which has no seed to write back.
func (s *Store) SaveWorldTime(gameTime, dayTime int64) error {
s.metaMu.Lock()
defer s.metaMu.Unlock()
if s.meta.Format == 0 {
return nil
}
if s.meta.GameTime == gameTime && s.meta.DayTime == dayTime {
return nil
}
meta := s.meta
meta.GameTime, meta.DayTime = gameTime, dayTime
if err := writeWorldMetadata(s.dir, meta); err != nil {
return err
}
s.meta = meta
return nil
}
func validateWorldMetadata(dir string, seed int64) (worldMetadata, error) {
path := filepath.Join(dir, worldMetadataFile)
raw, err := os.ReadFile(path)
if err == nil {
var meta worldMetadata
if err := json.Unmarshal(raw, &meta); err != nil {
return fmt.Errorf("world: decode %s: %w", path, err)
return worldMetadata{}, fmt.Errorf("world: decode %s: %w", path, err)
}
if meta.Format != 1 {
return fmt.Errorf("world: unsupported metadata format %d", meta.Format)
return worldMetadata{}, fmt.Errorf("world: unsupported metadata format %d", meta.Format)
}
if meta.Seed != seed {
return fmt.Errorf("world: seed mismatch for %s: stored %d, configured %d", dir, meta.Seed, seed)
return worldMetadata{}, fmt.Errorf("world: seed mismatch for %s: stored %d, configured %d", dir, meta.Seed, seed)
}
return nil
return meta, nil
}
if !os.IsNotExist(err) {
return err
return worldMetadata{}, err
}
meta := worldMetadata{Format: 1, Seed: seed}
return meta, writeWorldMetadata(dir, meta)
}
raw, err = json.MarshalIndent(worldMetadata{Format: 1, Seed: seed}, "", " ")
// writeWorldMetadata replaces the metadata file atomically: write a temporary
// beside it, fsync, then rename over the original.
func writeWorldMetadata(dir string, meta worldMetadata) error {
path := filepath.Join(dir, worldMetadataFile)
raw, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}

View file

@ -0,0 +1,77 @@
package world
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// TestWorldTimeRoundTrip checks the clock survives a restart. Without it the
// sky snaps back to dawn every time the server comes up, however long the world
// has been running.
func TestWorldTimeRoundTrip(t *testing.T) {
dir := t.TempDir()
store, err := NewStoreForSeed(dir, 4242)
if err != nil {
t.Fatalf("open store: %v", err)
}
if gameTime, dayTime := store.WorldTime(); gameTime != 0 || dayTime != 0 {
t.Fatalf("a new world starts at %d/%d, want 0/0", gameTime, dayTime)
}
if err := store.SaveWorldTime(72_000, 18_000); err != nil {
t.Fatalf("save time: %v", err)
}
store.Close()
reopened, err := NewStoreForSeed(dir, 4242)
if err != nil {
t.Fatalf("reopen store: %v", err)
}
defer reopened.Close()
gameTime, dayTime := reopened.WorldTime()
if gameTime != 72_000 || dayTime != 18_000 {
t.Errorf("reopened at %d/%d, want 72000/18000", gameTime, dayTime)
}
// The seed guard has to survive the rewrite: it is the only thing stopping
// a world from being regenerated with different terrain.
if _, err := NewStoreForSeed(dir, 9999); err == nil {
t.Error("reopening with a different seed was accepted")
}
}
// TestWorldMetadataWithoutClock accepts a metadata file written before the
// clock was persisted, rather than rejecting the world.
func TestWorldMetadataWithoutClock(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "region"), 0o755); err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(map[string]any{"format": 1, "seed": 7})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, worldMetadataFile), raw, 0o644); err != nil {
t.Fatal(err)
}
store, err := NewStoreForSeed(dir, 7)
if err != nil {
t.Fatalf("open a world written before the clock existed: %v", err)
}
defer store.Close()
if gameTime, dayTime := store.WorldTime(); gameTime != 0 || dayTime != 0 {
t.Errorf("clock read %d/%d from a file that has none, want 0/0", gameTime, dayTime)
}
if err := store.SaveWorldTime(10, 20); err != nil {
t.Fatalf("save time into an older world: %v", err)
}
reopened, err := NewStoreForSeed(dir, 7)
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
if gameTime, dayTime := reopened.WorldTime(); gameTime != 10 || dayTime != 20 {
t.Errorf("after upgrading the file the clock reads %d/%d, want 10/20", gameTime, dayTime)
}
}