diff --git a/README.md b/README.md index ae46433..503c883 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,15 @@ block editing, persistent worlds, and an overworld generator built on the real ``` go build ./... -go run ./cmd/regionio -seed 12345 +go run ./cmd/regionio -seed 12345 -port 25565 -viewdistance 2 ``` The world seed defaults to `0`; override it with the `-seed` flag or the `REGIONIO_SEED` environment variable. The server listens on `0.0.0.0:25565`. -Changing the seed for an existing world directory is rejected. +Changing the seed for an existing world directory is rejected. The server caps +the client-requested chunk radius at `2` by default because cold density-based +generation is expensive; raise it with `-viewdistance 3` after the surrounding +world has been generated and cached. ## Testing diff --git a/cmd/regionio/main.go b/cmd/regionio/main.go index 992ee41..2669520 100644 --- a/cmd/regionio/main.go +++ b/cmd/regionio/main.go @@ -27,13 +27,18 @@ func main() { // fatal — a wrong seed silently generates a different world than intended. seedFlag := flag.Int64("seed", parseSeedEnv(os.Getenv("REGIONIO_SEED"), cfg.WorldSeed, log), "world seed (overrides REGIONIO_SEED)") + port := flag.Int("port", cfg.Port, "TCP listen port") worldDir := flag.String("world", cfg.WorldDir, "world directory (empty = in-memory only)") maxCache := flag.Int("maxcache", cfg.MaxCachedChunks, "max cached chunks, LRU eviction (0 = unbounded)") + viewDistance := flag.Int("viewdistance", cfg.MaxViewDistance, "maximum client chunk view radius (2-16)") flag.Parse() cfg.WorldSeed = *seedFlag + cfg.Port = *port cfg.WorldDir = *worldDir cfg.MaxCachedChunks = *maxCache - log.Info("using world seed", "seed", cfg.WorldSeed, "worldDir", cfg.WorldDir, "maxcache", cfg.MaxCachedChunks) + cfg.MaxViewDistance = *viewDistance + log.Info("using world seed", "seed", cfg.WorldSeed, "worldDir", cfg.WorldDir, + "maxcache", cfg.MaxCachedChunks, "viewdistance", cfg.MaxViewDistance) srv, err := server.New(cfg) if err != nil { diff --git a/internal/network/configuration.go b/internal/network/configuration.go index d078a65..7bb2c2a 100644 --- a/internal/network/configuration.go +++ b/internal/network/configuration.go @@ -98,15 +98,15 @@ func (h *handler) handleClientInformation(pkt protocol.Packet) error { return err } - // view_distance drives the chunk streamer radius. The client sends 2..32; - // clamp into a sane server range so a huge view distance doesn't trigger a - // generation explosion. + // view_distance drives the chunk streamer radius. The server owns the upper + // bound because accepting the client's render distance can multiply cold + // generation work into hundreds of chunks. vdInt := int(int8(vd)) if vdInt < 2 { vdInt = 2 } - if vdInt > 16 { - vdInt = 16 + if max := h.srv.Config().MaxViewDistance; vdInt > max { + vdInt = max } h.viewDistance = vdInt diff --git a/internal/network/handler.go b/internal/network/handler.go index 6073b3f..765ddb1 100644 --- a/internal/network/handler.go +++ b/internal/network/handler.go @@ -31,6 +31,7 @@ type handler struct { session *server.PlayerSession knownPlayers map[[16]byte]bool knownEntities map[int32]visibleEntity + spawnY float64 // Creative inventory state for block placement. heldSlot int32 // selected hotbar index (0-8) diff --git a/internal/network/multiplayer_test.go b/internal/network/multiplayer_test.go index 2b391f7..c88860c 100644 --- a/internal/network/multiplayer_test.go +++ b/internal/network/multiplayer_test.go @@ -150,11 +150,13 @@ func TestBoundaryEditBroadcastsEveryChangedLightChunk(t *testing.T) { srv.SetPlayerViewDistance(h.session, 2) valid, lightChunks := cache.SetBlockWithLight(15, 100, 8, world.StateGlowstone) - if !valid || len(lightChunks) != 2 { - t.Fatalf("boundary edit valid=%v light chunks=%v; want two", valid, lightChunks) + if !valid || len(lightChunks) != 6 { + t.Fatalf("boundary edit valid=%v light chunks=%v; want six cached neighbors", valid, lightChunks) } h.broadcastBlockUpdate(15, 100, 8, world.StateGlowstone, lightChunks) - assertPacketIDs(t, recorder.take(t), protocol.PlayBlockUpdate, protocol.PlayLightUpdate, protocol.PlayLightUpdate) + assertPacketIDs(t, recorder.take(t), protocol.PlayBlockUpdate, + protocol.PlayLightUpdate, protocol.PlayLightUpdate, protocol.PlayLightUpdate, + protocol.PlayLightUpdate, protocol.PlayLightUpdate, protocol.PlayLightUpdate) } func TestIntegrationFourClientsVisibilityMovementLeaveAndLight(t *testing.T) { diff --git a/internal/network/play.go b/internal/network/play.go index 69c30ce..b878be3 100644 --- a/internal/network/play.go +++ b/internal/network/play.go @@ -12,11 +12,10 @@ import ( "regionio/internal/world" ) -// Spawn coordinates. Y sits above the maximum terrain height so the player -// drops onto the generated surface rather than spawning inside it. +// Spawn column. The feet-level Y is resolved from the generated surface when +// the player enters the play phase. const ( spawnX = 8.5 - spawnY = 200.0 spawnZ = 8.5 ) @@ -29,7 +28,12 @@ func (h *handler) beginPlay() error { return err } h.session = session - h.srv.SetPlayerTransform(session, spawnX, spawnY, spawnZ, 0, 0, true) + 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 @@ -56,7 +60,7 @@ func (h *handler) beginPlay() error { } // 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.viewDistance) + 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() @@ -76,7 +80,7 @@ func (h *handler) sendDefaultSpawnPosition() error { // yaw and pitch. GlobalPos starts with the dimension resource key. w := protocol.NewWriter(40) w.String("minecraft:overworld") - w.Position(8, 100, 8) + w.Position(8, int(math.Floor(h.spawnY)), 8) w.Float32(0.0) w.Float32(0.0) return h.conn.SendWriter(protocol.PlayDefaultSpawnPos, w) @@ -109,13 +113,14 @@ func (h *handler) onPlayerMove(x, y, z float64, yaw, pitch float32, onGround boo } func (h *handler) visibilityRadius() int { - if h.viewDistance < 2 { - return defaultViewRadius + distance := h.viewDistance + if distance < 2 { + distance = defaultViewRadius } - if h.viewDistance > 16 { - return 16 + if max := h.srv.Config().MaxViewDistance; distance > max { + distance = max } - return h.viewDistance + return distance } // sendPlayLogin writes the clientbound play "login" packet. Field layout was @@ -176,11 +181,11 @@ func (h *handler) sendGameEvent(event byte, value float32) error { func (h *handler) sendPlayerPosition(teleportID int32) error { w := protocol.NewWriter(64) w.VarInt(teleportID) - w.Float64(spawnX).Float64(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 + 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) } diff --git a/internal/network/play_spawn_test.go b/internal/network/play_spawn_test.go index 5079ed9..1cc2000 100644 --- a/internal/network/play_spawn_test.go +++ b/internal/network/play_spawn_test.go @@ -1,14 +1,17 @@ package network import ( + "log/slog" "testing" "regionio/internal/protocol" + "regionio/internal/server" + "regionio/internal/world" ) func TestSendDefaultSpawnPositionLayout(t *testing.T) { recorder := &recordingConn{} - h := &handler{conn: NewConn(recorder)} + h := &handler{conn: NewConn(recorder), spawnY: 100} if err := h.sendDefaultSpawnPosition(); err != nil { t.Fatal(err) @@ -38,3 +41,17 @@ func TestSendDefaultSpawnPositionLayout(t *testing.T) { t.Fatalf("remaining bytes = %d, want 0", remaining) } } + +func TestVisibilityRadiusUsesServerLimit(t *testing.T) { + cfg := server.DefaultConfig() + cfg.WorldDir = "" + cfg.MaxViewDistance = 2 + srv, err := server.NewWithCache(cfg, world.NewCache(-1, world.GenerateFlat)) + if err != nil { + t.Fatal(err) + } + h := &handler{srv: srv, log: slog.Default(), viewDistance: 16} + if got := h.visibilityRadius(); got != 2 { + t.Fatalf("visibility radius = %d, want server limit 2", got) + } +} diff --git a/internal/network/streamer.go b/internal/network/streamer.go index dd6ead4..dd1df8e 100644 --- a/internal/network/streamer.go +++ b/internal/network/streamer.go @@ -48,8 +48,9 @@ type streamer struct { } // defaultViewRadius is used when the client hasn't sent client_information or -// sent an implausible value. Matches the legacy chunkRadius. -const defaultViewRadius = 4 +// sent an implausible value. Vanilla generation is expensive, so keep the cold +// start bounded until nearby terrain has warmed in the cache. +const defaultViewRadius = 2 // newStreamer constructs a streamer for the given cache/conn. viewDistance comes // from the client's client_information (clamped to a safe range); genRadius is @@ -161,6 +162,13 @@ func (s *streamer) processRecenter(ctx context.Context, cx, cz int32) (recenterR s.tickets.Replace(viewTickets, prefetchTickets) } + // The center frame needs a 3x3 terrain neighborhood for lighting. Preload + // those chunks concurrently so the first visible chunk is not delayed by + // eight sequential generator calls inside the lighting pass. + if !s.loaded[[2]int32{cx, cz}] { + s.parallelPreload(ctx, spiralOrder(cx, cz, 1)) + } + // Client residency follows viewRadius exactly. The prefetch ring is retained // only server-side by tickets and never left loaded on the client. for key := range s.loaded { @@ -175,7 +183,8 @@ func (s *streamer) processRecenter(ctx context.Context, cx, cz int32) (recenterR if next, superseded := s.streamPriority(ctx, cx, cz, toSend, true); superseded { return next, true } - // Pre-generate the ring so the next recenter finds frames warm in the cache. + // Preload terrain only. Building full frames here would calculate lighting + // for off-screen chunks and recursively generate yet another outer ring. if next, superseded := s.streamPriority(ctx, cx, cz, toPreGen, false); superseded { return next, true } @@ -207,7 +216,7 @@ func (s *streamer) streamPriority(ctx context.Context, cx, cz int32, keys [][2]i if send { s.parallelSend(ctx, keys[start:end]) } else { - s.parallelGenerate(ctx, keys[start:end]) + s.parallelPreload(ctx, keys[start:end]) } if next, ok := s.latestRecenter(); ok { return next, true @@ -255,8 +264,8 @@ func (s *streamer) sendForgetLevelChunk(cx, cz int32) { _ = s.conn.SendWriter(protocol.PlayForgetLevelChunk, w) } -// parallelSend generates the given chunks across the worker pool and sends each -// frame as soon as it is ready (order is best-effort; the client reassembles). +// parallelSend generates the given chunks across the worker pool, then sends +// them in caller order so the client receives a contiguous near-first view. // Already-loaded chunks are skipped. Returns when all are sent or ctx cancels. func (s *streamer) parallelSend(ctx context.Context, keys [][2]int32) { var pending []frameJob @@ -304,13 +313,26 @@ func (s *streamer) parallelSend(ctx context.Context, keys [][2]int32) { wg.Wait() close(results) }() + generated := make(map[[2]int32]frameResult, len(pending)) + failed := false for r := range results { if r.err != nil { - // Send failed — the connection is likely closing. Bail out; the - // serve loop will tear us down via ctx cancel. if s.log != nil { s.log.Debug("streamer frame failed", "cx", r.cx, "cz", r.cz, "err", r.err) } + failed = true + continue + } + generated[[2]int32{r.cx, r.cz}] = r + } + if failed { + return + } + // Generation completes out of order, but client presentation should not. + // Emit the contiguous spiral order supplied by the caller. + for _, j := range pending { + r, ok := generated[[2]int32{j.cx, j.cz}] + if !ok { return } if s.conn != nil { @@ -321,13 +343,13 @@ func (s *streamer) parallelSend(ctx context.Context, keys [][2]int32) { return } } - s.loaded[[2]int32{r.cx, r.cz}] = true + s.loaded[[2]int32{j.cx, j.cz}] = true } } -// parallelGenerate warms the cache for the given chunks without sending them -// (used for the predictive ring). Errors are ignored. -func (s *streamer) parallelGenerate(ctx context.Context, keys [][2]int32) { +// parallelPreload warms terrain for the given chunks without calculating light, +// encoding frames, or sending packets. Errors are ignored. +func (s *streamer) parallelPreload(ctx context.Context, keys [][2]int32) { var pending []frameJob for _, k := range keys { if s.loaded[k] { @@ -355,7 +377,7 @@ func (s *streamer) parallelGenerate(ctx context.Context, keys [][2]int32) { return default: } - _, _ = s.cache.FrameErrContext(ctx, j.cx, j.cz) // warm cache; discard frame + _ = s.cache.PreloadErrContext(ctx, j.cx, j.cz) } }() } @@ -381,8 +403,8 @@ type frameResult struct { err error } -// generateWorker reads jobs, generates+frames the chunk via the (thread-safe) -// cache, and sends the frame to the conn. It exits when jobs closes. +// generateWorker reads jobs and generates+frames chunks via the thread-safe +// cache. It exits when jobs closes. func (s *streamer) generateWorker(ctx context.Context, jobs <-chan frameJob, results chan<- frameResult) { for j := range jobs { select { @@ -407,9 +429,7 @@ func (s *streamer) generateWorker(ctx context.Context, jobs <-chan frameJob, res } // spiralOrder returns chunk coordinates in a square of side (2*radius+1) around -// (cx, cz), ordered from the centre outward (Chebyshev rings). The centre is -// first, then ring 1, ring 2, … ring `radius`. Within a ring the order is -// deterministic but not otherwise constrained — nearest-first is what matters. +// (cx, cz), ordered from the centre outward in contiguous Chebyshev rings. func spiralOrder(cx, cz int32, radius int) [][2]int32 { if radius < 0 { radius = 0 @@ -417,14 +437,18 @@ func spiralOrder(cx, cz int32, radius int) [][2]int32 { out := make([][2]int32, 0, (2*radius+1)*(2*radius+1)) out = append(out, [2]int32{cx, cz}) for r := 1; r <= radius; r++ { - // Walk the perimeter of the ring at Chebyshev distance r. - for d := -r; d <= r; d++ { - out = append(out, [2]int32{cx + int32(d), cz - int32(r)}) // top edge - out = append(out, [2]int32{cx + int32(d), cz + int32(r)}) // bottom edge + // Walk one continuous perimeter: top, right, bottom, left. + for x := -r; x <= r; x++ { + out = append(out, [2]int32{cx + int32(x), cz - int32(r)}) } - for d := -r + 1; d <= r-1; d++ { - out = append(out, [2]int32{cx - int32(r), cz + int32(d)}) // left edge - out = append(out, [2]int32{cx + int32(r), cz + int32(d)}) // right edge + for z := -r + 1; z <= r; z++ { + out = append(out, [2]int32{cx + int32(r), cz + int32(z)}) + } + for x := r - 1; x >= -r; x-- { + out = append(out, [2]int32{cx + int32(x), cz + int32(r)}) + } + for z := r - 1; z >= -r+1; z-- { + out = append(out, [2]int32{cx - int32(r), cz + int32(z)}) } } return out diff --git a/internal/network/streamer_test.go b/internal/network/streamer_test.go index 65a73ef..80ce850 100644 --- a/internal/network/streamer_test.go +++ b/internal/network/streamer_test.go @@ -69,6 +69,28 @@ func TestSpiralOrderRingStructure(t *testing.T) { } } +func TestSpiralOrderWalksEachRingContiguously(t *testing.T) { + order := spiralOrder(0, 0, 4) + for i := 1; i < len(order); i++ { + previousRing := chunkDistanceFrom(0, 0, order[i-1]) + currentRing := chunkDistanceFrom(0, 0, order[i]) + if previousRing != currentRing { + continue + } + dx := order[i][0] - order[i-1][0] + if dx < 0 { + dx = -dx + } + dz := order[i][1] - order[i-1][1] + if dz < 0 { + dz = -dz + } + if dx+dz != 1 { + t.Fatalf("ring %d jumps from %v to %v", currentRing, order[i-1], order[i]) + } + } +} + // TestRequestRecenterNonBlocking confirms requestRecenter never blocks the // caller even when many requests are pushed rapidly (the streamer drains stale // ones). This is the property the read loop relies on to stay responsive. @@ -104,6 +126,7 @@ func TestStreamerSendsStrictlyNearFirst(t *testing.T) { lastDistance := int32(-1) chunks := 0 + wantOrder := spiralOrder(7, -3, 2) for _, packet := range recorder.take(t) { if packet.ID != protocol.PlayLevelChunk { continue @@ -122,6 +145,9 @@ func TestStreamerSendsStrictlyNearFirst(t *testing.T) { t.Fatalf("chunk (%d,%d) at distance %d arrived after distance %d", x, z, distance, lastDistance) } lastDistance = distance + if want := wantOrder[chunks]; x != want[0] || z != want[1] { + t.Fatalf("chunk[%d] = (%d,%d), want %v", chunks, x, z, want) + } chunks++ } if chunks != 25 { diff --git a/internal/server/server.go b/internal/server/server.go index 6c7e55d..a79ad4e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -32,6 +32,10 @@ type Config struct { // MaxCachedChunks bounds the in-memory chunk+frame cache (LRU). 0 means // unbounded (use only for tests/flat worlds). At ~200KiB/chunk, 1024 ≈ 200MB. MaxCachedChunks int + // MaxViewDistance caps the client-requested chunk radius. Generation is much + // more expensive than vanilla's pregenerated worlds, so the server owns the + // upper bound instead of accepting the client's render distance verbatim. + MaxViewDistance int } // DefaultConfig returns sensible defaults matching vanilla expectations. @@ -51,6 +55,7 @@ func DefaultConfig() Config { // MaxCachedChunks keeps the live cache near 200MB at the default; the // streamer's pre-gen ring and player view distance comfortably fit. MaxCachedChunks: 1024, + MaxViewDistance: 2, } } @@ -143,6 +148,9 @@ func validateConfig(cfg Config) error { if cfg.MaxCachedChunks < 0 { return fmt.Errorf("server: max cached chunks must not be negative") } + if cfg.MaxViewDistance < 2 || cfg.MaxViewDistance > 16 { + return fmt.Errorf("server: max view distance must be between 2 and 16") + } return nil } diff --git a/internal/server/spawner.go b/internal/server/spawner.go index 0d64070..249efd6 100644 --- a/internal/server/spawner.go +++ b/internal/server/spawner.go @@ -19,6 +19,7 @@ func (s *Server) StartSpawning(ctx context.Context) { func (s *Server) entityTickLoop(ctx context.Context) { ticker := time.NewTicker(50 * time.Millisecond) // 20 TPS defer ticker.Stop() + rng := rand.New(rand.NewSource(time.Now().UnixNano())) for { select { case <-ctx.Done(): @@ -55,9 +56,9 @@ func (s *Server) entityTickLoop(ctx context.Context) { } } else { // Random wander - e.X += (rand.Float64() - 0.5) * 0.2 - e.Z += (rand.Float64() - 0.5) * 0.2 - e.Yaw += float32((rand.Float64() - 0.5) * 10.0) + e.X += (rng.Float64() - 0.5) * 0.2 + e.Z += (rng.Float64() - 0.5) * 0.2 + e.Yaw += float32((rng.Float64() - 0.5) * 10.0) } } @@ -73,6 +74,7 @@ func (s *Server) entityTickLoop(ctx context.Context) { func (s *Server) mobSpawnLoop(ctx context.Context) { ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() + rng := rand.New(rand.NewSource(time.Now().UnixNano())) pigType := registry.EntityTypeIndex("minecraft:pig") zombieType := registry.EntityTypeIndex("minecraft:zombie") @@ -85,28 +87,41 @@ func (s *Server) mobSpawnLoop(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: - if s.PlayerCount() == 0 || s.entities.Count() >= 50 { - continue // limit to 50 entities + if s.PlayerCount() == 0 || s.entities.Count() >= 20 { + continue } - - // Spawn near the spawn point (8.5, 200, 8.5) - x := (rand.Float64() - 0.5) * 30.0 - z := (rand.Float64() - 0.5) * 30.0 - - t := pigType - name := "minecraft:pig" - if rand.Float32() < 0.5 { - t = zombieType - name = "minecraft:zombie" - } - - s.entities.Add(&world.Entity{ - TypeID: t, - TypeName: name, - X: x + 8.5, - Y: 200.0, - Z: z + 8.5, - }) + s.spawnMobNearPlayer(rng, pigType, zombieType) } } } + +func (s *Server) spawnMobNearPlayer(rng *rand.Rand, pigType, zombieType int) bool { + players := s.PlayerSnapshots() + if len(players) == 0 { + return false + } + player := players[rng.Intn(len(players))] + angle := rng.Float64() * 2 * math.Pi + distance := 16.0 + rng.Float64()*16.0 + x := int(math.Floor(player.X + math.Cos(angle)*distance)) + z := int(math.Floor(player.Z + math.Sin(angle)*distance)) + y, ok := s.chunks.SafeSpawnY(x, z) + if !ok { + return false + } + + typeID := pigType + typeName := "minecraft:pig" + if rng.Float32() < 0.5 { + typeID = zombieType + typeName = "minecraft:zombie" + } + s.entities.Add(&world.Entity{ + TypeID: typeID, + TypeName: typeName, + X: float64(x) + 0.5, + Y: float64(y), + Z: float64(z) + 0.5, + }) + return true +} diff --git a/internal/server/spawner_test.go b/internal/server/spawner_test.go new file mode 100644 index 0000000..7723fd5 --- /dev/null +++ b/internal/server/spawner_test.go @@ -0,0 +1,39 @@ +package server + +import ( + "math" + "math/rand" + "testing" + + "regionio/internal/world" +) + +func TestSpawnMobNearPlayerUsesSurface(t *testing.T) { + cfg := DefaultConfig() + cfg.WorldDir = "" + srv, err := NewWithCache(cfg, world.NewCache(-1, world.GenerateFlat)) + if err != nil { + t.Fatal(err) + } + session, err := srv.RegisterPlayer(Profile{Name: "Alice", UUID: OfflineUUID("Alice")}, nil) + if err != nil { + t.Fatal(err) + } + srv.SetPlayerTransform(session, 8.5, 80, 8.5, 0, 0, true) + + if !srv.spawnMobNearPlayer(rand.New(rand.NewSource(1)), 10, 20) { + t.Fatal("spawnMobNearPlayer returned false") + } + entities := srv.Entities().All() + if len(entities) != 1 { + t.Fatalf("entities = %d, want 1", len(entities)) + } + entity := entities[0] + if entity.Y != world.FlatSurfaceY+1 { + t.Fatalf("mob Y = %v, want surface Y %d", entity.Y, world.FlatSurfaceY+1) + } + distance := math.Hypot(entity.X-8.5, entity.Z-8.5) + if distance < 15 || distance > 33 { + t.Fatalf("mob distance = %v, want near player", distance) + } +} diff --git a/internal/world/cache.go b/internal/world/cache.go index 62f0cf3..974d045 100644 --- a/internal/world/cache.go +++ b/internal/world/cache.go @@ -40,7 +40,7 @@ type Cache struct { maxChunks int // LRU capacity; 0 = unbounded mu sync.Mutex - lightMu sync.Mutex + lightMu sync.RWMutex chunks map[[2]int32]*Chunk frames map[[2]int32][]byte dirty map[[2]int32]uint64 @@ -248,6 +248,22 @@ func (c *Cache) FrameErrContext(ctx context.Context, cx, cz int32) ([]byte, erro return c.frameErr(cx, cz) } +// PreloadErrContext loads or generates a chunk without calculating lighting or +// encoding a network frame. Streamers use it for predictive terrain work so a +// prefetch ring does not recursively expand through lighting neighborhoods. +func (c *Cache) PreloadErrContext(ctx context.Context, cx, cz int32) error { + select { + case c.frameSlots <- struct{}{}: + defer func() { <-c.frameSlots }() + case <-ctx.Done(): + return ctx.Err() + } + release := c.beginUse([2]int32{cx, cz}) + defer release() + _, err := c.chunkAtErr(cx, cz) + return err +} + func (c *Cache) frameErr(cx, cz int32) ([]byte, error) { key := [2]int32{cx, cz} @@ -307,6 +323,33 @@ func (c *Cache) GetBlock(x, y, z int) uint16 { return ch.GetBlock(x, y, z) } +// SafeSpawnY returns a feet-level Y with a supporting floor and two air blocks +// above it. Water columns and decorative plants are skipped rather than +// spawning an entity inside them. Loading happens once for the whole column. +func (c *Cache) SafeSpawnY(x, z int) (int, bool) { + cx := int32(x >> 4) + cz := int32(z >> 4) + release := c.beginUse([2]int32{cx, cz}) + defer release() + ch, err := c.chunkAtErr(cx, cz) + if err != nil { + return 0, false + } + + ch.mu.RLock() + defer ch.mu.RUnlock() + for y := MinY + WorldHeight - 3; y >= MinY; y-- { + floor := ch.getBlock(x, y, z) + if !supportsEntitySpawn(floor) { + continue + } + if ch.getBlock(x, y+1, z) == StateAir && ch.getBlock(x, y+2, z) == StateAir { + return y + 1, true + } + } + return 0, false +} + // LightUpdate returns the standalone light_update body for a loaded chunk. func (c *Cache) LightUpdate(cx, cz int32) ([]byte, error) { release := c.beginUse([2]int32{cx, cz}) diff --git a/internal/world/cache_light.go b/internal/world/cache_light.go index 350383f..8c00cfd 100644 --- a/internal/world/cache_light.go +++ b/internal/world/cache_light.go @@ -10,8 +10,8 @@ import ( // neighborhood. Light attenuates to zero within 15 blocks, so chunks outside // that neighborhood cannot affect the center chunk. func (c *Cache) ensureLight(chunk *Chunk) error { - c.lightMu.Lock() - defer c.lightMu.Unlock() + c.lightMu.RLock() + defer c.lightMu.RUnlock() return c.ensureLightLocked(chunk) } @@ -65,44 +65,57 @@ func (c *Cache) ensureLightLocked(chunk *Chunk) error { } } -// lightInputSnapshot returns blocks for a neighboring chunk without inserting -// a cache miss into the LRU. This keeps lighting correct even for very small -// cache limits and avoids an eight-chunk eviction cascade per frame. +// lightInputSnapshot returns a stable neighbor snapshot. Cache misses are kept +// in the LRU so adjacent frames reuse the same expensive terrain instead of +// regenerating up to eight neighbors for every lighting calculation. func (c *Cache) lightInputSnapshot(cx, cz int32) (*Chunk, error) { key := [2]int32{cx, cz} - c.mu.Lock() - if chunk := c.chunks[key]; chunk != nil { - c.touch(key) - c.mu.Unlock() - snapshot, _ := chunk.snapshot() - return snapshot, nil - } - if pending := c.loads[key]; pending != nil { - c.mu.Unlock() - <-pending.done - if pending.err != nil { - return nil, pending.err - } - snapshot, _ := pending.ch.snapshot() - return snapshot, nil - } - c.mu.Unlock() - - if c.store != nil { - loaded, err := c.store.LoadChunk(cx, cz) - if err == nil { - snapshot, _ := loaded.snapshot() + // A cache smaller than the required 3x3 neighborhood cannot retain these + // inputs usefully. Keep misses detached to avoid evicting the requested + // center chunk and churning the LRU on every frame. + if c.maxChunks > 0 && c.maxChunks < 9 { + c.mu.Lock() + if chunk := c.chunks[key]; chunk != nil { + c.touch(key) + c.mu.Unlock() + snapshot, _ := chunk.snapshot() return snapshot, nil } - if !errors.Is(err, ErrChunkNotFound) { - return nil, fmt.Errorf("world: load light neighbor (%d,%d): %w", cx, cz, err) + if pending := c.loads[key]; pending != nil { + c.mu.Unlock() + <-pending.done + if pending.err != nil { + return nil, pending.err + } + snapshot, _ := pending.ch.snapshot() + return snapshot, nil } + c.mu.Unlock() + if c.store != nil { + loaded, err := c.store.LoadChunk(cx, cz) + if err == nil { + snapshot, _ := loaded.snapshot() + return snapshot, nil + } + if !errors.Is(err, ErrChunkNotFound) { + return nil, fmt.Errorf("world: load light neighbor (%d,%d): %w", cx, cz, err) + } + } + generated := c.gen(cx, cz) + if generated == nil { + return nil, fmt.Errorf("world: generator returned nil light neighbor (%d,%d)", cx, cz) + } + snapshot, _ := generated.snapshot() + return snapshot, nil } - generated := c.gen(cx, cz) - if generated == nil { - return nil, fmt.Errorf("world: generator returned nil light neighbor (%d,%d)", cx, cz) + + release := c.beginUse(key) + defer release() + chunk, err := c.chunkAtErr(cx, cz) + if err != nil { + return nil, fmt.Errorf("world: load light neighbor (%d,%d): %w", cx, cz, err) } - snapshot, _ := generated.snapshot() + snapshot, _ := chunk.snapshot() return snapshot, nil } diff --git a/internal/world/cache_light_reuse_test.go b/internal/world/cache_light_reuse_test.go new file mode 100644 index 0000000..c277b22 --- /dev/null +++ b/internal/world/cache_light_reuse_test.go @@ -0,0 +1,35 @@ +package world + +import ( + "sync" + "testing" +) + +func TestAdjacentFramesReuseGeneratedLightNeighbors(t *testing.T) { + var mu sync.Mutex + generated := make(map[[2]int32]int) + cache := NewCache(-1, func(cx, cz int32) *Chunk { + mu.Lock() + generated[[2]int32{cx, cz}]++ + mu.Unlock() + return NewChunk(cx, cz, BiomePlains) + }) + + if _, err := cache.FrameErr(0, 0); err != nil { + t.Fatal(err) + } + if _, err := cache.FrameErr(1, 0); err != nil { + t.Fatal(err) + } + + mu.Lock() + defer mu.Unlock() + if len(generated) != 12 { + t.Fatalf("generated chunks = %d, want 12 shared neighborhood chunks", len(generated)) + } + for pos, count := range generated { + if count != 1 { + t.Fatalf("chunk %v generated %d times, want once", pos, count) + } + } +} diff --git a/internal/world/spawn_surface_test.go b/internal/world/spawn_surface_test.go new file mode 100644 index 0000000..7bfd6a3 --- /dev/null +++ b/internal/world/spawn_surface_test.go @@ -0,0 +1,41 @@ +package world + +import "testing" + +func TestSafeSpawnYUsesGeneratedSurface(t *testing.T) { + cache := NewCache(-1, GenerateFlat) + y, ok := cache.SafeSpawnY(8, 8) + if !ok || y != FlatSurfaceY+1 { + t.Fatalf("SafeSpawnY = %d, %v; want %d, true", y, ok, FlatSurfaceY+1) + } +} + +func TestSafeSpawnYRejectsUnderwaterColumn(t *testing.T) { + cache := NewCache(-1, func(cx, cz int32) *Chunk { + chunk := NewChunk(cx, cz, BiomePlains) + chunk.setBlockRaw(8, 60, 8, StateStone) + for y := 61; y <= SeaLevel; y++ { + chunk.setBlockRaw(8, y, 8, StateWater) + } + return chunk + }) + if y, ok := cache.SafeSpawnY(8, 8); ok { + t.Fatalf("SafeSpawnY = %d, true; want underwater column rejected", y) + } +} + +func TestSafeSpawnYAcceptsNonOpaqueSolidFloor(t *testing.T) { + stairs := nameToStateID("minecraft:oak_stairs", nil) + if stairs == StateAir { + t.Fatal("oak stairs state is unavailable") + } + cache := NewCache(-1, func(cx, cz int32) *Chunk { + chunk := NewChunk(cx, cz, BiomePlains) + chunk.setBlockRaw(8, 70, 8, stairs) + return chunk + }) + y, ok := cache.SafeSpawnY(8, 8) + if !ok || y != 71 { + t.Fatalf("SafeSpawnY = %d, %v; want 71, true for stairs", y, ok) + } +} diff --git a/internal/world/state_names.go b/internal/world/state_names.go index fb5d22c..7fd9cae 100644 --- a/internal/world/state_names.go +++ b/internal/world/state_names.go @@ -3,6 +3,7 @@ package world import ( _ "embed" "encoding/json" + "strings" "sync" "regionio/internal/nbt" @@ -39,6 +40,41 @@ func stateByID(id uint16) (stateName, bool) { return s, ok } +// supportsEntitySpawn distinguishes collision floors from decorative blocks. +// Light opacity is not sufficient here: stairs and slabs can have opacity zero +// while still supporting an entity. +func supportsEntitySpawn(id uint16) bool { + if id == StateAir || id == StateWater { + return false + } + if lightOpacity(id) > 0 { + return true + } + state, ok := stateByID(id) + if !ok { + return false + } + name := state.Name + for _, suffix := range []string{ + "_sapling", "_flower", "_tulip", "_mushroom", "_torch", + "_rail", "_button", "_pressure_plate", "_carpet", "_banner", + "_sign", "_hanging_sign", + } { + if strings.HasSuffix(name, suffix) { + return false + } + } + switch name { + case "minecraft:short_grass", "minecraft:tall_grass", "minecraft:fern", + "minecraft:large_fern", "minecraft:dead_bush", "minecraft:dandelion", + "minecraft:poppy", "minecraft:allium", "minecraft:azure_bluet", + "minecraft:oxeye_daisy", "minecraft:cornflower", + "minecraft:lily_of_the_valley", "minecraft:sunflower": + return false + } + return true +} + func buildStateTable() { var blocks map[string]struct { States []struct {