From bf2bed55999e6d8e1d0d60d3888afa74e772f65f Mon Sep 17 00:00:00 2001 From: sebseb7 Date: Thu, 21 May 2026 09:14:47 +0200 Subject: [PATCH] merge chunk updates --- README.md | 5 +- codebase_map.md | 10 +- config.json | 4 +- src/config.js | 17 ++- src/replay/replayChunks.js | 21 +--- src/session/BotIdleBehavior.js | 107 ++++++++++++++++++ src/session/ServerConnection.js | 15 +++ src/sniffer/MitmProxy.js | 2 + src/sniffer/PacketLog.js | 79 +++++++++++-- src/sniffer/TransparentProxy.js | 2 + src/sniffer/index.js | 2 + src/state/ChunkCache.js | 157 ++++++++++++++++++++------ src/state/WorldStateCache.js | 53 ++++++++- src/state/chunkMerge.js | 193 ++++++++++++++++++++++++++++++++ 14 files changed, 591 insertions(+), 76 deletions(-) create mode 100644 src/session/BotIdleBehavior.js create mode 100644 src/state/chunkMerge.js diff --git a/README.md b/README.md index 62a0350..f2f07fb 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ To make the client transition seamless and prevent loading/rendering glitches, F | Cache Component | Monitored Packets & Data | Eviction / Strategy | | :--- | :--- | :--- | -| **Chunks** | `map_chunk`, `update_light`, `unload_chunk`, `block_change`, `multi_block_change` | LRU cache (default max 1024 chunks) with active block change overlays. | +| **Chunks** | `map_chunk`, `update_light`, `unload_chunk`, `block_change`, `multi_block_change` | LRU cache (default max 1024 chunks); block and light updates merged into cached `map_chunk` columns. | | **Entities** | `spawn_entity`, `entity_metadata`, `entity_equipment`, `entity_effect`, `set_passengers`, `entity_destroy`, relative movements / teleports | Tracks positions, gear, mounts, and status effects. | | **Player State** | `login`, `position`, `update_health`, `experience`, `abilities`, `difficulty`, `respawn` | Caches player attributes to sync client UI and positioning. | | **Inventory** | `window_items`, `set_slot`, `held_item_slot`, `set_player_inventory`, `set_cursor_item` | Captures open container, inventory contents, and hand slots. | @@ -107,7 +107,8 @@ Copy the template structure and configure your parameters in `config.json` in th * **`proxy`**: Local proxy server settings. * `onlineMode`: If true, proxy checks Mojang authentication for incoming clients (requires client to match bot username or have appropriate credentials depending on target server configuration). Set to `false` for simple local offline-mode connections. * **`bot`**: Bot behavior settings. - * `antiAfk`: Keeps the bot moving or performing minor actions so it doesn't get kicked for inactivity. + * `antiAfk`: When no client is connected, randomly turns, sneaks, and swings so the bot stays active. + * `antiAfkMinInterval` / `antiAfkMaxInterval`: Milliseconds between idle actions (default 1500–6000). * **`cache`**: Memory usage controls for caching the world. --- diff --git a/codebase_map.md b/codebase_map.md index 0c9e59e..2611791 100644 --- a/codebase_map.md +++ b/codebase_map.md @@ -283,17 +283,17 @@ Master coordinator for world cache segments. Integrates and clears sub-caches on #### 🧩 [ChunkCache](file:///home/seb/flayerproxy/src/state/ChunkCache.js) `class` -Manages loaded map chunks, light maps, and block overlays using an LRU cache. +Manages loaded map chunks using an LRU cache; block and light updates are merged into chunk columns. | Method | Description | |---|---| | `constructor(maxChunks)` | Initializes chunk storage with LRU capacity limit. | | `_key(x, z)` | Computes string key for map lookups. | | `handleMapChunk(data, rawBuffer)` | Caches chunk data and marks it as active in the LRU tracking queue. | -| `handleUpdateLight(data, rawBuffer)` | Caches light updates. | +| `handleUpdateLight(data)` | Merges light updates into the cached chunk column. | | `handleUnloadChunk(data)` | Evicts chunk records from cache. | -| `handleBlockChange(data)` | Appends single block changes as an overlay. | -| `handleMultiBlockChange(data)` | Appends multiblock edits as an overlay. | +| `handleBlockChange(data)` | Merges single block changes into the cached chunk column. | +| `handleMultiBlockChange(data)` | Merges section block updates into the cached chunk column. | | `_buildChunkEntry(chunkData)` | Assembles raw data, block edits, and light arrays. | | `getChunksForReplay(centerChunkX, centerChunkZ, viewDistance)` | Returns cached chunks sorting closest-first. | | `hasChunkAtBlock(x, z)` | Verifies if a chunk is loaded. | @@ -459,7 +459,7 @@ Replays the cached world state to a connecting client. | Function | Description | |---|---| -| `replayChunks(write, writeRaw, chunks, center, totalCached)` | Loops through chunk arrays, sending raw chunk/light buffers and block overlay edits, and issues a final `chunk_batch_finished` packet. | +| `replayChunks(write, writeRaw, chunks, center, totalCached)` | Loops through chunk arrays, sending map_chunk buffers (block/light edits already merged), and issues a final `chunk_batch_finished` packet. | #### 📄 [replayHelpers.js](file:///home/seb/flayerproxy/src/replay/replayHelpers.js) `functions` diff --git a/config.json b/config.json index 166a238..c72580c 100644 --- a/config.json +++ b/config.json @@ -18,11 +18,13 @@ "onlineMode": false, "upstreamAuth": "microsoft", "logDir": "logs/sniffer", + "chunkLogDir": "logs/sniffer/chunks", "includePayload": true }, "bot": { "antiAfk": true, - "antiAfkInterval": 30000, + "antiAfkMinInterval": 1500, + "antiAfkMaxInterval": 6000, "viewDistance": 10 }, "cache": { diff --git a/src/config.js b/src/config.js index 4b59eab..d06ba91 100644 --- a/src/config.js +++ b/src/config.js @@ -24,7 +24,22 @@ function loadConfig() { // Apply defaults config.proxy = Object.assign({ host: '0.0.0.0', port: 25566, onlineMode: false, maxClients: 1 }, config.proxy); - config.bot = Object.assign({ antiAfk: true, antiAfkInterval: 30000, viewDistance: 10 }, config.bot); + config.bot = Object.assign( + { + antiAfk: true, + antiAfkMinInterval: 1500, + antiAfkMaxInterval: 6000, + antiAfkInterval: 6000, + viewDistance: 10, + }, + config.bot, + ); + if (config.bot.antiAfkMaxInterval == null && config.bot.antiAfkInterval != null) { + config.bot.antiAfkMaxInterval = config.bot.antiAfkInterval; + } + if (config.bot.antiAfkMinInterval == null && config.bot.antiAfkMaxInterval != null) { + config.bot.antiAfkMinInterval = Math.max(500, Math.floor(config.bot.antiAfkMaxInterval / 4)); + } config.cache = Object.assign({ maxChunks: 1024, trackEntities: true }, config.cache); config.auth.auth = config.auth.auth || 'offline'; diff --git a/src/replay/replayChunks.js b/src/replay/replayChunks.js index 2ff188c..2350361 100644 --- a/src/replay/replayChunks.js +++ b/src/replay/replayChunks.js @@ -4,7 +4,8 @@ const { CHUNK_YIELD_EVERY, yieldEventLoop } = require('./replayHelpers'); const log = createLogger('StateReplayer'); /** - * Replay cached chunks (with light, block changes) to a client. + * Replay cached chunks to a client. + * Block and light updates are already merged into map_chunk packet data. * * @param {function(string, object): void} write - named packet writer * @param {function(Buffer, string): void} writeRaw - raw buffer writer @@ -27,7 +28,6 @@ async function replayChunks(write, writeRaw, chunks, center, totalCached) { log.info(`Replaying ${chunks.length} chunks around (${center.chunkX}, ${center.chunkZ})...`); } - let lightCount = 0; let rawChunkCount = 0; for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i]; @@ -37,20 +37,6 @@ async function replayChunks(write, writeRaw, chunks, center, totalCached) { } else { write('map_chunk', chunk.packetData); } - if (chunk.rawLightBuffer) { - writeRaw(chunk.rawLightBuffer, `update_light ${chunk.packetData.x},${chunk.packetData.z}`); - lightCount++; - } else if (chunk.lightData) { - write('update_light', chunk.lightData); - lightCount++; - } - - for (const bc of chunk.blockChanges) { - write('block_change', bc); - } - for (const mbc of chunk.multiBlockChanges) { - write('multi_block_change', mbc); - } if ((i + 1) % CHUNK_YIELD_EVERY === 0) { await yieldEventLoop(); @@ -61,9 +47,6 @@ async function replayChunks(write, writeRaw, chunks, center, totalCached) { if (rawChunkCount > 0) { log.info(`Replayed ${rawChunkCount}/${chunks.length} chunks from raw buffers`); } - if (lightCount > 0) { - log.info(`Replayed ${lightCount} update_light packets`); - } } module.exports = { replayChunks }; diff --git a/src/session/BotIdleBehavior.js b/src/session/BotIdleBehavior.js new file mode 100644 index 0000000..c756d3d --- /dev/null +++ b/src/session/BotIdleBehavior.js @@ -0,0 +1,107 @@ +const { createLogger } = require('../utils/logger'); + +const log = createLogger('BotIdle'); + +const ACTIONS = ['look', 'sneak', 'swing']; + +/** + * Random look / sneak / swing while the bot holds the session (no Java client). + */ +class BotIdleBehavior { + /** + * @param {import('mineflayer').Bot} bot + * @param {object} botConfig - config.bot + */ + constructor(bot, botConfig) { + this.bot = bot; + this.config = botConfig; + this._enabled = false; + this._timer = null; + this._sneakReleaseTimer = null; + this._sneaking = false; + } + + start() { + if (!this.config.antiAfk || this._enabled) return; + this._enabled = true; + log.debug('Idle behavior started'); + this._scheduleNext(); + } + + stop() { + this._enabled = false; + if (this._timer) { + clearTimeout(this._timer); + this._timer = null; + } + if (this._sneakReleaseTimer) { + clearTimeout(this._sneakReleaseTimer); + this._sneakReleaseTimer = null; + } + this._releaseSneak(); + } + + _scheduleNext() { + if (!this._enabled) return; + const min = this.config.antiAfkMinInterval ?? 1500; + const max = this.config.antiAfkMaxInterval ?? this.config.antiAfkInterval ?? 6000; + const lo = Math.min(min, max); + const hi = Math.max(min, max); + const delay = lo + Math.random() * (hi - lo); + this._timer = setTimeout(() => { + this._timer = null; + this._tick().finally(() => this._scheduleNext()); + }, delay); + } + + async _tick() { + if (!this._enabled || !this.bot?.entity) return; + + const action = ACTIONS[Math.floor(Math.random() * ACTIONS.length)]; + try { + if (action === 'look') await this._randomLook(); + else if (action === 'sneak') this._randomSneak(); + else if (action === 'swing') this._randomSwing(); + } catch (err) { + log.debug(`Idle ${action} skipped: ${err.message}`); + } + } + + async _randomLook() { + const { yaw, pitch } = this.bot.entity; + const newYaw = yaw + (Math.random() - 0.5) * Math.PI; + const newPitch = Math.max(-1.2, Math.min(1.2, pitch + (Math.random() - 0.5) * 0.6)); + await this.bot.look(newYaw, newPitch, false); + } + + _randomSneak() { + if (this._sneaking) { + this._releaseSneak(); + return; + } + this.bot.setControlState('sneak', true); + this._sneaking = true; + const holdMs = 400 + Math.random() * 2000; + this._sneakReleaseTimer = setTimeout(() => { + this._sneakReleaseTimer = null; + this._releaseSneak(); + }, holdMs); + } + + _releaseSneak() { + if (!this._sneaking) return; + try { + this.bot.setControlState('sneak', false); + } catch { + /* bot may be gone */ + } + this._sneaking = false; + } + + _randomSwing() { + const hand = Math.random() < 0.5 ? 'right' : 'left'; + this.bot.swingArm(hand); + } +} + +module.exports = { BotIdleBehavior }; diff --git a/src/session/ServerConnection.js b/src/session/ServerConnection.js index 3b0231b..1f971ff 100644 --- a/src/session/ServerConnection.js +++ b/src/session/ServerConnection.js @@ -3,6 +3,7 @@ const mineflayer = require('mineflayer'); const { createLogger } = require('../utils/logger'); const { relayClientMovement, syncProxyClientPosition, confirmServerPosition } = require('./MovementRelay'); const { ChunkAckManager } = require('./ChunkAckManager'); +const { BotIdleBehavior } = require('./BotIdleBehavior'); const log = createLogger('ServerConn'); @@ -26,6 +27,8 @@ class ServerConnection extends EventEmitter { /** True after first spawn; later spawns are respawns on the same connection */ this._initialSpawnDone = false; this._chunkAck = new ChunkAckManager(); + /** @type {BotIdleBehavior|null} */ + this._idleBehavior = null; } /** @@ -34,6 +37,8 @@ class ServerConnection extends EventEmitter { connect() { log.info(`Connecting to ${this.config.server.host}:${this.config.server.port} as ${this.config.auth.username}...`); this._initialSpawnDone = false; + this._idleBehavior?.stop(); + this._idleBehavior = null; this.bot = mineflayer.createBot({ host: this.config.server.host, @@ -94,6 +99,12 @@ class ServerConnection extends EventEmitter { this.bot.on('spawn', () => { log.info('Bot spawned in world'); this.connected = true; + if (!this._idleBehavior) { + this._idleBehavior = new BotIdleBehavior(this.bot, this.config.bot); + } + if (this._botControlEnabled) { + this._idleBehavior.start(); + } if (!this._initialSpawnDone) { this._initialSpawnDone = true; this.emit('connected'); @@ -105,6 +116,7 @@ class ServerConnection extends EventEmitter { this.bot.on('end', (reason) => { log.warn(`Bot disconnected: ${reason}`); this.connected = false; + this._idleBehavior?.stop(); this.emit('disconnected', reason); }); @@ -160,8 +172,10 @@ class ServerConnection extends EventEmitter { if (enabled) { log.info('Bot control ENABLED (bot mode)'); if (this.bot) this.bot.physicsEnabled = true; + this._idleBehavior?.start(); } else { log.info('Bot control DISABLED (client taking over)'); + this._idleBehavior?.stop(); if (this.bot) { this.bot.physicsEnabled = false; try { @@ -248,6 +262,7 @@ class ServerConnection extends EventEmitter { * Gracefully close the connection. */ disconnect() { + this._idleBehavior?.stop(); if (this.bot) { this.bot.quit(); } diff --git a/src/sniffer/MitmProxy.js b/src/sniffer/MitmProxy.js index f4c9a9c..9ec3fa1 100644 --- a/src/sniffer/MitmProxy.js +++ b/src/sniffer/MitmProxy.js @@ -55,6 +55,7 @@ class MitmProxy { ); log.info(`Upstream auth: ${sniffer.upstreamAuth || 'microsoft'}`); log.info(`Logs: ${sniffer.logDir}`); + if (sniffer.chunkLogDir) log.info(`Chunk logs: ${sniffer.chunkLogDir}`); }); this.server.on('error', (err) => { @@ -76,6 +77,7 @@ class MitmProxy { const packetLog = new PacketLog({ logDir: this.config.sniffer.logDir, + chunkLogDir: this.config.sniffer.chunkLogDir, sessionId: `session-${Date.now()}`, clientUsername: 'unknown', server: `${this.config.server.host}:${this.config.server.port}`, diff --git a/src/sniffer/PacketLog.js b/src/sniffer/PacketLog.js index 99aaf36..2b3a29c 100644 --- a/src/sniffer/PacketLog.js +++ b/src/sniffer/PacketLog.js @@ -12,6 +12,21 @@ const LARGE_PACKETS = new Set([ 'custom_payload', ]); +/** Written to chunkLogDir only — keeps the main session log readable. */ +const CHUNK_PACKETS = new Set([ + 'map_chunk', + 'chunk_data', + 'level_chunk_with_light', + 'light_update', + 'update_light', + 'unload_chunk', + 'chunk_batch_start', + 'chunk_batch_finished', + 'chunk_batch_received', + 'block_change', + 'multi_block_change', +]); + /** * Append-only JSONL packet log for login / handoff analysis. */ @@ -19,6 +34,7 @@ class PacketLog { /** * @param {object} opts * @param {string} opts.logDir + * @param {string} [opts.chunkLogDir] - defaults to logDir/chunks * @param {string} opts.sessionId * @param {boolean} [opts.includePayload=true] */ @@ -38,19 +54,35 @@ class PacketLog { this.filePath = file; this.spillDir = this._spillDir; - this.writeMeta({ + const chunkDir = path.resolve(opts.chunkLogDir ?? path.join(dir, 'chunks')); + fs.mkdirSync(chunkDir, { recursive: true }); + const chunkFile = path.join(chunkDir, `${opts.sessionId}.jsonl`); + this._chunkSpillDir = path.join(chunkDir, opts.sessionId); + fs.mkdirSync(this._chunkSpillDir, { recursive: true }); + this._chunkSpillCount = 0; + this._chunkStream = fs.createWriteStream(chunkFile, { flags: 'a' }); + this.chunkFilePath = chunkFile; + this.chunkSpillDir = this._chunkSpillDir; + + const sessionStart = { type: 'session_start', sessionId: opts.sessionId, clientUsername: opts.clientUsername, server: opts.server, version: opts.version, - }); + }; + this.writeMeta(sessionStart); + this._writeChunkMeta(sessionStart); } writeMeta(record) { this._write({ ...record, t: new Date().toISOString() }); } + _writeChunkMeta(record) { + this._writeChunk({ ...record, t: new Date().toISOString() }); + } + /** * @param {'C2S'|'S2C'} dir * @param {object} meta - minecraft-protocol packet meta @@ -125,26 +157,50 @@ class PacketLog { entry.summary = summarizeLargePacket(meta.name, data, rawBuffer); } + if (CHUNK_PACKETS.has(meta.name)) { + this._writeChunk(entry); + return; + } this._write(entry); } close(reason) { if (this._closed) return; - this.writeMeta({ type: 'session_end', reason: reason || 'closed' }); + const end = { type: 'session_end', reason: reason || 'closed' }; + this.writeMeta(end); + this._writeChunkMeta(end); this._closed = true; this._stream.end(); + this._chunkStream.end(); } _write(obj) { if (this._closed) return; + this._emitLine(obj, { + spillDir: this._spillDir, + spillCountRef: () => ++this._spillCount, + writeRaw: (line) => this._writeRaw(line), + }); + } + + _writeChunk(obj) { + if (this._closed) return; + this._emitLine(obj, { + spillDir: this._chunkSpillDir, + spillCountRef: () => ++this._chunkSpillCount, + writeRaw: (line) => this._writeChunkRaw(line), + }); + } + + _emitLine(obj, { spillDir, spillCountRef, writeRaw }) { const line = JSON.stringify(obj); if (line.length + 1 <= MAX_INLINE_LINE) { - this._writeRaw(line); + writeRaw(line); return; } - const spillFile = `line-${String(++this._spillCount).padStart(6, '0')}.jsonl`; - fs.writeFileSync(path.join(this._spillDir, spillFile), `${line}\n`); - this._writeRaw(JSON.stringify(buildSpillRef(obj, spillFile))); + const spillFile = `line-${String(spillCountRef()).padStart(6, '0')}.jsonl`; + fs.writeFileSync(path.join(spillDir, spillFile), `${line}\n`); + writeRaw(JSON.stringify(buildSpillRef(obj, spillFile))); } _writeRaw(line) { @@ -153,6 +209,13 @@ class PacketLog { this._stream.once('drain', () => {}); } } + + _writeChunkRaw(line) { + const ok = this._chunkStream.write(`${line}\n`); + if (!ok) { + this._chunkStream.once('drain', () => {}); + } + } } function buildPreview(obj) { @@ -247,4 +310,4 @@ function summarizeLargePacket(name, data, rawBuffer) { return summary; } -module.exports = { PacketLog }; +module.exports = { PacketLog, CHUNK_PACKETS }; diff --git a/src/sniffer/TransparentProxy.js b/src/sniffer/TransparentProxy.js index 494c376..70e5750 100644 --- a/src/sniffer/TransparentProxy.js +++ b/src/sniffer/TransparentProxy.js @@ -35,6 +35,7 @@ class TransparentProxy { ); log.info('Join the server in-game (not just refresh the server list)'); log.info(`Logs: ${sniffer.logDir}`); + if (sniffer.chunkLogDir) log.info(`Chunk logs: ${sniffer.chunkLogDir}`); }); } @@ -50,6 +51,7 @@ class TransparentProxy { let clientUsername = 'unknown'; const packetLog = new PacketLog({ logDir: sniffer.logDir, + chunkLogDir: sniffer.chunkLogDir, sessionId: `session-${Date.now()}`, clientUsername, server: `${targetHost}:${targetPort}`, diff --git a/src/sniffer/index.js b/src/sniffer/index.js index 1b24f95..3a2b6d9 100644 --- a/src/sniffer/index.js +++ b/src/sniffer/index.js @@ -20,6 +20,7 @@ try { onlineMode: false, upstreamAuth: 'microsoft', logDir: path.join(__dirname, '..', '..', 'logs', 'sniffer'), + chunkLogDir: path.join(__dirname, '..', '..', 'logs', 'sniffer', 'chunks'), includePayload: true, }, config.sniffer, @@ -29,6 +30,7 @@ try { log.info(`Client online-mode: ${config.sniffer.onlineMode}`); log.info(`Upstream auth: ${config.sniffer.upstreamAuth}`); log.info(`Logs: ${path.resolve(config.sniffer.logDir)}`); + log.info(`Chunk logs: ${path.resolve(config.sniffer.chunkLogDir)}`); } catch (err) { log.error(err.message); process.exit(1); diff --git a/src/state/ChunkCache.js b/src/state/ChunkCache.js index d284d44..71c265a 100644 --- a/src/state/ChunkCache.js +++ b/src/state/ChunkCache.js @@ -1,17 +1,32 @@ const { createLogger } = require('../utils/logger'); +const { isChunkWithinViewDistance } = require('../utils/positionSync'); +const { + worldBoundsForDimension, + loadColumnFromMapChunk, + exportMapChunkPacket, + normalizeMapChunkPacket, + applyBlockChange, + applyUpdateLight, + applyMultiBlockChange, +} = require('./chunkMerge'); + const log = createLogger('ChunkCache'); /** * Caches chunk column data keyed by "x,z". - * Stores the raw packet data so we can replay it directly to a connecting client. + * Block and light updates are merged into the column; replay sends map_chunk only. */ class ChunkCache { - constructor(maxChunks = 1024) { + /** + * @param {number} maxChunks + * @param {{ version?: string, getWorldBounds?: () => { minY: number, worldHeight: number } }} [options] + */ + constructor(maxChunks = 1024, options = {}) { this.maxChunks = maxChunks; - /** @type {Map} key "x,z" -> raw packet data */ + this.version = options.version ?? '1.21.10'; + this.getWorldBounds = options.getWorldBounds ?? (() => worldBoundsForDimension(this.version)); + /** @type {Map} key "x,z" -> stored chunk */ this.chunks = new Map(); - /** @type {Map} key "x,z" -> update_light packet data */ - this.lights = new Map(); /** Track access order for LRU eviction */ this.accessOrder = []; } @@ -22,27 +37,75 @@ class ChunkCache { } /** - * Store a chunk from a map_chunk packet. - * We store the entire packet data object so we can replay it verbatim. + * Remove cached chunks outside the server's ChunkTrackingView for this center. + * @returns {number} chunks removed */ - handleMapChunk(data, rawBuffer) { + forgetOutsideView(centerChunkX, centerChunkZ, viewDistance) { + if (centerChunkX == null || centerChunkZ == null || viewDistance == null) return 0; + + let removed = 0; + for (const key of [...this.chunks.keys()]) { + const [x, z] = key.split(',').map(Number); + if (!isChunkWithinViewDistance(centerChunkX, centerChunkZ, x, z, viewDistance)) { + this.chunks.delete(key); + this.accessOrder = this.accessOrder.filter((k) => k !== key); + removed++; + } + } + if (removed > 0) { + log.debug( + `Forgot ${removed} chunk(s) outside view (${centerChunkX}, ${centerChunkZ}) distance ${viewDistance}` + ); + } + return removed; + } + + /** + * Store a map_chunk when it lies within view distance of the current center. + * @param {object} [view] - { centerChunkX, centerChunkZ, viewDistance } + */ + handleMapChunk(data, rawBuffer, view) { + if (view?.centerChunkX != null && view.viewDistance != null) { + if ( + !isChunkWithinViewDistance( + view.centerChunkX, + view.centerChunkZ, + data.x, + data.z, + view.viewDistance + ) + ) { + return; + } + this.forgetOutsideView(view.centerChunkX, view.centerChunkZ, view.viewDistance); + } + const key = this._key(data.x, data.z); this.chunks.set(key, { - packetData: structuredClone(data), + packetData: normalizeMapChunkPacket(structuredClone(data)), rawBuffer: rawBuffer ? Buffer.from(rawBuffer) : null, + column: null, }); this._touch(key); this._evictIfNeeded(); } - handleUpdateLight(data, rawBuffer) { + /** + * Merge update_light into a cached chunk column (requires map_chunk already cached). + */ + handleUpdateLight(data) { const key = this._key(data.chunkX, data.chunkZ); - this.lights.set(key, { - packetData: structuredClone(data), - rawBuffer: rawBuffer ? Buffer.from(rawBuffer) : null, - }); - if (this.chunks.has(key)) { + const stored = this.chunks.get(key); + if (!stored) return; + try { + const column = this._ensureColumn(stored); + applyUpdateLight(column, data); + this._syncPacketFromColumn(stored); this._touch(key); + } catch (err) { + log.warn( + `update_light merge failed for ${data.chunkX},${data.chunkZ}: ${err.message}` + ); } } @@ -52,23 +115,23 @@ class ChunkCache { handleUnloadChunk(data) { const key = this._key(data.chunkX, data.chunkZ); this.chunks.delete(key); - this.lights.delete(key); this.accessOrder = this.accessOrder.filter(k => k !== key); } /** - * Apply a single block_change to the cached chunk data. - * We don't modify the raw chunk buffer — instead we store block changes - * as a separate overlay. On replay, we send chunks then block changes. + * Merge block_change into the cached chunk column. */ handleBlockChange(data) { const chunkX = Math.floor(data.location.x / 16); const chunkZ = Math.floor(data.location.z / 16); - const key = this._key(chunkX, chunkZ); - const stored = this.chunks.get(key); - if (stored) { - if (!stored._blockChanges) stored._blockChanges = []; - stored._blockChanges.push(structuredClone(data)); + const stored = this.chunks.get(this._key(chunkX, chunkZ)); + if (!stored) return; + try { + const column = this._ensureColumn(stored); + applyBlockChange(column, data); + this._syncPacketFromColumn(stored); + } catch (err) { + log.warn(`block_change merge failed for ${chunkX},${chunkZ}: ${err.message}`); } } @@ -78,23 +141,45 @@ class ChunkCache { if (chunkX == null || chunkZ == null) return; const key = this._key(chunkX, chunkZ); const stored = this.chunks.get(key); - if (stored) { - if (!stored._multiBlockChanges) stored._multiBlockChanges = []; - stored._multiBlockChanges.push(structuredClone(data)); + if (!stored) return; + if (stored.packetData.x !== chunkX || stored.packetData.z !== chunkZ) return; + try { + const column = this._ensureColumn(stored); + applyMultiBlockChange(column, data); + this._syncPacketFromColumn(stored); + } catch (err) { + log.warn(`multi_block_change merge failed for ${chunkX},${chunkZ}: ${err.message}`); } } + /** + * @param {object} stored + * @returns {import('prismarine-chunk').Chunk} + */ + _ensureColumn(stored) { + if (!stored.column) { + stored.column = loadColumnFromMapChunk( + stored.packetData, + this.version, + this.getWorldBounds() + ); + stored.rawBuffer = null; + } + return stored.column; + } + + /** + * @param {object} stored + */ + _syncPacketFromColumn(stored) { + stored.packetData = exportMapChunkPacket(stored.column, stored.packetData); + stored.rawBuffer = null; + } + _buildChunkEntry(chunkData) { - const blockChanges = chunkData._blockChanges || []; - const multiBlockChanges = chunkData._multiBlockChanges || []; - const lightEntry = this.lights.get(this._key(chunkData.packetData.x, chunkData.packetData.z)); return { packetData: chunkData.packetData, rawMapChunkBuffer: chunkData.rawBuffer, - blockChanges, - multiBlockChanges, - lightData: lightEntry?.packetData ?? null, - rawLightBuffer: lightEntry?.rawBuffer ?? null, }; } @@ -107,7 +192,7 @@ class ChunkCache { const result = []; for (const [key, stored] of this.chunks) { const [x, z] = key.split(',').map(Number); - if (Math.abs(x - centerChunkX) > viewDistance || Math.abs(z - centerChunkZ) > viewDistance) { + if (!isChunkWithinViewDistance(centerChunkX, centerChunkZ, x, z, viewDistance)) { continue; } result.push(this._buildChunkEntry(stored)); @@ -145,14 +230,12 @@ class ChunkCache { while (this.chunks.size > this.maxChunks && this.accessOrder.length > 0) { const oldest = this.accessOrder.shift(); this.chunks.delete(oldest); - this.lights.delete(oldest); log.debug(`Evicted chunk ${oldest} (cache full: ${this.chunks.size}/${this.maxChunks})`); } } clear() { this.chunks.clear(); - this.lights.clear(); this.accessOrder = []; } } diff --git a/src/state/WorldStateCache.js b/src/state/WorldStateCache.js index 6585a2c..6ede9e9 100644 --- a/src/state/WorldStateCache.js +++ b/src/state/WorldStateCache.js @@ -1,5 +1,6 @@ const { createLogger } = require('../utils/logger'); const { ChunkCache } = require('./ChunkCache'); +const { worldBoundsForDimension, dimensionNameFromLogin } = require('./chunkMerge'); const { EntityCache } = require('./EntityCache'); const { PlayerStateCache } = require('./PlayerStateCache'); const { InventoryCache } = require('./InventoryCache'); @@ -18,7 +19,16 @@ function cloneConfigData(data) { */ class WorldStateCache { constructor(config) { - this.chunks = new ChunkCache(config.cache.maxChunks); + this._serverVersion = config.server?.version ?? '1.21.10'; + this._defaultViewDistance = config.bot?.viewDistance ?? 10; + this.chunks = new ChunkCache(config.cache.maxChunks, { + version: this._serverVersion, + getWorldBounds: () => + worldBoundsForDimension( + this._serverVersion, + dimensionNameFromLogin(this.player.loginPacket) + ), + }); this.entities = new EntityCache(); this.player = new PlayerStateCache(); this.inventory = new InventoryCache(); @@ -94,6 +104,40 @@ class WorldStateCache { return this.getRawConfigPacketsForReplay().length > 0; } + /** + * Chunk view center + distance for cache retention (matches server ChunkTrackingView). + * @returns {{ centerChunkX: number, centerChunkZ: number, viewDistance: number }|null} + */ + _getChunkViewContext() { + const viewDistance = + this.misc.viewDistance?.viewDistance ?? this._defaultViewDistance; + + if (this.misc.viewPosition?.chunkX != null && this.misc.viewPosition?.chunkZ != null) { + return { + centerChunkX: this.misc.viewPosition.chunkX, + centerChunkZ: this.misc.viewPosition.chunkZ, + viewDistance, + }; + } + + const pos = this.player.position; + if (pos?.x != null && pos?.z != null) { + return { + centerChunkX: Math.floor(pos.x / 16), + centerChunkZ: Math.floor(pos.z / 16), + viewDistance, + }; + } + + return null; + } + + _forgetChunksOutsideView() { + const view = this._getChunkViewContext(); + if (!view) return; + this.chunks.forgetOutsideView(view.centerChunkX, view.centerChunkZ, view.viewDistance); + } + /** * Process a server->client play packet and route to appropriate cache. * @param {string} name - packet name @@ -109,6 +153,7 @@ class WorldStateCache { break; case 'position': this.player.handlePosition(data); + this._forgetChunksOutsideView(); break; case 'update_health': this.player.handleUpdateHealth(data); @@ -135,10 +180,10 @@ class WorldStateCache { // Chunks case 'map_chunk': - this.chunks.handleMapChunk(data, buffer); + this.chunks.handleMapChunk(data, buffer, this._getChunkViewContext() ?? undefined); break; case 'update_light': - this.chunks.handleUpdateLight(data, buffer); + this.chunks.handleUpdateLight(data); break; case 'unload_chunk': this.chunks.handleUnloadChunk(data); @@ -282,12 +327,14 @@ class WorldStateCache { break; case 'update_view_distance': this.misc.handleUpdateViewDistance(data); + this._forgetChunksOutsideView(); break; case 'declare_commands': this.misc.handleDeclareCommands(data); break; case 'update_view_position': this.misc.handleUpdateViewPosition(data); + this._forgetChunksOutsideView(); break; case 'update_recipes': diff --git a/src/state/chunkMerge.js b/src/state/chunkMerge.js new file mode 100644 index 0000000..bfbc7f7 --- /dev/null +++ b/src/state/chunkMerge.js @@ -0,0 +1,193 @@ +const ChunkLoader = require('prismarine-chunk'); + +const DEFAULT_WORLD = { minY: -64, worldHeight: 384 }; + +/** + * prismarine-chunk uses smart-buffer, which requires Node Buffers. + * structuredClone (and some decoders) produce Uint8Array instead. + * @param {Buffer|Uint8Array|number[]|null|undefined} value + * @returns {Buffer|null} + */ +function asBuffer(value) { + if (value == null) return null; + if (Buffer.isBuffer(value)) return value; + if (value instanceof Uint8Array || Array.isArray(value)) return Buffer.from(value); + return null; +} + +/** + * Ensure map_chunk binary fields are Buffers after structuredClone / protocol decode. + * @param {object} packet + */ +function normalizeMapChunkPacket(packet) { + const chunkData = packet.chunkData ?? packet.data; + const buf = asBuffer(chunkData); + if (buf) { + if (packet.chunkData !== undefined) packet.chunkData = buf; + else packet.data = buf; + } + for (const key of ['skyLight', 'blockLight']) { + if (!Array.isArray(packet[key])) continue; + packet[key] = packet[key].map((section) => asBuffer(section) ?? section); + } + return packet; +} + +/** + * World bounds for prismarine-chunk from login dimension (defaults to overworld). + * @param {string} version + * @param {string} [dimensionName] + */ +function worldBoundsForDimension(version, dimensionName = 'overworld') { + try { + const registry = require('prismarine-registry')(version); + const dim = + registry.dimensionsByName[dimensionName] ?? + registry.dimensionsByName[dimensionName.replace(/^minecraft:/, '')]; + if (dim) { + return { minY: dim.minY, worldHeight: dim.height }; + } + } catch { + /* fall through */ + } + return { ...DEFAULT_WORLD }; +} + +/** + * @param {object} loginPacket + * @returns {string} + */ +function dimensionNameFromLogin(loginPacket) { + if (!loginPacket?.dimension) return 'overworld'; + const d = loginPacket.dimension; + if (typeof d === 'string') return d.replace(/^minecraft:/, ''); + const names = ['the_nether', 'overworld', 'the_end']; + return names[d] ?? 'overworld'; +} + +/** + * @param {string} version + * @param {{ minY: number, worldHeight: number }} worldBounds + * @returns {import('prismarine-chunk').Chunk} + */ +function createEmptyColumn(version, worldBounds) { + const Chunk = ChunkLoader(version); + return new Chunk(worldBounds); +} + +/** + * @param {object} packet - decoded map_chunk + * @param {string} version + * @param {{ minY: number, worldHeight: number }} worldBounds + */ +function loadColumnFromMapChunk(packet, version, worldBounds) { + const Chunk = ChunkLoader(version); + const column = new Chunk(worldBounds); + normalizeMapChunkPacket(packet); + const chunkData = asBuffer(packet.chunkData ?? packet.data); + if (chunkData?.length) { + column.load(chunkData); + } + if (packet.skyLight !== undefined) { + column.loadParsedLight( + packet.skyLight, + packet.blockLight, + packet.skyLightMask, + packet.blockLightMask, + packet.emptySkyLightMask, + packet.emptyBlockLightMask + ); + } + if (packet.blockEntities?.length) { + for (const blockEntity of packet.blockEntities) { + if (blockEntity.x !== undefined) { + column.setBlockEntity( + { x: blockEntity.x & 0xf, y: blockEntity.y, z: blockEntity.z & 0xf }, + blockEntity + ); + } + } + } + return column; +} + +/** + * @param {import('prismarine-chunk').Chunk} column + * @param {object} packet - decoded map_chunk template (heightmaps, blockEntities, x, z preserved) + */ +function exportMapChunkPacket(column, packet) { + const out = structuredClone(packet); + out.chunkData = column.dump(); + const light = column.dumpLight(); + out.skyLight = light.skyLight; + out.blockLight = light.blockLight; + out.skyLightMask = light.skyLightMask; + out.blockLightMask = light.blockLightMask; + out.emptySkyLightMask = light.emptySkyLightMask; + out.emptyBlockLightMask = light.emptyBlockLightMask; + return normalizeMapChunkPacket(out); +} + +/** + * Chunk columns use local X/Z (0–15) and world Y. See prismarine-world posInChunk. + * @param {import('prismarine-chunk').Chunk} column + * @param {{ location: { x: number, y: number, z: number }, type: number }} packet + */ +function applyBlockChange(column, packet) { + const { x, y, z } = packet.location; + column.setBlockStateId({ x: x & 0x0f, y, z: z & 0x0f }, packet.type); +} + +/** + * @param {import('prismarine-chunk').Chunk} column + * @param {object} packet - decoded update_light (same light fields as map_chunk) + */ +function applyUpdateLight(column, packet) { + column.loadParsedLight( + packet.skyLight ?? [], + packet.blockLight ?? [], + packet.skyLightMask, + packet.blockLightMask, + packet.emptySkyLightMask, + packet.emptyBlockLightMask + ); +} + +/** + * @param {import('prismarine-chunk').Chunk} column + * @param {{ chunkCoordinates?: { x: number, y: number, z: number }, records: number[] }} packet + */ +function applyMultiBlockChange(column, packet) { + const coords = packet.chunkCoordinates; + if (!coords || !packet.records?.length) return; + const sectionY = coords.y; + + for (const record of packet.records) { + const blockZ = (record >> 4) & 0x0f; + const blockX = (record >> 8) & 0x0f; + const blockY = record & 0x0f; + const stateId = record >> 12; + column.setBlockStateId( + { + x: blockX, + y: sectionY * 16 + blockY, + z: blockZ, + }, + stateId + ); + } +} + +module.exports = { + DEFAULT_WORLD, + asBuffer, + normalizeMapChunkPacket, + worldBoundsForDimension, + dimensionNameFromLogin, + createEmptyColumn, + loadColumnFromMapChunk, + exportMapChunkPacket, + applyBlockChange, + applyUpdateLight, + applyMultiBlockChange, +};