Implement ticketed chunk lifecycle

This commit is contained in:
Master290 2026-07-21 10:04:56 +03:00
parent cae06eb97e
commit cbd5c7b546
12 changed files with 696 additions and 100 deletions

View file

@ -5,6 +5,6 @@ test:
test-race:
go test -race ./internal/network ./internal/server ./internal/world \
-run 'Test(Integration|BoundaryEdit|PlayerInfo|PlayerRegistry|Concurrent|Incremental|EncodeLight|Cache|Store|Eviction|Region)'
-run 'Test(Integration|BoundaryEdit|PlayerInfo|PlayerRegistry|Concurrent|Incremental|EncodeLight|Cache|Store|Eviction|Region|Ticket|Streamer|LoadSixteen)'
verify: test test-race

View file

@ -13,8 +13,8 @@ block editing, persistent worlds, and an overworld generator built on the real
- **Registries**: 28 synchronized registries + tags, captured verbatim from the
26.1.2 vanilla server and sent during configuration.
- **World**: revisioned, concurrency-safe chunk snapshots; memoized
`level_chunk_with_light` frames; bounded LRU cache; Anvil `.mca` persistence
with autosave and seed metadata.
`level_chunk_with_light` frames; ticket-aware bounded LRU cache; shared frame
admission limit; Anvil `.mca` persistence with autosave and seed metadata.
- **Generation**: vanilla-derived overworld terrain from the embedded datapack
(`ImprovedNoise`/`PerlinNoise`/`BlendedNoise`/`NormalNoise` + the density
function interpreter), 3D multi-noise biomes, surface-rule interpretation,
@ -24,7 +24,11 @@ block editing, persistent worlds, and an overworld generator built on the real
creative block place/break; broadcast chat; and hotbar item→block mapping.
- **Lighting**: stored vanilla nibble arrays for sky and block light; horizontal
and cross-chunk propagation; incremental updates after edits; persisted
`SkyLight`/`BlockLight`; and chunk-scoped `light_update` broadcasts.
`SkyLight`/`BlockLight`; load-time border reconciliation; and chunk-scoped
`light_update` broadcasts.
- **Chunk lifecycle**: per-client view and prefetch tickets, strict near-first
ring streaming, stale-recenter cutoff, explicit client unload packets, and
eviction only after the final owner releases a chunk.
- **Safety**: duplicate chunk generation is coalesced; corrupt stored chunks are
not silently regenerated or overwritten; a world cannot reopen with another
seed.
@ -45,7 +49,7 @@ Changing the seed for an existing world directory is rejected.
```
go test ./...
go test -race ./internal/network ./internal/server ./internal/world \
-run 'Test(Integration|BoundaryEdit|PlayerInfo|PlayerRegistry|Concurrent|Incremental|EncodeLight|Cache|Store|Eviction|Region)'
-run 'Test(Integration|BoundaryEdit|PlayerInfo|PlayerRegistry|Concurrent|Incremental|EncodeLight|Cache|Store|Eviction|Region|Ticket|Streamer|LoadSixteen)'
# or run both gates:
make verify
```
@ -54,23 +58,27 @@ The integration suite exercises four clients across two visibility regions:
join, movement, leaving, mob visibility, and local block/light updates. A
two-client scenario separately covers shared block edits and chat. Concurrency
tests cover simultaneous frame encoding, editing, autosave, cache misses, and
session movement/broadcasts. Lighting tests compare the initial flat chunk and a
31x31x31 glowstone propagation volume against fixtures captured from the
official vanilla 26.1.2 server. Optional terrain parity diagnostics compare
surface heights against `/tmp/vanilla_ground.json` when that capture is present.
session movement/broadcasts. A 16-client lifecycle test exercises overlapping
ticket ownership, bounded global frame work, packet output, and cleanup after
disconnect. Lighting tests compare the initial flat chunk and a 31x31x31
glowstone propagation volume against fixtures captured from the official
vanilla 26.1.2 server. Optional terrain parity diagnostics compare surface
heights against `/tmp/vanilla_ground.json` when that capture is present.
## v0.3 scope
## v0.4 scope
RegionIO v0.3 is a small creative multiplayer server core, not a complete
RegionIO v0.4 is a small creative multiplayer server core, not a complete
vanilla gameplay implementation. Player and mob visibility is chunk-scoped,
but there is no interest prioritization or delta-movement compression yet.
Lighting matches vanilla's block-state dampening, emission, and face-occlusion
properties and propagates across loaded chunk boundaries. Chunks outside the
live cache are recalculated exactly when loaded rather than retained as active
light-engine state. Structures, placed features, mob AI, authentication,
inventory, and survival mechanics remain intentionally partial. The density
router is vanilla-derived, while biome/surface/decoration layers still contain
approximations and require stricter parity fixtures.
properties and reconciles persisted borders when chunks re-enter the live
cache. Streaming prioritizes Chebyshev rings and abandons unstarted stale work;
an already admitted frame calculation completes atomically rather than being
interrupted halfway. Unowned clean chunks remain as an LRU warm cache until
capacity pressure evicts them. Structures, placed features, mob AI,
authentication, inventory, and survival mechanics remain intentionally partial.
The density router is vanilla-derived, while biome/surface/decoration layers
still contain approximations and require stricter parity fixtures.
## Project layout

View file

@ -53,6 +53,25 @@ func (c *recordingConn) take(t *testing.T) []protocol.Packet {
return packets
}
func (c *recordingConn) countPacketID(id int32) int {
c.mu.Lock()
raw := append([]byte(nil), c.buf.Bytes()...)
c.mu.Unlock()
reader := bytes.NewReader(raw)
br := bufio.NewReader(reader)
count := 0
for br.Buffered() > 0 || reader.Len() > 0 {
packet, err := protocol.ReadPacket(br, -1)
if err != nil {
return count
}
if packet.ID == id {
count++
}
}
return count
}
type testAddr string
func (a testAddr) Network() string { return "test" }

View file

@ -13,10 +13,9 @@ import (
// streamer.go is the per-connection background chunk streamer. The read loop
// no longer generates or sends chunks inline; it pushes recenter requests here
// and stays free to handle the player's packets (movement, chat, keep-alive
// acks). The streamer generates chunks in a worker pool (Cache.Frame is
// goroutine-safe), pre-generates a ring beyond the view distance so movement
// doesn't pop in, and sends finished frames serially under the conn's write
// mutex.
// acks). The streamer holds cache tickets for the view and one predictive ring,
// admits work in distance-priority batches, and sends finished frames serially
// under the conn's write mutex.
//
// Ownership:
// - read loop: calls requestRecenter (non-blocking), owns nothing else here.
@ -45,6 +44,7 @@ type streamer struct {
viewRadius int // chunks within this Chebyshev radius are sent to the client
genRadius int // viewRadius + 1: pre-generated but not sent (predictive ring)
poolSize int // parallel generation workers
tickets *world.TicketSet
}
// defaultViewRadius is used when the client hasn't sent client_information or
@ -68,7 +68,7 @@ func newStreamer(cache *world.Cache, conn *Conn, log *slog.Logger, viewDistance
if pool < 2 {
pool = 2
}
return &streamer{
s := &streamer{
cache: cache,
conn: conn,
log: log,
@ -78,6 +78,10 @@ func newStreamer(cache *world.Cache, conn *Conn, log *slog.Logger, viewDistance
genRadius: viewDistance + 1,
poolSize: pool,
}
if cache != nil {
s.tickets = cache.NewTicketSet()
}
return s
}
// requestRecenter asks the streamer to recenter on (cx, cz). Non-blocking: if
@ -105,41 +109,30 @@ func (s *streamer) requestRecenter(cx, cz int32) {
// connection close). On each recenter it generates+sends the newly-in-range
// chunks in spiral order (nearest first) and pre-generates the outer ring.
func (s *streamer) run(ctx context.Context) {
var (
// latest holds the most recent recenter request; processed when the
// previous batch finishes or on arrival if idle.
pending bool
next recenterReq
)
if s.tickets != nil {
defer s.tickets.Close()
}
for {
// If we have a pending recenter, process it; otherwise block waiting.
if pending {
var req recenterReq
select {
case <-ctx.Done():
return
case req := <-s.recenter:
next = req // newer request supersedes the pending one
default:
// No newer request; process the one we have.
pending = false
s.processRecenter(ctx, next.cx, next.cz)
case req = <-s.recenter:
}
} else {
select {
case <-ctx.Done():
return
case req := <-s.recenter:
pending = true
next = req
for {
next, superseded := s.processRecenter(ctx, req.cx, req.cz)
if !superseded {
break
}
req = next
}
}
}
// processRecenter generates and sends the chunks newly in range of (cx, cz),
// pre-generates the predictive ring, and drops chunks that left the gen radius.
// pre-generates the predictive ring, and drops chunks that left client view.
// It is the only place `loaded`/`centerX`/`centerZ` are mutated.
func (s *streamer) processRecenter(ctx context.Context, cx, cz int32) {
func (s *streamer) processRecenter(ctx context.Context, cx, cz int32) (recenterReq, bool) {
s.centerX, s.centerZ, s.hasCenter = cx, cz, true
s.sendChunkCacheCenter(cx, cz)
@ -147,13 +140,49 @@ func (s *streamer) processRecenter(ctx context.Context, cx, cz int32) {
// Build the desired set: everything within genRadius (the union of what we
// send + the pre-gen ring). Sent = within viewRadius; pre-gen = the ring.
order := spiralOrder(cx, cz, s.genRadius)
desired := make(map[[2]int32]bool, len(order))
view := make(map[[2]int32]bool, (2*s.viewRadius+1)*(2*s.viewRadius+1))
// Split into "to send" (within viewRadius) and "pre-gen only" (the ring).
var toSend [][2]int32
var toPreGen [][2]int32
var viewTickets []world.ChunkPos
var prefetchTickets []world.ChunkPos
for _, key := range order {
desired[key] = true
if chunkDistanceFrom(cx, cz, key) <= int32(s.viewRadius) {
view[key] = true
toSend = append(toSend, key)
viewTickets = append(viewTickets, world.ChunkPos{X: key[0], Z: key[1]})
} else {
toPreGen = append(toPreGen, key)
prefetchTickets = append(prefetchTickets, world.ChunkPos{X: key[0], Z: key[1]})
}
}
if s.tickets != nil {
s.tickets.Replace(viewTickets, prefetchTickets)
}
// 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 {
if !view[key] {
s.sendForgetLevelChunk(key[0], key[1])
delete(s.loaded, key)
}
}
// Work is admitted in strict distance order. Each batch is at most poolSize,
// so a new recenter only waits for currently-running frames, not a full ring.
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.
if next, superseded := s.streamPriority(ctx, cx, cz, toPreGen, false); superseded {
return next, true
}
return recenterReq{}, false
}
func chunkDistanceFrom(cx, cz int32, key [2]int32) int32 {
dx := key[0] - cx
if dx < 0 {
dx = -dx
@ -162,28 +191,46 @@ func (s *streamer) processRecenter(ctx context.Context, cx, cz int32) {
if dz < 0 {
dz = -dz
}
if dx <= int32(s.viewRadius) && dz <= int32(s.viewRadius) {
toSend = append(toSend, key)
if dz > dx {
return dz
}
return dx
}
func (s *streamer) streamPriority(ctx context.Context, cx, cz int32, keys [][2]int32, send bool) (recenterReq, bool) {
for start := 0; start < len(keys); {
ring := chunkDistanceFrom(cx, cz, keys[start])
end := start
for end < len(keys) && end-start < s.poolSize && chunkDistanceFrom(cx, cz, keys[end]) == ring {
end++
}
if send {
s.parallelSend(ctx, keys[start:end])
} else {
toPreGen = append(toPreGen, key)
s.parallelGenerate(ctx, keys[start:end])
}
if next, ok := s.latestRecenter(); ok {
return next, true
}
select {
case <-ctx.Done():
return recenterReq{}, false
default:
}
start = end
}
return recenterReq{}, false
}
// Generate the send set in parallel, sending each frame as it completes.
// The pool guarantees `poolSize` concurrent cache.Frame calls; a single
// sender drains results and writes to the conn (serialized by writeMu).
s.parallelSend(ctx, toSend)
// Pre-generate the ring so the next recenter finds frames warm in the cache.
// Errors are irrelevant here (we don't send anything), so no sender.
s.parallelGenerate(ctx, toPreGen)
// Forget chunks that left the gen radius. The client drops them itself once
// it gets the new chunk-cache-center, but trimming our set keeps memory
// bounded and avoids re-sending.
for key := range s.loaded {
if !desired[key] {
s.sendForgetLevelChunk(key[0], key[1])
delete(s.loaded, key)
func (s *streamer) latestRecenter() (recenterReq, bool) {
var latest recenterReq
found := false
for {
select {
case latest = <-s.recenter:
found = true
default:
return latest, found
}
}
}
@ -261,9 +308,19 @@ func (s *streamer) parallelSend(ctx context.Context, keys [][2]int32) {
if r.err != nil {
// Send failed — the connection is likely closing. Bail out; the
// serve loop will tear us down via ctx cancel.
s.log.Debug("streamer send failed", "cx", r.cx, "cz", r.cz, "err", r.err)
if s.log != nil {
s.log.Debug("streamer frame failed", "cx", r.cx, "cz", r.cz, "err", r.err)
}
return
}
if s.conn != nil {
if err := s.conn.SendFramed(r.frame); err != nil {
if s.log != nil {
s.log.Debug("streamer send failed", "cx", r.cx, "cz", r.cz, "err", err)
}
return
}
}
s.loaded[[2]int32{r.cx, r.cz}] = true
}
}
@ -298,7 +355,7 @@ func (s *streamer) parallelGenerate(ctx context.Context, keys [][2]int32) {
return
default:
}
_, _ = s.cache.FrameErr(j.cx, j.cz) // warm the cache; discard the frame
_, _ = s.cache.FrameErrContext(ctx, j.cx, j.cz) // warm cache; discard frame
}
}()
}
@ -320,6 +377,7 @@ type frameJob struct{ cx, cz int32 }
// frameResult is a generated chunk frame plus any send error.
type frameResult struct {
cx, cz int32
frame []byte
err error
}
@ -332,7 +390,7 @@ func (s *streamer) generateWorker(ctx context.Context, jobs <-chan frameJob, res
return
default:
}
frame, err := s.cache.FrameErr(j.cx, j.cz)
frame, err := s.cache.FrameErrContext(ctx, j.cx, j.cz)
if err != nil {
select {
case results <- frameResult{cx: j.cx, cz: j.cz, err: err}:
@ -340,15 +398,8 @@ func (s *streamer) generateWorker(ctx context.Context, jobs <-chan frameJob, res
}
return
}
if err := s.conn.SendFramed(frame); err != nil {
select {
case results <- frameResult{cx: j.cx, cz: j.cz, err: err}:
case <-ctx.Done():
}
return
}
select {
case results <- frameResult{cx: j.cx, cz: j.cz}:
case results <- frameResult{cx: j.cx, cz: j.cz, frame: frame}:
case <-ctx.Done():
return
}

View file

@ -1,8 +1,14 @@
package network
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"regionio/internal/protocol"
"regionio/internal/world"
)
// TestSpiralOrderCenterFirst confirms the centre coordinate is returned first
@ -82,3 +88,174 @@ func TestRequestRecenterNonBlocking(t *testing.T) {
t.Fatal("requestRecenter blocked for 2s")
}
}
func TestStreamerSendsStrictlyNearFirst(t *testing.T) {
cache := world.NewCache(-1, func(cx, cz int32) *world.Chunk {
return world.NewChunk(cx, cz, world.BiomePlains)
})
recorder := &recordingConn{}
s := newStreamer(cache, NewConn(recorder), nil, 2)
s.genRadius = s.viewRadius
s.poolSize = 4
if _, superseded := s.processRecenter(context.Background(), 7, -3); superseded {
t.Fatal("unexpected recenter supersession")
}
defer s.tickets.Close()
lastDistance := int32(-1)
chunks := 0
for _, packet := range recorder.take(t) {
if packet.ID != protocol.PlayLevelChunk {
continue
}
r := packet.Body()
x, err := r.Int32()
if err != nil {
t.Fatal(err)
}
z, err := r.Int32()
if err != nil {
t.Fatal(err)
}
distance := chunkDistanceFrom(7, -3, [2]int32{x, z})
if distance < lastDistance {
t.Fatalf("chunk (%d,%d) at distance %d arrived after distance %d", x, z, distance, lastDistance)
}
lastDistance = distance
chunks++
}
if chunks != 25 {
t.Fatalf("level chunks sent = %d, want 25", chunks)
}
if got := cache.Stats().Tickets; got != 25 {
t.Fatalf("tickets = %d, want 25", got)
}
}
func TestStreamerQueuedRecenterStopsOldOuterRings(t *testing.T) {
cache := world.NewCache(-1, func(cx, cz int32) *world.Chunk {
return world.NewChunk(cx, cz, world.BiomePlains)
})
s := newStreamer(cache, nil, nil, 2)
s.genRadius = s.viewRadius
s.poolSize = 4
s.requestRecenter(100, 100)
next, superseded := s.processRecenter(context.Background(), 0, 0)
defer s.tickets.Close()
if !superseded || next != (recenterReq{cx: 100, cz: 100}) {
t.Fatalf("superseded=%v next=%+v, want latest (100,100)", superseded, next)
}
if len(s.loaded) != 1 || !s.loaded[[2]int32{0, 0}] {
t.Fatalf("old loaded set = %v, want only old center", s.loaded)
}
}
func TestStreamerRecenterForgetsViewAndReplacesPrefetchTickets(t *testing.T) {
cache := world.NewCacheWithLimit(-1, func(cx, cz int32) *world.Chunk {
return world.NewChunk(cx, cz, world.BiomePlains)
}, nil, 9)
recorder := &recordingConn{}
s := newStreamer(cache, NewConn(recorder), nil, 2)
s.viewRadius, s.genRadius, s.poolSize = 0, 1, 4
defer s.tickets.Close()
if _, superseded := s.processRecenter(context.Background(), 0, 0); superseded {
t.Fatal("unexpected first recenter supersession")
}
if len(s.loaded) != 1 || cache.Stats().Tickets != 9 {
t.Fatalf("first lifecycle loaded=%v stats=%+v", s.loaded, cache.Stats())
}
recorder.take(t)
if _, superseded := s.processRecenter(context.Background(), 10, 10); superseded {
t.Fatal("unexpected second recenter supersession")
}
if len(s.loaded) != 1 || !s.loaded[[2]int32{10, 10}] {
t.Fatalf("second loaded set = %v, want only (10,10)", s.loaded)
}
stats := cache.Stats()
if stats.Tickets != 9 || stats.Chunks > 9 || hasLevelChunkPacket(recorder.take(t), protocol.PlayForgetLevelChunk) != 1 {
t.Fatalf("second lifecycle stats=%+v; want 9 tickets, <=9 chunks and one forget", stats)
}
}
func hasLevelChunkPacket(packets []protocol.Packet, id int32) int {
count := 0
for _, packet := range packets {
if packet.ID == id {
count++
}
}
return count
}
func TestLoadSixteenClientStreamersBoundedAndReleasesTickets(t *testing.T) {
var active, peak atomic.Int32
gen := func(cx, cz int32) *world.Chunk {
now := active.Add(1)
for {
old := peak.Load()
if now <= old || peak.CompareAndSwap(old, now) {
break
}
}
time.Sleep(200 * time.Microsecond)
active.Add(-1)
return world.NewChunk(cx, cz, world.BiomePlains)
}
cache := world.NewCacheWithLimit(-1, gen, nil, 32)
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
const clients = 16
const groups = 4
recorders := make([]*recordingConn, clients)
for i := 0; i < clients; i++ {
recorders[i] = &recordingConn{}
s := newStreamer(cache, NewConn(recorders[i]), nil, 2)
// A 3x3 view produces 144 ticket claims. Four spawn regions exercise
// both shared tickets and concurrent independent generation.
s.viewRadius, s.genRadius, s.poolSize = 1, 1, 4
wg.Add(1)
go func(index int) {
defer wg.Done()
group := int32(index % groups)
s.requestRecenter(group*64, group*64)
s.run(ctx)
}(i)
}
deadline := time.Now().Add(120 * time.Second)
for (cache.Stats().Frames < groups*9 || cache.Stats().Tickets < clients*9 || clientsWithPacket(recorders, protocol.PlayLevelChunk) < clients) && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
stats := cache.Stats()
if stats.Frames < groups*9 || stats.Tickets != clients*9 || clientsWithPacket(recorders, protocol.PlayLevelChunk) != clients {
cancel()
wg.Wait()
t.Fatalf("loaded stats = %+v, want at least %d frames and %d tickets", stats, groups*9, clients*9)
}
if got := peak.Load(); got > 8 {
cancel()
wg.Wait()
t.Fatalf("peak concurrent generators = %d, want <= shared limit 8", got)
}
cancel()
wg.Wait()
deadline = time.Now().Add(5 * time.Second)
for cache.Stats().Tickets != 0 && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if stats = cache.Stats(); stats.Tickets != 0 || stats.Chunks > 32 {
t.Fatalf("after disconnect stats = %+v, want zero tickets and <=32 chunks", stats)
}
}
func clientsWithPacket(recorders []*recordingConn, id int32) int {
count := 0
for _, recorder := range recorders {
if recorder.countPacketID(id) > 0 {
count++
}
}
return count
}

View file

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"log/slog"
"runtime"
"sync"
"time"
@ -43,6 +44,11 @@ type Cache struct {
chunks map[[2]int32]*Chunk
frames map[[2]int32][]byte
dirty map[[2]int32]uint64
tickets map[[2]int32]int
inflight map[[2]int32]int
// frameSlots bounds expensive load/light/encode work across all players.
// Streamers may have their own workers, but they share this admission gate.
frameSlots chan struct{}
// LRU bookkeeping: order is MRU(front)→LRU(back); index gives O(1) lookup.
order *list.List // elements are *[2]int32; nil when maxChunks==0
index map[[2]int32]*list.Element
@ -61,13 +67,23 @@ type chunkLoad struct {
// threshold using gen to produce missing chunks. It has no persistence and no
// eviction limit (unbounded; for tests/flat worlds).
func NewCache(threshold int32, gen Generator) *Cache {
workers := runtime.GOMAXPROCS(0)
if workers > 8 {
workers = 8
}
if workers < 2 {
workers = 2
}
c := &Cache{
threshold: threshold,
gen: gen,
chunks: make(map[[2]int32]*Chunk),
frames: make(map[[2]int32][]byte),
dirty: make(map[[2]int32]uint64),
tickets: make(map[[2]int32]int),
inflight: make(map[[2]int32]int),
loads: make(map[[2]int32]*chunkLoad),
frameSlots: make(chan struct{}, workers),
}
return c
}
@ -124,6 +140,11 @@ func (c *Cache) evictIfNeeded() {
return
}
key := *back.Value.(*[2]int32)
if c.tickets[key] > 0 || c.inflight[key] > 0 {
c.order.MoveToFront(back)
checked++
continue
}
// Never drop a dirty chunk: it has unsaved edits. Bump it to MRU and
// stop evicting this cycle; the autosave flush will clear it and the
// next eviction pass can reclaim it.
@ -209,6 +230,25 @@ func (c *Cache) Frame(cx, cz int32) []byte {
// FrameErr returns a framed chunk packet while preserving storage failures.
// Callers serving clients should prefer it to Frame so corruption is observable.
func (c *Cache) FrameErr(cx, cz int32) ([]byte, error) {
return c.FrameErrContext(context.Background(), cx, cz)
}
// FrameErrContext is FrameErr with cancellable admission to the shared frame
// worker budget. Cancellation prevents obsolete streamer jobs from starting;
// an operation already admitted completes so cache state is never half-built.
func (c *Cache) FrameErrContext(ctx context.Context, cx, cz int32) ([]byte, error) {
select {
case c.frameSlots <- struct{}{}:
defer func() { <-c.frameSlots }()
case <-ctx.Done():
return nil, ctx.Err()
}
release := c.beginUse([2]int32{cx, cz})
defer release()
return c.frameErr(cx, cz)
}
func (c *Cache) frameErr(cx, cz int32) ([]byte, error) {
key := [2]int32{cx, cz}
for {
@ -258,6 +298,8 @@ func (c *Cache) GetBlock(x, y, z int) uint16 {
}
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 StateAir
@ -267,6 +309,8 @@ func (c *Cache) GetBlock(x, y, z int) uint16 {
// 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})
defer release()
ch, err := c.chunkAtErr(cx, cz)
if err != nil {
return nil, err
@ -298,6 +342,8 @@ func (c *Cache) SetBlockWithLight(x, y, z int, state uint16) (bool, []ChunkPos)
}
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 false, nil
@ -321,6 +367,7 @@ func (c *Cache) SetBlockWithLight(x, y, z int, state uint16) (bool, []ChunkPos)
// its light from the authoritative blocks.
ch.mu.Lock()
ch.lightReady = false
ch.lightValidated = false
ch.mu.Unlock()
lightChanged = []ChunkPos{{X: cx, Z: cz}}
}

View file

@ -18,7 +18,7 @@ func (c *Cache) ensureLight(chunk *Chunk) error {
func (c *Cache) ensureLightLocked(chunk *Chunk) error {
for {
center, revision := chunk.snapshot()
if center.lightReady {
if center.lightReady && center.lightValidated {
return nil
}
@ -48,11 +48,18 @@ func (c *Cache) ensureLightLocked(chunk *Chunk) error {
chunk.mu.Unlock()
continue
}
chunk.installLight(volume)
changed := chunk.installLight(volume)
if center.lightReady && changed {
revision = chunk.revision.Add(1)
}
chunk.mu.Unlock()
c.mu.Lock()
delete(c.frames, [2]int32{chunk.X, chunk.Z})
key := [2]int32{chunk.X, chunk.Z}
delete(c.frames, key)
if c.store != nil && center.lightReady && changed {
c.dirty[key] = revision
}
c.mu.Unlock()
return nil
}

View file

@ -0,0 +1,124 @@
package world
import "sync"
func (c *Cache) beginUse(key [2]int32) func() {
c.mu.Lock()
c.inflight[key]++
c.mu.Unlock()
return func() {
c.mu.Lock()
c.inflight[key]--
if c.inflight[key] <= 0 {
delete(c.inflight, key)
}
c.evictIfNeeded()
c.mu.Unlock()
}
}
// TicketLevel describes why a streamer keeps a chunk resident. View tickets
// are client-visible; Prefetch tickets retain the predictive outer ring.
type TicketLevel uint8
const (
TicketView TicketLevel = iota
TicketPrefetch
)
// TicketSet is one owner's atomic chunk-residency claim. A streamer replaces
// the complete set on recenter and closes it on disconnect. Multiple sets may
// overlap; a chunk becomes evictable only after its final owner releases it.
type TicketSet struct {
mu sync.Mutex
cache *Cache
held map[[2]int32]TicketLevel
closed bool
}
// NewTicketSet creates an empty ticket set associated with this cache.
func (c *Cache) NewTicketSet() *TicketSet {
return &TicketSet{cache: c, held: make(map[[2]int32]TicketLevel)}
}
// Replace atomically changes the ticket set. View entries take precedence when
// a coordinate is present in both slices.
func (t *TicketSet) Replace(view, prefetch []ChunkPos) {
if t == nil || t.cache == nil {
return
}
desired := make(map[[2]int32]TicketLevel, len(view)+len(prefetch))
for _, pos := range prefetch {
desired[[2]int32{pos.X, pos.Z}] = TicketPrefetch
}
for _, pos := range view {
desired[[2]int32{pos.X, pos.Z}] = TicketView
}
t.mu.Lock()
defer t.mu.Unlock()
if t.closed {
return
}
t.cache.mu.Lock()
for key := range t.held {
if _, keep := desired[key]; keep {
continue
}
t.cache.tickets[key]--
if t.cache.tickets[key] <= 0 {
delete(t.cache.tickets, key)
}
}
for key := range desired {
if _, alreadyHeld := t.held[key]; !alreadyHeld {
t.cache.tickets[key]++
}
}
t.held = desired
t.cache.evictIfNeeded()
t.cache.mu.Unlock()
}
// Close releases every ticket. It is safe to call more than once.
func (t *TicketSet) Close() {
if t == nil || t.cache == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
if t.closed {
return
}
t.cache.mu.Lock()
for key := range t.held {
t.cache.tickets[key]--
if t.cache.tickets[key] <= 0 {
delete(t.cache.tickets, key)
}
}
clear(t.held)
t.closed = true
t.cache.evictIfNeeded()
t.cache.mu.Unlock()
}
// CacheStats is a concurrency-safe lifecycle snapshot used by diagnostics and
// load tests.
type CacheStats struct {
Chunks int
Frames int
TicketedChunks int
Tickets int
}
// Stats returns current cache and ticket counts.
func (c *Cache) Stats() CacheStats {
c.mu.Lock()
defer c.mu.Unlock()
stats := CacheStats{Chunks: len(c.chunks), Frames: len(c.frames), TicketedChunks: len(c.tickets)}
for _, count := range c.tickets {
stats.Tickets += count
}
return stats
}

View file

@ -0,0 +1,78 @@
package world
import (
"context"
"errors"
"testing"
)
func TestTicketPinsChunkUntilRelease(t *testing.T) {
cache := NewCacheWithLimit(-1, func(cx, cz int32) *Chunk {
return NewChunk(cx, cz, BiomePlains)
}, nil, 2)
tickets := cache.NewTicketSet()
tickets.Replace([]ChunkPos{{X: 0, Z: 0}}, nil)
for x := int32(0); x < 4; x++ {
if _, err := cache.FrameErr(x, 0); err != nil {
t.Fatal(err)
}
}
if !hasChunk(cache, 0, 0) {
t.Fatal("ticketed chunk was evicted")
}
if got := cache.Stats().Tickets; got != 1 {
t.Fatalf("ticket count = %d, want 1", got)
}
tickets.Close()
if got := cache.Stats(); got.Tickets != 0 || got.Chunks > 2 {
t.Fatalf("after release stats = %+v, want no tickets and <=2 chunks", got)
}
}
func TestOverlappingTicketSetsRequireFinalRelease(t *testing.T) {
cache := NewCacheWithLimit(-1, func(cx, cz int32) *Chunk {
return NewChunk(cx, cz, BiomePlains)
}, nil, 1)
first, second := cache.NewTicketSet(), cache.NewTicketSet()
pos := []ChunkPos{{X: 0, Z: 0}}
first.Replace(pos, nil)
second.Replace(nil, pos)
if _, err := cache.FrameErr(0, 0); err != nil {
t.Fatal(err)
}
first.Close()
if got := cache.Stats().Tickets; got != 1 {
t.Fatalf("tickets after first close = %d, want 1", got)
}
if _, err := cache.FrameErr(1, 0); err != nil {
t.Fatal(err)
}
if !hasChunk(cache, 0, 0) {
t.Fatal("chunk was evicted while second owner still held it")
}
second.Close()
second.Close() // idempotent
if got := cache.Stats(); got.Tickets != 0 || got.Chunks > 1 {
t.Fatalf("after final close stats = %+v", got)
}
}
func TestFrameAdmissionCanBeCancelled(t *testing.T) {
cache := NewCache(-1, func(cx, cz int32) *Chunk {
return NewChunk(cx, cz, BiomePlains)
})
for i := 0; i < cap(cache.frameSlots); i++ {
cache.frameSlots <- struct{}{}
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := cache.FrameErrContext(ctx, 0, 0)
if !errors.Is(err, context.Canceled) {
t.Fatalf("FrameErrContext error = %v, want context.Canceled", err)
}
for i := 0; i < cap(cache.frameSlots); i++ {
<-cache.frameSlots
}
}

View file

@ -77,6 +77,9 @@ type Chunk struct {
skyLight [SectionCount]*[2048]byte
blockLight [SectionCount]*[2048]byte
lightReady bool
// lightValidated is runtime-only. Persisted arrays are ready to read but are
// reconciled with current neighbor blocks once after entering a live cache.
lightValidated bool
biome uint16 // fallback uniform biome when biomes[si] is nil
}
@ -140,6 +143,7 @@ func (c *Chunk) SetBlock(lx, y, lz int, state uint16) {
if changed {
c.mu.Lock()
c.lightReady = false
c.lightValidated = false
c.mu.Unlock()
}
}
@ -243,6 +247,7 @@ func (c *Chunk) snapshot() (*Chunk, uint64) {
}
}
clone.lightReady = c.lightReady
clone.lightValidated = c.lightValidated
return clone, revision
}

View file

@ -331,6 +331,7 @@ func (c *Chunk) installLight(v *lightVolume) bool {
c.skyLight = sky
c.blockLight = block
c.lightReady = true
c.lightValidated = true
return changed
}

View file

@ -185,6 +185,85 @@ func TestStoreLightRoundTrip(t *testing.T) {
}
}
func TestCacheReconcilesPersistedLightWithLoadedNeighbor(t *testing.T) {
dir := t.TempDir()
store, err := NewStore(dir)
if err != nil {
t.Fatal(err)
}
glowstone := nameToStateID("minecraft:glowstone", nil)
left := NewChunk(0, 0, BiomePlains)
left.SetBlock(15, 100, 8, glowstone)
if err := store.SaveChunk(left); err != nil {
t.Fatal(err)
}
// Simulate a chunk saved before its unloaded neighbor gained a light source.
right := NewChunk(1, 0, BiomePlains)
right.lightReady = true
if err := store.SaveChunk(right); err != nil {
t.Fatal(err)
}
if err := store.Close(); err != nil {
t.Fatal(err)
}
store, err = NewStore(dir)
if err != nil {
t.Fatal(err)
}
defer store.Close()
cache := NewCacheWithLimit(-1, func(cx, cz int32) *Chunk {
return NewChunk(cx, cz, BiomePlains)
}, store, 1)
tickets := cache.NewTicketSet()
tickets.Replace([]ChunkPos{{X: 1, Z: 0}}, nil)
loaded, err := cache.chunkAtErr(1, 0)
if err != nil {
t.Fatal(err)
}
if _, block, ready := loaded.LightAt(0, 100, 8); !ready || block != 0 {
t.Fatalf("persisted pre-reconcile light = %d ready=%v, want stale zero", block, ready)
}
if _, err := cache.FrameErr(1, 0); err != nil {
t.Fatal(err)
}
if _, block, ready := loaded.LightAt(0, 100, 8); !ready || block != 14 {
t.Fatalf("reconciled border light = %d ready=%v, want 14", block, ready)
}
if err := cache.SaveAll(); err != nil {
t.Fatal(err)
}
tickets.Close()
if _, err := cache.FrameErr(10, 0); err != nil {
t.Fatal(err)
}
if hasChunk(cache, 1, 0) {
t.Fatal("released light chunk remained resident after LRU replacement")
}
reloadedAfterEviction, err := cache.chunkAtErr(1, 0)
if err != nil {
t.Fatal(err)
}
if _, block, ready := reloadedAfterEviction.LightAt(0, 100, 8); !ready || block != 14 {
t.Fatalf("light after ticket unload/reload = %d ready=%v, want 14", block, ready)
}
if err := store.Close(); err != nil {
t.Fatal(err)
}
store, err = NewStore(dir)
if err != nil {
t.Fatal(err)
}
defer store.Close()
reloaded, err := store.LoadChunk(1, 0)
if err != nil {
t.Fatal(err)
}
if _, block, ready := reloaded.LightAt(0, 100, 8); !ready || block != 14 {
t.Fatalf("persisted reconciled light = %d ready=%v, want 14", block, ready)
}
}
// TestStoreSaveLoadIntegration is the end-to-end "world survives restart" test:
// generate a chunk via a store-backed cache, edit a block, SaveAll, then open a
// fresh cache over the same store and confirm the edit is present.