RegionIO/internal/network/streamer.go
2026-07-21 10:04:56 +03:00

431 lines
11 KiB
Go

package network
import (
"context"
"log/slog"
"runtime"
"sync"
"regionio/internal/protocol"
"regionio/internal/world"
)
// 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 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.
// - streamer goroutine: owns `loaded`, `centerX/Z`, the pool, and the sender.
// - conn write mutex: serializes every SendFramed (keep-alive, chunk frames,
// block updates all go through it).
// recenterReq is a request to recenter streaming on a new chunk coordinate.
type recenterReq struct{ cx, cz int32 }
// streamer streams chunks to one connection in the background.
type streamer struct {
cache *world.Cache
conn *Conn
log *slog.Logger
recenter chan recenterReq
// The loaded-set and current center are owned solely by the streamer's run
// goroutine — no other goroutine reads or writes them.
loaded map[[2]int32]bool
centerX int32
centerZ int32
hasCenter bool
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
// sent an implausible value. Matches the legacy chunkRadius.
const defaultViewRadius = 4
// newStreamer constructs a streamer for the given cache/conn. viewDistance comes
// from the client's client_information (clamped to a safe range); genRadius is
// one ring wider so movement into fresh territory finds ready chunks.
func newStreamer(cache *world.Cache, conn *Conn, log *slog.Logger, viewDistance int) *streamer {
if viewDistance < 2 {
viewDistance = defaultViewRadius
}
if viewDistance > 16 {
viewDistance = 16
}
pool := runtime.NumCPU()
if pool > 8 {
pool = 8
}
if pool < 2 {
pool = 2
}
s := &streamer{
cache: cache,
conn: conn,
log: log,
recenter: make(chan recenterReq, 4),
loaded: make(map[[2]int32]bool),
viewRadius: 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
// the streamer is busy, the latest request wins (buffered channel drains the
// stale ones on next select).
func (s *streamer) requestRecenter(cx, cz int32) {
for {
select {
case s.recenter <- recenterReq{cx, cz}:
return
default:
// Channel full: a previous request is still queued. Drop it so the
// newest recenter is what the streamer acts on next.
select {
case <-s.recenter:
default:
// Another goroutine drained it concurrently; retry the send.
continue
}
}
}
}
// run is the streamer's main loop. It blocks until ctx is cancelled (on
// 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) {
if s.tickets != nil {
defer s.tickets.Close()
}
for {
var req recenterReq
select {
case <-ctx.Done():
return
case req = <-s.recenter:
}
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 client view.
// It is the only place `loaded`/`centerX`/`centerZ` are mutated.
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)
// 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)
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 {
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
}
dz := key[1] - cz
if dz < 0 {
dz = -dz
}
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 {
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
}
func (s *streamer) latestRecenter() (recenterReq, bool) {
var latest recenterReq
found := false
for {
select {
case latest = <-s.recenter:
found = true
default:
return latest, found
}
}
}
func (s *streamer) sendChunkCacheCenter(cx, cz int32) {
if s.conn == nil {
return
}
w := protocol.NewWriter(8)
w.VarInt(cx)
w.VarInt(cz)
_ = s.conn.SendWriter(protocol.PlayChunkCacheCenter, w)
}
func (s *streamer) sendForgetLevelChunk(cx, cz int32) {
if s.conn == nil {
return
}
w := protocol.NewWriter(8)
w.Int32(cz)
w.Int32(cx)
_ = 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).
// 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
for _, k := range keys {
if s.loaded[k] {
continue
}
pending = append(pending, frameJob{k[0], k[1]})
}
if len(pending) == 0 {
return
}
jobs := make(chan frameJob, len(pending))
results := make(chan frameResult, len(pending))
var wg sync.WaitGroup
workers := s.poolSize
if workers > len(pending) {
workers = len(pending)
}
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
s.generateWorker(ctx, jobs, results)
}()
}
// Feed the jobs.
go func() {
for _, j := range pending {
select {
case <-ctx.Done():
close(jobs)
return
case jobs <- j:
}
}
close(jobs)
}()
// Sender: drain results serially so writes don't interleave.
go func() {
wg.Wait()
close(results)
}()
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)
}
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
}
}
// 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) {
var pending []frameJob
for _, k := range keys {
if s.loaded[k] {
continue
}
pending = append(pending, frameJob{k[0], k[1]})
}
if len(pending) == 0 {
return
}
jobs := make(chan frameJob, len(pending))
var wg sync.WaitGroup
workers := s.poolSize
if workers > len(pending) {
workers = len(pending)
}
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs {
select {
case <-ctx.Done():
return
default:
}
_, _ = s.cache.FrameErrContext(ctx, j.cx, j.cz) // warm cache; discard frame
}
}()
}
Loop:
for _, j := range pending {
select {
case <-ctx.Done():
break Loop
case jobs <- j:
}
}
close(jobs)
wg.Wait()
}
// frameJob is one chunk coordinate awaiting generation.
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
}
// generateWorker reads jobs, generates+frames the chunk via the (thread-safe)
// cache, and sends the frame to the conn. It exits when jobs closes.
func (s *streamer) generateWorker(ctx context.Context, jobs <-chan frameJob, results chan<- frameResult) {
for j := range jobs {
select {
case <-ctx.Done():
return
default:
}
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}:
case <-ctx.Done():
}
return
}
select {
case results <- frameResult{cx: j.cx, cz: j.cz, frame: frame}:
case <-ctx.Done():
return
}
}
}
// 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.
func spiralOrder(cx, cz int32, radius int) [][2]int32 {
if radius < 0 {
radius = 0
}
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
}
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
}
}
return out
}