diff --git a/cmd/regionio/main.go b/cmd/regionio/main.go index 2669520..d7fce8d 100644 --- a/cmd/regionio/main.go +++ b/cmd/regionio/main.go @@ -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() } diff --git a/internal/network/play.go b/internal/network/play.go index e6072fc..cb763dc 100644 --- a/internal/network/play.go +++ b/internal/network/play.go @@ -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()) diff --git a/internal/protocol/ids.go b/internal/protocol/ids.go index 04f0176..d137d8c 100644 --- a/internal/protocol/ids.go +++ b/internal/protocol/ids.go @@ -84,6 +84,7 @@ const ( PlayLightUpdate = 0x30 PlayPlayerInfoRemove = 0x45 PlayPlayerInfoUpdate = 0x46 + PlaySetTime = 0x71 // Entity packets PlayAddEntity = 0x01 diff --git a/internal/server/clock.go b/internal/server/clock.go new file mode 100644 index 0000000..29c5112 --- /dev/null +++ b/internal/server/clock.go @@ -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() +} diff --git a/internal/server/clock_test.go b/internal/server/clock_test.go new file mode 100644 index 0000000..d3986cf --- /dev/null +++ b/internal/server/clock_test.go @@ -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) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index a79ad4e..a8ab016 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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. diff --git a/internal/server/spawner.go b/internal/server/spawner.go index 249efd6..b371678 100644 --- a/internal/server/spawner.go +++ b/internal/server/spawner.go @@ -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] diff --git a/internal/world/store.go b/internal/world/store.go index f6e6ad4..c5de4d4 100644 --- a/internal/world/store.go +++ b/internal/world/store.go @@ -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 } diff --git a/internal/world/worldtime_test.go b/internal/world/worldtime_test.go new file mode 100644 index 0000000..da39532 --- /dev/null +++ b/internal/world/worldtime_test.go @@ -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) + } +}