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 |
|
| 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. |
|
| **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. |
|
| **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. |
|
| **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.
|
* **`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.
|
* `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.
|
* **`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.
|
* **`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`
|
#### 🧩 [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 |
|
| Method | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `constructor(maxChunks)` | Initializes chunk storage with LRU capacity limit. |
|
| `constructor(maxChunks)` | Initializes chunk storage with LRU capacity limit. |
|
||||||
| `_key(x, z)` | Computes string key for map lookups. |
|
| `_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. |
|
| `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. |
|
| `handleUnloadChunk(data)` | Evicts chunk records from cache. |
|
||||||
| `handleBlockChange(data)` | Appends single block changes as an overlay. |
|
| `handleBlockChange(data)` | Merges single block changes into the cached chunk column. |
|
||||||
| `handleMultiBlockChange(data)` | Appends multiblock edits as an overlay. |
|
| `handleMultiBlockChange(data)` | Merges section block updates into the cached chunk column. |
|
||||||
| `_buildChunkEntry(chunkData)` | Assembles raw data, block edits, and light arrays. |
|
| `_buildChunkEntry(chunkData)` | Assembles raw data, block edits, and light arrays. |
|
||||||
| `getChunksForReplay(centerChunkX, centerChunkZ, viewDistance)` | Returns cached chunks sorting closest-first. |
|
| `getChunksForReplay(centerChunkX, centerChunkZ, viewDistance)` | Returns cached chunks sorting closest-first. |
|
||||||
| `hasChunkAtBlock(x, z)` | Verifies if a chunk is loaded. |
|
| `hasChunkAtBlock(x, z)` | Verifies if a chunk is loaded. |
|
||||||
@@ -459,7 +459,7 @@ Replays the cached world state to a connecting client.
|
|||||||
|
|
||||||
| Function | Description |
|
| 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`
|
#### 📄 [replayHelpers.js](file:///home/seb/flayerproxy/src/replay/replayHelpers.js) `functions`
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,13 @@
|
|||||||
"onlineMode": false,
|
"onlineMode": false,
|
||||||
"upstreamAuth": "microsoft",
|
"upstreamAuth": "microsoft",
|
||||||
"logDir": "logs/sniffer",
|
"logDir": "logs/sniffer",
|
||||||
|
"chunkLogDir": "logs/sniffer/chunks",
|
||||||
"includePayload": true
|
"includePayload": true
|
||||||
},
|
},
|
||||||
"bot": {
|
"bot": {
|
||||||
"antiAfk": true,
|
"antiAfk": true,
|
||||||
"antiAfkInterval": 30000,
|
"antiAfkMinInterval": 1500,
|
||||||
|
"antiAfkMaxInterval": 6000,
|
||||||
"viewDistance": 10
|
"viewDistance": 10
|
||||||
},
|
},
|
||||||
"cache": {
|
"cache": {
|
||||||
|
|||||||
@@ -24,7 +24,22 @@ function loadConfig() {
|
|||||||
|
|
||||||
// Apply defaults
|
// Apply defaults
|
||||||
config.proxy = Object.assign({ host: '0.0.0.0', port: 25566, onlineMode: false, maxClients: 1 }, config.proxy);
|
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.cache = Object.assign({ maxChunks: 1024, trackEntities: true }, config.cache);
|
||||||
config.auth.auth = config.auth.auth || 'offline';
|
config.auth.auth = config.auth.auth || 'offline';
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ const { CHUNK_YIELD_EVERY, yieldEventLoop } = require('./replayHelpers');
|
|||||||
const log = createLogger('StateReplayer');
|
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(string, object): void} write - named packet writer
|
||||||
* @param {function(Buffer, string): void} writeRaw - raw buffer 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})...`);
|
log.info(`Replaying ${chunks.length} chunks around (${center.chunkX}, ${center.chunkZ})...`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let lightCount = 0;
|
|
||||||
let rawChunkCount = 0;
|
let rawChunkCount = 0;
|
||||||
for (let i = 0; i < chunks.length; i++) {
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
const chunk = chunks[i];
|
const chunk = chunks[i];
|
||||||
@@ -37,20 +37,6 @@ async function replayChunks(write, writeRaw, chunks, center, totalCached) {
|
|||||||
} else {
|
} else {
|
||||||
write('map_chunk', chunk.packetData);
|
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) {
|
if ((i + 1) % CHUNK_YIELD_EVERY === 0) {
|
||||||
await yieldEventLoop();
|
await yieldEventLoop();
|
||||||
@@ -61,9 +47,6 @@ async function replayChunks(write, writeRaw, chunks, center, totalCached) {
|
|||||||
if (rawChunkCount > 0) {
|
if (rawChunkCount > 0) {
|
||||||
log.info(`Replayed ${rawChunkCount}/${chunks.length} chunks from raw buffers`);
|
log.info(`Replayed ${rawChunkCount}/${chunks.length} chunks from raw buffers`);
|
||||||
}
|
}
|
||||||
if (lightCount > 0) {
|
|
||||||
log.info(`Replayed ${lightCount} update_light packets`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { replayChunks };
|
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 { createLogger } = require('../utils/logger');
|
||||||
const { relayClientMovement, syncProxyClientPosition, confirmServerPosition } = require('./MovementRelay');
|
const { relayClientMovement, syncProxyClientPosition, confirmServerPosition } = require('./MovementRelay');
|
||||||
const { ChunkAckManager } = require('./ChunkAckManager');
|
const { ChunkAckManager } = require('./ChunkAckManager');
|
||||||
|
const { BotIdleBehavior } = require('./BotIdleBehavior');
|
||||||
|
|
||||||
const log = createLogger('ServerConn');
|
const log = createLogger('ServerConn');
|
||||||
|
|
||||||
@@ -26,6 +27,8 @@ class ServerConnection extends EventEmitter {
|
|||||||
/** True after first spawn; later spawns are respawns on the same connection */
|
/** True after first spawn; later spawns are respawns on the same connection */
|
||||||
this._initialSpawnDone = false;
|
this._initialSpawnDone = false;
|
||||||
this._chunkAck = new ChunkAckManager();
|
this._chunkAck = new ChunkAckManager();
|
||||||
|
/** @type {BotIdleBehavior|null} */
|
||||||
|
this._idleBehavior = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +37,8 @@ class ServerConnection extends EventEmitter {
|
|||||||
connect() {
|
connect() {
|
||||||
log.info(`Connecting to ${this.config.server.host}:${this.config.server.port} as ${this.config.auth.username}...`);
|
log.info(`Connecting to ${this.config.server.host}:${this.config.server.port} as ${this.config.auth.username}...`);
|
||||||
this._initialSpawnDone = false;
|
this._initialSpawnDone = false;
|
||||||
|
this._idleBehavior?.stop();
|
||||||
|
this._idleBehavior = null;
|
||||||
|
|
||||||
this.bot = mineflayer.createBot({
|
this.bot = mineflayer.createBot({
|
||||||
host: this.config.server.host,
|
host: this.config.server.host,
|
||||||
@@ -94,6 +99,12 @@ class ServerConnection extends EventEmitter {
|
|||||||
this.bot.on('spawn', () => {
|
this.bot.on('spawn', () => {
|
||||||
log.info('Bot spawned in world');
|
log.info('Bot spawned in world');
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
|
if (!this._idleBehavior) {
|
||||||
|
this._idleBehavior = new BotIdleBehavior(this.bot, this.config.bot);
|
||||||
|
}
|
||||||
|
if (this._botControlEnabled) {
|
||||||
|
this._idleBehavior.start();
|
||||||
|
}
|
||||||
if (!this._initialSpawnDone) {
|
if (!this._initialSpawnDone) {
|
||||||
this._initialSpawnDone = true;
|
this._initialSpawnDone = true;
|
||||||
this.emit('connected');
|
this.emit('connected');
|
||||||
@@ -105,6 +116,7 @@ class ServerConnection extends EventEmitter {
|
|||||||
this.bot.on('end', (reason) => {
|
this.bot.on('end', (reason) => {
|
||||||
log.warn(`Bot disconnected: ${reason}`);
|
log.warn(`Bot disconnected: ${reason}`);
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
|
this._idleBehavior?.stop();
|
||||||
this.emit('disconnected', reason);
|
this.emit('disconnected', reason);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,8 +172,10 @@ class ServerConnection extends EventEmitter {
|
|||||||
if (enabled) {
|
if (enabled) {
|
||||||
log.info('Bot control ENABLED (bot mode)');
|
log.info('Bot control ENABLED (bot mode)');
|
||||||
if (this.bot) this.bot.physicsEnabled = true;
|
if (this.bot) this.bot.physicsEnabled = true;
|
||||||
|
this._idleBehavior?.start();
|
||||||
} else {
|
} else {
|
||||||
log.info('Bot control DISABLED (client taking over)');
|
log.info('Bot control DISABLED (client taking over)');
|
||||||
|
this._idleBehavior?.stop();
|
||||||
if (this.bot) {
|
if (this.bot) {
|
||||||
this.bot.physicsEnabled = false;
|
this.bot.physicsEnabled = false;
|
||||||
try {
|
try {
|
||||||
@@ -248,6 +262,7 @@ class ServerConnection extends EventEmitter {
|
|||||||
* Gracefully close the connection.
|
* Gracefully close the connection.
|
||||||
*/
|
*/
|
||||||
disconnect() {
|
disconnect() {
|
||||||
|
this._idleBehavior?.stop();
|
||||||
if (this.bot) {
|
if (this.bot) {
|
||||||
this.bot.quit();
|
this.bot.quit();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class MitmProxy {
|
|||||||
);
|
);
|
||||||
log.info(`Upstream auth: ${sniffer.upstreamAuth || 'microsoft'}`);
|
log.info(`Upstream auth: ${sniffer.upstreamAuth || 'microsoft'}`);
|
||||||
log.info(`Logs: ${sniffer.logDir}`);
|
log.info(`Logs: ${sniffer.logDir}`);
|
||||||
|
if (sniffer.chunkLogDir) log.info(`Chunk logs: ${sniffer.chunkLogDir}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.server.on('error', (err) => {
|
this.server.on('error', (err) => {
|
||||||
@@ -76,6 +77,7 @@ class MitmProxy {
|
|||||||
|
|
||||||
const packetLog = new PacketLog({
|
const packetLog = new PacketLog({
|
||||||
logDir: this.config.sniffer.logDir,
|
logDir: this.config.sniffer.logDir,
|
||||||
|
chunkLogDir: this.config.sniffer.chunkLogDir,
|
||||||
sessionId: `session-${Date.now()}`,
|
sessionId: `session-${Date.now()}`,
|
||||||
clientUsername: 'unknown',
|
clientUsername: 'unknown',
|
||||||
server: `${this.config.server.host}:${this.config.server.port}`,
|
server: `${this.config.server.host}:${this.config.server.port}`,
|
||||||
|
|||||||
@@ -12,6 +12,21 @@ const LARGE_PACKETS = new Set([
|
|||||||
'custom_payload',
|
'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.
|
* Append-only JSONL packet log for login / handoff analysis.
|
||||||
*/
|
*/
|
||||||
@@ -19,6 +34,7 @@ class PacketLog {
|
|||||||
/**
|
/**
|
||||||
* @param {object} opts
|
* @param {object} opts
|
||||||
* @param {string} opts.logDir
|
* @param {string} opts.logDir
|
||||||
|
* @param {string} [opts.chunkLogDir] - defaults to logDir/chunks
|
||||||
* @param {string} opts.sessionId
|
* @param {string} opts.sessionId
|
||||||
* @param {boolean} [opts.includePayload=true]
|
* @param {boolean} [opts.includePayload=true]
|
||||||
*/
|
*/
|
||||||
@@ -38,19 +54,35 @@ class PacketLog {
|
|||||||
this.filePath = file;
|
this.filePath = file;
|
||||||
this.spillDir = this._spillDir;
|
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',
|
type: 'session_start',
|
||||||
sessionId: opts.sessionId,
|
sessionId: opts.sessionId,
|
||||||
clientUsername: opts.clientUsername,
|
clientUsername: opts.clientUsername,
|
||||||
server: opts.server,
|
server: opts.server,
|
||||||
version: opts.version,
|
version: opts.version,
|
||||||
});
|
};
|
||||||
|
this.writeMeta(sessionStart);
|
||||||
|
this._writeChunkMeta(sessionStart);
|
||||||
}
|
}
|
||||||
|
|
||||||
writeMeta(record) {
|
writeMeta(record) {
|
||||||
this._write({ ...record, t: new Date().toISOString() });
|
this._write({ ...record, t: new Date().toISOString() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_writeChunkMeta(record) {
|
||||||
|
this._writeChunk({ ...record, t: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {'C2S'|'S2C'} dir
|
* @param {'C2S'|'S2C'} dir
|
||||||
* @param {object} meta - minecraft-protocol packet meta
|
* @param {object} meta - minecraft-protocol packet meta
|
||||||
@@ -125,26 +157,50 @@ class PacketLog {
|
|||||||
entry.summary = summarizeLargePacket(meta.name, data, rawBuffer);
|
entry.summary = summarizeLargePacket(meta.name, data, rawBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (CHUNK_PACKETS.has(meta.name)) {
|
||||||
|
this._writeChunk(entry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this._write(entry);
|
this._write(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
close(reason) {
|
close(reason) {
|
||||||
if (this._closed) return;
|
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._closed = true;
|
||||||
this._stream.end();
|
this._stream.end();
|
||||||
|
this._chunkStream.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
_write(obj) {
|
_write(obj) {
|
||||||
if (this._closed) return;
|
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);
|
const line = JSON.stringify(obj);
|
||||||
if (line.length + 1 <= MAX_INLINE_LINE) {
|
if (line.length + 1 <= MAX_INLINE_LINE) {
|
||||||
this._writeRaw(line);
|
writeRaw(line);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const spillFile = `line-${String(++this._spillCount).padStart(6, '0')}.jsonl`;
|
const spillFile = `line-${String(spillCountRef()).padStart(6, '0')}.jsonl`;
|
||||||
fs.writeFileSync(path.join(this._spillDir, spillFile), `${line}\n`);
|
fs.writeFileSync(path.join(spillDir, spillFile), `${line}\n`);
|
||||||
this._writeRaw(JSON.stringify(buildSpillRef(obj, spillFile)));
|
writeRaw(JSON.stringify(buildSpillRef(obj, spillFile)));
|
||||||
}
|
}
|
||||||
|
|
||||||
_writeRaw(line) {
|
_writeRaw(line) {
|
||||||
@@ -153,6 +209,13 @@ class PacketLog {
|
|||||||
this._stream.once('drain', () => {});
|
this._stream.once('drain', () => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_writeChunkRaw(line) {
|
||||||
|
const ok = this._chunkStream.write(`${line}\n`);
|
||||||
|
if (!ok) {
|
||||||
|
this._chunkStream.once('drain', () => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPreview(obj) {
|
function buildPreview(obj) {
|
||||||
@@ -247,4 +310,4 @@ function summarizeLargePacket(name, data, rawBuffer) {
|
|||||||
return summary;
|
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('Join the server in-game (not just refresh the server list)');
|
||||||
log.info(`Logs: ${sniffer.logDir}`);
|
log.info(`Logs: ${sniffer.logDir}`);
|
||||||
|
if (sniffer.chunkLogDir) log.info(`Chunk logs: ${sniffer.chunkLogDir}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ class TransparentProxy {
|
|||||||
let clientUsername = 'unknown';
|
let clientUsername = 'unknown';
|
||||||
const packetLog = new PacketLog({
|
const packetLog = new PacketLog({
|
||||||
logDir: sniffer.logDir,
|
logDir: sniffer.logDir,
|
||||||
|
chunkLogDir: sniffer.chunkLogDir,
|
||||||
sessionId: `session-${Date.now()}`,
|
sessionId: `session-${Date.now()}`,
|
||||||
clientUsername,
|
clientUsername,
|
||||||
server: `${targetHost}:${targetPort}`,
|
server: `${targetHost}:${targetPort}`,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ try {
|
|||||||
onlineMode: false,
|
onlineMode: false,
|
||||||
upstreamAuth: 'microsoft',
|
upstreamAuth: 'microsoft',
|
||||||
logDir: path.join(__dirname, '..', '..', 'logs', 'sniffer'),
|
logDir: path.join(__dirname, '..', '..', 'logs', 'sniffer'),
|
||||||
|
chunkLogDir: path.join(__dirname, '..', '..', 'logs', 'sniffer', 'chunks'),
|
||||||
includePayload: true,
|
includePayload: true,
|
||||||
},
|
},
|
||||||
config.sniffer,
|
config.sniffer,
|
||||||
@@ -29,6 +30,7 @@ try {
|
|||||||
log.info(`Client online-mode: ${config.sniffer.onlineMode}`);
|
log.info(`Client online-mode: ${config.sniffer.onlineMode}`);
|
||||||
log.info(`Upstream auth: ${config.sniffer.upstreamAuth}`);
|
log.info(`Upstream auth: ${config.sniffer.upstreamAuth}`);
|
||||||
log.info(`Logs: ${path.resolve(config.sniffer.logDir)}`);
|
log.info(`Logs: ${path.resolve(config.sniffer.logDir)}`);
|
||||||
|
log.info(`Chunk logs: ${path.resolve(config.sniffer.chunkLogDir)}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error(err.message);
|
log.error(err.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -1,17 +1,32 @@
|
|||||||
const { createLogger } = require('../utils/logger');
|
const { createLogger } = require('../utils/logger');
|
||||||
|
const { isChunkWithinViewDistance } = require('../utils/positionSync');
|
||||||
|
const {
|
||||||
|
worldBoundsForDimension,
|
||||||
|
loadColumnFromMapChunk,
|
||||||
|
exportMapChunkPacket,
|
||||||
|
normalizeMapChunkPacket,
|
||||||
|
applyBlockChange,
|
||||||
|
applyUpdateLight,
|
||||||
|
applyMultiBlockChange,
|
||||||
|
} = require('./chunkMerge');
|
||||||
|
|
||||||
const log = createLogger('ChunkCache');
|
const log = createLogger('ChunkCache');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Caches chunk column data keyed by "x,z".
|
* 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 {
|
class ChunkCache {
|
||||||
constructor(maxChunks = 1024) {
|
/**
|
||||||
|
* @param {number} maxChunks
|
||||||
|
* @param {{ version?: string, getWorldBounds?: () => { minY: number, worldHeight: number } }} [options]
|
||||||
|
*/
|
||||||
|
constructor(maxChunks = 1024, options = {}) {
|
||||||
this.maxChunks = maxChunks;
|
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();
|
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 */
|
/** Track access order for LRU eviction */
|
||||||
this.accessOrder = [];
|
this.accessOrder = [];
|
||||||
}
|
}
|
||||||
@@ -22,27 +37,75 @@ class ChunkCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store a chunk from a map_chunk packet.
|
* Remove cached chunks outside the server's ChunkTrackingView for this center.
|
||||||
* We store the entire packet data object so we can replay it verbatim.
|
* @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);
|
const key = this._key(data.x, data.z);
|
||||||
this.chunks.set(key, {
|
this.chunks.set(key, {
|
||||||
packetData: structuredClone(data),
|
packetData: normalizeMapChunkPacket(structuredClone(data)),
|
||||||
rawBuffer: rawBuffer ? Buffer.from(rawBuffer) : null,
|
rawBuffer: rawBuffer ? Buffer.from(rawBuffer) : null,
|
||||||
|
column: null,
|
||||||
});
|
});
|
||||||
this._touch(key);
|
this._touch(key);
|
||||||
this._evictIfNeeded();
|
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);
|
const key = this._key(data.chunkX, data.chunkZ);
|
||||||
this.lights.set(key, {
|
const stored = this.chunks.get(key);
|
||||||
packetData: structuredClone(data),
|
if (!stored) return;
|
||||||
rawBuffer: rawBuffer ? Buffer.from(rawBuffer) : null,
|
try {
|
||||||
});
|
const column = this._ensureColumn(stored);
|
||||||
if (this.chunks.has(key)) {
|
applyUpdateLight(column, data);
|
||||||
|
this._syncPacketFromColumn(stored);
|
||||||
this._touch(key);
|
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) {
|
handleUnloadChunk(data) {
|
||||||
const key = this._key(data.chunkX, data.chunkZ);
|
const key = this._key(data.chunkX, data.chunkZ);
|
||||||
this.chunks.delete(key);
|
this.chunks.delete(key);
|
||||||
this.lights.delete(key);
|
|
||||||
this.accessOrder = this.accessOrder.filter(k => k !== key);
|
this.accessOrder = this.accessOrder.filter(k => k !== key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a single block_change to the cached chunk data.
|
* Merge block_change into the cached chunk column.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
handleBlockChange(data) {
|
handleBlockChange(data) {
|
||||||
const chunkX = Math.floor(data.location.x / 16);
|
const chunkX = Math.floor(data.location.x / 16);
|
||||||
const chunkZ = Math.floor(data.location.z / 16);
|
const chunkZ = Math.floor(data.location.z / 16);
|
||||||
const key = this._key(chunkX, chunkZ);
|
const stored = this.chunks.get(this._key(chunkX, chunkZ));
|
||||||
const stored = this.chunks.get(key);
|
if (!stored) return;
|
||||||
if (stored) {
|
try {
|
||||||
if (!stored._blockChanges) stored._blockChanges = [];
|
const column = this._ensureColumn(stored);
|
||||||
stored._blockChanges.push(structuredClone(data));
|
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;
|
if (chunkX == null || chunkZ == null) return;
|
||||||
const key = this._key(chunkX, chunkZ);
|
const key = this._key(chunkX, chunkZ);
|
||||||
const stored = this.chunks.get(key);
|
const stored = this.chunks.get(key);
|
||||||
if (stored) {
|
if (!stored) return;
|
||||||
if (!stored._multiBlockChanges) stored._multiBlockChanges = [];
|
if (stored.packetData.x !== chunkX || stored.packetData.z !== chunkZ) return;
|
||||||
stored._multiBlockChanges.push(structuredClone(data));
|
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) {
|
_buildChunkEntry(chunkData) {
|
||||||
const blockChanges = chunkData._blockChanges || [];
|
|
||||||
const multiBlockChanges = chunkData._multiBlockChanges || [];
|
|
||||||
const lightEntry = this.lights.get(this._key(chunkData.packetData.x, chunkData.packetData.z));
|
|
||||||
return {
|
return {
|
||||||
packetData: chunkData.packetData,
|
packetData: chunkData.packetData,
|
||||||
rawMapChunkBuffer: chunkData.rawBuffer,
|
rawMapChunkBuffer: chunkData.rawBuffer,
|
||||||
blockChanges,
|
|
||||||
multiBlockChanges,
|
|
||||||
lightData: lightEntry?.packetData ?? null,
|
|
||||||
rawLightBuffer: lightEntry?.rawBuffer ?? null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +192,7 @@ class ChunkCache {
|
|||||||
const result = [];
|
const result = [];
|
||||||
for (const [key, stored] of this.chunks) {
|
for (const [key, stored] of this.chunks) {
|
||||||
const [x, z] = key.split(',').map(Number);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
result.push(this._buildChunkEntry(stored));
|
result.push(this._buildChunkEntry(stored));
|
||||||
@@ -145,14 +230,12 @@ class ChunkCache {
|
|||||||
while (this.chunks.size > this.maxChunks && this.accessOrder.length > 0) {
|
while (this.chunks.size > this.maxChunks && this.accessOrder.length > 0) {
|
||||||
const oldest = this.accessOrder.shift();
|
const oldest = this.accessOrder.shift();
|
||||||
this.chunks.delete(oldest);
|
this.chunks.delete(oldest);
|
||||||
this.lights.delete(oldest);
|
|
||||||
log.debug(`Evicted chunk ${oldest} (cache full: ${this.chunks.size}/${this.maxChunks})`);
|
log.debug(`Evicted chunk ${oldest} (cache full: ${this.chunks.size}/${this.maxChunks})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.chunks.clear();
|
this.chunks.clear();
|
||||||
this.lights.clear();
|
|
||||||
this.accessOrder = [];
|
this.accessOrder = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const { createLogger } = require('../utils/logger');
|
const { createLogger } = require('../utils/logger');
|
||||||
const { ChunkCache } = require('./ChunkCache');
|
const { ChunkCache } = require('./ChunkCache');
|
||||||
|
const { worldBoundsForDimension, dimensionNameFromLogin } = require('./chunkMerge');
|
||||||
const { EntityCache } = require('./EntityCache');
|
const { EntityCache } = require('./EntityCache');
|
||||||
const { PlayerStateCache } = require('./PlayerStateCache');
|
const { PlayerStateCache } = require('./PlayerStateCache');
|
||||||
const { InventoryCache } = require('./InventoryCache');
|
const { InventoryCache } = require('./InventoryCache');
|
||||||
@@ -18,7 +19,16 @@ function cloneConfigData(data) {
|
|||||||
*/
|
*/
|
||||||
class WorldStateCache {
|
class WorldStateCache {
|
||||||
constructor(config) {
|
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.entities = new EntityCache();
|
||||||
this.player = new PlayerStateCache();
|
this.player = new PlayerStateCache();
|
||||||
this.inventory = new InventoryCache();
|
this.inventory = new InventoryCache();
|
||||||
@@ -94,6 +104,40 @@ class WorldStateCache {
|
|||||||
return this.getRawConfigPacketsForReplay().length > 0;
|
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.
|
* Process a server->client play packet and route to appropriate cache.
|
||||||
* @param {string} name - packet name
|
* @param {string} name - packet name
|
||||||
@@ -109,6 +153,7 @@ class WorldStateCache {
|
|||||||
break;
|
break;
|
||||||
case 'position':
|
case 'position':
|
||||||
this.player.handlePosition(data);
|
this.player.handlePosition(data);
|
||||||
|
this._forgetChunksOutsideView();
|
||||||
break;
|
break;
|
||||||
case 'update_health':
|
case 'update_health':
|
||||||
this.player.handleUpdateHealth(data);
|
this.player.handleUpdateHealth(data);
|
||||||
@@ -135,10 +180,10 @@ class WorldStateCache {
|
|||||||
|
|
||||||
// Chunks
|
// Chunks
|
||||||
case 'map_chunk':
|
case 'map_chunk':
|
||||||
this.chunks.handleMapChunk(data, buffer);
|
this.chunks.handleMapChunk(data, buffer, this._getChunkViewContext() ?? undefined);
|
||||||
break;
|
break;
|
||||||
case 'update_light':
|
case 'update_light':
|
||||||
this.chunks.handleUpdateLight(data, buffer);
|
this.chunks.handleUpdateLight(data);
|
||||||
break;
|
break;
|
||||||
case 'unload_chunk':
|
case 'unload_chunk':
|
||||||
this.chunks.handleUnloadChunk(data);
|
this.chunks.handleUnloadChunk(data);
|
||||||
@@ -282,12 +327,14 @@ class WorldStateCache {
|
|||||||
break;
|
break;
|
||||||
case 'update_view_distance':
|
case 'update_view_distance':
|
||||||
this.misc.handleUpdateViewDistance(data);
|
this.misc.handleUpdateViewDistance(data);
|
||||||
|
this._forgetChunksOutsideView();
|
||||||
break;
|
break;
|
||||||
case 'declare_commands':
|
case 'declare_commands':
|
||||||
this.misc.handleDeclareCommands(data);
|
this.misc.handleDeclareCommands(data);
|
||||||
break;
|
break;
|
||||||
case 'update_view_position':
|
case 'update_view_position':
|
||||||
this.misc.handleUpdateViewPosition(data);
|
this.misc.handleUpdateViewPosition(data);
|
||||||
|
this._forgetChunksOutsideView();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'update_recipes':
|
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