Implement multiplayer persistence and vanilla lighting
This commit is contained in:
parent
8f7cacf9d9
commit
cae06eb97e
47 changed files with 3784 additions and 465 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -19,6 +19,7 @@
|
||||||
# Logs and runtime artefacts
|
# Logs and runtime artefacts
|
||||||
/logs/
|
/logs/
|
||||||
*.log
|
*.log
|
||||||
|
/world/regionio-world.json
|
||||||
|
|
||||||
# Editor / tool local config
|
# Editor / tool local config
|
||||||
/.claude/
|
/.claude/
|
||||||
|
|
|
||||||
10
Makefile
Normal file
10
Makefile
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
.PHONY: test test-race verify
|
||||||
|
|
||||||
|
test:
|
||||||
|
go 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)'
|
||||||
|
|
||||||
|
verify: test test-race
|
||||||
57
README.md
57
README.md
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
A Minecraft Java Edition server core written in Go, targeting version
|
A Minecraft Java Edition server core written in Go, targeting version
|
||||||
**26.1.2** (protocol **775**). RegionIO implements the connection lifecycle
|
**26.1.2** (protocol **775**). RegionIO implements the connection lifecycle
|
||||||
(status → login → configuration → play), chunk streaming, block editing, and a
|
(status → login → configuration → play), multiplayer chunk streaming, shared
|
||||||
vanilla-faithful overworld generator built on the real `noise_router`
|
block editing, persistent worlds, and an overworld generator built on the real
|
||||||
`final_density` tree.
|
`noise_router` `final_density` tree.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
|
|
@ -12,14 +12,22 @@ vanilla-faithful overworld generator built on the real `noise_router`
|
||||||
state machine with zlib compression, keep-alive, and chunk streaming.
|
state machine with zlib compression, keep-alive, and chunk streaming.
|
||||||
- **Registries**: 28 synchronized registries + tags, captured verbatim from the
|
- **Registries**: 28 synchronized registries + tags, captured verbatim from the
|
||||||
26.1.2 vanilla server and sent during configuration.
|
26.1.2 vanilla server and sent during configuration.
|
||||||
- **World**: in-memory chunk cache with memoized, compression-ready
|
- **World**: revisioned, concurrency-safe chunk snapshots; memoized
|
||||||
`level_chunk_with_light` frames; paletted block containers; heightmaps.
|
`level_chunk_with_light` frames; bounded LRU cache; Anvil `.mca` persistence
|
||||||
- **Generation**: bit-faithful overworld terrain from the embedded datapack
|
with autosave and seed metadata.
|
||||||
|
- **Generation**: vanilla-derived overworld terrain from the embedded datapack
|
||||||
(`ImprovedNoise`/`PerlinNoise`/`BlendedNoise`/`NormalNoise` + the density
|
(`ImprovedNoise`/`PerlinNoise`/`BlendedNoise`/`NormalNoise` + the density
|
||||||
function interpreter), plus multi-noise **biomes** (per-chunk, surface layer)
|
function interpreter), 3D multi-noise biomes, surface-rule interpretation,
|
||||||
via the vanilla `Climate` finder over the official biome parameter table.
|
deterministic decoration, and basic template structures.
|
||||||
- **Gameplay**: creative block place/break, hotbar item→block mapping, chat,
|
- **Gameplay**: four-player session registry; player join/leave and movement
|
||||||
teleport ack, and a randomized bedrock floor / beach & gravel surface pass.
|
synchronization; chunk-scoped visibility for players and mobs; shared
|
||||||
|
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.
|
||||||
|
- **Safety**: duplicate chunk generation is coalesced; corrupt stored chunks are
|
||||||
|
not silently regenerated or overwritten; a world cannot reopen with another
|
||||||
|
seed.
|
||||||
|
|
||||||
## Build & run
|
## Build & run
|
||||||
|
|
||||||
|
|
@ -30,16 +38,39 @@ go run ./cmd/regionio -seed 12345
|
||||||
|
|
||||||
The world seed defaults to `0`; override it with the `-seed` flag or the
|
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`.
|
`REGIONIO_SEED` environment variable. The server listens on `0.0.0.0:25565`.
|
||||||
|
Changing the seed for an existing world directory is rejected.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
```
|
```
|
||||||
go test ./...
|
go test ./...
|
||||||
|
go test -race ./internal/network ./internal/server ./internal/world \
|
||||||
|
-run 'Test(Integration|BoundaryEdit|PlayerInfo|PlayerRegistry|Concurrent|Incremental|EncodeLight|Cache|Store|Eviction|Region)'
|
||||||
|
# or run both gates:
|
||||||
|
make verify
|
||||||
```
|
```
|
||||||
|
|
||||||
Parity tests (`internal/world/vanilla_parity_test.go`) compare generated surface
|
The integration suite exercises four clients across two visibility regions:
|
||||||
heights against captures from the official server and skip when no capture is
|
join, movement, leaving, mob visibility, and local block/light updates. A
|
||||||
present.
|
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.
|
||||||
|
|
||||||
|
## v0.3 scope
|
||||||
|
|
||||||
|
RegionIO v0.3 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.
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ func main() {
|
||||||
saveCtx, saveStop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
saveCtx, saveStop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
autosaveDone := srv.Chunks().StartAutosave(saveCtx, log, 30*time.Second)
|
autosaveDone := srv.Chunks().StartAutosave(saveCtx, log, 30*time.Second)
|
||||||
|
|
||||||
srv.StartSpawning()
|
srv.StartSpawning(saveCtx)
|
||||||
|
|
||||||
ln := network.NewListener(srv, log)
|
ln := network.NewListener(srv, log)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,6 @@ import (
|
||||||
"regionio/internal/registry"
|
"regionio/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ClientSettings holds the subset of client_information we currently track.
|
|
||||||
type ClientSettings struct {
|
|
||||||
Locale string
|
|
||||||
ViewDistance int8
|
|
||||||
ChatMode int32
|
|
||||||
MainHand int32
|
|
||||||
}
|
|
||||||
|
|
||||||
// beginConfiguration is called on entering the configuration phase. It mirrors
|
// beginConfiguration is called on entering the configuration phase. It mirrors
|
||||||
// the vanilla opening sequence: server brand, enabled feature flags, then the
|
// the vanilla opening sequence: server brand, enabled feature flags, then the
|
||||||
// known-packs negotiation. The client's known-packs reply triggers the registry
|
// known-packs negotiation. The client's known-packs reply triggers the registry
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"regionio/internal/protocol"
|
"regionio/internal/protocol"
|
||||||
"regionio/internal/server"
|
"regionio/internal/server"
|
||||||
|
|
@ -56,7 +57,12 @@ func (c *Conn) RemoteAddr() net.Addr { return c.raw.RemoteAddr() }
|
||||||
|
|
||||||
// ReadPacket reads the next frame using the current compression settings.
|
// ReadPacket reads the next frame using the current compression settings.
|
||||||
func (c *Conn) ReadPacket() (protocol.Packet, error) {
|
func (c *Conn) ReadPacket() (protocol.Packet, error) {
|
||||||
return protocol.ReadPacket(c.br, c.compressionThreshold)
|
if err := c.raw.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil {
|
||||||
|
return protocol.Packet{}, err
|
||||||
|
}
|
||||||
|
pkt, err := protocol.ReadPacket(c.br, c.compressionThreshold)
|
||||||
|
_ = c.raw.SetReadDeadline(time.Time{})
|
||||||
|
return pkt, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send writes a packet with the given ID and pre-encoded body. Safe for
|
// Send writes a packet with the given ID and pre-encoded body. Safe for
|
||||||
|
|
|
||||||
276
internal/network/entity_packets_test.go
Normal file
276
internal/network/entity_packets_test.go
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
package network
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"regionio/internal/protocol"
|
||||||
|
"regionio/internal/server"
|
||||||
|
"regionio/internal/world"
|
||||||
|
)
|
||||||
|
|
||||||
|
func readServerPacket(t *testing.T, c net.Conn) protocol.Packet {
|
||||||
|
t.Helper()
|
||||||
|
// net.Pipe has no buffering; read the frame from a goroutine-driven write.
|
||||||
|
br := make([]byte, 4096)
|
||||||
|
n, err := c.Read(br)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading packet: %v", err)
|
||||||
|
}
|
||||||
|
pkt, err := protocol.ReadPacket(bufio.NewReader(bytes.NewReader(br[:n])), -1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decoding packet: %v", err)
|
||||||
|
}
|
||||||
|
return pkt
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendEntityTeleportUsesPositionMoveRotation(t *testing.T) {
|
||||||
|
serverSide, clientSide := net.Pipe()
|
||||||
|
defer serverSide.Close()
|
||||||
|
defer clientSide.Close()
|
||||||
|
|
||||||
|
h := &handler{conn: NewConn(serverSide)}
|
||||||
|
ent := &world.Entity{
|
||||||
|
ID: 42,
|
||||||
|
X: 1.25,
|
||||||
|
Y: 65.5,
|
||||||
|
Z: -3.75,
|
||||||
|
Yaw: 90,
|
||||||
|
Pitch: 15,
|
||||||
|
VelocityX: 80,
|
||||||
|
VelocityY: -160,
|
||||||
|
VelocityZ: 240,
|
||||||
|
}
|
||||||
|
|
||||||
|
errc := make(chan error, 1)
|
||||||
|
go func() { errc <- h.sendEntityTeleport(ent) }()
|
||||||
|
|
||||||
|
pkt := readServerPacket(t, clientSide)
|
||||||
|
if err := <-errc; err != nil {
|
||||||
|
t.Fatalf("sendEntityTeleport: %v", err)
|
||||||
|
}
|
||||||
|
if pkt.ID != protocol.PlayTeleportEntity {
|
||||||
|
t.Fatalf("packet id = %#x, want %#x", pkt.ID, protocol.PlayTeleportEntity)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := pkt.Body()
|
||||||
|
if id, err := r.VarInt(); err != nil || id != ent.ID {
|
||||||
|
t.Fatalf("entity id = %d, %v; want %d", id, err, ent.ID)
|
||||||
|
}
|
||||||
|
coords := []struct {
|
||||||
|
name string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"x", ent.X},
|
||||||
|
{"y", ent.Y},
|
||||||
|
{"z", ent.Z},
|
||||||
|
}
|
||||||
|
for _, tc := range coords {
|
||||||
|
got, err := r.Float64()
|
||||||
|
if err != nil || got != tc.want {
|
||||||
|
t.Fatalf("%s = %v, %v; want %v", tc.name, got, err, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
velocities := []struct {
|
||||||
|
name string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"vx", float64(ent.VelocityX) / 8000.0},
|
||||||
|
{"vy", float64(ent.VelocityY) / 8000.0},
|
||||||
|
{"vz", float64(ent.VelocityZ) / 8000.0},
|
||||||
|
}
|
||||||
|
for _, tc := range velocities {
|
||||||
|
got, err := r.Float64()
|
||||||
|
if err != nil || got != tc.want {
|
||||||
|
t.Fatalf("%s = %v, %v; want %v", tc.name, got, err, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if yaw, err := r.Float32(); err != nil || yaw != ent.Yaw {
|
||||||
|
t.Fatalf("yaw = %v, %v; want %v", yaw, err, ent.Yaw)
|
||||||
|
}
|
||||||
|
if pitch, err := r.Float32(); err != nil || pitch != ent.Pitch {
|
||||||
|
t.Fatalf("pitch = %v, %v; want %v", pitch, err, ent.Pitch)
|
||||||
|
}
|
||||||
|
if flags, err := r.Int32(); err != nil || flags != 0 {
|
||||||
|
t.Fatalf("relative flags = %d, %v; want 0", flags, err)
|
||||||
|
}
|
||||||
|
if onGround, err := r.Bool(); err != nil || !onGround {
|
||||||
|
t.Fatalf("onGround = %v, %v; want true", onGround, err)
|
||||||
|
}
|
||||||
|
if rem := r.Remaining(); rem != 0 {
|
||||||
|
t.Fatalf("remaining bytes = %d, want 0", rem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendAddEntityLayout(t *testing.T) {
|
||||||
|
serverSide, clientSide := net.Pipe()
|
||||||
|
defer serverSide.Close()
|
||||||
|
defer clientSide.Close()
|
||||||
|
|
||||||
|
uuid := [16]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10}
|
||||||
|
h := &handler{conn: NewConn(serverSide)}
|
||||||
|
ent := &world.Entity{
|
||||||
|
ID: 43,
|
||||||
|
UUID: uuid,
|
||||||
|
TypeID: 77,
|
||||||
|
X: 10.5,
|
||||||
|
Y: 66.25,
|
||||||
|
Z: -20.75,
|
||||||
|
Pitch: 45,
|
||||||
|
Yaw: 180,
|
||||||
|
HeadYaw: 90,
|
||||||
|
VelocityX: 123,
|
||||||
|
VelocityY: -456,
|
||||||
|
VelocityZ: 789,
|
||||||
|
}
|
||||||
|
|
||||||
|
errc := make(chan error, 1)
|
||||||
|
go func() { errc <- h.sendAddEntity(ent) }()
|
||||||
|
|
||||||
|
pkt := readServerPacket(t, clientSide)
|
||||||
|
if err := <-errc; err != nil {
|
||||||
|
t.Fatalf("sendAddEntity: %v", err)
|
||||||
|
}
|
||||||
|
if pkt.ID != protocol.PlayAddEntity {
|
||||||
|
t.Fatalf("packet id = %#x, want %#x", pkt.ID, protocol.PlayAddEntity)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := pkt.Body()
|
||||||
|
if id, err := r.VarInt(); err != nil || id != ent.ID {
|
||||||
|
t.Fatalf("entity id = %d, %v; want %d", id, err, ent.ID)
|
||||||
|
}
|
||||||
|
if got, err := r.UUID(); err != nil || got != uuid {
|
||||||
|
t.Fatalf("uuid = %x, %v; want %x", got, err, uuid)
|
||||||
|
}
|
||||||
|
if typ, err := r.VarInt(); err != nil || typ != int32(ent.TypeID) {
|
||||||
|
t.Fatalf("type id = %d, %v; want %d", typ, err, ent.TypeID)
|
||||||
|
}
|
||||||
|
coords := []struct {
|
||||||
|
name string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"x", ent.X},
|
||||||
|
{"y", ent.Y},
|
||||||
|
{"z", ent.Z},
|
||||||
|
}
|
||||||
|
for _, tc := range coords {
|
||||||
|
got, err := r.Float64()
|
||||||
|
if err != nil || got != tc.want {
|
||||||
|
t.Fatalf("%s = %v, %v; want %v", tc.name, got, err, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
angles := []struct {
|
||||||
|
name string
|
||||||
|
want byte
|
||||||
|
}{
|
||||||
|
{"pitch", byte(ent.Pitch * 256.0 / 360.0)},
|
||||||
|
{"yaw", byte(ent.Yaw * 256.0 / 360.0)},
|
||||||
|
{"headYaw", byte(ent.HeadYaw * 256.0 / 360.0)},
|
||||||
|
}
|
||||||
|
for _, tc := range angles {
|
||||||
|
got, err := r.ReadByte()
|
||||||
|
if err != nil || got != tc.want {
|
||||||
|
t.Fatalf("%s = %d, %v; want %d", tc.name, got, err, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if data, err := r.VarInt(); err != nil || data != 0 {
|
||||||
|
t.Fatalf("data = %d, %v; want 0", data, err)
|
||||||
|
}
|
||||||
|
encodedVelocities := []struct {
|
||||||
|
name string
|
||||||
|
want uint16
|
||||||
|
}{
|
||||||
|
{"velocityX", uint16(ent.VelocityX)},
|
||||||
|
{"velocityY", uint16(ent.VelocityY)},
|
||||||
|
{"velocityZ", uint16(ent.VelocityZ)},
|
||||||
|
}
|
||||||
|
for _, tc := range encodedVelocities {
|
||||||
|
got, err := r.Uint16()
|
||||||
|
if err != nil || got != tc.want {
|
||||||
|
t.Fatalf("%s = %d, %v; want %d", tc.name, got, err, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rem := r.Remaining(); rem != 0 {
|
||||||
|
t.Fatalf("remaining bytes = %d, want 0", rem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlayerInfoPacketLayouts(t *testing.T) {
|
||||||
|
serverSide, clientSide := net.Pipe()
|
||||||
|
defer serverSide.Close()
|
||||||
|
defer clientSide.Close()
|
||||||
|
|
||||||
|
profile := server.Profile{Name: "Alice", UUID: server.OfflineUUID("Alice")}
|
||||||
|
h := &handler{conn: NewConn(serverSide)}
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() { errCh <- h.sendPlayerInfoAdd(profile) }()
|
||||||
|
pkt := readServerPacket(t, clientSide)
|
||||||
|
if err := <-errCh; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if pkt.ID != protocol.PlayPlayerInfoUpdate {
|
||||||
|
t.Fatalf("player info update id = %#x, want %#x", pkt.ID, protocol.PlayPlayerInfoUpdate)
|
||||||
|
}
|
||||||
|
r := pkt.Body()
|
||||||
|
if actions, err := r.ReadByte(); err != nil || actions != 0xff {
|
||||||
|
t.Fatalf("actions = %#x, %v; want 0xff", actions, err)
|
||||||
|
}
|
||||||
|
if count, err := r.VarInt(); err != nil || count != 1 {
|
||||||
|
t.Fatalf("entry count = %d, %v; want 1", count, err)
|
||||||
|
}
|
||||||
|
if uuid, err := r.UUID(); err != nil || uuid != profile.UUID {
|
||||||
|
t.Fatalf("profile UUID = %x, %v; want %x", uuid, err, profile.UUID)
|
||||||
|
}
|
||||||
|
if name, err := r.String(); err != nil || name != profile.Name {
|
||||||
|
t.Fatalf("profile name = %q, %v; want %q", name, err, profile.Name)
|
||||||
|
}
|
||||||
|
if properties, err := r.VarInt(); err != nil || properties != 0 {
|
||||||
|
t.Fatalf("properties = %d, %v; want 0", properties, err)
|
||||||
|
}
|
||||||
|
if chatSession, err := r.Bool(); err != nil || chatSession {
|
||||||
|
t.Fatalf("chat session = %v, %v; want false", chatSession, err)
|
||||||
|
}
|
||||||
|
if gameMode, err := r.VarInt(); err != nil || gameMode != 1 {
|
||||||
|
t.Fatalf("game mode = %d, %v; want 1", gameMode, err)
|
||||||
|
}
|
||||||
|
if listed, err := r.Bool(); err != nil || !listed {
|
||||||
|
t.Fatalf("listed = %v, %v; want true", listed, err)
|
||||||
|
}
|
||||||
|
if latency, err := r.VarInt(); err != nil || latency != 0 {
|
||||||
|
t.Fatalf("latency = %d, %v; want 0", latency, err)
|
||||||
|
}
|
||||||
|
if displayName, err := r.Bool(); err != nil || displayName {
|
||||||
|
t.Fatalf("display name = %v, %v; want absent", displayName, err)
|
||||||
|
}
|
||||||
|
if order, err := r.VarInt(); err != nil || order != 0 {
|
||||||
|
t.Fatalf("list order = %d, %v; want 0", order, err)
|
||||||
|
}
|
||||||
|
if showHat, err := r.Bool(); err != nil || !showHat {
|
||||||
|
t.Fatalf("show hat = %v, %v; want true", showHat, err)
|
||||||
|
}
|
||||||
|
if r.Remaining() != 0 {
|
||||||
|
t.Fatalf("player info update trailing bytes = %d", r.Remaining())
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() { errCh <- h.sendPlayerInfoRemove(profile.UUID) }()
|
||||||
|
pkt = readServerPacket(t, clientSide)
|
||||||
|
if err := <-errCh; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if pkt.ID != protocol.PlayPlayerInfoRemove {
|
||||||
|
t.Fatalf("player info remove id = %#x, want %#x", pkt.ID, protocol.PlayPlayerInfoRemove)
|
||||||
|
}
|
||||||
|
r = pkt.Body()
|
||||||
|
if count, err := r.VarInt(); err != nil || count != 1 {
|
||||||
|
t.Fatalf("remove count = %d, %v; want 1", count, err)
|
||||||
|
}
|
||||||
|
if uuid, err := r.UUID(); err != nil || uuid != profile.UUID {
|
||||||
|
t.Fatalf("removed UUID = %x, %v; want %x", uuid, err, profile.UUID)
|
||||||
|
}
|
||||||
|
if r.Remaining() != 0 {
|
||||||
|
t.Fatalf("player info remove trailing bytes = %d", r.Remaining())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,11 +27,14 @@ type handler struct {
|
||||||
streamer *streamer
|
streamer *streamer
|
||||||
// viewDistance is the client's requested view distance (from
|
// viewDistance is the client's requested view distance (from
|
||||||
// client_information), clamped; used to size the streamer.
|
// client_information), clamped; used to size the streamer.
|
||||||
viewDistance int
|
viewDistance int
|
||||||
|
session *server.PlayerSession
|
||||||
|
knownPlayers map[[16]byte]bool
|
||||||
|
knownEntities map[int32]visibleEntity
|
||||||
|
|
||||||
// Creative inventory state for block placement.
|
// Creative inventory state for block placement.
|
||||||
heldSlot int32 // selected hotbar index (0-8)
|
heldSlot int32 // selected hotbar index (0-8)
|
||||||
hotbar [9]int32 // item network IDs per hotbar slot (-1 = empty)
|
hotbar [9]int32 // item network IDs per hotbar slot (-1 = empty)
|
||||||
}
|
}
|
||||||
|
|
||||||
// serve runs the read/dispatch loop for a single connection. It owns the
|
// serve runs the read/dispatch loop for a single connection. It owns the
|
||||||
|
|
@ -44,7 +47,7 @@ func (h *handler) serve() {
|
||||||
defer cancel() // stops the streamer when the read loop ends
|
defer cancel() // stops the streamer when the read loop ends
|
||||||
h.ctx = ctx
|
h.ctx = ctx
|
||||||
|
|
||||||
defer h.srv.RemovePlayerPosition(h.conn.Profile.Name)
|
defer func() { h.srv.UnregisterPlayer(h.session) }()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
pkt, err := h.conn.ReadPacket()
|
pkt, err := h.conn.ReadPacket()
|
||||||
|
|
@ -123,7 +126,7 @@ func (h *handler) handleHandshake(pkt protocol.Packet) error {
|
||||||
func (h *handler) handleStatus(pkt protocol.Packet) error {
|
func (h *handler) handleStatus(pkt protocol.Packet) error {
|
||||||
switch pkt.ID {
|
switch pkt.ID {
|
||||||
case protocol.StatusRequestID:
|
case protocol.StatusRequestID:
|
||||||
jsonBytes, err := h.srv.StatusJSON(0)
|
jsonBytes, err := h.srv.StatusJSON(h.srv.PlayerCount())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,23 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"regionio/internal/server"
|
"regionio/internal/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Listener accepts TCP connections and serves each in its own goroutine.
|
// Listener accepts TCP connections and serves each in its own goroutine.
|
||||||
type Listener struct {
|
type Listener struct {
|
||||||
srv *server.Server
|
srv *server.Server
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
|
mu sync.Mutex
|
||||||
|
conns map[net.Conn]struct{}
|
||||||
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewListener constructs a Listener bound to srv.
|
// NewListener constructs a Listener bound to srv.
|
||||||
func NewListener(srv *server.Server, log *slog.Logger) *Listener {
|
func NewListener(srv *server.Server, log *slog.Logger) *Listener {
|
||||||
return &Listener{srv: srv, log: log}
|
return &Listener{srv: srv, log: log, conns: make(map[net.Conn]struct{})}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListenAndServe binds the configured address and accepts connections until
|
// ListenAndServe binds the configured address and accepts connections until
|
||||||
|
|
@ -31,6 +35,11 @@ func (l *Listener) ListenAndServe(ctx context.Context) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("listen on %s: %w", addr, err)
|
return fmt.Errorf("listen on %s: %w", addr, err)
|
||||||
}
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = ln.Close()
|
||||||
|
l.closeConnections()
|
||||||
|
l.wg.Wait()
|
||||||
|
}()
|
||||||
l.log.Info("RegionIO listening", "addr", addr, "version", "26.1.2")
|
l.log.Info("RegionIO listening", "addr", addr, "version", "26.1.2")
|
||||||
|
|
||||||
// Close the listener when the context is cancelled to unblock Accept.
|
// Close the listener when the context is cancelled to unblock Accept.
|
||||||
|
|
@ -48,12 +57,34 @@ func (l *Listener) ListenAndServe(ctx context.Context) error {
|
||||||
l.log.Warn("accept failed", "err", err)
|
l.log.Warn("accept failed", "err", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
l.mu.Lock()
|
||||||
|
l.conns[raw] = struct{}{}
|
||||||
|
l.wg.Add(1)
|
||||||
|
l.mu.Unlock()
|
||||||
go l.serveConn(raw)
|
go l.serveConn(raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *Listener) closeConnections() {
|
||||||
|
l.mu.Lock()
|
||||||
|
conns := make([]net.Conn, 0, len(l.conns))
|
||||||
|
for conn := range l.conns {
|
||||||
|
conns = append(conns, conn)
|
||||||
|
}
|
||||||
|
l.mu.Unlock()
|
||||||
|
for _, conn := range conns {
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// serveConn wraps a raw connection and runs its state-machine handler.
|
// serveConn wraps a raw connection and runs its state-machine handler.
|
||||||
func (l *Listener) serveConn(raw net.Conn) {
|
func (l *Listener) serveConn(raw net.Conn) {
|
||||||
|
defer func() {
|
||||||
|
l.mu.Lock()
|
||||||
|
delete(l.conns, raw)
|
||||||
|
l.mu.Unlock()
|
||||||
|
l.wg.Done()
|
||||||
|
}()
|
||||||
conn := NewConn(raw)
|
conn := NewConn(raw)
|
||||||
h := &handler{
|
h := &handler{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
|
|
|
||||||
321
internal/network/multiplayer_test.go
Normal file
321
internal/network/multiplayer_test.go
Normal file
|
|
@ -0,0 +1,321 @@
|
||||||
|
package network
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"regionio/internal/protocol"
|
||||||
|
"regionio/internal/server"
|
||||||
|
"regionio/internal/world"
|
||||||
|
)
|
||||||
|
|
||||||
|
type recordingConn struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buf bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *recordingConn) Read([]byte) (int, error) { return 0, io.EOF }
|
||||||
|
func (c *recordingConn) Close() error { return nil }
|
||||||
|
func (c *recordingConn) LocalAddr() net.Addr { return testAddr("local") }
|
||||||
|
func (c *recordingConn) RemoteAddr() net.Addr { return testAddr("remote") }
|
||||||
|
func (c *recordingConn) SetDeadline(time.Time) error { return nil }
|
||||||
|
func (c *recordingConn) SetReadDeadline(time.Time) error { return nil }
|
||||||
|
func (c *recordingConn) SetWriteDeadline(time.Time) error { return nil }
|
||||||
|
func (c *recordingConn) Write(p []byte) (int, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.buf.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *recordingConn) take(t *testing.T) []protocol.Packet {
|
||||||
|
t.Helper()
|
||||||
|
c.mu.Lock()
|
||||||
|
raw := append([]byte(nil), c.buf.Bytes()...)
|
||||||
|
c.buf.Reset()
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
reader := bytes.NewReader(raw)
|
||||||
|
br := bufio.NewReader(reader)
|
||||||
|
var packets []protocol.Packet
|
||||||
|
for br.Buffered() > 0 || reader.Len() > 0 {
|
||||||
|
pkt, err := protocol.ReadPacket(br, -1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode recorded packet: %v", err)
|
||||||
|
}
|
||||||
|
packets = append(packets, pkt)
|
||||||
|
}
|
||||||
|
return packets
|
||||||
|
}
|
||||||
|
|
||||||
|
type testAddr string
|
||||||
|
|
||||||
|
func (a testAddr) Network() string { return "test" }
|
||||||
|
func (a testAddr) String() string { return string(a) }
|
||||||
|
|
||||||
|
func TestIntegrationTwoPlayersShareBlockAndChatUpdates(t *testing.T) {
|
||||||
|
cfg := server.DefaultConfig()
|
||||||
|
cfg.WorldDir = ""
|
||||||
|
cfg.MaxPlayers = 4
|
||||||
|
cache := world.NewCache(-1, world.GenerateFlat)
|
||||||
|
srv, err := server.NewWithCache(cfg, cache)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw1, raw2 := &recordingConn{}, &recordingConn{}
|
||||||
|
h1 := &handler{conn: NewConn(raw1), srv: srv, log: slog.Default()}
|
||||||
|
h2 := &handler{conn: NewConn(raw2), srv: srv, log: slog.Default()}
|
||||||
|
h1.conn.Profile = server.Profile{Name: "Alice", UUID: server.OfflineUUID("Alice")}
|
||||||
|
h2.conn.Profile = server.Profile{Name: "Bob", UUID: server.OfflineUUID("Bob")}
|
||||||
|
h1.session, err = srv.RegisterPlayer(h1.conn.Profile, h1.conn.Send)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer srv.UnregisterPlayer(h1.session)
|
||||||
|
h2.session, err = srv.RegisterPlayer(h2.conn.Profile, h2.conn.Send)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer srv.UnregisterPlayer(h2.session)
|
||||||
|
|
||||||
|
action := protocol.NewWriter(24)
|
||||||
|
action.VarInt(0).Position(2, world.FlatSurfaceY, 3).Byte(1).VarInt(17)
|
||||||
|
if err := h1.handlePlayerAction(protocol.Packet{ID: protocol.PlayPlayerAction, Data: action.Bytes()}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := cache.GetBlock(2, world.FlatSurfaceY, 3); got != world.StateAir {
|
||||||
|
t.Fatalf("shared block state = %d, want air", got)
|
||||||
|
}
|
||||||
|
assertPacketIDs(t, raw1.take(t), protocol.PlayBlockUpdate, protocol.PlayLightUpdate, protocol.PlayBlockChangedAck)
|
||||||
|
assertPacketIDs(t, raw2.take(t), protocol.PlayBlockUpdate, protocol.PlayLightUpdate)
|
||||||
|
|
||||||
|
chat := protocol.NewWriter(16).String("hello")
|
||||||
|
if err := h1.handleChat(protocol.Packet{ID: protocol.PlayChatMessage, Data: chat.Bytes()}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertPacketIDs(t, raw1.take(t), protocol.PlaySystemChat)
|
||||||
|
assertPacketIDs(t, raw2.take(t), protocol.PlaySystemChat)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoundaryEditBroadcastsEveryChangedLightChunk(t *testing.T) {
|
||||||
|
cfg := server.DefaultConfig()
|
||||||
|
cfg.WorldDir = ""
|
||||||
|
cache := world.NewCache(-1, func(cx, cz int32) *world.Chunk {
|
||||||
|
return world.NewChunk(cx, cz, world.BiomePlains)
|
||||||
|
})
|
||||||
|
if _, err := cache.FrameErr(0, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := cache.FrameErr(1, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv, err := server.NewWithCache(cfg, cache)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
recorder := &recordingConn{}
|
||||||
|
h := &handler{conn: NewConn(recorder), srv: srv, log: slog.Default()}
|
||||||
|
h.conn.Profile = server.Profile{Name: "Alice", UUID: server.OfflineUUID("Alice")}
|
||||||
|
h.session, err = srv.RegisterPlayer(h.conn.Profile, h.conn.Send)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer srv.UnregisterPlayer(h.session)
|
||||||
|
srv.SetPlayerTransform(h.session, 15, 100, 8, 0, 0, true)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
h.broadcastBlockUpdate(15, 100, 8, world.StateGlowstone, lightChunks)
|
||||||
|
assertPacketIDs(t, recorder.take(t), protocol.PlayBlockUpdate, protocol.PlayLightUpdate, protocol.PlayLightUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationFourClientsVisibilityMovementLeaveAndLight(t *testing.T) {
|
||||||
|
cfg := server.DefaultConfig()
|
||||||
|
cfg.WorldDir = ""
|
||||||
|
cfg.MaxPlayers = 4
|
||||||
|
cache := world.NewCache(-1, world.GenerateFlat)
|
||||||
|
srv, err := server.NewWithCache(cfg, cache)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
names := []string{"Alice", "Bob", "Carol", "Dave"}
|
||||||
|
positions := [][3]float64{{0, 80, 0}, {16, 80, 0}, {160, 80, 0}, {176, 80, 0}}
|
||||||
|
handlers := make([]*handler, len(names))
|
||||||
|
recorders := make([]*recordingConn, len(names))
|
||||||
|
for i, name := range names {
|
||||||
|
recorders[i] = &recordingConn{}
|
||||||
|
h := &handler{conn: NewConn(recorders[i]), srv: srv, log: slog.Default(), viewDistance: 2}
|
||||||
|
h.conn.Profile = server.Profile{Name: name, UUID: server.OfflineUUID(name)}
|
||||||
|
h.session, err = srv.RegisterPlayer(h.conn.Profile, h.conn.Send)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("register %s: %v", name, err)
|
||||||
|
}
|
||||||
|
srv.SetPlayerTransform(h.session, positions[i][0], positions[i][1], positions[i][2], 0, 0, true)
|
||||||
|
srv.SetPlayerViewDistance(h.session, h.viewDistance)
|
||||||
|
handlers[i] = h
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
for _, h := range handlers {
|
||||||
|
srv.UnregisterPlayer(h.session)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
mobID := srv.Entities().Add(&world.Entity{
|
||||||
|
TypeID: 100, TypeName: "minecraft:pig", X: 8, Y: 80, Z: 8,
|
||||||
|
})
|
||||||
|
for _, h := range handlers {
|
||||||
|
if err := h.syncVisibleEntities(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, recorder := range recorders {
|
||||||
|
packets := recorder.take(t)
|
||||||
|
if got := countPackets(packets, protocol.PlayPlayerInfoUpdate); got != 4 {
|
||||||
|
t.Fatalf("client %s player-info count = %d, want 4", names[i], got)
|
||||||
|
}
|
||||||
|
wantAdds := 1
|
||||||
|
if i < 2 {
|
||||||
|
wantAdds = 2 // nearby player plus the pig
|
||||||
|
}
|
||||||
|
if got := countPackets(packets, protocol.PlayAddEntity); got != wantAdds {
|
||||||
|
t.Fatalf("client %s add-entity count = %d, want %d", names[i], got, wantAdds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
move := protocol.NewWriter(40)
|
||||||
|
move.Float64(32).Float64(80).Float64(0).Float32(90).Float32(15).Byte(1)
|
||||||
|
if err := handlers[1].handlePlay(protocol.Packet{ID: protocol.PlayMovePosRot, Data: move.Bytes()}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := handlers[0].syncVisibleEntities(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
packets := recorders[0].take(t)
|
||||||
|
if got := countPackets(packets, protocol.PlayTeleportEntity); got != 1 {
|
||||||
|
t.Fatalf("nearby movement teleports = %d, want 1", got)
|
||||||
|
}
|
||||||
|
assertTeleportTransform(t, firstPacket(t, packets, protocol.PlayTeleportEntity), handlers[1].session.EntityID, 32, 80, 0, 90, 15, true)
|
||||||
|
|
||||||
|
move = protocol.NewWriter(32)
|
||||||
|
move.Float64(64).Float64(80).Float64(0).Byte(1)
|
||||||
|
if err := handlers[1].handlePlay(protocol.Packet{ID: protocol.PlayMovePos, Data: move.Bytes()}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := handlers[0].syncVisibleEntities(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertPacketIDs(t, recorders[0].take(t), protocol.PlayRemoveEntities)
|
||||||
|
|
||||||
|
move = protocol.NewWriter(32)
|
||||||
|
move.Float64(16).Float64(80).Float64(0).Byte(1)
|
||||||
|
if err := handlers[1].handlePlay(protocol.Packet{ID: protocol.PlayMovePos, Data: move.Bytes()}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := handlers[0].syncVisibleEntities(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertPacketIDs(t, recorders[0].take(t), protocol.PlayAddEntity)
|
||||||
|
|
||||||
|
srv.UnregisterPlayer(handlers[1].session)
|
||||||
|
if err := handlers[0].syncVisibleEntities(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertPacketIDs(t, recorders[0].take(t), protocol.PlayRemoveEntities, protocol.PlayPlayerInfoRemove)
|
||||||
|
|
||||||
|
action := protocol.NewWriter(24)
|
||||||
|
action.VarInt(0).Position(2, world.FlatSurfaceY, 3).Byte(1).VarInt(91)
|
||||||
|
if err := handlers[0].handlePlayerAction(protocol.Packet{ID: protocol.PlayPlayerAction, Data: action.Bytes()}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertPacketIDs(t, recorders[0].take(t), protocol.PlayBlockUpdate, protocol.PlayLightUpdate, protocol.PlayBlockChangedAck)
|
||||||
|
if packets := recorders[2].take(t); len(packets) != 0 {
|
||||||
|
t.Fatalf("far client Carol received %d block/light packets", len(packets))
|
||||||
|
}
|
||||||
|
if packets := recorders[3].take(t); len(packets) != 0 {
|
||||||
|
t.Fatalf("far client Dave received %d block/light packets", len(packets))
|
||||||
|
}
|
||||||
|
|
||||||
|
srv.Entities().Remove(mobID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func countPackets(packets []protocol.Packet, id int32) int {
|
||||||
|
count := 0
|
||||||
|
for _, packet := range packets {
|
||||||
|
if packet.ID == id {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstPacket(t *testing.T, packets []protocol.Packet, id int32) protocol.Packet {
|
||||||
|
t.Helper()
|
||||||
|
for _, packet := range packets {
|
||||||
|
if packet.ID == id {
|
||||||
|
return packet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("packet %#x not found", id)
|
||||||
|
return protocol.Packet{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertTeleportTransform(t *testing.T, packet protocol.Packet, entityID int32, x, y, z float64, yaw, pitch float32, onGround bool) {
|
||||||
|
t.Helper()
|
||||||
|
r := packet.Body()
|
||||||
|
if got, err := r.VarInt(); err != nil || got != entityID {
|
||||||
|
t.Fatalf("teleport entity = %d, %v; want %d", got, err, entityID)
|
||||||
|
}
|
||||||
|
for _, field := range []struct {
|
||||||
|
name string
|
||||||
|
want float64
|
||||||
|
}{{"x", x}, {"y", y}, {"z", z}} {
|
||||||
|
got, err := r.Float64()
|
||||||
|
if err != nil || got != field.want {
|
||||||
|
t.Fatalf("teleport %s = %v, %v; want %v", field.name, got, err, field.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if got, err := r.Float64(); err != nil || got != 0 {
|
||||||
|
t.Fatalf("teleport velocity[%d] = %v, %v; want 0", i, got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got, err := r.Float32(); err != nil || got != yaw {
|
||||||
|
t.Fatalf("teleport yaw = %v, %v; want %v", got, err, yaw)
|
||||||
|
}
|
||||||
|
if got, err := r.Float32(); err != nil || got != pitch {
|
||||||
|
t.Fatalf("teleport pitch = %v, %v; want %v", got, err, pitch)
|
||||||
|
}
|
||||||
|
if _, err := r.Int32(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, err := r.Bool(); err != nil || got != onGround {
|
||||||
|
t.Fatalf("teleport onGround = %v, %v; want %v", got, err, onGround)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPacketIDs(t *testing.T, packets []protocol.Packet, want ...int32) {
|
||||||
|
t.Helper()
|
||||||
|
if len(packets) != len(want) {
|
||||||
|
ids := make([]int32, len(packets))
|
||||||
|
for i := range packets {
|
||||||
|
ids[i] = packets[i].ID
|
||||||
|
}
|
||||||
|
t.Fatalf("packet IDs = %v (count %d), want %v (count %d)", ids, len(packets), want, len(want))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if packets[i].ID != want[i] {
|
||||||
|
t.Fatalf("packet[%d] id = %#x, want %#x", i, packets[i].ID, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
package network
|
package network
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"math"
|
"math"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"regionio/internal/nbt"
|
"regionio/internal/nbt"
|
||||||
"regionio/internal/protocol"
|
"regionio/internal/protocol"
|
||||||
"regionio/internal/registry"
|
"regionio/internal/registry"
|
||||||
|
"regionio/internal/server"
|
||||||
"regionio/internal/world"
|
"regionio/internal/world"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -22,6 +24,13 @@ const (
|
||||||
// hands chunk streaming off to the background streamer. The streamer stops when
|
// hands chunk streaming off to the background streamer. The streamer stops when
|
||||||
// h.ctx (the connection lifetime context) is cancelled.
|
// h.ctx (the connection lifetime context) is cancelled.
|
||||||
func (h *handler) beginPlay() error {
|
func (h *handler) beginPlay() error {
|
||||||
|
session, err := h.srv.RegisterPlayer(h.conn.Profile, h.conn.Send)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h.session = session
|
||||||
|
h.srv.SetPlayerTransform(session, spawnX, spawnY, spawnZ, 0, 0, true)
|
||||||
|
h.srv.SetPlayerViewDistance(session, h.visibilityRadius())
|
||||||
for i := range h.hotbar {
|
for i := range h.hotbar {
|
||||||
h.hotbar[i] = -1 // empty
|
h.hotbar[i] = -1 // empty
|
||||||
}
|
}
|
||||||
|
|
@ -36,6 +45,15 @@ func (h *handler) beginPlay() error {
|
||||||
if err := h.sendPlayerPosition(1); err != nil {
|
if err := h.sendPlayerPosition(1); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := h.sendChunkCacheCenter(0, 0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := h.sendDefaultSpawnPosition(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := h.sendPlayerAbilities(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
// Launch the background chunk streamer. It owns generation + sending so the
|
// Launch the background chunk streamer. It owns generation + sending so the
|
||||||
// read loop stays free; requestRecenter is a non-blocking push.
|
// 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.viewDistance)
|
||||||
|
|
@ -46,10 +64,38 @@ func (h *handler) beginPlay() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *handler) sendChunkCacheCenter(cx, cz int32) error {
|
||||||
|
w := protocol.NewWriter(8)
|
||||||
|
w.VarInt(cx)
|
||||||
|
w.VarInt(cz)
|
||||||
|
return h.conn.SendWriter(protocol.PlayChunkCacheCenter, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) sendDefaultSpawnPosition() error {
|
||||||
|
w := protocol.NewWriter(12)
|
||||||
|
w.Position(8, 100, 8)
|
||||||
|
w.Float32(0.0)
|
||||||
|
return h.conn.SendWriter(protocol.PlayDefaultSpawnPos, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) sendPlayerAbilities() error {
|
||||||
|
w := protocol.NewWriter(9)
|
||||||
|
w.Byte(0x0F)
|
||||||
|
w.Float32(0.05)
|
||||||
|
w.Float32(0.1)
|
||||||
|
return h.conn.SendWriter(protocol.PlayAbilities, w)
|
||||||
|
}
|
||||||
|
|
||||||
// onPlayerMove recenters the streamer when the player crosses into a new chunk.
|
// onPlayerMove recenters the streamer when the player crosses into a new chunk.
|
||||||
// It is a non-blocking push; the read loop never waits on generation.
|
// It is a non-blocking push; the read loop never waits on generation.
|
||||||
func (h *handler) onPlayerMove(x, y, z float64) error {
|
func (h *handler) onPlayerMove(x, y, z float64, yaw, pitch float32, onGround bool) error {
|
||||||
h.srv.SetPlayerPosition(h.conn.Profile.Name, x, y, z)
|
if math.IsNaN(x) || math.IsNaN(y) || math.IsNaN(z) ||
|
||||||
|
math.IsInf(x, 0) || math.IsInf(y, 0) || math.IsInf(z, 0) ||
|
||||||
|
math.IsNaN(float64(yaw)) || math.IsNaN(float64(pitch)) ||
|
||||||
|
math.IsInf(float64(yaw), 0) || math.IsInf(float64(pitch), 0) {
|
||||||
|
return errors.New("invalid player position")
|
||||||
|
}
|
||||||
|
h.srv.SetPlayerTransform(h.session, x, y, z, yaw, pitch, onGround)
|
||||||
cx := int32(int64(math.Floor(x)) >> 4)
|
cx := int32(int64(math.Floor(x)) >> 4)
|
||||||
cz := int32(int64(math.Floor(z)) >> 4)
|
cz := int32(int64(math.Floor(z)) >> 4)
|
||||||
if h.streamer != nil {
|
if h.streamer != nil {
|
||||||
|
|
@ -58,6 +104,16 @@ func (h *handler) onPlayerMove(x, y, z float64) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *handler) visibilityRadius() int {
|
||||||
|
if h.viewDistance < 2 {
|
||||||
|
return defaultViewRadius
|
||||||
|
}
|
||||||
|
if h.viewDistance > 16 {
|
||||||
|
return 16
|
||||||
|
}
|
||||||
|
return h.viewDistance
|
||||||
|
}
|
||||||
|
|
||||||
// sendPlayLogin writes the clientbound play "login" packet. Field layout was
|
// sendPlayLogin writes the clientbound play "login" packet. Field layout was
|
||||||
// confirmed against the 26.1.2 vanilla server capture.
|
// confirmed against the 26.1.2 vanilla server capture.
|
||||||
func (h *handler) sendPlayLogin() error {
|
func (h *handler) sendPlayLogin() error {
|
||||||
|
|
@ -67,8 +123,12 @@ func (h *handler) sendPlayLogin() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
w := protocol.NewWriter(128)
|
w := protocol.NewWriter(128)
|
||||||
w.Int32(1) // entity ID
|
entityID := int32(1)
|
||||||
w.Bool(false) // is hardcore
|
if h.session != nil {
|
||||||
|
entityID = h.session.EntityID
|
||||||
|
}
|
||||||
|
w.Int32(entityID) // entity ID
|
||||||
|
w.Bool(false) // is hardcore
|
||||||
|
|
||||||
// Dimension names: the worlds available on this server.
|
// Dimension names: the worlds available on this server.
|
||||||
dims := []string{"minecraft:overworld", "minecraft:the_end", "minecraft:the_nether"}
|
dims := []string{"minecraft:overworld", "minecraft:the_end", "minecraft:the_nether"}
|
||||||
|
|
@ -78,23 +138,23 @@ func (h *handler) sendPlayLogin() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
w.VarInt(int32(h.srv.Config().MaxPlayers)) // max players (legacy)
|
w.VarInt(int32(h.srv.Config().MaxPlayers)) // max players (legacy)
|
||||||
w.VarInt(10) // view distance
|
w.VarInt(int32(h.visibilityRadius())) // view distance
|
||||||
w.VarInt(10) // simulation distance
|
w.VarInt(int32(h.visibilityRadius())) // simulation distance
|
||||||
w.Bool(false) // reduced debug info
|
w.Bool(false) // reduced debug info
|
||||||
w.Bool(true) // enable respawn screen
|
w.Bool(true) // enable respawn screen
|
||||||
w.Bool(false) // do limited crafting
|
w.Bool(false) // do limited crafting
|
||||||
|
|
||||||
w.VarInt(int32(dimTypeIdx)) // dimension type (registry index)
|
w.VarInt(int32(dimTypeIdx)) // dimension type (registry index)
|
||||||
w.String("minecraft:overworld") // dimension name (this world)
|
w.String("minecraft:overworld") // dimension name (this world)
|
||||||
w.Int64(0) // hashed seed
|
w.Int64(0) // hashed seed
|
||||||
w.Byte(1) // game mode: creative (instant break, creative inventory)
|
w.Byte(1) // game mode: creative (instant break, creative inventory)
|
||||||
w.Byte(0xFF) // previous game mode: -1 (none)
|
w.Byte(0xFF) // previous game mode: -1 (none)
|
||||||
w.Bool(false) // is debug
|
w.Bool(false) // is debug
|
||||||
w.Bool(false) // is flat
|
w.Bool(false) // is flat
|
||||||
w.Bool(false) // has death location
|
w.Bool(false) // has death location
|
||||||
w.VarInt(0) // portal cooldown
|
w.VarInt(0) // portal cooldown
|
||||||
w.VarInt(63) // sea level (overworld)
|
w.VarInt(63) // sea level (overworld)
|
||||||
w.Bool(false) // enforces secure chat
|
w.Bool(false) // enforces secure chat
|
||||||
|
|
||||||
return h.conn.SendWriter(protocol.PlayLogin, w)
|
return h.conn.SendWriter(protocol.PlayLogin, w)
|
||||||
}
|
}
|
||||||
|
|
@ -125,50 +185,174 @@ func (h *handler) sendPlayerPosition(teleportID int32) error {
|
||||||
func (h *handler) keepAliveLoop() {
|
func (h *handler) keepAliveLoop() {
|
||||||
ticker := time.NewTicker(15 * time.Second)
|
ticker := time.NewTicker(15 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for range ticker.C {
|
for {
|
||||||
id := time.Now().UnixMilli()
|
select {
|
||||||
w := protocol.NewWriter(8)
|
case <-h.ctx.Done():
|
||||||
w.Int64(id)
|
|
||||||
if err := h.conn.SendWriter(protocol.PlayKeepAliveCB, w); err != nil {
|
|
||||||
return
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
id := time.Now().UnixMilli()
|
||||||
|
w := protocol.NewWriter(8)
|
||||||
|
w.Int64(id)
|
||||||
|
if err := h.conn.SendWriter(protocol.PlayKeepAliveCB, w); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// entitySyncLoop periodically sends all entities in the world to the client.
|
// visibleEntity is the comparable state retained by one client's visibility
|
||||||
// In a real server this would track which entities the player can see and send updates.
|
// tracker. OnGround is separate because mobs currently always use true.
|
||||||
|
type visibleEntity struct {
|
||||||
|
entity world.Entity
|
||||||
|
onGround bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// entitySyncLoop maintains tab-list membership and chunk-scoped entity state.
|
||||||
func (h *handler) entitySyncLoop() {
|
func (h *handler) entitySyncLoop() {
|
||||||
ticker := time.NewTicker(200 * time.Millisecond)
|
ticker := time.NewTicker(200 * time.Millisecond)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
known := make(map[int32]bool)
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-h.ctx.Done():
|
case <-h.ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
all := h.srv.Entities().All()
|
if err := h.syncVisibleEntities(); err != nil {
|
||||||
current := make(map[int32]bool)
|
return
|
||||||
for _, e := range all {
|
|
||||||
current[e.ID] = true
|
|
||||||
if !known[e.ID] {
|
|
||||||
h.sendAddEntity(e)
|
|
||||||
known[e.ID] = true
|
|
||||||
} else {
|
|
||||||
h.sendEntityTeleport(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// remove entities that disappeared
|
|
||||||
for id := range known {
|
|
||||||
if !current[id] {
|
|
||||||
h.sendRemoveEntity(id)
|
|
||||||
delete(known, id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// syncVisibleEntities performs one deterministic visibility pass. Keeping it
|
||||||
|
// separate from the ticker makes the four-client workflow integration-testable.
|
||||||
|
func (h *handler) syncVisibleEntities() error {
|
||||||
|
if h.session == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if h.knownPlayers == nil {
|
||||||
|
h.knownPlayers = make(map[[16]byte]bool)
|
||||||
|
}
|
||||||
|
if h.knownEntities == nil {
|
||||||
|
h.knownEntities = make(map[int32]visibleEntity)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewer := h.session.Snapshot()
|
||||||
|
players := h.srv.PlayerSnapshots()
|
||||||
|
currentPlayers := make(map[[16]byte]bool, len(players))
|
||||||
|
for _, player := range players {
|
||||||
|
currentPlayers[player.Profile.UUID] = true
|
||||||
|
if !h.knownPlayers[player.Profile.UUID] {
|
||||||
|
if err := h.sendPlayerInfoAdd(player.Profile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h.knownPlayers[player.Profile.UUID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentEntities := make(map[int32]visibleEntity)
|
||||||
|
for _, player := range players {
|
||||||
|
if player.EntityID == viewer.EntityID || !playerVisible(viewer, player, h.visibilityRadius()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
currentEntities[player.EntityID] = visibleEntity{
|
||||||
|
entity: world.Entity{
|
||||||
|
ID: player.EntityID, UUID: player.Profile.UUID,
|
||||||
|
TypeID: registry.EntityTypeIndex("minecraft:player"), TypeName: "minecraft:player",
|
||||||
|
X: player.X, Y: player.Y, Z: player.Z,
|
||||||
|
Yaw: player.Yaw, Pitch: player.Pitch, HeadYaw: player.Yaw,
|
||||||
|
},
|
||||||
|
onGround: player.OnGround,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, entity := range h.srv.Entities().All() {
|
||||||
|
if entityVisible(viewer, entity, h.visibilityRadius()) {
|
||||||
|
currentEntities[entity.ID] = visibleEntity{entity: entity, onGround: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for id, current := range currentEntities {
|
||||||
|
known, exists := h.knownEntities[id]
|
||||||
|
if !exists {
|
||||||
|
entity := current.entity
|
||||||
|
if err := h.sendAddEntity(&entity); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if known != current {
|
||||||
|
entity := current.entity
|
||||||
|
if err := h.sendEntityTeleportState(&entity, current.onGround); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id := range h.knownEntities {
|
||||||
|
if _, visible := currentEntities[id]; !visible {
|
||||||
|
if err := h.sendRemoveEntity(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.knownEntities = currentEntities
|
||||||
|
|
||||||
|
for uuid := range h.knownPlayers {
|
||||||
|
if !currentPlayers[uuid] {
|
||||||
|
if err := h.sendPlayerInfoRemove(uuid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
delete(h.knownPlayers, uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func playerVisible(viewer, target server.PlayerSnapshot, radius int) bool {
|
||||||
|
return chunksWithin(viewer.X, viewer.Z, target.X, target.Z, radius)
|
||||||
|
}
|
||||||
|
|
||||||
|
func entityVisible(viewer server.PlayerSnapshot, target world.Entity, radius int) bool {
|
||||||
|
return chunksWithin(viewer.X, viewer.Z, target.X, target.Z, radius)
|
||||||
|
}
|
||||||
|
|
||||||
|
func chunksWithin(ax, az, bx, bz float64, radius int) bool {
|
||||||
|
acx := int32(int64(math.Floor(ax)) >> 4)
|
||||||
|
acz := int32(int64(math.Floor(az)) >> 4)
|
||||||
|
bcx := int32(int64(math.Floor(bx)) >> 4)
|
||||||
|
bcz := int32(int64(math.Floor(bz)) >> 4)
|
||||||
|
dx := acx - bcx
|
||||||
|
if dx < 0 {
|
||||||
|
dx = -dx
|
||||||
|
}
|
||||||
|
dz := acz - bcz
|
||||||
|
if dz < 0 {
|
||||||
|
dz = -dz
|
||||||
|
}
|
||||||
|
return dx <= int32(radius) && dz <= int32(radius)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) sendPlayerInfoAdd(profile server.Profile) error {
|
||||||
|
w := protocol.NewWriter(64)
|
||||||
|
w.Byte(0xff) // All eight initialization actions, fixed 8-bit EnumSet.
|
||||||
|
w.VarInt(1)
|
||||||
|
w.UUID(profile.UUID)
|
||||||
|
w.String(profile.Name)
|
||||||
|
w.VarInt(0) // profile properties
|
||||||
|
w.Bool(false) // no signed chat session
|
||||||
|
w.VarInt(1) // creative game mode
|
||||||
|
w.Bool(true) // listed
|
||||||
|
w.VarInt(0) // latency
|
||||||
|
w.Bool(false) // no custom display name
|
||||||
|
w.VarInt(0) // list order
|
||||||
|
w.Bool(true) // show hat
|
||||||
|
return h.conn.SendWriter(protocol.PlayPlayerInfoUpdate, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) sendPlayerInfoRemove(uuid [16]byte) error {
|
||||||
|
w := protocol.NewWriter(20)
|
||||||
|
w.VarInt(1)
|
||||||
|
w.UUID(uuid)
|
||||||
|
return h.conn.SendWriter(protocol.PlayPlayerInfoRemove, w)
|
||||||
|
}
|
||||||
|
|
||||||
// sendAddEntity sends the minecraft:add_entity packet.
|
// sendAddEntity sends the minecraft:add_entity packet.
|
||||||
func (h *handler) sendAddEntity(e *world.Entity) error {
|
func (h *handler) sendAddEntity(e *world.Entity) error {
|
||||||
w := protocol.NewWriter(64)
|
w := protocol.NewWriter(64)
|
||||||
|
|
@ -187,12 +371,21 @@ func (h *handler) sendAddEntity(e *world.Entity) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *handler) sendEntityTeleport(e *world.Entity) error {
|
func (h *handler) sendEntityTeleport(e *world.Entity) error {
|
||||||
|
return h.sendEntityTeleportState(e, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) sendEntityTeleportState(e *world.Entity, onGround bool) error {
|
||||||
w := protocol.NewWriter(64)
|
w := protocol.NewWriter(64)
|
||||||
w.VarInt(e.ID)
|
w.VarInt(e.ID)
|
||||||
|
// PositionMoveRotation: position, deltaMovement, yRot, xRot.
|
||||||
w.Float64(e.X).Float64(e.Y).Float64(e.Z)
|
w.Float64(e.X).Float64(e.Y).Float64(e.Z)
|
||||||
w.Byte(byte(e.Yaw * 256.0 / 360.0))
|
w.Float64(float64(e.VelocityX) / 8000.0)
|
||||||
w.Byte(byte(e.Pitch * 256.0 / 360.0))
|
w.Float64(float64(e.VelocityY) / 8000.0)
|
||||||
w.Bool(true) // On ground
|
w.Float64(float64(e.VelocityZ) / 8000.0)
|
||||||
|
w.Float32(e.Yaw)
|
||||||
|
w.Float32(e.Pitch)
|
||||||
|
w.Int32(0) // Relative.SET_STREAM_CODEC uses ByteBufCodecs.INT; no relative flags.
|
||||||
|
w.Bool(onGround)
|
||||||
return h.conn.SendWriter(protocol.PlayTeleportEntity, w)
|
return h.conn.SendWriter(protocol.PlayTeleportEntity, w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -225,7 +418,6 @@ func (h *handler) handlePlay(pkt protocol.Packet) error {
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
case protocol.PlayMovePos, protocol.PlayMovePosRot:
|
case protocol.PlayMovePos, protocol.PlayMovePosRot:
|
||||||
// Both packets begin with the absolute X, Y, Z position.
|
|
||||||
r := pkt.Body()
|
r := pkt.Body()
|
||||||
x, err := r.Float64()
|
x, err := r.Float64()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -239,7 +431,48 @@ func (h *handler) handlePlay(pkt protocol.Packet) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return h.onPlayerMove(x, y, z)
|
snapshot := h.session.Snapshot()
|
||||||
|
yaw, pitch := snapshot.Yaw, snapshot.Pitch
|
||||||
|
if pkt.ID == protocol.PlayMovePosRot {
|
||||||
|
yaw, err = r.Float32()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pitch, err = r.Float32()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flags, err := r.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return h.onPlayerMove(x, y, z, yaw, pitch, flags&1 != 0)
|
||||||
|
|
||||||
|
case protocol.PlayMoveRot:
|
||||||
|
r := pkt.Body()
|
||||||
|
yaw, err := r.Float32()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pitch, err := r.Float32()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flags, err := r.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
snapshot := h.session.Snapshot()
|
||||||
|
return h.onPlayerMove(snapshot.X, snapshot.Y, snapshot.Z, yaw, pitch, flags&1 != 0)
|
||||||
|
|
||||||
|
case protocol.PlayMoveStatusOnly:
|
||||||
|
flags, err := pkt.Body().ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
snapshot := h.session.Snapshot()
|
||||||
|
return h.onPlayerMove(snapshot.X, snapshot.Y, snapshot.Z, snapshot.Yaw, snapshot.Pitch, flags&1 != 0)
|
||||||
|
|
||||||
case protocol.PlayPlayerAction:
|
case protocol.PlayPlayerAction:
|
||||||
return h.handlePlayerAction(pkt)
|
return h.handlePlayerAction(pkt)
|
||||||
|
|
@ -279,16 +512,15 @@ func (h *handler) handleChat(pkt protocol.Packet) error {
|
||||||
}
|
}
|
||||||
line := "<" + h.conn.Profile.Name + "> " + msg
|
line := "<" + h.conn.Profile.Name + "> " + msg
|
||||||
h.log.Info("chat", "msg", line)
|
h.log.Info("chat", "msg", line)
|
||||||
return h.sendSystemChat(line)
|
return h.broadcastSystemChat(line)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendSystemChat sends a plain-text system chat message. The text component is
|
func (h *handler) broadcastSystemChat(text string) error {
|
||||||
// network NBT; a bare string tag is the shorthand for {"text": ...}.
|
|
||||||
func (h *handler) sendSystemChat(text string) error {
|
|
||||||
w := protocol.NewWriter(len(text) + 8)
|
w := protocol.NewWriter(len(text) + 8)
|
||||||
w.Raw(nbt.Marshal(nbt.String(text)))
|
w.Raw(nbt.Marshal(nbt.String(text)))
|
||||||
w.Bool(false) // not an action-bar overlay
|
w.Bool(false)
|
||||||
return h.conn.SendWriter(protocol.PlaySystemChat, w)
|
h.srv.Broadcast(protocol.PlaySystemChat, w.Bytes())
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePlayerAction processes digging. In creative the client sends
|
// handlePlayerAction processes digging. In creative the client sends
|
||||||
|
|
@ -315,10 +547,8 @@ func (h *handler) handlePlayerAction(pkt protocol.Packet) error {
|
||||||
|
|
||||||
const startDig, finishDig = 0, 2
|
const startDig, finishDig = 0, 2
|
||||||
if status == startDig || status == finishDig {
|
if status == startDig || status == finishDig {
|
||||||
if h.srv.Chunks().SetBlock(x, y, z, world.StateAir) {
|
if valid, lightChunks := h.srv.Chunks().SetBlockWithLight(x, y, z, world.StateAir); valid {
|
||||||
if err := h.sendBlockUpdate(x, y, z, world.StateAir); err != nil {
|
h.broadcastBlockUpdate(x, y, z, world.StateAir, lightChunks)
|
||||||
return err
|
|
||||||
}
|
|
||||||
h.log.Debug("block broken", "x", x, "y", y, "z", z)
|
h.log.Debug("block broken", "x", x, "y", y, "z", z)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -401,10 +631,8 @@ func (h *handler) handleUseItemOn(pkt protocol.Packet) error {
|
||||||
if state, ok := h.heldBlock(); ok {
|
if state, ok := h.heldBlock(); ok {
|
||||||
off := faceOffsets[face]
|
off := faceOffsets[face]
|
||||||
px, py, pz := x+off[0], y+off[1], z+off[2]
|
px, py, pz := x+off[0], y+off[1], z+off[2]
|
||||||
if h.srv.Chunks().SetBlock(px, py, pz, state) {
|
if valid, lightChunks := h.srv.Chunks().SetBlockWithLight(px, py, pz, state); valid {
|
||||||
if err := h.sendBlockUpdate(px, py, pz, state); err != nil {
|
h.broadcastBlockUpdate(px, py, pz, state, lightChunks)
|
||||||
return err
|
|
||||||
}
|
|
||||||
h.log.Debug("block placed", "x", px, "y", py, "z", pz, "state", state)
|
h.log.Debug("block placed", "x", px, "y", py, "z", pz, "state", state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -422,12 +650,18 @@ func (h *handler) heldBlock() (uint16, bool) {
|
||||||
return world.ItemToBlock(itemID)
|
return world.ItemToBlock(itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendBlockUpdate notifies the client of a single block change.
|
func (h *handler) broadcastBlockUpdate(x, y, z int, state uint16, lightChunks []world.ChunkPos) {
|
||||||
func (h *handler) sendBlockUpdate(x, y, z int, state uint16) error {
|
|
||||||
w := protocol.NewWriter(12)
|
w := protocol.NewWriter(12)
|
||||||
w.Position(x, y, z)
|
w.Position(x, y, z)
|
||||||
w.VarInt(int32(state))
|
w.VarInt(int32(state))
|
||||||
return h.conn.SendWriter(protocol.PlayBlockUpdate, w)
|
cx := int32(x >> 4)
|
||||||
|
cz := int32(z >> 4)
|
||||||
|
h.srv.BroadcastChunk(cx, cz, protocol.PlayBlockUpdate, w.Bytes())
|
||||||
|
for _, chunk := range lightChunks {
|
||||||
|
if light, err := h.srv.Chunks().LightUpdate(chunk.X, chunk.Z); err == nil {
|
||||||
|
h.srv.BroadcastChunk(chunk.X, chunk.Z, protocol.PlayLightUpdate, light)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendBlockChangedAck confirms a block-action sequence so the client does not
|
// sendBlockChangedAck confirms a block-action sequence so the client does not
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"regionio/internal/protocol"
|
||||||
"regionio/internal/world"
|
"regionio/internal/world"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -141,6 +142,8 @@ func (s *streamer) run(ctx context.Context) {
|
||||||
func (s *streamer) processRecenter(ctx context.Context, cx, cz int32) {
|
func (s *streamer) processRecenter(ctx context.Context, cx, cz int32) {
|
||||||
s.centerX, s.centerZ, s.hasCenter = cx, cz, true
|
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
|
// Build the desired set: everything within genRadius (the union of what we
|
||||||
// send + the pre-gen ring). Sent = within viewRadius; pre-gen = the ring.
|
// send + the pre-gen ring). Sent = within viewRadius; pre-gen = the ring.
|
||||||
order := spiralOrder(cx, cz, s.genRadius)
|
order := spiralOrder(cx, cz, s.genRadius)
|
||||||
|
|
@ -179,11 +182,32 @@ func (s *streamer) processRecenter(ctx context.Context, cx, cz int32) {
|
||||||
// bounded and avoids re-sending.
|
// bounded and avoids re-sending.
|
||||||
for key := range s.loaded {
|
for key := range s.loaded {
|
||||||
if !desired[key] {
|
if !desired[key] {
|
||||||
|
s.sendForgetLevelChunk(key[0], key[1])
|
||||||
delete(s.loaded, key)
|
delete(s.loaded, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// 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).
|
// 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.
|
// Already-loaded chunks are skipped. Returns when all are sent or ctx cancels.
|
||||||
|
|
@ -274,14 +298,15 @@ func (s *streamer) parallelGenerate(ctx context.Context, keys [][2]int32) {
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
_ = s.cache.Frame(j.cx, j.cz) // warm the cache; discard the frame
|
_, _ = s.cache.FrameErr(j.cx, j.cz) // warm the cache; discard the frame
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
Loop:
|
||||||
for _, j := range pending {
|
for _, j := range pending {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
break
|
break Loop
|
||||||
case jobs <- j:
|
case jobs <- j:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -307,7 +332,14 @@ func (s *streamer) generateWorker(ctx context.Context, jobs <-chan frameJob, res
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
frame := s.cache.Frame(j.cx, j.cz)
|
frame, err := s.cache.FrameErr(j.cx, j.cz)
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case results <- frameResult{cx: j.cx, cz: j.cz, err: err}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := s.conn.SendFramed(frame); err != nil {
|
if err := s.conn.SendFramed(frame); err != nil {
|
||||||
select {
|
select {
|
||||||
case results <- frameResult{cx: j.cx, cz: j.cz, err: err}:
|
case results <- frameResult{cx: j.cx, cz: j.cz, err: err}:
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,15 @@ func (r *Reader) Uint16() (uint16, error) {
|
||||||
return binary.BigEndian.Uint16(b), nil
|
return binary.BigEndian.Uint16(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Int32 reads a big-endian signed int.
|
||||||
|
func (r *Reader) Int32() (int32, error) {
|
||||||
|
b, err := r.readN(4)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return int32(binary.BigEndian.Uint32(b)), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Int64 reads a big-endian signed long.
|
// Int64 reads a big-endian signed long.
|
||||||
func (r *Reader) Int64() (int64, error) {
|
func (r *Reader) Int64() (int64, error) {
|
||||||
b, err := r.readN(8)
|
b, err := r.readN(8)
|
||||||
|
|
@ -119,9 +128,9 @@ func (r *Reader) Position() (x, y, z int, err error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, 0, err
|
return 0, 0, 0, err
|
||||||
}
|
}
|
||||||
x = int(v >> 38) // top 26 bits, sign-extended
|
x = int(v >> 38) // top 26 bits, sign-extended
|
||||||
y = int(v << 52 >> 52) // low 12 bits, sign-extended
|
y = int(v << 52 >> 52) // low 12 bits, sign-extended
|
||||||
z = int(v << 26 >> 38) // middle 26 bits, sign-extended
|
z = int(v << 26 >> 38) // middle 26 bits, sign-extended
|
||||||
return x, y, z, nil
|
return x, y, z, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,11 @@ const (
|
||||||
|
|
||||||
// Login, clientbound.
|
// Login, clientbound.
|
||||||
const (
|
const (
|
||||||
LoginDisconnectID = 0x00
|
LoginDisconnectID = 0x00
|
||||||
EncryptionRequest = 0x01
|
EncryptionRequest = 0x01
|
||||||
LoginSuccessID = 0x02
|
LoginSuccessID = 0x02
|
||||||
SetCompressionID = 0x03
|
SetCompressionID = 0x03
|
||||||
LoginPluginReq = 0x04
|
LoginPluginReq = 0x04
|
||||||
CookieRequestLogin = 0x05
|
CookieRequestLogin = 0x05
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -53,12 +53,12 @@ const (
|
||||||
|
|
||||||
// Configuration, clientbound.
|
// Configuration, clientbound.
|
||||||
const (
|
const (
|
||||||
ConfigCookieRequest = 0x00
|
ConfigCookieRequest = 0x00
|
||||||
ConfigCustomPayloadCB = 0x01
|
ConfigCustomPayloadCB = 0x01
|
||||||
ConfigDisconnect = 0x02
|
ConfigDisconnect = 0x02
|
||||||
ConfigFinishClientbound = 0x03
|
ConfigFinishClientbound = 0x03
|
||||||
ConfigKeepAliveCB = 0x04
|
ConfigKeepAliveCB = 0x04
|
||||||
ConfigPing = 0x05
|
ConfigPing = 0x05
|
||||||
ConfigRegistryData = 0x07
|
ConfigRegistryData = 0x07
|
||||||
ConfigUpdateEnabledFeatures = 0x0c
|
ConfigUpdateEnabledFeatures = 0x0c
|
||||||
ConfigUpdateTags = 0x0d
|
ConfigUpdateTags = 0x0d
|
||||||
|
|
@ -74,42 +74,46 @@ const (
|
||||||
PlayDefaultSpawnPos = 0x61
|
PlayDefaultSpawnPos = 0x61
|
||||||
PlayChunkCacheCenter = 0x5e
|
PlayChunkCacheCenter = 0x5e
|
||||||
PlayLevelChunk = 0x2d
|
PlayLevelChunk = 0x2d
|
||||||
|
PlayForgetLevelChunk = 0x25
|
||||||
PlayAbilities = 0x40
|
PlayAbilities = 0x40
|
||||||
PlaySetHeldSlot = 0x69
|
PlaySetHeldSlot = 0x69
|
||||||
PlayDisconnect = 0x20
|
PlayDisconnect = 0x20
|
||||||
PlayBlockUpdate = 0x08
|
PlayBlockUpdate = 0x08
|
||||||
PlayBlockChangedAck = 0x04
|
PlayBlockChangedAck = 0x04
|
||||||
PlaySystemChat = 0x79
|
PlaySystemChat = 0x79
|
||||||
|
PlayLightUpdate = 0x30
|
||||||
|
PlayPlayerInfoRemove = 0x45
|
||||||
|
PlayPlayerInfoUpdate = 0x46
|
||||||
|
|
||||||
// Entity packets
|
// Entity packets
|
||||||
PlayAddEntity = 0x01
|
PlayAddEntity = 0x01
|
||||||
PlayTeleportEntity = 0x7D
|
PlayTeleportEntity = 0x7d
|
||||||
PlayMoveEntityPos = 0x35
|
PlayMoveEntityPos = 0x35
|
||||||
PlayMoveEntityPosRot = 0x36
|
PlayMoveEntityPosRot = 0x36
|
||||||
PlayMoveEntityRot = 0x38
|
PlayMoveEntityRot = 0x38
|
||||||
PlaySetEntityMotion = 0x65
|
PlaySetEntityMotion = 0x65
|
||||||
PlayRemoveEntities = 0x4D
|
PlayRemoveEntities = 0x4d
|
||||||
PlaySetEntityData = 0x63
|
PlaySetEntityData = 0x63
|
||||||
PlaySetEquipment = 0x66
|
PlaySetEquipment = 0x66
|
||||||
)
|
)
|
||||||
|
|
||||||
// Play, serverbound (protocol 775).
|
// Play, serverbound (protocol 775).
|
||||||
const (
|
const (
|
||||||
PlayAcceptTeleport = 0x00
|
PlayAcceptTeleport = 0x00
|
||||||
PlayKeepAliveServer = 0x1c
|
PlayKeepAliveServer = 0x1c
|
||||||
PlayClientTickEnd = 0x0d
|
PlayClientTickEnd = 0x0d
|
||||||
PlayClientInformation = 0x0e
|
PlayClientInformation = 0x0e
|
||||||
PlayCustomPayload = 0x16
|
PlayCustomPayload = 0x16
|
||||||
PlayMovePos = 0x1e
|
PlayMovePos = 0x1e
|
||||||
PlayMovePosRot = 0x1f
|
PlayMovePosRot = 0x1f
|
||||||
PlayMoveRot = 0x20
|
PlayMoveRot = 0x20
|
||||||
PlayMoveStatusOnly = 0x21
|
PlayMoveStatusOnly = 0x21
|
||||||
PlayPlayerLoaded = 0x2c
|
PlayPlayerLoaded = 0x2c
|
||||||
PlayPlayerAction = 0x29
|
PlayPlayerAction = 0x29
|
||||||
PlayUseItemOn = 0x42
|
PlayUseItemOn = 0x42
|
||||||
PlaySetCreativeSlot = 0x38
|
PlaySetCreativeSlot = 0x38
|
||||||
PlaySetCarriedItem = 0x35
|
PlaySetCarriedItem = 0x35
|
||||||
PlayChatMessage = 0x09
|
PlayChatMessage = 0x09
|
||||||
)
|
)
|
||||||
|
|
||||||
// GameEvent sub-IDs carried by the clientbound game_event packet.
|
// GameEvent sub-IDs carried by the clientbound game_event packet.
|
||||||
|
|
|
||||||
117
internal/protocol/ids_test.go
Normal file
117
internal/protocol/ids_test.go
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
package protocol
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestPlayPacketIDsProtocol775(t *testing.T) {
|
||||||
|
clientbound := map[string]int32{
|
||||||
|
"PlayAddEntity": PlayAddEntity,
|
||||||
|
"PlayBlockChangedAck": PlayBlockChangedAck,
|
||||||
|
"PlayBlockUpdate": PlayBlockUpdate,
|
||||||
|
"PlayDisconnect": PlayDisconnect,
|
||||||
|
"PlayForgetLevelChunk": PlayForgetLevelChunk,
|
||||||
|
"PlayGameEvent": PlayGameEvent,
|
||||||
|
"PlayKeepAliveCB": PlayKeepAliveCB,
|
||||||
|
"PlayLevelChunk": PlayLevelChunk,
|
||||||
|
"PlayLogin": PlayLogin,
|
||||||
|
"PlayMoveEntityPos": PlayMoveEntityPos,
|
||||||
|
"PlayMoveEntityPosRot": PlayMoveEntityPosRot,
|
||||||
|
"PlayMoveEntityRot": PlayMoveEntityRot,
|
||||||
|
"PlayAbilities": PlayAbilities,
|
||||||
|
"PlayPlayerPosition": PlayPlayerPosition,
|
||||||
|
"PlayRemoveEntities": PlayRemoveEntities,
|
||||||
|
"PlayChunkCacheCenter": PlayChunkCacheCenter,
|
||||||
|
"PlayDefaultSpawnPos": PlayDefaultSpawnPos,
|
||||||
|
"PlaySetEntityData": PlaySetEntityData,
|
||||||
|
"PlaySetEntityMotion": PlaySetEntityMotion,
|
||||||
|
"PlaySetEquipment": PlaySetEquipment,
|
||||||
|
"PlaySetHeldSlot": PlaySetHeldSlot,
|
||||||
|
"PlaySystemChat": PlaySystemChat,
|
||||||
|
"PlayLightUpdate": PlayLightUpdate,
|
||||||
|
"PlayPlayerInfoRemove": PlayPlayerInfoRemove,
|
||||||
|
"PlayPlayerInfoUpdate": PlayPlayerInfoUpdate,
|
||||||
|
"PlayTeleportEntity": PlayTeleportEntity,
|
||||||
|
}
|
||||||
|
wantClientbound := map[string]int32{
|
||||||
|
"PlayAddEntity": 0x01,
|
||||||
|
"PlayBlockChangedAck": 0x04,
|
||||||
|
"PlayBlockUpdate": 0x08,
|
||||||
|
"PlayDisconnect": 0x20,
|
||||||
|
"PlayForgetLevelChunk": 0x25,
|
||||||
|
"PlayGameEvent": 0x26,
|
||||||
|
"PlayKeepAliveCB": 0x2c,
|
||||||
|
"PlayLevelChunk": 0x2d,
|
||||||
|
"PlayLogin": 0x31,
|
||||||
|
"PlayMoveEntityPos": 0x35,
|
||||||
|
"PlayMoveEntityPosRot": 0x36,
|
||||||
|
"PlayMoveEntityRot": 0x38,
|
||||||
|
"PlayAbilities": 0x40,
|
||||||
|
"PlayPlayerPosition": 0x48,
|
||||||
|
"PlayRemoveEntities": 0x4d,
|
||||||
|
"PlayChunkCacheCenter": 0x5e,
|
||||||
|
"PlayDefaultSpawnPos": 0x61,
|
||||||
|
"PlaySetEntityData": 0x63,
|
||||||
|
"PlaySetEntityMotion": 0x65,
|
||||||
|
"PlaySetEquipment": 0x66,
|
||||||
|
"PlaySetHeldSlot": 0x69,
|
||||||
|
"PlaySystemChat": 0x79,
|
||||||
|
"PlayLightUpdate": 0x30,
|
||||||
|
"PlayPlayerInfoRemove": 0x45,
|
||||||
|
"PlayPlayerInfoUpdate": 0x46,
|
||||||
|
"PlayTeleportEntity": 0x7d,
|
||||||
|
}
|
||||||
|
assertIDs(t, "clientbound", clientbound, wantClientbound)
|
||||||
|
|
||||||
|
serverbound := map[string]int32{
|
||||||
|
"PlayAcceptTeleport": PlayAcceptTeleport,
|
||||||
|
"PlayKeepAliveServer": PlayKeepAliveServer,
|
||||||
|
"PlayClientTickEnd": PlayClientTickEnd,
|
||||||
|
"PlayClientInformation": PlayClientInformation,
|
||||||
|
"PlayCustomPayload": PlayCustomPayload,
|
||||||
|
"PlayMovePos": PlayMovePos,
|
||||||
|
"PlayMovePosRot": PlayMovePosRot,
|
||||||
|
"PlayMoveRot": PlayMoveRot,
|
||||||
|
"PlayMoveStatusOnly": PlayMoveStatusOnly,
|
||||||
|
"PlayPlayerLoaded": PlayPlayerLoaded,
|
||||||
|
"PlayPlayerAction": PlayPlayerAction,
|
||||||
|
"PlayUseItemOn": PlayUseItemOn,
|
||||||
|
"PlaySetCreativeSlot": PlaySetCreativeSlot,
|
||||||
|
"PlaySetCarriedItem": PlaySetCarriedItem,
|
||||||
|
"PlayChatMessage": PlayChatMessage,
|
||||||
|
}
|
||||||
|
wantServerbound := map[string]int32{
|
||||||
|
"PlayAcceptTeleport": 0x00,
|
||||||
|
"PlayKeepAliveServer": 0x1c,
|
||||||
|
"PlayClientTickEnd": 0x0d,
|
||||||
|
"PlayClientInformation": 0x0e,
|
||||||
|
"PlayCustomPayload": 0x16,
|
||||||
|
"PlayMovePos": 0x1e,
|
||||||
|
"PlayMovePosRot": 0x1f,
|
||||||
|
"PlayMoveRot": 0x20,
|
||||||
|
"PlayMoveStatusOnly": 0x21,
|
||||||
|
"PlayPlayerLoaded": 0x2c,
|
||||||
|
"PlayPlayerAction": 0x29,
|
||||||
|
"PlayUseItemOn": 0x42,
|
||||||
|
"PlaySetCreativeSlot": 0x38,
|
||||||
|
"PlaySetCarriedItem": 0x35,
|
||||||
|
"PlayChatMessage": 0x09,
|
||||||
|
}
|
||||||
|
assertIDs(t, "serverbound", serverbound, wantServerbound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertIDs(t *testing.T, direction string, got, want map[string]int32) {
|
||||||
|
t.Helper()
|
||||||
|
seen := make(map[int32]string, len(got))
|
||||||
|
for name, gotID := range got {
|
||||||
|
wantID, ok := want[name]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s %s missing expected ID", direction, name)
|
||||||
|
}
|
||||||
|
if gotID != wantID {
|
||||||
|
t.Fatalf("%s %s = %#x, want %#x", direction, name, gotID, wantID)
|
||||||
|
}
|
||||||
|
if previous, ok := seen[gotID]; ok {
|
||||||
|
t.Fatalf("%s duplicate packet ID %#x: %s and %s", direction, gotID, previous, name)
|
||||||
|
}
|
||||||
|
seen[gotID] = name
|
||||||
|
}
|
||||||
|
}
|
||||||
25
internal/registry/entity_test.go
Normal file
25
internal/registry/entity_test.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package registry
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestEntityTypeIndex(t *testing.T) {
|
||||||
|
tests := map[string]int{
|
||||||
|
"minecraft:pig": 100,
|
||||||
|
"minecraft:player": 155,
|
||||||
|
"minecraft:zombie": 150,
|
||||||
|
}
|
||||||
|
for name, want := range tests {
|
||||||
|
if got := EntityTypeIndex(name); got != want {
|
||||||
|
t.Fatalf("EntityTypeIndex(%q) = %d, want %d", name, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := EntityTypeIndex("minecraft:not_a_real_entity"); got != -1 {
|
||||||
|
t.Fatalf("unknown entity type = %d, want -1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntityTypeIsNotSyncedRegistry(t *testing.T) {
|
||||||
|
if got := Index("minecraft:entity_type", "minecraft:pig"); got != -1 {
|
||||||
|
t.Fatalf("synced registry unexpectedly has entity_type pig = %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -39,11 +39,19 @@ type Registry struct {
|
||||||
|
|
||||||
// synced is the parsed, ordered list loaded once at init.
|
// synced is the parsed, ordered list loaded once at init.
|
||||||
var synced []Registry
|
var synced []Registry
|
||||||
|
var syncedLookup map[string]map[string]int
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
if err := json.Unmarshal(syncedJSON, &synced); err != nil {
|
if err := json.Unmarshal(syncedJSON, &synced); err != nil {
|
||||||
panic(fmt.Sprintf("registry: parsing embedded synced_registries.json: %v", err))
|
panic(fmt.Sprintf("registry: parsing embedded synced_registries.json: %v", err))
|
||||||
}
|
}
|
||||||
|
syncedLookup = make(map[string]map[string]int)
|
||||||
|
for _, reg := range synced {
|
||||||
|
syncedLookup[reg.Name] = make(map[string]int)
|
||||||
|
for i, e := range reg.Entries {
|
||||||
|
syncedLookup[reg.Name][e] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Synced returns the ordered synchronized registries. The slice is shared and
|
// Synced returns the ordered synchronized registries. The slice is shared and
|
||||||
|
|
@ -54,14 +62,9 @@ func Synced() []Registry { return synced }
|
||||||
// which is the numeric (network) ID the client assigns it. It returns -1 if
|
// which is the numeric (network) ID the client assigns it. It returns -1 if
|
||||||
// the registry or entry is unknown.
|
// the registry or entry is unknown.
|
||||||
func Index(registryName, entry string) int {
|
func Index(registryName, entry string) int {
|
||||||
for _, reg := range synced {
|
if regMap, ok := syncedLookup[registryName]; ok {
|
||||||
if reg.Name != registryName {
|
if idx, ok := regMap[entry]; ok {
|
||||||
continue
|
return idx
|
||||||
}
|
|
||||||
for i, e := range reg.Entries {
|
|
||||||
if e == entry {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return -1
|
return -1
|
||||||
|
|
@ -77,3 +80,23 @@ type KnownPack struct {
|
||||||
// CorePack is the vanilla built-in pack. Advertising it lets a matching client
|
// CorePack is the vanilla built-in pack. Advertising it lets a matching client
|
||||||
// supply registry contents from its own copy.
|
// supply registry contents from its own copy.
|
||||||
var CorePack = KnownPack{Namespace: "minecraft", ID: "core", Version: "26.1.2"}
|
var CorePack = KnownPack{Namespace: "minecraft", ID: "core", Version: "26.1.2"}
|
||||||
|
|
||||||
|
// builtinEntityTypes contains the vanilla 26.1.2 BuiltInRegistries.ENTITY_TYPE
|
||||||
|
// network IDs needed by gameplay packets. This is intentionally separate from
|
||||||
|
// the synchronized registries above: minecraft:entity_type is a built-in
|
||||||
|
// registry in this protocol data set and is not sent in registry_data during
|
||||||
|
// configuration.
|
||||||
|
var builtinEntityTypes = map[string]int{
|
||||||
|
"minecraft:pig": 100,
|
||||||
|
"minecraft:player": 155,
|
||||||
|
"minecraft:zombie": 150,
|
||||||
|
}
|
||||||
|
|
||||||
|
// EntityTypeIndex returns the vanilla numeric ID for a built-in entity type, or
|
||||||
|
// -1 if it is not in the embedded table.
|
||||||
|
func EntityTypeIndex(name string) int {
|
||||||
|
if idx, ok := builtinEntityTypes[name]; ok {
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"regionio/internal/protocol"
|
"regionio/internal/protocol"
|
||||||
|
|
@ -57,31 +61,96 @@ type Server struct {
|
||||||
store *world.Store // nil when persistence is disabled
|
store *world.Store // nil when persistence is disabled
|
||||||
entities *world.EntityManager
|
entities *world.EntityManager
|
||||||
|
|
||||||
// playerPos tracks the last known position of each player by name.
|
playersMu sync.RWMutex
|
||||||
playerPos sync.Map // map[string][3]float64
|
players map[[16]byte]*PlayerSession
|
||||||
|
playerNames map[string][16]byte
|
||||||
|
nextPlayerID int32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PacketSender is the connection capability retained by the session registry.
|
||||||
|
// The network package supplies Conn.Send without introducing an import cycle.
|
||||||
|
type PacketSender func(id int32, body []byte) error
|
||||||
|
|
||||||
|
// PlayerSession is one active play-state client. Position is guarded separately
|
||||||
|
// so entity ticks can inspect players without holding the server registry lock.
|
||||||
|
type PlayerSession struct {
|
||||||
|
EntityID int32
|
||||||
|
Profile Profile
|
||||||
|
send PacketSender
|
||||||
|
mu sync.RWMutex
|
||||||
|
position [3]float64
|
||||||
|
yaw float32
|
||||||
|
pitch float32
|
||||||
|
onGround bool
|
||||||
|
viewDist int
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerSnapshot is an immutable view of one play-state session.
|
||||||
|
type PlayerSnapshot struct {
|
||||||
|
EntityID int32
|
||||||
|
Profile Profile
|
||||||
|
X, Y, Z float64
|
||||||
|
Yaw, Pitch float32
|
||||||
|
OnGround bool
|
||||||
|
ViewDistance int
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrServerFull = errors.New("server: player limit reached")
|
||||||
|
ErrDuplicatePlayer = errors.New("server: player is already connected")
|
||||||
|
)
|
||||||
|
|
||||||
// New constructs a Server from cfg. When cfg.WorldDir is set, the world is
|
// New constructs a Server from cfg. When cfg.WorldDir is set, the world is
|
||||||
// backed by an on-disk store under that directory; otherwise it is in-memory
|
// backed by an on-disk store under that directory; otherwise it is in-memory
|
||||||
// only. A returned error (e.g. the world dir cannot be created) is fatal.
|
// only. A returned error (e.g. the world dir cannot be created) is fatal.
|
||||||
func New(cfg Config) (*Server, error) {
|
func New(cfg Config) (*Server, error) {
|
||||||
|
if err := validateConfig(cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
gen := world.NewVanillaGenerator(cfg.WorldSeed)
|
gen := world.NewVanillaGenerator(cfg.WorldSeed)
|
||||||
em := world.NewEntityManager()
|
em := world.NewEntityManager()
|
||||||
if cfg.WorldDir == "" {
|
if cfg.WorldDir == "" {
|
||||||
// No persistence; keep eviction off too (flat/test worlds expect full
|
return newServerState(cfg,
|
||||||
// presence). Real servers set WorldDir and MaxCachedChunks together.
|
world.NewCacheWithLimit(int32(cfg.CompressionThreshold), gen, nil, cfg.MaxCachedChunks), nil, em), nil
|
||||||
return &Server{cfg: cfg, chunks: world.NewCache(int32(cfg.CompressionThreshold), gen), entities: em}, nil
|
|
||||||
}
|
}
|
||||||
store, err := world.NewStore(cfg.WorldDir)
|
store, err := world.NewStoreForSeed(cfg.WorldDir, cfg.WorldSeed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return newServerState(cfg,
|
||||||
|
world.NewCacheWithLimit(int32(cfg.CompressionThreshold), gen, store, cfg.MaxCachedChunks), store, em), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithCache constructs a server around an existing cache. It is useful for
|
||||||
|
// embedding and integration tests that provide a specialized world generator.
|
||||||
|
func NewWithCache(cfg Config, chunks *world.Cache) (*Server, error) {
|
||||||
|
if err := validateConfig(cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if chunks == nil {
|
||||||
|
return nil, errors.New("server: nil chunk cache")
|
||||||
|
}
|
||||||
|
return newServerState(cfg, chunks, nil, world.NewEntityManager()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateConfig(cfg Config) error {
|
||||||
|
if cfg.Port < 1 || cfg.Port > 65535 {
|
||||||
|
return fmt.Errorf("server: port %d out of range", cfg.Port)
|
||||||
|
}
|
||||||
|
if cfg.MaxPlayers < 1 {
|
||||||
|
return fmt.Errorf("server: max players must be positive")
|
||||||
|
}
|
||||||
|
if cfg.MaxCachedChunks < 0 {
|
||||||
|
return fmt.Errorf("server: max cached chunks must not be negative")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newServerState(cfg Config, chunks *world.Cache, store *world.Store, entities *world.EntityManager) *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
cfg: cfg,
|
cfg: cfg, chunks: chunks, store: store, entities: entities,
|
||||||
chunks: world.NewCacheWithLimit(int32(cfg.CompressionThreshold), gen, store, cfg.MaxCachedChunks),
|
players: make(map[[16]byte]*PlayerSession), playerNames: make(map[string][16]byte),
|
||||||
store: store,
|
}
|
||||||
entities: em,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config returns the active configuration.
|
// Config returns the active configuration.
|
||||||
|
|
@ -93,30 +162,202 @@ func (s *Server) Chunks() *world.Cache { return s.chunks }
|
||||||
// Entities returns the shared entity manager.
|
// Entities returns the shared entity manager.
|
||||||
func (s *Server) Entities() *world.EntityManager { return s.entities }
|
func (s *Server) Entities() *world.EntityManager { return s.entities }
|
||||||
|
|
||||||
// SetPlayerPosition updates the tracked position of a player.
|
// RegisterPlayer adds a profile to the active play-state registry.
|
||||||
func (s *Server) SetPlayerPosition(name string, x, y, z float64) {
|
func (s *Server) RegisterPlayer(profile Profile, send PacketSender) (*PlayerSession, error) {
|
||||||
s.playerPos.Store(name, [3]float64{x, y, z})
|
s.playersMu.Lock()
|
||||||
|
defer s.playersMu.Unlock()
|
||||||
|
nameKey := strings.ToLower(profile.Name)
|
||||||
|
if _, exists := s.players[profile.UUID]; exists {
|
||||||
|
return nil, ErrDuplicatePlayer
|
||||||
|
}
|
||||||
|
if _, exists := s.playerNames[nameKey]; exists {
|
||||||
|
return nil, ErrDuplicatePlayer
|
||||||
|
}
|
||||||
|
if s.cfg.MaxPlayers > 0 && len(s.players) >= s.cfg.MaxPlayers {
|
||||||
|
return nil, ErrServerFull
|
||||||
|
}
|
||||||
|
s.nextPlayerID++
|
||||||
|
session := &PlayerSession{
|
||||||
|
EntityID: s.nextPlayerID,
|
||||||
|
Profile: profile,
|
||||||
|
send: send,
|
||||||
|
onGround: true,
|
||||||
|
viewDist: 4,
|
||||||
|
}
|
||||||
|
s.players[profile.UUID] = session
|
||||||
|
s.playerNames[nameKey] = profile.UUID
|
||||||
|
return session, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemovePlayerPosition removes a player from tracking.
|
// UnregisterPlayer removes exactly the supplied session. Pointer identity keeps
|
||||||
func (s *Server) RemovePlayerPosition(name string) {
|
// a delayed disconnect from removing a future session with the same profile.
|
||||||
s.playerPos.Delete(name)
|
func (s *Server) UnregisterPlayer(session *PlayerSession) {
|
||||||
|
if session == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.playersMu.Lock()
|
||||||
|
defer s.playersMu.Unlock()
|
||||||
|
if current := s.players[session.Profile.UUID]; current == session {
|
||||||
|
delete(s.players, session.Profile.UUID)
|
||||||
|
delete(s.playerNames, strings.ToLower(session.Profile.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPlayerPosition updates a session's authoritative position snapshot.
|
||||||
|
func (s *Server) SetPlayerPosition(session *PlayerSession, x, y, z float64) {
|
||||||
|
if session == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.mu.Lock()
|
||||||
|
session.position = [3]float64{x, y, z}
|
||||||
|
session.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPlayerTransform updates all movement fields received from the client.
|
||||||
|
func (s *Server) SetPlayerTransform(session *PlayerSession, x, y, z float64, yaw, pitch float32, onGround bool) {
|
||||||
|
if session == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.mu.Lock()
|
||||||
|
session.position = [3]float64{x, y, z}
|
||||||
|
session.yaw = yaw
|
||||||
|
session.pitch = pitch
|
||||||
|
session.onGround = onGround
|
||||||
|
session.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPlayerViewDistance records the clamped chunk radius used for visibility.
|
||||||
|
func (s *Server) SetPlayerViewDistance(session *PlayerSession, distance int) {
|
||||||
|
if session == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if distance < 2 {
|
||||||
|
distance = 4
|
||||||
|
}
|
||||||
|
if distance > 16 {
|
||||||
|
distance = 16
|
||||||
|
}
|
||||||
|
session.mu.Lock()
|
||||||
|
session.viewDist = distance
|
||||||
|
session.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns a consistent copy of this session's gameplay state.
|
||||||
|
func (session *PlayerSession) Snapshot() PlayerSnapshot {
|
||||||
|
if session == nil {
|
||||||
|
return PlayerSnapshot{}
|
||||||
|
}
|
||||||
|
session.mu.RLock()
|
||||||
|
snapshot := PlayerSnapshot{
|
||||||
|
EntityID: session.EntityID,
|
||||||
|
Profile: session.Profile,
|
||||||
|
X: session.position[0],
|
||||||
|
Y: session.position[1],
|
||||||
|
Z: session.position[2],
|
||||||
|
Yaw: session.yaw,
|
||||||
|
Pitch: session.pitch,
|
||||||
|
OnGround: session.onGround,
|
||||||
|
ViewDistance: session.viewDist,
|
||||||
|
}
|
||||||
|
session.mu.RUnlock()
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerSnapshots returns consistent copies of all active play sessions.
|
||||||
|
func (s *Server) PlayerSnapshots() []PlayerSnapshot {
|
||||||
|
s.playersMu.RLock()
|
||||||
|
players := make([]*PlayerSession, 0, len(s.players))
|
||||||
|
for _, player := range s.players {
|
||||||
|
players = append(players, player)
|
||||||
|
}
|
||||||
|
s.playersMu.RUnlock()
|
||||||
|
|
||||||
|
snapshots := make([]PlayerSnapshot, 0, len(players))
|
||||||
|
for _, player := range players {
|
||||||
|
snapshots = append(snapshots, player.Snapshot())
|
||||||
|
}
|
||||||
|
return snapshots
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerCount returns the number of tracked players.
|
||||||
|
func (s *Server) PlayerCount() int {
|
||||||
|
s.playersMu.RLock()
|
||||||
|
defer s.playersMu.RUnlock()
|
||||||
|
return len(s.players)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast sends one already-encoded packet body to every active player. A
|
||||||
|
// failed recipient cannot make another player's gameplay handler fail.
|
||||||
|
func (s *Server) Broadcast(id int32, body []byte) {
|
||||||
|
s.playersMu.RLock()
|
||||||
|
senders := make([]PacketSender, 0, len(s.players))
|
||||||
|
for _, player := range s.players {
|
||||||
|
senders = append(senders, player.send)
|
||||||
|
}
|
||||||
|
s.playersMu.RUnlock()
|
||||||
|
for _, send := range senders {
|
||||||
|
if send != nil {
|
||||||
|
_ = send(id, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastChunk sends a packet only to players whose chunk view contains the
|
||||||
|
// target chunk. It is used for block and light changes.
|
||||||
|
func (s *Server) BroadcastChunk(cx, cz int32, id int32, body []byte) {
|
||||||
|
s.playersMu.RLock()
|
||||||
|
players := make([]*PlayerSession, 0, len(s.players))
|
||||||
|
for _, player := range s.players {
|
||||||
|
players = append(players, player)
|
||||||
|
}
|
||||||
|
s.playersMu.RUnlock()
|
||||||
|
|
||||||
|
for _, player := range players {
|
||||||
|
snapshot := player.Snapshot()
|
||||||
|
pcx := int32(int64(math.Floor(snapshot.X)) >> 4)
|
||||||
|
pcz := int32(int64(math.Floor(snapshot.Z)) >> 4)
|
||||||
|
if chunkDistance(pcx, pcz, cx, cz) <= int32(snapshot.ViewDistance) && player.send != nil {
|
||||||
|
_ = player.send(id, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func chunkDistance(ax, az, bx, bz int32) int32 {
|
||||||
|
dx := ax - bx
|
||||||
|
if dx < 0 {
|
||||||
|
dx = -dx
|
||||||
|
}
|
||||||
|
dz := az - bz
|
||||||
|
if dz < 0 {
|
||||||
|
dz = -dz
|
||||||
|
}
|
||||||
|
if dz > dx {
|
||||||
|
return dz
|
||||||
|
}
|
||||||
|
return dx
|
||||||
}
|
}
|
||||||
|
|
||||||
// NearestPlayer returns the position of the nearest player to (x, y, z).
|
// NearestPlayer returns the position of the nearest player to (x, y, z).
|
||||||
// Returns false if no players are online.
|
// Returns false if no players are online.
|
||||||
func (s *Server) NearestPlayer(x, y, z float64) (pos [3]float64, ok bool) {
|
func (s *Server) NearestPlayer(x, y, z float64) (pos [3]float64, ok bool) {
|
||||||
minDist := float64(-1)
|
minDist := float64(-1)
|
||||||
s.playerPos.Range(func(key, value any) bool {
|
s.playersMu.RLock()
|
||||||
p := value.([3]float64)
|
players := make([]*PlayerSession, 0, len(s.players))
|
||||||
|
for _, player := range s.players {
|
||||||
|
players = append(players, player)
|
||||||
|
}
|
||||||
|
s.playersMu.RUnlock()
|
||||||
|
for _, player := range players {
|
||||||
|
player.mu.RLock()
|
||||||
|
p := player.position
|
||||||
|
player.mu.RUnlock()
|
||||||
dist := (p[0]-x)*(p[0]-x) + (p[1]-y)*(p[1]-y) + (p[2]-z)*(p[2]-z)
|
dist := (p[0]-x)*(p[0]-x) + (p[1]-y)*(p[1]-y) + (p[2]-z)*(p[2]-z)
|
||||||
if minDist < 0 || dist < minDist {
|
if minDist < 0 || dist < minDist {
|
||||||
minDist = dist
|
minDist = dist
|
||||||
pos = p
|
pos = p
|
||||||
ok = true
|
ok = true
|
||||||
}
|
}
|
||||||
return true
|
}
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
130
internal/server/server_test.go
Normal file
130
internal/server/server_test.go
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"regionio/internal/world"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlayerRegistrySupportsFourPlayersAndEnforcesLimit(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.MaxPlayers = 4
|
||||||
|
srv, err := NewWithCache(cfg, world.NewCache(-1, world.GenerateFlat))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sends atomic.Int32
|
||||||
|
var sessions []*PlayerSession
|
||||||
|
for _, name := range []string{"Alice", "Bob", "Carol", "Dave"} {
|
||||||
|
profile := Profile{Name: name, UUID: OfflineUUID(name)}
|
||||||
|
session, err := srv.RegisterPlayer(profile, func(int32, []byte) error {
|
||||||
|
sends.Add(1)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("register %s: %v", name, err)
|
||||||
|
}
|
||||||
|
sessions = append(sessions, session)
|
||||||
|
}
|
||||||
|
if got := srv.PlayerCount(); got != 4 {
|
||||||
|
t.Fatalf("player count = %d, want 4", got)
|
||||||
|
}
|
||||||
|
if _, err := srv.RegisterPlayer(Profile{Name: "Eve", UUID: OfflineUUID("Eve")}, nil); !errors.Is(err, ErrServerFull) {
|
||||||
|
t.Fatalf("fifth player error = %v, want ErrServerFull", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv.Broadcast(1, []byte("packet"))
|
||||||
|
if got := sends.Load(); got != 4 {
|
||||||
|
t.Fatalf("broadcast sends = %d, want 4", got)
|
||||||
|
}
|
||||||
|
for _, session := range sessions {
|
||||||
|
srv.UnregisterPlayer(session)
|
||||||
|
}
|
||||||
|
if got := srv.PlayerCount(); got != 0 {
|
||||||
|
t.Fatalf("player count after disconnect = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentFourPlayerTransformsAndChunkBroadcast(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.WorldDir = ""
|
||||||
|
cfg.MaxPlayers = 4
|
||||||
|
srv, err := NewWithCache(cfg, world.NewCache(-1, world.GenerateFlat))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sends [4]atomic.Int32
|
||||||
|
sessions := make([]*PlayerSession, 4)
|
||||||
|
for i, name := range []string{"Alice", "Bob", "Carol", "Dave"} {
|
||||||
|
i := i
|
||||||
|
sessions[i], err = srv.RegisterPlayer(Profile{Name: name, UUID: OfflineUUID(name)}, func(int32, []byte) error {
|
||||||
|
sends[i].Add(1)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv.SetPlayerViewDistance(sessions[i], 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, session := range sessions {
|
||||||
|
i, session := i, session
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for step := 0; step < 500; step++ {
|
||||||
|
srv.SetPlayerTransform(session, float64(i*16+step), 80, float64(-step), float32(step%360), 10, step%2 == 0)
|
||||||
|
_ = srv.PlayerSnapshots()
|
||||||
|
srv.BroadcastChunk(int32(step>>4), int32(-step>>4), 1, nil)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if got := len(srv.PlayerSnapshots()); got != 4 {
|
||||||
|
t.Fatalf("snapshot count = %d, want 4", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, session := range sessions {
|
||||||
|
x := float64(i * 160)
|
||||||
|
srv.SetPlayerTransform(session, x, 80, 0, 0, 0, true)
|
||||||
|
before := sends[i].Load()
|
||||||
|
srv.BroadcastChunk(0, 0, 2, nil)
|
||||||
|
got := sends[i].Load() - before
|
||||||
|
if i == 0 && got != 1 {
|
||||||
|
t.Fatalf("near player received %d packets, want 1", got)
|
||||||
|
}
|
||||||
|
if i > 0 && got != 0 {
|
||||||
|
t.Fatalf("far player %d received %d packets, want 0", i, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlayerRegistryRejectsDuplicateName(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
srv, err := NewWithCache(cfg, world.NewCache(-1, world.GenerateFlat))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first := Profile{Name: "Alice", UUID: OfflineUUID("Alice")}
|
||||||
|
if _, err := srv.RegisterPlayer(first, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
duplicate := Profile{Name: "ALICE", UUID: OfflineUUID("ALICE")}
|
||||||
|
if _, err := srv.RegisterPlayer(duplicate, nil); !errors.Is(err, ErrDuplicatePlayer) {
|
||||||
|
t.Fatalf("duplicate error = %v, want ErrDuplicatePlayer", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerRejectsNegativeCacheLimit(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.MaxCachedChunks = -1
|
||||||
|
if _, err := NewWithCache(cfg, world.NewCache(-1, world.GenerateFlat)); err == nil {
|
||||||
|
t.Fatal("negative cache limit was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"math"
|
"math"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -10,92 +11,102 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// StartSpawning begins the entity tick and spawn loops.
|
// StartSpawning begins the entity tick and spawn loops.
|
||||||
func (s *Server) StartSpawning() {
|
func (s *Server) StartSpawning(ctx context.Context) {
|
||||||
go s.entityTickLoop()
|
go s.entityTickLoop(ctx)
|
||||||
go s.mobSpawnLoop()
|
go s.mobSpawnLoop(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) entityTickLoop() {
|
func (s *Server) entityTickLoop(ctx context.Context) {
|
||||||
ticker := time.NewTicker(50 * time.Millisecond) // 20 TPS
|
ticker := time.NewTicker(50 * time.Millisecond) // 20 TPS
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for range ticker.C {
|
for {
|
||||||
all := s.entities.All()
|
select {
|
||||||
for _, e := range all {
|
case <-ctx.Done():
|
||||||
// Apply gravity
|
return
|
||||||
yBelow := int(e.Y - 0.1) // slightly below the entity
|
case <-ticker.C:
|
||||||
blockBelow := s.chunks.GetBlock(int(e.X), yBelow, int(e.Z))
|
all := s.entities.All()
|
||||||
|
for i := range all {
|
||||||
if blockBelow == world.StateAir || blockBelow == world.StateWater { // Air or Water
|
snapshot := all[i]
|
||||||
e.VelocityY -= 80 // gravity acceleration
|
yBelow := int(math.Floor(snapshot.Y - 0.1))
|
||||||
if e.VelocityY < -3000 {
|
blockBelow := s.chunks.GetBlock(int(math.Floor(snapshot.X)), yBelow, int(math.Floor(snapshot.Z)))
|
||||||
e.VelocityY = -3000 // terminal velocity
|
nearest, hasPlayer := s.NearestPlayer(snapshot.X, snapshot.Y, snapshot.Z)
|
||||||
}
|
s.entities.Update(all[i].ID, func(e *world.Entity) {
|
||||||
} else {
|
// Apply gravity
|
||||||
e.VelocityY = 0
|
if blockBelow == world.StateAir || blockBelow == world.StateWater { // Air or Water
|
||||||
e.Y = float64(yBelow + 1)
|
e.VelocityY -= 80 // gravity acceleration
|
||||||
|
if e.VelocityY < -3000 {
|
||||||
// Basic random wandering or player tracking when on ground
|
e.VelocityY = -3000 // terminal velocity
|
||||||
pos, ok := s.NearestPlayer(e.X, e.Y, e.Z)
|
}
|
||||||
|
} else {
|
||||||
if ok && e.TypeName == "minecraft:zombie" {
|
e.VelocityY = 0
|
||||||
// Zombies move towards the player
|
e.Y = float64(yBelow + 1)
|
||||||
dx := pos[0] - e.X
|
|
||||||
dz := pos[2] - e.Z
|
// Basic random wandering or player tracking when on ground
|
||||||
dist := math.Sqrt(dx*dx + dz*dz)
|
if hasPlayer && e.TypeName == "minecraft:zombie" {
|
||||||
if dist > 1.0 && dist < 32.0 {
|
// Zombies move towards the player
|
||||||
e.X += (dx / dist) * 0.15
|
dx := nearest[0] - e.X
|
||||||
e.Z += (dz / dist) * 0.15
|
dz := nearest[2] - e.Z
|
||||||
// Simple yaw calculation
|
dist := math.Sqrt(dx*dx + dz*dz)
|
||||||
e.Yaw = float32(math.Atan2(-dx, dz) * (180 / math.Pi))
|
if dist > 1.0 && dist < 32.0 {
|
||||||
|
e.X += (dx / dist) * 0.15
|
||||||
|
e.Z += (dz / dist) * 0.15
|
||||||
|
// Simple yaw calculation
|
||||||
|
e.Yaw = float32(math.Atan2(-dx, dz) * (180 / math.Pi))
|
||||||
|
}
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Random wander
|
if e.VelocityY != 0 {
|
||||||
e.X += (rand.Float64() - 0.5) * 0.2
|
e.Y += float64(e.VelocityY) / 8000.0
|
||||||
e.Z += (rand.Float64() - 0.5) * 0.2
|
}
|
||||||
e.Yaw += float32((rand.Float64() - 0.5) * 10.0)
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if e.VelocityY != 0 {
|
|
||||||
e.Y += float64(e.VelocityY) / 8000.0
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) mobSpawnLoop() {
|
func (s *Server) mobSpawnLoop(ctx context.Context) {
|
||||||
ticker := time.NewTicker(2 * time.Second)
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
pigType := registry.Index("minecraft:entity_type", "minecraft:pig")
|
pigType := registry.EntityTypeIndex("minecraft:pig")
|
||||||
zombieType := registry.Index("minecraft:entity_type", "minecraft:zombie")
|
zombieType := registry.EntityTypeIndex("minecraft:zombie")
|
||||||
if pigType < 0 || zombieType < 0 {
|
if pigType < 0 || zombieType < 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for range ticker.C {
|
for {
|
||||||
all := s.entities.All()
|
select {
|
||||||
if len(all) > 50 {
|
case <-ctx.Done():
|
||||||
continue // limit to 50 entities
|
return
|
||||||
}
|
case <-ticker.C:
|
||||||
|
if s.PlayerCount() == 0 || s.entities.Count() >= 50 {
|
||||||
|
continue // limit to 50 entities
|
||||||
|
}
|
||||||
|
|
||||||
// Spawn near the spawn point (8.5, 200, 8.5)
|
// Spawn near the spawn point (8.5, 200, 8.5)
|
||||||
x := (rand.Float64() - 0.5) * 30.0
|
x := (rand.Float64() - 0.5) * 30.0
|
||||||
z := (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{
|
t := pigType
|
||||||
TypeID: t,
|
name := "minecraft:pig"
|
||||||
TypeName: name,
|
if rand.Float32() < 0.5 {
|
||||||
X: x + 8.5,
|
t = zombieType
|
||||||
Y: 200.0, // They float for now since there's no gravity
|
name = "minecraft:zombie"
|
||||||
Z: z + 8.5,
|
}
|
||||||
})
|
|
||||||
|
s.entities.Add(&world.Entity{
|
||||||
|
TypeID: t,
|
||||||
|
TypeName: name,
|
||||||
|
X: x + 8.5,
|
||||||
|
Y: 200.0,
|
||||||
|
Z: z + 8.5,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ package world
|
||||||
import (
|
import (
|
||||||
"container/list"
|
"container/list"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -36,13 +38,23 @@ type Cache struct {
|
||||||
store *Store // nil = in-memory only (tests, flat worlds)
|
store *Store // nil = in-memory only (tests, flat worlds)
|
||||||
maxChunks int // LRU capacity; 0 = unbounded
|
maxChunks int // LRU capacity; 0 = unbounded
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
chunks map[[2]int32]*Chunk
|
lightMu sync.Mutex
|
||||||
frames map[[2]int32][]byte
|
chunks map[[2]int32]*Chunk
|
||||||
dirty map[[2]int32]struct{}
|
frames map[[2]int32][]byte
|
||||||
|
dirty map[[2]int32]uint64
|
||||||
// LRU bookkeeping: order is MRU(front)→LRU(back); index gives O(1) lookup.
|
// LRU bookkeeping: order is MRU(front)→LRU(back); index gives O(1) lookup.
|
||||||
order *list.List // elements are *[2]int32; nil when maxChunks==0
|
order *list.List // elements are *[2]int32; nil when maxChunks==0
|
||||||
index map[[2]int32]*list.Element
|
index map[[2]int32]*list.Element
|
||||||
|
loads map[[2]int32]*chunkLoad
|
||||||
|
}
|
||||||
|
|
||||||
|
// chunkLoad coordinates concurrent misses for the same coordinate. The first
|
||||||
|
// caller performs disk I/O or generation; all others wait for that exact result.
|
||||||
|
type chunkLoad struct {
|
||||||
|
done chan struct{}
|
||||||
|
ch *Chunk
|
||||||
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCache returns a world cache that frames packets at the given compression
|
// NewCache returns a world cache that frames packets at the given compression
|
||||||
|
|
@ -54,7 +66,8 @@ func NewCache(threshold int32, gen Generator) *Cache {
|
||||||
gen: gen,
|
gen: gen,
|
||||||
chunks: make(map[[2]int32]*Chunk),
|
chunks: make(map[[2]int32]*Chunk),
|
||||||
frames: make(map[[2]int32][]byte),
|
frames: make(map[[2]int32][]byte),
|
||||||
dirty: make(map[[2]int32]struct{}),
|
dirty: make(map[[2]int32]uint64),
|
||||||
|
loads: make(map[[2]int32]*chunkLoad),
|
||||||
}
|
}
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
@ -74,6 +87,9 @@ func NewCacheWithStore(threshold int32, gen Generator, store *Store) *Cache {
|
||||||
// ≈ maxChunks×200KiB.
|
// ≈ maxChunks×200KiB.
|
||||||
func NewCacheWithLimit(threshold int32, gen Generator, store *Store, maxChunks int) *Cache {
|
func NewCacheWithLimit(threshold int32, gen Generator, store *Store, maxChunks int) *Cache {
|
||||||
c := NewCacheWithStore(threshold, gen, store)
|
c := NewCacheWithStore(threshold, gen, store)
|
||||||
|
if maxChunks < 0 {
|
||||||
|
maxChunks = 0
|
||||||
|
}
|
||||||
c.maxChunks = maxChunks
|
c.maxChunks = maxChunks
|
||||||
if maxChunks > 0 {
|
if maxChunks > 0 {
|
||||||
c.order = list.New()
|
c.order = list.New()
|
||||||
|
|
@ -84,7 +100,7 @@ func NewCacheWithLimit(threshold int32, gen Generator, store *Store, maxChunks i
|
||||||
|
|
||||||
// touch marks key as most-recently-used. Must be called under c.mu.
|
// touch marks key as most-recently-used. Must be called under c.mu.
|
||||||
func (c *Cache) touch(key [2]int32) {
|
func (c *Cache) touch(key [2]int32) {
|
||||||
if c.maxChunks == 0 {
|
if c.maxChunks <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if e, ok := c.index[key]; ok {
|
if e, ok := c.index[key]; ok {
|
||||||
|
|
@ -98,10 +114,11 @@ func (c *Cache) touch(key [2]int32) {
|
||||||
// Dirty chunks are skipped (moved back to MRU and the eviction halts) so the
|
// Dirty chunks are skipped (moved back to MRU and the eviction halts) so the
|
||||||
// autosave can persist them first. Must be called under c.mu.
|
// autosave can persist them first. Must be called under c.mu.
|
||||||
func (c *Cache) evictIfNeeded() {
|
func (c *Cache) evictIfNeeded() {
|
||||||
if c.maxChunks == 0 {
|
if c.maxChunks <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for len(c.chunks) > c.maxChunks {
|
checked := 0
|
||||||
|
for len(c.chunks) > c.maxChunks && checked < len(c.chunks) {
|
||||||
back := c.order.Back()
|
back := c.order.Back()
|
||||||
if back == nil {
|
if back == nil {
|
||||||
return
|
return
|
||||||
|
|
@ -112,12 +129,14 @@ func (c *Cache) evictIfNeeded() {
|
||||||
// next eviction pass can reclaim it.
|
// next eviction pass can reclaim it.
|
||||||
if _, dirty := c.dirty[key]; dirty && c.store != nil {
|
if _, dirty := c.dirty[key]; dirty && c.store != nil {
|
||||||
c.order.MoveToFront(back)
|
c.order.MoveToFront(back)
|
||||||
break
|
checked++
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
delete(c.chunks, key)
|
delete(c.chunks, key)
|
||||||
delete(c.frames, key)
|
delete(c.frames, key)
|
||||||
c.order.Remove(back)
|
c.order.Remove(back)
|
||||||
delete(c.index, key)
|
delete(c.index, key)
|
||||||
|
checked = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,66 +144,110 @@ func (c *Cache) evictIfNeeded() {
|
||||||
// disk (if a store is attached) → generation. Generation and disk reads run
|
// disk (if a store is attached) → generation. Generation and disk reads run
|
||||||
// outside the lock.
|
// outside the lock.
|
||||||
func (c *Cache) chunkAt(cx, cz int32) *Chunk {
|
func (c *Cache) chunkAt(cx, cz int32) *Chunk {
|
||||||
|
ch, _ := c.chunkAtErr(cx, cz)
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// chunkAtErr is the error-preserving form used by network and mutation paths.
|
||||||
|
// A corrupt or unreadable stored chunk is never replaced by generated terrain.
|
||||||
|
func (c *Cache) chunkAtErr(cx, cz int32) (*Chunk, error) {
|
||||||
key := [2]int32{cx, cz}
|
key := [2]int32{cx, cz}
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
if ch, ok := c.chunks[key]; ok {
|
if ch, ok := c.chunks[key]; ok {
|
||||||
c.touch(key)
|
c.touch(key)
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
return ch
|
return ch, nil
|
||||||
}
|
}
|
||||||
|
if pending, ok := c.loads[key]; ok {
|
||||||
|
c.mu.Unlock()
|
||||||
|
<-pending.done
|
||||||
|
return pending.ch, pending.err
|
||||||
|
}
|
||||||
|
pending := &chunkLoad{done: make(chan struct{})}
|
||||||
|
c.loads[key] = pending
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
// Try disk before generation so saved edits survive restarts.
|
// Try disk before generation so saved edits survive restarts.
|
||||||
var ch *Chunk
|
var ch *Chunk
|
||||||
|
var loadErr error
|
||||||
if c.store != nil {
|
if c.store != nil {
|
||||||
if loaded, err := c.store.LoadChunk(cx, cz); err == nil {
|
if loaded, err := c.store.LoadChunk(cx, cz); err == nil {
|
||||||
ch = loaded
|
ch = loaded
|
||||||
|
} else if !errors.Is(err, ErrChunkNotFound) {
|
||||||
|
loadErr = fmt.Errorf("world: load chunk (%d,%d): %w", cx, cz, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ch == nil {
|
if ch == nil && loadErr == nil {
|
||||||
ch = c.gen(cx, cz) // generate outside the lock
|
ch = c.gen(cx, cz) // generate outside the lock
|
||||||
|
if ch == nil {
|
||||||
|
loadErr = fmt.Errorf("world: generator returned nil chunk (%d,%d)", cx, cz)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
if loadErr == nil {
|
||||||
if existing, ok := c.chunks[key]; ok {
|
c.chunks[key] = ch
|
||||||
c.touch(key)
|
c.touch(key)
|
||||||
return existing // another goroutine won the race
|
c.evictIfNeeded()
|
||||||
}
|
}
|
||||||
c.chunks[key] = ch
|
pending.ch, pending.err = ch, loadErr
|
||||||
c.touch(key)
|
delete(c.loads, key)
|
||||||
c.evictIfNeeded()
|
close(pending.done)
|
||||||
return ch
|
c.mu.Unlock()
|
||||||
|
return ch, loadErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Frame returns the prebuilt level_chunk packet for (cx, cz), building it on
|
// Frame returns the prebuilt level_chunk packet for (cx, cz), building it on
|
||||||
// first request and caching until the chunk is edited. The slice must not be
|
// first request and caching until the chunk is edited. The slice must not be
|
||||||
// mutated.
|
// mutated.
|
||||||
func (c *Cache) Frame(cx, cz int32) []byte {
|
func (c *Cache) Frame(cx, cz int32) []byte {
|
||||||
|
frame, _ := c.FrameErr(cx, cz)
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
key := [2]int32{cx, cz}
|
key := [2]int32{cx, cz}
|
||||||
|
|
||||||
c.mu.Lock()
|
for {
|
||||||
if f, ok := c.frames[key]; ok {
|
c.mu.Lock()
|
||||||
c.touch(key)
|
if f, ok := c.frames[key]; ok {
|
||||||
|
c.touch(key)
|
||||||
|
c.mu.Unlock()
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
return f
|
|
||||||
}
|
|
||||||
c.mu.Unlock()
|
|
||||||
|
|
||||||
ch := c.chunkAt(cx, cz)
|
ch, err := c.chunkAtErr(cx, cz)
|
||||||
frame := protocol.AppendPacket(nil, c.threshold, protocol.PlayLevelChunk, ch.Encode())
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.ensureLight(ch); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
snapshot, revision := ch.snapshot()
|
||||||
|
frame := protocol.AppendPacket(nil, c.threshold, protocol.PlayLevelChunk, snapshot.encode())
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
if existing, ok := c.frames[key]; ok {
|
||||||
if existing, ok := c.frames[key]; ok {
|
c.touch(key)
|
||||||
|
c.mu.Unlock()
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
// An edit or eviction while the frame was being built makes it stale.
|
||||||
|
// Retry from a fresh snapshot instead of publishing old bytes forever.
|
||||||
|
if c.chunks[key] != ch || ch.currentRevision() != revision {
|
||||||
|
c.mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.frames[key] = frame
|
||||||
c.touch(key)
|
c.touch(key)
|
||||||
return existing
|
c.evictIfNeeded()
|
||||||
|
c.mu.Unlock()
|
||||||
|
return frame, nil
|
||||||
}
|
}
|
||||||
c.frames[key] = frame
|
|
||||||
c.touch(key)
|
|
||||||
c.evictIfNeeded()
|
|
||||||
return frame
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetBlock returns the block state at world coordinates (x, y, z).
|
// GetBlock returns the block state at world coordinates (x, y, z).
|
||||||
|
|
@ -195,32 +258,82 @@ func (c *Cache) GetBlock(x, y, z int) uint16 {
|
||||||
}
|
}
|
||||||
cx := int32(x >> 4)
|
cx := int32(x >> 4)
|
||||||
cz := int32(z >> 4)
|
cz := int32(z >> 4)
|
||||||
ch := c.chunkAt(cx, cz)
|
ch, err := c.chunkAtErr(cx, cz)
|
||||||
|
if err != nil {
|
||||||
|
return StateAir
|
||||||
|
}
|
||||||
return ch.GetBlock(x, y, z)
|
return ch.GetBlock(x, y, z)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetBlock changes the block at world coordinates (x, y, z), invalidating the
|
// LightUpdate returns the standalone light_update body for a loaded chunk.
|
||||||
// affected chunk's cached frame and marking it dirty for autosave. It reports
|
func (c *Cache) LightUpdate(cx, cz int32) ([]byte, error) {
|
||||||
// whether a chunk was actually touched (false if y is out of range).
|
ch, err := c.chunkAtErr(cx, cz)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := c.ensureLight(ch); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ch.EncodeLightUpdate(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBlock changes a block and incrementally updates lighting. Callers that need
|
||||||
|
// to broadcast every affected light chunk should use SetBlockWithLight.
|
||||||
func (c *Cache) SetBlock(x, y, z int, state uint16) bool {
|
func (c *Cache) SetBlock(x, y, z int, state uint16) bool {
|
||||||
|
valid, _ := c.SetBlockWithLight(x, y, z, state)
|
||||||
|
return valid
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChunkPos identifies a chunk changed by a lighting update.
|
||||||
|
type ChunkPos struct {
|
||||||
|
X, Z int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBlockWithLight changes a block and returns the loaded chunks whose stored
|
||||||
|
// light changed. Lighting operations are serialized so concurrent edits cannot
|
||||||
|
// publish mutually stale propagation results.
|
||||||
|
func (c *Cache) SetBlockWithLight(x, y, z int, state uint16) (bool, []ChunkPos) {
|
||||||
if y < MinY || y >= MinY+WorldHeight {
|
if y < MinY || y >= MinY+WorldHeight {
|
||||||
return false
|
return false, nil
|
||||||
}
|
}
|
||||||
cx := int32(x >> 4)
|
cx := int32(x >> 4)
|
||||||
cz := int32(z >> 4)
|
cz := int32(z >> 4)
|
||||||
ch := c.chunkAt(cx, cz)
|
ch, err := c.chunkAtErr(cx, cz)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
ch.SetBlock(x, y, z, state)
|
c.lightMu.Lock()
|
||||||
|
defer c.lightMu.Unlock()
|
||||||
|
live := c.cachedLightNeighborhood(cx, cz)
|
||||||
|
for _, neighbor := range live {
|
||||||
|
if err := c.ensureLightLocked(neighbor); err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, changed := ch.setBlock(x, y, z, state)
|
||||||
|
if !changed {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
lightChanged, err := c.updateLightAfterBlockLocked(x, y, z, live)
|
||||||
|
if err != nil {
|
||||||
|
// The block edit is still valid and dirty; a later Frame call will rebuild
|
||||||
|
// its light from the authoritative blocks.
|
||||||
|
ch.mu.Lock()
|
||||||
|
ch.lightReady = false
|
||||||
|
ch.mu.Unlock()
|
||||||
|
lightChanged = []ChunkPos{{X: cx, Z: cz}}
|
||||||
|
}
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
key := [2]int32{cx, cz}
|
key := [2]int32{cx, cz}
|
||||||
delete(c.frames, key)
|
delete(c.frames, key)
|
||||||
if c.store != nil {
|
if c.store != nil {
|
||||||
c.dirty[key] = struct{}{}
|
c.dirty[key] = ch.currentRevision()
|
||||||
}
|
}
|
||||||
c.touch(key) // edited chunk is most-recently-used
|
c.touch(key) // edited chunk is most-recently-used
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
return true
|
return true, lightChanged
|
||||||
}
|
}
|
||||||
|
|
||||||
// markDirty flags the chunk at (cx, cz) for the next autosave. Public so tests
|
// markDirty flags the chunk at (cx, cz) for the next autosave. Public so tests
|
||||||
|
|
@ -230,7 +343,10 @@ func (c *Cache) markDirty(cx, cz int32) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.dirty[[2]int32{cx, cz}] = struct{}{}
|
key := [2]int32{cx, cz}
|
||||||
|
if ch := c.chunks[key]; ch != nil {
|
||||||
|
c.dirty[key] = ch.currentRevision()
|
||||||
|
}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -277,22 +393,32 @@ func (c *Cache) flushDirty() error {
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
chunks[k] = c.chunks[k]
|
chunks[k] = c.chunks[k]
|
||||||
}
|
}
|
||||||
c.dirty = make(map[[2]int32]struct{})
|
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
var firstErr error
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
ch := chunks[k]
|
ch := chunks[k]
|
||||||
if ch == nil {
|
if ch == nil {
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.dirty, k)
|
||||||
|
c.mu.Unlock()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := c.store.SaveChunk(ch); err != nil {
|
snapshot, savedRevision := ch.snapshot()
|
||||||
c.mu.Lock()
|
if err := c.store.saveSnapshot(snapshot); err != nil {
|
||||||
c.dirty[k] = struct{}{} // re-mark; retry next cycle
|
if firstErr == nil {
|
||||||
c.mu.Unlock()
|
firstErr = err
|
||||||
return err
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
if dirtyRevision, ok := c.dirty[k]; ok && dirtyRevision <= savedRevision {
|
||||||
|
delete(c.dirty, k)
|
||||||
|
}
|
||||||
|
c.evictIfNeeded()
|
||||||
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
return nil
|
return firstErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveAll synchronously persists every chunk currently in memory. Used at
|
// SaveAll synchronously persists every chunk currently in memory. Used at
|
||||||
|
|
@ -301,24 +427,5 @@ func (c *Cache) SaveAll() error {
|
||||||
if c.store == nil {
|
if c.store == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
c.mu.Lock()
|
return c.flushDirty()
|
||||||
keys := make([][2]int32, 0, len(c.chunks))
|
|
||||||
for k := range c.chunks {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
chunks := make(map[[2]int32]*Chunk, len(keys))
|
|
||||||
for _, k := range keys {
|
|
||||||
chunks[k] = c.chunks[k]
|
|
||||||
}
|
|
||||||
c.mu.Unlock()
|
|
||||||
|
|
||||||
var firstErr error
|
|
||||||
for _, k := range keys {
|
|
||||||
if ch := chunks[k]; ch != nil {
|
|
||||||
if err := c.store.SaveChunk(ch); err != nil && firstErr == nil {
|
|
||||||
firstErr = err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return firstErr
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
174
internal/world/cache_light.go
Normal file
174
internal/world/cache_light.go
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
package world
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ensureLight calculates an exact center-chunk solution from a 3x3 block
|
||||||
|
// 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()
|
||||||
|
return c.ensureLightLocked(chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) ensureLightLocked(chunk *Chunk) error {
|
||||||
|
for {
|
||||||
|
center, revision := chunk.snapshot()
|
||||||
|
if center.lightReady {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks := make(map[[2]int32]*Chunk, 9)
|
||||||
|
for dz := int32(-1); dz <= 1; dz++ {
|
||||||
|
for dx := int32(-1); dx <= 1; dx++ {
|
||||||
|
key := [2]int32{chunk.X + dx, chunk.Z + dz}
|
||||||
|
if dx == 0 && dz == 0 {
|
||||||
|
chunks[key] = center
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
snapshot, err := c.lightInputSnapshot(key[0], key[1])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
chunks[key] = snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
volume := newLightVolume(int(chunk.X-1), int(chunk.Z-1), 3, 3, chunks)
|
||||||
|
clear(volume.sky)
|
||||||
|
clear(volume.block)
|
||||||
|
volume.calculate()
|
||||||
|
|
||||||
|
chunk.mu.Lock()
|
||||||
|
if chunk.revision.Load() != revision {
|
||||||
|
chunk.mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
chunk.installLight(volume)
|
||||||
|
chunk.mu.Unlock()
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.frames, [2]int32{chunk.X, chunk.Z})
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) cachedLightNeighborhood(cx, cz int32) map[[2]int32]*Chunk {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
chunks := make(map[[2]int32]*Chunk, 9)
|
||||||
|
for dz := int32(-1); dz <= 1; dz++ {
|
||||||
|
for dx := int32(-1); dx <= 1; dx++ {
|
||||||
|
key := [2]int32{cx + dx, cz + dz}
|
||||||
|
if chunk := c.chunks[key]; chunk != nil {
|
||||||
|
chunks[key] = chunk
|
||||||
|
c.touch(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) updateLightAfterBlockLocked(x, y, z int, live map[[2]int32]*Chunk) ([]ChunkPos, error) {
|
||||||
|
cx, cz := int32(x>>4), int32(z>>4)
|
||||||
|
inputs := make(map[[2]int32]*Chunk, 9)
|
||||||
|
for dz := int32(-1); dz <= 1; dz++ {
|
||||||
|
for dx := int32(-1); dx <= 1; dx++ {
|
||||||
|
key := [2]int32{cx + dx, cz + dz}
|
||||||
|
if chunk := live[key]; chunk != nil {
|
||||||
|
snapshot, _ := chunk.snapshot()
|
||||||
|
inputs[key] = snapshot
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
snapshot, err := c.lightInputSnapshot(key[0], key[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
inputs[key] = snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
volume := newLightVolume(int(cx-1), int(cz-1), 3, 3, inputs)
|
||||||
|
volume.relaxBlockChange(x-volume.minX, y, z-volume.minZ)
|
||||||
|
|
||||||
|
keys := make([][2]int32, 0, len(live))
|
||||||
|
for key := range live {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Slice(keys, func(i, j int) bool {
|
||||||
|
if keys[i][0] != keys[j][0] {
|
||||||
|
return keys[i][0] < keys[j][0]
|
||||||
|
}
|
||||||
|
return keys[i][1] < keys[j][1]
|
||||||
|
})
|
||||||
|
|
||||||
|
changed := make([]ChunkPos, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
chunk := live[key]
|
||||||
|
chunk.mu.Lock()
|
||||||
|
didChange := chunk.installLight(volume)
|
||||||
|
if didChange {
|
||||||
|
chunk.revision.Add(1)
|
||||||
|
changed = append(changed, ChunkPos{X: key[0], Z: key[1]})
|
||||||
|
}
|
||||||
|
revision := chunk.revision.Load()
|
||||||
|
chunk.mu.Unlock()
|
||||||
|
|
||||||
|
if didChange {
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.frames, key)
|
||||||
|
if c.store != nil {
|
||||||
|
c.dirty[key] = revision
|
||||||
|
}
|
||||||
|
c.touch(key)
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed, nil
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
package world
|
package world
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"regionio/internal/protocol"
|
||||||
)
|
)
|
||||||
|
|
||||||
// flatGen returns a generator that produces distinct chunks keyed by coordinate,
|
// flatGen returns a generator that produces distinct chunks keyed by coordinate,
|
||||||
|
|
@ -173,3 +179,72 @@ func TestEvictionReloadPreservesEdits(t *testing.T) {
|
||||||
t.Errorf("after eviction+reload, edited block = %d, want bedrock %d", got, StateBedrock)
|
t.Errorf("after eviction+reload, edited block = %d, want bedrock %d", got, StateBedrock)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConcurrentMissGeneratesChunkOnce(t *testing.T) {
|
||||||
|
var targetCalls atomic.Int32
|
||||||
|
gen := func(cx, cz int32) *Chunk {
|
||||||
|
if cx == 4 && cz == -7 {
|
||||||
|
targetCalls.Add(1)
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
return NewChunk(cx, cz, BiomePlains)
|
||||||
|
}
|
||||||
|
c := NewCache(256, gen)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if frame, err := c.FrameErr(4, -7); err != nil || len(frame) == 0 {
|
||||||
|
t.Errorf("FrameErr = %d bytes, %v", len(frame), err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if got := targetCalls.Load(); got != 1 {
|
||||||
|
t.Fatalf("target generator calls = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentFrameAndBlockEdits(t *testing.T) {
|
||||||
|
c := NewCache(256, flatGen())
|
||||||
|
if len(c.Frame(0, 0)) == 0 {
|
||||||
|
t.Fatal("initial frame is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < 250; i++ {
|
||||||
|
state := StateStone
|
||||||
|
if i%2 == 0 {
|
||||||
|
state = StateBedrock
|
||||||
|
}
|
||||||
|
c.SetBlock(3, SeaLevel, 3, state)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
if len(c.Frame(0, 0)) == 0 {
|
||||||
|
t.Error("frame became empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
wg.Wait()
|
||||||
|
c.SetBlock(3, SeaLevel, 3, StateBedrock)
|
||||||
|
if got := c.GetBlock(3, SeaLevel, 3); got != StateBedrock {
|
||||||
|
t.Fatalf("final block = %d, want %d", got, StateBedrock)
|
||||||
|
}
|
||||||
|
finalFrame := c.Frame(0, 0)
|
||||||
|
if len(finalFrame) == 0 {
|
||||||
|
t.Fatal("final frame is empty")
|
||||||
|
}
|
||||||
|
snapshot, _ := c.chunkAt(0, 0).snapshot()
|
||||||
|
wantFrame := protocol.AppendPacket(nil, 256, protocol.PlayLevelChunk, snapshot.encode())
|
||||||
|
if !bytes.Equal(finalFrame, wantFrame) {
|
||||||
|
t.Fatal("cached frame does not represent the final chunk revision")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,10 @@ func GenerateFlat(cx, cz int32) *Chunk {
|
||||||
c := NewChunk(cx, cz, BiomePlains)
|
c := NewChunk(cx, cz, BiomePlains)
|
||||||
for lx := 0; lx < 16; lx++ {
|
for lx := 0; lx < 16; lx++ {
|
||||||
for lz := 0; lz < 16; lz++ {
|
for lz := 0; lz < 16; lz++ {
|
||||||
c.SetBlock(lx, MinY+0, lz, StateBedrock)
|
c.setBlockRaw(lx, MinY+0, lz, StateBedrock)
|
||||||
c.SetBlock(lx, MinY+1, lz, StateDirt)
|
c.setBlockRaw(lx, MinY+1, lz, StateDirt)
|
||||||
c.SetBlock(lx, MinY+2, lz, StateDirt)
|
c.setBlockRaw(lx, MinY+2, lz, StateDirt)
|
||||||
c.SetBlock(lx, FlatSurfaceY, lz, StateGrass)
|
c.setBlockRaw(lx, FlatSurfaceY, lz, StateGrass)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return c
|
return c
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,16 @@ import (
|
||||||
// default compression for Anvil .mca chunk records (compression type 2).
|
// default compression for Anvil .mca chunk records (compression type 2).
|
||||||
|
|
||||||
// zlibDeflate compresses src into a new byte slice.
|
// zlibDeflate compresses src into a new byte slice.
|
||||||
func zlibDeflate(src []byte) []byte {
|
func zlibDeflate(src []byte) ([]byte, error) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
w := zlib.NewWriter(&buf)
|
w := zlib.NewWriter(&buf)
|
||||||
_, _ = w.Write(src)
|
if _, err := w.Write(src); err != nil {
|
||||||
_ = w.Close()
|
return nil, err
|
||||||
return buf.Bytes()
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// zlibInflate decompresses src (a zlib stream). It returns an error if src is
|
// zlibInflate decompresses src (a zlib stream). It returns an error if src is
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package world
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math/bits"
|
"math/bits"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"regionio/internal/protocol"
|
"regionio/internal/protocol"
|
||||||
)
|
)
|
||||||
|
|
@ -37,6 +39,7 @@ const (
|
||||||
StateSnow uint16 = 6919 // snow, layers=1
|
StateSnow uint16 = 6919 // snow, layers=1
|
||||||
StateSnowBlock uint16 = 6928
|
StateSnowBlock uint16 = 6928
|
||||||
StateIce uint16 = 6927
|
StateIce uint16 = 6927
|
||||||
|
StateGlowstone uint16 = 7016
|
||||||
StateMycelium uint16 = 8919
|
StateMycelium uint16 = 8919
|
||||||
StateTerracotta uint16 = 12912
|
StateTerracotta uint16 = 12912
|
||||||
StateRedSandstone uint16 = 13247
|
StateRedSandstone uint16 = 13247
|
||||||
|
|
@ -57,19 +60,24 @@ const totalBlockStates = 29873
|
||||||
// the biome direct-palette bit width.
|
// the biome direct-palette bit width.
|
||||||
const (
|
const (
|
||||||
biomeCellSize = 4
|
biomeCellSize = 4
|
||||||
biomeCellsXZ = 16 / biomeCellSize // 4
|
biomeCellsXZ = 16 / biomeCellSize // 4
|
||||||
biomeCellsPerSection = biomeCellsXZ * biomeCellsXZ * biomeCellsXZ // 64
|
biomeCellsPerSection = biomeCellsXZ * biomeCellsXZ * biomeCellsXZ // 64
|
||||||
totalBiomes = 65 // synced minecraft:worldgen/biome registry size
|
totalBiomes = 65 // synced minecraft:worldgen/biome registry size
|
||||||
)
|
)
|
||||||
|
|
||||||
// Chunk is a 16xWorldHeightx16 column of block states. Each section may carry a
|
// Chunk is a 16xWorldHeightx16 column of block states. Each section may carry a
|
||||||
// per-cell biome array (4×4×4); when biomes[si] is nil the section falls back to
|
// per-cell biome array (4×4×4); when biomes[si] is nil the section falls back to
|
||||||
// the column-wide biome field (used by flat/simple generators).
|
// the column-wide biome field (used by flat/simple generators).
|
||||||
type Chunk struct {
|
type Chunk struct {
|
||||||
X, Z int32
|
mu sync.RWMutex
|
||||||
sections [SectionCount]*[sectionVol]uint16
|
revision atomic.Uint64
|
||||||
biomes [SectionCount]*[biomeCellsPerSection]uint16
|
X, Z int32
|
||||||
biome uint16 // fallback uniform biome when biomes[si] is nil
|
sections [SectionCount]*[sectionVol]uint16
|
||||||
|
biomes [SectionCount]*[biomeCellsPerSection]uint16
|
||||||
|
skyLight [SectionCount]*[2048]byte
|
||||||
|
blockLight [SectionCount]*[2048]byte
|
||||||
|
lightReady bool
|
||||||
|
biome uint16 // fallback uniform biome when biomes[si] is nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewChunk returns an empty (all-air) chunk at (x, z) with the given biome.
|
// NewChunk returns an empty (all-air) chunk at (x, z) with the given biome.
|
||||||
|
|
@ -91,6 +99,13 @@ func (c *Chunk) section(i int) *[sectionVol]uint16 {
|
||||||
// GetBlock returns the block state at local (lx, lz) and world height y, or
|
// GetBlock returns the block state at local (lx, lz) and world height y, or
|
||||||
// StateAir if the section is empty or y is out of range.
|
// StateAir if the section is empty or y is out of range.
|
||||||
func (c *Chunk) GetBlock(lx, y, lz int) uint16 {
|
func (c *Chunk) GetBlock(lx, y, lz int) uint16 {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.getBlock(lx, y, lz)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getBlock is the lock-free form used while operating on a private snapshot.
|
||||||
|
func (c *Chunk) getBlock(lx, y, lz int) uint16 {
|
||||||
si := (y - MinY) >> 4
|
si := (y - MinY) >> 4
|
||||||
if si < 0 || si >= SectionCount {
|
if si < 0 || si >= SectionCount {
|
||||||
return StateAir
|
return StateAir
|
||||||
|
|
@ -106,6 +121,8 @@ func (c *Chunk) GetBlock(lx, y, lz int) uint16 {
|
||||||
// mirrors GetBlock: the per-section biome array if present, else the column's
|
// mirrors GetBlock: the per-section biome array if present, else the column's
|
||||||
// uniform fallback biome. Needed for on-disk chunk serialization.
|
// uniform fallback biome. Needed for on-disk chunk serialization.
|
||||||
func (c *Chunk) GetBiome(lx, y, lz int) uint16 {
|
func (c *Chunk) GetBiome(lx, y, lz int) uint16 {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
si := (y - MinY) >> 4
|
si := (y - MinY) >> 4
|
||||||
if si < 0 || si >= SectionCount {
|
if si < 0 || si >= SectionCount {
|
||||||
return c.biome
|
return c.biome
|
||||||
|
|
@ -119,6 +136,18 @@ func (c *Chunk) GetBiome(lx, y, lz int) uint16 {
|
||||||
|
|
||||||
// SetBlock sets the block at local (lx, lz) and absolute world height y.
|
// SetBlock sets the block at local (lx, lz) and absolute world height y.
|
||||||
func (c *Chunk) SetBlock(lx, y, lz int, state uint16) {
|
func (c *Chunk) SetBlock(lx, y, lz int, state uint16) {
|
||||||
|
_, changed := c.setBlock(lx, y, lz, state)
|
||||||
|
if changed {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.lightReady = false
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setBlockRaw writes to an unpublished chunk during generation. Generators call
|
||||||
|
// it only after their parallel sampling phases have joined and before the chunk
|
||||||
|
// enters Cache, avoiding a mutex operation for every solid terrain block.
|
||||||
|
func (c *Chunk) setBlockRaw(lx, y, lz int, state uint16) {
|
||||||
si := (y - MinY) >> 4
|
si := (y - MinY) >> 4
|
||||||
if si < 0 || si >= SectionCount {
|
if si < 0 || si >= SectionCount {
|
||||||
return
|
return
|
||||||
|
|
@ -126,6 +155,24 @@ func (c *Chunk) SetBlock(lx, y, lz int, state uint16) {
|
||||||
c.section(si)[blockIndex(lx, y, lz)] = state
|
c.section(si)[blockIndex(lx, y, lz)] = state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setBlock changes a block and returns the resulting revision. The changed flag
|
||||||
|
// lets the cache avoid dirtying a chunk for a no-op client prediction.
|
||||||
|
func (c *Chunk) setBlock(lx, y, lz int, state uint16) (revision uint64, changed bool) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
si := (y - MinY) >> 4
|
||||||
|
if si < 0 || si >= SectionCount {
|
||||||
|
return c.revision.Load(), false
|
||||||
|
}
|
||||||
|
idx := blockIndex(lx, y, lz)
|
||||||
|
s := c.section(si)
|
||||||
|
if s[idx] == state {
|
||||||
|
return c.revision.Load(), false
|
||||||
|
}
|
||||||
|
s[idx] = state
|
||||||
|
return c.revision.Add(1), true
|
||||||
|
}
|
||||||
|
|
||||||
// biomeIndex maps a block within a section to its YZX-ordered 4×4×4 biome cell.
|
// biomeIndex maps a block within a section to its YZX-ordered 4×4×4 biome cell.
|
||||||
// Coordinates are folded into 0..15 (block coords) then divided to cell coords.
|
// Coordinates are folded into 0..15 (block coords) then divided to cell coords.
|
||||||
func biomeIndex(lx, ly, lz int) int {
|
func biomeIndex(lx, ly, lz int) int {
|
||||||
|
|
@ -142,18 +189,70 @@ const biomeCellsXZBits = 2 // biomeCellsXZ=4 → 2 bits
|
||||||
// section's per-cell biome array is allocated lazily on first write. Any block
|
// section's per-cell biome array is allocated lazily on first write. Any block
|
||||||
// in the cell shares its biome, matching the 4-block resolution vanilla uses.
|
// in the cell shares its biome, matching the 4-block resolution vanilla uses.
|
||||||
func (c *Chunk) SetBiome(lx, y, lz int, biome uint16) {
|
func (c *Chunk) SetBiome(lx, y, lz int, biome uint16) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
si := (y - MinY) >> 4
|
si := (y - MinY) >> 4
|
||||||
if si < 0 || si >= SectionCount {
|
if si < 0 || si >= SectionCount {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if c.biomes[si] == nil {
|
if c.biomes[si] == nil {
|
||||||
c.biomes[si] = new([biomeCellsPerSection]uint16)
|
cells := new([biomeCellsPerSection]uint16)
|
||||||
|
for i := range cells {
|
||||||
|
cells[i] = c.biome
|
||||||
|
}
|
||||||
|
c.biomes[si] = cells
|
||||||
|
}
|
||||||
|
idx := biomeIndex(lx, y, lz)
|
||||||
|
if c.biomes[si][idx] != biome {
|
||||||
|
c.biomes[si][idx] = biome
|
||||||
|
c.revision.Add(1)
|
||||||
}
|
}
|
||||||
c.biomes[si][biomeIndex(lx, y, lz)] = biome
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode serializes the level_chunk_with_light body for this chunk.
|
// Encode serializes the level_chunk_with_light body for this chunk.
|
||||||
func (c *Chunk) Encode() []byte {
|
func (c *Chunk) Encode() []byte {
|
||||||
|
snapshot, _ := c.snapshot()
|
||||||
|
return snapshot.encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshot returns a detached, immutable copy and the revision it represents.
|
||||||
|
// Section arrays are copied so encoding and persistence never race block edits.
|
||||||
|
func (c *Chunk) snapshot() (*Chunk, uint64) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
revision := c.revision.Load()
|
||||||
|
clone := &Chunk{X: c.X, Z: c.Z, biome: c.biome}
|
||||||
|
clone.revision.Store(revision)
|
||||||
|
for i := 0; i < SectionCount; i++ {
|
||||||
|
if c.sections[i] != nil {
|
||||||
|
section := *c.sections[i]
|
||||||
|
clone.sections[i] = §ion
|
||||||
|
}
|
||||||
|
if c.biomes[i] != nil {
|
||||||
|
biomes := *c.biomes[i]
|
||||||
|
clone.biomes[i] = &biomes
|
||||||
|
}
|
||||||
|
if c.skyLight[i] != nil {
|
||||||
|
light := *c.skyLight[i]
|
||||||
|
clone.skyLight[i] = &light
|
||||||
|
}
|
||||||
|
if c.blockLight[i] != nil {
|
||||||
|
light := *c.blockLight[i]
|
||||||
|
clone.blockLight[i] = &light
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clone.lightReady = c.lightReady
|
||||||
|
return clone, revision
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentRevision returns the latest mutation revision.
|
||||||
|
func (c *Chunk) currentRevision() uint64 {
|
||||||
|
return c.revision.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// encode serializes a detached snapshot.
|
||||||
|
func (c *Chunk) encode() []byte {
|
||||||
w := protocol.NewWriter(8192)
|
w := protocol.NewWriter(8192)
|
||||||
w.Int32(c.X).Int32(c.Z)
|
w.Int32(c.X).Int32(c.Z)
|
||||||
c.writeHeightmaps(w)
|
c.writeHeightmaps(w)
|
||||||
|
|
@ -173,8 +272,8 @@ func (c *Chunk) Encode() []byte {
|
||||||
|
|
||||||
// Heightmap.Types ordinals sent to the client.
|
// Heightmap.Types ordinals sent to the client.
|
||||||
const (
|
const (
|
||||||
hmWorldSurface = 1
|
hmWorldSurface = 1
|
||||||
hmMotionBlocking = 4
|
hmMotionBlocking = 4
|
||||||
hmMotionBlockingNoLeaves = 5
|
hmMotionBlockingNoLeaves = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -232,8 +331,8 @@ func packHeightmap(h [256]uint16) []uint64 {
|
||||||
func (c *Chunk) writeSection(w *protocol.Writer, i int) {
|
func (c *Chunk) writeSection(w *protocol.Writer, i int) {
|
||||||
s := c.sections[i]
|
s := c.sections[i]
|
||||||
if s == nil {
|
if s == nil {
|
||||||
w.Uint16(0) // non-air block count
|
w.Uint16(0) // non-air block count
|
||||||
w.Uint16(0) // reserved 2-byte field (always 0 in vanilla)
|
w.Uint16(0) // reserved 2-byte field (always 0 in vanilla)
|
||||||
writeSingleValued(w, uint32(StateAir))
|
writeSingleValued(w, uint32(StateAir))
|
||||||
} else {
|
} else {
|
||||||
w.Uint16(uint16(nonAirCount(s)))
|
w.Uint16(uint16(nonAirCount(s)))
|
||||||
|
|
|
||||||
|
|
@ -3,22 +3,21 @@ package world
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Entity represents an in-game movable entity (mob, animal, etc).
|
// Entity represents an in-game movable entity (mob, animal, etc).
|
||||||
type Entity struct {
|
type Entity struct {
|
||||||
ID int32
|
ID int32
|
||||||
UUID [16]byte
|
UUID [16]byte
|
||||||
TypeID int // Network ID from the minecraft:entity_type registry
|
TypeID int // Network ID from the built-in minecraft:entity_type registry
|
||||||
TypeName string
|
TypeName string
|
||||||
|
|
||||||
X, Y, Z float64
|
X, Y, Z float64
|
||||||
Pitch, Yaw float32
|
Pitch, Yaw float32
|
||||||
HeadYaw float32
|
HeadYaw float32
|
||||||
VelocityX int16
|
VelocityX int16
|
||||||
VelocityY int16
|
VelocityY int16
|
||||||
VelocityZ int16
|
VelocityZ int16
|
||||||
}
|
}
|
||||||
|
|
||||||
// EntityManager tracks active entities in the server and manages thread-safe access.
|
// EntityManager tracks active entities in the server and manages thread-safe access.
|
||||||
|
|
@ -40,7 +39,8 @@ func NewEntityManager() *EntityManager {
|
||||||
func (em *EntityManager) Add(e *Entity) int32 {
|
func (em *EntityManager) Add(e *Entity) int32 {
|
||||||
em.mu.Lock()
|
em.mu.Lock()
|
||||||
defer em.mu.Unlock()
|
defer em.mu.Unlock()
|
||||||
e.ID = atomic.AddInt32(&em.nextID, 1)
|
em.nextID++
|
||||||
|
e.ID = em.nextID
|
||||||
if e.UUID == [16]byte{} {
|
if e.UUID == [16]byte{} {
|
||||||
rand.Read(e.UUID[:])
|
rand.Read(e.UUID[:])
|
||||||
// Version 4 UUID
|
// Version 4 UUID
|
||||||
|
|
@ -58,20 +58,41 @@ func (em *EntityManager) Remove(id int32) {
|
||||||
delete(em.entities, id)
|
delete(em.entities, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves an entity by ID, or nil if not found.
|
// Get retrieves a snapshot of an entity by ID, or nil if not found.
|
||||||
func (em *EntityManager) Get(id int32) *Entity {
|
func (em *EntityManager) Get(id int32) *Entity {
|
||||||
em.mu.RLock()
|
em.mu.RLock()
|
||||||
defer em.mu.RUnlock()
|
defer em.mu.RUnlock()
|
||||||
return em.entities[id]
|
e := em.entities[id]
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copy := *e
|
||||||
|
return ©
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update executes a function on an entity under a write lock.
|
||||||
|
func (em *EntityManager) Update(id int32, fn func(*Entity)) {
|
||||||
|
em.mu.Lock()
|
||||||
|
defer em.mu.Unlock()
|
||||||
|
if e, ok := em.entities[id]; ok {
|
||||||
|
fn(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// All returns a snapshot slice of all active entities.
|
// All returns a snapshot slice of all active entities.
|
||||||
func (em *EntityManager) All() []*Entity {
|
func (em *EntityManager) All() []Entity {
|
||||||
em.mu.RLock()
|
em.mu.RLock()
|
||||||
defer em.mu.RUnlock()
|
defer em.mu.RUnlock()
|
||||||
list := make([]*Entity, 0, len(em.entities))
|
list := make([]Entity, 0, len(em.entities))
|
||||||
for _, e := range em.entities {
|
for _, e := range em.entities {
|
||||||
list = append(list, e)
|
list = append(list, *e)
|
||||||
}
|
}
|
||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Count returns the number of active entities.
|
||||||
|
func (em *EntityManager) Count() int {
|
||||||
|
em.mu.RLock()
|
||||||
|
defer em.mu.RUnlock()
|
||||||
|
return len(em.entities)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ var oreSpecs = []oreSpec{
|
||||||
{"minecraft:gold_ore", MinY, MinY + 32, 4, 4, 0.25},
|
{"minecraft:gold_ore", MinY, MinY + 32, 4, 4, 0.25},
|
||||||
{"minecraft:redstone_ore", MinY, MinY + 16, 4, 4, 0.3},
|
{"minecraft:redstone_ore", MinY, MinY + 16, 4, 4, 0.3},
|
||||||
{"minecraft:lapis_ore", MinY, MinY + 32, 3, 4, 0.25},
|
{"minecraft:lapis_ore", MinY, MinY + 32, 3, 4, 0.25},
|
||||||
{"minecraft:diamond_ore", MinY, MinY - 16 + 16, 3, 3, 0.2}, // -64..-16-ish
|
{"minecraft:diamond_ore", MinY, MinY + 16, 3, 3, 0.2}, // -64..-48
|
||||||
{"minecraft:emerald_ore", MinY + 16, MinY + 48, 1, 1, 0.15},
|
{"minecraft:emerald_ore", MinY + 16, MinY + 48, 1, 1, 0.15},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,50 @@ func extractSectionData(t *testing.T, body []byte) []byte {
|
||||||
return body[8+consumed : 8+consumed+int(size)]
|
return body[8+consumed : 8+consumed+int(size)]
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGoldenAgainstVanilla asserts our flat-chunk section data is byte-for-byte
|
// extractLightData returns the complete LightData tail of level_chunk_with_light.
|
||||||
// identical to a chunk captured from the official 26.1.2 server (same world
|
func extractLightData(t *testing.T, body []byte) []byte {
|
||||||
// coordinate). This guards the paletted-container and heightmap encoding.
|
t.Helper()
|
||||||
// Light is intentionally not compared (we send full-bright, which differs).
|
r := protocol.NewReader(body[8:])
|
||||||
|
heightmaps, err := r.VarInt()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := int32(0); i < heightmaps; i++ {
|
||||||
|
if _, err := r.VarInt(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
longs, err := r.VarInt()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for j := int32(0); j < longs; j++ {
|
||||||
|
if _, err := r.Int64(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sectionBytes, err := r.VarInt()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := int32(0); i < sectionBytes; i++ {
|
||||||
|
if _, err := r.ReadByte(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
blockEntities, err := r.VarInt()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if blockEntities != 0 {
|
||||||
|
t.Fatalf("fixture has %d block entities; extractor only supports zero", blockEntities)
|
||||||
|
}
|
||||||
|
offset := len(body) - r.Remaining()
|
||||||
|
return body[offset:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGoldenAgainstVanilla asserts our flat chunk's section and light data are
|
||||||
|
// byte-for-byte identical to a capture from the official 26.1.2 server.
|
||||||
func TestGoldenAgainstVanilla(t *testing.T) {
|
func TestGoldenAgainstVanilla(t *testing.T) {
|
||||||
vanilla, err := os.ReadFile("testdata/vanilla_flat_chunk.bin")
|
vanilla, err := os.ReadFile("testdata/vanilla_flat_chunk.bin")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -43,4 +83,54 @@ func TestGoldenAgainstVanilla(t *testing.T) {
|
||||||
if !bytes.Equal(want, got) {
|
if !bytes.Equal(want, got) {
|
||||||
t.Fatalf("section data differs: vanilla=%d bytes, ours=%d bytes", len(want), len(got))
|
t.Fatalf("section data differs: vanilla=%d bytes, ours=%d bytes", len(want), len(got))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wantLight := extractLightData(t, vanilla)
|
||||||
|
gotLight := extractLightData(t, ours)
|
||||||
|
if !bytes.Equal(wantLight, gotLight) {
|
||||||
|
first := 0
|
||||||
|
for first < len(wantLight) && first < len(gotLight) && wantLight[first] == gotLight[first] {
|
||||||
|
first++
|
||||||
|
}
|
||||||
|
t.Fatalf("light data differs at byte %d: vanilla=%d bytes %v %x, ours=%d bytes %v %x", first, len(wantLight), lightSummary(t, wantLight), wantLight[:24], len(gotLight), lightSummary(t, gotLight), gotLight[:24])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lightSummary(t *testing.T, data []byte) [6]uint64 {
|
||||||
|
t.Helper()
|
||||||
|
r := protocol.NewReader(data)
|
||||||
|
var summary [6]uint64
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
longs, err := r.VarInt()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for j := int32(0); j < longs; j++ {
|
||||||
|
value, err := r.Int64()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if j == 0 {
|
||||||
|
summary[i] = uint64(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
count, err := r.VarInt()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
summary[4+i] = uint64(count)
|
||||||
|
for j := int32(0); j < count; j++ {
|
||||||
|
length, err := r.VarInt()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for k := int32(0); k < length; k++ {
|
||||||
|
if _, err := r.ReadByte(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return summary
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,119 +2,484 @@ package world
|
||||||
|
|
||||||
import "regionio/internal/protocol"
|
import "regionio/internal/protocol"
|
||||||
|
|
||||||
// lightSections is the number of light subchunks: one below the world and one
|
// Light is stored as vanilla nibble arrays: one 2048-byte array per 16^3
|
||||||
// above, plus one per block section.
|
// section. The two protocol-only sections below and above the world are added
|
||||||
|
// while encoding.
|
||||||
const lightSections = SectionCount + 2
|
const lightSections = SectionCount + 2
|
||||||
|
|
||||||
// writeLight computes and emits simple lighting data. It does a vertical pass
|
type lightVolume struct {
|
||||||
// for sky light (sunlight propagating downward) and a single-block pass for
|
minX, minZ int
|
||||||
// block light (emissive blocks), without horizontal flood-fill.
|
width int
|
||||||
func (c *Chunk) writeLight(w *protocol.Writer) {
|
depth int
|
||||||
skyLight := make([]*[2048]byte, lightSections)
|
blocks []uint16
|
||||||
blockLight := make([]*[2048]byte, lightSections)
|
sky []byte
|
||||||
|
block []byte
|
||||||
|
}
|
||||||
|
|
||||||
// Section lightSections-1 is above the world, fully lit by the sky.
|
type lightNode struct {
|
||||||
skyLight[lightSections-1] = new([2048]byte)
|
x, y, z int
|
||||||
for i := range skyLight[lightSections-1] {
|
}
|
||||||
skyLight[lightSections-1][i] = 0xFF
|
|
||||||
|
var lightDirections = [...]lightNode{
|
||||||
|
{0, -1, 0},
|
||||||
|
{0, 1, 0},
|
||||||
|
{0, 0, -1},
|
||||||
|
{0, 0, 1},
|
||||||
|
{-1, 0, 0},
|
||||||
|
{1, 0, 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLightVolume(minCX, minCZ, chunksWide, chunksDeep int, chunks map[[2]int32]*Chunk) *lightVolume {
|
||||||
|
v := &lightVolume{
|
||||||
|
minX: minCX * 16,
|
||||||
|
minZ: minCZ * 16,
|
||||||
|
width: chunksWide * 16,
|
||||||
|
depth: chunksDeep * 16,
|
||||||
}
|
}
|
||||||
|
count := v.width * WorldHeight * v.depth
|
||||||
for lx := 0; lx < 16; lx++ {
|
v.blocks = make([]uint16, count)
|
||||||
for lz := 0; lz < 16; lz++ {
|
v.sky = make([]byte, count)
|
||||||
// Sky light pass
|
v.block = make([]byte, count)
|
||||||
currentSky := byte(15)
|
for key, chunk := range chunks {
|
||||||
for y := MinY + WorldHeight - 1; y >= MinY; y-- {
|
baseX := int(key[0])*16 - v.minX
|
||||||
block := c.GetBlock(lx, y, lz)
|
baseZ := int(key[1])*16 - v.minZ
|
||||||
op := blockOpacity[block]
|
for y := MinY; y < MinY+WorldHeight; y++ {
|
||||||
if op >= currentSky {
|
for z := 0; z < 16; z++ {
|
||||||
currentSky = 0
|
for x := 0; x < 16; x++ {
|
||||||
} else {
|
idx := v.indexLocal(baseX+x, y, baseZ+z)
|
||||||
currentSky -= op
|
v.blocks[idx] = chunk.getBlock(x, y, z)
|
||||||
}
|
if chunk.lightReady {
|
||||||
|
v.sky[idx] = chunk.getLight(false, x, y, z)
|
||||||
if currentSky > 0 {
|
v.block[idx] = chunk.getLight(true, x, y, z)
|
||||||
si := (y - MinY) >> 4
|
|
||||||
lsi := si + 1
|
|
||||||
if skyLight[lsi] == nil {
|
|
||||||
skyLight[lsi] = new([2048]byte)
|
|
||||||
}
|
|
||||||
idx := blockIndex(lx, y, lz)
|
|
||||||
if idx%2 == 0 {
|
|
||||||
skyLight[lsi][idx/2] |= currentSky
|
|
||||||
} else {
|
|
||||||
skyLight[lsi][idx/2] |= currentSky << 4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block light pass
|
|
||||||
em := blockEmission[block]
|
|
||||||
if em > 0 {
|
|
||||||
si := (y - MinY) >> 4
|
|
||||||
lsi := si + 1
|
|
||||||
if blockLight[lsi] == nil {
|
|
||||||
blockLight[lsi] = new([2048]byte)
|
|
||||||
}
|
|
||||||
idx := blockIndex(lx, y, lz)
|
|
||||||
if idx%2 == 0 {
|
|
||||||
blockLight[lsi][idx/2] |= em
|
|
||||||
} else {
|
|
||||||
blockLight[lsi][idx/2] |= em << 4
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
var skyMask, blockMask, emptySkyMask, emptyBlockMask uint64
|
func (v *lightVolume) indexLocal(x, y, z int) int {
|
||||||
var skyCount, blockCount int
|
return ((y-MinY)*v.depth+z)*v.width + x
|
||||||
|
}
|
||||||
|
|
||||||
for i := 0; i < lightSections; i++ {
|
func (v *lightVolume) inside(x, y, z int) bool {
|
||||||
if skyLight[i] != nil {
|
return x >= 0 && x < v.width && z >= 0 && z < v.depth && y >= MinY && y < MinY+WorldHeight
|
||||||
skyMask |= 1 << i
|
}
|
||||||
skyCount++
|
|
||||||
} else {
|
func (v *lightVolume) calculate() {
|
||||||
emptySkyMask |= 1 << i
|
v.calculateSky()
|
||||||
|
v.calculateBlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *lightVolume) calculateSky() {
|
||||||
|
queue := make([]int, 0, len(v.sky)/2)
|
||||||
|
for z := 0; z < v.depth; z++ {
|
||||||
|
for x := 0; x < v.width; x++ {
|
||||||
|
from := StateAir
|
||||||
|
for y := MinY + WorldHeight - 1; y >= MinY; y-- {
|
||||||
|
idx := v.indexLocal(x, y, z)
|
||||||
|
state := v.blocks[idx]
|
||||||
|
if lightOpacity(state) != 0 || lightShapeOccludes(from, state, 0) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
v.sky[idx] = 15
|
||||||
|
queue = append(queue, idx)
|
||||||
|
from = state
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
v.propagateIncreases(v.sky, queue)
|
||||||
|
}
|
||||||
|
|
||||||
if blockLight[i] != nil {
|
func (v *lightVolume) calculateBlock() {
|
||||||
blockMask |= 1 << i
|
queue := make([]int, 0, 256)
|
||||||
blockCount++
|
for idx, state := range v.blocks {
|
||||||
} else {
|
if emission := lightEmission(state); emission > 0 {
|
||||||
emptyBlockMask |= 1 << i
|
v.block[idx] = emission
|
||||||
|
queue = append(queue, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.propagateIncreases(v.block, queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *lightVolume) propagateIncreases(levels []byte, queue []int) {
|
||||||
|
for head := 0; head < len(queue); head++ {
|
||||||
|
idx := queue[head]
|
||||||
|
level := levels[idx]
|
||||||
|
if level <= 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
x, y, z := v.coordinates(idx)
|
||||||
|
from := v.blocks[idx]
|
||||||
|
for direction, delta := range lightDirections {
|
||||||
|
nx, ny, nz := x+delta.x, y+delta.y, z+delta.z
|
||||||
|
if !v.inside(nx, ny, nz) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nidx := v.indexLocal(nx, ny, nz)
|
||||||
|
into := v.blocks[nidx]
|
||||||
|
attenuation := lightOpacity(into)
|
||||||
|
if attenuation < 1 {
|
||||||
|
attenuation = 1
|
||||||
|
}
|
||||||
|
if attenuation >= level || lightShapeOccludes(from, into, direction) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidate := level - attenuation
|
||||||
|
if candidate > levels[nidx] {
|
||||||
|
levels[nidx] = candidate
|
||||||
|
queue = append(queue, nidx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *lightVolume) coordinates(idx int) (x, y, z int) {
|
||||||
|
x = idx % v.width
|
||||||
|
row := idx / v.width
|
||||||
|
z = row % v.depth
|
||||||
|
y = row/v.depth + MinY
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *lightVolume) relaxBlockChange(x, y, z int) {
|
||||||
|
skySources := v.skySources()
|
||||||
|
blockSeeds := make([]int, 0, 7)
|
||||||
|
if v.inside(x, y, z) {
|
||||||
|
idx := v.indexLocal(x, y, z)
|
||||||
|
blockSeeds = append(blockSeeds, idx)
|
||||||
|
for _, delta := range lightDirections {
|
||||||
|
if v.inside(x+delta.x, y+delta.y, z+delta.z) {
|
||||||
|
blockSeeds = append(blockSeeds, v.indexLocal(x+delta.x, y+delta.y, z+delta.z))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.relax(v.block, nil, blockSeeds)
|
||||||
|
|
||||||
|
skySeeds := make([]int, 0, WorldHeight*2)
|
||||||
|
for sy := MinY; sy < MinY+WorldHeight; sy++ {
|
||||||
|
idx := v.indexLocal(x, sy, z)
|
||||||
|
skySeeds = append(skySeeds, idx)
|
||||||
|
for _, direction := range []int{4, 5, 2, 3} {
|
||||||
|
delta := lightDirections[direction]
|
||||||
|
if v.inside(x+delta.x, sy, z+delta.z) {
|
||||||
|
skySeeds = append(skySeeds, v.indexLocal(x+delta.x, sy, z+delta.z))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.relax(v.sky, skySources, skySeeds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *lightVolume) skySources() []bool {
|
||||||
|
sources := make([]bool, len(v.sky))
|
||||||
|
for z := 0; z < v.depth; z++ {
|
||||||
|
for x := 0; x < v.width; x++ {
|
||||||
|
from := StateAir
|
||||||
|
for y := MinY + WorldHeight - 1; y >= MinY; y-- {
|
||||||
|
idx := v.indexLocal(x, y, z)
|
||||||
|
state := v.blocks[idx]
|
||||||
|
if lightOpacity(state) != 0 || lightShapeOccludes(from, state, 0) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sources[idx] = true
|
||||||
|
from = state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sources
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *lightVolume) relax(levels []byte, sources []bool, seeds []int) {
|
||||||
|
queued := make([]bool, len(levels))
|
||||||
|
queue := make([]int, 0, len(seeds)*2)
|
||||||
|
for _, idx := range seeds {
|
||||||
|
if idx >= 0 && idx < len(levels) && !queued[idx] {
|
||||||
|
queued[idx] = true
|
||||||
|
queue = append(queue, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for head := 0; head < len(queue); head++ {
|
||||||
|
idx := queue[head]
|
||||||
|
queued[idx] = false
|
||||||
|
desired := v.desiredLight(levels, sources, idx)
|
||||||
|
if desired == levels[idx] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
levels[idx] = desired
|
||||||
|
x, y, z := v.coordinates(idx)
|
||||||
|
for _, delta := range lightDirections {
|
||||||
|
nx, ny, nz := x+delta.x, y+delta.y, z+delta.z
|
||||||
|
if !v.inside(nx, ny, nz) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nidx := v.indexLocal(nx, ny, nz)
|
||||||
|
if !queued[nidx] {
|
||||||
|
queued[nidx] = true
|
||||||
|
queue = append(queue, nidx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *lightVolume) desiredLight(levels []byte, sources []bool, idx int) byte {
|
||||||
|
state := v.blocks[idx]
|
||||||
|
desired := lightEmission(state)
|
||||||
|
if sources != nil {
|
||||||
|
desired = 0
|
||||||
|
if sources[idx] {
|
||||||
|
desired = 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attenuation := lightOpacity(state)
|
||||||
|
if attenuation < 1 {
|
||||||
|
attenuation = 1
|
||||||
|
}
|
||||||
|
x, y, z := v.coordinates(idx)
|
||||||
|
opposite := [...]int{1, 0, 3, 2, 5, 4}
|
||||||
|
for direction, delta := range lightDirections {
|
||||||
|
nx, ny, nz := x+delta.x, y+delta.y, z+delta.z
|
||||||
|
if !v.inside(nx, ny, nz) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nidx := v.indexLocal(nx, ny, nz)
|
||||||
|
neighbor := levels[nidx]
|
||||||
|
if neighbor <= attenuation || lightShapeOccludes(v.blocks[nidx], state, opposite[direction]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidate := neighbor - attenuation
|
||||||
|
if candidate > desired {
|
||||||
|
desired = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return desired
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chunk) getLight(block bool, x, y, z int) byte {
|
||||||
|
si := (y - MinY) >> 4
|
||||||
|
if si < 0 || si >= SectionCount {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
layers := &c.skyLight
|
||||||
|
if block {
|
||||||
|
layers = &c.blockLight
|
||||||
|
}
|
||||||
|
section := layers[si]
|
||||||
|
if section == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
idx := blockIndex(x, y, z)
|
||||||
|
b := section[idx>>1]
|
||||||
|
if idx&1 == 0 {
|
||||||
|
return b & 0x0f
|
||||||
|
}
|
||||||
|
return b >> 4
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chunk) setLight(block bool, x, y, z int, value byte) bool {
|
||||||
|
si := (y - MinY) >> 4
|
||||||
|
if si < 0 || si >= SectionCount {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
layers := &c.skyLight
|
||||||
|
if block {
|
||||||
|
layers = &c.blockLight
|
||||||
|
}
|
||||||
|
section := layers[si]
|
||||||
|
if section == nil {
|
||||||
|
if value == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
section = new([2048]byte)
|
||||||
|
layers[si] = section
|
||||||
|
}
|
||||||
|
idx := blockIndex(x, y, z)
|
||||||
|
old := section[idx>>1]
|
||||||
|
if idx&1 == 0 {
|
||||||
|
section[idx>>1] = old&0xf0 | value&0x0f
|
||||||
|
} else {
|
||||||
|
section[idx>>1] = old&0x0f | value<<4
|
||||||
|
}
|
||||||
|
return old != section[idx>>1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// LightAt returns the stored sky and block light at a local block coordinate.
|
||||||
|
func (c *Chunk) LightAt(x, y, z int) (sky, block byte, ready bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.getLight(false, x, y, z), c.getLight(true, x, y, z), c.lightReady
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chunk) installLight(v *lightVolume) bool {
|
||||||
|
changed := !c.lightReady
|
||||||
|
var sky [SectionCount]*[2048]byte
|
||||||
|
var block [SectionCount]*[2048]byte
|
||||||
|
baseX := int(c.X)*16 - v.minX
|
||||||
|
baseZ := int(c.Z)*16 - v.minZ
|
||||||
|
for y := MinY; y < MinY+WorldHeight; y++ {
|
||||||
|
for z := 0; z < 16; z++ {
|
||||||
|
for x := 0; x < 16; x++ {
|
||||||
|
idx := v.indexLocal(baseX+x, y, baseZ+z)
|
||||||
|
installNibble(&sky, x, y, z, v.sky[idx])
|
||||||
|
installNibble(&block, x, y, z, v.block[idx])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !lightLayersEqual(c.skyLight, sky) || !lightLayersEqual(c.blockLight, block) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
c.skyLight = sky
|
||||||
|
c.blockLight = block
|
||||||
|
c.lightReady = true
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func installNibble(layers *[SectionCount]*[2048]byte, x, y, z int, value byte) {
|
||||||
|
if value == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
si := (y - MinY) >> 4
|
||||||
|
if layers[si] == nil {
|
||||||
|
layers[si] = new([2048]byte)
|
||||||
|
}
|
||||||
|
idx := blockIndex(x, y, z)
|
||||||
|
if idx&1 == 0 {
|
||||||
|
layers[si][idx>>1] |= value
|
||||||
|
} else {
|
||||||
|
layers[si][idx>>1] |= value << 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lightLayersEqual(a, b [SectionCount]*[2048]byte) bool {
|
||||||
|
for i := 0; i < SectionCount; i++ {
|
||||||
|
if a[i] == nil || b[i] == nil {
|
||||||
|
if a[i] != nil || b[i] != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if *a[i] != *b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chunk) writeLight(w *protocol.Writer) {
|
||||||
|
sky, block, highest := c.skyLight, c.blockLight, c.highestFilledSection()
|
||||||
|
if !c.lightReady {
|
||||||
|
chunks := map[[2]int32]*Chunk{{c.X, c.Z}: c}
|
||||||
|
v := newLightVolume(int(c.X), int(c.Z), 1, 1, chunks)
|
||||||
|
v.calculate()
|
||||||
|
standalone := &Chunk{X: c.X, Z: c.Z}
|
||||||
|
standalone.installLight(v)
|
||||||
|
sky, block = standalone.skyLight, standalone.blockLight
|
||||||
|
}
|
||||||
|
maxLightSection := highest + 2
|
||||||
|
if maxLightSection < 0 {
|
||||||
|
maxLightSection = 0
|
||||||
|
}
|
||||||
|
writeLightLayers(w, sky, block, maxLightSection)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Chunk) highestFilledSection() int {
|
||||||
|
for si := SectionCount - 1; si >= 0; si-- {
|
||||||
|
if c.sections[si] == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, state := range c.sections[si] {
|
||||||
|
if state != StateAir {
|
||||||
|
return si
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeLightLayers(w *protocol.Writer, sky, block [SectionCount]*[2048]byte, maxLightSection int) {
|
||||||
|
if maxLightSection >= lightSections {
|
||||||
|
maxLightSection = lightSections - 1
|
||||||
|
}
|
||||||
|
var skySections [lightSections]*[2048]byte
|
||||||
|
var blockSections [lightSections]*[2048]byte
|
||||||
|
for i := 0; i < SectionCount && i+1 <= maxLightSection; i++ {
|
||||||
|
skySections[i+1] = sky[i]
|
||||||
|
blockSections[i+1] = block[i]
|
||||||
|
}
|
||||||
|
if maxLightSection == lightSections-1 {
|
||||||
|
skySections[maxLightSection] = new([2048]byte)
|
||||||
|
for i := range skySections[maxLightSection] {
|
||||||
|
skySections[maxLightSection][i] = 0xff
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var skyMask, blockMask, emptySkyMask, emptyBlockMask uint64
|
||||||
|
for i := 0; i <= maxLightSection; i++ {
|
||||||
|
if skySections[i] == nil {
|
||||||
|
emptySkyMask |= 1 << i
|
||||||
|
} else {
|
||||||
|
skyMask |= 1 << i
|
||||||
|
}
|
||||||
|
if blockSections[i] == nil {
|
||||||
|
emptyBlockMask |= 1 << i
|
||||||
|
} else {
|
||||||
|
blockMask |= 1 << i
|
||||||
|
}
|
||||||
|
}
|
||||||
writeBitSet(w, []uint64{skyMask})
|
writeBitSet(w, []uint64{skyMask})
|
||||||
writeBitSet(w, []uint64{blockMask})
|
writeBitSet(w, []uint64{blockMask})
|
||||||
writeBitSet(w, []uint64{emptySkyMask})
|
writeBitSet(w, []uint64{emptySkyMask})
|
||||||
writeBitSet(w, []uint64{emptyBlockMask})
|
writeBitSet(w, []uint64{emptyBlockMask})
|
||||||
|
writeLightArrays(w, skySections[:])
|
||||||
|
writeLightArrays(w, blockSections[:])
|
||||||
|
}
|
||||||
|
|
||||||
w.VarInt(int32(skyCount))
|
func writeLightArrays(w *protocol.Writer, sections []*[2048]byte) {
|
||||||
for i := 0; i < lightSections; i++ {
|
count := 0
|
||||||
if skyLight[i] != nil {
|
for _, section := range sections {
|
||||||
w.VarInt(2048)
|
if section != nil {
|
||||||
w.Raw(skyLight[i][:])
|
count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
w.VarInt(int32(count))
|
||||||
w.VarInt(int32(blockCount))
|
for _, section := range sections {
|
||||||
for i := 0; i < lightSections; i++ {
|
if section != nil {
|
||||||
if blockLight[i] != nil {
|
|
||||||
w.VarInt(2048)
|
w.VarInt(2048)
|
||||||
w.Raw(blockLight[i][:])
|
w.Raw(section[:])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// allSectionsMask returns a bitset (as longs) with the low lightSections bits set.
|
// EncodeLightUpdate serializes a standalone light_update packet body from a
|
||||||
|
// consistent chunk snapshot.
|
||||||
|
func (c *Chunk) EncodeLightUpdate() []byte {
|
||||||
|
snapshot, _ := c.snapshot()
|
||||||
|
w := protocol.NewWriter(8192)
|
||||||
|
w.VarInt(snapshot.X)
|
||||||
|
w.VarInt(snapshot.Z)
|
||||||
|
sky, block := snapshot.skyLight, snapshot.blockLight
|
||||||
|
if !snapshot.lightReady {
|
||||||
|
chunks := map[[2]int32]*Chunk{{snapshot.X, snapshot.Z}: snapshot}
|
||||||
|
volume := newLightVolume(int(snapshot.X), int(snapshot.Z), 1, 1, chunks)
|
||||||
|
volume.calculate()
|
||||||
|
standalone := &Chunk{X: snapshot.X, Z: snapshot.Z}
|
||||||
|
standalone.installLight(volume)
|
||||||
|
sky, block = standalone.skyLight, standalone.blockLight
|
||||||
|
}
|
||||||
|
writeLightLayers(w, sky, block, lightSections-1)
|
||||||
|
return w.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
func allSectionsMask() []uint64 {
|
func allSectionsMask() []uint64 {
|
||||||
return []uint64{(uint64(1) << lightSections) - 1}
|
return []uint64{(uint64(1) << lightSections) - 1}
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeBitSet emits a length-prefixed array of longs.
|
|
||||||
func writeBitSet(w *protocol.Writer, longs []uint64) {
|
func writeBitSet(w *protocol.Writer, longs []uint64) {
|
||||||
|
for len(longs) > 0 && longs[len(longs)-1] == 0 {
|
||||||
|
longs = longs[:len(longs)-1]
|
||||||
|
}
|
||||||
w.VarInt(int32(len(longs)))
|
w.VarInt(int32(len(longs)))
|
||||||
for _, v := range longs {
|
for _, value := range longs {
|
||||||
w.Int64(int64(v))
|
w.Int64(int64(value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
BIN
internal/world/light_properties.bin
Normal file
BIN
internal/world/light_properties.bin
Normal file
Binary file not shown.
113
internal/world/light_properties.go
Normal file
113
internal/world/light_properties.go
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
package world
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lightPropertiesBinary is generated by tools/VanillaLightDump.java directly
|
||||||
|
// from the 26.1.2 runtime block-state registry.
|
||||||
|
//
|
||||||
|
//go:embed light_properties.bin
|
||||||
|
var lightPropertiesBinary []byte
|
||||||
|
|
||||||
|
var (
|
||||||
|
blockOpacity [totalBlockStates]byte
|
||||||
|
blockEmission [totalBlockStates]byte
|
||||||
|
blockLightFlags [totalBlockStates]byte
|
||||||
|
blockLightShape [totalBlockStates]uint16
|
||||||
|
lightFaceShapes [][lightShapeBytes]byte
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
lightPropertiesMagic = 0x52494f4c // RIOL
|
||||||
|
lightPropertiesVersion = 1
|
||||||
|
lightShapeBytes = 6 * 32 // six 16x16 face masks
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if err := decodeLightProperties(lightPropertiesBinary); err != nil {
|
||||||
|
panic(fmt.Sprintf("world: decode vanilla light properties: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeLightProperties(data []byte) error {
|
||||||
|
if len(data) < 16 {
|
||||||
|
return fmt.Errorf("header is truncated")
|
||||||
|
}
|
||||||
|
if binary.BigEndian.Uint32(data[0:4]) != lightPropertiesMagic {
|
||||||
|
return fmt.Errorf("invalid magic")
|
||||||
|
}
|
||||||
|
if version := binary.BigEndian.Uint32(data[4:8]); version != lightPropertiesVersion {
|
||||||
|
return fmt.Errorf("unsupported version %d", version)
|
||||||
|
}
|
||||||
|
states := int(binary.BigEndian.Uint32(data[8:12]))
|
||||||
|
shapes := int(binary.BigEndian.Uint32(data[12:16]))
|
||||||
|
if states != totalBlockStates {
|
||||||
|
return fmt.Errorf("state count %d, want %d", states, totalBlockStates)
|
||||||
|
}
|
||||||
|
want := 16 + states*5 + shapes*lightShapeBytes
|
||||||
|
if len(data) != want {
|
||||||
|
return fmt.Errorf("length %d, want %d", len(data), want)
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := 16
|
||||||
|
for id := 0; id < states; id++ {
|
||||||
|
blockOpacity[id] = data[offset]
|
||||||
|
blockEmission[id] = data[offset+1]
|
||||||
|
blockLightFlags[id] = data[offset+2]
|
||||||
|
blockLightShape[id] = binary.BigEndian.Uint16(data[offset+3 : offset+5])
|
||||||
|
if int(blockLightShape[id]) >= shapes {
|
||||||
|
return fmt.Errorf("state %d references shape %d of %d", id, blockLightShape[id], shapes)
|
||||||
|
}
|
||||||
|
offset += 5
|
||||||
|
}
|
||||||
|
lightFaceShapes = make([][lightShapeBytes]byte, shapes)
|
||||||
|
for i := range lightFaceShapes {
|
||||||
|
copy(lightFaceShapes[i][:], data[offset:offset+lightShapeBytes])
|
||||||
|
offset += lightShapeBytes
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lightOpacity(state uint16) byte {
|
||||||
|
if int(state) >= len(blockOpacity) {
|
||||||
|
return 15
|
||||||
|
}
|
||||||
|
return blockOpacity[state]
|
||||||
|
}
|
||||||
|
|
||||||
|
func lightEmission(state uint16) byte {
|
||||||
|
if int(state) >= len(blockEmission) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return blockEmission[state]
|
||||||
|
}
|
||||||
|
|
||||||
|
// lightShapeOccludes mirrors Shapes.faceShapeOccludes for the 1/16-resolution
|
||||||
|
// face masks emitted from vanilla's VoxelShape data.
|
||||||
|
func lightShapeOccludes(from, into uint16, direction int) bool {
|
||||||
|
if direction < 0 || direction >= 6 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var fromShape, intoShape [lightShapeBytes]byte
|
||||||
|
// Vanilla substitutes an empty shape unless both flags are true. Ordinary
|
||||||
|
// full cubes are handled by dampening; only shape-aware blocks (slabs,
|
||||||
|
// stairs, etc.) participate in face occlusion.
|
||||||
|
if int(from) < len(blockLightFlags) && blockLightFlags[from]&6 == 6 {
|
||||||
|
fromShape = lightFaceShapes[blockLightShape[from]]
|
||||||
|
}
|
||||||
|
if int(into) < len(blockLightFlags) && blockLightFlags[into]&6 == 6 {
|
||||||
|
intoShape = lightFaceShapes[blockLightShape[into]]
|
||||||
|
}
|
||||||
|
opposite := [...]int{1, 0, 3, 2, 5, 4}
|
||||||
|
fromOffset := direction * 32
|
||||||
|
intoOffset := opposite[direction] * 32
|
||||||
|
for i := 0; i < 32; i++ {
|
||||||
|
if fromShape[fromOffset+i]|intoShape[intoOffset+i] != 0xff {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
207
internal/world/light_test.go
Normal file
207
internal/world/light_test.go
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
package world
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"regionio/internal/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Captured from vanilla 26.1.2 after placing minecraft:glowstone at
|
||||||
|
// (15,100,8). Layout is YZX over [0..30]x[85..115]x[-7..23].
|
||||||
|
//
|
||||||
|
//go:embed testdata/vanilla_glowstone_block_light.bin
|
||||||
|
var vanillaGlowstoneBlockLight []byte
|
||||||
|
|
||||||
|
func TestIncrementalBlockLightAgainstVanillaFixture(t *testing.T) {
|
||||||
|
const minX, minY, minZ, size = 0, 85, -7, 31
|
||||||
|
if len(vanillaGlowstoneBlockLight) != size*size*size {
|
||||||
|
t.Fatalf("vanilla fixture length = %d, want %d", len(vanillaGlowstoneBlockLight), size*size*size)
|
||||||
|
}
|
||||||
|
cache := NewCache(-1, func(cx, cz int32) *Chunk {
|
||||||
|
return NewChunk(cx, cz, BiomePlains)
|
||||||
|
})
|
||||||
|
for cz := int32(-1); cz <= 1; cz++ {
|
||||||
|
for cx := int32(-1); cx <= 1; cx++ {
|
||||||
|
cache.chunkAt(cx, cz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
glowstone := nameToStateID("minecraft:glowstone", nil)
|
||||||
|
if valid, _ := cache.SetBlockWithLight(15, 100, 8, glowstone); !valid {
|
||||||
|
t.Fatal("glowstone edit rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
fixtureIndex := 0
|
||||||
|
mismatches := 0
|
||||||
|
for y := minY; y < minY+size; y++ {
|
||||||
|
for z := minZ; z < minZ+size; z++ {
|
||||||
|
for x := minX; x < minX+size; x++ {
|
||||||
|
chunk := cache.chunkAt(int32(x>>4), int32(z>>4))
|
||||||
|
_, got, ready := chunk.LightAt(x, y, z)
|
||||||
|
want := vanillaGlowstoneBlockLight[fixtureIndex]
|
||||||
|
fixtureIndex++
|
||||||
|
if !ready || got != want {
|
||||||
|
if mismatches < 10 {
|
||||||
|
t.Errorf("block light (%d,%d,%d) = %d, ready=%v; vanilla=%d", x, y, z, got, ready, want)
|
||||||
|
}
|
||||||
|
mismatches++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mismatches > 10 {
|
||||||
|
t.Errorf("... and %d additional light mismatches", mismatches-10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIncrementalBlockLightCrossesChunkBoundaryAndClears(t *testing.T) {
|
||||||
|
cache := NewCache(-1, func(cx, cz int32) *Chunk {
|
||||||
|
return NewChunk(cx, cz, BiomePlains)
|
||||||
|
})
|
||||||
|
left := cache.chunkAt(0, 0)
|
||||||
|
right := cache.chunkAt(1, 0)
|
||||||
|
if _, err := cache.FrameErr(0, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := cache.FrameErr(1, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
glowstone := nameToStateID("minecraft:glowstone", nil)
|
||||||
|
if emission := lightEmission(glowstone); emission != 15 {
|
||||||
|
t.Fatalf("glowstone emission = %d, want 15", emission)
|
||||||
|
}
|
||||||
|
valid, changed := cache.SetBlockWithLight(15, 0, 8, glowstone)
|
||||||
|
if !valid || !containsChunkPos(changed, 0, 0) || !containsChunkPos(changed, 1, 0) {
|
||||||
|
t.Fatalf("place changed = %v, valid=%v; want chunks (0,0) and (1,0)", changed, valid)
|
||||||
|
}
|
||||||
|
assertLight(t, left, 15, 0, 8, 15)
|
||||||
|
assertLight(t, right, 0, 0, 8, 14)
|
||||||
|
assertLight(t, right, 1, 0, 8, 13)
|
||||||
|
|
||||||
|
valid, changed = cache.SetBlockWithLight(15, 0, 8, StateAir)
|
||||||
|
if !valid || !containsChunkPos(changed, 0, 0) || !containsChunkPos(changed, 1, 0) {
|
||||||
|
t.Fatalf("remove changed = %v, valid=%v; want chunks (0,0) and (1,0)", changed, valid)
|
||||||
|
}
|
||||||
|
assertLight(t, left, 15, 0, 8, 0)
|
||||||
|
assertLight(t, right, 0, 0, 8, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIncrementalSkyLightSpreadsUnderRoofAcrossChunkBoundary(t *testing.T) {
|
||||||
|
cache := NewCache(-1, func(cx, cz int32) *Chunk {
|
||||||
|
chunk := NewChunk(cx, cz, BiomePlains)
|
||||||
|
for z := 0; z < 16; z++ {
|
||||||
|
for x := 0; x < 16; x++ {
|
||||||
|
chunk.setBlockRaw(x, 1, z, StateStone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chunk
|
||||||
|
})
|
||||||
|
left := cache.chunkAt(0, 0)
|
||||||
|
right := cache.chunkAt(1, 0)
|
||||||
|
if _, err := cache.FrameErr(0, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := cache.FrameErr(1, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertSky(t, right, 0, 0, 8, 0)
|
||||||
|
valid, changed := cache.SetBlockWithLight(15, 1, 8, StateAir)
|
||||||
|
if !valid || !containsChunkPos(changed, 0, 0) || !containsChunkPos(changed, 1, 0) {
|
||||||
|
t.Fatalf("open changed = %v, valid=%v; want chunks (0,0) and (1,0)", changed, valid)
|
||||||
|
}
|
||||||
|
assertSky(t, left, 15, 0, 8, 15)
|
||||||
|
assertSky(t, right, 0, 0, 8, 14)
|
||||||
|
assertSky(t, right, 1, 0, 8, 13)
|
||||||
|
|
||||||
|
valid, changed = cache.SetBlockWithLight(15, 1, 8, StateStone)
|
||||||
|
if !valid || !containsChunkPos(changed, 0, 0) || !containsChunkPos(changed, 1, 0) {
|
||||||
|
t.Fatalf("close changed = %v, valid=%v; want chunks (0,0) and (1,0)", changed, valid)
|
||||||
|
}
|
||||||
|
assertSky(t, left, 15, 0, 8, 0)
|
||||||
|
assertSky(t, right, 0, 0, 8, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertLight(t *testing.T, chunk *Chunk, x, y, z int, want byte) {
|
||||||
|
t.Helper()
|
||||||
|
_, got, ready := chunk.LightAt(x, y, z)
|
||||||
|
if !ready || got != want {
|
||||||
|
t.Fatalf("block light (%d,%d,%d) = %d, ready=%v; want %d", x, y, z, got, ready, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertSky(t *testing.T, chunk *Chunk, x, y, z int, want byte) {
|
||||||
|
t.Helper()
|
||||||
|
got, _, ready := chunk.LightAt(x, y, z)
|
||||||
|
if !ready || got != want {
|
||||||
|
t.Fatalf("sky light (%d,%d,%d) = %d, ready=%v; want %d", x, y, z, got, ready, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsChunkPos(chunks []ChunkPos, x, z int32) bool {
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
if chunk.X == x && chunk.Z == z {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeLightUpdateLayout(t *testing.T) {
|
||||||
|
chunk := NewChunk(-2, 3, BiomePlains)
|
||||||
|
r := protocol.NewReader(chunk.EncodeLightUpdate())
|
||||||
|
|
||||||
|
if x, err := r.VarInt(); err != nil || x != -2 {
|
||||||
|
t.Fatalf("chunk x = %d, %v; want -2", x, err)
|
||||||
|
}
|
||||||
|
if z, err := r.VarInt(); err != nil || z != 3 {
|
||||||
|
t.Fatalf("chunk z = %d, %v; want 3", z, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
masks := make([]uint64, 4)
|
||||||
|
for i := range masks {
|
||||||
|
length, err := r.VarInt()
|
||||||
|
if err != nil || length < 0 || length > 1 {
|
||||||
|
t.Fatalf("mask[%d] length = %d, %v; want 0 or 1", i, length, err)
|
||||||
|
}
|
||||||
|
if length == 1 {
|
||||||
|
value, err := r.Int64()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mask[%d]: %v", i, err)
|
||||||
|
}
|
||||||
|
masks[i] = uint64(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if masks[0] == 0 || masks[2] == 0 {
|
||||||
|
t.Fatalf("sky masks were not populated: data=%#x empty=%#x", masks[0], masks[2])
|
||||||
|
}
|
||||||
|
if masks[1] != 0 || masks[3] != (uint64(1)<<lightSections)-1 {
|
||||||
|
t.Fatalf("block masks = data %#x, empty %#x", masks[1], masks[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
consumeLightArrays(t, r)
|
||||||
|
consumeLightArrays(t, r)
|
||||||
|
if r.Remaining() != 0 {
|
||||||
|
t.Fatalf("light update trailing bytes = %d", r.Remaining())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func consumeLightArrays(t *testing.T, r *protocol.Reader) {
|
||||||
|
t.Helper()
|
||||||
|
count, err := r.VarInt()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := int32(0); i < count; i++ {
|
||||||
|
length, err := r.VarInt()
|
||||||
|
if err != nil || length != 2048 {
|
||||||
|
t.Fatalf("light array[%d] length = %d, %v; want 2048", i, length, err)
|
||||||
|
}
|
||||||
|
for j := int32(0); j < length; j++ {
|
||||||
|
if _, err := r.ReadByte(); err != nil {
|
||||||
|
t.Fatalf("light array[%d] byte %d: %v", i, j, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -86,6 +86,13 @@ func locationIndex(localX, localZ int) int { return (localZ << 5) | localX }
|
||||||
// ReadChunk returns the decompressed NBT payload for the chunk, or
|
// ReadChunk returns the decompressed NBT payload for the chunk, or
|
||||||
// ErrChunkNotFound when the chunk is absent.
|
// ErrChunkNotFound when the chunk is absent.
|
||||||
func (r *RegionFile) ReadChunk(localX, localZ int) ([]byte, error) {
|
func (r *RegionFile) ReadChunk(localX, localZ int) ([]byte, error) {
|
||||||
|
if localX < 0 || localX > 31 || localZ < 0 || localZ > 31 {
|
||||||
|
return nil, fmt.Errorf("world: local coordinates out of bounds")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
loc := r.offsets[locationIndex(localX, localZ)]
|
loc := r.offsets[locationIndex(localX, localZ)]
|
||||||
if loc == 0 {
|
if loc == 0 {
|
||||||
return nil, ErrChunkNotFound
|
return nil, ErrChunkNotFound
|
||||||
|
|
@ -96,9 +103,6 @@ func (r *RegionFile) ReadChunk(localX, localZ int) ([]byte, error) {
|
||||||
return nil, fmt.Errorf("world: invalid sector offset %d", sectorOffset)
|
return nil, fmt.Errorf("world: invalid sector offset %d", sectorOffset)
|
||||||
}
|
}
|
||||||
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
|
|
||||||
// 4-byte length then payload (compression byte + compressed data).
|
// 4-byte length then payload (compression byte + compressed data).
|
||||||
var lenBuf [4]byte
|
var lenBuf [4]byte
|
||||||
if _, err := r.f.ReadAt(lenBuf[:], int64(sectorOffset)*sectorSize); err != nil {
|
if _, err := r.f.ReadAt(lenBuf[:], int64(sectorOffset)*sectorSize); err != nil {
|
||||||
|
|
@ -124,7 +128,14 @@ func (r *RegionFile) ReadChunk(localX, localZ int) ([]byte, error) {
|
||||||
// WriteChunk stores the NBT payload for the chunk, allocating (or reusing)
|
// WriteChunk stores the NBT payload for the chunk, allocating (or reusing)
|
||||||
// sectors and updating the offset + timestamp tables.
|
// sectors and updating the offset + timestamp tables.
|
||||||
func (r *RegionFile) WriteChunk(localX, localZ int, nbt []byte) error {
|
func (r *RegionFile) WriteChunk(localX, localZ int, nbt []byte) error {
|
||||||
compressed := append([]byte{compressionZlib}, zlibDeflate(nbt)...)
|
if localX < 0 || localX > 31 || localZ < 0 || localZ > 31 {
|
||||||
|
return fmt.Errorf("world: local coordinates out of bounds")
|
||||||
|
}
|
||||||
|
deflated, err := zlibDeflate(nbt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
compressed := append([]byte{compressionZlib}, deflated...)
|
||||||
// +4 for the length prefix; sectors needed to hold everything.
|
// +4 for the length prefix; sectors needed to hold everything.
|
||||||
totalLen := 4 + len(compressed)
|
totalLen := 4 + len(compressed)
|
||||||
sectorsNeeded := (totalLen + sectorSize - 1) / sectorSize
|
sectorsNeeded := (totalLen + sectorSize - 1) / sectorSize
|
||||||
|
|
@ -229,4 +240,4 @@ var _ = io.EOF
|
||||||
|
|
||||||
// (zlib helpers live in compress.go to keep this file format-focused; the
|
// (zlib helpers live in compress.go to keep this file format-focused; the
|
||||||
// references below are satisfied there.)
|
// references below are satisfied there.)
|
||||||
var _ = bytes.Equal
|
var _ = bytes.Equal
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,9 @@ type stateName struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
stateByIDOnce sync.Once
|
stateByIDOnce sync.Once
|
||||||
stateByIDImpl map[uint16]stateName
|
stateByIDImpl map[uint16]stateName
|
||||||
|
idsByName map[string][]uint16
|
||||||
)
|
)
|
||||||
|
|
||||||
// stateByID returns the named form of a block-state ID, building the lookup
|
// stateByID returns the named form of a block-state ID, building the lookup
|
||||||
|
|
@ -49,12 +50,15 @@ func buildStateTable() {
|
||||||
panic("world: parsing embedded blocks.json: " + err.Error())
|
panic("world: parsing embedded blocks.json: " + err.Error())
|
||||||
}
|
}
|
||||||
stateByIDImpl = make(map[uint16]stateName, 30000)
|
stateByIDImpl = make(map[uint16]stateName, 30000)
|
||||||
|
idsByName = make(map[string][]uint16, len(blocks))
|
||||||
for name, b := range blocks {
|
for name, b := range blocks {
|
||||||
for _, s := range b.States {
|
for _, s := range b.States {
|
||||||
if s.ID < 0 || s.ID > 65535 {
|
if s.ID < 0 || s.ID > 65535 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
stateByIDImpl[uint16(s.ID)] = stateName{Name: name, Properties: s.Properties}
|
id := uint16(s.ID)
|
||||||
|
stateByIDImpl[id] = stateName{Name: name, Properties: s.Properties}
|
||||||
|
idsByName[name] = append(idsByName[name], id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -89,19 +93,14 @@ type paletteEntryKey struct {
|
||||||
// Chunk. Unknown names/properties map to air (0).
|
// Chunk. Unknown names/properties map to air (0).
|
||||||
func nameToStateID(name string, props map[string]string) uint16 {
|
func nameToStateID(name string, props map[string]string) uint16 {
|
||||||
stateByIDOnce.Do(buildStateTable)
|
stateByIDOnce.Do(buildStateTable)
|
||||||
for id, s := range stateByIDImpl {
|
ids := idsByName[name]
|
||||||
if s.Name != name {
|
for _, id := range ids {
|
||||||
continue
|
if propsMatch(stateByIDImpl[id].Properties, props) {
|
||||||
}
|
|
||||||
if propsMatch(s.Properties, props) {
|
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fall back to any state of that block if properties don't match exactly.
|
if len(ids) > 0 {
|
||||||
for id, s := range stateByIDImpl {
|
return ids[0]
|
||||||
if s.Name == name {
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return StateAir
|
return StateAir
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package world
|
package world
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -62,11 +63,82 @@ type Store struct {
|
||||||
regions map[[2]int]*RegionFile
|
regions map[[2]int]*RegionFile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const worldMetadataFile = "regionio-world.json"
|
||||||
|
|
||||||
|
type worldMetadata struct {
|
||||||
|
Format int `json:"format"`
|
||||||
|
Seed int64 `json:"seed"`
|
||||||
|
}
|
||||||
|
|
||||||
// NewStore opens (or creates) the world directory at dir, ensuring region/
|
// NewStore opens (or creates) the world directory at dir, ensuring region/
|
||||||
// exists. Chunks are loaded/saved relative to dir/region.
|
// exists. Chunks are loaded/saved relative to dir/region.
|
||||||
func NewStore(dir string) (*Store, error) {
|
func NewStore(dir string) (*Store, error) {
|
||||||
|
return newStore(dir, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStoreForSeed opens a persistent world and records its generation seed.
|
||||||
|
// Reopening the same directory with another seed is rejected to prevent seams
|
||||||
|
// between previously stored chunks and newly generated terrain.
|
||||||
|
func NewStoreForSeed(dir string, seed int64) (*Store, error) {
|
||||||
|
return newStore(dir, &seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStore(dir string, seed *int64) (*Store, error) {
|
||||||
regionDir := filepath.Join(dir, "region")
|
regionDir := filepath.Join(dir, "region")
|
||||||
return &Store{dir: dir, regions: make(map[[2]int]*RegionFile)}, mkdirAll(regionDir)
|
if err := mkdirAll(regionDir); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if seed != nil {
|
||||||
|
if err := validateWorldMetadata(dir, *seed); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &Store{dir: dir, regions: make(map[[2]int]*RegionFile)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateWorldMetadata(dir string, seed int64) 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)
|
||||||
|
}
|
||||||
|
if meta.Format != 1 {
|
||||||
|
return 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 nil
|
||||||
|
}
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err = json.MarshalIndent(worldMetadata{Format: 1, Seed: seed}, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw = append(raw, '\n')
|
||||||
|
tmp, err := os.CreateTemp(dir, ".regionio-world-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
defer os.Remove(tmpName)
|
||||||
|
if _, err := tmp.Write(raw); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Sync(); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmpName, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// regionFor returns the cached RegionFile for the chunk's region, opening it on
|
// regionFor returns the cached RegionFile for the chunk's region, opening it on
|
||||||
|
|
@ -122,6 +194,12 @@ func (s *Store) LoadChunk(cx, cz int32) (*Chunk, error) {
|
||||||
|
|
||||||
// SaveChunk encodes the chunk and writes it to its region file.
|
// SaveChunk encodes the chunk and writes it to its region file.
|
||||||
func (s *Store) SaveChunk(c *Chunk) error {
|
func (s *Store) SaveChunk(c *Chunk) error {
|
||||||
|
snapshot, _ := c.snapshot()
|
||||||
|
return s.saveSnapshot(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveSnapshot writes a detached chunk snapshot without copying it again.
|
||||||
|
func (s *Store) saveSnapshot(c *Chunk) error {
|
||||||
rf, err := s.regionFor(c.X, c.Z)
|
rf, err := s.regionFor(c.X, c.Z)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -156,6 +234,9 @@ func chunkToNBT(c *Chunk) *nbt.Compound {
|
||||||
Set("Status", nbt.String("minecraft:full")).
|
Set("Status", nbt.String("minecraft:full")).
|
||||||
Set("LastUpdate", nbt.Long(0)).
|
Set("LastUpdate", nbt.Long(0)).
|
||||||
Set("InhabitedTime", nbt.Long(0))
|
Set("InhabitedTime", nbt.Long(0))
|
||||||
|
if c.lightReady {
|
||||||
|
level.Set("isLightOn", nbt.Byte(1))
|
||||||
|
}
|
||||||
|
|
||||||
// Sections: one compound per vertical section, including empty ones so the
|
// Sections: one compound per vertical section, including empty ones so the
|
||||||
// section Y range is contiguous (vanilla expects all sections present for
|
// section Y range is contiguous (vanilla expects all sections present for
|
||||||
|
|
@ -238,6 +319,14 @@ func sectionToNBT(c *Chunk, si int) *nbt.Compound {
|
||||||
biomes.Set("data", packIndices(c.biomes[si][:], biomeIndexOf))
|
biomes.Set("data", packIndices(c.biomes[si][:], biomeIndexOf))
|
||||||
}
|
}
|
||||||
sec.Set("biomes", biomes)
|
sec.Set("biomes", biomes)
|
||||||
|
if c.lightReady {
|
||||||
|
if sky := c.skyLight[si]; sky != nil {
|
||||||
|
sec.Set("SkyLight", nbt.ByteArray(append([]byte(nil), sky[:]...)))
|
||||||
|
}
|
||||||
|
if block := c.blockLight[si]; block != nil {
|
||||||
|
sec.Set("BlockLight", nbt.ByteArray(append([]byte(nil), block[:]...)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return sec
|
return sec
|
||||||
}
|
}
|
||||||
|
|
@ -287,7 +376,7 @@ func topNonAirY(c *Chunk, x, z int) int {
|
||||||
// for the palette size, mirroring the network paletted-container packing (no
|
// for the palette size, mirroring the network paletted-container packing (no
|
||||||
// value spans a long boundary in vanilla's chunk NBT).
|
// value spans a long boundary in vanilla's chunk NBT).
|
||||||
func packIndices(ids []uint16, indexOf map[uint16]int) nbt.LongArray {
|
func packIndices(ids []uint16, indexOf map[uint16]int) nbt.LongArray {
|
||||||
bits := bitsNeeded(len(indexOf))
|
bits := bitsFor(len(indexOf))
|
||||||
if bits < 1 {
|
if bits < 1 {
|
||||||
bits = 1
|
bits = 1
|
||||||
}
|
}
|
||||||
|
|
@ -306,21 +395,10 @@ func packIndices(ids []uint16, indexOf map[uint16]int) nbt.LongArray {
|
||||||
return longs
|
return longs
|
||||||
}
|
}
|
||||||
|
|
||||||
// bitsNeeded returns ceil(log2(n)) for n>1, or 0 for n<=1.
|
|
||||||
func bitsNeeded(n int) int {
|
|
||||||
bits := 0
|
|
||||||
v := n - 1
|
|
||||||
for v > 0 {
|
|
||||||
v >>= 1
|
|
||||||
bits++
|
|
||||||
}
|
|
||||||
return bits
|
|
||||||
}
|
|
||||||
|
|
||||||
// nbtToChunk decodes the Level-nested chunk NBT back into a Chunk. The chunk's
|
// nbtToChunk decodes the Level-nested chunk NBT back into a Chunk. The chunk's
|
||||||
// absolute coordinates are derived from the on-disk xPos/zPos (authoritative);
|
// absolute coordinates are derived from the on-disk xPos/zPos (authoritative);
|
||||||
// the region/local coords passed in are used only to validate.
|
// the region/local coords passed in are used only to validate.
|
||||||
func nbtToChunk(root *nbt.Compound, regionX, regionZ, _, _ int) (*Chunk, error) {
|
func nbtToChunk(root *nbt.Compound, regionX, regionZ, localX, localZ int) (*Chunk, error) {
|
||||||
levelTag, ok := root.Get("Level")
|
levelTag, ok := root.Get("Level")
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("world: chunk NBT missing Level")
|
return nil, fmt.Errorf("world: chunk NBT missing Level")
|
||||||
|
|
@ -331,8 +409,18 @@ func nbtToChunk(root *nbt.Compound, regionX, regionZ, _, _ int) (*Chunk, error)
|
||||||
}
|
}
|
||||||
cx := int32(nbtAsInt(level, "xPos"))
|
cx := int32(nbtAsInt(level, "xPos"))
|
||||||
cz := int32(nbtAsInt(level, "zPos"))
|
cz := int32(nbtAsInt(level, "zPos"))
|
||||||
|
wantX := int32(regionX*32 + localX)
|
||||||
|
wantZ := int32(regionZ*32 + localZ)
|
||||||
|
if cx != wantX || cz != wantZ {
|
||||||
|
return nil, fmt.Errorf("world: chunk coordinates (%d,%d) do not match region slot (%d,%d)", cx, cz, wantX, wantZ)
|
||||||
|
}
|
||||||
|
|
||||||
c := &Chunk{X: cx, Z: cz, biome: BiomePlains}
|
c := &Chunk{X: cx, Z: cz, biome: BiomePlains}
|
||||||
|
if lightTag, ok := level.Get("isLightOn"); ok {
|
||||||
|
if enabled, ok := lightTag.(nbt.Byte); ok && enabled != 0 {
|
||||||
|
c.lightReady = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sections.
|
// Sections.
|
||||||
if secTag, ok := level.Get("sections"); ok {
|
if secTag, ok := level.Get("sections"); ok {
|
||||||
|
|
@ -349,12 +437,32 @@ func nbtToChunk(root *nbt.Compound, regionX, regionZ, _, _ int) (*Chunk, error)
|
||||||
}
|
}
|
||||||
readBlockStates(c, si, sc)
|
readBlockStates(c, si, sc)
|
||||||
readBiomes(c, si, sc)
|
readBiomes(c, si, sc)
|
||||||
|
readLightSection(c, si, sc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readLightSection(c *Chunk, si int, sc *nbt.Compound) {
|
||||||
|
read := func(name string) *[2048]byte {
|
||||||
|
tag, ok := sc.Get(name)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, ok := tag.(nbt.ByteArray)
|
||||||
|
if !ok || len(data) != 2048 {
|
||||||
|
c.lightReady = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := new([2048]byte)
|
||||||
|
copy(out[:], data)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
c.skyLight[si] = read("SkyLight")
|
||||||
|
c.blockLight[si] = read("BlockLight")
|
||||||
|
}
|
||||||
|
|
||||||
// readBlockStates decodes a section's block_states {palette, data?} into the
|
// readBlockStates decodes a section's block_states {palette, data?} into the
|
||||||
// chunk's section array. A palette of size 1 fills the whole section; otherwise
|
// chunk's section array. A palette of size 1 fills the whole section; otherwise
|
||||||
// the packed data array is unpacked.
|
// the packed data array is unpacked.
|
||||||
|
|
@ -427,9 +535,11 @@ func readBiomes(c *Chunk, si int, sc *nbt.Compound) {
|
||||||
ids[i] = biomeIDByName(string(e.(nbt.String)))
|
ids[i] = biomeIDByName(string(e.(nbt.String)))
|
||||||
}
|
}
|
||||||
if len(ids) == 1 {
|
if len(ids) == 1 {
|
||||||
// Uniform biome for the section: keep the per-cell array nil and set the
|
cells := new([biomeCellsPerSection]uint16)
|
||||||
// column fallback when this is the only biome source.
|
for i := range cells {
|
||||||
c.biome = ids[0]
|
cells[i] = ids[0]
|
||||||
|
}
|
||||||
|
c.biomes[si] = cells
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if dataTag, ok := bc.Get("data"); ok {
|
if dataTag, ok := bc.Get("data"); ok {
|
||||||
|
|
@ -481,7 +591,7 @@ func nbtAsString(c *nbt.Compound, name string) nbt.String {
|
||||||
// unpackIndices reverses packIndices: fills dst with palette IDs using the
|
// unpackIndices reverses packIndices: fills dst with palette IDs using the
|
||||||
// packed long array.
|
// packed long array.
|
||||||
func unpackIndices(dst []uint16, ids []uint16, data nbt.LongArray) {
|
func unpackIndices(dst []uint16, ids []uint16, data nbt.LongArray) {
|
||||||
bits := bitsNeeded(len(ids))
|
bits := bitsFor(len(ids))
|
||||||
if bits < 1 {
|
if bits < 1 {
|
||||||
bits = 1
|
bits = 1
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
package world
|
package world
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -100,7 +103,8 @@ func TestStoreChunkRoundTrip(t *testing.T) {
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("root not compound")
|
t.Fatal("root not compound")
|
||||||
}
|
}
|
||||||
decoded, err := nbtToChunk(root, 0, 0, 0, 0)
|
rx, rz, lx, lz := regionIndex(original.X, original.Z)
|
||||||
|
decoded, err := nbtToChunk(root, rx, rz, lx, lz)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("nbtToChunk: %v", err)
|
t.Fatalf("nbtToChunk: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -117,6 +121,68 @@ func TestStoreChunkRoundTrip(t *testing.T) {
|
||||||
if decoded.X != 10 || decoded.Z != -5 {
|
if decoded.X != 10 || decoded.Z != -5 {
|
||||||
t.Errorf("coords = (%d,%d), want (10,-5)", decoded.X, decoded.Z)
|
t.Errorf("coords = (%d,%d), want (10,-5)", decoded.X, decoded.Z)
|
||||||
}
|
}
|
||||||
|
if decoded.lightReady {
|
||||||
|
t.Error("legacy chunk without isLightOn loaded as light-ready")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreLightRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := NewStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cache := NewCacheWithStore(-1, func(cx, cz int32) *Chunk {
|
||||||
|
return NewChunk(cx, cz, BiomePlains)
|
||||||
|
}, store)
|
||||||
|
left := cache.chunkAt(0, 0)
|
||||||
|
right := cache.chunkAt(1, 0)
|
||||||
|
if _, err := cache.FrameErr(0, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := cache.FrameErr(1, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
glowstone := nameToStateID("minecraft:glowstone", nil)
|
||||||
|
if valid, _ := cache.SetBlockWithLight(15, 0, 8, glowstone); !valid {
|
||||||
|
t.Fatal("glowstone edit rejected")
|
||||||
|
}
|
||||||
|
wantLeftSky, wantLeftBlock, ready := left.LightAt(15, 0, 8)
|
||||||
|
if !ready || wantLeftBlock != 15 {
|
||||||
|
t.Fatalf("pre-save source light = sky %d block %d ready %v", wantLeftSky, wantLeftBlock, ready)
|
||||||
|
}
|
||||||
|
wantRightSky, wantRightBlock, ready := right.LightAt(0, 0, 8)
|
||||||
|
if !ready || wantRightBlock != 14 {
|
||||||
|
t.Fatalf("pre-save neighbor light = sky %d block %d ready %v", wantRightSky, wantRightBlock, ready)
|
||||||
|
}
|
||||||
|
if err := cache.SaveAll(); 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()
|
||||||
|
loadedLeft, err := store.LoadChunk(0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gotSky, gotBlock, gotReady := loadedLeft.LightAt(15, 0, 8)
|
||||||
|
if !gotReady || gotSky != wantLeftSky || gotBlock != wantLeftBlock {
|
||||||
|
t.Fatalf("loaded source light = sky %d block %d ready %v; want sky %d block %d ready", gotSky, gotBlock, gotReady, wantLeftSky, wantLeftBlock)
|
||||||
|
}
|
||||||
|
loadedRight, err := store.LoadChunk(1, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gotSky, gotBlock, gotReady = loadedRight.LightAt(0, 0, 8)
|
||||||
|
if !gotReady || gotSky != wantRightSky || gotBlock != wantRightBlock {
|
||||||
|
t.Fatalf("loaded neighbor light = sky %d block %d ready %v; want sky %d block %d ready", gotSky, gotBlock, gotReady, wantRightSky, wantRightBlock)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStoreSaveLoadIntegration is the end-to-end "world survives restart" test:
|
// TestStoreSaveLoadIntegration is the end-to-end "world survives restart" test:
|
||||||
|
|
@ -207,8 +273,8 @@ func TestCacheAutosavePersistsEdits(t *testing.T) {
|
||||||
|
|
||||||
// Wait for at least one autosave cycle.
|
// Wait for at least one autosave cycle.
|
||||||
time.Sleep(250 * time.Millisecond)
|
time.Sleep(250 * time.Millisecond)
|
||||||
cancel() // stop the autosave loop so it releases the store
|
cancel() // stop the autosave loop so it releases the store
|
||||||
<-autosaveDone // wait for the goroutine to fully exit
|
<-autosaveDone // wait for the goroutine to fully exit
|
||||||
store.Close()
|
store.Close()
|
||||||
|
|
||||||
// Fresh cache over the same store should see the edit without SaveAll.
|
// Fresh cache over the same store should see the edit without SaveAll.
|
||||||
|
|
@ -223,3 +289,130 @@ func TestCacheAutosavePersistsEdits(t *testing.T) {
|
||||||
t.Errorf("autosaved block = %d, want bedrock %d", got, StateBedrock)
|
t.Errorf("autosaved block = %d, want bedrock %d", got, StateBedrock)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStoreRejectsSeedMismatch(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := NewStoreForSeed(dir, 12345)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
store, err = NewStoreForSeed(dir, 12345)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopen with matching seed: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := NewStoreForSeed(dir, 54321); err == nil {
|
||||||
|
t.Fatal("opening a world with a different seed succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheDoesNotRegenerateCorruptStoredChunk(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
regionDir := filepath.Join(dir, "region")
|
||||||
|
rf, err := OpenRegion(regionDir, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
corrupt := []byte("not an nbt document")
|
||||||
|
if err := rf.WriteChunk(0, 0, corrupt); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := rf.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := NewStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
var generated atomic.Bool
|
||||||
|
cache := NewCacheWithStore(256, func(cx, cz int32) *Chunk {
|
||||||
|
generated.Store(true)
|
||||||
|
return NewChunk(cx, cz, BiomePlains)
|
||||||
|
}, store)
|
||||||
|
if _, err := cache.FrameErr(0, 0); err == nil {
|
||||||
|
t.Fatal("FrameErr succeeded for corrupt stored chunk")
|
||||||
|
}
|
||||||
|
if generated.Load() {
|
||||||
|
t.Fatal("generator ran after a stored chunk read error")
|
||||||
|
}
|
||||||
|
if cache.SetBlock(0, SeaLevel, 0, StateBedrock) {
|
||||||
|
t.Fatal("SetBlock accepted an edit over a corrupt stored chunk")
|
||||||
|
}
|
||||||
|
if err := cache.SaveAll(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, lx, lz := regionIndex(0, 0)
|
||||||
|
rf = store.regions[[2]int{0, 0}]
|
||||||
|
raw, err := rf.ReadChunk(lx, lz)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(raw, corrupt) {
|
||||||
|
t.Fatalf("stored corrupt payload was overwritten: %q", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentAutosavePreservesLatestEdit(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := NewStore(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cache := NewCacheWithStore(256, flatGen(), store)
|
||||||
|
cache.chunkAt(0, 0)
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
state := StateStone
|
||||||
|
if i%2 == 0 {
|
||||||
|
state = StateDirt
|
||||||
|
}
|
||||||
|
cache.SetBlock(5, SeaLevel, 5, state)
|
||||||
|
}
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
_ = cache.flushDirty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
wg.Wait()
|
||||||
|
cache.SetBlock(5, SeaLevel, 5, StateBedrock)
|
||||||
|
if err := cache.SaveAll(); 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()
|
||||||
|
loaded, err := store.LoadChunk(0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := loaded.GetBlock(5, SeaLevel, 5); got != StateBedrock {
|
||||||
|
t.Fatalf("persisted final block = %d, want %d", got, StateBedrock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ func generateFromDensity(d worldgen.DensityFunction, cx, cz int32) *Chunk {
|
||||||
col := &columns[lx][lz]
|
col := &columns[lx][lz]
|
||||||
for i := 0; i < WorldHeight; i++ {
|
for i := 0; i < WorldHeight; i++ {
|
||||||
if s := col[i]; s != StateAir {
|
if s := col[i]; s != StateAir {
|
||||||
c.SetBlock(lx, MinY+i, lz, s)
|
c.setBlockRaw(lx, MinY+i, lz, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
internal/world/testdata/vanilla_glowstone_block_light.bin
vendored
Normal file
BIN
internal/world/testdata/vanilla_glowstone_block_light.bin
vendored
Normal file
Binary file not shown.
|
|
@ -15,7 +15,7 @@ import (
|
||||||
const (
|
const (
|
||||||
cellWidth = 4
|
cellWidth = 4
|
||||||
cellHeight = 8
|
cellHeight = 8
|
||||||
cellsXZ = 16 / cellWidth // 4
|
cellsXZ = 16 / cellWidth // 4
|
||||||
cellsY = WorldHeight / cellHeight // 48
|
cellsY = WorldHeight / cellHeight // 48
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -82,8 +82,8 @@ func generateVanilla(od *worldgen.OverworldDensity, seed int64, cx, cz int32) *C
|
||||||
surfaceRule, ruleErr := od.SurfaceRule()
|
surfaceRule, ruleErr := od.SurfaceRule()
|
||||||
|
|
||||||
var columns [16][16][WorldHeight]uint16
|
var columns [16][16][WorldHeight]uint16
|
||||||
var surfTop [16][16]int // top solid index, -1 if none
|
var surfTop [16][16]int // top solid index, -1 if none
|
||||||
var grass [16][16]bool // grassy land surface (tree-plantable)
|
var grass [16][16]bool // grassy land surface (tree-plantable)
|
||||||
for lx := 0; lx < 16; lx++ {
|
for lx := 0; lx < 16; lx++ {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(lx int) {
|
go func(lx int) {
|
||||||
|
|
@ -105,7 +105,7 @@ func generateVanilla(od *worldgen.OverworldDensity, seed int64, cx, cz int32) *C
|
||||||
col := &columns[lx][lz]
|
col := &columns[lx][lz]
|
||||||
for i := 0; i < WorldHeight; i++ {
|
for i := 0; i < WorldHeight; i++ {
|
||||||
if s := col[i]; s != StateAir {
|
if s := col[i]; s != StateAir {
|
||||||
c.SetBlock(lx, MinY+i, lz, s)
|
c.setBlockRaw(lx, MinY+i, lz, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
111
tools/VanillaLightDump.java
Normal file
111
tools/VanillaLightDump.java
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import java.io.BufferedOutputStream;
|
||||||
|
import java.io.DataOutputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import net.minecraft.SharedConstants;
|
||||||
|
import net.minecraft.core.Direction;
|
||||||
|
import net.minecraft.server.Bootstrap;
|
||||||
|
import net.minecraft.world.level.block.Block;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import net.minecraft.world.phys.AABB;
|
||||||
|
import net.minecraft.world.phys.shapes.VoxelShape;
|
||||||
|
|
||||||
|
// Dumps protocol-775 lighting properties directly from vanilla runtime state.
|
||||||
|
// Compile/run against the unpacked 26.1.2 server classpath and redirect stdout
|
||||||
|
// to internal/world/light_properties.bin.
|
||||||
|
public final class VanillaLightDump {
|
||||||
|
private record MaskKey(byte[] data) {
|
||||||
|
@Override public boolean equals(Object other) {
|
||||||
|
return other instanceof MaskKey key && Arrays.equals(data, key.data);
|
||||||
|
}
|
||||||
|
@Override public int hashCode() { return Arrays.hashCode(data); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
SharedConstants.tryDetectVersion();
|
||||||
|
Bootstrap.bootStrap();
|
||||||
|
|
||||||
|
int count = Block.BLOCK_STATE_REGISTRY.size();
|
||||||
|
byte[] dampening = new byte[count];
|
||||||
|
byte[] emission = new byte[count];
|
||||||
|
byte[] flags = new byte[count];
|
||||||
|
int[] shapeIndex = new int[count];
|
||||||
|
List<byte[]> shapes = new ArrayList<>();
|
||||||
|
Map<MaskKey, Integer> indices = new HashMap<>();
|
||||||
|
|
||||||
|
for (BlockState state : Block.BLOCK_STATE_REGISTRY) {
|
||||||
|
int id = Block.getId(state);
|
||||||
|
dampening[id] = (byte) state.getLightDampening();
|
||||||
|
emission[id] = (byte) state.getLightEmission();
|
||||||
|
int stateFlags = 0;
|
||||||
|
if (state.propagatesSkylightDown()) stateFlags |= 1;
|
||||||
|
if (state.canOcclude()) stateFlags |= 2;
|
||||||
|
if (state.useShapeForLightOcclusion()) stateFlags |= 4;
|
||||||
|
flags[id] = (byte) stateFlags;
|
||||||
|
|
||||||
|
byte[] masks = faceMasks(state);
|
||||||
|
MaskKey key = new MaskKey(masks);
|
||||||
|
Integer index = indices.get(key);
|
||||||
|
if (index == null) {
|
||||||
|
index = shapes.size();
|
||||||
|
indices.put(key, index);
|
||||||
|
shapes.add(masks);
|
||||||
|
}
|
||||||
|
shapeIndex[id] = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
DataOutputStream out = new DataOutputStream(new BufferedOutputStream(System.out));
|
||||||
|
out.writeInt(0x52494f4c); // RIOL
|
||||||
|
out.writeInt(1);
|
||||||
|
out.writeInt(count);
|
||||||
|
out.writeInt(shapes.size());
|
||||||
|
for (int id = 0; id < count; id++) {
|
||||||
|
out.writeByte(dampening[id]);
|
||||||
|
out.writeByte(emission[id]);
|
||||||
|
out.writeByte(flags[id]);
|
||||||
|
out.writeShort(shapeIndex[id]);
|
||||||
|
}
|
||||||
|
for (byte[] shape : shapes) out.write(shape);
|
||||||
|
out.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] faceMasks(BlockState state) {
|
||||||
|
byte[] result = new byte[Direction.values().length * 32];
|
||||||
|
for (Direction direction : Direction.values()) {
|
||||||
|
VoxelShape shape = state.getFaceOcclusionShape(direction);
|
||||||
|
List<AABB> boxes = shape.toAabbs();
|
||||||
|
int base = direction.get3DDataValue() * 32;
|
||||||
|
for (int v = 0; v < 16; v++) {
|
||||||
|
for (int u = 0; u < 16; u++) {
|
||||||
|
double du = (u + 0.5) / 16.0;
|
||||||
|
double dv = (v + 0.5) / 16.0;
|
||||||
|
if (covered(boxes, direction, du, dv)) {
|
||||||
|
int bit = v * 16 + u;
|
||||||
|
result[base + bit / 8] |= (byte) (1 << (bit % 8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean covered(List<AABB> boxes, Direction direction, double u, double v) {
|
||||||
|
for (AABB box : boxes) {
|
||||||
|
boolean inside = switch (direction.getAxis()) {
|
||||||
|
case Y -> contains(box.minX, box.maxX, u) && contains(box.minZ, box.maxZ, v);
|
||||||
|
case Z -> contains(box.minX, box.maxX, u) && contains(box.minY, box.maxY, v);
|
||||||
|
case X -> contains(box.minZ, box.maxZ, u) && contains(box.minY, box.maxY, v);
|
||||||
|
};
|
||||||
|
if (inside) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean contains(double min, double max, double value) {
|
||||||
|
return value >= min - 1.0e-7 && value <= max + 1.0e-7;
|
||||||
|
}
|
||||||
|
}
|
||||||
148
tools/vanilla_light_fixture.go
Normal file
148
tools/vanilla_light_fixture.go
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
// Command vanilla_light_fixture extracts a compact block-light parity fixture
|
||||||
|
// from a vanilla world containing glowstone at (15,100,8).
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"regionio/internal/nbt"
|
||||||
|
"regionio/internal/world"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
minX, minY, minZ = 0, 85, -7
|
||||||
|
sizeX, sizeY, sizeZ = 31, 31, 31
|
||||||
|
)
|
||||||
|
|
||||||
|
type lightChunk struct {
|
||||||
|
sections map[int][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
worldDir := flag.String("world", "", "vanilla overworld directory")
|
||||||
|
output := flag.String("output", "", "fixture output path")
|
||||||
|
flag.Parse()
|
||||||
|
if *worldDir == "" || *output == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: go run ./tools/vanilla_light_fixture.go -world <dir> -output <file>")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks := make(map[[2]int]*lightChunk)
|
||||||
|
data := make([]byte, 0, sizeX*sizeY*sizeZ)
|
||||||
|
for y := minY; y < minY+sizeY; y++ {
|
||||||
|
for z := minZ; z < minZ+sizeZ; z++ {
|
||||||
|
for x := minX; x < minX+sizeX; x++ {
|
||||||
|
key := [2]int{x >> 4, z >> 4}
|
||||||
|
chunk := chunks[key]
|
||||||
|
if chunk == nil {
|
||||||
|
var err error
|
||||||
|
chunk, err = readLightChunk(*worldDir, key[0], key[1])
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
chunks[key] = chunk
|
||||||
|
}
|
||||||
|
section := chunk.sections[y>>4]
|
||||||
|
if section == nil {
|
||||||
|
data = append(data, 0)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx := (y&15)<<8 | (z&15)<<4 | (x & 15)
|
||||||
|
value := section[idx>>1]
|
||||||
|
if idx&1 == 0 {
|
||||||
|
data = append(data, value&0x0f)
|
||||||
|
} else {
|
||||||
|
data = append(data, value>>4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(*output, data, 0o644); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLightChunk(worldDir string, cx, cz int) (*lightChunk, error) {
|
||||||
|
rx, rz := floorDiv(cx, 32), floorDiv(cz, 32)
|
||||||
|
regionDir := filepath.Join(worldDir, "dimensions", "minecraft", "overworld", "region")
|
||||||
|
if _, err := os.Stat(regionDir); err != nil {
|
||||||
|
regionDir = filepath.Join(worldDir, "region")
|
||||||
|
}
|
||||||
|
region, err := world.OpenRegion(regionDir, rx, rz)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer region.Close()
|
||||||
|
raw, err := region.ReadChunk(cx-rx*32, cz-rz*32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("chunk (%d,%d): %w", cx, cz, err)
|
||||||
|
}
|
||||||
|
_, tag, err := nbt.UnmarshalNamed(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
root, ok := tag.(*nbt.Compound)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("chunk (%d,%d): root is not a compound", cx, cz)
|
||||||
|
}
|
||||||
|
if level, ok := root.Get("Level"); ok {
|
||||||
|
root, _ = level.(*nbt.Compound)
|
||||||
|
}
|
||||||
|
sectionsTag, ok := root.Get("sections")
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("chunk (%d,%d): missing sections", cx, cz)
|
||||||
|
}
|
||||||
|
sections, ok := sectionsTag.(nbt.List)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("chunk (%d,%d): sections is not a list", cx, cz)
|
||||||
|
}
|
||||||
|
out := &lightChunk{sections: make(map[int][]byte)}
|
||||||
|
for _, sectionTag := range sections.Elems {
|
||||||
|
section, ok := sectionTag.(*nbt.Compound)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
yTag, ok := section.Get("Y")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
y, ok := integerTag(yTag)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lightTag, ok := section.Get("BlockLight")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
light, ok := lightTag.(nbt.ByteArray)
|
||||||
|
if !ok || len(light) != 2048 {
|
||||||
|
return nil, fmt.Errorf("chunk (%d,%d) section %d: invalid BlockLight", cx, cz, y)
|
||||||
|
}
|
||||||
|
out.sections[y] = append([]byte(nil), light...)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func integerTag(tag nbt.Tag) (int, bool) {
|
||||||
|
switch value := tag.(type) {
|
||||||
|
case nbt.Byte:
|
||||||
|
return int(value), true
|
||||||
|
case nbt.Short:
|
||||||
|
return int(value), true
|
||||||
|
case nbt.Int:
|
||||||
|
return int(value), true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func floorDiv(value, divisor int) int {
|
||||||
|
quotient := value / divisor
|
||||||
|
if value < 0 && value%divisor != 0 {
|
||||||
|
quotient--
|
||||||
|
}
|
||||||
|
return quotient
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue