merge chunk updates

This commit is contained in:
sebseb7
2026-05-21 09:14:47 +02:00
parent fe1d362ea7
commit bf2bed5599
14 changed files with 591 additions and 76 deletions

View File

@@ -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 15006000).
* **`cache`**: Memory usage controls for caching the world.
---

View File

@@ -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`

View File

@@ -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": {

View File

@@ -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';

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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();
}

View File

@@ -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}`,

View File

@@ -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 };

View File

@@ -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}`,

View File

@@ -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);

View File

@@ -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<string, object>} key "x,z" -> raw packet data */
this.version = options.version ?? '1.21.10';
this.getWorldBounds = options.getWorldBounds ?? (() => worldBoundsForDimension(this.version));
/** @type {Map<string, object>} key "x,z" -> stored chunk */
this.chunks = new Map();
/** @type {Map<string, object>} 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 = [];
}
}

View File

@@ -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':

193
src/state/chunkMerge.js Normal file
View File

@@ -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 (015) 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,
};