merge chunk updates
This commit is contained in:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
107
src/session/BotIdleBehavior.js
Normal file
107
src/session/BotIdleBehavior.js
Normal 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 };
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
193
src/state/chunkMerge.js
Normal 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 (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,
|
||||
};
|
||||
Reference in New Issue
Block a user