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

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
tools/fernflower.jar
serversSrc/
logs/
versions/
tools/
scripts/
.cursor/

202
README.md Normal file
View File

@@ -0,0 +1,202 @@
# 🎮 FlayerProxy
> **A seamless Minecraft Bot-to-Proxy handoff bridge.** Keep your Minecraft character online 24/7, and instantly take control from a standard client whenever you join.
```text
_____ _ ____
| ___| | __ _ _ _ ___ _ _| _ \ _ __ _____ ___ _
| |_ | |/ _` | | | |/ _ \ '__| |_) | '__/ _ \ \/ / | | |
| _| | | (_| | |_| | __/ | | __/| | | (_) > <| |_| |
|_| |_|\__,_|\__, |\___|_| |_| |_| \___/_/\_\\__, |
|___/ |___/
```
**FlayerProxy** bridges [Mineflayer](https://github.com/PrismarineJS/mineflayer) (a powerful Minecraft bot framework) and [minecraft-protocol](https://github.com/PrismarineJS/node-minecraft-protocol). It connects to a target Minecraft server, maintains a persistent online session, runs anti-AFK tasks, caches the surrounding world state, and lets you connect using a standard Minecraft client to take control of your character on the fly—without disconnecting from the server.
---
## 🚀 How It Works
FlayerProxy operates as a stateful proxy between your client and the target server. The lifecycle revolves around four major states:
```mermaid
stateDiagram-v2
[*] --> INIT : Start Application
INIT --> BOT_MODE : Connects to Server
BOT_MODE --> HANDOFF : Proxy Client Connects
HANDOFF --> CLIENT_MODE : State Replayed & Teleport Confirmed
CLIENT_MODE --> BOT_MODE : Proxy Client Disconnects
CLIENT_MODE --> INIT : Server Disconnects
BOT_MODE --> INIT : Server Disconnects
```
### 1. `INIT`
* The bot initiates a connection to the upstream target server.
* The local proxy server begins listening for incoming client connections.
### 2. `BOT_MODE`
* No human player is connected.
* The bot holds the session, runs anti-AFK behavior (if configured), and handles physics.
* **State Caching**: The proxy continuously captures and updates a local cache of the world (chunks, entities, inventory, scoreboards, player position, etc.).
### 3. `HANDOFF`
* A standard Minecraft client connects to the proxy.
* Bot AI/physics are immediately disabled to prevent conflict.
* **State Replay**: The `StateReplayer` sends the cached world state sequentially to the client, reproducing the exact state of the environment without reloading.
* The client is synchronized to the bot's coordinates and confirms the teleport.
### 4. `CLIENT_MODE`
* The handoff is completed.
* A bidirectional `ClientBridge` is established to pipe raw network packets directly between your client and the target server.
* You play normally with low latency, while the cache continues to monitor updates in the background.
* When you disconnect, the proxy reverts to `BOT_MODE`, re-enabling the bot's anti-AFK AI.
---
## 🛠️ State Cache System
To make the client transition seamless and prevent loading/rendering glitches, FlayerProxy caches a comprehensive set of packet states:
| Cache Component | Monitored Packets & Data | Eviction / Strategy |
| :--- | :--- | :--- |
| **Chunks** | `map_chunk`, `update_light`, `unload_chunk`, `block_change`, `multi_block_change` | LRU cache (default max 1024 chunks) with active block change overlays. |
| **Entities** | `spawn_entity`, `entity_metadata`, `entity_equipment`, `entity_effect`, `set_passengers`, `entity_destroy`, relative movements / teleports | Tracks positions, gear, mounts, and status effects. |
| **Player State** | `login`, `position`, `update_health`, `experience`, `abilities`, `difficulty`, `respawn` | Caches player attributes to sync client UI and positioning. |
| **Inventory** | `window_items`, `set_slot`, `held_item_slot`, `set_player_inventory`, `set_cursor_item` | Captures open container, inventory contents, and hand slots. |
| **Misc / Environment**| `update_time`, `game_state_change`, `initialize_world_border`, `player_info` (tab list), `scoreboard_*`, `teams`, `boss_bar`, `tags` | Keeps track of scoreboard rankings, player lists, time of day, world borders, and registry tags. |
---
## ⚙️ Configuration
Copy the template structure and configure your parameters in `config.json` in the root directory:
```json
{
"server": {
"host": "192.168.178.58",
"port": 25565,
"version": "1.21.10"
},
"auth": {
"username": "FlayerBot",
"auth": "microsoft"
},
"proxy": {
"host": "0.0.0.0",
"port": 25566,
"onlineMode": false,
"maxClients": 1
},
"bot": {
"antiAfk": true,
"antiAfkInterval": 30000,
"viewDistance": 10
},
"cache": {
"maxChunks": 1024,
"trackEntities": true
}
}
```
### Config Options Reference
* **`server`**: Upstream Minecraft server details. `version` must match the server version.
* **`auth`**: Credentials. `auth` can be set to `"microsoft"` or `"offline"`.
* **`proxy`**: Local proxy server settings.
* `onlineMode`: If true, proxy checks Mojang authentication for incoming clients (requires client to match bot username or have appropriate credentials depending on target server configuration). Set to `false` for simple local offline-mode connections.
* **`bot`**: Bot behavior settings.
* `antiAfk`: Keeps the bot moving or performing minor actions so it doesn't get kicked for inactivity.
* **`cache`**: Memory usage controls for caching the world.
---
## 📁 Project Structure
```text
├── config.json # Configuration settings
├── codebase_map.md # Comprehensive function & class map
├── package.json # Dependencies and scripts
└── src
├── index.js # Entry point; boots session and handles process signals
├── config.js # Configuration loader & validator
├── constants/ # Constants, e.g. packets requiring raw forwarding
├── proxy/
│ ├── ProxyServer.js # Wrapper for minecraft-protocol server
│ └── ClientBridge.js # Pipes client-to-server & server-to-client packets
├── session/
│ ├── SessionManager.js # Orchestrates states (BOT_MODE <-> CLIENT_MODE)
│ ├── ServerConnection.js # Wraps Mineflayer bot and captures raw packets
│ ├── ChunkAckManager.js # Intercepts and controls chunk acks
│ ├── MovementRelay.js # Syncs client and bot coordinates
│ └── handoffFlow.js # Step-by-step handoff sequence runner
├── state/
│ ├── WorldStateCache.js # Main caching coordinator
│ ├── ChunkCache.js # Map chunks, lights, and block edits (LRU)
│ ├── EntityCache.js # Track mobs, players, metadata, and positions
│ ├── InventoryCache.js # Items, equipment slots, and cursor items
│ ├── PlayerStateCache.js # XP, health, spawning details, and coords
│ ├── JoinSyncCache.js # Recipes and advancements sent at login
│ ├── MiscCache.js # Scoreboards, world borders, time, and headers
│ ├── ScoreboardCache.js # Tracks scoreboard entries and teams
│ └── WorldBorderCache.js # Tracks world border constraints
├── replay/
│ ├── StateReplayer.js # Replays cached packets to clients on handoff
│ ├── replayChunks.js # Streams chunk and light packets
│ └── replayHelpers.js # Replay yielding and wait conditions
├── sniffer/ # Packet sniffer & MITM interception tool
│ ├── index.js # Sniffer launcher and entry point
│ ├── MitmProxy.js # Decrypts/parses both legs (Mitm mode)
│ ├── TransparentProxy.js # Transparent TCP proxy (Transparent mode)
│ ├── StreamTap.js # Unmodified stream frame parser
│ ├── PacketLog.js # Structured JSONL log writer
│ └── mitm*.js # Login, encryption, gate, relay and session logic
└── utils/
├── logger.js # Structured logging utility
├── angles.js # Minecraft rotation & angle utility functions
├── chatRelay.js # Chat re-signing for the bot connection
├── clientDisconnect.js # Safe error-handling disconnect wrappers
├── handoffSync.js # Temporary play-phase socket relay helper
└── positionSync.js # Coordinates and confirms client positioning
```
---
## 🚦 Getting Started
### Prerequisites
* [Node.js](https://nodejs.org/) (v18 or higher recommended)
* A valid Minecraft account (if connecting to online-mode servers)
### Installation
1. Clone the repository:
```bash
git clone <repository-url>
cd flayerproxy
```
2. Install dependencies:
```bash
npm install
```
### Running the Proxy
1. Create and customize your `config.json` in the root.
2. Start the proxy server:
```bash
npm start
```
3. Open Minecraft, click **Direct Connection** (or Add Server), and connect to `localhost:25566` (or whichever port you configured).
---
## 🧪 Technical Notes & Packet Handling
* **Config-Phase Capture**: During the server's handshake/configuration phase, FlayerProxy intercept `registry_data` and metadata packets. These are later passed verbatim to your joining client so that custom blocks, biomes, and registry configurations match the target server exactly.
* **Keep-Alive Filtering**: The Mineflayer bot automatically responds to upstream `keep_alive` checks. The proxy blocks `keep_alive` packets from being sent to/from the local client to prevent sequence mismatches that would trigger an immediate kick.
* **Position Synchronization**: During the handoff, the client must be instantly moved to the exact coords of the bot. The proxy writes a `position` packet to the client, pauses packet forwarding, waits for the client to return a `teleport_confirm` packet, and then opens the bidirectional stream. This ensures you spawn safely in the world without falling or glitching.
---
## 📜 License
This project is licensed under the [ISC License](LICENSE).

649
codebase_map.md Normal file
View File

@@ -0,0 +1,649 @@
# 🗺️ FlayerProxy Codebase Map
This document provides a comprehensive mapping of all the classes, functions, and files in the `src/` directory of **FlayerProxy**, along with Mermaid diagrams showing how the components interact during different modes of operation.
---
## 📑 Table of Contents
- [Architecture & Interaction Diagrams](#-architecture--interaction-diagrams)
- [High-Level Core System](#1-high-level-core-system-diagram)
- [Client Handoff Flow](#2-client-handoff-flow-diagram)
- [MITM Sniffer Architecture](#3-mitm-sniffer-architecture-diagram)
- [Detailed File Mapping](#-detailed-file-mapping)
- [Root Scripts & Configuration](#1-root-scripts--configuration) — `src/index.js`, `src/config.js`
- [session — Session State Machine](#2-session--session-state-machine) — `SessionManager`, `ServerConnection`, `ChunkAckManager`, `MovementRelay`, `handoffFlow`
- [proxy — Client Connection Proxy](#3-proxy--client-connection-proxy) — `ProxyServer`, `ClientBridge`
- [state — World State Caching](#4-state--world-state-caching) — `WorldStateCache`, `ChunkCache`, `EntityCache`, `PlayerStateCache`, `InventoryCache`, `MiscCache`, `JoinSyncCache`, `WorldBorderCache`, `ScoreboardCache`
- [replay — Client Handoff Replay](#5-replay--client-handoff-replay) — `StateReplayer`, `replayChunks`, `replayHelpers`
- [utils — Helper Utilities](#6-utils--helper-utilities) — `angles`, `chatRelay`, `clientDisconnect`, `handoffSync`, `logger`, `positionSync`
- [sniffer — MITM Packet Sniffer](#7-sniffer--mitm-packet-sniffer) — `MitmProxy`, `TransparentProxy`, `StreamTap`, `PacketLog`, and relay modules
---
## 🏗️ Architecture & Interaction Diagrams
### 1. High-Level Core System Diagram
This diagram shows how the core components of FlayerProxy (`SessionManager`, `ProxyServer`, `ServerConnection`, `WorldStateCache`, `StateReplayer`, and `ClientBridge`) cooperate to manage the proxy's dual-mode lifecycle.
```mermaid
graph TD
classDef main fill:#d4e1f5,stroke:#1a5f7a,stroke-width:2px;
classDef helper fill:#f5f0d4,stroke:#7a6b1a,stroke-width:1px;
classDef external fill:#e1d5e7,stroke:#9673a6,stroke-width:1px;
Client["Minecraft Client (Port 25566)"]:::external
Server["Target Minecraft Server"]:::external
subgraph FlayerProxy ["FlayerProxy Core"]
SM["[SessionManager]"]:::main
SC["[ServerConnection]"]:::main
PS["[ProxyServer]"]:::main
WSC["[WorldStateCache]"]:::main
SR["[StateReplayer]"]:::main
CB["[ClientBridge]"]:::main
end
SM -->|Orchestrates| SC
SM -->|Orchestrates| PS
SM -->|Orchestrates| WSC
SM -->|Orchestrates| SR
SM -->|Coordinates Handoff| CB
SC -->|Holds session with| Bot["Mineflayer Bot"]:::external
Bot -->|Connects to| Server
SC -->|Captures play packets to| WSC
PS -->|Listens for incoming| Client
SR -->|Replays cache from WSC to| Client
CB -->|Pipes C2S / S2C play packets| Client
CB -->|Pipes C2S / S2C play packets| SC
```
---
### 2. Client Handoff Flow Diagram
This sequence diagram shows the step-by-step handoff process when a standard Minecraft client connects to the proxy, disabling the bot's AI, replaying the cached state, and establishing the packet-forwarding bridge.
```mermaid
sequenceDiagram
autonumber
actor Player as Minecraft Client
participant PS as ProxyServer
participant SM as SessionManager
participant SC as ServerConnection
participant WSC as WorldStateCache
participant SR as StateReplayer
participant Server as Upstream Server
Note over SM,SC: State: BOT_MODE (Bot AI holds session)
Player->>PS: Connect to Proxy (Port 25566)
PS->>SM: _onClientConnect(client)
Note over SM: State -> HANDOFF
SM->>SC: setBotControl(false) [Disable Bot physics/AI]
SM->>SM: _primeChunksNearBot() [Confirm position & await chunks]
SM->>SR: replay(client)
SR->>Player: Send 'login' packet (join_game)
SR->>Player: Send difficulty, abilities, permission level
SR->>Player: Send early misc packets (tags, commands, server_data, time)
SR->>Player: Send recipe & advancement sync packets
SR->>Player: Send initial 'position' teleport packet
Player-->>SR: Send 'teleport_confirm' packet
SR->>Player: Send tab list (player_info)
SR->>Player: Send level info (world border, spawn, time)
SR->>Player: Send chunks (chunk_batch_start, map_chunks, light, block_changes, chunk_batch_finished)
SR->>Player: Send spawned entities (metadata, equipment, effects, passengers)
SR->>Player: Send experience, health, and status effects
SR->>Player: Send inventory snapshot (window_items, set_slot, held_item_slot)
Note over SR: State replay complete
SM->>SC: syncProxyClientPosition(client) [Final snap]
SC->>Player: Send updated 'position' + 'update_view_position'
Player-->>SC: Send 'teleport_confirm'
SM->>SC: confirmServerPosition() [Confirm to Server]
SC->>Server: Send serverbound 'position_look'
SM->>Server: Send 'player_loaded' (hasClientLoaded)
Note over SM: State -> CLIENT_MODE
SM->>CB: Start ClientBridge(client, serverConn, worldState)
Note over CB: Bidirectional Packet Piping Active
```
---
### 3. MITM Sniffer Architecture Diagram
The Packet Sniffer has two modes of operation:
* **MitmProxy**: Decrypts both the client leg and the server leg using custom negotiated encryption keys to print and inspect packet structures in real time.
* **TransparentProxy**: Pipes TCP streams directly without decryption, using a non-disruptive `StreamTap` to record unencrypted packets (like status handshakes or offline logins).
```mermaid
graph LR
classDef sniffer fill:#d4f5d4,stroke:#1a7a1a,stroke-width:2px;
classDef external fill:#e1d5e7,stroke:#9673a6,stroke-width:1px;
Client["Minecraft Client (Port 25567)"]:::external
Server["Target Minecraft Server"]:::external
subgraph MitmProxyApp ["MitmProxy (Packet Sniffer)"]
MProxy["MitmProxy (mc.createServer)"]:::sniffer
PacketLog["PacketLog (JSONL log file)"]:::sniffer
StreamTap["StreamTap (C2S & S2C frame parsing)"]:::sniffer
end
Client -->|connects| MProxy
MProxy -->|initiates upstream client| UpstreamClient["mc.createClient"]:::sniffer
UpstreamClient -->|connects| Server
MProxy -->|logs packets| PacketLog
MProxy -->|taps packets for state tracking| StreamTap
```
---
## 🗂️ Detailed File Mapping
### 1. Root Scripts & Configuration
#### 📄 [src/index.js](file:///home/seb/flayerproxy/src/index.js)
The main entry point for the application. Loads system config and handles errors, starts the core [SessionManager](file:///home/seb/flayerproxy/src/session/SessionManager.js), hooks process event signals (`SIGINT`, `SIGTERM`) for a graceful shutdown, and captures `uncaughtException` / `unhandledRejection` warnings.
| Function | Description |
|---|---|
| `shutdown(signal)` | Gracefully stops the proxy and exits the node process. |
#### 📄 [src/config.js](file:///home/seb/flayerproxy/src/config.js)
Handles configuration loading, validation, and parsing.
| Function | Description |
|---|---|
| `loadConfig()` | Synchronously reads `config.json`, validates host, port, version, and auth configuration, applies default options, and returns the configuration object. |
---
### 2. session — Session State Machine
#### 🧩 [SessionManager](file:///home/seb/flayerproxy/src/session/SessionManager.js) `class`
Orchestrates the dual-mode proxy state machine: `INIT``BOT_MODE``HANDOFF``CLIENT_MODE`.
| Method | Description |
|---|---|
| `constructor(config)` | Initializes state machine with configuration. |
| `start()` | Establishes connections to the upstream Minecraft server and starts the proxy server. |
| `_scheduleReconnect(delaySec)` | Schedules a timed reconnect sequence if the connection is lost. |
| `_setupServerEvents()` | Hooks upstream server events (`connected`, `disconnected`, `kicked`, `error`, `death`, `respawn`). |
| `_primeChunksNearBot()` | Triggers server movement packets to verify that the chunk cache is loaded near the bot before handing off. |
| `_refreshClientAfterBotRespawn()` | Re-aligns position and client view in case the bot respawns while a client is connected. |
| `_onClientConnect(client)` | Initiates the handoff sequence to transition from `BOT_MODE` to `CLIENT_MODE`. |
| `_cleanupClient()` | Stops the packet bridge and cleans up client-related event listeners. |
| `_transitionTo(newState)` | Transitions the machine state and logs status summaries. |
| `stop()` | Gracefully halts all services. |
#### 🧩 [ServerConnection](file:///home/seb/flayerproxy/src/session/ServerConnection.js) `class` extends `EventEmitter`
Manages the persistent Mineflayer bot connection to the target server.
| Method | Description |
|---|---|
| `constructor(config, worldState)` | Initializes bot connection manager with config and world state reference. |
| `connect()` | Spawns the bot connection via Mineflayer. |
| `_setupConfigCapture()` | Caches configuration-phase registry data and tags. |
| `_setupPacketCapture()` | Hooks play packets and routes them directly to the state cache. |
| `_setupBotEvents()` | Listens for bot lifecycle events (`spawn`, `end`, `kicked`, `error`, `death`, and chat logging). |
| `setBotControl(enabled)` | Enables/disables physics and AI on the Mineflayer bot. |
| `setClientDrivesChunkBatchAck(clientDrives)` | Delegates chunk batch acknowledgement control between client and Mineflayer. |
| `flushChunkBatchAck()` | Unblocks the server chunk sender. |
| `refreshProxyClientPermissions(client)` | Sends player permissions status packets. |
| `syncProxyClientPosition(client)` | Snaps client position coordinates to the bot. |
| `confirmServerPosition()` | Confirms final block coordinates back to the server. |
| `setProxyClientChunkAck(enabled)` | Configures the [ChunkAckManager](file:///home/seb/flayerproxy/src/session/ChunkAckManager.js). |
| `relayClientMovement(name, data)` | Translates Notchian/mineflayer coordinates and forwards client movements upstream. |
| `writeToServer(name, data)` | Writes raw packets directly upstream. |
| `disconnect()` | Safely disconnects the bot. |
#### 🧩 [ChunkAckManager](file:///home/seb/flayerproxy/src/session/ChunkAckManager.js) `class`
Intercepts Mineflayer's chunk batch acknowledgement listeners to prevent double-acknowledging packets during the client session.
| Method | Description |
|---|---|
| `constructor()` | Initializes acknowledgement state. |
| `disable(rawClient)` | Disables auto-acknowledgement on the client stream. |
| `restore(rawClient)` | Restores Mineflayer auto-acknowledgement. |
| `flush(rawClient)` | Sends a manual chunk batch received acknowledgement packet. |
#### 📄 [MovementRelay.js](file:///home/seb/flayerproxy/src/session/MovementRelay.js) `functions`
| Function | Description |
|---|---|
| `relayClientMovement(bot, rawClient, name, data)` | Applies client movement packets to the bot entity structure and writes updates upstream. |
| `syncProxyClientPosition(bot, worldState, client)` | Sends a teleport/position packet to the client and awaits a matching `teleport_confirm`. |
| `confirmServerPosition(bot, rawClient, connected)` | Writes a `position_look` verification packet to the upstream server. |
#### 📄 [handoffFlow.js](file:///home/seb/flayerproxy/src/session/handoffFlow.js) `functions`
| Function | Description |
|---|---|
| `performHandoff({...})` | Coordinates the sequential handoff sequence: installs temporary upstream forwarding rules, primes nearby chunks, triggers the [StateReplayer](file:///home/seb/flayerproxy/src/replay/StateReplayer.js), aligns player coordinates/permissions, and spawns the [ClientBridge](file:///home/seb/flayerproxy/src/proxy/ClientBridge.js). |
---
### 3. proxy — Client Connection Proxy
#### 🧩 [ProxyServer](file:///home/seb/flayerproxy/src/proxy/ProxyServer.js) `class`
Listens for connection attempts from standard Minecraft Java clients.
| Method | Description |
|---|---|
| `constructor(config, onClientConnect, worldState)` | Initializes proxy server with config, client callback, and world state. |
| `start()` | Initializes the local minecraft-protocol server (`mc.createServer`), configures pre-login listeners to replay raw config packets, and registers joining players. |
| `updateRegistryCodec(codec)` | Replaces the registry codec object in the protocol handler options. |
| `stop()` | Halts client listening sockets. |
#### 🧩 [ClientBridge](file:///home/seb/flayerproxy/src/proxy/ClientBridge.js) `class`
Manages bidirectional packet pipelines when in `CLIENT_MODE`.
| Method | Description |
|---|---|
| `constructor(client, serverConn, worldState)` | Initializes bridge with client, server connection, and world state references. |
| `_getViewDistance()` | Resolves the view distance from configuration or state records. |
| `_syncClientViewFromBlockCoords(blockX, blockZ)` | Computes chunk coordinates and updates client view center position. |
| `_syncClientViewFromBot()` | Aligns client view center with the bot entity position. |
| `_playerBlockCoordsForView()` | Returns coordinates representing the client player's view anchor. |
| `_ensureViewIncludesChunk(chunkX, chunkZ)` | Ensures the client's view includes target coordinates prior to sending map chunks. |
| `enableMovement()` | Authorizes client movement and syncs view center alignment. |
| `start()` | Sets up packet intercept listeners to route packets between legs, excluding blocked elements (e.g. `keep_alive`, `teleport_confirm`, `message_acknowledgement`) and re-signing client chat events. |
| `_shouldForwardPlayerInfo(data)` | Filters latency updates for unknown players. |
| `stop()` | Tears down the forwarding pipe. |
---
### 4. state — World State Caching
#### 🧩 [WorldStateCache](file:///home/seb/flayerproxy/src/state/WorldStateCache.js) `class`
Master coordinator for world cache segments. Integrates and clears sub-caches on server switches.
| Method | Description |
|---|---|
| `constructor(config)` | Initializes all sub-caches from configuration. |
| `handleConfigPacket(name, data)` | Caches parsed config-phase packets. |
| `buildRegistryCodec()` | Combines cached config packets to build a custom Minecraft registry codec. |
| `handleRawConfigPacket(name, buffer)` | Appends raw configuration buffers. |
| `getRawConfigPacketsForReplay()` | Filters and returns config-phase buffers for client replay. |
| `hasRawConfigPackets()` | Returns `true` if raw configuration buffers are cached. |
| `handleServerPacket(name, data, buffer)` | Evaluates incoming play packets and routes them to sub-caches. |
| `getSummary()` | Returns cache sizing info for log monitoring. |
| `clear()` | Wipes all cached structures. |
#### 🧩 [ChunkCache](file:///home/seb/flayerproxy/src/state/ChunkCache.js) `class`
Manages loaded map chunks, light maps, and block overlays using an LRU cache.
| Method | Description |
|---|---|
| `constructor(maxChunks)` | Initializes chunk storage with LRU capacity limit. |
| `_key(x, z)` | Computes string key for map lookups. |
| `handleMapChunk(data, rawBuffer)` | Caches chunk data and marks it as active in the LRU tracking queue. |
| `handleUpdateLight(data, rawBuffer)` | Caches light updates. |
| `handleUnloadChunk(data)` | Evicts chunk records from cache. |
| `handleBlockChange(data)` | Appends single block changes as an overlay. |
| `handleMultiBlockChange(data)` | Appends multiblock edits as an overlay. |
| `_buildChunkEntry(chunkData)` | Assembles raw data, block edits, and light arrays. |
| `getChunksForReplay(centerChunkX, centerChunkZ, viewDistance)` | Returns cached chunks sorting closest-first. |
| `hasChunkAtBlock(x, z)` | Verifies if a chunk is loaded. |
| `clear()` | Wipes chunk maps. |
#### 🧩 [EntityCache](file:///home/seb/flayerproxy/src/state/EntityCache.js) `class`
Tracks entities, positions, gear, and status effects.
| Method | Category | Description |
|---|---|---|
| `constructor()` | — | Initializes entity tracking maps. |
| `handleSpawnEntity(data)` | Lifecycle | Instantiates a new tracked entity. |
| `handleEntityDestroy(data)` | Lifecycle | Removes entities. |
| `handleEntityMetadata(data)` | State | Updates metadata flags. |
| `handleEntityEquipment(data)` | State | Caches equipment items. |
| `handleEntityEffect(data)` | State | Adds status effects. |
| `handleRemoveEntityEffect(data)` | State | Removes status effects. |
| `handleSetPassengers(data)` | State | Caches passenger mounting structures. |
| `handleEntityPosition(data)` | Movement | Updates entity coordinates. |
| `handleSyncEntityPosition(data)` | Movement | Translates and maps sync positions. |
| `handleRelEntityMove(data)` | Movement | Applies relative move differentials. |
| `handleEntityMoveLook(data)` | Movement | Applies move and rotation differentials. |
| `handleEntityTeleport(data)` | Movement | Sets absolute coordinates. |
| `getAllEntities()` | Query | Returns sanitized entity arrays. |
| `removePlayerEntity(entityId)` | Query | Excludes own entity. |
| `clear()` | — | Clears entity maps. |
#### 🧩 [PlayerStateCache](file:///home/seb/flayerproxy/src/state/PlayerStateCache.js) `class`
Caches player-specific attributes.
| Method | Description |
|---|---|
| `constructor()` | Initializes player state storage. |
| `handleLogin(data)` | Stores the initial login join_game packet. |
| `handlePosition(data)` | Stores coordinate positions. |
| `handleUpdateHealth(data)` | Stores health/hunger levels. |
| `handleExperience(data)` | Caches XP level values. |
| `handleAbilities(data)` | Caches flight capabilities and speeds. |
| `handleEntityStatus(data)` | Caches permissions status. |
| `handleSpawnPosition(data)` | Caches spawn points. |
| `handleDifficulty(data)` | Caches difficulty levels. |
| `handleGameStateChange(data)` | Captures gamemode updates. |
| `handleRespawn(data)` | Resets active position, health, and status values on player respawn. |
| `handleEntityEffect(data)` | Caches player status effects. |
| `handleRemoveEntityEffect(data)` | Evicts player status effects. |
| `getState()` | Returns all active player data. |
| `clear()` | Wipes player state. |
#### 🧩 [InventoryCache](file:///home/seb/flayerproxy/src/state/InventoryCache.js) `class`
Caches slot lists, inventories, and held items.
| Method | Description |
|---|---|
| `constructor()` | Initializes inventory storage. |
| `handleWindowItems(data)` | Caches full item lists. |
| `handleSetSlot(data)` | Caches individual slot updates. |
| `handleHeldItemSlot(data)` | Stores current hotbar slot index. |
| `handleSetPlayerInventory(data)` | Stores inventory slots. |
| `handleSetCursorItem(data)` | Stores cursor items. |
| `getReplayPackets()` | Assembles sequence of inventory packet states. |
| `clear()` | Clears inventory states. |
#### 🧩 [MiscCache](file:///home/seb/flayerproxy/src/state/MiscCache.js) `class`
Coordinates world data, tab lists, scoreboards, tags, and bossbars.
| Method | Category | Description |
|---|---|---|
| `constructor()` | — | Initializes miscellaneous state storage. |
| `handleUpdateTime(data)` | World | Caches time variables. |
| `handleGameStateChange(data)` | World | Tracks weather triggers. |
| `handleSimulationDistance(data)` | World | Caches simulation distance settings. |
| `handleUpdateViewDistance(data)` | World | Caches view distance settings. |
| `handleUpdateViewPosition(data)` | World | Caches view position markers. |
| `handlePlayerInfo(data)` | Tab list | Tracks player additions. |
| `handlePlayerRemove(data)` | Tab list | Tracks player removals. |
| `handlePlayerListHeader(data)` | Tab list | Caches tab list headers. |
| `handleBossBar(data)` | UI | Caches bossbar indicators. |
| `handleTags(data)` | Registry | Caches server tag structures. |
| `handleServerData(data)` | Registry | Caches server description parameters. |
| `handleDeclareCommands(data)` | Registry | Caches commands registry. |
| `getReplayPackets()` | Replay | Compiles time, weather, border, teams, scoreboard, and bossbar packets. |
| `getPlayerInfoReplayPackets()` | Replay | Compiles player_info packets. |
| `getKnownPlayerUuids()` | Query | Identifies player UUIDs. |
| `clear()` | — | Clears variables. |
#### 🧩 [JoinSyncCache](file:///home/seb/flayerproxy/src/state/JoinSyncCache.js) `class`
Holds configuration elements that are typically sent only once at login, such as advancement criteria and recipe lists.
| Method | Description |
|---|---|
| `constructor()` | Initializes join-sync storage. |
| `handlePacket(name, data)` | Identifies and caches advancements and recipe book packets. |
| `getReplayPackets()` | Returns advancement and recipe packets. |
| `clear()` | Clears variables. |
#### 🧩 [WorldBorderCache](file:///home/seb/flayerproxy/src/state/WorldBorderCache.js) `class`
Caches world border coordinates and warn margins.
| Method | Description |
|---|---|
| `constructor()` | Initializes border state storage. |
| `handleInitWorldBorder(data)` | Caches initial world border packet. |
| `handleWorldBorderCenter(data)` | Caches center coordinates. |
| `handleWorldBorderSize(data)` | Caches border size. |
| `handleWorldBorderLerpSize(data)` | Caches border interpolation size. |
| `handleWorldBorderWarningDelay(data)` | Caches warning delay. |
| `handleWorldBorderWarningReach(data)` | Caches warning reach distance. |
| `getReplayPackets()` | Returns border initialization packets. |
| `clear()` | Resets variables. |
#### 🧩 [ScoreboardCache](file:///home/seb/flayerproxy/src/state/ScoreboardCache.js) `class`
Caches teams, scores, objectives, and scoreboard layouts.
| Method | Description |
|---|---|
| `constructor()` | Initializes scoreboard storage. |
| `handleScoreboardObjective(data)` | Caches objective structures. |
| `handleScoreboardDisplayObjective(data)` | Caches display positions. |
| `handleScoreboardScore(data)` | Caches score updates. |
| `handleResetScore(data)` | Removes score records. |
| `handleTeams(data)` | Caches teams. |
| `getReplayPackets()` | Aggregates objectives, displays, scores, and teams. |
| `clear()` | Clears variables. |
---
### 5. replay — Client Handoff Replay
#### 🧩 [StateReplayer](file:///home/seb/flayerproxy/src/replay/StateReplayer.js) `class`
Replays the cached world state to a connecting client.
| Method | Description |
|---|---|
| `constructor(worldState, serverConn)` | Initializes replayer with world state and server connection references. |
| `replay(client)` | Coordinates sequential packet delivery (see replay sequence below). |
**`replay()` sequence:**
| Step | Packets sent |
|---|---|
| 1 | `login` packet (join_game) |
| 2 | Difficulty, abilities, and permission level |
| 3 | Pre-level metadata |
| 4 | Active hotbar selection |
| 5 | Recipe books and advancements |
| 6 | Teleport client player → await `teleport_confirm` |
| 7 | Tab list names |
| 8 | Time, spawn, world border, weather, view distance |
| 9 | Chunk loading start → replay chunks |
| 10 | Spawned entities (metadata, passengers, effects) |
| 11 | XP, health, and status effects |
| 12 | Full inventory (`window_items`) |
#### 📄 [replayChunks.js](file:///home/seb/flayerproxy/src/replay/replayChunks.js) `functions`
| Function | Description |
|---|---|
| `replayChunks(write, writeRaw, chunks, center, totalCached)` | Loops through chunk arrays, sending raw chunk/light buffers and block overlay edits, and issues a final `chunk_batch_finished` packet. |
#### 📄 [replayHelpers.js](file:///home/seb/flayerproxy/src/replay/replayHelpers.js) `functions`
| Function | Description |
|---|---|
| `yieldEventLoop()` | Yields the event loop using `setImmediate`. |
| `replayPacketData(client, name, data)` | Overrides `enforcesSecureChat` to `false` for clients connecting without secure Mojang keys. |
| `getPlayerChunkCenter(playerState, misc, bot)` | Resolves chunk coordinates for position center checks. |
| `splitMiscReplayPackets(packets)` | Splits misc packets into early configurations, level coordinates, and weather variables. |
| `waitForClientTeleportConfirm(client)` | Awaits the client's `teleport_confirm` packet. |
---
### 6. utils — Helper Utilities
#### 📄 [angles.js](file:///home/seb/flayerproxy/src/utils/angles.js) `functions`
| Function | Description |
|---|---|
| `toByteAngle(value)` | Converts float degrees to i8 byte angles. |
| `sanitizeSpawnEntity(spawnData)` | Normalizes angle properties of entity spawn packets. |
#### 📄 [chatRelay.js](file:///home/seb/flayerproxy/src/utils/chatRelay.js) `functions`
| Function | Description |
|---|---|
| `disableInboundChatValidation(client)` | Removes message acknowledgement checks to prevent chat validation kicks. |
| `extractChatText(name, data)` | Extracts text string parameter values from chat packets. |
| `relayClientChatAsUpstream(serverConn, name, data, log)` | Re-signs and forwards client messages using the bot's credentials. |
#### 📄 [clientDisconnect.js](file:///home/seb/flayerproxy/src/utils/clientDisconnect.js) `functions`
| Function | Description |
|---|---|
| `disconnectReasonText(reason)` | Formats error reason arguments into plain text strings. |
| `wrapClientEnd(client)` | Wraps connection end methods to prevent formatting exceptions. |
| `safeEndClient(client, reason)` | Disconnects client sockets. |
#### 📄 [handoffSync.js](file:///home/seb/flayerproxy/src/utils/handoffSync.js) `functions`
| Function | Description |
|---|---|
| `installHandoffUpstreamRelay(client, serverConn, log)` | Pipes chunk batch and player loaded packets directly to the server connection during handoff. |
| `removeHandoffUpstreamRelay(client, handler)` | Removes the handoff forwarding pipe. |
| `sendPermissionStatusToClient(client, permissionStatus, log)` | Sends the player's OP status packet to the client. |
#### 📄 [logger.js](file:///home/seb/flayerproxy/src/utils/logger.js) `functions`
| Function | Description |
|---|---|
| `createLogger(module)` | Returns console logging utilities formatted with module tags and colors. |
#### 📄 [positionSync.js](file:///home/seb/flayerproxy/src/utils/positionSync.js) `functions`
| Function | Description |
|---|---|
| `buildClientboundPositionPacket(bot, teleportId)` | Creates a player teleport packet from the bot's current coordinates. |
| `waitForClientTeleportConfirm(client, timeoutMs, log)` | Awaits the client's position confirmation. |
| `movementFlags(onGround, hasHorizontalCollision)` | Builds movement flags objects. |
| `buildServerboundPositionLook(bot)` | Creates a serverbound player position verification packet. |
| `distanceSq(a, b)` | Computes squared distance. |
| `chunkCoordsFromBlock(x, z)` | Resolves block coordinates to chunk coordinates. |
| `isChunkWithinViewDistance(centerChunkX, centerChunkZ, chunkX, chunkZ, viewDistance)` | Checks if a chunk lies within view distance limits. |
| `updateClientViewPosition(client, chunkX, chunkZ, lastView)` | Sends `update_view_position` to the client. |
| `ensureClientViewIncludesChunk(client, playerBlockX, playerBlockZ, chunkX, chunkZ, viewDistance, lastView)` | Snaps the client view center forward if a target chunk is about to load outside of its radius. |
---
### 7. sniffer — MITM Packet Sniffer
#### 📄 [src/sniffer/index.js](file:///home/seb/flayerproxy/src/sniffer/index.js)
Main launcher file for the sniffer tool. Parses arguments, applies default configurations for local hosting and logging directory, launches the [MitmProxy](file:///home/seb/flayerproxy/src/sniffer/MitmProxy.js) engine, and sets up shutdown listeners.
#### 🧩 [MitmProxy](file:///home/seb/flayerproxy/src/sniffer/MitmProxy.js) `class`
Configures an interception proxy that decrypts packet bytes on both connections.
| Method | Description |
|---|---|
| `constructor(config)` | Initializes the MITM proxy with configuration. |
| `start()` | Initializes the server. |
| `_onConnection(client)` | Sets up packet listeners, configures log files, and spawns the upstream server client. |
| `_startUpstream(session, cleanup)` | Begins the upstream connection flow. |
| `_tryBeginJavaCrypto(session, cleanup)` | Checks state before negotiating encryption. |
| `_doJavaCrypto(session, cleanup)` | Sets up local encryption filters. |
| `stop()` | Shuts down proxy services. |
#### 🧩 [TransparentProxy](file:///home/seb/flayerproxy/src/sniffer/TransparentProxy.js) `class`
Transparently forwards TCP bytes between the client and server while feeding a copy to the `StreamTap` to extract and log packets.
| Method | Description |
|---|---|
| `constructor(config)` | Initializes transparent proxy with configuration. |
| `start()` | Binds the transparent port listener. |
| `_onClientConnect(clientSocket, targetHost, targetPort, sniffer)` | Pipes client and server sockets together and hooks up stream taps. |
| `stop()` | Shuts down socket handlers. |
#### 🧩 [StreamTap](file:///home/seb/flayerproxy/src/sniffer/StreamTap.js) `class`
Parses packet streams without modifying the underlying bytes.
| Method | Description |
|---|---|
| `constructor(dir, version, packetLog, hooks)` | Initializes stream tap with direction, version, logger, and hooks. |
| `feed(chunk)` | Passes byte chunks to the framer/splitter. |
| `_syncState()` | Aligns state variables. |
| `_parser()` | Creates deserializers. |
| `_parseFrame(frame)` | Decompresses frame data. |
| `_onFrame(frame)` | Parses parameters, extracts compression settings and login handshakes, and writes entries to logs. |
| `_setState(next)` | Sets protocol states. |
| `_advanceState(name, data)` | Advances states. |
#### 🧩 [PacketLog](file:///home/seb/flayerproxy/src/sniffer/PacketLog.js) `class`
Formats and writes packet data into JSONL logs.
| Method | Description |
|---|---|
| `constructor(opts)` | Initializes log output streams. |
| `writeMeta(record)` | Writes metadata entries. |
| `logUnparsed(dir, state, frame, message)` | Logs parser errors. |
| `logOpaque(dir, bytes, extra)` | Summarizes encrypted play traffic volume. |
| `logPacket(dir, meta, data, rawBuffer, extra)` | Records details for parsed packets. |
| `close(reason)` | Safely closes output write streams. |
#### 📄 [mitmEncryption.js](file:///home/seb/flayerproxy/src/sniffer/mitmEncryption.js) `functions`
| Function | Description |
|---|---|
| `enableJavaEncryption(client, server, options)` | Negotiates the encryption phase on the client connection. |
#### 📄 [mitmGate.js](file:///home/seb/flayerproxy/src/sniffer/mitmGate.js) `functions`
Packet gating logic — controls buffering, ordering, and flushing of packets during connection state transitions.
| Function | Category | Description |
|---|---|---|
| `canRelayC2S(session, meta)` | C2S | Checks if a client packet should be sent upstream. |
| `c2sForwardLabel(session, meta)` | C2S | Labels C2S traffic. |
| `classifyS2C(session, meta)` | S2C | Classifies S2C traffic. |
| `shouldBufferS2C(session, meta)` | S2C | Checks if S2C packets should be buffered. |
| `queueBufferedS2C(session, data, meta, buffer)` | S2C | Appends packets to buffer arrays. |
| `queueHeldS2C(session, data, meta, buffer)` | S2C | Queues packets while encryption is pending. |
| `isStalePlayS2C(meta)` | S2C | Filters out stale packets. |
| `flushQueue(session, queue)` | Flush | Flushes a packet queue to the client. |
| `flushPendingConfig(session)` | Flush | Flushes configuration packets. |
| `flushPendingPlay(session)` | Flush | Flushes play packets to client. |
| `sortPlayPending(pending)` | Ordering | Sorts play-phase packets to match join sequence requirements. |
| `partitionAfterCrypto(pendingS2C)` | Ordering | Partitions packets by state. |
| `hasPendingSuccess(session)` | State | Checks if a login success packet is waiting. |
| `onJavaLoginAcknowledged(session)` | State | Shifts connection states to `CONFIGURATION`. |
| `onJavaFinishConfiguration(session, packetLog)` | State | Shifts connection states to `PLAY`. |
#### 📄 [mitmLogin.js](file:///home/seb/flayerproxy/src/sniffer/mitmLogin.js) `functions`
| Function | Description |
|---|---|
| `applyLoginStartIdentity(client, packet, server, options)` | Verifies Mojang public key signatures. |
#### 📄 [mitmRelay.js](file:///home/seb/flayerproxy/src/sniffer/mitmRelay.js) `functions`
Handles low-level packet relay, compression synchronization, and raw buffer forwarding decisions.
| Function | Description |
|---|---|
| `shouldWriteRaw(meta, buffer)` | Decides if a packet should be written as a raw buffer. |
| `relayPacket(target, meta, data, buffer)` | Writes a packet parsed or raw. |
| `syncCompression(target, name, data)` | Synchronizes compression threshold. |
| `sortLoginPending(pending)` | Sorts login packets. |
| `relayLoginCompressToJava(client, meta, data, buffer)` | Writes compression setting. |
| `relayToJava(client, meta, data, buffer)` | Safely writes packets to Java. |
#### 📄 [mitmSession.js](file:///home/seb/flayerproxy/src/sniffer/mitmSession.js) `functions`
| Function | Description |
|---|---|
| `createMitmSession(client, packetLog)` | Initializes a sniffer session. |
| `createSessionCleanup(session, packetLog, proxy)` | Returns a session cleanup function. |
#### 📄 [mitmUpstream.js](file:///home/seb/flayerproxy/src/sniffer/mitmUpstream.js) `functions`
| Function | Description |
|---|---|
| `startStatusPipe(session, config, packetLog, proxy)` | Pipes status pings at the TCP socket layer. |
| `startUpstream(session, config, cleanup, callbacks)` | Starts the upstream server connection. |

32
config.json Normal file
View File

@@ -0,0 +1,32 @@
{
"server": {
"host": "192.168.178.58",
"port": 25565,
"version": "1.21.10"
},
"auth": {
"username": "FlayerBot",
"auth": "microsoft"
},
"proxy": {
"port": 25566,
"onlineMode": true,
"maxClients": 1
},
"sniffer": {
"port": 25567,
"onlineMode": false,
"upstreamAuth": "microsoft",
"logDir": "logs/sniffer",
"includePayload": true
},
"bot": {
"antiAfk": true,
"antiAfkInterval": 30000,
"viewDistance": 10
},
"cache": {
"maxChunks": 1024,
"trackEntities": true
}
}

959
package-lock.json generated Normal file
View File

@@ -0,0 +1,959 @@
{
"name": "flayerproxy",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flayerproxy",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"minecraft-data": "^3.110.2",
"minecraft-protocol": "^1.66.2",
"mineflayer": "^4.37.1",
"prismarine-chunk": "^1.40.0",
"prismarine-world": "^3.7.0"
}
},
"node_modules/@azure/msal-common": {
"version": "14.16.1",
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.1.tgz",
"integrity": "sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==",
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@azure/msal-node": {
"version": "2.16.3",
"resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.16.3.tgz",
"integrity": "sha512-CO+SE4weOsfJf+C5LM8argzvotrXw252/ZU6SM2Tz63fEblhH1uuVaaO4ISYFuN4Q6BhTo7I3qIdi8ydUQCqhw==",
"license": "MIT",
"dependencies": {
"@azure/msal-common": "14.16.1",
"jsonwebtoken": "^9.0.0",
"uuid": "^8.3.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@types/node": {
"version": "25.9.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.0.tgz",
"integrity": "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/node-rsa": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/node-rsa/-/node-rsa-1.1.4.tgz",
"integrity": "sha512-dB0ECel6JpMnq5ULvpUTunx3yNm8e/dIkv8Zu9p2c8me70xIRUUG3q+qXRwcSf9rN3oqamv4116iHy90dJGRpA==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/readable-stream": {
"version": "4.0.23",
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz",
"integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@xboxreplay/xboxlive-auth": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@xboxreplay/xboxlive-auth/-/xboxlive-auth-5.1.0.tgz",
"integrity": "sha512-UngHHsehZbiTjyyNmo8HvdoUDKMID1U9uVfrpFWUK/2UxPuVTKy5n+CzZQ3S488sW5vOhgh0lHqqynT8ouwgvw==",
"license": "Apache-2.0",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/aes-js": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz",
"integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==",
"license": "MIT"
},
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/asn1": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz",
"integrity": "sha512-6i37w/+EhlWlGUJff3T/Q8u1RGmP5wgbiwYnOnbOqvtrPxT63/sYFyP9RcpxtxGymtfA075IvmOnL7ycNOWl3w==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-equal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.1.tgz",
"integrity": "sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==",
"license": "MIT",
"engines": {
"node": ">=0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/discontinuous-range": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz",
"integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==",
"license": "MIT"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/endian-toggle": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/endian-toggle/-/endian-toggle-0.0.0.tgz",
"integrity": "sha512-ShfqhXeHRE4TmggSlHXG8CMGIcsOsqDw/GcoPcosToE59Rm9e4aXaMhEQf2kPBsBRrKem1bbOAv5gOKnkliMFQ==",
"license": "MIT"
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"license": "MIT"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"license": "MIT"
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lodash.reduce": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.reduce/-/lodash.reduce-4.6.0.tgz",
"integrity": "sha512-6raRe2vxCYBhpBu+B+TtNGUzah+hQjVdu3E17wfusjyrXBka2nBS8OH/gjVZ5PvHOhWmIZTYri09Z6n/QfnNMw==",
"license": "MIT"
},
"node_modules/macaddress": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/macaddress/-/macaddress-0.5.4.tgz",
"integrity": "sha512-i8xVWoUjj2woYU8kbpQby86Kq7uF7xl2brtKREXUBWpfgqx1fKXEeYzDiVMVxA/IufC1d3xxwJRHtFCX+9IspA==",
"license": "MIT"
},
"node_modules/minecraft-data": {
"version": "3.110.2",
"resolved": "https://registry.npmjs.org/minecraft-data/-/minecraft-data-3.110.2.tgz",
"integrity": "sha512-u0aCCSpQWVreGnZGU/Lu0jmZmc0Y37M0Fvw6eQVQY0BdS/BGRDDU+ug6/qP3QDuZRJCSzi8wNW8ODnOhwpnkpA==",
"license": "MIT"
},
"node_modules/minecraft-folder-path": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/minecraft-folder-path/-/minecraft-folder-path-1.2.0.tgz",
"integrity": "sha512-qaUSbKWoOsH9brn0JQuBhxNAzTDMwrOXorwuRxdJKKKDYvZhtml+6GVCUrY5HRiEsieBEjCUnhVpDuQiKsiFaw==",
"license": "MIT"
},
"node_modules/minecraft-protocol": {
"version": "1.66.2",
"resolved": "https://registry.npmjs.org/minecraft-protocol/-/minecraft-protocol-1.66.2.tgz",
"integrity": "sha512-keY1IY1E2AeurcekCfcXrg0TDbykGVFiMe1E4wR8QkNtQRieNwfr2xaF3g3vT9ChkwzvENqp3jxgmtFCKSUKPg==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/node-rsa": "^1.1.4",
"@types/readable-stream": "^4.0.0",
"aes-js": "^3.1.2",
"buffer-equal": "^1.0.0",
"debug": "^4.3.2",
"endian-toggle": "^0.0.0",
"lodash.merge": "^4.3.0",
"minecraft-data": "^3.78.0",
"minecraft-folder-path": "^1.2.0",
"node-fetch": "^2.6.1",
"node-rsa": "^0.4.2",
"prismarine-auth": "^3.1.1",
"prismarine-chat": "^1.10.0",
"prismarine-nbt": "^2.5.0",
"prismarine-realms": "^1.2.0",
"protodef": "^1.17.0",
"readable-stream": "^4.1.0",
"uuid-1345": "^1.0.1",
"yggdrasil": "^1.4.0"
},
"engines": {
"node": ">=22"
}
},
"node_modules/mineflayer": {
"version": "4.37.1",
"resolved": "https://registry.npmjs.org/mineflayer/-/mineflayer-4.37.1.tgz",
"integrity": "sha512-kchZCJb1znzz8ZhE0+gLQ3e2t/9xUsqUy/IM/sGfceINxi3h6KXKY9luaUEa59vnD/x0OKwYdERY4sscm0ErNQ==",
"license": "MIT",
"dependencies": {
"minecraft-data": "^3.108.0",
"minecraft-protocol": "^1.66.0",
"mojangson": "^2.0.4",
"prismarine-biome": "^1.1.1",
"prismarine-block": "^1.22.0",
"prismarine-chat": "^1.7.1",
"prismarine-chunk": "^1.39.0",
"prismarine-entity": "^2.5.0",
"prismarine-item": "^1.17.0",
"prismarine-nbt": "^2.0.0",
"prismarine-physics": "^1.9.0",
"prismarine-recipe": "^1.5.0",
"prismarine-registry": "^1.10.0",
"prismarine-windows": "^2.9.0",
"prismarine-world": "^3.6.0",
"protodef": "^1.18.0",
"typed-emitter": "^1.0.0",
"uuid-1345": "^1.0.2",
"vec3": "^0.1.7"
},
"engines": {
"node": ">=22"
}
},
"node_modules/mojangson": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/mojangson/-/mojangson-2.0.4.tgz",
"integrity": "sha512-HYmhgDjr1gzF7trGgvcC/huIg2L8FsVbi/KacRe6r1AswbboGVZDS47SOZlomPuMWvZLas8m9vuHHucdZMwTmQ==",
"license": "MIT",
"dependencies": {
"nearley": "^2.19.5"
}
},
"node_modules/moo": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz",
"integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==",
"license": "BSD-3-Clause"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nearley": {
"version": "2.20.1",
"resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz",
"integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==",
"license": "MIT",
"dependencies": {
"commander": "^2.19.0",
"moo": "^0.5.0",
"railroad-diagrams": "^1.0.0",
"randexp": "0.4.6"
},
"bin": {
"nearley-railroad": "bin/nearley-railroad.js",
"nearley-test": "bin/nearley-test.js",
"nearley-unparse": "bin/nearley-unparse.js",
"nearleyc": "bin/nearleyc.js"
},
"funding": {
"type": "individual",
"url": "https://nearley.js.org/#give-to-nearley"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-rsa": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-0.4.2.tgz",
"integrity": "sha512-Bvso6Zi9LY4otIZefYrscsUpo2mUpiAVIEmSZV2q41sP8tHZoert3Yu6zv4f/RXJqMNZQKCtnhDugIuCma23YA==",
"license": "MIT",
"dependencies": {
"asn1": "0.2.3"
}
},
"node_modules/prismarine-auth": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/prismarine-auth/-/prismarine-auth-3.1.1.tgz",
"integrity": "sha512-NuNrMGZdoigFKsvi1ZZgAEvNYNuE5qe6lo/tw+bqeNbkhpjHC0u1JNxLEujnfqduXI18e19PvUtWNMDl/gH7yw==",
"license": "MIT",
"dependencies": {
"@azure/msal-node": "^2.0.2",
"@xboxreplay/xboxlive-auth": "^5.1.0",
"debug": "^4.3.3",
"smart-buffer": "^4.1.0",
"uuid-1345": "^1.0.2"
}
},
"node_modules/prismarine-biome": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/prismarine-biome/-/prismarine-biome-1.4.0.tgz",
"integrity": "sha512-fD2WmjN8Zr/xA/jeMInReLgaDlznwA5xlaK529PzWuGzgjpc5ijVu1Lp1oqHyZn3WxOG/bRVtW1bU+tmgCurWA==",
"license": "MIT",
"peerDependencies": {
"prismarine-registry": "^1.1.0"
}
},
"node_modules/prismarine-block": {
"version": "1.23.0",
"resolved": "https://registry.npmjs.org/prismarine-block/-/prismarine-block-1.23.0.tgz",
"integrity": "sha512-j2UoU4KbXMvNlBw+aLkMOnEuMayYefznUfbrfv1VIbckG3RA9LpNWltOMHXuOR5YkHp8uIZPOclj95XC88jgGw==",
"license": "MIT",
"dependencies": {
"minecraft-data": "^3.38.0",
"prismarine-biome": "^1.1.0",
"prismarine-chat": "^1.5.0",
"prismarine-item": "^1.10.1",
"prismarine-nbt": "^2.0.0",
"prismarine-registry": "^1.1.0"
}
},
"node_modules/prismarine-chat": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/prismarine-chat/-/prismarine-chat-1.13.0.tgz",
"integrity": "sha512-tvDbrQmJEoy09yLE5nnedGhQYxnRDaPRePMv7W39dFaHr2LGcA2JfCmH0vG5193+BsEFz3a5+0EpQSK8OW7YmA==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.2",
"mojangson": "^2.0.1",
"prismarine-nbt": "^2.0.0",
"prismarine-registry": "^1.4.0"
}
},
"node_modules/prismarine-chunk": {
"version": "1.40.0",
"resolved": "https://registry.npmjs.org/prismarine-chunk/-/prismarine-chunk-1.40.0.tgz",
"integrity": "sha512-TtT84Bys7+aGA94HwcK0QDp+jkWcLOLErKYtaWWl+EJya28NqPoBASr5L/lPZ8ZWLQUugg/aFIefZI/rEhEQWw==",
"license": "MIT",
"dependencies": {
"prismarine-biome": "^1.2.0",
"prismarine-block": "^1.14.1",
"prismarine-nbt": "^2.2.1",
"prismarine-registry": "^1.1.0",
"smart-buffer": "^4.1.0",
"uint4": "^0.1.2",
"vec3": "^0.1.3",
"xxhash-wasm": "^0.4.2"
},
"engines": {
"node": ">=14"
}
},
"node_modules/prismarine-entity": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/prismarine-entity/-/prismarine-entity-2.6.0.tgz",
"integrity": "sha512-/LlZRLOpACiXk+GqoaKi0XPBFnNMjb1d4OIzuSCSEgNMK6FUo3Wnin5yeSZ7ff3Ztt7yagN9lX2jSOafn6IIzg==",
"license": "MIT",
"dependencies": {
"prismarine-chat": "^1.4.1",
"prismarine-item": "^1.11.2",
"prismarine-registry": "^1.4.0",
"vec3": "^0.1.4"
}
},
"node_modules/prismarine-item": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/prismarine-item/-/prismarine-item-1.18.0.tgz",
"integrity": "sha512-8pEq6YfcneVvarvUFnex09a3+MR8/4NCQVyawIKAa3kh/g9dHLexoEcpQEgM3cmpg4gbLmspSiARGwed5uGhlg==",
"license": "MIT",
"dependencies": {
"prismarine-nbt": "^2.0.0",
"prismarine-registry": "^1.4.0"
}
},
"node_modules/prismarine-nbt": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/prismarine-nbt/-/prismarine-nbt-2.8.0.tgz",
"integrity": "sha512-5D6FUZq0PNtf3v/41ImDlwThVesOv5adyqCRMZLzmkUGEmRJNNh5C6AsnvrClBftXs+IF0yqPnZoj8kcNPiMGg==",
"license": "MIT",
"dependencies": {
"protodef": "^1.18.0"
}
},
"node_modules/prismarine-physics": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/prismarine-physics/-/prismarine-physics-1.11.0.tgz",
"integrity": "sha512-P25VSDi3kJHQAb/AJBiJCQuxyRCVXRSdEiDjx56ywocgt65N/exatVTiJjOK5HgEKHJSfw0sXSAohQhvutnGAA==",
"license": "MIT",
"dependencies": {
"minecraft-data": "^3.0.0",
"prismarine-nbt": "^2.0.0",
"vec3": "^0.1.7"
}
},
"node_modules/prismarine-realms": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/prismarine-realms/-/prismarine-realms-1.6.0.tgz",
"integrity": "sha512-AwemW0vwxG9hQaFtg1twSV7eymB6QtYxGK0jjpxfdA2sdK15kU8jh8uD1o5XF0oxSMU+BbpzZMCmXtXq4QE6bw==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.3",
"node-fetch": "^2.6.1"
}
},
"node_modules/prismarine-recipe": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prismarine-recipe/-/prismarine-recipe-1.5.0.tgz",
"integrity": "sha512-GRZHbsyBIUgVNF10vFRv2YWZj86vokCT5EWX6iK6gfx6h4FapgZT29V2DNkjv5+hmdzBCLZvfx1/RYr8VPeoGQ==",
"license": "MIT",
"peerDependencies": {
"prismarine-registry": "^1.4.0"
}
},
"node_modules/prismarine-registry": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/prismarine-registry/-/prismarine-registry-1.12.0.tgz",
"integrity": "sha512-OC5U6YrflY6OcAWRZEqe2HGZuNp0bIuP7H+oKEHD6rLfKNDxo8Ymx5eh2VvrZWnMVugpwID1Qj/UjA4MoCzNDw==",
"license": "MIT",
"dependencies": {
"minecraft-data": "^3.70.0",
"prismarine-block": "^1.17.1",
"prismarine-nbt": "^2.0.0"
}
},
"node_modules/prismarine-windows": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/prismarine-windows/-/prismarine-windows-2.10.0.tgz",
"integrity": "sha512-ssXLGAr7W9JLvvLjYMoo1j4j6AdJaoIb0/HlqkWMWlQqvZJeiS4zyBjJY6+GtR4OzpjkEf6IvF5cNXhHFpbcZQ==",
"license": "MIT",
"dependencies": {
"prismarine-item": "^1.12.2",
"prismarine-registry": "^1.7.0",
"typed-emitter": "^2.1.0"
}
},
"node_modules/prismarine-windows/node_modules/typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
"license": "MIT",
"optionalDependencies": {
"rxjs": "*"
}
},
"node_modules/prismarine-world": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/prismarine-world/-/prismarine-world-3.7.0.tgz",
"integrity": "sha512-M5euvNjQ3vIk689BSa0YC6PBwpVY35Oc6q6KyZ0IqyFtI+cQ9em+8l5OTAK/uu9/gzDDhR7cmm9L2WXgTXBQCw==",
"license": "MIT",
"dependencies": {
"vec3": "^0.1.7"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/protodef": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/protodef/-/protodef-1.19.0.tgz",
"integrity": "sha512-94f3GR7pk4Qi5YVLaLvWBfTGUIzzO8hyo7vFVICQuu5f5nwKtgGDaeC1uXIu49s5to/49QQhEYeL0aigu1jEGA==",
"license": "MIT",
"dependencies": {
"lodash.reduce": "^4.6.0",
"protodef-validator": "^1.3.0",
"readable-stream": "^4.4.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/protodef-validator": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/protodef-validator/-/protodef-validator-1.4.0.tgz",
"integrity": "sha512-2y2coBolqCEuk5Kc3QwO7ThR+/7TZiOit4FrpAgl+vFMvq8w76nDhh09z08e2NQOdrgPLsN2yzXsvRvtADgUZQ==",
"license": "MIT",
"dependencies": {
"ajv": "^6.5.4"
},
"bin": {
"protodef-validator": "cli.js"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/railroad-diagrams": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz",
"integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==",
"license": "CC0-1.0"
},
"node_modules/randexp": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz",
"integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==",
"license": "MIT",
"dependencies": {
"discontinuous-range": "1.0.0",
"ret": "~0.1.10"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/ret": {
"version": "0.1.15",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
"integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==",
"license": "MIT",
"engines": {
"node": ">=0.12"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/typed-emitter": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-1.4.0.tgz",
"integrity": "sha512-weBmoo3HhpKGgLBOYwe8EB31CzDFuaK7CCL+axXhUYhn4jo6DSkHnbefboCF5i4DQ2aMFe0C/FdTWcPdObgHyg==",
"license": "MIT"
},
"node_modules/uint4": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/uint4/-/uint4-0.1.2.tgz",
"integrity": "sha512-lhEx78gdTwFWG+mt6cWAZD/R6qrIj0TTBeH5xwyuDJyswLNlGe+KVlUPQ6+mx5Ld332pS0AMUTo9hIly7YsWxQ==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT"
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"license": "BSD-2-Clause",
"dependencies": {
"punycode": "^2.1.0"
}
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/uuid-1345": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/uuid-1345/-/uuid-1345-1.0.2.tgz",
"integrity": "sha512-bA5zYZui+3nwAc0s3VdGQGBfbVsJLVX7Np7ch2aqcEWFi5lsAEcmO3+lx3djM1npgpZI8KY2FITZ2uYTnYUYyw==",
"license": "MIT",
"dependencies": {
"macaddress": "^0.5.1"
}
},
"node_modules/vec3": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/vec3/-/vec3-0.1.10.tgz",
"integrity": "sha512-Sr1U3mYtMqCOonGd3LAN9iqy0qF6C+Gjil92awyK/i2OwiUo9bm7PnLgFpafymun50mOjnDcg4ToTgRssrlTcw==",
"license": "BSD"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/xxhash-wasm": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-0.4.2.tgz",
"integrity": "sha512-/eyHVRJQCirEkSZ1agRSCwriMhwlyUcFkXD5TPVSLP+IPzjsqMVzZwdoczLp1SoQU0R3dxz1RpIK+4YNQbCVOA==",
"license": "MIT"
},
"node_modules/yggdrasil": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/yggdrasil/-/yggdrasil-1.8.0.tgz",
"integrity": "sha512-r5bKOhkZ52DJ6q034uSkdsdZLoFVhOmfDOagRs6h/JX5W7+XIPOMb+peCbElhLEoIckwt43NCUoNQbydOzuPcQ==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.1",
"uuid": "^10.0.0"
}
},
"node_modules/yggdrasil/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
}
}
}

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "flayerproxy",
"version": "1.0.0",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"sniffer": "node src/sniffer/index.js",
"test": "echo \"Error: no test specified\" && exit 1",
"install:fernflower": "bash scripts/install-fernflower.sh"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"minecraft-data": "^3.110.2",
"minecraft-protocol": "^1.66.2",
"mineflayer": "^4.37.1",
"prismarine-chunk": "^1.40.0",
"prismarine-world": "^3.7.0"
}
}

264
protocol.md Normal file
View File

@@ -0,0 +1,264 @@
# Minecraft Server: Login Flow & World Synchronization
This document outlines the detailed protocol sequences, state transitions, and network synchronization mechanisms used in the Minecraft server, based on the codebase in `serversSrc`.
---
## 1. Connection Handshake & Protocol Transition
When a client initiates a connection to a Minecraft server, it starts in the **Handshake** protocol. This is handled by [ServerHandshakePacketListenerImpl](file:///home/seb/flayerproxy/serversSrc/net/minecraft/server/network/ServerHandshakePacketListenerImpl.java).
```mermaid
sequenceDiagram
autonumber
participant Client
participant Server (Handshake)
participant Server (Login)
participant Mojang Session Service
participant Server (Config)
Client->>Server (Handshake): ClientIntentionPacket (Intention=LOGIN, protocolVersion)
Note over Server (Handshake): Validates protocol version
Server (Handshake)->>Server (Login): Instantiate ServerLoginPacketListenerImpl
Client->>Server (Login): ServerboundHelloPacket (name)
alt Offline Mode
Server (Login)->>Server (Login): startClientVerification (Offline UUID)
else Online Mode
Server (Login)-->>Client: ClientboundHelloPacket (ServerID, public key, challenge)
Client->>Server (Login): ServerboundKeyPacket (encrypted shared secret, encrypted challenge)
Note over Server (Login): Decrypts secret & sets up AES encryption
Server (Login)->>Mojang Session Service: hasJoinedServer(username, digest, IP)
Mojang Session Service-->>Server (Login): GameProfile (UUID, textures)
end
Note over Server (Login): verifyLoginAndFinishConnectionSetup
Server (Login)-->>Client: ClientboundLoginCompressionPacket (optional)
Server (Login)-->>Client: ClientboundLoginFinishedPacket
Client->>Server (Login): ServerboundLoginAcknowledgedPacket
Server (Login)->>Server (Config): Switch protocol, instantiate ServerConfigurationPacketListenerImpl
```
### Protocol Steps:
1. **Client Intention**: The client sends a `ClientIntentionPacket` indicating its target state:
- `STATUS`: The client is pinging the server for info (MOTD, online players).
- `LOGIN`/`TRANSFER`: The client wants to connect to the game server.
2. **Version Verification**: The server verifies that the client's protocol version matches the server's current version:
- If mismatched, the server sends a `ClientboundLoginDisconnectPacket` and closes the socket.
- If matching, the server transitions the connection to the `Login` protocol state and spawns a [ServerLoginPacketListenerImpl](file:///home/seb/flayerproxy/serversSrc/net/minecraft/server/network/ServerLoginPacketListenerImpl.java).
---
## 2. Login Protocol Phase
The login protocol handles authentication, encryption setup, and duplicate connection handling.
### Step-by-Step Flow:
1. **Hello**: The client sends its username inside a `ServerboundHelloPacket`.
2. **Authentication Determination**:
- **Offline Mode**: The server bypasses encryption/auth, creates an offline UUID profile, and starts verification.
- **Online Mode**: The server transitions to the `KEY` state and sends a `ClientboundHelloPacket` containing a random challenge token, the server's public key, and server ID.
3. **Encryption Setup**:
- The client generates a shared secret symmetric key (AES), encrypts it and the challenge token using the server's RSA public key, and sends it back in a `ServerboundKeyPacket`.
- The server decrypts the shared secret and challenge token using its private key. It verifies the challenge matches.
- Symmetric AES encryption is initialized on the network socket (`connection.setEncryptionKey(...)`).
4. **Session Verification**:
- The server computes a SHA-1 hash (server ID + shared secret + server public key) and sends a request to Mojang's session servers to verify if the client has successfully authenticated their session (`hasJoinedServer`).
- If verified, the server receives the client's official `GameProfile` (UUID, username, skin textures, etc.).
5. **Verifying and Compression**:
- The server verifies if the player is allowed to connect (checks `UserBanList`, `IpBanList`, `UserWhiteList`, and server full limitations via `PlayerList.canPlayerLogin`).
- If a compression threshold is configured in `server.properties`, the server sends a `ClientboundLoginCompressionPacket` and turns on network compression.
- **Duplicate Connection Check**: The server disconnects any existing players with the same UUID.
6. **Finished Protocol Transition**:
- The server sends a `ClientboundLoginFinishedPacket` to notify the client that the login phase is complete.
- The client acknowledges this by responding with `ServerboundLoginAcknowledgedPacket`.
- The server switches the connection to the **Configuration** protocol and creates a [ServerConfigurationPacketListenerImpl](file:///home/seb/flayerproxy/serversSrc/net/minecraft/server/network/ServerConfigurationPacketListenerImpl.java).
---
## 3. Configuration Phase
The server configuration phase is a task-based queue that sets up registry entries, client/server resource settings, and initial spawn calculations before the player actually joins the world.
```mermaid
sequenceDiagram
autonumber
participant Client
participant Server (Config)
Note over Server (Config): startConfiguration()
Server (Config)-->>Client: Brand / Server Links / Update Features Packets
Server (Config)-->>Client: Registry/Pack select request (SynchronizeRegistriesTask)
Client->>Server (Config): ServerboundSelectKnownPacks
Note over Server (Config): PrepareSpawnTask (Loads player data & chunks)
Note over Server (Config): JoinWorldTask (Sends finish config packet)
Server (Config)-->>Client: ClientboundFinishConfigurationPacket
Client->>Server (Config): ServerboundFinishConfigurationPacket
Note over Server (Config): Transition connection to Play state
```
### Configuration Task Queue:
- **Server Identity**: The server sends initial information like brand name (`ClientboundCustomPayloadPacket` with `BrandPayload`) and links (`ClientboundServerLinksPacket`).
- **SynchronizeRegistriesTask**: Sends the server's known resource packs and waits for the client to acknowledge with `ServerboundSelectKnownPacks`. The server then replies with `ClientboundRegistryDataPacket` and `ClientboundUpdateTagsPacket`.
- **Optional Tasks**:
- `ServerCodeOfConductConfigurationTask`: Prompts client to accept terms.
- `ServerResourcePackConfigurationTask`: Sends resource pack download prompts.
- **PrepareSpawnTask**:
1. Loads player data (position, rotation, dimension).
2. Asynchronously requests spawn chunk loading (radius of 3 chunks around player spawn position).
3. Holds configuration tick execution until the client's immediate spawn area is fully loaded and ready.
- **JoinWorldTask**: Sends `ClientboundFinishConfigurationPacket` to trigger play state transition.
- **Transition**:
- The client responds with `ServerboundFinishConfigurationPacket`.
- The server updates the network handler to the **Play** protocol template (`GameProtocols.CLIENTBOUND_TEMPLATE.bind(...)`).
- The server spawns the player into the level and hands control to [ServerGamePacketListenerImpl](file:///home/seb/flayerproxy/serversSrc/net/minecraft/server/network/ServerGamePacketListenerImpl.java).
---
## 4. Play State Transition (Initial World Sync)
When the connection transitions to the **Play** state, [PlayerList.placeNewPlayer()](file:///home/seb/flayerproxy/serversSrc/net/minecraft/server/players/PlayerList.java#L141-L194) sends a dense stream of packets to synchronize the player's HUD, environment, inventory, and initial chunks.
```
[ Client ] [ Server (Play) ]
| |
| <----------- ClientboundLoginPacket -------------| (EID, difficulty, dimensions...)
| <------- ClientboundChangeDifficultyPacket ------| (Current difficulty state)
| <------ ClientboundPlayerAbilitiesPacket -------| (Flying/creative capabilities)
| <-------- ClientboundSetHeldSlotPacket ----------| (Active hotbar slot index)
| <------- ClientboundUpdateRecipesPacket ---------| (Synchronize recipes)
| <--------- Send Commands Tree Packet ------------| (Command syntax helper)
| <------ ClientboundPlayerInfoUpdatePacket -------| (Initialize online tab list)
| <--------- Teleport Packet to Spawn -------------| (Pos/Rot snap location)
| |
| (Send World Info) |
| <----- ClientboundInitializeBorderPacket --------| (World border settings)
| <------------- Synchronize Clock Packet ---------| (World time / day-night tick)
| <---- ClientboundSetDefaultSpawnPositionPacket --| (World spawn coordinate)
| <--------- ClientboundGameEventPacket -----------| (Weather updates: Rain/Thunder)
| <-------- LEVEL_CHUNKS_LOAD_START Event ---------| (Trigger client chunk loading)
| |
| (Active Entities & Chunks) |
| <------- ClientboundUpdateMobEffectPacket -------| (Apply ongoing status effects)
| <--------- Initialize Inventory Packet ----------| (Fill inventory UI slots)
```
1. **ClientboundLoginPacket**: Sets up core game parameters (Entity ID, hardcore mode, view distance, simulation distance).
2. **ClientboundChangeDifficultyPacket & ClientboundPlayerAbilitiesPacket**: Syncs world difficulty and character flight/speed settings.
3. **ClientboundSetHeldSlotPacket**: Syncs the player's currently selected hotbar slot.
4. **ClientboundUpdateRecipesPacket**: Syncs stonecutter and generic crafting recipes.
5. **Commands & Permissions**: Sends operator status and command syntax mappings.
6. **Scoreboard & Teams**: Sends objective lists and color styling data (`updateEntireScoreboard`).
7. **Player List Info**: Sends `ClientboundPlayerInfoUpdatePacket` to add the joining player and all current players to the tab list.
8. **Position Teleport**: Teleports the player's local camera to the spawn position.
9. **Environment/Weather**: Syncs the world border, time of day, default spawn points, and weather conditions (e.g., `START_RAINING`, `RAIN_LEVEL_CHANGE`).
10. **Start Level Sync**: Sends a game event of type `LEVEL_CHUNKS_LOAD_START` to indicate to the client that it should prepare to receive chunk data.
---
## 5. Live World Synchronization Mechanisms
Once a player is in the world, the server continuously updates the player's client via three loops: **Chunk Sync**, **Entity Sync**, and **World State Sync**.
```mermaid
sequenceDiagram
autonumber
participant Client
participant Server (Play)
Note over Server (Play): PlayerList.placeNewPlayer()
Server (Play)-->>Client: ClientboundLoginPacket (Entity ID, Dimension keys, View distance...)
Server (Play)-->>Client: ChangeDifficulty / PlayerAbilities / SetHeldSlot / UpdateRecipes Packets
Server (Play)-->>Client: Teleport (initial position)
Server (Play)-->>Client: InitializeBorder / Sync Time / DefaultSpawnPosition Packets
Server (Play)-->>Client: LEVEL_CHUNKS_LOAD_START Game Event
rect rgb(200, 220, 240)
Note over Server (Play), Client: Chunk Synchronization Loop
Server (Play)-->>Client: ClientboundChunkBatchStartPacket
Server (Play)-->>Client: ClientboundLevelChunkWithLightPacket (multiple chunks)
Server (Play)-->>Client: ClientboundChunkBatchFinishedPacket
end
rect rgb(220, 240, 200)
Note over Server (Play), Client: Entity Tracking Loop
Server (Play)-->>Client: ClientboundBundlePacket (Spawn Entity, Metadata, Attributes, Equipment)
Server (Play)-->>Client: ClientboundMoveEntityPacket / EntityPositionSyncPacket / RotateHeadPacket
Server (Play)-->>Client: ClientboundRemoveEntitiesPacket (when entity out of range)
end
rect rgb(240, 200, 220)
Note over Server (Play), Client: Dynamic World State Loop
Server (Play)-->>Client: ClientboundBlockUpdatePacket (single block)
Server (Play)-->>Client: ClientboundSectionBlocksUpdatePacket (multiple blocks in section)
Server (Play)-->>Client: ClientboundLightUpdatePacket (lighting recalculations)
end
```
### A. Chunk Synchronization
Minecraft manages which chunks are loaded on the client through the player's view distance and [ChunkTrackingView](file:///home/seb/flayerproxy/serversSrc/net/minecraft/server/level/ChunkMap.java#L1031-L1065).
- **Movement Tracking**: As a player walks, the difference between their previous `ChunkTrackingView` and current `ChunkTrackingView` is computed:
- **New Chunks**: Scheduled for sending via `markChunkPendingToSend(player, chunk)`.
- **Old Chunks**: Cleared from the client via `ClientboundForgetLevelChunkPacket`.
- **Chunk Batching**: To prevent network congestion, the server uses [PlayerChunkSender](file:///home/seb/flayerproxy/serversSrc/net/minecraft/server/network/PlayerChunkSender.java) to batch chunks:
1. Sends a `ClientboundChunkBatchStartPacket`.
2. Sends individual chunk data using `ClientboundLevelChunkWithLightPacket` (containing blocks, state mappings, tile entities, and light values).
3. Sends `ClientboundChunkBatchFinishedPacket` confirming the batch size.
4. Waits for the client to acknowledge before sending the next batch (the batch rate dynamically throttles based on the client's processing feedback).
---
### B. Entity Tracking & Synchronization
The server tracks close entities (players, items, projectiles, mobs) on a per-player basis using [ChunkMap.TrackedEntity](file:///home/seb/flayerproxy/serversSrc/net/minecraft/server/level/ChunkMap.java#L1287-L1405).
#### 1. Spawn Sync (Adding a Pairing)
When an entity enters a player's tracking range:
1. The server calls `addPairing(player)`, collecting all initialization packets.
2. It packs them inside a `ClientboundBundlePacket` to guarantee atomic client rendering:
- `getAddEntityPacket(...)`: Spawns the visual representation of the entity.
- `ClientboundSetEntityDataPacket`: Syncs metadata values (e.g., if a creeper is ignited, if a wolf is sitting).
- `ClientboundUpdateAttributesPacket`: Syncs movement speed, health limits, etc.
- `ClientboundSetEquipmentPacket`: Syncs armor, shield, and hand items.
- `ClientboundSetPassengersPacket`: Syncs riding links.
#### 2. Position & State Sync (Incremental Updates)
Every tick, the server runs `sendChanges()` inside [ServerEntity](file:///home/seb/flayerproxy/serversSrc/net/minecraft/server/level/ServerEntity.java#L89-L227):
- **Relative Movement**: If the movement delta since the last packet is small, the server encodes it using a `VecDeltaCodec` (fitting into a `short` representation) and sends:
- `ClientboundMoveEntityPacket.Pos` (position only)
- `ClientboundMoveEntityPacket.Rot` (rotation only)
- `ClientboundMoveEntityPacket.PosRot` (both position and rotation)
- **Hard Synced Teleportation**: If the displacement exceeds the short delta limit, the riding/grounded state changes, or 400 ticks (`FORCED_TELEPORT_PERIOD`) have passed, the server sends a `ClientboundEntityPositionSyncPacket` to force-snap the position.
- **Head Rotation**: Head yaw changes are tracked separately and sent via `ClientboundRotateHeadPacket`.
- **Velocity**: Real-time motion forces (like knockback) are sent using `ClientboundSetEntityMotionPacket`.
#### 3. Despawn Sync (Removing a Pairing)
When an entity is destroyed or moves out of the player's tracking range, `removePairing(player)` is executed, sending a `ClientboundRemoveEntitiesPacket` to free memory on the client.
---
### C. Dynamic World State Updates
Real-time edits to the environment (player building, chest placements, water flows) are pushed block-by-block using [ChunkHolder](file:///home/seb/flayerproxy/serversSrc/net/minecraft/server/level/ChunkHolder.java#L116-L232):
- **Block Change Recording**: When a block updates, `blockChanged(pos)` records the position relative to its 16x16x16 section.
- **Broadcasting Updates**:
- **Single Block Change**: The server sends a `ClientboundBlockUpdatePacket` containing the coordinate and the new block state.
- **Multiple Block Changes**: If multiple blocks update in the same section within a single tick, they are consolidated and sent as a `ClientboundSectionBlocksUpdatePacket` to save bandwidth.
- **Tile Entities**: If the updated block holds a block entity (like a sign, container, or banner), the server fetches and broadcasts its NBT tag via `ClientboundBlockEntityDataPacket`.
- **Light Recalculation**: If block updates affect ambient brightness, a `ClientboundLightUpdatePacket` is broadcasted.
---
## Packet sniffer proxy (development)
MITM proxy: the Java client connects to a local `minecraft-protocol` server; the sniffer opens a second authenticated client to `config.server` and relays decrypted packets both ways while logging to JSONL.
```bash
npm run sniffer
```
- Listens on `config.sniffer.port` (default **25567**); upstream target is `config.server`.
- Connect the Java client to the sniffer (not 25566). One client at a time.
- Logs: `logs/sniffer/session-<timestamp>.jsonl` with `"type":"packet"` entries (`dir`, `state`, `name`, payload or summary).
- `sniffer.upstreamAuth`: `"microsoft"` (default) or `"offline"` for the upstream leg.
- `sniffer.onlineMode`: `false` (default) lets the Java client join the sniffer without Mojang checking the sniffer itself; upstream still uses `upstreamAuth`.
- Server list **ping** (`nextState: 1`) uses a raw TCP pass-through; **Join** (`nextState: 2`) runs the MITM path (login, `registry_data`, `map_chunk`, …).
- `registry_data` / chunk packets are relayed with `writeRaw` where needed so NBT stays byte-identical.
For the main FlayerProxy handoff path (25566), captured upstream config is still replayed with `writeRaw` so registry NBT stays byte-identical.

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