diff --git a/anticheat/integration/dragonfly/conn.go b/anticheat/integration/dragonfly/conn.go index 1197e8ee..87fe9599 100644 --- a/anticheat/integration/dragonfly/conn.go +++ b/anticheat/integration/dragonfly/conn.go @@ -7,6 +7,7 @@ import ( "sync" "github.com/df-mc/dragonfly/server/session" + "github.com/oomph-ac/oomph/anticheat/oconfig" "github.com/oomph-ac/oomph/anticheat/player" playercontext "github.com/oomph-ac/oomph/anticheat/player/context" "github.com/sandertv/gophertunnel/minecraft" @@ -110,6 +111,9 @@ func (*sessionConn) ClientCacheEnabled() bool { return false } func (c *sessionConn) StartGameContext(ctx context.Context, data minecraft.GameData) error { data.PlayerMovementSettings.RewindHistorySize = 100 c.gameData = data + if oconfig.ChunkObfuscator().Enabled { + data.WorldSeed = 0 + } if c.player != nil { c.player.SetServerConn(&embeddedServerConn{conn: c}) } diff --git a/anticheat/oconfig/chunk_obfuscator.go b/anticheat/oconfig/chunk_obfuscator.go new file mode 100644 index 00000000..4f7de55b --- /dev/null +++ b/anticheat/oconfig/chunk_obfuscator.go @@ -0,0 +1,34 @@ +package oconfig + +type ObfuscationMode string + +const ( + ObfuscationModeHide ObfuscationMode = "hide" + ObfuscationModeRandom ObfuscationMode = "random" + ObfuscationModeLayered ObfuscationMode = "layered" +) + +type ChunkObfuscatorOpts struct { + Enabled bool `json:"enabled" comment:"Whether Oomph should obfuscate underground blocks before sending chunks to players."` + BlockRadius int `json:"block_radius" comment:"The radius around a block update where hidden blocks are shown."` + Dimensions ChunkObfuscatorDimensionsOpts `json:"dimensions"` +} + +type ChunkObfuscatorDimensionsOpts struct { + Overworld ChunkObfuscatorDimensionOpts `json:"overworld"` + Nether ChunkObfuscatorDimensionOpts `json:"nether"` +} + +type ChunkObfuscatorDimensionOpts struct { + Enabled bool `json:"enabled" comment:"Whether chunk obfuscation is enabled in this dimension."` + Mode ObfuscationMode `json:"mode" comment:"The obfuscation mode. Allowed values are hide, random, and layered."` + MaxY int `json:"max_y" comment:"The highest block Y coordinate that Oomph obfuscates."` + HiddenBlocks []string `json:"hidden_blocks" comment:"Blocks hidden by hide mode and used as decoys by random and layered modes."` + TerrainBlocks []string `json:"terrain_blocks" comment:"Solid terrain blocks eligible for random and layered obfuscation."` + ReplacementBlock string `json:"replacement_block" comment:"The block used by hide mode at Y 0 and above."` + DeepReplacementBlock string `json:"deep_replacement_block" comment:"The block used by hide mode below Y 0."` +} + +func ChunkObfuscator() ChunkObfuscatorOpts { + return Global.ChunkObfuscator +} diff --git a/anticheat/oconfig/config.go b/anticheat/oconfig/config.go index 31a5ffa5..d072601e 100644 --- a/anticheat/oconfig/config.go +++ b/anticheat/oconfig/config.go @@ -1,9 +1,12 @@ package oconfig -import "maps" +import ( + "maps" + "slices" +) const ( - ConfigVersion uint64 = 7 + ConfigVersion uint64 = 8 DefaultShutdownMessage = "§cServer is restarting." ) @@ -27,10 +30,11 @@ type Config struct { UseLegacyEvents bool `json:"use_legacy_events" comment:"This option signifies wether the proxy should use the legacy event system to allow the remote server to handle punishments/flags.\nThis option is recommended to be set to false as the system will be removed in the future."` - Resource ResourceOpts `json:"resource_opts" comment:"Options for your resource packs."` - Network NetworkOpts `json:"network_opts" comment:"Options for configuring the network settings for Oomph."` - Movement MovementOpts `json:"movement_opts" comment:"Options for configuring movement policies and strictness for Oomph."` - Combat CombatOpts `json:"combat_opts" comment:"Options for configuring combat policies and strictness for Oomph."` + Resource ResourceOpts `json:"resource_opts" comment:"Options for your resource packs."` + Network NetworkOpts `json:"network_opts" comment:"Options for configuring the network settings for Oomph."` + Movement MovementOpts `json:"movement_opts" comment:"Options for configuring movement policies and strictness for Oomph."` + Combat CombatOpts `json:"combat_opts" comment:"Options for configuring combat policies and strictness for Oomph."` + ChunkObfuscator ChunkObfuscatorOpts `json:"chunk_obfuscator" comment:"Options for configuring chunk obfuscation."` Detections map[string]Detection `json:"detections" comment:"The configuration for each detection used by the proxy.\nThe allowed punishment types are:\n- none: No punishment will be applied to the player.\n- kick: The player will be kicked when they reach the maximum amount of violations allowed by the detection.\n- ban: The player will be banned when the maximum amount of violations is reached. A ban provider is required for this option to be applied.\nThe tags that can be applied in the flag message are:\n- {player}: The player's username.\n- {xuid}: The player's XBOX Live ID.\n- {violations}: The amount of violations that have been reached on the detection.\n- {prefix}: The prefix defined in the Oomph configuration."` } @@ -102,6 +106,41 @@ var ( EntitySearchRadius: 6, }, + ChunkObfuscator: ChunkObfuscatorOpts{ + Enabled: true, + BlockRadius: 4, + Dimensions: ChunkObfuscatorDimensionsOpts{ + Overworld: ChunkObfuscatorDimensionOpts{ + Enabled: true, + Mode: ObfuscationModeHide, + MaxY: 64, + HiddenBlocks: []string{ + "minecraft:coal_ore", "minecraft:deepslate_coal_ore", "minecraft:copper_ore", "minecraft:deepslate_copper_ore", + "minecraft:diamond_ore", "minecraft:deepslate_diamond_ore", "minecraft:emerald_ore", "minecraft:deepslate_emerald_ore", + "minecraft:gold_ore", "minecraft:deepslate_gold_ore", "minecraft:iron_ore", "minecraft:deepslate_iron_ore", + "minecraft:lapis_ore", "minecraft:deepslate_lapis_ore", "minecraft:redstone_ore", "minecraft:deepslate_redstone_ore", + "minecraft:raw_copper_block", "minecraft:raw_iron_block", + }, + TerrainBlocks: []string{ + "minecraft:stone", "minecraft:deepslate", "minecraft:andesite", "minecraft:diorite", "minecraft:granite", + "minecraft:tuff", "minecraft:calcite", "minecraft:dirt", "minecraft:gravel", "minecraft:smooth_basalt", + "minecraft:amethyst_block", "minecraft:budding_amethyst", "minecraft:oak_planks", + }, + ReplacementBlock: "minecraft:stone", + DeepReplacementBlock: "minecraft:deepslate", + }, + Nether: ChunkObfuscatorDimensionOpts{ + Enabled: true, + Mode: ObfuscationModeLayered, + MaxY: 128, + HiddenBlocks: []string{"minecraft:ancient_debris", "minecraft:nether_gold_ore", "minecraft:quartz_ore"}, + TerrainBlocks: []string{"minecraft:netherrack", "minecraft:magma", "minecraft:blackstone", "minecraft:basalt", "minecraft:crimson_nylium", "minecraft:warped_nylium", "minecraft:gravel", "minecraft:soul_sand", "minecraft:soul_soil"}, + ReplacementBlock: "minecraft:netherrack", + DeepReplacementBlock: "minecraft:netherrack", + }, + }, + }, + Detections: map[string]Detection{ "Autoclicker_A": { MaxVl: 25.0, @@ -233,5 +272,9 @@ var ( func cloneConfig(cfg Config) Config { cfg.Detections = maps.Clone(cfg.Detections) + cfg.ChunkObfuscator.Dimensions.Overworld.HiddenBlocks = slices.Clone(cfg.ChunkObfuscator.Dimensions.Overworld.HiddenBlocks) + cfg.ChunkObfuscator.Dimensions.Overworld.TerrainBlocks = slices.Clone(cfg.ChunkObfuscator.Dimensions.Overworld.TerrainBlocks) + cfg.ChunkObfuscator.Dimensions.Nether.HiddenBlocks = slices.Clone(cfg.ChunkObfuscator.Dimensions.Nether.HiddenBlocks) + cfg.ChunkObfuscator.Dimensions.Nether.TerrainBlocks = slices.Clone(cfg.ChunkObfuscator.Dimensions.Nether.TerrainBlocks) return cfg } diff --git a/anticheat/oconfig/json.go b/anticheat/oconfig/json.go index 067354fb..353f23a2 100644 --- a/anticheat/oconfig/json.go +++ b/anticheat/oconfig/json.go @@ -17,8 +17,7 @@ var ( // ParseRawJSON parses a raw JSON string and returns a Config struct. func ParseRawJSON(data []byte) (Config, error) { - parsedCfg := DefaultConfig - parsedCfg.Detections = maps.Clone(DefaultConfig.Detections) + parsedCfg := cloneConfig(DefaultConfig) if err := hjson.Unmarshal(data, &parsedCfg); err != nil { return Config{}, fmt.Errorf("unable to parse config: %w", err) } diff --git a/anticheat/oconfig/json_test.go b/anticheat/oconfig/json_test.go index f9d8f431..26ee1238 100644 --- a/anticheat/oconfig/json_test.go +++ b/anticheat/oconfig/json_test.go @@ -85,7 +85,7 @@ func TestParseRawJSONMigratesVersionFiveWithoutLosingValues(t *testing.T) { func TestParseRawJSONRejectsNewerConfigVersion(t *testing.T) { _, err := ParseRawJSON([]byte(`{ - version: 8 + version: 9 prefix: future-prefix }`)) if !errors.Is(err, ErrConfigTooNew) { @@ -127,7 +127,7 @@ func TestParseRawJSONVersionZeroDoesNotShareDefaultDetections(t *testing.T) { func TestParseJSONDoesNotRewriteNewerConfig(t *testing.T) { path := filepath.Join(t.TempDir(), "oomph.hjson") original := []byte(`{ - version: 8 + version: 9 future_setting: keep-me }`) if err := os.WriteFile(path, original, 0o600); err != nil { @@ -204,7 +204,7 @@ func TestParseJSONSetsGlobalForCurrentConfig(t *testing.T) { func TestParseJSONLeavesCurrentConfigFileUnchanged(t *testing.T) { path := filepath.Join(t.TempDir(), "oomph.hjson") original := []byte(`{ - version: 7 + version: 8 prefix: current-prefix third_party_setting: keep-me # preserve this comment and formatting diff --git a/anticheat/player/component/acknowledgement/chunks.go b/anticheat/player/component/acknowledgement/chunks.go index ddaa8da6..8b4e54dd 100644 --- a/anticheat/player/component/acknowledgement/chunks.go +++ b/anticheat/player/component/acknowledgement/chunks.go @@ -24,18 +24,19 @@ func NewChunkUpdateACK(p *player.Player, pk *packet.LevelChunk) *ChunkUpdate { return &ChunkUpdate{mPlayer: p, pk: pk} } -func (ack *ChunkUpdate) Run() { +func (ack *ChunkUpdate) Run() (oworld.ChunkInfo, bool) { if ack.pk.CacheEnabled { ack.mPlayer.Disconnect(game.ErrorChunkCacheUnsupported) - return + return oworld.ChunkInfo{}, false } cInfo, err := oworld.CacheChunk(ack.pk, ack.mPlayer.BlockNetwork()) if err != nil { ack.mPlayer.Disconnect(fmt.Sprintf(game.ErrorInternalDecodeChunk, err)) - return + return oworld.ChunkInfo{}, false } ack.mPlayer.World().AddChunk(ack.pk.Position, cInfo) ack.mPlayer.Dbg.Notify(player.DebugModeChunks, true, "added chunk at %v", ack.pk.Position) + return cInfo, true } // SubChunkUpdate is an acknowledgment that runs when a player receives a SubChunk packet. @@ -44,14 +45,21 @@ type SubChunkUpdate struct { pk *packet.SubChunk } +type SubChunkUpdateResult struct { + Entry int + Position protocol.ChunkPos + Layer int + PayloadOffset int +} + func NewSubChunkUpdateACK(p *player.Player, pk *packet.SubChunk) *SubChunkUpdate { return &SubChunkUpdate{mPlayer: p, pk: pk} } -func (ack *SubChunkUpdate) Run() { +func (ack *SubChunkUpdate) Run() []SubChunkUpdateResult { if ack.pk.CacheEnabled { ack.mPlayer.Disconnect(game.ErrorChunkCacheUnsupported) - return + return nil } buf := internal.BufferPool.Get().(*bytes.Buffer) @@ -62,7 +70,8 @@ func (ack *SubChunkUpdate) Run() { var bufUsed bool newChunks := make(map[protocol.ChunkPos]*chunk.Chunk) - for _, entry := range ack.pk.SubChunkEntries { + results := make([]SubChunkUpdateResult, 0, len(ack.pk.SubChunkEntries)) + for entryIndex, entry := range ack.pk.SubChunkEntries { chunkPos := protocol.ChunkPos{ ack.pk.Position[0] + int32(entry.Offset[0]), ack.pk.Position[2] + int32(entry.Offset[2]), @@ -105,8 +114,11 @@ func (ack *SubChunkUpdate) Run() { } ch.Sub()[cachedSub.Layer()] = cachedSub.SubChunk() ack.mPlayer.World().AddSubChunk(chunkPos, cachedSub.Hash()) + results = append(results, SubChunkUpdateResult{Entry: entryIndex, Position: chunkPos, Layer: int(cachedSub.Layer()), PayloadOffset: cachedSub.PayloadOffset()}) ack.mPlayer.Dbg.Notify(player.DebugModeChunks, true, "cached subchunk %d at %v", cachedSub.Layer(), chunkPos) case protocol.SubChunkResultSuccessAllAir: + layer := int(ack.pk.Position[1]) + int(entry.Offset[1]) - (ch.Range().Min() >> 4) + results = append(results, SubChunkUpdateResult{Entry: entryIndex, Position: chunkPos, Layer: layer, PayloadOffset: -1}) ack.mPlayer.Dbg.Notify(player.DebugModeChunks, true, "all-air chunk at %v", chunkPos) default: ack.mPlayer.Dbg.Notify(player.DebugModeChunks, true, "no subchunk data for %v (result=%d)", chunkPos, entry.Result) @@ -118,4 +130,5 @@ func (ack *SubChunkUpdate) Run() { ack.mPlayer.World().AddChunk(pos, oworld.ChunkInfo{Chunk: newChunk, Cached: false}) ack.mPlayer.Dbg.Notify(player.DebugModeChunks, true, "(sub) added chunk at %v", pos) } + return results } diff --git a/anticheat/player/component/chunk_obfuscator.go b/anticheat/player/component/chunk_obfuscator.go new file mode 100644 index 00000000..1dbe364c --- /dev/null +++ b/anticheat/player/component/chunk_obfuscator.go @@ -0,0 +1,418 @@ +package component + +import ( + "slices" + + df_cube "github.com/df-mc/dragonfly/server/block/cube" + df_world "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/chunk" + "github.com/oomph-ac/oomph/anticheat/game" + "github.com/oomph-ac/oomph/anticheat/player" + "github.com/oomph-ac/oomph/anticheat/player/component/acknowledgement" + oworld "github.com/oomph-ac/oomph/anticheat/world" + chunkobfuscator "github.com/oomph-ac/oomph/anticheat/world/chunk_obfuscator" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +type chunkObfuscation struct { + player *player.Player + engine *chunkobfuscator.Obfuscator + dimension int32 + pending pendingReveals +} + +type subChunkUpdates map[protocol.SubChunkPos][]protocol.BlockChangeEntry + +type subChunkWork struct { + source *chunk.Chunk + obfuscated *chunk.Chunk + layers []int +} + +type pendingReveals map[protocol.SubChunkPos]map[df_cube.Pos]struct{} + +func (r pendingReveals) add(pos df_cube.Pos) { + subChunkPos := protocol.SubChunkPos{int32(pos[0]) >> 4, int32(pos[1]) >> 4, int32(pos[2]) >> 4} + if r[subChunkPos] == nil { + r[subChunkPos] = make(map[df_cube.Pos]struct{}) + } + + r[subChunkPos][pos] = struct{}{} +} + +func (r pendingReveals) restore(source, obfuscated *chunk.Chunk, pos protocol.ChunkPos, layers []int) int { + if len(r) == 0 { + return 0 + } + + restoreLayer := func(layer int) int { + if layer < 0 || layer >= len(source.Sub()) { + return 0 + } + + subChunkPos := protocol.SubChunkPos{pos[0], int32(source.Range().Min()>>4 + layer), pos[1]} + restored := 0 + for blockPos := range r[subChunkPos] { + x, y, z := uint8(blockPos[0]), int16(blockPos[1]), uint8(blockPos[2]) + runtimeID := source.Block(x, y, z, 0) + if obfuscated.Block(x, y, z, 0) != runtimeID { + obfuscated.SetBlock(x, y, z, 0, runtimeID) + restored++ + } + } + delete(r, subChunkPos) + return restored + } + if layers == nil { + restored := 0 + for layer := range source.Sub() { + restored += restoreLayer(layer) + } + return restored + } + + restored := 0 + for _, layer := range layers { + restored += restoreLayer(layer) + } + return restored +} + +func (r pendingReveals) consume(source *chunk.Chunk, pos protocol.ChunkPos, layers []int) { + if layers == nil { + for layer := range source.Sub() { + delete(r, protocol.SubChunkPos{pos[0], int32(source.Range().Min()>>4 + layer), pos[1]}) + } + return + } + + for _, layer := range layers { + if layer >= 0 && layer < len(source.Sub()) { + delete(r, protocol.SubChunkPos{pos[0], int32(source.Range().Min()>>4 + layer), pos[1]}) + } + } +} + +func cloneChunkLayers(source *chunk.Chunk, layers []int) *chunk.Chunk { + cloned := chunk.New(oworld.BlockRegistry, source.Range()) + copy(cloned.Sub(), source.Sub()) + for _, layer := range layers { + if layer >= 0 && layer < len(source.Sub()) { + cloned.Sub()[layer] = source.Sub()[layer].Clone() + } + } + return cloned +} + +func newChunkObfuscation(p *player.Player) *chunkObfuscation { + return &chunkObfuscation{player: p, engine: chunkobfuscator.Current(), dimension: p.Dimension(), pending: make(pendingReveals)} +} + +func (x *chunkObfuscation) enabled(dimension int32) bool { + return x.engine != nil && x.engine.Enabled(dimension) +} + +func (x *chunkObfuscation) syncDimension(dimension int32) { + if x.dimension != dimension { + x.dimension = dimension + clear(x.pending) + } +} + +func (x *chunkObfuscation) obfuscateSubChunks(pk *packet.SubChunk, results []acknowledgement.SubChunkUpdateResult) bool { + x.syncDimension(pk.Dimension) + if len(results) == 0 || !x.enabled(pk.Dimension) { + return false + } + + chunks := make(map[protocol.ChunkPos]*subChunkWork) + for _, result := range results { + work, found := chunks[result.Position] + if !found { + work = &subChunkWork{source: x.player.World().Chunk(result.Position)} + chunks[result.Position] = work + } + + if !slices.Contains(work.layers, result.Layer) { + work.layers = append(work.layers, result.Layer) + } + } + + for pos, work := range chunks { + if work.source == nil { + continue + } + + if !x.engine.HasCandidates(work.source, pk.Dimension, work.layers...) { + x.pending.consume(work.source, pos, work.layers) + continue + } + + obfuscated := cloneChunkLayers(work.source, work.layers) + changed := x.engine.Obfuscate(obfuscated, x.neighbors(pos), pk.Dimension, chunkSeed(pos), work.layers...) + changed -= x.pending.restore(work.source, obfuscated, pos, work.layers) + if changed != 0 { + work.obfuscated = obfuscated + } + } + + modified := false + for _, result := range results { + obfuscated := chunks[result.Position].obfuscated + if obfuscated == nil { + continue + } + + entry := &pk.SubChunkEntries[result.Entry] + payload, ok := entry.RawPayload.Value() + if !ok || result.PayloadOffset < 0 || result.PayloadOffset > len(payload) { + continue + } + + encoded, err := oworld.EncodeSubChunk(obfuscated, result.Layer, x.player.BlockNetwork()) + if err != nil { + x.player.Log().Warn("unable to encode obfuscated subchunk", "error", err) + continue + } + + entry.RawPayload = protocol.Option(append(encoded, payload[result.PayloadOffset:]...)) + modified = true + } + + for pos, work := range chunks { + x.updateEdges(pos, pk.Dimension, work.layers) + } + + return modified +} + +func (x *chunkObfuscation) obfuscateLevelChunk(pk *packet.LevelChunk, info oworld.ChunkInfo) bool { + x.syncDimension(pk.Dimension) + if !x.enabled(pk.Dimension) { + return false + } + + if !x.engine.HasCandidates(info.Chunk, pk.Dimension) { + x.pending.consume(info.Chunk, pk.Position, nil) + x.updateEdges(pk.Position, pk.Dimension, nil) + return false + } + + obfuscated := info.Chunk.Clone() + changed := x.engine.Obfuscate(obfuscated, x.neighbors(pk.Position), pk.Dimension, chunkSeed(pk.Position)) + changed -= x.pending.restore(info.Chunk, obfuscated, pk.Position, nil) + modified := changed != 0 + if modified { + if err := oworld.EncodeLevelChunk(pk, obfuscated, info.PayloadOffset, x.player.BlockNetwork()); err != nil { + x.player.Log().Warn("unable to encode obfuscated chunk", "error", err) + modified = false + } + } + + x.updateEdges(pk.Position, pk.Dimension, nil) + + return modified +} + +func (x *chunkObfuscation) neighbors(pos protocol.ChunkPos) chunkobfuscator.NeighborChunks { + w := x.player.World() + return chunkobfuscator.NeighborChunks{West: w.Chunk(protocol.ChunkPos{pos[0] - 1, pos[1]}), East: w.Chunk(protocol.ChunkPos{pos[0] + 1, pos[1]}), North: w.Chunk(protocol.ChunkPos{pos[0], pos[1] - 1}), South: w.Chunk(protocol.ChunkPos{pos[0], pos[1] + 1})} +} + +func chunkSeed(pos protocol.ChunkPos) uint64 { + return uint64(uint32(pos[0]))<<32 | uint64(uint32(pos[1])) +} + +func (x *chunkObfuscation) updateEdges(pos protocol.ChunkPos, dimension int32, layers []int) { + if !x.enabled(dimension) || !x.engine.EdgesEnabled(dimension) { + return + } + + update := func(target protocol.ChunkPos, edge chunkobfuscator.Edge, targetLayers []int) { + source := x.player.World().Chunk(target) + if source == nil { + return + } + + if !x.engine.HasCandidates(source, dimension, targetLayers...) { + return + } + + changes := x.engine.EdgeChanges(source, x.neighbors(target), dimension, chunkSeed(target), edge, targetLayers...) + if len(changes) == 0 { + return + } + + updates := make(subChunkUpdates) + for _, change := range changes { + blockPos := protocol.BlockPos{target[0]<<4 + int32(change.X), int32(change.Y), target[1]<<4 + int32(change.Z)} + x.addUpdate(updates, blockPos, change.RuntimeID) + } + x.send(updates) + } + + adjacentEdges := [...]struct { + position protocol.ChunkPos + edge chunkobfuscator.Edge + }{ + {position: protocol.ChunkPos{pos[0] - 1, pos[1]}, edge: chunkobfuscator.EastEdge}, + {position: protocol.ChunkPos{pos[0] + 1, pos[1]}, edge: chunkobfuscator.WestEdge}, + {position: protocol.ChunkPos{pos[0], pos[1] - 1}, edge: chunkobfuscator.SouthEdge}, + {position: protocol.ChunkPos{pos[0], pos[1] + 1}, edge: chunkobfuscator.NorthEdge}, + } + for _, adjacent := range adjacentEdges { + update(adjacent.position, adjacent.edge, layers) + } + + for _, layer := range layers { + if !slices.Contains(layers, layer-1) { + update(pos, chunkobfuscator.TopEdge, []int{layer - 1}) + } + + if !slices.Contains(layers, layer+1) { + update(pos, chunkobfuscator.BottomEdge, []int{layer + 1}) + } + } +} + +func (x *chunkObfuscation) exposesBlocks(oldRuntimeID, newRuntimeID uint32) bool { + dimension := x.player.Dimension() + return x.enabled(dimension) && x.engine.ExposesBlocks(oldRuntimeID, newRuntimeID, dimension) +} + +func (x *chunkObfuscation) revealAhead(pos df_cube.Pos, face df_cube.Face, depth int) { + if x.engine == nil { + return + } + + positions := blocksAhead(pos, face, depth) + skip := min(x.engine.BlockRadius(), len(positions)) + if skip == len(positions) { + return + } + + x.reveal(positions[skip:]) +} + +func blocksAhead(pos df_cube.Pos, face df_cube.Face, depth int) []df_cube.Pos { + if face < df_cube.FaceDown || face > df_cube.FaceEast || depth <= 0 { + return nil + } + + positions := make([]df_cube.Pos, 0, depth) + direction := face.Opposite() + for range depth { + pos = pos.Side(direction) + positions = append(positions, pos) + } + return positions +} + +func (x *chunkObfuscation) revealAround(changed []df_cube.Pos) { + dimension := x.player.Dimension() + x.syncDimension(dimension) + if !x.enabled(dimension) { + return + } + + x.send(x.revealUpdates(revealPositionsAround(changed, x.engine.BlockRadius()))) +} + +func (x *chunkObfuscation) reveal(positions []df_cube.Pos) { + dimension := x.player.Dimension() + x.syncDimension(dimension) + if !x.enabled(dimension) { + return + } + + x.send(x.revealUpdates(positions)) +} + +func revealPositionsAround(changed []df_cube.Pos, radius int) []df_cube.Pos { + if radius == 0 || len(changed) == 0 { + return nil + } + + volume := (4*radius*radius*radius + 6*radius*radius + 8*radius) / 3 + if len(changed) == 1 { + positions := make([]df_cube.Pos, 0, volume) + pos := changed[0] + for dx := -radius; dx <= radius; dx++ { + for dy := -radius; dy <= radius; dy++ { + for dz := -radius; dz <= radius; dz++ { + if distance := game.AbsNum(dx) + game.AbsNum(dy) + game.AbsNum(dz); distance != 0 && distance <= radius { + positions = append(positions, df_cube.Pos{pos[0] + dx, pos[1] + dy, pos[2] + dz}) + } + } + } + } + return positions + } + + shown := make(map[df_cube.Pos]struct{}, len(changed)*(volume+1)) + for _, pos := range changed { + shown[pos] = struct{}{} + } + + positions := make([]df_cube.Pos, 0, len(changed)*volume) + for _, pos := range changed { + for dx := -radius; dx <= radius; dx++ { + for dy := -radius; dy <= radius; dy++ { + for dz := -radius; dz <= radius; dz++ { + if distance := game.AbsNum(dx) + game.AbsNum(dy) + game.AbsNum(dz); distance == 0 || distance > radius { + continue + } + + blockPos := df_cube.Pos{pos[0] + dx, pos[1] + dy, pos[2] + dz} + if _, found := shown[blockPos]; found { + continue + } + + positions = append(positions, blockPos) + shown[blockPos] = struct{}{} + } + } + } + } + + return positions +} + +func (x *chunkObfuscation) revealUpdates(positions []df_cube.Pos) subChunkUpdates { + updates := make(subChunkUpdates) + dimension := x.player.Dimension() + w := x.player.World() + for _, blockPos := range positions { + chunkPos := protocol.ChunkPos{int32(blockPos[0]) >> 4, int32(blockPos[2]) >> 4} + source := w.Chunk(chunkPos) + if source == nil { + x.pending.add(blockPos) + continue + } + + layer := (blockPos[1] >> 4) - (source.Range().Min() >> 4) + if layer < 0 || layer >= len(source.Sub()) || source.Sub()[layer].Empty() { + x.pending.add(blockPos) + } + + runtimeID := df_world.BlockRuntimeID(w.Block(blockPos)) + if !x.engine.ObfuscatesBlock(runtimeID, dimension) { + continue + } + + x.addUpdate(updates, protocol.BlockPos{int32(blockPos[0]), int32(blockPos[1]), int32(blockPos[2])}, runtimeID) + } + return updates +} + +func (x *chunkObfuscation) addUpdate(updates subChunkUpdates, pos protocol.BlockPos, runtimeID uint32) { + subChunkPos := protocol.SubChunkPos{pos.X() >> 4, pos.Y() >> 4, pos.Z() >> 4} + updates[subChunkPos] = append(updates[subChunkPos], protocol.BlockChangeEntry{BlockPos: pos, BlockRuntimeID: x.player.EncodeBlockRuntimeID(runtimeID), Flags: packet.BlockUpdateNetwork}) +} + +func (x *chunkObfuscation) send(updates subChunkUpdates) { + for pos, entries := range updates { + _ = x.player.SendPacketToClient(&packet.UpdateSubChunkBlocks{Position: protocol.BlockPos{pos[0], pos[1], pos[2]}, Blocks: entries}) + } +} diff --git a/anticheat/player/component/world.go b/anticheat/player/component/world.go index 881973d0..4cf42ee8 100644 --- a/anticheat/player/component/world.go +++ b/anticheat/player/component/world.go @@ -20,7 +20,8 @@ import ( // WorldUpdaterComponent is a component that handles block and chunk updates to the world of the member player. type WorldUpdaterComponent struct { - mPlayer *player.Player + mPlayer *player.Player + obfuscation *chunkObfuscation chunkRadius int32 serverChunkRadius int32 @@ -38,6 +39,8 @@ type WorldUpdaterComponent struct { func NewWorldUpdaterComponent(p *player.Player) *WorldUpdaterComponent { return &WorldUpdaterComponent{ mPlayer: p, + obfuscation: newChunkObfuscation(p), + chunkRadius: 1_000_000_000, clientPlacedBlocks: make(map[df_cube.Pos]*chainedBlockPlacement), @@ -49,15 +52,16 @@ func NewWorldUpdaterComponent(p *player.Player) *WorldUpdaterComponent { } // HandleSubChunk handles a SubChunk packet from the server. -func (c *WorldUpdaterComponent) HandleSubChunk(pk *packet.SubChunk) { +func (c *WorldUpdaterComponent) HandleSubChunk(pk *packet.SubChunk) bool { if !c.mPlayer.Ready { c.mPlayer.ACKs().Add(acknowledgement.NewPlayerInitalizedACK(c.mPlayer)) } - acknowledgement.NewSubChunkUpdateACK(c.mPlayer, pk).Run() + results := acknowledgement.NewSubChunkUpdateACK(c.mPlayer, pk).Run() + return c.obfuscation.obfuscateSubChunks(pk, results) } // HandleLevelChunk handles a LevelChunk packet from the server. -func (c *WorldUpdaterComponent) HandleLevelChunk(pk *packet.LevelChunk) { +func (c *WorldUpdaterComponent) HandleLevelChunk(pk *packet.LevelChunk) bool { if !c.mPlayer.Ready { c.mPlayer.ACKs().Add(acknowledgement.NewPlayerInitalizedACK(c.mPlayer)) } @@ -65,9 +69,13 @@ func (c *WorldUpdaterComponent) HandleLevelChunk(pk *packet.LevelChunk) { // Check if this LevelChunk packet is compatiable with oomph's handling. if _, requestMode := pk.SubChunkLimit.Value(); requestMode { //c.mPlayer.Log().Debug("cannot debug chunk due to subchunk request mode unsupported", "subChunkCount", pk.SubChunkCount) - return + return false } - acknowledgement.NewChunkUpdateACK(c.mPlayer, pk).Run() + cInfo, ok := acknowledgement.NewChunkUpdateACK(c.mPlayer, pk).Run() + if !ok { + return false + } + return c.obfuscation.obfuscateLevelChunk(pk, cInfo) } // HandleUpdateBlock handles an UpdateBlock packet from the server. @@ -77,7 +85,12 @@ func (c *WorldUpdaterComponent) HandleUpdateBlock(pk *packet.UpdateBlock) { c.mPlayer.Log().Debug("unsupported layer update block", "layer", pk.Layer, "block", pk.NewBlockRuntimeID, "pos", pos) return } - c.AddPendingUpdate(pos, c.mPlayer.DecodeBlockRuntimeID(pk.NewBlockRuntimeID)) + runtimeID := c.mPlayer.DecodeBlockRuntimeID(pk.NewBlockRuntimeID) + oldRuntimeID := df_world.BlockRuntimeID(c.mPlayer.World().Block(pos)) + c.AddPendingUpdate(pos, runtimeID) + if c.obfuscation.exposesBlocks(oldRuntimeID, runtimeID) { + c.obfuscation.revealAround([]df_cube.Pos{pos}) + } } // HandleUpdateSubChunkBlocks handles an UpdateSubChunkBlocks packet from the server. @@ -85,12 +98,32 @@ func (c *WorldUpdaterComponent) HandleUpdateSubChunkBlocks(pk *packet.UpdateSubC if !c.mPlayer.Ready { c.mPlayer.ACKs().Add(acknowledgement.NewPlayerInitalizedACK(c.mPlayer)) } - for _, entry := range pk.Blocks { - c.AddPendingUpdate(df_cube.Pos{int(entry.BlockPos.X()), int(entry.BlockPos.Y()), int(entry.BlockPos.Z())}, c.mPlayer.DecodeBlockRuntimeID(entry.BlockRuntimeID)) + changed := c.addBlockUpdates(pk.Blocks, make([]df_cube.Pos, 0, len(pk.Blocks)+len(pk.Extra))) + changed = c.addBlockUpdates(pk.Extra, changed) + if len(changed) != 0 { + c.obfuscation.revealAround(changed) } - for _, entry := range pk.Extra { - c.AddPendingUpdate(df_cube.Pos{int(entry.BlockPos.X()), int(entry.BlockPos.Y()), int(entry.BlockPos.Z())}, c.mPlayer.DecodeBlockRuntimeID(entry.BlockRuntimeID)) +} + +func (c *WorldUpdaterComponent) addBlockUpdates(entries []protocol.BlockChangeEntry, changed []df_cube.Pos) []df_cube.Pos { + for _, entry := range entries { + pos := df_cube.Pos{int(entry.BlockPos.X()), int(entry.BlockPos.Y()), int(entry.BlockPos.Z())} + runtimeID := c.mPlayer.DecodeBlockRuntimeID(entry.BlockRuntimeID) + oldRuntimeID := df_world.BlockRuntimeID(c.mPlayer.World().Block(pos)) + c.AddPendingUpdate(pos, runtimeID) + if c.obfuscation.exposesBlocks(oldRuntimeID, runtimeID) { + changed = append(changed, pos) + } } + return changed +} + +func (c *WorldUpdaterComponent) ShowBlocksAround(pos protocol.BlockPos) { + c.obfuscation.revealAround([]df_cube.Pos{{int(pos.X()), int(pos.Y()), int(pos.Z())}}) +} + +func (c *WorldUpdaterComponent) ShowBlocksAhead(pos protocol.BlockPos, face df_cube.Face, depth int) { + c.obfuscation.revealAhead(df_cube.Pos{int(pos.X()), int(pos.Y()), int(pos.Z())}, face, depth) } // AttemptItemInteractionWithBlock attempts a block placement request from the client. It returns false if the simulation is unable diff --git a/anticheat/player/network.go b/anticheat/player/network.go index d1e74571..edf8bd3f 100755 --- a/anticheat/player/network.go +++ b/anticheat/player/network.go @@ -7,6 +7,7 @@ import ( "time" "github.com/df-mc/dragonfly/server/world" + "github.com/oomph-ac/oomph/anticheat/oconfig" "github.com/oomph-ac/oomph/anticheat/world/blocknetwork" "github.com/sandertv/gophertunnel/minecraft" "github.com/sandertv/gophertunnel/minecraft/protocol/login" @@ -165,5 +166,8 @@ func (p *Player) Latency() time.Duration { func (p *Player) StartGameContext(ctx context.Context, data minecraft.GameData) error { //data.PlayerMovementSettings.MovementType = protocol.PlayerMovementModeServerWithRewind data.PlayerMovementSettings.RewindHistorySize = 100 + if oconfig.ChunkObfuscator().Enabled { + data.WorldSeed = 0 + } return p.conn.StartGameContext(ctx, data) } diff --git a/anticheat/player/packet.go b/anticheat/player/packet.go index 45a2e869..9ad50e62 100644 --- a/anticheat/player/packet.go +++ b/anticheat/player/packet.go @@ -420,16 +420,19 @@ func (p *Player) handleServerPacket(ctx *context.HandlePacketContext) { case *packet.ItemStackResponse: p.inventory.HandleItemStackResponse(pk) case *packet.LevelChunk: - p.worldUpdater.HandleLevelChunk(pk) + modified := p.worldUpdater.HandleLevelChunk(pk) _, requestMode := pk.SubChunkLimit.Value() fullChunk := !pk.CacheEnabled && !requestMode - if fullChunk && p.opts.Network.AttemptFixChunks { + if !modified && fullChunk && p.opts.Network.AttemptFixChunks { if err := oworld.ReencodeLevelChunk(pk, p.BlockNetwork()); err != nil { p.Log().Warn("unable to re-encode chunk", "error", err) } else { - ctx.SetModified() + modified = true } } + if modified { + ctx.SetModified() + } case *packet.MobEffect: pk.Tick = 0 ctx.SetModified() @@ -487,7 +490,9 @@ func (p *Player) handleServerPacket(ctx *context.HandlePacketContext) { case *packet.SetPlayerGameType: p.gamemodeHandle.Handle(pk) case *packet.SubChunk: - p.worldUpdater.HandleSubChunk(pk) + if p.worldUpdater.HandleSubChunk(pk) { + ctx.SetModified() + } case *packet.UpdateAbilities: if pk.AbilityData.EntityUniqueID == p.UniqueId { p.movement.ServerUpdate(pk) diff --git a/anticheat/player/player.go b/anticheat/player/player.go index 28a49d9c..8dcf1ac0 100755 --- a/anticheat/player/player.go +++ b/anticheat/player/player.go @@ -18,6 +18,7 @@ import ( "github.com/oomph-ac/oomph/anticheat/utils" "github.com/oomph-ac/oomph/anticheat/world" "github.com/oomph-ac/oomph/anticheat/world/blocknetwork" + "github.com/oomph-ac/oomph/anticheat/world/chunk_obfuscator" "github.com/sandertv/gophertunnel/minecraft" "github.com/sandertv/gophertunnel/minecraft/protocol" "github.com/sandertv/gophertunnel/minecraft/protocol/login" @@ -224,6 +225,7 @@ type Player struct { // New creates and returns a new Player instance. func New(log *slog.Logger, mState MonitoringState, listener *minecraft.Listener) *Player { world.FinalizeBlockRegistry() + chunkobfuscator.Init() p := &Player{ MState: mState, diff --git a/anticheat/player/world.go b/anticheat/player/world.go index 9f30d212..7f3522be 100644 --- a/anticheat/player/world.go +++ b/anticheat/player/world.go @@ -2,6 +2,7 @@ package player import ( "math" + "time" "github.com/chewxy/math32" "github.com/df-mc/dragonfly/server/block" @@ -21,9 +22,9 @@ import ( // WorldUpdaterComponent is a component that handles block and chunk updates to the world of the member player. type WorldUpdaterComponent interface { // HandleLevelChunk allows the world updater component to handle a LevelChunk packet sent by the server. - HandleLevelChunk(pk *packet.LevelChunk) + HandleLevelChunk(pk *packet.LevelChunk) bool // HandleSubChunk allows the world updater component to handle a SubChunk packet sent by the server. - HandleSubChunk(pk *packet.SubChunk) + HandleSubChunk(pk *packet.SubChunk) bool // HandleUpdateBlock allows the world updater component to handle an UpdateBlock packet sent by the server. HandleUpdateBlock(pk *packet.UpdateBlock) // HandleUpdateSubChunkBlocks allows the world updater component to handle a UpdateSubChunkBlocks packet sent by the server. @@ -54,6 +55,10 @@ type WorldUpdaterComponent interface { HasPendingUpdate(pos df_cube.Pos) bool // RemovePendingUpdate removes a pending block update. RemovePendingUpdate(pos df_cube.Pos, blockRuntimeID uint32) + // ShowBlocksAround shows blocks obfuscated around a successfully broken block. + ShowBlocksAround(pos protocol.BlockPos) + // ShowBlocksAhead shows blocks obfuscated ahead of a successfully broken block. + ShowBlocksAhead(pos protocol.BlockPos, face df_cube.Face, depth int) // Tick ticks the world updater component. Tick() @@ -434,6 +439,13 @@ func (p *Player) tryBreakBlock(interactFace cube.Face) bool { p.blockBreakProgress = 0.0 return false } + + p.WorldUpdater().ShowBlocksAround(breakPos) + + breakTicks := max(p.expectedBlockBreakTime(breakPos), 1) + latencyTicks := float64(p.StackLatency) / float64(50*time.Millisecond) + lookahead := min(int(math.Ceil(latencyTicks/float64(breakTicks)))+4, 12) + p.WorldUpdater().ShowBlocksAhead(breakPos, df_cube.Face(interactFace), lookahead) return true } diff --git a/anticheat/world/cache.go b/anticheat/world/cache.go index 270f82e1..f7db35c7 100644 --- a/anticheat/world/cache.go +++ b/anticheat/world/cache.go @@ -59,16 +59,22 @@ func CacheSubChunk(payload *bytes.Buffer, c *chunk.Chunk, pos protocol.ChunkPos, return sc, nil } + payloadLen := payload.Len() var index byte decodedSC, err := decodeSubChunk(payload, c, &index, chunk.NetworkEncoding) if err != nil { return nil, err } - if codec.Mode() == blocknetwork.Hashes { + + switch codec.Mode() { + case blocknetwork.RuntimeIDs: + case blocknetwork.Hashes: decodedSC.ConvertBlockNetworkHashesToRuntimeIDs(BlockRegistry) + default: + return nil, fmt.Errorf("unknown block network mode %d", codec.Mode()) } - cachedSC := &CachedSubChunk{hash: hash, layer: index, sc: decodedSC} + cachedSC := &CachedSubChunk{hash: hash, layer: index, sc: decodedSC, payloadOffset: payloadLen - payload.Len()} cachedSC.subs.Add(1) subChunkCache[hash] = cachedSC @@ -76,6 +82,21 @@ func CacheSubChunk(payload *bytes.Buffer, c *chunk.Chunk, pos protocol.ChunkPos, return cachedSC, nil } +func EncodeSubChunk(c *chunk.Chunk, index int, codec blocknetwork.Codec) ([]byte, error) { + if index < 0 || index >= len(c.Sub()) { + return nil, fmt.Errorf("invalid subchunk index %d", index) + } + + switch codec.Mode() { + case blocknetwork.RuntimeIDs: + return chunk.EncodeSubChunk(c, chunk.NetworkEncoding, index), nil + case blocknetwork.Hashes: + return chunk.EncodeSubChunkWithBlockNetworkHashes(c, index), nil + default: + return nil, fmt.Errorf("unknown block network mode %d", codec.Mode()) + } +} + func CacheChunk(input *packet.LevelChunk, codec blocknetwork.Codec) (ChunkInfo, error) { cMu.Lock() defer cMu.Unlock() @@ -84,7 +105,7 @@ func CacheChunk(input *packet.LevelChunk, codec blocknetwork.Codec) (ChunkInfo, if c, ok := chunkCache[hash]; ok { c.subs.Add(1) //fmt.Println("returning cached chunk", hash) - return ChunkInfo{Hash: hash, Chunk: c.chunk, Cached: true}, nil + return ChunkInfo{Hash: hash, Chunk: c.chunk, Cached: true, PayloadOffset: c.payloadOffset}, nil } dimension, ok := world.DimensionByID(int(input.Dimension)) @@ -92,24 +113,55 @@ func CacheChunk(input *packet.LevelChunk, codec blocknetwork.Codec) (ChunkInfo, return ChunkInfo{}, fmt.Errorf("unknown dimension %v", input.Dimension) } - decodedChunk, err := chunk.NetworkDecode( + buf := bytes.NewBuffer(input.RawPayload) + decodedChunk, _, err := chunk.NetworkDecodeBuffer( BlockRegistry, - input.RawPayload, + buf, int(input.SubChunkCount), dimension.Range(), ) if err != nil { return ChunkInfo{}, err } - if codec.Mode() == blocknetwork.Hashes { + + switch codec.Mode() { + case blocknetwork.RuntimeIDs: + case blocknetwork.Hashes: decodedChunk.ConvertBlockNetworkHashesToRuntimeIDs() + default: + return ChunkInfo{}, fmt.Errorf("unknown block network mode %d", codec.Mode()) } decodedChunk.CompactForRuntimeCache() - cachedChunk := &CachedChunk{hash: hash, chunk: decodedChunk} + cachedChunk := &CachedChunk{hash: hash, chunk: decodedChunk, payloadOffset: len(input.RawPayload) - buf.Len()} cachedChunk.subs.Add(1) chunkCache[hash] = cachedChunk - return ChunkInfo{Hash: hash, Chunk: cachedChunk.chunk, Cached: true}, nil + return ChunkInfo{Hash: hash, Chunk: cachedChunk.chunk, Cached: true, PayloadOffset: cachedChunk.payloadOffset}, nil +} + +func EncodeLevelChunk(input *packet.LevelChunk, c *chunk.Chunk, payloadOffset int, codec blocknetwork.Codec) error { + if payloadOffset < 0 || payloadOffset > len(input.RawPayload) { + return fmt.Errorf("invalid level chunk payload offset %d", payloadOffset) + } + + var data chunk.SerialisedData + switch codec.Mode() { + case blocknetwork.RuntimeIDs: + data = chunk.Encode(c, chunk.NetworkEncoding) + case blocknetwork.Hashes: + data = chunk.EncodeWithBlockNetworkHashes(c) + default: + return fmt.Errorf("unknown block network mode %d", codec.Mode()) + } + out := bytes.NewBuffer(make([]byte, 0, len(input.RawPayload))) + for _, sub := range data.SubChunks { + out.Write(sub) + } + out.Write(data.Biomes) + out.Write(input.RawPayload[payloadOffset:]) + input.RawPayload = out.Bytes() + input.SubChunkCount = uint32(len(data.SubChunks)) + return nil } // ReencodeLevelChunk fully re-encodes the block palettes in input while preserving the session's block-network @@ -119,36 +171,29 @@ func ReencodeLevelChunk(input *packet.LevelChunk, codec blocknetwork.Codec) erro if !ok { return fmt.Errorf("unknown dimension %v", input.Dimension) } + buf := bytes.NewBuffer(input.RawPayload) decoded, _, err := chunk.NetworkDecodeBuffer(BlockRegistry, buf, int(input.SubChunkCount), dimension.Range()) if err != nil { return err } - if codec.Mode() == blocknetwork.Hashes { + + switch codec.Mode() { + case blocknetwork.RuntimeIDs: + case blocknetwork.Hashes: decoded.ConvertBlockNetworkHashesToRuntimeIDs() + default: + return fmt.Errorf("unknown block network mode %d", codec.Mode()) } - var data chunk.SerialisedData - if codec.Mode() == blocknetwork.Hashes { - data = chunk.EncodeWithBlockNetworkHashes(decoded) - } else { - data = chunk.Encode(decoded, chunk.NetworkEncoding) - } - out := bytes.NewBuffer(make([]byte, 0, len(input.RawPayload))) - for _, sub := range data.SubChunks { - out.Write(sub) - } - out.Write(data.Biomes) - out.Write(buf.Bytes()) - input.RawPayload = out.Bytes() - input.SubChunkCount = uint32(len(data.SubChunks)) - return nil + return EncodeLevelChunk(input, decoded, len(input.RawPayload)-buf.Len(), codec) } type CachedSubChunk struct { - layer byte - hash xxh3.Uint128 - subs atomic.Int64 - sc *chunk.SubChunk + layer byte + hash xxh3.Uint128 + subs atomic.Int64 + sc *chunk.SubChunk + payloadOffset int } func (csc *CachedSubChunk) Layer() byte { @@ -163,10 +208,15 @@ func (csc *CachedSubChunk) SubChunk() *chunk.SubChunk { return csc.sc } +func (csc *CachedSubChunk) PayloadOffset() int { + return csc.payloadOffset +} + type CachedChunk struct { - hash xxh3.Uint128 - subs atomic.Int64 - chunk *chunk.Chunk + hash xxh3.Uint128 + subs atomic.Int64 + chunk *chunk.Chunk + payloadOffset int } // Chunk returns a dereferenced copy of the chunk stored. diff --git a/anticheat/world/chunk_obfuscator/edges.go b/anticheat/world/chunk_obfuscator/edges.go new file mode 100644 index 00000000..64e04dd7 --- /dev/null +++ b/anticheat/world/chunk_obfuscator/edges.go @@ -0,0 +1,207 @@ +package chunkobfuscator + +import ( + "github.com/df-mc/dragonfly/server/world/chunk" + "github.com/oomph-ac/oomph/anticheat/oconfig" +) + +type Edge uint8 + +const ( + WestEdge Edge = iota + EastEdge + NorthEdge + SouthEdge + BottomEdge + TopEdge +) + +type BlockChange struct { + RuntimeID uint32 + Y int16 + X byte + Z byte +} + +func (x *Obfuscator) EdgesEnabled(dimensionID int32) bool { + d := x.dimension(dimensionID) + return x.enabled && d != nil && d.enabled && d.mode != oconfig.ObfuscationModeHide && d.candidates() != 0 +} + +// EdgeChanges returns updates for the chunk edge, optionally limited to selected layers. +func (x *Obfuscator) EdgeChanges(c *chunk.Chunk, neighbors NeighborChunks, dimensionID int32, seed uint64, edge Edge, layers ...int) []BlockChange { + d := x.dimension(dimensionID) + if !x.enabled || d == nil || !d.enabled || d.mode == oconfig.ObfuscationModeHide { + return nil + } + + candidates := d.candidates() + if candidates == 0 { + return nil + } + + minX, maxX, minZ, maxZ, ok := edgeBounds(neighbors, edge) + if !ok { + return nil + } + + minY, maxY := c.Range().Min()+1, min(d.maxY, c.Range().Max()-1) + capacity := edgeCapacity(c, d, candidates, edge, layers, minY, maxY, minX, maxX, minZ, maxZ) + if capacity == 0 { + return nil + } + + changes := make([]BlockChange, 0, capacity) + if layers == nil { + for index, sub := range c.Sub() { + changes = appendLayerEdgeChanges(changes, c, neighbors, d, candidates, seed, edge, index, sub, minY, maxY, minX, maxX, minZ, maxZ) + } + return changes + } + + for _, index := range layers { + if index >= 0 && index < len(c.Sub()) { + changes = appendLayerEdgeChanges(changes, c, neighbors, d, candidates, seed, edge, index, c.Sub()[index], minY, maxY, minX, maxX, minZ, maxZ) + } + } + return changes +} + +func edgeCapacity(c *chunk.Chunk, d *dimension, candidates blockType, edge Edge, layers []int, minY, maxY int, minX, maxX, minZ, maxZ byte) int { + if minY > maxY { + return 0 + } + area := int(maxX-minX+1) * int(maxZ-minZ+1) + if layers == nil { + capacity := 0 + for index, sub := range c.Sub() { + if layerHasCandidates(c, d, candidates, index, sub, minY, maxY) { + fromY, toY, ok := edgeYRange(c, edge, index, minY, maxY) + if ok { + capacity += (toY - fromY + 1) * area + } + } + } + return capacity + } + + capacity := 0 + for _, layer := range layers { + if layer < 0 || layer >= len(c.Sub()) { + continue + } + if layerHasCandidates(c, d, candidates, layer, c.Sub()[layer], minY, maxY) { + fromY, toY, ok := edgeYRange(c, edge, layer, minY, maxY) + if ok { + capacity += (toY - fromY + 1) * area + } + } + } + return capacity +} + +func edgeYRange(c *chunk.Chunk, edge Edge, layer, minY, maxY int) (fromY, toY int, ok bool) { + subMinY := ((c.Range().Min() >> 4) + layer) << 4 + fromY, toY = max(minY, subMinY), min(maxY, subMinY+15) + switch edge { + case BottomEdge: + fromY, toY = subMinY, subMinY + case TopEdge: + fromY, toY = subMinY+15, subMinY+15 + } + + return fromY, toY, fromY >= minY && toY <= maxY && fromY <= toY +} + +func appendLayerEdgeChanges(changes []BlockChange, c *chunk.Chunk, neighbors NeighborChunks, d *dimension, candidates blockType, seed uint64, edge Edge, index int, sub *chunk.SubChunk, minY, maxY int, minX, maxX, minZ, maxZ byte) []BlockChange { + layers := sub.Layers() + if sub.Empty() || len(layers) == 0 || !d.paletteContains(layers[0], candidates) { + return changes + } + + storage := layers[0] + fromY, toY, ok := edgeYRange(c, edge, index, minY, maxY) + if !ok { + return changes + } + + for y := fromY; y <= toY; y++ { + layerBlock := uint32(0) + if d.mode == oconfig.ObfuscationModeLayered { + layerBlock = d.decoy(seed ^ uint64(int64(y))) + } + for x := minX; x <= maxX; x++ { + for z := minZ; z <= maxZ; z++ { + runtimeID := storage.At(x, byte(y), z) + if !d.has(runtimeID, candidates) || !d.enclosedInStorage(c, neighbors, storage, x, int16(y), z) { + continue + } + replacement := layerBlock + switch d.mode { + case oconfig.ObfuscationModeHide: + replacement = d.hideReplacement(y) + case oconfig.ObfuscationModeRandom: + replacement = d.decoy(blockSeed(seed, x, y, z)) + } + if replacement != runtimeID { + changes = append(changes, BlockChange{X: x, Y: int16(y), Z: z, RuntimeID: replacement}) + } + } + } + } + + return changes +} + +func edgeBounds(neighbors NeighborChunks, edge Edge) (minX, maxX, minZ, maxZ byte, ok bool) { + minX, maxX, minZ, maxZ = 1, 14, 1, 14 + switch edge { + case WestEdge: + minX, maxX, ok = 0, 0, neighbors.West != nil + if neighbors.North != nil { + minZ = 0 + } + if neighbors.South != nil { + maxZ = 15 + } + case EastEdge: + minX, maxX, ok = 15, 15, neighbors.East != nil + if neighbors.North != nil { + minZ = 0 + } + if neighbors.South != nil { + maxZ = 15 + } + case NorthEdge: + minZ, maxZ, ok = 0, 0, neighbors.North != nil + if neighbors.West != nil { + minX = 0 + } + if neighbors.East != nil { + maxX = 15 + } + case SouthEdge: + minZ, maxZ, ok = 15, 15, neighbors.South != nil + if neighbors.West != nil { + minX = 0 + } + if neighbors.East != nil { + maxX = 15 + } + case BottomEdge, TopEdge: + ok = true + if neighbors.West != nil { + minX = 0 + } + if neighbors.East != nil { + maxX = 15 + } + if neighbors.North != nil { + minZ = 0 + } + if neighbors.South != nil { + maxZ = 15 + } + } + return +} diff --git a/anticheat/world/chunk_obfuscator/obfuscation.go b/anticheat/world/chunk_obfuscator/obfuscation.go new file mode 100644 index 00000000..9328f345 --- /dev/null +++ b/anticheat/world/chunk_obfuscator/obfuscation.go @@ -0,0 +1,304 @@ +package chunkobfuscator + +import ( + "github.com/df-mc/dragonfly/server/world/chunk" + "github.com/oomph-ac/oomph/anticheat/oconfig" +) + +type NeighborChunks struct { + West *chunk.Chunk + East *chunk.Chunk + North *chunk.Chunk + South *chunk.Chunk +} + +// HasCandidates reports whether the chunk, or any selected layer, contains blocks eligible for obfuscation. +func (x *Obfuscator) HasCandidates(c *chunk.Chunk, dimensionID int32, layers ...int) bool { + d := x.dimension(dimensionID) + if !x.enabled || d == nil || !d.enabled { + return false + } + + candidates := d.candidates() + minY, maxY := d.bounds(c) + if candidates == 0 || minY > maxY { + return false + } + + if layers == nil { + for index, sub := range c.Sub() { + if layerHasCandidates(c, d, candidates, index, sub, minY, maxY) { + return true + } + } + return false + } + + for _, index := range layers { + if index >= 0 && index < len(c.Sub()) && layerHasCandidates(c, d, candidates, index, c.Sub()[index], minY, maxY) { + return true + } + } + + return false +} + +// Obfuscate obfuscates the chunk, or only the selected layers when supplied. +func (x *Obfuscator) Obfuscate(c *chunk.Chunk, neighbors NeighborChunks, dimensionID int32, seed uint64, layers ...int) int { + d := x.dimension(dimensionID) + if !x.enabled || d == nil || !d.enabled { + return 0 + } + + candidates := d.candidates() + if candidates == 0 { + return 0 + } + + return obfuscateChunk(c, neighbors, d, candidates, seed, layers) +} + +func obfuscateChunk(c *chunk.Chunk, neighbors NeighborChunks, d *dimension, candidates blockType, seed uint64, layers []int) int { + minY, maxY := d.bounds(c) + if minY > maxY { + return 0 + } + + minX, maxX, minZ, maxZ := byte(0), byte(15), byte(0), byte(15) + if d.mode != oconfig.ObfuscationModeHide { + minX, maxX, minZ, maxZ = 1, 14, 1, 14 + if neighbors.West != nil { + minX = 0 + } + if neighbors.East != nil { + maxX = 15 + } + if neighbors.North != nil { + minZ = 0 + } + if neighbors.South != nil { + maxZ = 15 + } + } + + if layers == nil { + changed := 0 + for index, sub := range c.Sub() { + changed += obfuscateLayer(c, neighbors, d, candidates, seed, index, sub, minY, maxY, minX, maxX, minZ, maxZ) + } + return changed + } + + changed := 0 + for _, index := range layers { + if index >= 0 && index < len(c.Sub()) { + changed += obfuscateLayer(c, neighbors, d, candidates, seed, index, c.Sub()[index], minY, maxY, minX, maxX, minZ, maxZ) + } + } + + return changed +} + +func obfuscateLayer(c *chunk.Chunk, neighbors NeighborChunks, d *dimension, candidates blockType, seed uint64, index int, sub *chunk.SubChunk, minY, maxY int, minX, maxX, minZ, maxZ byte) int { + if !layerHasCandidates(c, d, candidates, index, sub, minY, maxY) { + return 0 + } + + storage := sub.Layers()[0] + subMinY := ((c.Range().Min() >> 4) + index) << 4 + fromY, toY := max(minY, subMinY), min(maxY, subMinY+15) + if d.mode == oconfig.ObfuscationModeHide { + return obfuscateHiddenBlocks(storage, d, candidates, fromY, toY, minX, maxX, minZ, maxZ) + } + return obfuscateEnclosedBlocks(c, neighbors, storage, d, candidates, seed, fromY, toY, minX, maxX, minZ, maxZ) +} + +func obfuscateHiddenBlocks(storage *chunk.PalettedStorage, d *dimension, candidates blockType, minY, maxY int, minX, maxX, minZ, maxZ byte) int { + changed := 0 + for y := minY; y <= maxY; y++ { + replacement := d.hideReplacement(y) + for x := minX; x <= maxX; x++ { + for z := minZ; z <= maxZ; z++ { + runtimeID := storage.At(x, byte(y), z) + if !d.has(runtimeID, candidates) { + continue + } + if replacement != runtimeID { + storage.Set(x, byte(y), z, replacement) + changed++ + } + } + } + } + + return changed +} + +func obfuscateEnclosedBlocks(c *chunk.Chunk, neighbors NeighborChunks, storage *chunk.PalettedStorage, d *dimension, candidates blockType, seed uint64, minY, maxY int, minX, maxX, minZ, maxZ byte) int { + palette := storage.Palette() + var paletteClasses [4096]blockType + for index := range palette.Len() { + runtimeID := palette.Value(uint16(index)) + if int(runtimeID) < len(d.blocks) { + paletteClasses[index] = d.blocks[runtimeID] + } + } + + var paletteIndexes [4096]uint16 + var blockClasses [4096]blockType + for x := byte(0); x < 16; x++ { + for z := byte(0); z < 16; z++ { + for y := byte(0); y < 16; y++ { + offset := int(x)<<8 | int(z)<<4 | int(y) + paletteIndex := storage.PaletteIndex(x, y, z) + paletteIndexes[offset] = paletteIndex + blockClasses[offset] = paletteClasses[paletteIndex] + } + } + } + + changed := 0 + for y := minY; y <= maxY; y++ { + layerBlock := uint32(0) + if d.mode == oconfig.ObfuscationModeLayered { + layerBlock = d.decoy(seed ^ uint64(int64(y))) + } + for x := minX; x <= maxX; x++ { + xOffset := int(x)<<8 | int(byte(y)&15) + for z := minZ; z <= maxZ; z++ { + offset := xOffset | int(z)<<4 + if blockClasses[offset]&candidates == 0 || !d.enclosedInCache(c, neighbors, &blockClasses, offset, x, int16(y), z) { + continue + } + + runtimeID := palette.Value(paletteIndexes[offset]) + replacement := layerBlock + if d.mode == oconfig.ObfuscationModeRandom { + replacement = d.decoy(blockSeed(seed, x, y, z)) + } + + if replacement != runtimeID { + storage.Set(x, byte(y), z, replacement) + changed++ + } + } + } + } + return changed +} + +func (d dimension) enclosedInCache(c *chunk.Chunk, neighbors NeighborChunks, classes *[4096]blockType, offset int, x byte, y int16, z byte) bool { + switch { + case x == 0 && (neighbors.West == nil || !d.isSolid(neighbors.West.Block(15, y, z, 0))): + return false + case x != 0 && classes[offset-256] == 0: + return false + case x == 15 && (neighbors.East == nil || !d.isSolid(neighbors.East.Block(0, y, z, 0))): + return false + case x != 15 && classes[offset+256] == 0: + return false + case z == 0 && (neighbors.North == nil || !d.isSolid(neighbors.North.Block(x, y, 15, 0))): + return false + case z != 0 && classes[offset-16] == 0: + return false + case z == 15 && (neighbors.South == nil || !d.isSolid(neighbors.South.Block(x, y, 0, 0))): + return false + case z != 15 && classes[offset+16] == 0: + return false + } + + switch byte(y) & 15 { + case 0: + return d.isSolid(c.Block(x, y-1, z, 0)) && classes[offset+1] != 0 + case 15: + return classes[offset-1] != 0 && d.isSolid(c.Block(x, y+1, z, 0)) + default: + return classes[offset-1] != 0 && classes[offset+1] != 0 + } +} + +func layerHasCandidates(c *chunk.Chunk, d *dimension, candidates blockType, index int, sub *chunk.SubChunk, minY, maxY int) bool { + subMinY := ((c.Range().Min() >> 4) + index) << 4 + layers := sub.Layers() + return subMinY <= maxY && subMinY+15 >= minY && !sub.Empty() && len(layers) != 0 && d.paletteContains(layers[0], candidates) +} + +func (d dimension) bounds(c *chunk.Chunk) (int, int) { + if d.mode == oconfig.ObfuscationModeHide { + return c.Range().Min(), min(d.maxY, c.Range().Max()) + } + + return c.Range().Min() + 1, min(d.maxY, c.Range().Max()-1) +} + +func (d dimension) enclosedInStorage(c *chunk.Chunk, neighbors NeighborChunks, storage *chunk.PalettedStorage, x byte, y int16, z byte) bool { + localY := byte(y) + interior := x != 0 && x != 15 && z != 0 && z != 15 + if interior && (!d.isSolid(storage.At(x-1, localY, z)) || !d.isSolid(storage.At(x+1, localY, z)) || !d.isSolid(storage.At(x, localY, z-1)) || !d.isSolid(storage.At(x, localY, z+1))) { + return false + } + + if !interior && !d.edgeNeighborsSolid(neighbors, storage, x, y, z) { + return false + } + + switch localY & 15 { + case 0: + return d.isSolid(c.Block(x, y-1, z, 0)) && d.isSolid(storage.At(x, localY+1, z)) + case 15: + return d.isSolid(storage.At(x, localY-1, z)) && d.isSolid(c.Block(x, y+1, z, 0)) + default: + return d.isSolid(storage.At(x, localY-1, z)) && d.isSolid(storage.At(x, localY+1, z)) + } +} + +func (d dimension) edgeNeighborsSolid(neighbors NeighborChunks, storage *chunk.PalettedStorage, x byte, y int16, z byte) bool { + localY := byte(y) + + if x == 0 && (neighbors.West == nil || !d.isSolid(neighbors.West.Block(15, y, z, 0))) { + return false + } + if x != 0 && !d.isSolid(storage.At(x-1, localY, z)) { + return false + } + if x == 15 && (neighbors.East == nil || !d.isSolid(neighbors.East.Block(0, y, z, 0))) { + return false + } + if x != 15 && !d.isSolid(storage.At(x+1, localY, z)) { + return false + } + if z == 0 && (neighbors.North == nil || !d.isSolid(neighbors.North.Block(x, y, 15, 0))) { + return false + } + if z != 0 && !d.isSolid(storage.At(x, localY, z-1)) { + return false + } + if z == 15 && (neighbors.South == nil || !d.isSolid(neighbors.South.Block(x, y, 0, 0))) { + return false + } + if z != 15 && !d.isSolid(storage.At(x, localY, z+1)) { + return false + } + + return true +} + +func (d dimension) hideReplacement(y int) uint32 { + if y < 0 { + return d.deepReplacement + } + + return d.replacement +} + +func (d dimension) decoy(value uint64) uint32 { + value += 0x9e3779b97f4a7c15 + value = (value ^ value>>30) * 0xbf58476d1ce4e5b9 + value = (value ^ value>>27) * 0x94d049bb133111eb + value ^= value >> 31 + return d.decoys[value%uint64(len(d.decoys))] +} + +func blockSeed(seed uint64, x byte, y int, z byte) uint64 { + return seed ^ uint64(x)<<36 ^ uint64(uint32(y))<<4 ^ uint64(z) +} diff --git a/anticheat/world/chunk_obfuscator/obfuscator.go b/anticheat/world/chunk_obfuscator/obfuscator.go new file mode 100644 index 00000000..de6be40c --- /dev/null +++ b/anticheat/world/chunk_obfuscator/obfuscator.go @@ -0,0 +1,101 @@ +package chunkobfuscator + +import ( + "fmt" + "sync" + + "github.com/df-mc/dragonfly/server/world/chunk" + "github.com/oomph-ac/oomph/anticheat/oconfig" + "github.com/oomph-ac/oomph/anticheat/oerror" + oworld "github.com/oomph-ac/oomph/anticheat/world" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +const ( + hiddenBlock blockType = 1 << iota + terrainBlock +) + +type blockType uint8 + +type Obfuscator struct { + enabled bool + blockRadius int + overworld dimension + nether dimension +} + +type dimension struct { + enabled bool + mode oconfig.ObfuscationMode + maxY int + blocks []blockType + decoys []uint32 + replacement uint32 + deepReplacement uint32 +} + +var ( + current *Obfuscator + initOnce sync.Once +) + +func newObfuscator(registry chunk.BlockRegistry, opts oconfig.ChunkObfuscatorOpts) (*Obfuscator, error) { + if opts.BlockRadius < 0 || opts.BlockRadius > 4 { + return nil, fmt.Errorf("block radius must be between 0 and 4") + } + obfuscator := &Obfuscator{enabled: opts.Enabled, blockRadius: opts.BlockRadius} + if !opts.Enabled { + return obfuscator, nil + } + overworld, err := compileDimension(registry, opts.Dimensions.Overworld) + if err != nil { + return nil, fmt.Errorf("overworld: %w", err) + } + nether, err := compileDimension(registry, opts.Dimensions.Nether) + if err != nil { + return nil, fmt.Errorf("nether: %w", err) + } + obfuscator.overworld, obfuscator.nether = overworld, nether + return obfuscator, nil +} + +func Init() { + initOnce.Do(func() { + obfuscator, err := newObfuscator(oworld.BlockRegistry, oconfig.ChunkObfuscator()) + if err != nil { + panic(oerror.New("unable to initialize chunk obfuscator: %v", err)) + } + current = obfuscator + }) +} + +func Current() *Obfuscator { return current } + +func (x *Obfuscator) Enabled(dimensionID int32) bool { + d := x.dimension(dimensionID) + return x.enabled && d != nil && d.enabled +} + +func (x *Obfuscator) BlockRadius() int { return x.blockRadius } + +func (x *Obfuscator) ExposesBlocks(oldRuntimeID, newRuntimeID uint32, dimensionID int32) bool { + d := x.dimension(dimensionID) + return x.enabled && d != nil && d.enabled && d.isSolid(oldRuntimeID) && !d.isSolid(newRuntimeID) +} + +func (x *Obfuscator) ObfuscatesBlock(runtimeID uint32, dimensionID int32) bool { + d := x.dimension(dimensionID) + return x.enabled && d != nil && d.enabled && d.has(runtimeID, d.candidates()) +} + +func (x *Obfuscator) dimension(dimensionID int32) *dimension { + switch dimensionID { + case packet.DimensionOverworld: + return &x.overworld + case packet.DimensionNether: + return &x.nether + default: + return nil + } +} diff --git a/anticheat/world/chunk_obfuscator/registry.go b/anticheat/world/chunk_obfuscator/registry.go new file mode 100644 index 00000000..02a9303a --- /dev/null +++ b/anticheat/world/chunk_obfuscator/registry.go @@ -0,0 +1,99 @@ +package chunkobfuscator + +import ( + "fmt" + + "github.com/df-mc/dragonfly/server/world/chunk" + "github.com/oomph-ac/oomph/anticheat/oconfig" +) + +func compileDimension(registry chunk.BlockRegistry, opts oconfig.ChunkObfuscatorDimensionOpts) (dimension, error) { + if !opts.Enabled { + return dimension{}, nil + } + + switch opts.Mode { + case oconfig.ObfuscationModeHide, oconfig.ObfuscationModeRandom, oconfig.ObfuscationModeLayered: + default: + return dimension{}, fmt.Errorf("unknown mode %q", opts.Mode) + } + + if len(opts.HiddenBlocks) == 0 { + return dimension{}, fmt.Errorf("hidden blocks cannot be empty") + } + + classes := make(map[string]blockType, len(opts.HiddenBlocks)+len(opts.TerrainBlocks)) + for _, name := range opts.HiddenBlocks { + classes[name] |= hiddenBlock + } + for _, name := range opts.TerrainBlocks { + classes[name] |= terrainBlock + } + + d := dimension{enabled: opts.Enabled, mode: opts.Mode, maxY: opts.MaxY, blocks: make([]blockType, registry.BlockCount()), decoys: make([]uint32, 0, len(opts.HiddenBlocks))} + for runtimeID := range d.blocks { + name, _, ok := registry.RuntimeIDToState(uint32(runtimeID)) + if ok { + d.blocks[runtimeID] = classes[name] + } + } + for _, name := range opts.HiddenBlocks { + runtimeID, err := blockRuntimeID(registry, name) + if err != nil { + return dimension{}, fmt.Errorf("hidden block: %w", err) + } + d.decoys = append(d.decoys, runtimeID) + } + for _, name := range opts.TerrainBlocks { + if _, err := blockRuntimeID(registry, name); err != nil { + return dimension{}, fmt.Errorf("terrain block: %w", err) + } + } + replacement, err := blockRuntimeID(registry, opts.ReplacementBlock) + if err != nil { + return dimension{}, fmt.Errorf("replacement block: %w", err) + } + deepReplacement, err := blockRuntimeID(registry, opts.DeepReplacementBlock) + if err != nil { + return dimension{}, fmt.Errorf("deep replacement block: %w", err) + } + d.replacement, d.deepReplacement = replacement, deepReplacement + return d, nil +} + +func blockRuntimeID(registry chunk.BlockRegistry, name string) (uint32, error) { + runtimeID, ok := registry.StateToRuntimeID(name, nil) + if !ok { + return 0, fmt.Errorf("unknown block %q", name) + } + return runtimeID, nil +} + +func (d dimension) has(runtimeID uint32, candidates blockType) bool { + return int(runtimeID) < len(d.blocks) && d.blocks[runtimeID]&candidates != 0 +} + +func (d dimension) candidates() blockType { + switch d.mode { + case oconfig.ObfuscationModeHide: + return hiddenBlock + case oconfig.ObfuscationModeRandom, oconfig.ObfuscationModeLayered: + return hiddenBlock | terrainBlock + default: + return 0 + } +} + +func (d dimension) isSolid(runtimeID uint32) bool { + return int(runtimeID) < len(d.blocks) && d.blocks[runtimeID] != 0 +} + +func (d dimension) paletteContains(storage *chunk.PalettedStorage, candidates blockType) bool { + palette := storage.Palette() + for index := range palette.Len() { + if d.has(palette.Value(uint16(index)), candidates) { + return true + } + } + return false +} diff --git a/anticheat/world/world.go b/anticheat/world/world.go index a88c0b13..0a88e22b 100644 --- a/anticheat/world/world.go +++ b/anticheat/world/world.go @@ -16,9 +16,10 @@ import ( ) type ChunkInfo struct { - Cached bool - Hash xxh3.Uint128 - Chunk *chunk.Chunk + Cached bool + Hash xxh3.Uint128 + Chunk *chunk.Chunk + PayloadOffset int } type World struct {