This commit is contained in:
sebseb7
2026-05-20 18:20:59 +02:00
commit db2edb66ff
46 changed files with 7296 additions and 0 deletions

34
src/config.js Normal file
View File

@@ -0,0 +1,34 @@
const fs = require('fs');
const path = require('path');
const CONFIG_PATH = path.join(__dirname, '..', 'config.json');
function loadConfig() {
if (!fs.existsSync(CONFIG_PATH)) {
throw new Error(`Config file not found: ${CONFIG_PATH}`);
}
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
const config = JSON.parse(raw);
// Validate required fields
if (!config.server || !config.server.host || !config.server.port) {
throw new Error('config.json must specify server.host and server.port');
}
if (!config.server.version) {
throw new Error('config.json must specify server.version');
}
if (!config.auth || !config.auth.username) {
throw new Error('config.json must specify auth.username');
}
// 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.cache = Object.assign({ maxChunks: 1024, trackEntities: true }, config.cache);
config.auth.auth = config.auth.auth || 'offline';
return config;
}
module.exports = { loadConfig };

View File

@@ -0,0 +1,11 @@
/** Play packets that must be forwarded with writeRaw to survive NBT/chunk re-serialization */
const RAW_FORWARD_PACKETS = new Set([
'map_chunk',
'update_light',
'unload_chunk',
'chunk_batch_start',
'chunk_batch_finished',
'update_view_position',
]);
module.exports = { RAW_FORWARD_PACKETS };

49
src/index.js Normal file
View File

@@ -0,0 +1,49 @@
const { loadConfig } = require('./config');
const { SessionManager } = require('./session/SessionManager');
const { createLogger } = require('./utils/logger');
const log = createLogger('Main');
// ─── Banner ──────────────────────────────────────────
console.log(`
\x1b[33m _____ _ ____
| ___| | __ _ _ _ ___ _ _| _ \\ _ __ _____ ___ _
| |_ | |/ _\` | | | |/ _ \\ '__| |_) | '__/ _ \\ \\/ / | | |
| _| | | (_| | |_| | __/ | | __/| | | (_) > <| |_| |
|_| |_|\\__,_|\\__, |\\___|_| |_| |_| \\___/_/\\_\\\\__, |
|___/ |___/ \x1b[0m
`);
// ─── Load config ─────────────────────────────────────
let config;
try {
config = loadConfig();
log.info(`Loaded config: server=${config.server.host}:${config.server.port} version=${config.server.version}`);
log.info(`Proxy will listen on port ${config.proxy.port}`);
} catch (err) {
log.error(`Failed to load config: ${err.message}`);
process.exit(1);
}
// ─── Start session manager ──────────────────────────
const session = new SessionManager(config);
session.start();
// ─── Graceful shutdown ──────────────────────────────
function shutdown(signal) {
log.info(`Received ${signal}, shutting down...`);
session.stop();
setTimeout(() => process.exit(0), 2000);
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('uncaughtException', (err) => {
log.error(`Uncaught exception: ${err.message}`);
log.error(err.stack);
});
process.on('unhandledRejection', (reason) => {
log.error(`Unhandled rejection: ${reason}`);
});

292
src/proxy/ClientBridge.js Normal file
View File

@@ -0,0 +1,292 @@
const { createLogger } = require('../utils/logger');
const { RAW_FORWARD_PACKETS } = require('../constants/rawPackets');
const {
CHAT_SESSION_PACKETS,
disableInboundChatValidation,
relayClientChatAsUpstream,
} = require('../utils/chatRelay');
const {
chunkCoordsFromBlock,
updateClientViewPosition,
ensureClientViewIncludesChunk,
} = require('../utils/positionSync');
const log = createLogger('ClientBridge');
/**
* Manages bidirectional packet forwarding between a connected Java client
* and the upstream server connection.
*
* In ClientMode: client→server and server→client packets are piped through.
* The WorldStateCache continues to be updated from server packets.
*/
class ClientBridge {
/**
* @param {object} client - The minecraft-protocol client from the proxy server
* @param {import('../session/ServerConnection').ServerConnection} serverConn
* @param {import('../state/WorldStateCache').WorldStateCache} worldState
*/
constructor(client, serverConn, worldState) {
this.client = client;
this.serverConn = serverConn;
this.worldState = worldState;
this.active = false;
this._clientPacketHandler = null;
this._serverPacketHandler = null;
this._clientEndHandler = null;
/** UUIDs the client has seen via player_info add_player */
this.knownPlayerUuids = new Set(worldState.misc.getKnownPlayerUuids());
// Packets the mineflayer bot already handles on the upstream connection.
// Forwarding them again from the proxy client causes duplicate responses and kicks.
this._blockedClientPackets = new Set([
'keep_alive',
'teleport_confirm',
'message_acknowledgement',
]);
/** Must reach the server for chunk streaming (PlayerChunkSender / hasClientLoaded) */
this._priorityClientPackets = new Set([
'chunk_batch_received',
'player_loaded',
]);
/** Block movement until client matches server (set true after syncProxyClientPosition) */
this._movementSynced = false;
this._movementPackets = new Set([
'position',
'position_look',
'look',
'flying',
'vehicle_move',
'steer_vehicle',
'paddle_boat',
]);
// Packets from server that should NOT be forwarded to client
// (these are internal to the bot)
this._blockedServerPackets = new Set([]);
/** Proxy client view center — map_chunk outside this range is ignored by vanilla */
this._clientView = { chunkX: null, chunkZ: null };
/** Last block coords from client movement (for view center ahead of server) */
this._lastClientBlock = { x: null, z: null };
}
_getViewDistance() {
return (
this.worldState.misc.viewDistance?.viewDistance ??
this.serverConn.config?.bot?.viewDistance ??
10
);
}
/**
* ClientboundSetChunkCacheCenterPacket — vanilla ignores map_chunk outside this center.
* Use the moving player's block coords (client packet), not a lagging bot read.
*/
_syncClientViewFromBlockCoords(blockX, blockZ) {
if (blockX == null || blockZ == null) return;
const { chunkX, chunkZ } = chunkCoordsFromBlock(blockX, blockZ);
updateClientViewPosition(this.client, chunkX, chunkZ, this._clientView);
this._lastClientBlock.x = blockX;
this._lastClientBlock.z = blockZ;
}
_syncClientViewFromBot() {
const pos = this.serverConn.bot?.entity?.position;
if (!pos) return;
this._syncClientViewFromBlockCoords(pos.x, pos.z);
}
/** Block coords to anchor view center (client ahead of bot, else bot). */
_playerBlockCoordsForView() {
if (this._lastClientBlock.x != null) {
return { x: this._lastClientBlock.x, z: this._lastClientBlock.z };
}
const pos = this.serverConn.bot?.entity?.position;
if (!pos) return null;
return { x: pos.x, z: pos.z };
}
/**
* If a map_chunk would be outside the client's cache radius, send center first.
* Matches server ChunkMap sending ClientboundSetChunkCacheCenterPacket before batches.
*/
_ensureViewIncludesChunk(chunkX, chunkZ) {
const player = this._playerBlockCoordsForView();
if (!player) return;
ensureClientViewIncludesChunk(
this.client,
player.x,
player.z,
chunkX,
chunkZ,
this._getViewDistance(),
this._clientView
);
}
/**
* Allow client movement packets to reach the server.
*/
enableMovement() {
this._movementSynced = true;
this._syncClientViewFromBot();
log.info('Client movement forwarding enabled');
}
start() {
if (this.active) return;
this.active = true;
this.serverConn.setClientDrivesChunkBatchAck(true);
this.serverConn.flushChunkBatchAck();
log.info('Client bridge started — forwarding packets');
disableInboundChatValidation(this.client);
// Client → Server
this._clientPacketHandler = (data, meta) => {
if (!this.active) return;
if (meta.state !== 'play') return;
if (this._blockedClientPackets.has(meta.name)) return;
if (!this._movementSynced && this._movementPackets.has(meta.name)) return;
try {
if (this._movementPackets.has(meta.name)) {
// Update view center from client coords before relay — server sends center on
// chunk boundary (ChunkMap.applyChunkTrackingView) but map_chunk may arrive first.
if (
(meta.name === 'position' || meta.name === 'position_look' || meta.name === 'vehicle_move') &&
data.x != null &&
data.z != null
) {
this._syncClientViewFromBlockCoords(data.x, data.z);
}
const ok = this.serverConn.relayClientMovement(meta.name, data);
if (!ok) {
this.serverConn.confirmServerPosition();
this.serverConn.syncProxyClientPosition(this.client).catch(() => {});
}
return;
}
if (CHAT_SESSION_PACKETS.has(meta.name)) {
if (meta.name === 'message_acknowledgement') {
return;
}
relayClientChatAsUpstream(this.serverConn, meta.name, data, log);
return;
}
if (this._priorityClientPackets.has(meta.name)) {
this.serverConn.writeToServer(meta.name, data);
return;
}
this.serverConn.writeToServer(meta.name, data);
} catch (err) {
log.error(`Error forwarding client→server packet '${meta.name}':`, err.message);
}
};
// Run before minecraft-protocol server chat validation (registered at login).
this.client.prependListener('packet', this._clientPacketHandler);
// Server → Client
this._serverPacketHandler = (name, data, buffer) => {
if (!this.active) return;
if (this._blockedServerPackets.has(name)) return;
if (name === 'player_info' && !this._shouldForwardPlayerInfo(data)) return;
if (name === 'position') {
this._movementSynced = true;
if (data.x != null && data.z != null) {
this._syncClientViewFromBlockCoords(data.x, data.z);
}
}
if (name === 'update_view_position') {
this._clientView.chunkX = data.chunkX;
this._clientView.chunkZ = data.chunkZ;
}
if (name === 'map_chunk' && data.x != null && data.z != null) {
this._ensureViewIncludesChunk(data.x, data.z);
}
try {
if (this.client.state !== 'play') return;
// update_view_position must arrive before map_chunk in the same batch (ChunkMap.java)
if (buffer && RAW_FORWARD_PACKETS.has(name)) {
this.client.writeRaw(buffer);
return;
}
this.client.write(name, data);
} catch (err) {
log.error(`Error forwarding server→client packet '${name}':`, err.message);
}
};
this.serverConn.on('serverPacket', this._serverPacketHandler);
// Client disconnect
this._clientEndHandler = () => {
log.info('Client connection ended');
this.stop();
};
this.client.on('end', this._clientEndHandler);
}
/**
* Forward player_info adds; skip latency-only updates for unknown UUIDs.
*/
_shouldForwardPlayerInfo(data) {
const action = data.action;
const entries = data.data || [];
if (action && typeof action === 'object' && action.add_player) {
for (const entry of entries) {
if (entry.uuid) this.knownPlayerUuids.add(entry.uuid);
}
return true;
}
if (entries.length === 0) return true;
const allKnown = entries.every((e) => e.uuid && this.knownPlayerUuids.has(e.uuid));
if (allKnown) return true;
const anyKnown = entries.some((e) => e.uuid && this.knownPlayerUuids.has(e.uuid));
if (anyKnown) return true;
return false;
}
/**
* Stop bridging and clean up listeners.
*/
stop() {
if (!this.active) return;
this.active = false;
this.serverConn.setClientDrivesChunkBatchAck(false);
if (this._clientPacketHandler) {
this.client.removeListener('packet', this._clientPacketHandler);
}
if (this._serverPacketHandler) {
this.serverConn.removeListener('serverPacket', this._serverPacketHandler);
}
if (this._clientEndHandler) {
this.client.removeListener('end', this._clientEndHandler);
}
log.info('Client bridge stopped');
}
}
module.exports = { ClientBridge };

109
src/proxy/ProxyServer.js Normal file
View File

@@ -0,0 +1,109 @@
const mc = require('minecraft-protocol');
const { createLogger } = require('../utils/logger');
const { wrapClientEnd, safeEndClient } = require('../utils/clientDisconnect');
const { disableInboundChatValidation } = require('../utils/chatRelay');
const log = createLogger('ProxyServer');
class ProxyServer {
constructor(config, onClientConnect, worldState) {
this.config = config;
this.onClientConnect = onClientConnect;
this.worldState = worldState;
this.server = null;
this.activeClient = null;
}
start() {
this.server = mc.createServer({
host: this.config.proxy.host || '0.0.0.0',
'online-mode': this.config.proxy.onlineMode,
// Java client chat is re-signed for the bot upstream; do not validate client signatures here.
enforceSecureProfile: false,
port: this.config.proxy.port,
version: this.config.server.version,
maxPlayers: this.config.proxy.maxClients,
motd: '§6FlayerProxy',
hideErrors: true,
errorHandler: (client, err) => {
log.error(`Client error (${client.username || 'unknown'}):`, err.message);
safeEndClient(client, err);
},
});
// Replay upstream server's raw config packets before minecraft-protocol's parsed registry.
this.server.on('login', (client) => {
client.prependOnceListener('login_acknowledged', () => {
const packets = this.worldState.getRawConfigPacketsForReplay();
if (packets.length === 0) return;
for (const { name, buffer } of packets) {
try {
client.writeRaw(buffer);
} catch (err) {
log.error(`Failed to write raw config packet '${name}':`, err.message);
}
}
log.info(`Sent ${packets.length} raw config packets to ${client.username}`);
});
});
this.server.on('playerJoin', (client) => {
wrapClientEnd(client);
disableInboundChatValidation(client);
log.info(`Client ready: ${client.username}`);
if (this.activeClient) {
client.end('Another client is already connected.');
return;
}
this.activeClient = client;
client.on('end', () => {
log.info(`Client disconnected: ${client.username}`);
if (this.activeClient === client) {
this.activeClient = null;
}
});
this.onClientConnect(client);
});
this.server.on('error', (err) => {
log.error('Proxy server error:', err.message);
});
this.server.on('listening', () => {
log.info(`Proxy server listening on port ${this.config.proxy.port}`);
});
}
/**
* Replace the registry codec sent to clients during configuration.
* Must be called after the bot has received registry_data from the upstream server.
* @param {object} codec
*/
updateRegistryCodec(codec) {
if (!this.server?.options) return;
this.server.options.registryCodec = codec;
const count = codec.codec ? 1 : Object.keys(codec).length;
if (count === 0) {
log.info('Proxy registry disabled (using raw upstream config packets)');
} else {
log.info(`Proxy registry updated from server (${count} registries)`);
}
}
stop() {
if (this.activeClient) {
try { this.activeClient.end('Proxy shutting down'); } catch (e) {}
this.activeClient = null;
}
if (this.server) {
this.server.close();
}
}
}
module.exports = { ProxyServer };

216
src/replay/StateReplayer.js Normal file
View File

@@ -0,0 +1,216 @@
const { createLogger } = require('../utils/logger');
const { buildClientboundPositionPacket } = require('../utils/positionSync');
const { LEVEL_CHUNKS_LOAD_START } = require('../utils/handoffSync');
const {
POST_REPLAY_SETTLE_MS,
replayPacketData,
getPlayerChunkCenter,
splitMiscReplayPackets,
waitForClientTeleportConfirm,
} = require('./replayHelpers');
const { replayChunks } = require('./replayChunks');
const log = createLogger('StateReplayer');
/**
* Replays cached world state to a freshly connected client.
* Sends packets in the correct order so the vanilla client initializes properly.
*/
class StateReplayer {
/**
* @param {import('../state/WorldStateCache').WorldStateCache} worldState
* @param {import('../session/ServerConnection').ServerConnection} serverConn
*/
constructor(worldState, serverConn) {
this.worldState = worldState;
this.serverConn = serverConn;
}
/**
* Replay all cached state to the given client connection.
* The client should be in the 'play' state already.
*
* @param {object} client - minecraft-protocol client connection (from proxy server)
* @returns {Promise<void>}
*/
async replay(client) {
const ws = this.worldState;
const bot = this.serverConn?.bot;
const playerState = ws.player.getState();
if (!playerState.loginPacket) {
log.error('Cannot replay: no login packet cached');
return;
}
log.info('Starting state replay...');
let packetCount = 0;
const write = (name, data) => {
const payload = replayPacketData(client, name, data);
if (payload !== data && data?.enforcesSecureChat) {
log.info(`Replay ${name}: cleared enforcesSecureChat (proxy client has no profile keys)`);
}
try {
client.write(name, payload);
packetCount++;
} catch (err) {
log.error(`Failed to write packet '${name}':`, err.message);
}
};
const writeRaw = (buffer, label) => {
try {
client.writeRaw(buffer);
packetCount++;
} catch (err) {
log.error(`Failed to write raw packet '${label}':`, err.message);
}
};
// 1. Login packet (join_game)
write('login', { ...playerState.loginPacket });
// 2. Difficulty
if (playerState.difficulty) {
write('difficulty', playerState.difficulty);
}
// 3. Abilities + permission level (entity_status 2428 for game mode switcher)
if (playerState.abilities) {
write('abilities', playerState.abilities);
}
if (playerState.permissionStatus) {
write('entity_status', playerState.permissionStatus);
}
const { beforeLevel: miscEarly, levelInfo: miscLevelInfo, weatherPackets } = splitMiscReplayPackets(
ws.misc.getReplayPackets()
);
for (const pkt of miscEarly) {
write(pkt.name, pkt.data);
}
// 4. held_item_slot (matches placeNewPlayer order)
const invPackets = ws.inventory.getReplayPackets();
const heldItemPackets = invPackets.filter(p => p.name === 'held_item_slot');
const fullInvPackets = invPackets.filter(p => p.name !== 'held_item_slot');
for (const pkt of heldItemPackets) {
write(pkt.name, pkt.data);
}
// 5b. Recipes + advancements (ClientboundUpdateRecipesPacket, UpdateAdvancementsPacket)
const joinPackets = ws.joinSync.getReplayPackets();
if (joinPackets.length > 0) {
log.info(`Replaying ${joinPackets.length} join sync packets (recipes/advancements)...`);
for (const pkt of joinPackets) {
write(pkt.name, pkt.data);
}
} else {
log.warn('No recipes/advancements cached from server — client may log advancement load errors');
}
const center = getPlayerChunkCenter(playerState, ws.misc, bot);
const viewDistance = ws.misc.viewDistance?.viewDistance ?? this.serverConn?.config?.bot?.viewDistance ?? 10;
// 6. Teleport before terrain — PlayerList.placeNewPlayer teleports before sendLevelInfo/chunks
const cachedPos = playerState.position;
const teleportId = (cachedPos?.teleportId ?? 0) + 1;
const initialPosition = bot?.entity?.position
? buildClientboundPositionPacket(bot, teleportId)
: cachedPos;
if (initialPosition) {
write('position', initialPosition);
await waitForClientTeleportConfirm(client);
}
// 7. Tab list (after initial teleport on vanilla)
const playerInfoPackets = ws.misc.getPlayerInfoReplayPackets();
if (playerInfoPackets.length > 0) {
log.info(`Replaying ${playerInfoPackets.length} player_info packets...`);
for (const pkt of playerInfoPackets) {
write(pkt.name, pkt.data);
}
}
// 8. sendLevelInfo — border, time, weather, spawn (PlayerList.sendLevelInfo)
for (const pkt of miscLevelInfo) {
write(pkt.name, pkt.data);
}
if (playerState.spawnPosition) {
write('spawn_position', playerState.spawnPosition);
}
for (const pkt of weatherPackets) {
write(pkt.name, pkt.data);
}
if (ws.misc.viewDistance) {
write('update_view_distance', ws.misc.viewDistance);
}
write('update_view_position', {
chunkX: center.chunkX,
chunkZ: center.chunkZ,
});
write('game_state_change', LEVEL_CHUNKS_LOAD_START);
// 9. Chunks
write('chunk_batch_start', {});
const totalCached = ws.chunks.size;
const chunks = ws.chunks.getChunksForReplay(center.chunkX, center.chunkZ, viewDistance);
await replayChunks(write, writeRaw, chunks, center, totalCached);
// 10. Entities
const entities = ws.entities.getAllEntities();
log.info(`Replaying ${entities.length} entities...`);
for (const entity of entities) {
if (entity.entityId === playerState.entityId) continue;
if (entity.spawnData) {
write('spawn_entity', entity.spawnData);
}
if (entity.metadata) {
write('entity_metadata', entity.metadata);
}
if (entity.equipment) {
write('entity_equipment', entity.equipment);
}
for (const effect of entity.effects) {
write('entity_effect', effect);
}
if (entity.passengers) {
write('set_passengers', entity.passengers);
}
}
// 11. Experience & health (final position sync is done in SessionManager after replay)
if (playerState.experience) {
write('experience', playerState.experience);
}
if (playerState.health) {
write('update_health', playerState.health);
}
if (playerState.effects) {
for (const effect of playerState.effects) {
write('entity_effect', effect);
}
}
// 12. Full inventory (window_items, set_slot, etc.) matches player.initInventoryMenu() at the end
if (fullInvPackets.length > 0) {
log.info(`Replaying ${fullInvPackets.length} inventory packets...`);
for (const pkt of fullInvPackets) {
write(pkt.name, pkt.data);
}
}
log.info(`State replay complete: ${packetCount} packets sent`);
log.info(`Waiting ${POST_REPLAY_SETTLE_MS}ms for client to render terrain...`);
await new Promise((resolve) => setTimeout(resolve, POST_REPLAY_SETTLE_MS));
}
}
module.exports = { StateReplayer };

View File

@@ -0,0 +1,69 @@
const { createLogger } = require('../utils/logger');
const { CHUNK_YIELD_EVERY, yieldEventLoop } = require('./replayHelpers');
const log = createLogger('StateReplayer');
/**
* Replay cached chunks (with light, block changes) to a client.
*
* @param {function(string, object): void} write - named packet writer
* @param {function(Buffer, string): void} writeRaw - raw buffer writer
* @param {object[]} chunks - chunks from ChunkCache.getChunksForReplay
* @param {{ chunkX: number, chunkZ: number }} center - player chunk center
* @param {number} totalCached - total chunks in cache (for logging)
* @returns {Promise<void>}
*/
async function replayChunks(write, writeRaw, chunks, center, totalCached) {
if (totalCached > chunks.length) {
log.info(
`Filtered ${totalCached - chunks.length} cached chunks outside view distance of bot at (${center.chunkX}, ${center.chunkZ})`
);
}
if (chunks.length === 0) {
log.warn(
`No cached chunks near bot at (${center.chunkX}, ${center.chunkZ}) — terrain will stream live from server after handoff`
);
} else {
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];
if (chunk.rawMapChunkBuffer) {
writeRaw(chunk.rawMapChunkBuffer, `map_chunk ${chunk.packetData.x},${chunk.packetData.z}`);
rawChunkCount++;
} 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();
}
}
write('chunk_batch_finished', { batchSize: chunks.length });
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/replay/replayHelpers.js Normal file
View File

@@ -0,0 +1,107 @@
const TELEPORT_CONFIRM_TIMEOUT_MS = 15000;
const CHUNK_YIELD_EVERY = 32;
/** Vanilla keeps "Loading Terrain" at least ~2s after chunks start loading */
const POST_REPLAY_SETTLE_MS = 2500;
function yieldEventLoop() {
return new Promise((resolve) => setImmediate(resolve));
}
/**
* Proxy clients without Mojang profile keys cannot satisfy enforcesSecureChat.
* Strip the flag on replay so vanilla does not disable chat locally.
*/
function replayPacketData(client, name, data) {
if (client.profileKeys || !data || typeof data !== 'object') return data;
if ((name === 'login' || name === 'server_data') && data.enforcesSecureChat) {
return { ...data, enforcesSecureChat: false };
}
return data;
}
function getPlayerChunkCenter(playerState, misc, bot) {
if (bot?.entity?.position) {
const p = bot.entity.position;
return {
chunkX: Math.floor(p.x / 16),
chunkZ: Math.floor(p.z / 16),
};
}
if (playerState.position) {
return {
chunkX: Math.floor(playerState.position.x / 16),
chunkZ: Math.floor(playerState.position.z / 16),
};
}
if (misc.viewPosition) {
return {
chunkX: misc.viewPosition.chunkX,
chunkZ: misc.viewPosition.chunkZ,
};
}
if (playerState.spawnPosition?.location) {
const loc = playerState.spawnPosition.location;
return {
chunkX: Math.floor(loc.x / 16),
chunkZ: Math.floor(loc.z / 16),
};
}
return { chunkX: 0, chunkZ: 0 };
}
/** Split misc replay to match placeNewPlayer: HUD first, border/time after teleport */
function splitMiscReplayPackets(packets) {
const beforeLevel = [];
const levelInfo = [];
const weatherPackets = [];
for (const pkt of packets) {
if (
pkt.name === 'initialize_world_border' ||
pkt.name === 'world_border_center' ||
pkt.name === 'world_border_size' ||
pkt.name === 'update_time'
) {
levelInfo.push(pkt);
} else if (
pkt.name === 'game_state_change' &&
pkt.data?.reason != null &&
[1, 7, 8].includes(pkt.data.reason)
) {
weatherPackets.push(pkt);
} else if (pkt.name === 'update_view_distance') {
continue;
} else {
beforeLevel.push(pkt);
}
}
return { beforeLevel, levelInfo, weatherPackets };
}
function waitForClientTeleportConfirm(client) {
return new Promise((resolve) => {
if (!client || client.ended) return resolve();
const timeout = setTimeout(() => {
client.removeListener('teleport_confirm', onConfirm);
resolve();
}, TELEPORT_CONFIRM_TIMEOUT_MS);
const onConfirm = () => {
clearTimeout(timeout);
resolve();
};
client.once('teleport_confirm', onConfirm);
});
}
module.exports = {
TELEPORT_CONFIRM_TIMEOUT_MS,
CHUNK_YIELD_EVERY,
POST_REPLAY_SETTLE_MS,
yieldEventLoop,
replayPacketData,
getPlayerChunkCenter,
splitMiscReplayPackets,
waitForClientTeleportConfirm,
};

View File

@@ -0,0 +1,59 @@
const { createLogger } = require('../utils/logger');
const log = createLogger('ServerConn');
/**
* Manages mineflayer's chunk_batch_finished auto-ack listener.
*
* While a Java proxy client is connected, only the client should send
* chunk_batch_received (see PlayerChunkSender.onChunkBatchReceivedByClient).
* This class saves and restores the mineflayer-installed listeners so we can
* toggle ack ownership between bot and client.
*/
class ChunkAckManager {
constructor() {
/** @type {Function[]|null} */
this._savedListeners = null;
}
/**
* Disable mineflayer's chunk_batch_finished auto-ack.
* @param {object} rawClient - minecraft-protocol client
*/
disable(rawClient) {
if (!rawClient) return;
if (!this._savedListeners) {
this._savedListeners = rawClient.listeners('chunk_batch_finished').slice();
}
rawClient.removeAllListeners('chunk_batch_finished');
log.debug('Disabled mineflayer chunk_batch_finished auto-ack');
}
/**
* Restore mineflayer's chunk_batch_finished auto-ack.
* @param {object} rawClient - minecraft-protocol client
*/
restore(rawClient) {
if (!rawClient || !this._savedListeners) return;
rawClient.removeAllListeners('chunk_batch_finished');
for (const fn of this._savedListeners) {
rawClient.on('chunk_batch_finished', fn);
}
log.debug('Restored mineflayer chunk_batch_finished auto-ack');
}
/**
* Send a chunk_batch_received to unblock PlayerChunkSender.
* @param {object} rawClient - minecraft-protocol client
*/
flush(rawClient) {
if (!rawClient) return;
try {
rawClient.write('chunk_batch_received', { chunksPerTick: 9.0 });
} catch (err) {
log.debug('flushChunkBatchAck failed:', err.message);
}
}
}
module.exports = { ChunkAckManager };

View File

@@ -0,0 +1,156 @@
const { createLogger } = require('../utils/logger');
const conv = require('mineflayer/lib/conversions');
const {
buildClientboundPositionPacket,
buildServerboundPositionLook,
waitForClientTeleportConfirm,
movementFlags,
distanceSq,
MAX_CLIENT_MOVEMENT_WARN_DELTA,
} = require('../utils/positionSync');
const log = createLogger('ServerConn');
/**
* Apply a proxy client's movement packet to the bot entity, then send serverbound packets
* using the client's coordinates so ChunkMap.move() tracks where the player walks.
* @param {import('mineflayer').Bot} bot
* @param {object} rawClient - minecraft-protocol client
* @param {string} name - packet name
* @param {object} data - packet data
* @returns {boolean} false only when the bot entity is not ready
*/
function relayClientMovement(bot, rawClient, name, data) {
if (!bot?.entity?.position) return false;
const entity = bot.entity;
if (name === 'position' || name === 'position_look') {
const target = { x: data.x, y: data.y, z: data.z };
const dist = Math.sqrt(distanceSq(target, entity.position));
if (dist > MAX_CLIENT_MOVEMENT_WARN_DELTA) {
log.warn(
`Client ${dist.toFixed(1)} blocks ahead of bot — forwarding anyway so server streams chunks`
);
}
entity.position.set(target.x, target.y, target.z);
}
if (name === 'position_look' || name === 'look') {
if (data.yaw !== undefined) entity.yaw = conv.fromNotchianYaw(data.yaw);
if (data.pitch !== undefined) entity.pitch = conv.fromNotchianPitch(data.pitch);
}
const onGround = data.onGround ?? data.flags?.onGround;
if (onGround !== undefined) entity.onGround = onGround;
const flags = movementFlags(
onGround ?? entity.onGround,
data.flags?.hasHorizontalCollision
);
try {
if (name === 'flying' && data.x === undefined) {
rawClient.write('flying', { flags });
} else if (name === 'look') {
rawClient.write('look', {
yaw: conv.toNotchianYaw(entity.yaw),
pitch: conv.toNotchianPitch(entity.pitch),
flags,
});
} else if (name === 'position') {
rawClient.write('position', {
x: data.x,
y: data.y,
z: data.z,
flags,
});
} else if (name === 'position_look') {
rawClient.write('position_look', {
x: data.x,
y: data.y,
z: data.z,
yaw: data.yaw,
pitch: data.pitch,
flags,
});
} else {
rawClient.write(name, data);
}
return true;
} catch (err) {
log.error(`Failed to relay movement '${name}':`, err.message);
return false;
}
}
/**
* Snap the proxy client to the bot's current server-side position.
* Call after replay and before enabling movement forwarding.
* @param {import('mineflayer').Bot} bot
* @param {{ player: import('../state/PlayerStateCache').PlayerStateCache }} worldState
* @param {object} client - minecraft-protocol client
* @returns {Promise<boolean>}
*/
async function syncProxyClientPosition(bot, worldState, client) {
if (!bot?.entity?.position) {
log.warn('Cannot sync client position: bot entity not ready');
return false;
}
const cached = worldState.player.position;
const teleportId = (cached?.teleportId ?? 0) + 1;
const packet = buildClientboundPositionPacket(bot, teleportId);
if (!packet) return false;
const { x, y, z } = bot.entity.position;
const chunkX = Math.floor(x / 16);
const chunkZ = Math.floor(z / 16);
try {
client.write('position', packet);
worldState.player.handlePosition(packet);
} catch (err) {
log.error('Failed to write position sync to client:', err.message);
return false;
}
await waitForClientTeleportConfirm(client, 10000, log);
try {
client.write('update_view_position', { chunkX, chunkZ });
} catch (err) {
log.error('Failed to write update_view_position after sync:', err.message);
}
log.info(
`Synced client to bot position (${x.toFixed(2)}, ${y.toFixed(2)}, ${z.toFixed(2)}) chunk (${chunkX}, ${chunkZ})`
);
return true;
}
/**
* Tell the server the bot's current position (serverbound position_look).
* @param {import('mineflayer').Bot} bot
* @param {object} rawClient - minecraft-protocol client
* @param {boolean} connected
* @returns {boolean}
*/
function confirmServerPosition(bot, rawClient, connected) {
if (!rawClient || !connected) return false;
const packet = buildServerboundPositionLook(bot);
if (!packet) return false;
try {
rawClient.write('position_look', packet);
const { x, y, z } = bot.entity.position;
log.info(`Confirmed server position (${x.toFixed(2)}, ${y.toFixed(2)}, ${z.toFixed(2)})`);
return true;
} catch (err) {
log.error('Failed to confirm server position:', err.message);
return false;
}
}
module.exports = { relayClientMovement, syncProxyClientPosition, confirmServerPosition };

View File

@@ -0,0 +1,257 @@
const EventEmitter = require('events');
const mineflayer = require('mineflayer');
const { createLogger } = require('../utils/logger');
const { relayClientMovement, syncProxyClientPosition, confirmServerPosition } = require('./MovementRelay');
const { ChunkAckManager } = require('./ChunkAckManager');
const log = createLogger('ServerConn');
/**
* Manages the persistent connection to the Minecraft server via a Mineflayer bot.
* Provides access to both the high-level bot API and the raw minecraft-protocol client.
*/
class ServerConnection extends EventEmitter {
/**
* @param {object} config - Full config object
* @param {import('../state/WorldStateCache').WorldStateCache} worldState
*/
constructor(config, worldState) {
super();
this.config = config;
this.worldState = worldState;
this.bot = null;
this.rawClient = null;
this.connected = false;
this._botControlEnabled = true;
/** True after first spawn; later spawns are respawns on the same connection */
this._initialSpawnDone = false;
this._chunkAck = new ChunkAckManager();
}
/**
* Connect the bot to the Minecraft server.
*/
connect() {
log.info(`Connecting to ${this.config.server.host}:${this.config.server.port} as ${this.config.auth.username}...`);
this._initialSpawnDone = false;
this.bot = mineflayer.createBot({
host: this.config.server.host,
port: this.config.server.port,
username: this.config.auth.username,
auth: this.config.auth.auth,
version: this.config.server.version,
viewDistance: this.config.bot.viewDistance,
checkTimeoutInterval: 60000,
hideErrors: false,
});
this.rawClient = this.bot._client;
this._setupConfigCapture();
this._setupPacketCapture();
this._setupBotEvents();
}
/**
* Capture configuration-phase packets for later replay.
*/
_setupConfigCapture() {
const configPacketNames = new Set([
'registry_data', 'feature_flags', 'tags', 'finish_configuration',
'custom_payload', 'reset_chat',
]);
// Capture raw buffers so proxy clients get byte-identical registry data.
this.rawClient.on('packet', (data, meta, buffer) => {
if (meta.state !== 'configuration') return;
if (!configPacketNames.has(meta.name)) return;
this.worldState.handleRawConfigPacket(meta.name, buffer);
this.worldState.handleConfigPacket(meta.name, data);
});
}
/**
* Hook into raw packet events to feed the world state cache.
*/
_setupPacketCapture() {
this.rawClient.on('packet', (data, meta, buffer) => {
if (meta.state !== 'play') return;
// Feed every server->client play packet to the world state cache
this.worldState.handleServerPacket(meta.name, data, buffer);
// Forward to any connected client (include raw buffer for chunk packets)
this.emit('serverPacket', meta.name, data, buffer);
});
}
/**
* Setup high-level bot events.
*/
_setupBotEvents() {
this.bot.on('spawn', () => {
log.info('Bot spawned in world');
this.connected = true;
if (!this._initialSpawnDone) {
this._initialSpawnDone = true;
this.emit('connected');
} else {
this.emit('respawn');
}
});
this.bot.on('end', (reason) => {
log.warn(`Bot disconnected: ${reason}`);
this.connected = false;
this.emit('disconnected', reason);
});
this.bot.on('kicked', (reason) => {
log.error(`Bot kicked: ${JSON.stringify(reason)}`);
this.connected = false;
this.emit('kicked', reason);
});
this.bot.on('error', (err) => {
log.error(`Bot error: ${err.message}`);
this.emit('error', err);
});
this.bot.on('death', () => {
log.warn('Bot died');
this.emit('death');
if (this._botControlEnabled) {
setTimeout(() => {
try {
this.bot.respawn();
} catch (e) {
log.error('Failed to respawn:', e.message);
}
}, 1000);
}
});
this.bot.on('messagestr', (text, messageType) => {
if (!this.connected || !text) return;
const label =
messageType === 'chat' ? 'Chat' :
messageType === 'system' ? 'Server' :
messageType === 'game_info' ? 'ActionBar' :
messageType;
const line = `[${label}] ${text}`;
if (messageType === 'game_info') {
log.debug(line);
} else {
log.info(line);
}
});
}
/**
* Enable/disable bot AI control.
* When disabled, the bot stops all autonomous behavior.
*/
setBotControl(enabled) {
this._botControlEnabled = enabled;
if (enabled) {
log.info('Bot control ENABLED (bot mode)');
if (this.bot) this.bot.physicsEnabled = true;
} else {
log.info('Bot control DISABLED (client taking over)');
if (this.bot) {
this.bot.physicsEnabled = false;
try {
this.bot.clearControlStates();
} catch (e) { /* ignore */ }
}
}
}
/**
* When true, the Java client forwards chunk_batch_received and mineflayer must not auto-ack.
* When false, mineflayer acks batches on the bot connection (required during handoff/replay).
*/
setClientDrivesChunkBatchAck(clientDrives) {
this.setProxyClientChunkAck(!clientDrives);
}
/** Unblock PlayerChunkSender if a batch finished without an ack yet. */
flushChunkBatchAck() {
this._chunkAck.flush(this.rawClient);
}
/**
* Re-send permission entity_status after /op or on handoff (PlayerList.sendPlayerPermissionLevel).
*/
refreshProxyClientPermissions(client) {
const status = this.worldState.player.permissionStatus;
if (!status || !client) return false;
try {
client.write('entity_status', { ...status });
return true;
} catch (err) {
log.error('Failed to refresh client permissions:', err.message);
return false;
}
}
/**
* Snap the proxy client to the bot's current server-side position.
* Call after replay and before enabling movement forwarding.
*/
async syncProxyClientPosition(client) {
return syncProxyClientPosition(this.bot, this.worldState, client);
}
/**
* Tell the server the bot's current position (serverbound position_look).
*/
confirmServerPosition() {
return confirmServerPosition(this.bot, this.rawClient, this.connected);
}
/**
* While a Java client is connected, only the client should send chunk_batch_received
* (see PlayerChunkSender.onChunkBatchReceivedByClient). Mineflayer auto-acks otherwise.
*/
setProxyClientChunkAck(enabled) {
if (enabled) {
this._chunkAck.restore(this.rawClient);
} else {
this._chunkAck.disable(this.rawClient);
}
}
/**
* Apply a proxy client's movement packet to the bot entity, then send serverbound packets
* using the client's coordinates so ChunkMap.move() tracks where the player walks.
* @returns {boolean} false only when the bot entity is not ready
*/
relayClientMovement(name, data) {
return relayClientMovement(this.bot, this.rawClient, name, data);
}
/**
* Write a packet to the upstream server.
*/
writeToServer(name, data) {
if (this.rawClient && this.connected) {
this.rawClient.write(name, data);
}
}
/**
* Gracefully close the connection.
*/
disconnect() {
if (this.bot) {
this.bot.quit();
}
}
}
module.exports = { ServerConnection };

View File

@@ -0,0 +1,316 @@
const { createLogger } = require('../utils/logger');
const { ServerConnection } = require('./ServerConnection');
const { ProxyServer } = require('../proxy/ProxyServer');
const { WorldStateCache } = require('../state/WorldStateCache');
const { StateReplayer } = require('../replay/StateReplayer');
const { performHandoff } = require('./handoffFlow');
const { removeHandoffUpstreamRelay } = require('../utils/handoffSync');
const { disconnectReasonText } = require('../utils/clientDisconnect');
const log = createLogger('Session');
/**
* Session states
*/
const State = {
INIT: 'INIT',
BOT_MODE: 'BOT_MODE',
HANDOFF: 'HANDOFF',
CLIENT_MODE: 'CLIENT_MODE',
};
/**
* Orchestrates the lifecycle: bot mode ↔ client mode.
*
* - INIT: Connecting to server
* - BOT_MODE: No client connected, bot holds the session
* - HANDOFF: Client just connected, replaying cached state
* - CLIENT_MODE: Client is in control, packets piped bidirectionally
*/
class SessionManager {
constructor(config) {
this.config = config;
this.state = State.INIT;
this._shuttingDown = false;
this._reconnectTimer = null;
// Core components
this.worldState = new WorldStateCache(config);
this.serverConn = new ServerConnection(config, this.worldState);
this.proxyServer = new ProxyServer(config, (client) => this._onClientConnect(client), this.worldState);
this.replayer = new StateReplayer(this.worldState, this.serverConn);
// Current client bridge (if in CLIENT_MODE)
this.clientBridge = null;
this.currentClient = null;
this._setupServerEvents();
}
/**
* Boot up: connect to server and start proxy.
*/
start() {
log.info('Starting FlayerProxy...');
this.serverConn.connect();
this.proxyServer.start();
}
/**
* Schedule a reconnect, cancelling any previous pending one.
*/
_scheduleReconnect(delaySec) {
if (this._shuttingDown) return;
// Cancel any existing timer
if (this._reconnectTimer) {
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
log.info(`Reconnecting in ${delaySec} seconds...`);
this._reconnectTimer = setTimeout(() => {
this._reconnectTimer = null;
if (this._shuttingDown) return;
this.worldState.clear();
this.serverConn.connect();
}, delaySec * 1000);
}
/**
* Setup event handlers for server connection lifecycle.
*/
_setupServerEvents() {
this.serverConn.on('connected', () => {
log.info('Server connection established');
if (this.worldState.hasRawConfigPackets()) {
const packets = this.worldState.getRawConfigPacketsForReplay();
const registryCount = packets.filter(p => p.name === 'registry_data').length;
this.proxyServer.updateRegistryCodec({});
log.info(`Captured ${packets.length} raw config packets (${registryCount} registries) from server`);
} else {
const registryCodec = this.worldState.buildRegistryCodec();
if (registryCodec) {
this.proxyServer.updateRegistryCodec(registryCodec);
} else {
log.warn('No registry_data captured from server — proxy clients will use minecraft-data defaults');
}
}
this._transitionTo(State.BOT_MODE);
});
this.serverConn.on('disconnected', (reason) => {
log.warn(`Server disconnected: ${reason}`);
if (this.state === State.INIT) return; // Already handled by kicked
// Kick any connected client
if (this.currentClient) {
try {
this.currentClient.end(`Server disconnected: ${disconnectReasonText(reason)}`);
} catch (e) { /* ignore */ }
}
this._cleanupClient();
this._transitionTo(State.INIT);
this._scheduleReconnect(5);
});
this.serverConn.on('kicked', (reason) => {
log.error(`Kicked from server: ${JSON.stringify(reason)}`);
if (this.currentClient) {
try {
this.currentClient.end(`Kicked from server: ${disconnectReasonText(reason)}`);
} catch (e) { /* ignore */ }
}
this._cleanupClient();
this._transitionTo(State.INIT);
this._scheduleReconnect(15);
});
this.serverConn.on('error', (err) => {
log.error(`Server error: ${err.message}`);
});
this.serverConn.on('death', () => {
if (this.currentClient && this.state === State.CLIENT_MODE) {
log.warn('Bot died while client is connected — will resync when bot respawns');
}
});
this.serverConn.on('respawn', () => {
if (this.currentClient && this.state === State.CLIENT_MODE) {
this._refreshClientAfterBotRespawn().catch((err) => {
log.error('Failed to refresh session after respawn:', err.message);
});
}
});
}
/**
* Ask the server for chunks at the bot's current position if the cache is empty there.
*/
async _primeChunksNearBot() {
const bot = this.serverConn.bot;
if (!bot?.entity?.position) return;
const cx = Math.floor(bot.entity.position.x / 16);
const cz = Math.floor(bot.entity.position.z / 16);
if (this.worldState.chunks.getChunksForReplay(cx, cz, 2).length > 0) {
return;
}
log.info(`No cached chunks at bot (${cx}, ${cz}) — nudging server chunk loader...`);
// Server has no serverbound view-center packet; movement triggers ChunkMap.move().
this.serverConn.confirmServerPosition();
const rawClient = this.serverConn.rawClient;
if (!rawClient) return;
await new Promise((resolve) => {
const timeout = setTimeout(() => {
rawClient.removeListener('packet', onPacket);
resolve();
}, 1500);
const onPacket = (data, meta) => {
if (meta.state !== 'play' || meta.name !== 'map_chunk') return;
if (this.worldState.chunks.getChunksForReplay(cx, cz, 2).length > 0) {
clearTimeout(timeout);
rawClient.removeListener('packet', onPacket);
log.info('Received chunks from server for handoff');
resolve();
}
};
rawClient.on('packet', onPacket);
});
}
/**
* Bot respawned on the same server connection while a client is attached.
*/
async _refreshClientAfterBotRespawn() {
const client = this.currentClient;
if (!client) return;
log.info('Bot respawned — resyncing client to new position');
this.worldState.entities.clear();
await this.serverConn.syncProxyClientPosition(client);
this.serverConn.confirmServerPosition();
if (this.clientBridge) {
this.clientBridge._syncClientViewFromBot();
}
}
/**
* Handle a new Java client connection from the proxy server.
*/
async _onClientConnect(client) {
if (this.state === State.INIT) {
log.warn('Client connected but bot is not ready yet — rejecting');
client.end('Proxy is still connecting to the server. Try again in a moment.');
return;
}
if (this.state === State.HANDOFF || this.state === State.CLIENT_MODE) {
log.warn('Client connected but another client is active — rejecting');
client.end('Another client session is active.');
return;
}
// BOT_MODE → HANDOFF
log.info(`Client ${client.username} connected — starting handoff`);
this._transitionTo(State.HANDOFF);
this.currentClient = client;
// Disable bot physics; keep mineflayer chunk_batch ack until the bridge takes over
this.serverConn.setBotControl(false);
// Handle client disconnect during handoff
const onDisconnect = () => {
log.info('Client disconnected during handoff');
this._cleanupClient();
this._transitionTo(State.BOT_MODE);
this.serverConn.setBotControl(true);
};
client.once('end', onDisconnect);
const result = await performHandoff({
client,
serverConn: this.serverConn,
worldState: this.worldState,
replayer: this.replayer,
proxyServer: this.proxyServer,
primeChunks: () => this._primeChunksNearBot(),
isHandoffState: () => this.state === State.HANDOFF,
});
client.removeListener('end', onDisconnect);
if (!result) {
this._cleanupClient();
this._transitionTo(State.BOT_MODE);
this.serverConn.setBotControl(true);
return;
}
this._transitionTo(State.CLIENT_MODE);
this.clientBridge = result.bridge;
// Handle client disconnect in client mode
client.on('end', () => {
log.info('Client disconnected — returning to bot mode');
this._cleanupClient();
this._transitionTo(State.BOT_MODE);
this.serverConn.setBotControl(true);
});
}
/**
* Clean up client bridge and references.
*/
_cleanupClient() {
if (this.clientBridge) {
this.clientBridge.stop();
this.clientBridge = null;
}
this.currentClient = null;
this.proxyServer.activeClient = null;
}
/**
* Transition to a new state.
*/
_transitionTo(newState) {
const oldState = this.state;
this.state = newState;
log.info(`State: ${oldState}${newState}`);
if (newState === State.BOT_MODE) {
const summary = this.worldState.getSummary();
log.info(`Cache status: ${summary.chunks} chunks, ${summary.entities} entities, position: ${summary.hasPosition}`);
}
}
/**
* Gracefully shut down everything.
*/
stop() {
this._shuttingDown = true;
if (this._reconnectTimer) {
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
log.info('Shutting down FlayerProxy...');
this._cleanupClient();
this.proxyServer.stop();
this.serverConn.disconnect();
}
}
module.exports = { SessionManager };

View File

@@ -0,0 +1,67 @@
const { createLogger } = require('../utils/logger');
const {
installHandoffUpstreamRelay,
removeHandoffUpstreamRelay,
sendPermissionStatusToClient,
} = require('../utils/handoffSync');
const { ClientBridge } = require('../proxy/ClientBridge');
const log = createLogger('Session');
/**
* Execute the handoff sequence: prime chunks → replay → position sync → permissions → bridge.
*
* @param {object} opts
* @param {object} opts.client - minecraft-protocol proxy client
* @param {import('./ServerConnection').ServerConnection} opts.serverConn
* @param {import('../state/WorldStateCache').WorldStateCache} opts.worldState
* @param {import('../replay/StateReplayer').StateReplayer} opts.replayer
* @param {import('../proxy/ProxyServer').ProxyServer} opts.proxyServer
* @param {function(): Promise<void>} opts.primeChunks - primes chunks near bot
* @param {function(): boolean} opts.isHandoffState - returns true if still in HANDOFF state
* @returns {Promise<{ bridge: ClientBridge, upstreamRelay: object }|null>} null on failure
*/
async function performHandoff({ client, serverConn, worldState, replayer, proxyServer, primeChunks, isHandoffState }) {
const upstreamRelay = installHandoffUpstreamRelay(client, serverConn, log);
try {
await primeChunks();
// Replay cached state (placeNewPlayer order: teleport → level info → chunks)
await replayer.replay(client);
if (!isHandoffState()) return null;
await serverConn.syncProxyClientPosition(client);
serverConn.confirmServerPosition();
if (
!sendPermissionStatusToClient(
client,
worldState.player.permissionStatus,
log
)
) {
log.warn(
'No OP permission cached for client — run /op FlayerBot on the server (not your launcher username), then reconnect'
);
}
serverConn.writeToServer('player_loaded', {});
log.info('Sent player_loaded to server (hasClientLoaded)');
removeHandoffUpstreamRelay(client, upstreamRelay);
const bridge = new ClientBridge(client, serverConn, worldState);
bridge.start();
bridge.enableMovement();
log.info(`Session handed off to ${client.username}`);
return { bridge, upstreamRelay: null };
} catch (err) {
log.error('Error during handoff:', err);
removeHandoffUpstreamRelay(client, upstreamRelay);
return null;
}
}
module.exports = { performHandoff };

256
src/sniffer/MitmProxy.js Normal file
View File

@@ -0,0 +1,256 @@
const mc = require('minecraft-protocol');
const { createLogger } = require('../utils/logger');
const { PacketLog } = require('./PacketLog');
const { relayPacket, sortLoginPending, relayToJava } = require('./mitmRelay');
const { enableJavaEncryption } = require('./mitmEncryption');
const { applyLoginStartIdentity } = require('./mitmLogin');
const {
GATE,
canRelayC2S,
c2sForwardLabel,
hasPendingSuccess,
onJavaLoginAcknowledged,
onJavaFinishConfiguration,
partitionAfterCrypto,
} = require('./mitmGate');
const { createMitmSession, createSessionCleanup } = require('./mitmSession');
const { startStatusPipe, startUpstream } = require('./mitmUpstream');
const log = createLogger('Sniffer');
const states = mc.states;
/**
* MITM sniffer: Java ↔ node server ↔ upstream client ↔ real server.
* Each leg is decrypted by minecraft-protocol so packets can be logged by name.
*/
class MitmProxy {
constructor(config) {
this.config = config;
this.server = null;
this.activeSession = null;
}
start() {
const sniffer = this.config.sniffer;
this.server = mc.createServer({
host: sniffer.host || '0.0.0.0',
'online-mode': sniffer.onlineMode === true,
port: sniffer.port,
version: this.config.server.version,
maxPlayers: 1,
motd: '§eMITM Sniffer',
kickTimeout: 120000,
checkTimeoutInterval: 10000,
hideErrors: true,
errorHandler: (_client, err) => {
log.error('Client error:', err.message);
},
});
this.server.on('connection', (client) => this._onConnection(client));
this.server.on('listening', () => {
log.info(
`MITM sniffer on ${sniffer.host || '0.0.0.0'}:${sniffer.port}${this.config.server.host}:${this.config.server.port}`,
);
log.info(`Upstream auth: ${sniffer.upstreamAuth || 'microsoft'}`);
log.info(`Logs: ${sniffer.logDir}`);
});
this.server.on('error', (err) => {
log.error('Sniffer listen error:', err.message);
});
}
_onConnection(client) {
const addr = client.socket?.remoteAddress || '?';
if (this.activeSession) {
log.warn(`Rejecting ${addr} — session active`);
client.end('Sniffer allows one client at a time.');
return;
}
// Do not run local login — upstream is the real server session.
client.removeAllListeners('login_start');
const packetLog = new PacketLog({
logDir: this.config.sniffer.logDir,
sessionId: `session-${Date.now()}`,
clientUsername: 'unknown',
server: `${this.config.server.host}:${this.config.server.port}`,
version: this.config.server.version,
includePayload: this.config.sniffer.includePayload,
});
const session = createMitmSession(client, packetLog);
this.activeSession = session;
const cleanup = createSessionCleanup(session, packetLog, this);
client.on('end', () => {
log.info(`Client disconnected ${session.username} (${addr})`);
cleanup('client_end');
});
client.on('error', (err) => {
log.error(`Client error: ${err.message}`);
cleanup('client_error');
});
client.on('packet', (data, meta, buffer) => {
packetLog.logPacket('C2S', meta, data, buffer, {
forwarded: c2sForwardLabel(session, meta),
clientState: client.state,
upstreamState: session.upstream?.state,
gate: session.gate,
});
if (meta.state === states.HANDSHAKING && meta.name === 'set_protocol' && data.nextState === 1) {
startStatusPipe(session, this.config, packetLog, this);
return;
}
if (!session.upstream && meta.state === states.LOGIN && meta.name === 'login_start') {
try {
applyLoginStartIdentity(client, data, this.server, this.server.options);
} catch (err) {
log.error(`login_start rejected: ${err.message}`);
client.end('Invalid login');
return;
}
session.username = data.username;
packetLog.writeMeta({ type: 'username', username: data.username });
packetLog.writeMeta({ type: 'handshake_intent', mode: 'login' });
this._startUpstream(session, cleanup);
return;
}
if (meta.name === 'login_acknowledged') {
try {
if (onJavaLoginAcknowledged(session)) {
log.info(`Java login acknowledged → configuration for ${session.username}`);
}
} catch (err) {
log.error(`login_acknowledged error: ${err.message}`);
}
return;
}
if (meta.name === 'finish_configuration') {
try {
if (onJavaFinishConfiguration(session, packetLog)) {
log.info(`MITM bridge active (play) for ${session.username}`);
}
} catch (err) {
log.error(`finish_configuration error: ${err.message}`);
}
return;
}
if (session.upstream && canRelayC2S(session, meta)) {
try {
relayPacket(session.upstream, meta, data, buffer);
} catch (err) {
log.error(`C2S relay error (${meta.name}):`, err.message);
}
}
});
log.info(`Client connected ${addr}${packetLog.filePath}`);
}
_startUpstream(session, cleanup) {
const tryBegin = () => this._tryBeginJavaCrypto(session, cleanup);
startUpstream(session, this.config, cleanup, {
GATE_LOGIN: GATE.LOGIN,
onCompressBeforeCrypto: tryBegin,
onEncryptionBegin: tryBegin,
onSuccessWhileHeld: tryBegin,
onSuccessNoEncryption: (s) => {
s.gate = GATE.AWAIT_LOGIN_ACK;
log.info(`Login success sent (no upstream encryption) for ${s.username}`);
},
});
}
_tryBeginJavaCrypto(session, cleanup) {
if (!session.waitingJavaCrypto || session.javaCryptoStarting || session.gate !== GATE.LOGIN) return;
if (!hasPendingSuccess(session)) return;
this._doJavaCrypto(session, cleanup);
}
async _doJavaCrypto(session, cleanup) {
if (session.javaCryptoStarting || session.gate !== GATE.LOGIN) return;
session.javaCryptoStarting = true;
sortLoginPending(session.pendingS2C);
const heldLogin = [];
for (const item of session.pendingS2C) {
const { meta } = item;
if (meta.name === 'encryption_begin') continue;
if (meta.name === 'compress' && meta.state === states.LOGIN) {
session.relayedCompress = true;
try {
relayToJava(session.client, item.meta, item.data, item.buffer);
} catch (err) {
log.error(`S2C pre-crypto compress error:`, err.message);
}
continue;
}
heldLogin.push(item);
}
session.pendingS2C.length = 0;
const { login: afterCrypto, config: heldConfig } = partitionAfterCrypto(heldLogin);
session.pendingConfig.push(...heldConfig);
try {
await enableJavaEncryption(session.client, this.server, this.server.options);
} catch (err) {
session.javaCryptoStarting = false;
log.error('Java encryption setup failed:', err.message);
session.client.end('Sniffer encryption setup failed');
cleanup('encryption_error');
return;
}
session.holdS2C = false;
session.waitingJavaCrypto = false;
session.gate = GATE.AWAIT_LOGIN_ACK;
session.packetLog.writeMeta({ type: 'java_crypto_ready' });
log.info(`Java crypto ready for ${session.username}, awaiting login_acknowledged`);
const successPackets = [
...afterCrypto.filter((p) => p.meta.name === 'success'),
...session.pendingS2C.filter((p) => p.meta.name === 'success'),
];
session.pendingS2C = session.pendingS2C.filter((p) => p.meta.name !== 'success');
for (const { data, meta, buffer } of successPackets) {
try {
relayToJava(session.client, meta, data, buffer);
} catch (err) {
log.error(`S2C success flush error:`, err.message);
}
}
}
stop() {
if (this.activeSession) {
const session = this.activeSession;
this.activeSession = null;
try { session.client.end('Sniffer shutting down'); } catch (_) {}
if (session.upstream && !session.upstream.ended) {
try { session.upstream.end('Sniffer shutting down'); } catch (_) {}
}
session.packetLog.close('shutdown');
}
if (this.server) {
this.server.close();
this.server = null;
}
}
}
module.exports = { MitmProxy };

250
src/sniffer/PacketLog.js Normal file
View File

@@ -0,0 +1,250 @@
const fs = require('fs');
const path = require('path');
/** JSONL lines longer than this go to sessionDir/line-NNNNNN.jsonl with a short ref in the main log. */
const MAX_INLINE_LINE = 180;
const LARGE_PACKETS = new Set([
'map_chunk',
'chunk_data',
'level_chunk_with_light',
'light_update',
'custom_payload',
]);
/**
* Append-only JSONL packet log for login / handoff analysis.
*/
class PacketLog {
/**
* @param {object} opts
* @param {string} opts.logDir
* @param {string} opts.sessionId
* @param {boolean} [opts.includePayload=true]
*/
constructor(opts) {
this.includePayload = opts.includePayload !== false;
this.sessionId = opts.sessionId;
this._seq = 0;
const dir = path.resolve(opts.logDir);
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, `${opts.sessionId}.jsonl`);
this._spillDir = path.join(dir, opts.sessionId);
fs.mkdirSync(this._spillDir, { recursive: true });
this._spillCount = 0;
this._stream = fs.createWriteStream(file, { flags: 'a' });
this._closed = false;
this.filePath = file;
this.spillDir = this._spillDir;
this.writeMeta({
type: 'session_start',
sessionId: opts.sessionId,
clientUsername: opts.clientUsername,
server: opts.server,
version: opts.version,
});
}
writeMeta(record) {
this._write({ ...record, t: new Date().toISOString() });
}
/**
* @param {'C2S'|'S2C'} dir
* @param {object} meta - minecraft-protocol packet meta
* @param {object} data - parsed params
* @param {Buffer} [rawBuffer]
* @param {object} [extra]
*/
logUnparsed(dir, state, frame, message) {
this._write({
type: 'parse_error',
seq: ++this._seq,
t: new Date().toISOString(),
dir,
state,
frameBytes: frame.length,
headHex: frame.subarray(0, Math.min(16, frame.length)).toString('hex'),
error: message,
forwarded: 'tcp',
});
}
logOpaque(dir, bytes, extra = {}) {
if (extra.encrypted) {
this._encryptedOpaque = this._encryptedOpaque || { C2S: 0, S2C: 0, bytes: { C2S: 0, S2C: 0 } };
this._encryptedOpaque[dir]++;
this._encryptedOpaque.bytes[dir] += bytes;
const n = this._encryptedOpaque[dir];
if (n !== 1 && n !== 5 && n % 100 !== 0) return;
this._write({
type: 'opaque_summary',
seq: ++this._seq,
t: new Date().toISOString(),
dir,
encryptedChunks: n,
encryptedBytes: this._encryptedOpaque.bytes[dir],
forwarded: 'tcp',
note: 'Encrypted play traffic (map_chunk etc.) is forwarded but not decoded on a transparent pipe.',
});
return;
}
this._write({
type: 'opaque',
seq: ++this._seq,
t: new Date().toISOString(),
dir,
bytes,
forwarded: 'tcp',
...extra,
});
}
logPacket(dir, meta, data, rawBuffer, extra = {}) {
const entry = {
type: 'packet',
seq: ++this._seq,
t: new Date().toISOString(),
dir,
state: meta.state,
name: meta.name,
clientState: extra.clientState,
upstreamState: extra.upstreamState,
forwarded: extra.forwarded ?? null,
};
if (rawBuffer) {
entry.rawBytes = rawBuffer.length;
}
if (this.includePayload && !LARGE_PACKETS.has(meta.name)) {
entry.data = summarizePacket(meta.name, data);
} else {
entry.summary = summarizeLargePacket(meta.name, data, rawBuffer);
}
this._write(entry);
}
close(reason) {
if (this._closed) return;
this.writeMeta({ type: 'session_end', reason: reason || 'closed' });
this._closed = true;
this._stream.end();
}
_write(obj) {
if (this._closed) return;
const line = JSON.stringify(obj);
if (line.length + 1 <= MAX_INLINE_LINE) {
this._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)));
}
_writeRaw(line) {
const ok = this._stream.write(`${line}\n`);
if (!ok) {
this._stream.once('drain', () => {});
}
}
}
function buildPreview(obj) {
if (obj.type === 'packet') {
const parts = [obj.dir, obj.state, obj.name].filter(Boolean).join(' ');
const extra = obj.rawBytes != null ? ` ${obj.rawBytes}b` : '';
const fwd = obj.forwarded ? `${obj.forwarded}` : '';
return `${parts}${extra}${fwd}`.trim();
}
if (obj.type === 'session_start') {
return `${obj.clientUsername ?? '?'}${obj.server ?? '?'}`;
}
if (obj.summary && typeof obj.summary === 'object') {
const s = obj.summary;
if (s.id) return `${s.name ?? 'packet'} id=${s.id}`;
if (s.channel) return `channel=${s.channel}`;
return JSON.stringify(s);
}
if (obj.reason) return String(obj.reason);
if (obj.username) return String(obj.username);
const compact = JSON.stringify(obj.data ?? obj);
return compact.length > 48 ? `${compact.slice(0, 45)}` : compact;
}
/** Compact pointer + preview kept in the main log (≤ MAX_INLINE_LINE). */
function buildSpillRef(obj, spillFile) {
const ref = { _spill: spillFile, type: obj.type };
if (obj.seq != null) ref.seq = obj.seq;
ref.preview = buildPreview(obj);
let encoded = JSON.stringify(ref);
while (encoded.length > MAX_INLINE_LINE && ref.preview.length > 6) {
ref.preview = `${ref.preview.slice(0, ref.preview.length - 3)}`;
encoded = JSON.stringify(ref);
}
return ref;
}
function summarizePacket(name, data) {
if (data == null) return data;
if (Buffer.isBuffer(data)) {
return { _type: 'Buffer', length: data.length };
}
if (typeof data !== 'object') return data;
try {
return JSON.parse(JSON.stringify(data, replacer));
} catch {
return { _type: 'Unserializable', name };
}
}
function replacer(_key, value) {
if (Buffer.isBuffer(value)) {
return { _type: 'Buffer', length: value.length };
}
if (typeof value === 'string' && value.length > 512) {
return `${value.slice(0, 512)}…(${value.length} chars)`;
}
if (Array.isArray(value) && value.length > 32) {
return { _type: 'Array', length: value.length, sample: value.slice(0, 3) };
}
return value;
}
function summarizeLargePacket(name, data, rawBuffer) {
const summary = { name };
if (rawBuffer) summary.rawBytes = rawBuffer.length;
if (!data || typeof data !== 'object') return summary;
if (name === 'map_chunk' || name === 'level_chunk_with_light') {
summary.x = data.x;
summary.z = data.z;
if (data.groundUp != null) summary.groundUp = data.groundUp;
} else if (name === 'registry_data') {
summary.id = data.id;
if (data.codec) summary.hasCodec = true;
} else if (name === 'player_info') {
summary.action = data.action;
summary.entryCount = data.data?.length ?? 0;
} else if (name === 'custom_payload') {
summary.channel = data.channel;
if (data.data) {
summary.dataLength = Buffer.isBuffer(data.data) ? data.data.length : String(data.data).length;
}
} else {
for (const [k, v] of Object.entries(data)) {
if (Buffer.isBuffer(v)) summary[k] = { bytes: v.length };
else if (typeof v === 'string' && v.length > 64) summary[k] = `${v.length} chars`;
else summary[k] = v;
}
}
return summary;
}
module.exports = { PacketLog };

164
src/sniffer/StreamTap.js Normal file
View File

@@ -0,0 +1,164 @@
const mc = require('minecraft-protocol');
const { createSplitter } = require('minecraft-protocol/src/transforms/framing');
const { createDeserializer } = require('minecraft-protocol/src/transforms/serializer');
const states = mc.states;
const S2C_CONFIGURATION_PACKETS = new Set([
'registry_data',
'feature_flags',
'tags',
'finish_configuration',
'custom_payload',
'reset_chat',
'code_of_conduct',
'server_data',
]);
/**
* Parse-only tap: frames are split and parsed for logs; bytes are not modified.
*/
class StreamTap {
constructor(dir, version, packetLog, hooks = {}) {
this.dir = dir;
this.version = version;
this.packetLog = packetLog;
this.hooks = hooks;
this.session = hooks.session || {
state: states.HANDSHAKING,
compressionThreshold: -1,
encrypted: false,
};
this.state = this.session.state;
this.stats = hooks.stats;
this.splitter = createSplitter();
this._parsers = new Map();
this.splitter.on('data', (frame) => this._onFrame(frame));
}
feed(chunk) {
if (this.session.encrypted) {
this.stats.encryptedBytes[this.dir] += chunk.length;
this.stats.encryptedChunks[this.dir] += 1;
this.packetLog.logOpaque(this.dir, chunk.length, { encrypted: true });
return;
}
this.stats.rawBytes[this.dir] += chunk.length;
this.splitter.write(chunk);
}
_syncState() {
this.state = this.session.state;
}
_parser() {
this._syncState();
const key = `${this.state}:${this.dir}`;
if (!this._parsers.has(key)) {
this._parsers.set(
key,
createDeserializer({
state: this.state,
isServer: this.dir === 'C2S',
version: this.version,
noErrorLogging: true,
}),
);
}
return this._parsers.get(key);
}
_parseFrame(frame) {
const payload =
this.session.compressionThreshold >= 0 ? this._decompressFrame(frame) : frame;
return this._parser().parsePacketBuffer(payload);
}
_decompressFrame(frame) {
const { readVarInt } = require('protodef').types.varint;
const zlib = require('zlib');
const { value, size } = readVarInt(frame, 0);
if (value === 0) return frame.slice(size);
return zlib.inflateSync(frame.slice(size), { finishFlush: 2 });
}
_onFrame(frame) {
if (this.session.encrypted) return;
this.stats.frames[this.dir] += 1;
let parsed;
try {
parsed = this._parseFrame(frame);
} catch (err) {
this.stats.parseErrors[this.dir] += 1;
this.packetLog.logUnparsed(this.dir, this.state, frame, err.message);
return;
}
if (!parsed) return;
const name = parsed.data.name;
const data = parsed.data.params;
this.stats.packets[this.dir] += 1;
this.packetLog.logPacket(this.dir, { state: this.state, name }, data, frame, {
forwarded: 'tcp',
});
if (name === 'login_start' && data.username) {
this.hooks.onUsername?.(data.username);
}
if (name === 'set_protocol' && data.serverHost) {
const mode = data.nextState === 1 ? 'status_ping' : data.nextState === 2 ? 'login' : `nextState_${data.nextState}`;
this.packetLog.writeMeta({
type: 'handshake_intent',
mode,
serverHost: data.serverHost,
serverPort: data.serverPort,
protocolVersion: data.protocolVersion,
});
}
if ((name === 'set_compression' || name === 'compress') && data.threshold != null) {
this.session.compressionThreshold = data.threshold;
this._parsers.clear();
this.packetLog.writeMeta({ type: 'compression', threshold: data.threshold, dir: this.dir });
}
// Encryption starts after the client sends its encryption response (C2S), not on S2C offer.
if (this.dir === 'C2S' && name === 'encryption_begin') {
this.session.encrypted = true;
this.packetLog.writeMeta({ type: 'encryption_started', dir: this.dir });
}
this._advanceState(name, data);
}
_setState(next) {
if (next === this.session.state) return;
this.session.state = next;
this.state = next;
this._parsers.clear();
}
_advanceState(name, data) {
if (this.dir === 'C2S') {
if (this.state === states.HANDSHAKING && name === 'set_protocol') {
this._setState(data.nextState === 1 ? states.STATUS : states.LOGIN);
} else if (this.state === states.LOGIN && name === 'login_acknowledged') {
this._setState(states.CONFIGURATION);
} else if (this.state === states.CONFIGURATION && name === 'finish_configuration') {
this._setState(states.PLAY);
}
return;
}
if (this.state === states.LOGIN && S2C_CONFIGURATION_PACKETS.has(name)) {
this._setState(states.CONFIGURATION);
} else if (this.state === states.CONFIGURATION && name === 'finish_configuration') {
this._setState(states.PLAY);
}
}
}
module.exports = { StreamTap };

View File

@@ -0,0 +1,176 @@
const net = require('net');
const mc = require('minecraft-protocol');
const { createLogger } = require('../utils/logger');
const { PacketLog } = require('./PacketLog');
const { StreamTap } = require('./StreamTap');
const log = createLogger('Sniffer');
/**
* TCP-transparent proxy: bytes forwarded unchanged; tap parses for JSONL only.
*/
class TransparentProxy {
constructor(config) {
this.config = config;
this.server = null;
this.activeSession = null;
}
start() {
const sniffer = this.config.sniffer;
const targetHost = this.config.server.host;
const targetPort = this.config.server.port;
this.server = net.createServer((clientSocket) => {
this._onClientConnect(clientSocket, targetHost, targetPort, sniffer);
});
this.server.on('error', (err) => {
log.error('Sniffer listen error:', err.message);
});
this.server.listen(sniffer.port, sniffer.host || '0.0.0.0', () => {
log.info(
`TCP sniffer on ${sniffer.host || '0.0.0.0'}:${sniffer.port}${targetHost}:${targetPort} (${this.config.server.version})`,
);
log.info('Join the server in-game (not just refresh the server list)');
log.info(`Logs: ${sniffer.logDir}`);
});
}
_onClientConnect(clientSocket, targetHost, targetPort, sniffer) {
const addr = clientSocket.remoteAddress || '?';
if (this.activeSession) {
log.warn(`Rejecting connection from ${addr} — session already active`);
clientSocket.destroy();
return;
}
let clientUsername = 'unknown';
const packetLog = new PacketLog({
logDir: sniffer.logDir,
sessionId: `session-${Date.now()}`,
clientUsername,
server: `${targetHost}:${targetPort}`,
version: this.config.server.version,
includePayload: sniffer.includePayload,
});
const session = {
state: mc.states.HANDSHAKING,
compressionThreshold: -1,
encrypted: false,
};
const stats = {
rawBytes: { C2S: 0, S2C: 0 },
frames: { C2S: 0, S2C: 0 },
packets: { C2S: 0, S2C: 0 },
parseErrors: { C2S: 0, S2C: 0 },
encryptedBytes: { C2S: 0, S2C: 0 },
encryptedChunks: { C2S: 0, S2C: 0 },
};
const c2sTap = new StreamTap('C2S', this.config.server.version, packetLog, {
session,
stats,
onUsername: (name) => {
clientUsername = name;
packetLog.writeMeta({ type: 'username', username: name });
},
});
const s2cTap = new StreamTap('S2C', this.config.server.version, packetLog, { session, stats });
const upstreamSocket = net.connect({ host: targetHost, port: targetPort });
let upstreamReady = false;
const pendingToUpstream = [];
this.activeSession = { clientSocket, upstreamSocket, packetLog };
let cleaned = false;
const cleanup = (reason) => {
if (cleaned) return;
cleaned = true;
packetLog.writeMeta({
type: 'session_stats',
reason,
username: clientUsername,
protocolState: session.state,
encrypted: session.encrypted,
stats,
});
try { clientSocket.destroy(); } catch (_) {}
try { upstreamSocket.destroy(); } catch (_) {}
packetLog.close(reason);
this.activeSession = null;
};
const flushUpstream = () => {
for (const buf of pendingToUpstream) {
upstreamSocket.write(buf);
}
pendingToUpstream.length = 0;
};
clientSocket.on('data', (chunk) => {
if (upstreamReady && !upstreamSocket.destroyed) {
upstreamSocket.write(chunk);
} else {
pendingToUpstream.push(chunk);
}
c2sTap.feed(chunk);
});
upstreamSocket.on('data', (chunk) => {
if (!clientSocket.destroyed) clientSocket.write(chunk);
s2cTap.feed(chunk);
});
upstreamSocket.on('connect', () => {
upstreamReady = true;
flushUpstream();
log.info(`Upstream TCP connected (${addr}${targetHost}:${targetPort})`);
packetLog.writeMeta({ type: 'upstream_connect' });
});
upstreamSocket.on('error', (err) => {
log.error(`Upstream error: ${err.message}`);
cleanup('upstream_error');
});
clientSocket.on('error', (err) => {
log.error(`Client socket error: ${err.message}`);
cleanup('client_error');
});
upstreamSocket.on('close', () => {
log.info(`Session ended (upstream closed) ${clientUsername} (${addr})`);
cleanup('upstream_close');
});
clientSocket.on('close', () => {
if (!cleaned) {
log.info(`Session ended (client closed) ${clientUsername} (${addr})`);
cleanup('client_close');
}
});
log.info(`Client connected ${addr} — logging to ${packetLog.filePath}`);
}
stop() {
if (this.activeSession) {
const { clientSocket, upstreamSocket, packetLog } = this.activeSession;
try { clientSocket.destroy(); } catch (_) {}
try { upstreamSocket.destroy(); } catch (_) {}
packetLog.close('shutdown');
this.activeSession = null;
}
if (this.server) {
this.server.close();
this.server = null;
}
}
}
module.exports = { TransparentProxy };

47
src/sniffer/index.js Normal file
View File

@@ -0,0 +1,47 @@
const path = require('path');
const { loadConfig } = require('../config');
const { createLogger } = require('../utils/logger');
const { MitmProxy } = require('./MitmProxy');
const log = createLogger('SnifferMain');
console.log(`
\x1b[36m Packet Sniffer Proxy\x1b[0m
MITM mode — decrypt both legs, log packet names
`);
let config;
try {
config = loadConfig();
config.sniffer = Object.assign(
{
host: '0.0.0.0',
port: 25567,
onlineMode: false,
upstreamAuth: 'microsoft',
logDir: path.join(__dirname, '..', '..', 'logs', 'sniffer'),
includePayload: true,
},
config.sniffer,
);
log.info(`Upstream: ${config.server.host}:${config.server.port} (${config.server.version})`);
log.info(`Listen: ${config.sniffer.host || '0.0.0.0'}:${config.sniffer.port}`);
log.info(`Client online-mode: ${config.sniffer.onlineMode}`);
log.info(`Upstream auth: ${config.sniffer.upstreamAuth}`);
log.info(`Logs: ${path.resolve(config.sniffer.logDir)}`);
} catch (err) {
log.error(err.message);
process.exit(1);
}
const proxy = new MitmProxy(config);
proxy.start();
function shutdown(signal) {
log.info(`${signal}, shutting down...`);
proxy.stop();
setTimeout(() => process.exit(0), 500);
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));

View File

@@ -0,0 +1,69 @@
const crypto = require('crypto');
const NodeRSA = require('node-rsa');
/**
* Offer encryption_begin to the Java client using the sniffer's RSA key (MITM leg).
* @param {import('minecraft-protocol').Client} client - server-side peer (Java)
* @param {import('minecraft-protocol').Server} server
* @param {object} options - createServer options
* @returns {Promise<void>} resolves when Java leg encryption is active
*/
function enableJavaEncryption(client, server, options) {
const onlineMode = options['online-mode'] === true;
return new Promise((resolve, reject) => {
const serverId = onlineMode ? crypto.randomBytes(4).toString('hex') : '-';
client.verifyToken = crypto.randomBytes(4);
const publicKeyStrArr = server.serverKey.exportKey('pkcs8-public-pem').split('\n');
let publicKeyStr = '';
for (let i = 1; i < publicKeyStrArr.length - 1; i++) {
publicKeyStr += publicKeyStrArr[i];
}
client.publicKey = Buffer.from(publicKeyStr, 'base64');
client.once('encryption_begin', (packet) => {
try {
const sharedSecret = decryptSharedSecret(server, client, packet);
client.setEncryption(sharedSecret);
resolve();
} catch (err) {
reject(err);
}
});
client.write('encryption_begin', {
serverId,
publicKey: client.publicKey,
verifyToken: client.verifyToken,
shouldAuthenticate: onlineMode,
});
});
}
function decryptSharedSecret(server, client, packet) {
const keyRsa = new NodeRSA(server.serverKey.exportKey('pkcs1'), 'private', {
encryptionScheme: 'pkcs1',
});
keyRsa.setOptions({ environment: 'browser' });
if (packet.hasVerifyToken === false && packet.crypto) {
const { concat } = require('minecraft-protocol/src/transforms/binaryStream');
const signable = concat('buffer', client.verifyToken, 'i64', packet.crypto.salt);
if (
client.profileKeys &&
!crypto.verify('sha256WithRSAEncryption', signable, client.profileKeys.public, packet.crypto.messageSignature)
) {
throw new Error('invalid_public_key_signature');
}
} else if (packet.verifyToken != null) {
const encryptedToken = packet.hasVerifyToken ? packet.crypto?.verifyToken : packet.verifyToken;
const decryptedToken = keyRsa.decrypt(encryptedToken);
if (!client.verifyToken.equals(decryptedToken)) {
throw new Error('invalid_verify_token');
}
}
return keyRsa.decrypt(packet.sharedSecret);
}
module.exports = { enableJavaEncryption };

198
src/sniffer/mitmGate.js Normal file
View File

@@ -0,0 +1,198 @@
const mc = require('minecraft-protocol');
const { relayToJava } = require('./mitmRelay');
const states = mc.states;
/** Java client join phases after upstream login (1.20.2+ configuration). */
const GATE = {
LOGIN: 'login',
AWAIT_LOGIN_ACK: 'await_login_ack',
CONFIGURATION: 'configuration',
PLAY: 'play',
};
const PREBRIDGE_C2S = new Set(['login_plugin_response', 'cookie_response']);
/** Upstream mc client (keepAlive: true) auto-responds; relaying from Java duplicates and kicks. */
const UPSTREAM_OWNED_C2S = new Set(['keep_alive']);
/**
* Upstream is a separate mc client that already completed its own login/config.
* Only relay Java C2S once both legs are in play.
*/
function canRelayC2S(session, meta) {
if (UPSTREAM_OWNED_C2S.has(meta.name)) return false;
if (session.gate === GATE.PLAY) return true;
return PREBRIDGE_C2S.has(meta.name);
}
function c2sForwardLabel(session, meta) {
if (UPSTREAM_OWNED_C2S.has(meta.name)) return 'blocked';
if (canRelayC2S(session, meta)) return 'mitm';
return 'pending';
}
/** How to handle upstream S2C for logging and routing. */
function classifyS2C(session, meta) {
if (session.gate === GATE.PLAY) return 'relay';
if (session.gate === GATE.CONFIGURATION && meta.state === states.CONFIGURATION) return 'relay';
if (shouldBufferS2C(session, meta)) return 'buffer';
return 'hold';
}
function shouldBufferS2C(session, meta) {
if (session.gate === GATE.PLAY) return false;
if (session.gate === GATE.CONFIGURATION) return meta.state === states.PLAY;
if (session.gate === GATE.AWAIT_LOGIN_ACK) return true;
return false;
}
function flushQueue(session, queue) {
for (const { data, meta, buffer } of queue) {
try {
relayToJava(session.client, meta, data, buffer);
} catch (err) {
throw new Error(`${meta.state}.${meta.name}: ${err.message}`);
}
}
queue.length = 0;
}
function flushPendingConfig(session) {
flushQueue(session, session.pendingConfig);
}
/** Join-critical play packets before terrain chunks. */
const PLAY_JOIN_ORDER = {
login: 0,
custom_payload: 1,
server_data: 2,
difficulty: 3,
abilities: 4,
held_item_slot: 5,
recipe_book_settings: 6,
recipe_book_add: 7,
entity_status: 8,
declare_recipes: 9,
position: 10,
player_info: 11,
update_view_distance: 12,
simulation_distance: 13,
spawn_position: 14,
initialize_world_border: 15,
update_time: 16,
game_state_change: 17,
set_ticking_state: 18,
step_tick: 19,
window_items: 20,
set_slot: 21,
system_chat: 22,
declare_commands: 23,
update_health: 24,
experience: 25,
};
const PLAY_CHUNK_PACKETS = new Set([
'map_chunk',
'update_light',
'unload_chunk',
'chunk_batch_start',
'chunk_batch_finished',
]);
function sortPlayPending(pending) {
pending.sort((a, b) => {
const oa = PLAY_JOIN_ORDER[a.meta.name] ?? 100;
const ob = PLAY_JOIN_ORDER[b.meta.name] ?? 100;
return oa - ob;
});
}
function isStalePlayS2C(meta) {
return meta.name === 'keep_alive';
}
function flushPendingPlay(session) {
sortPlayPending(session.pendingPlay);
const join = [];
const world = [];
for (const item of session.pendingPlay) {
if (isStalePlayS2C(item.meta)) continue;
if (PLAY_CHUNK_PACKETS.has(item.meta.name)) world.push(item);
else join.push(item);
}
session.pendingPlay.length = 0;
flushQueue(session, join);
if (world.length) {
setImmediate(() => {
flushQueue(session, world);
});
}
}
function queueBufferedS2C(session, data, meta, buffer) {
if (isStalePlayS2C(meta)) return;
const item = { data, meta, buffer };
if (meta.state === states.PLAY) {
session.pendingPlay.push(item);
} else {
session.pendingConfig.push(item);
}
}
function hasPendingSuccess(session) {
return session.pendingS2C.some((p) => p.meta.name === 'success');
}
function onJavaLoginAcknowledged(session) {
if (session.gate !== GATE.AWAIT_LOGIN_ACK) return false;
session.client.state = states.CONFIGURATION;
session.gate = GATE.CONFIGURATION;
flushPendingConfig(session);
return true;
}
function onJavaFinishConfiguration(session, packetLog) {
if (session.gate !== GATE.CONFIGURATION) return false;
session.client.state = states.PLAY;
session.gate = GATE.PLAY;
session.bridged = true;
flushPendingPlay(session);
packetLog.writeMeta({ type: 'bridge_active' });
return true;
}
function queueHeldS2C(session, data, meta, buffer) {
const item = { data, meta, buffer };
if (meta.state === states.CONFIGURATION) {
session.pendingConfig.push(item);
} else {
session.pendingS2C.push(item);
}
}
function partitionAfterCrypto(pendingS2C) {
const login = [];
const config = [];
for (const item of pendingS2C) {
if (item.meta.state === states.CONFIGURATION) config.push(item);
else login.push(item);
}
return { login, config };
}
module.exports = {
GATE,
canRelayC2S,
c2sForwardLabel,
classifyS2C,
shouldBufferS2C,
hasPendingSuccess,
flushPendingConfig,
flushPendingPlay,
onJavaLoginAcknowledged,
onJavaFinishConfiguration,
queueHeldS2C,
queueBufferedS2C,
partitionAfterCrypto,
};

54
src/sniffer/mitmLogin.js Normal file
View File

@@ -0,0 +1,54 @@
const crypto = require('crypto');
const uuid = require('minecraft-protocol/src/datatypes/uuid');
const { concat } = require('minecraft-protocol/src/transforms/binaryStream');
const { mojangPublicKeyPem } = require('minecraft-protocol/src/server/constants');
/**
* Apply login_start fields without completing local login (no success packet).
*/
function applyLoginStartIdentity(client, packet, _server, options) {
const mcData = require('minecraft-data')(client.version);
client.supportFeature = mcData.supportFeature;
client.username = packet.username;
if (packet.playerUUID) {
client.uuid = packet.playerUUID;
}
if (packet.signature && mcData.supportFeature('signatureEncryption')) {
if (packet.signature.timestamp < BigInt(Date.now())) {
throw new Error('expired_public_key');
}
const publicKey = crypto.createPublicKey({
key: packet.signature.publicKey,
format: 'der',
type: 'spki',
});
const signable = mcData.supportFeature('profileKeySignatureV2')
? concat('UUID', packet.playerUUID, 'i64', packet.signature.timestamp, 'buffer', publicKey.export({ type: 'spki', format: 'der' }))
: Buffer.from(`${packet.signature.timestamp}${publicKeyToPem(packet.signature.publicKey)}`, 'utf8');
if (!crypto.verify('RSA-SHA1', signable, crypto.createPublicKey(mojangPublicKeyPem), packet.signature.signature)) {
throw new Error('invalid_public_key_signature');
}
client.profileKeys = { public: publicKey };
}
if (options['online-mode'] !== true && !client.uuid) {
client.uuid = uuid.nameToMcOfflineUUID(client.username);
}
}
function publicKeyToPem(mcPubKeyBuffer) {
let pem = '-----BEGIN RSA PUBLIC KEY-----\n';
let base64PubKey = mcPubKeyBuffer.toString('base64');
const maxLineLength = 64;
while (base64PubKey.length > 0) {
pem += `${base64PubKey.substring(0, maxLineLength)}\n`;
base64PubKey = base64PubKey.substring(maxLineLength);
}
pem += '-----END RSA PUBLIC KEY-----\n';
return pem;
}
module.exports = { applyLoginStartIdentity };

108
src/sniffer/mitmRelay.js Normal file
View File

@@ -0,0 +1,108 @@
const { RAW_FORWARD_PACKETS } = require('../constants/rawPackets');
/** Configuration packets forwarded with writeRaw (byte-identical NBT). */
const CONFIG_RAW_PACKETS = new Set([
'registry_data',
'feature_flags',
'tags',
'finish_configuration',
'custom_payload',
'reset_chat',
'code_of_conduct',
'server_data',
]);
const COMPRESS_PACKETS = new Set(['compress', 'set_compression']);
function shouldWriteRaw(meta, buffer) {
if (!buffer || buffer.length === 0) return false;
if (meta.state === 'configuration' && CONFIG_RAW_PACKETS.has(meta.name)) return true;
if (meta.state === 'play' && RAW_FORWARD_PACKETS.has(meta.name)) return true;
return false;
}
/**
* @param {import('minecraft-protocol').Client} target
*/
function relayPacket(target, meta, data, buffer) {
if (shouldWriteRaw(meta, buffer)) {
target.writeRaw(buffer);
return 'raw';
}
// Large login success (skins) must stay byte-identical once compression is negotiated.
if (buffer?.length && meta.state === 'login' && meta.name === 'success') {
target.writeRaw(buffer);
return 'raw';
}
// Play-phase MITM: prefer wire-identical bytes (movement, chat, etc.).
if (buffer?.length && meta.state === 'play') {
target.writeRaw(buffer);
return 'raw';
}
target.write(meta.name, data, meta.state);
return 'parsed';
}
function syncCompression(target, name, data) {
if (!COMPRESS_PACKETS.has(name) || data.threshold == null) return;
target.compressionThreshold = data.threshold;
}
/** Login-phase S2C order the Java client expects (lower = earlier). */
const LOGIN_FORWARD_ORDER = {
compress: 0,
encryption_begin: 1,
success: 2,
login_plugin_request: 3,
cookie_request: 4,
disconnect: 99,
};
function sortLoginPending(pending) {
pending.sort((a, b) => {
const oa = LOGIN_FORWARD_ORDER[a.meta.name] ?? 50;
const ob = LOGIN_FORWARD_ORDER[b.meta.name] ?? 50;
return oa - ob;
});
}
/**
* Login compress must hit the wire before the compressor is enabled; otherwise
* writeRaw adds a spurious 0-length prefix and the client fails to decode.
*/
function relayLoginCompressToJava(client, meta, data, buffer) {
if (client.cipher != null) {
return 'skipped_late_compress';
}
if (client.compressor != null) {
syncCompression(client, meta.name, data);
return relayPacket(client, meta, data, buffer);
}
if (buffer?.length) {
client.writeRaw(buffer);
} else {
client.write(meta.name, data, meta.state);
}
syncCompression(client, meta.name, data);
return 'login_compress';
}
/**
* Forward S2C to Java; skip late login compress after encryption is already on.
*/
function relayToJava(client, meta, data, buffer) {
if (meta.name === 'compress' && meta.state === 'login') {
return relayLoginCompressToJava(client, meta, data, buffer);
}
return relayPacket(client, meta, data, buffer);
}
module.exports = {
CONFIG_RAW_PACKETS,
shouldWriteRaw,
relayPacket,
syncCompression,
sortLoginPending,
relayLoginCompressToJava,
relayToJava,
};

View File

@@ -0,0 +1,59 @@
const { GATE } = require('./mitmGate');
/**
* Build the per-connection MITM session state bag.
* @param {object} client - minecraft-protocol client
* @param {import('./PacketLog').PacketLog} packetLog
* @returns {object} session state
*/
function createMitmSession(client, packetLog) {
return {
client,
upstream: null,
bridged: false,
gate: GATE.LOGIN,
holdS2C: false,
pendingS2C: [],
pendingConfig: [],
pendingPlay: [],
waitingJavaCrypto: false,
javaCryptoStarting: false,
relayedCompress: false,
statusPipe: null,
packetLog,
username: 'unknown',
cleaned: false,
};
}
/**
* Build the cleanup closure for a MITM session.
* @param {object} session
* @param {import('./PacketLog').PacketLog} packetLog
* @param {{ activeSession: object|null }} proxy - MitmProxy instance (mutated on cleanup)
* @returns {function(string): void}
*/
function createSessionCleanup(session, packetLog, proxy) {
return (reason) => {
if (session.cleaned) return;
session.cleaned = true;
if (session.statusPipe) {
try { session.statusPipe.client.destroy(); } catch (_) {}
try { session.statusPipe.upstream.destroy(); } catch (_) {}
}
if (session.upstream && !session.upstream.ended) {
try { session.upstream.end(reason); } catch (_) {}
}
packetLog.writeMeta({
type: 'session_stats',
reason,
username: session.username,
bridged: session.bridged,
});
packetLog.close(reason);
if (proxy.activeSession === session) proxy.activeSession = null;
};
}
module.exports = { createMitmSession, createSessionCleanup };

155
src/sniffer/mitmUpstream.js Normal file
View File

@@ -0,0 +1,155 @@
const net = require('net');
const mc = require('minecraft-protocol');
const { createLogger } = require('../utils/logger');
const { relayToJava, syncCompression } = require('./mitmRelay');
const { classifyS2C, queueHeldS2C, queueBufferedS2C } = require('./mitmGate');
const log = createLogger('Sniffer');
const states = mc.states;
/**
* Pipe a status/ping handshake directly at the TCP level (no decryption needed).
* @param {object} session
* @param {{ server: { host: string, port: number } }} config
* @param {import('./PacketLog').PacketLog} packetLog
* @param {{ activeSession: object|null }} proxy - MitmProxy instance
*/
function startStatusPipe(session, config, packetLog, proxy) {
const { host, port } = config.server;
const upstream = net.connect({ host, port });
session.statusPipe = { client: session.client.socket, upstream };
packetLog.writeMeta({ type: 'handshake_intent', mode: 'status_ping' });
session.client.socket.pipe(upstream);
upstream.pipe(session.client.socket);
upstream.on('connect', () => packetLog.writeMeta({ type: 'upstream_connect', mode: 'status_tcp' }));
let statusDone = false;
const endStatus = () => {
if (statusDone) return;
statusDone = true;
if (proxy.activeSession === session) proxy.activeSession = null;
packetLog.close('status_done');
};
upstream.on('close', endStatus);
session.client.socket.on('close', endStatus);
}
/**
* Create the upstream mc.createClient and wire the S2C packet relay.
* @param {object} session
* @param {{ server: { host: string, port: number, version: string }, sniffer: { upstreamAuth?: string } }} config
* @param {function(string): void} cleanup
* @param {object} callbacks - { onCompressBeforeCrypto, onEncryptionBegin, onSuccessNoEncryption }
*/
function startUpstream(session, config, cleanup, callbacks) {
const { host, port, version } = config.server;
const auth = config.sniffer.upstreamAuth || 'microsoft';
const upstream = mc.createClient({
host,
port,
username: session.username,
version,
auth,
hideErrors: true,
keepAlive: true,
checkTimeoutInterval: 60000,
});
session.upstream = upstream;
upstream.on('connect', () => {
log.info(`Upstream connected for ${session.username}`);
session.packetLog.writeMeta({ type: 'upstream_connect' });
});
upstream.on('packet', (data, meta, buffer) => {
const s2cAction = classifyS2C(session, meta);
session.packetLog.logPacket('S2C', meta, data, buffer, {
forwarded: s2cAction,
clientState: session.client.state,
upstreamState: upstream.state,
gate: session.gate,
});
syncCompression(upstream, meta.name, data);
if (s2cAction === 'relay') {
try {
relayToJava(session.client, meta, data, buffer);
} catch (err) {
log.error(`S2C relay error (${meta.name}):`, err.message);
}
return;
}
if (s2cAction === 'buffer') {
queueBufferedS2C(session, data, meta, buffer);
return;
}
if (session.gate !== callbacks.GATE_LOGIN) {
return;
}
if (meta.name === 'compress' && meta.state === states.LOGIN) {
session.relayedCompress = true;
try {
relayToJava(session.client, meta, data, buffer);
} catch (err) {
log.error(`S2C compress error:`, err.message);
}
callbacks.onCompressBeforeCrypto(session);
return;
}
if (meta.name === 'encryption_begin') {
session.holdS2C = true;
session.waitingJavaCrypto = true;
callbacks.onEncryptionBegin(session);
return;
}
if (session.holdS2C) {
queueHeldS2C(session, data, meta, buffer);
if (meta.name === 'success') {
callbacks.onSuccessWhileHeld(session);
}
return;
}
if (meta.name === 'success') {
try {
relayToJava(session.client, meta, data, buffer);
callbacks.onSuccessNoEncryption(session);
} catch (err) {
log.error(`S2C success error:`, err.message);
}
return;
}
try {
relayToJava(session.client, meta, data, buffer);
} catch (err) {
log.error(`S2C relay error (${meta.name}):`, err.message);
}
});
upstream.on('end', () => {
log.info(`Upstream closed for ${session.username}`);
if (!session.cleaned && !session.client.ended) {
try { session.client.end('Upstream disconnected'); } catch (_) {}
}
cleanup('upstream_end');
});
upstream.on('error', (err) => {
log.error(`Upstream error: ${err.message}`);
if (!session.cleaned && !session.client.ended) {
try { session.client.end(err.message); } catch (_) {}
}
cleanup('upstream_error');
});
}
module.exports = { startStatusPipe, startUpstream };

160
src/state/ChunkCache.js Normal file
View File

@@ -0,0 +1,160 @@
const { createLogger } = require('../utils/logger');
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.
*/
class ChunkCache {
constructor(maxChunks = 1024) {
this.maxChunks = maxChunks;
/** @type {Map<string, object>} key "x,z" -> raw packet data */
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 = [];
}
/** @returns {string} */
_key(x, z) {
return `${x},${z}`;
}
/**
* Store a chunk from a map_chunk packet.
* We store the entire packet data object so we can replay it verbatim.
*/
handleMapChunk(data, rawBuffer) {
const key = this._key(data.x, data.z);
this.chunks.set(key, {
packetData: structuredClone(data),
rawBuffer: rawBuffer ? Buffer.from(rawBuffer) : null,
});
this._touch(key);
this._evictIfNeeded();
}
handleUpdateLight(data, rawBuffer) {
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)) {
this._touch(key);
}
}
/**
* Handle chunk unload — remove from cache.
*/
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.
*/
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));
}
}
handleMultiBlockChange(data) {
const chunkX = data.chunkCoordinates?.x;
const chunkZ = data.chunkCoordinates?.z;
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));
}
}
_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,
};
}
/**
* Get cached chunks within view distance of a center, sorted nearest-first.
* Vanilla ignores map_chunk outside the current view center — always set
* update_view_position before sending these.
*/
getChunksForReplay(centerChunkX, centerChunkZ, viewDistance) {
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) {
continue;
}
result.push(this._buildChunkEntry(stored));
}
result.sort((a, b) => {
const distA = Math.max(
Math.abs(a.packetData.x - centerChunkX),
Math.abs(a.packetData.z - centerChunkZ)
);
const distB = Math.max(
Math.abs(b.packetData.x - centerChunkX),
Math.abs(b.packetData.z - centerChunkZ)
);
return distA - distB;
});
return result;
}
hasChunkAtBlock(x, z) {
const chunkX = Math.floor(x / 16);
const chunkZ = Math.floor(z / 16);
return this.chunks.has(this._key(chunkX, chunkZ));
}
get size() {
return this.chunks.size;
}
_touch(key) {
this.accessOrder = this.accessOrder.filter(k => k !== key);
this.accessOrder.push(key);
}
_evictIfNeeded() {
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 = [];
}
}
module.exports = { ChunkCache };

166
src/state/EntityCache.js Normal file
View File

@@ -0,0 +1,166 @@
const { createLogger } = require('../utils/logger');
const { toByteAngle, sanitizeSpawnEntity } = require('../utils/angles');
const log = createLogger('EntityCache');
/**
* Tracks entities received from the server.
* Stores spawn data, metadata, equipment, effects, and position updates.
*/
class EntityCache {
constructor() {
/** @type {Map<number, object>} entityId -> entity state */
this.entities = new Map();
}
handleSpawnEntity(data) {
const entityId = data.entityId;
this.entities.set(entityId, {
spawnData: { ...data },
metadata: null,
equipment: null,
effects: [],
passengers: null,
});
}
handleEntityMetadata(data) {
const entity = this.entities.get(data.entityId);
if (entity) {
entity.metadata = { ...data };
}
}
handleEntityEquipment(data) {
const entity = this.entities.get(data.entityId);
if (entity) {
entity.equipment = { ...data };
}
}
handleEntityEffect(data) {
const entity = this.entities.get(data.entityId);
if (entity) {
// Replace existing effect of same type, or add
entity.effects = entity.effects.filter(e => e.effectId !== data.effectId);
entity.effects.push({ ...data });
}
}
handleRemoveEntityEffect(data) {
const entity = this.entities.get(data.entityId);
if (entity) {
entity.effects = entity.effects.filter(e => e.effectId !== data.effectId);
}
}
handleEntityDestroy(data) {
// data.entityIds is an array of entity IDs to destroy
const ids = data.entityIds || [];
for (const id of ids) {
this.entities.delete(id);
}
}
handleSetPassengers(data) {
const entity = this.entities.get(data.entityId);
if (entity) {
entity.passengers = { ...data };
}
}
/**
* Update entity position from various movement packets.
* We update the spawn data so replay sends correct initial position.
*/
handleEntityPosition(data) {
const entity = this.entities.get(data.entityId);
if (entity && entity.spawnData) {
if (data.x !== undefined) entity.spawnData.x = data.x;
if (data.y !== undefined) entity.spawnData.y = data.y;
if (data.z !== undefined) entity.spawnData.z = data.z;
if (data.yaw !== undefined) entity.spawnData.yaw = data.yaw;
if (data.pitch !== undefined) entity.spawnData.pitch = data.pitch;
}
}
handleSyncEntityPosition(data) {
const entity = this.entities.get(data.entityId);
if (entity && entity.spawnData) {
if (data.x !== undefined) entity.spawnData.x = data.x;
if (data.y !== undefined) entity.spawnData.y = data.y;
if (data.z !== undefined) entity.spawnData.z = data.z;
// sync_entity_position uses f32 degrees; spawn_entity expects i8 byte angles
if (data.yaw !== undefined) entity.spawnData.yaw = toByteAngle(data.yaw);
if (data.pitch !== undefined) entity.spawnData.pitch = toByteAngle(data.pitch);
}
}
handleRelEntityMove(data) {
const entity = this.entities.get(data.entityId);
if (entity && entity.spawnData) {
// delta values are fixed-point (divided by 4096)
entity.spawnData.x += (data.dX || 0) / 4096;
entity.spawnData.y += (data.dY || 0) / 4096;
entity.spawnData.z += (data.dZ || 0) / 4096;
}
}
handleEntityMoveLook(data) {
const entity = this.entities.get(data.entityId);
if (entity && entity.spawnData) {
entity.spawnData.x += (data.dX || 0) / 4096;
entity.spawnData.y += (data.dY || 0) / 4096;
entity.spawnData.z += (data.dZ || 0) / 4096;
if (data.yaw !== undefined) entity.spawnData.yaw = data.yaw;
if (data.pitch !== undefined) entity.spawnData.pitch = data.pitch;
}
}
handleEntityTeleport(data) {
const entity = this.entities.get(data.entityId);
if (entity && entity.spawnData) {
entity.spawnData.x = data.x;
entity.spawnData.y = data.y;
entity.spawnData.z = data.z;
entity.spawnData.yaw = data.yaw;
entity.spawnData.pitch = data.pitch;
}
}
/**
* Get all entities for replay.
* Returns spawn packets + metadata + equipment + effects.
*/
getAllEntities() {
const result = [];
for (const [entityId, entity] of this.entities) {
result.push({
entityId,
spawnData: sanitizeSpawnEntity(entity.spawnData),
metadata: entity.metadata,
equipment: entity.equipment,
effects: entity.effects,
passengers: entity.passengers,
});
}
return result;
}
/**
* Remove the bot's own entity ID from tracking
* (the player entity is handled separately via player state).
*/
removePlayerEntity(entityId) {
this.entities.delete(entityId);
}
get size() {
return this.entities.size;
}
clear() {
this.entities.clear();
}
}
module.exports = { EntityCache };

View File

@@ -0,0 +1,84 @@
const { createLogger } = require('../utils/logger');
const log = createLogger('InventoryCache');
/**
* Caches inventory state: window items, individual slot updates, held item.
*/
class InventoryCache {
constructor() {
/** Full window_items packet data (usually inventory slot 0) */
this.windowItems = null;
/** Individual set_slot updates (keyed by "windowId:slot") */
this.slotUpdates = new Map();
/** Currently held item slot index */
this.heldItemSlot = null;
/** set_player_inventory packet data */
this.playerInventory = null;
/** set_cursor_item packet data */
this.cursorItem = null;
}
handleWindowItems(data) {
this.windowItems = { ...data };
// Clear individual slot updates since we have a full snapshot
this.slotUpdates.clear();
}
handleSetSlot(data) {
const key = `${data.windowId}:${data.slot}`;
this.slotUpdates.set(key, { ...data });
}
handleHeldItemSlot(data) {
this.heldItemSlot = { ...data };
}
handleSetPlayerInventory(data) {
this.playerInventory = { ...data };
}
handleSetCursorItem(data) {
this.cursorItem = { ...data };
}
/**
* Get the packets needed to replay inventory state.
* @returns {Array<{name: string, data: object}>}
*/
getReplayPackets() {
const packets = [];
if (this.windowItems) {
packets.push({ name: 'window_items', data: this.windowItems });
}
// Apply any set_slot updates that came after window_items
for (const [, slotData] of this.slotUpdates) {
packets.push({ name: 'set_slot', data: slotData });
}
if (this.heldItemSlot) {
packets.push({ name: 'held_item_slot', data: this.heldItemSlot });
}
if (this.playerInventory) {
packets.push({ name: 'set_player_inventory', data: this.playerInventory });
}
if (this.cursorItem) {
packets.push({ name: 'set_cursor_item', data: this.cursorItem });
}
return packets;
}
clear() {
this.windowItems = null;
this.slotUpdates.clear();
this.heldItemSlot = null;
this.playerInventory = null;
this.cursorItem = null;
}
}
module.exports = { InventoryCache };

View File

@@ -0,0 +1,80 @@
const { createLogger } = require('../utils/logger');
const log = createLogger('JoinSyncCache');
/**
* Caches play packets from PlayerList.placeNewPlayer / PlayerAdvancements
* that the proxy client would otherwise never see (bot got them at login).
*/
class JoinSyncCache {
constructor() {
/** @type {{ name: string, data: object }|null} */
this.updateRecipes = null;
/** @type {Array<{ name: string, data: object }>} */
this.advancementPackets = [];
/** @type {Array<{ name: string, data: object }>} */
this.recipeBookAdd = [];
this.recipeBookSettings = null;
}
handlePacket(name, data) {
switch (name) {
case 'update_recipes':
case 'declare_recipes':
this.updateRecipes = { name, data: structuredClone(data) };
log.debug(`Cached ${name}`);
break;
case 'advancements':
if (data.reset) {
this.advancementPackets = [{ name, data: structuredClone(data) }];
log.info('Cached full advancements snapshot (reset)');
} else {
this.advancementPackets.push({ name, data: structuredClone(data) });
}
break;
case 'recipe_book_add':
if (data.replace) {
this.recipeBookAdd = [{ name, data: structuredClone(data) }];
} else {
this.recipeBookAdd.push({ name, data: structuredClone(data) });
}
break;
case 'recipe_book_settings':
this.recipeBookSettings = structuredClone(data);
break;
default:
break;
}
}
/**
* Packets to send after inventory, before teleport (placeNewPlayer order).
* @returns {Array<{ name: string, data: object }>}
*/
getReplayPackets() {
const packets = [];
if (this.updateRecipes) packets.push(this.updateRecipes);
if (this.recipeBookSettings) {
packets.push({ name: 'recipe_book_settings', data: this.recipeBookSettings });
}
for (const pkt of this.recipeBookAdd) {
packets.push(pkt);
}
for (const pkt of this.advancementPackets) {
packets.push(pkt);
}
return packets;
}
clear() {
this.updateRecipes = null;
this.advancementPackets = [];
this.recipeBookAdd = [];
this.recipeBookSettings = null;
}
}
module.exports = { JoinSyncCache };

274
src/state/MiscCache.js Normal file
View File

@@ -0,0 +1,274 @@
const { createLogger } = require('../utils/logger');
const { ScoreboardCache } = require('./ScoreboardCache');
const { WorldBorderCache } = require('./WorldBorderCache');
const log = createLogger('MiscCache');
/**
* Caches miscellaneous world state:
* time, weather, world border, scoreboard, boss bars, tab list, tags, etc.
*/
class MiscCache {
constructor() {
// Time
this.time = null;
// Weather (from game_state_change: reason 1 = begin rain, 2 = end rain,
// 7 = rain level, 8 = thunder level)
this.weather = {
raining: false,
rainLevel: null,
thunderLevel: null,
};
// World border (delegated)
this._worldBorder = new WorldBorderCache();
// Tab list
this.playerInfo = new Map(); // UUID -> merged player_info data
/** @type {Array<{action: object, data: object[]}>} verbatim packets for replay */
this.playerInfoPackets = [];
this.playerListHeader = null; // playerlist_header
// Scoreboard (delegated)
this._scoreboard = new ScoreboardCache();
// Boss bars
this.bossBars = new Map(); // UUID -> boss_bar data
// Tags
this.tags = null;
// Server data
this.serverData = null;
// Simulation distance / view distance
this.simulationDistance = null;
this.viewDistance = null;
// Declare commands
this.declareCommands = null;
// Update view position
this.viewPosition = null;
// Player remove tracking
this.removedPlayers = new Set();
}
handleUpdateTime(data) {
this.time = { ...data };
}
handleGameStateChange(data) {
switch (data.reason) {
case 1: // Begin rain
this.weather.raining = true;
break;
case 2: // End rain
this.weather.raining = false;
break;
case 7: // Rain level
this.weather.rainLevel = data.gameMode;
break;
case 8: // Thunder level
this.weather.thunderLevel = data.gameMode;
break;
}
}
// World border packets — delegate to WorldBorderCache
handleInitWorldBorder(data) { this._worldBorder.handleInitWorldBorder(data); }
handleWorldBorderCenter(data) { this._worldBorder.handleWorldBorderCenter(data); }
handleWorldBorderSize(data) { this._worldBorder.handleWorldBorderSize(data); }
handleWorldBorderLerpSize(data) { this._worldBorder.handleWorldBorderLerpSize(data); }
handleWorldBorderWarningDelay(data) { this._worldBorder.handleWorldBorderWarningDelay(data); }
handleWorldBorderWarningReach(data) { this._worldBorder.handleWorldBorderWarningReach(data); }
// Tab list
handlePlayerInfo(data) {
this.playerInfoPackets.push({
action: structuredClone(data.action),
data: structuredClone(data.data || []),
});
if (data.data) {
for (const entry of data.data) {
const uuid = entry.uuid;
if (!uuid) continue;
const existing = this.playerInfo.get(uuid) || {};
this.playerInfo.set(uuid, { ...existing, ...entry });
this.removedPlayers.delete(uuid);
}
}
}
handlePlayerRemove(data) {
if (data.players) {
for (const uuid of data.players) {
this.playerInfo.delete(uuid);
this.removedPlayers.add(uuid);
}
}
}
handlePlayerListHeader(data) {
this.playerListHeader = { ...data };
}
// Scoreboard — delegate to ScoreboardCache
handleScoreboardObjective(data) { this._scoreboard.handleScoreboardObjective(data); }
handleScoreboardDisplayObjective(data) { this._scoreboard.handleScoreboardDisplayObjective(data); }
handleScoreboardScore(data) { this._scoreboard.handleScoreboardScore(data); }
handleResetScore(data) { this._scoreboard.handleResetScore(data); }
handleTeams(data) { this._scoreboard.handleTeams(data); }
// Boss bars
handleBossBar(data) {
if (data.action === 1) {
// Remove
this.bossBars.delete(data.entityUUID);
} else {
const existing = this.bossBars.get(data.entityUUID) || {};
this.bossBars.set(data.entityUUID, { ...existing, ...data });
}
}
handleTags(data) {
this.tags = { ...data };
}
handleServerData(data) {
this.serverData = { ...data };
}
handleSimulationDistance(data) {
this.simulationDistance = { ...data };
}
handleUpdateViewDistance(data) {
this.viewDistance = { ...data };
}
handleDeclareCommands(data) {
this.declareCommands = { ...data };
}
handleUpdateViewPosition(data) {
this.viewPosition = { ...data };
}
/**
* Get all replay packets for misc state.
* @returns {Array<{name: string, data: object}>}
*/
getReplayPackets() {
const packets = [];
if (this.tags) {
packets.push({ name: 'tags', data: this.tags });
}
if (this.declareCommands) {
packets.push({ name: 'declare_commands', data: this.declareCommands });
}
if (this.serverData) {
packets.push({ name: 'server_data', data: this.serverData });
}
if (this.time) {
packets.push({ name: 'update_time', data: this.time });
}
// Weather
if (this.weather.raining) {
packets.push({ name: 'game_state_change', data: { reason: 1, gameMode: 0 } });
if (this.weather.rainLevel != null) {
packets.push({ name: 'game_state_change', data: { reason: 7, gameMode: this.weather.rainLevel } });
}
if (this.weather.thunderLevel != null) {
packets.push({ name: 'game_state_change', data: { reason: 8, gameMode: this.weather.thunderLevel } });
}
}
// World border
packets.push(...this._worldBorder.getReplayPackets());
// Simulation distance + view distance
if (this.simulationDistance) {
packets.push({ name: 'simulation_distance', data: this.simulationDistance });
}
if (this.viewDistance) {
packets.push({ name: 'update_view_distance', data: this.viewDistance });
}
// update_view_position is sent by StateReplayer after chunks + player position
// player_info is replayed verbatim via getPlayerInfoReplayPackets()
if (this.playerListHeader) {
packets.push({ name: 'playerlist_header', data: this.playerListHeader });
}
// Scoreboard
packets.push(...this._scoreboard.getReplayPackets());
// Boss bars
for (const [, bar] of this.bossBars) {
// Send as "add" action
packets.push({ name: 'boss_bar', data: { ...bar, action: 0 } });
}
return packets;
}
/**
* Replay player_info packets exactly as received from the server.
* @returns {Array<{name: string, data: object}>}
*/
getPlayerInfoReplayPackets() {
return this.playerInfoPackets.map((pkt) => ({
name: 'player_info',
data: pkt,
}));
}
/**
* UUIDs that were added via player_info (for filtering live updates).
*/
getKnownPlayerUuids() {
const uuids = new Set();
for (const pkt of this.playerInfoPackets) {
if (pkt.action?.add_player) {
for (const entry of pkt.data) {
if (entry.uuid) uuids.add(entry.uuid);
}
}
}
for (const [uuid, entry] of this.playerInfo) {
if (entry.player) uuids.add(uuid);
}
return uuids;
}
clear() {
this.time = null;
this.weather = { raining: false, rainLevel: null, thunderLevel: null };
this._worldBorder.clear();
this.playerInfo.clear();
this.playerInfoPackets = [];
this.playerListHeader = null;
this._scoreboard.clear();
this.bossBars.clear();
this.tags = null;
this.serverData = null;
this.simulationDistance = null;
this.viewDistance = null;
this.declareCommands = null;
this.viewPosition = null;
this.removedPlayers.clear();
}
}
module.exports = { MiscCache };

View File

@@ -0,0 +1,134 @@
const { createLogger } = require('../utils/logger');
const log = createLogger('PlayerStateCache');
/** ClientboundEntityEventPacket — PlayerList.sendPlayerPermissionLevel (EntityEvent.java) */
const PERMISSION_STATUS_MIN = 24;
const PERMISSION_STATUS_MAX = 28;
/**
* Caches player-specific state: position, health, XP, abilities, gamemode.
*/
class PlayerStateCache {
constructor() {
this.loginPacket = null; // The login/join_game packet
this.position = null; // { x, y, z, yaw, pitch, flags, teleportId }
this.health = null; // { health, food, foodSaturation }
this.experience = null; // { experienceBar, level, totalExperience }
this.abilities = null; // { flags, flyingSpeed, walkingSpeed }
this.spawnPosition = null; // { location, angle }
this.gameMode = null; // from game_state_change
this.difficulty = null; // { difficulty, difficultyLocked }
this.entityId = null; // The player's entity ID from login
/** entity_status for game-mode switcher / command permission UI */
this.permissionStatus = null; // { entityId, entityStatus }
this.effects = []; // player status effects
}
handleLogin(data) {
this.loginPacket = { ...data };
this.entityId = data.entityId;
log.info(`Player entity ID: ${this.entityId}`);
}
handlePosition(data) {
this.position = { ...data };
}
handleUpdateHealth(data) {
this.health = { ...data };
}
handleExperience(data) {
this.experience = { ...data };
}
handleAbilities(data) {
this.abilities = { ...data };
}
/**
* Permission level for the local player (entity_status 2428).
*/
handleEntityStatus(data) {
if (this.entityId == null || data.entityId !== this.entityId) return;
const status = data.entityStatus ?? data.status;
if (status == null || status < PERMISSION_STATUS_MIN || status > PERMISSION_STATUS_MAX) return;
this.permissionStatus = {
entityId: data.entityId,
entityStatus: status,
};
log.info(`Cached permission level entity_status: ${status}`);
}
handleSpawnPosition(data) {
this.spawnPosition = { ...data };
}
handleDifficulty(data) {
this.difficulty = { ...data };
}
handleGameStateChange(data) {
// reason 3 = change game mode
if (data.reason === 3) {
this.gameMode = data.gameMode;
}
}
handleRespawn(data) {
// On respawn, reset some state
this.position = null;
this.health = null;
this.effects = [];
log.info('Player respawned — position/health/effects reset');
}
handleEntityEffect(data) {
if (this.entityId == null || data.entityId !== this.entityId) return;
this.effects = this.effects.filter(e => e.effectId !== data.effectId);
this.effects.push({ ...data });
}
handleRemoveEntityEffect(data) {
if (this.entityId == null || data.entityId !== this.entityId) return;
this.effects = this.effects.filter(e => e.effectId !== data.effectId);
}
/**
* Returns all cached player state for replay.
*/
getState() {
return {
loginPacket: this.loginPacket,
position: this.position,
health: this.health,
experience: this.experience,
abilities: this.abilities,
spawnPosition: this.spawnPosition,
difficulty: this.difficulty,
entityId: this.entityId,
permissionStatus: this.permissionStatus,
effects: this.effects,
};
}
clear() {
this.loginPacket = null;
this.position = null;
this.health = null;
this.experience = null;
this.abilities = null;
this.spawnPosition = null;
this.gameMode = null;
this.difficulty = null;
this.entityId = null;
this.permissionStatus = null;
this.effects = [];
}
}
module.exports = {
PlayerStateCache,
PERMISSION_STATUS_MIN,
PERMISSION_STATUS_MAX,
};

View File

@@ -0,0 +1,73 @@
/**
* Caches scoreboard state: objectives, display slots, scores, and teams.
*/
class ScoreboardCache {
constructor() {
this.objectives = new Map(); // name -> objective data
this.displays = new Map(); // position -> display data
this.scores = new Map(); // "name:objective" -> score data
this.teams = new Map(); // name -> team data
}
handleScoreboardObjective(data) {
if (data.action === 1) {
// Remove objective
this.objectives.delete(data.name);
} else {
this.objectives.set(data.name, { ...data });
}
}
handleScoreboardDisplayObjective(data) {
this.displays.set(data.position, { ...data });
}
handleScoreboardScore(data) {
const key = `${data.itemName || data.entity}:${data.scoreName || data.objective}`;
this.scores.set(key, { ...data });
}
handleResetScore(data) {
const key = `${data.itemName || data.entity}:${data.scoreName || data.objective}`;
this.scores.delete(key);
}
handleTeams(data) {
if (data.mode === 1) {
// Remove team
this.teams.delete(data.team);
} else {
const existing = this.teams.get(data.team) || {};
this.teams.set(data.team, { ...existing, ...data });
}
}
/**
* @returns {Array<{name: string, data: object}>}
*/
getReplayPackets() {
const packets = [];
for (const [, obj] of this.objectives) {
packets.push({ name: 'scoreboard_objective', data: obj });
}
for (const [, display] of this.displays) {
packets.push({ name: 'scoreboard_display_objective', data: display });
}
for (const [, score] of this.scores) {
packets.push({ name: 'scoreboard_score', data: score });
}
for (const [, team] of this.teams) {
packets.push({ name: 'teams', data: team });
}
return packets;
}
clear() {
this.objectives.clear();
this.displays.clear();
this.scores.clear();
this.teams.clear();
}
}
module.exports = { ScoreboardCache };

View File

@@ -0,0 +1,65 @@
/**
* Caches world border state: initialize, center, size, lerp, and warnings.
*/
class WorldBorderCache {
constructor() {
this.initBorder = null; // initialize_world_border
this.center = null; // world_border_center
this.size = null; // world_border_size
this.lerpSize = null; // world_border_lerp_size
this.warningDelay = null;
this.warningReach = null;
}
handleInitWorldBorder(data) {
this.initBorder = { ...data };
}
handleWorldBorderCenter(data) {
this.center = { ...data };
}
handleWorldBorderSize(data) {
this.size = { ...data };
}
handleWorldBorderLerpSize(data) {
this.lerpSize = { ...data };
}
handleWorldBorderWarningDelay(data) {
this.warningDelay = { ...data };
}
handleWorldBorderWarningReach(data) {
this.warningReach = { ...data };
}
/**
* @returns {Array<{name: string, data: object}>}
*/
getReplayPackets() {
const packets = [];
if (this.initBorder) {
packets.push({ name: 'initialize_world_border', data: this.initBorder });
}
if (this.center) {
packets.push({ name: 'world_border_center', data: this.center });
}
if (this.size) {
packets.push({ name: 'world_border_size', data: this.size });
}
return packets;
}
clear() {
this.initBorder = null;
this.center = null;
this.size = null;
this.lerpSize = null;
this.warningDelay = null;
this.warningReach = null;
}
}
module.exports = { WorldBorderCache };

View File

@@ -0,0 +1,337 @@
const { createLogger } = require('../utils/logger');
const { ChunkCache } = require('./ChunkCache');
const { EntityCache } = require('./EntityCache');
const { PlayerStateCache } = require('./PlayerStateCache');
const { InventoryCache } = require('./InventoryCache');
const { MiscCache } = require('./MiscCache');
const { JoinSyncCache } = require('./JoinSyncCache');
const log = createLogger('WorldState');
function cloneConfigData(data) {
return structuredClone(data);
}
/**
* Master cache coordinator.
* Routes incoming server packets to the appropriate sub-cache.
*/
class WorldStateCache {
constructor(config) {
this.chunks = new ChunkCache(config.cache.maxChunks);
this.entities = new EntityCache();
this.player = new PlayerStateCache();
this.inventory = new InventoryCache();
this.misc = new MiscCache();
this.joinSync = new JoinSyncCache();
/** Parsed configuration-phase packets (fallback if raw capture unavailable) */
this.configPackets = [];
/** Raw packet buffers from upstream server config phase, in receive order */
this.rawConfigPackets = [];
/** Track whether we've received the login packet */
this.initialized = false;
}
/**
* Store a configuration-phase packet for replay.
*/
handleConfigPacket(name, data) {
const cloned = cloneConfigData(data);
// Replace existing packet of same name, or append
const idx = this.configPackets.findIndex(p => p.name === name);
if (name === 'registry_data') {
// Registry data can have multiple packets (one per registry)
this.configPackets.push({ name, data: cloned });
} else if (idx >= 0) {
this.configPackets[idx] = { name, data: cloned };
} else {
this.configPackets.push({ name, data: cloned });
}
}
/**
* Build a registryCodec object from captured server registry_data packets.
* Used by the proxy so clients receive the real server's registries, not minecraft-data defaults.
* @returns {object|null}
*/
buildRegistryCodec() {
const registryPackets = this.configPackets.filter(p => p.name === 'registry_data');
if (registryPackets.length === 0) return null;
const first = registryPackets[0].data;
if (first.codec) {
return first;
}
const codec = {};
for (const { data } of registryPackets) {
if (data.id) {
codec[data.id] = data;
}
}
return Object.keys(codec).length > 0 ? codec : null;
}
/**
* Store a raw configuration packet buffer exactly as received from the server.
*/
handleRawConfigPacket(name, buffer) {
this.rawConfigPackets.push({ name, buffer: Buffer.from(buffer) });
}
/**
* Raw config packets to replay to proxy clients (excludes finish_configuration).
* @returns {Array<{name: string, buffer: Buffer}>}
*/
getRawConfigPacketsForReplay() {
return this.rawConfigPackets.filter(p => p.name !== 'finish_configuration');
}
hasRawConfigPackets() {
return this.getRawConfigPacketsForReplay().length > 0;
}
/**
* Process a server->client play packet and route to appropriate cache.
* @param {string} name - packet name
* @param {object} data - packet data
* @param {Buffer} [buffer] - raw packet bytes from the server
*/
handleServerPacket(name, data, buffer) {
switch (name) {
// Player state
case 'login':
this.player.handleLogin(data);
this.initialized = true;
break;
case 'position':
this.player.handlePosition(data);
break;
case 'update_health':
this.player.handleUpdateHealth(data);
break;
case 'experience':
this.player.handleExperience(data);
break;
case 'abilities':
this.player.handleAbilities(data);
break;
case 'entity_status':
this.player.handleEntityStatus(data);
break;
case 'spawn_position':
this.player.handleSpawnPosition(data);
break;
case 'difficulty':
this.player.handleDifficulty(data);
break;
case 'respawn':
this.player.handleRespawn(data);
this.entities.clear();
break;
// Chunks
case 'map_chunk':
this.chunks.handleMapChunk(data, buffer);
break;
case 'update_light':
this.chunks.handleUpdateLight(data, buffer);
break;
case 'unload_chunk':
this.chunks.handleUnloadChunk(data);
break;
case 'block_change':
this.chunks.handleBlockChange(data);
break;
case 'multi_block_change':
this.chunks.handleMultiBlockChange(data);
break;
// Entities
case 'spawn_entity':
this.entities.handleSpawnEntity(data);
break;
case 'entity_metadata':
this.entities.handleEntityMetadata(data);
break;
case 'entity_equipment':
this.entities.handleEntityEquipment(data);
break;
case 'entity_effect':
this.player.handleEntityEffect(data);
this.entities.handleEntityEffect(data);
break;
case 'remove_entity_effect':
this.player.handleRemoveEntityEffect(data);
this.entities.handleRemoveEntityEffect(data);
break;
case 'entity_destroy':
this.entities.handleEntityDestroy(data);
break;
case 'set_passengers':
this.entities.handleSetPassengers(data);
break;
case 'entity_teleport':
this.entities.handleEntityTeleport(data);
break;
case 'rel_entity_move':
this.entities.handleRelEntityMove(data);
break;
case 'entity_move_look':
this.entities.handleEntityMoveLook(data);
break;
case 'sync_entity_position':
this.entities.handleSyncEntityPosition(data);
break;
// Inventory
case 'window_items':
this.inventory.handleWindowItems(data);
break;
case 'set_slot':
this.inventory.handleSetSlot(data);
break;
case 'held_item_slot':
this.inventory.handleHeldItemSlot(data);
break;
case 'set_player_inventory':
this.inventory.handleSetPlayerInventory(data);
break;
case 'set_cursor_item':
this.inventory.handleSetCursorItem(data);
break;
// Time & weather
case 'update_time':
this.misc.handleUpdateTime(data);
break;
case 'game_state_change':
this.player.handleGameStateChange(data);
this.misc.handleGameStateChange(data);
break;
// World border
case 'initialize_world_border':
this.misc.handleInitWorldBorder(data);
break;
case 'world_border_center':
this.misc.handleWorldBorderCenter(data);
break;
case 'world_border_size':
this.misc.handleWorldBorderSize(data);
break;
case 'world_border_lerp_size':
this.misc.handleWorldBorderLerpSize(data);
break;
case 'world_border_warning_delay':
this.misc.handleWorldBorderWarningDelay(data);
break;
case 'world_border_warning_reach':
this.misc.handleWorldBorderWarningReach(data);
break;
// Tab list
case 'player_info':
this.misc.handlePlayerInfo(data);
break;
case 'player_remove':
this.misc.handlePlayerRemove(data);
break;
case 'playerlist_header':
this.misc.handlePlayerListHeader(data);
break;
// Scoreboard
case 'scoreboard_objective':
this.misc.handleScoreboardObjective(data);
break;
case 'scoreboard_display_objective':
this.misc.handleScoreboardDisplayObjective(data);
break;
case 'scoreboard_score':
this.misc.handleScoreboardScore(data);
break;
case 'reset_score':
this.misc.handleResetScore(data);
break;
case 'teams':
this.misc.handleTeams(data);
break;
// Boss bar
case 'boss_bar':
this.misc.handleBossBar(data);
break;
// Tags
case 'tags':
this.misc.handleTags(data);
break;
// Server data
case 'server_data':
this.misc.handleServerData(data);
break;
// View distance / simulation
case 'simulation_distance':
this.misc.handleSimulationDistance(data);
break;
case 'update_view_distance':
this.misc.handleUpdateViewDistance(data);
break;
case 'declare_commands':
this.misc.handleDeclareCommands(data);
break;
case 'update_view_position':
this.misc.handleUpdateViewPosition(data);
break;
case 'update_recipes':
case 'declare_recipes':
case 'advancements':
case 'recipe_book_add':
case 'recipe_book_settings':
this.joinSync.handlePacket(name, data);
break;
// Packets we intentionally don't cache (ephemeral):
// sound_effect, entity_sound_effect, world_particles, animation,
// block_break_animation, explosion, world_event, player_chat,
// system_chat, etc.
default:
break;
}
}
/**
* Get a summary of cached state for logging.
*/
getSummary() {
return {
chunks: this.chunks.size,
entities: this.entities.size,
initialized: this.initialized,
hasPosition: !!this.player.position,
hasInventory: !!this.inventory.windowItems,
playerInfoEntries: this.misc.playerInfo.size,
};
}
clear() {
this.chunks.clear();
this.entities.clear();
this.player.clear();
this.inventory.clear();
this.misc.clear();
this.joinSync.clear();
this.configPackets = [];
this.rawConfigPackets = [];
this.initialized = false;
}
}
module.exports = { WorldStateCache };

33
src/utils/angles.js Normal file
View File

@@ -0,0 +1,33 @@
/**
* Convert yaw/pitch to the i8 byte format used by spawn_entity and similar packets.
* Movement packets may send f32 degrees (e.g. sync_entity_position); spawn uses i8.
*/
function toByteAngle(value) {
if (typeof value !== 'number' || Number.isNaN(value)) return 0;
// Already a protocol byte angle (-128..127, integer)
if (value >= -128 && value <= 127 && Math.abs(value - Math.round(value)) < 1e-6) {
return Math.round(value);
}
// Notchian degrees (f32) -> byte: floor(angle * 256 / 360)
let byte = Math.floor((value % 360) * 256 / 360);
if (byte > 127) byte -= 256;
if (byte < -128) byte += 256;
return byte;
}
/**
* Prepare spawn_entity packet data for serialization.
*/
function sanitizeSpawnEntity(spawnData) {
if (!spawnData) return spawnData;
return {
...spawnData,
yaw: toByteAngle(spawnData.yaw),
pitch: toByteAngle(spawnData.pitch),
headPitch: toByteAngle(spawnData.headPitch),
};
}
module.exports = { toByteAngle, sanitizeSpawnEntity };

171
src/utils/chatRelay.js Normal file
View File

@@ -0,0 +1,171 @@
const { computeChatChecksum } = require('minecraft-protocol/src/datatypes/checksums');
const CHAT_C2S_PACKETS = new Set([
'chat_message',
'chat_command',
'chat_command_signed',
]);
/** Inbound packets handled locally; never forward to upstream bot. */
const CHAT_SESSION_PACKETS = new Set([
...CHAT_C2S_PACKETS,
'message_acknowledgement',
]);
/**
* minecraft-protocol server chat plugin always listens for message_acknowledgement
* even when enforceSecureProfile is false, and kicks with chat_validation_failed.
*/
function disableInboundChatValidation(client) {
if (!client) return;
client.removeAllListeners('message_acknowledgement');
}
/**
* Plain text or command string from a proxy client chat packet.
*/
function extractChatText(name, data) {
if (!data || typeof data !== 'object') return null;
if (name === 'chat_message') {
return typeof data.message === 'string' ? data.message : null;
}
if (name === 'chat_command' || name === 'chat_command_signed') {
if (typeof data.command !== 'string' || !data.command.length) return null;
return data.command.startsWith('/') ? data.command : `/${data.command}`;
}
return null;
}
function collectUpstreamAcknowledgements(rawClient) {
const lsm = rawClient._lastSeenMessages;
if (!lsm) return [];
const acks = [];
const cap = lsm.capacity ?? lsm.length;
for (let i = 0; i < cap; i++) {
const entry = lsm[i];
if (Buffer.isBuffer(entry)) acks.push(entry);
else if (entry?.signature && Buffer.isBuffer(entry.signature)) acks.push(entry.signature);
}
return acks;
}
function buildAcknowledgedBitset(rawClient) {
const lsm = rawClient._lastSeenMessages;
if (!lsm) return Buffer.alloc(3);
let acc = 0;
const cap = lsm.capacity ?? lsm.length;
for (let i = 0; i < cap; i++) {
if (lsm[i]) acc |= 1 << i;
}
const bitset = Buffer.allocUnsafe(3);
bitset[0] = acc & 0xff;
bitset[1] = (acc >> 8) & 0xff;
bitset[2] = (acc >> 16) & 0xff;
return bitset;
}
/** Fallback when mineflayer bot.chat is unavailable. */
function sendUpstreamSignedChat(rawClient, text, options) {
const mcData = require('minecraft-data')(rawClient.version);
const timestamp = options.timestamp ?? BigInt(Date.now());
const salt = options.salt ?? 1n;
if (!rawClient.profileKeys?.private) {
throw new Error('Upstream bot has no chat signing keys');
}
if (mcData.supportFeature('useChatSessions') && !rawClient._session?.uuid) {
throw new Error('Upstream chat session not initialized');
}
const acknowledgements = collectUpstreamAcknowledgements(rawClient);
const acknowledged = buildAcknowledgedBitset(rawClient);
const checksum = computeChatChecksum(rawClient._lastSeenMessages ?? []);
if (text.startsWith('/')) {
const command = text.slice(1);
const canSign = mcData.supportFeature('useChatSessions') && rawClient._session;
const packetName =
mcData.supportFeature('seperateSignedChatCommandPacket') && canSign
? 'chat_command_signed'
: 'chat_command';
rawClient.write(packetName, {
command,
timestamp,
salt,
argumentSignatures: [],
messageCount: rawClient._lastSeenMessages?.pending ?? 0,
checksum,
acknowledged,
});
if (rawClient._lastSeenMessages) rawClient._lastSeenMessages.pending = 0;
return;
}
if (!mcData.supportFeature('useChatSessions')) {
if (typeof rawClient._signedChat === 'function') {
rawClient._signedChat(text, { timestamp, salt });
return;
}
throw new Error('Unsupported chat protocol version');
}
const signature = rawClient.signMessage(text, timestamp, salt, undefined, acknowledgements);
rawClient.write('chat_message', {
message: text,
timestamp,
salt,
signature,
offset: rawClient._lastSeenMessages?.pending ?? 0,
checksum,
acknowledged,
});
if (rawClient._lastSeenMessages) rawClient._lastSeenMessages.pending = 0;
}
/**
* Re-sign chat for the upstream bot session (FlayerBot), not the Java client's account.
*/
function relayClientChatAsUpstream(serverConn, name, data, log) {
if (!CHAT_C2S_PACKETS.has(name)) return false;
if (!serverConn.connected) {
log?.warn?.('Cannot relay chat: upstream not connected');
return true;
}
const text = extractChatText(name, data);
if (text == null) {
log?.warn?.(`Ignoring ${name} with no message/command`);
return true;
}
try {
if (serverConn.bot?.chat) {
serverConn.bot.chat(text);
} else if (serverConn.rawClient) {
sendUpstreamSignedChat(serverConn.rawClient, text, {
timestamp: data.timestamp,
salt: data.salt,
});
} else {
throw new Error('No upstream chat path');
}
log?.info?.(`Chat sent upstream as bot (${text.length > 80 ? `${text.slice(0, 77)}` : text})`);
} catch (err) {
log?.error?.(`Chat re-sign failed: ${err.message}`);
}
return true;
}
module.exports = {
CHAT_SESSION_PACKETS,
CHAT_C2S_PACKETS,
disableInboundChatValidation,
extractChatText,
relayClientChatAsUpstream,
};

View File

@@ -0,0 +1,48 @@
/**
* minecraft-protocol's default errorHandler calls client.end(err), which crashes
* kick_disconnect serialization (reason must be string/JSON, not an Error).
*/
function disconnectReasonText(reason) {
if (reason instanceof Error) return reason.message || 'Disconnected';
if (reason == null) return 'Disconnected';
if (typeof reason === 'string') return reason;
try {
return JSON.stringify(reason);
} catch {
return String(reason);
}
}
/**
* Wrap client.end so Error objects are never passed to kick_disconnect.
*/
function wrapClientEnd(client) {
const protoEnd = client.end.bind(client);
client.end = function safeEnd(endReason, fullReason) {
return protoEnd(disconnectReasonText(endReason), fullReason);
};
}
/**
* End a proxy client without throwing if the socket is already closed.
*/
function safeEndClient(client, reason) {
if (!client || client.ended) return;
const text = disconnectReasonText(reason);
try {
client.end(text);
} catch {
try {
client._end(text);
} catch {
/* socket already gone */
}
}
}
module.exports = {
disconnectReasonText,
wrapClientEnd,
safeEndClient,
};

53
src/utils/handoffSync.js Normal file
View File

@@ -0,0 +1,53 @@
/**
* Helpers aligned with vanilla join flow (PlayerList.placeNewPlayer, PlayerChunkSender).
*/
function installHandoffUpstreamRelay(client, serverConn, log) {
const handler = (data, meta) => {
if (meta.state !== 'play') return;
if (meta.name === 'chunk_batch_received') {
serverConn.writeToServer('chunk_batch_received', data);
if (log) log.info('Forwarded client chunk_batch_received to server');
} else if (meta.name === 'player_loaded') {
serverConn.writeToServer('player_loaded', data);
if (log) log.info('Forwarded client player_loaded to server');
}
};
client.on('packet', handler);
return handler;
}
function removeHandoffUpstreamRelay(client, handler) {
if (handler) client.removeListener('packet', handler);
}
/** ClientboundGameEventPacket.LEVEL_CHUNKS_LOAD_START (reason 13) */
const LEVEL_CHUNKS_LOAD_START = { reason: 13, gameMode: 0 };
/** EntityEvent.PERMISSION_LEVEL_ADMINS — typical vanilla OP */
const PERMISSION_LEVEL_ADMINS = 27;
/**
* Push cached permission entity_status to the proxy client (game mode switcher, etc.).
*/
function sendPermissionStatusToClient(client, permissionStatus, log) {
if (!permissionStatus || !client || client.ended || client.state !== 'play') return false;
try {
client.write('entity_status', { ...permissionStatus });
if (log) {
log.info(`Sent permission entity_status ${permissionStatus.entityStatus} to client`);
}
return true;
} catch (err) {
if (log) log.error('Failed to send permission entity_status:', err.message);
return false;
}
}
module.exports = {
installHandoffUpstreamRelay,
removeHandoffUpstreamRelay,
LEVEL_CHUNKS_LOAD_START,
PERMISSION_LEVEL_ADMINS,
sendPermissionStatusToClient,
};

43
src/utils/logger.js Normal file
View File

@@ -0,0 +1,43 @@
const COLORS = {
reset: '\x1b[0m',
dim: '\x1b[2m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
white: '\x1b[37m',
};
const LEVEL_COLORS = {
DEBUG: COLORS.dim,
INFO: COLORS.green,
WARN: COLORS.yellow,
ERROR: COLORS.red,
};
function timestamp() {
return new Date().toISOString().replace('T', ' ').replace('Z', '');
}
function createLogger(module) {
const tag = `[${module}]`;
function log(level, ...args) {
const color = LEVEL_COLORS[level] || COLORS.white;
const ts = COLORS.dim + timestamp() + COLORS.reset;
const lvl = color + level.padEnd(5) + COLORS.reset;
const mod = COLORS.cyan + tag + COLORS.reset;
console.log(`${ts} ${lvl} ${mod}`, ...args);
}
return {
debug: (...args) => log('DEBUG', ...args),
info: (...args) => log('INFO', ...args),
warn: (...args) => log('WARN', ...args),
error: (...args) => log('ERROR', ...args),
};
}
module.exports = { createLogger };

161
src/utils/positionSync.js Normal file
View File

@@ -0,0 +1,161 @@
const conv = require('mineflayer/lib/conversions');
const ABSOLUTE_FLAGS = {
x: false,
y: false,
z: false,
yaw: false,
pitch: false,
dx: false,
dy: false,
dz: false,
yawDelta: false,
};
/**
* Build a clientbound position packet from the bot's live entity state.
* @param {import('mineflayer').Bot} bot
* @param {number} teleportId
* @returns {object|null}
*/
function buildClientboundPositionPacket(bot, teleportId) {
const entity = bot?.entity;
if (!entity?.position) return null;
return {
teleportId,
x: entity.position.x,
y: entity.position.y,
z: entity.position.z,
dx: 0,
dy: 0,
dz: 0,
yaw: conv.toNotchianYaw(entity.yaw),
pitch: conv.toNotchianPitch(entity.pitch),
flags: { ...ABSOLUTE_FLAGS },
};
}
function waitForClientTeleportConfirm(client, timeoutMs, log) {
return new Promise((resolve) => {
if (!client || client.ended) return resolve(false);
const timeout = setTimeout(() => {
client.removeListener('teleport_confirm', onConfirm);
if (log) log.warn('Timed out waiting for client teleport_confirm');
resolve(false);
}, timeoutMs);
const onConfirm = () => {
clearTimeout(timeout);
resolve(true);
};
client.once('teleport_confirm', onConfirm);
});
}
function movementFlags(onGround, hasHorizontalCollision) {
return {
onGround: !!onGround,
hasHorizontalCollision: hasHorizontalCollision ?? false,
};
}
/**
* Serverbound position_look from bot entity (what the server expects).
*/
function buildServerboundPositionLook(bot) {
const entity = bot?.entity;
if (!entity?.position) return null;
return {
x: entity.position.x,
y: entity.position.y,
z: entity.position.z,
yaw: conv.toNotchianYaw(entity.yaw),
pitch: conv.toNotchianPitch(entity.pitch),
flags: movementFlags(entity.onGround),
};
}
function distanceSq(a, b) {
const dx = a.x - b.x;
const dy = a.y - b.y;
const dz = a.z - b.z;
return dx * dx + dy * dy + dz * dz;
}
/** Log when client diverges further than this from the bot (still forwarded to server) */
const MAX_CLIENT_MOVEMENT_WARN_DELTA = 12;
function chunkCoordsFromBlock(x, z) {
return {
chunkX: Math.floor(x / 16),
chunkZ: Math.floor(z / 16),
};
}
/**
* Same test as ChunkTrackingView.isWithinDistance(..., includeNeighbors=false) on the server.
*/
function isChunkWithinViewDistance(centerChunkX, centerChunkZ, chunkX, chunkZ, viewDistance) {
const bufferRange = 1;
const deltaX = Math.max(0, Math.abs(chunkX - centerChunkX) - bufferRange);
const deltaZ = Math.max(0, Math.abs(chunkZ - centerChunkZ) - bufferRange);
return deltaX * deltaX + deltaZ * deltaZ < viewDistance * viewDistance;
}
/**
* Keep the proxy client's chunk view center aligned so map_chunk packets are accepted.
* @returns {boolean} true if the packet was sent
*/
function updateClientViewPosition(client, chunkX, chunkZ, lastView) {
if (!client || client.ended || client.state !== 'play') return false;
if (lastView && lastView.chunkX === chunkX && lastView.chunkZ === chunkZ) return false;
try {
client.write('update_view_position', { chunkX, chunkZ });
} catch {
return false;
}
if (lastView) {
lastView.chunkX = chunkX;
lastView.chunkZ = chunkZ;
}
return true;
}
/**
* Move view center to the player's chunk if missing or a chunk would be rejected.
* @returns {boolean} true if update_view_position was sent
*/
function ensureClientViewIncludesChunk(client, playerBlockX, playerBlockZ, chunkX, chunkZ, viewDistance, lastView) {
if (!client || client.ended || client.state !== 'play') return false;
const playerChunk = chunkCoordsFromBlock(playerBlockX, playerBlockZ);
if (lastView?.chunkX == null) {
return updateClientViewPosition(client, playerChunk.chunkX, playerChunk.chunkZ, lastView);
}
if (!isChunkWithinViewDistance(lastView.chunkX, lastView.chunkZ, chunkX, chunkZ, viewDistance)) {
return updateClientViewPosition(client, playerChunk.chunkX, playerChunk.chunkZ, lastView);
}
return false;
}
module.exports = {
buildClientboundPositionPacket,
buildServerboundPositionLook,
waitForClientTeleportConfirm,
movementFlags,
distanceSq,
MAX_CLIENT_MOVEMENT_WARN_DELTA,
chunkCoordsFromBlock,
isChunkWithinViewDistance,
updateClientViewPosition,
ensureClientViewIncludesChunk,
ABSOLUTE_FLAGS,
};