From db2edb66ffde14ad8a5a498d8a5aef0cfbb36a79 Mon Sep 17 00:00:00 2001 From: sebseb7 Date: Wed, 20 May 2026 18:20:59 +0200 Subject: [PATCH] Genesis --- .gitignore | 8 + README.md | 202 +++++++ codebase_map.md | 649 +++++++++++++++++++++ config.json | 32 ++ package-lock.json | 959 ++++++++++++++++++++++++++++++++ package.json | 22 + protocol.md | 264 +++++++++ src/config.js | 34 ++ src/constants/rawPackets.js | 11 + src/index.js | 49 ++ src/proxy/ClientBridge.js | 292 ++++++++++ src/proxy/ProxyServer.js | 109 ++++ src/replay/StateReplayer.js | 216 +++++++ src/replay/replayChunks.js | 69 +++ src/replay/replayHelpers.js | 107 ++++ src/session/ChunkAckManager.js | 59 ++ src/session/MovementRelay.js | 156 ++++++ src/session/ServerConnection.js | 257 +++++++++ src/session/SessionManager.js | 316 +++++++++++ src/session/handoffFlow.js | 67 +++ src/sniffer/MitmProxy.js | 256 +++++++++ src/sniffer/PacketLog.js | 250 +++++++++ src/sniffer/StreamTap.js | 164 ++++++ src/sniffer/TransparentProxy.js | 176 ++++++ src/sniffer/index.js | 47 ++ src/sniffer/mitmEncryption.js | 69 +++ src/sniffer/mitmGate.js | 198 +++++++ src/sniffer/mitmLogin.js | 54 ++ src/sniffer/mitmRelay.js | 108 ++++ src/sniffer/mitmSession.js | 59 ++ src/sniffer/mitmUpstream.js | 155 ++++++ src/state/ChunkCache.js | 160 ++++++ src/state/EntityCache.js | 166 ++++++ src/state/InventoryCache.js | 84 +++ src/state/JoinSyncCache.js | 80 +++ src/state/MiscCache.js | 274 +++++++++ src/state/PlayerStateCache.js | 134 +++++ src/state/ScoreboardCache.js | 73 +++ src/state/WorldBorderCache.js | 65 +++ src/state/WorldStateCache.js | 337 +++++++++++ src/utils/angles.js | 33 ++ src/utils/chatRelay.js | 171 ++++++ src/utils/clientDisconnect.js | 48 ++ src/utils/handoffSync.js | 53 ++ src/utils/logger.js | 43 ++ src/utils/positionSync.js | 161 ++++++ 46 files changed, 7296 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 codebase_map.md create mode 100644 config.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 protocol.md create mode 100644 src/config.js create mode 100644 src/constants/rawPackets.js create mode 100644 src/index.js create mode 100644 src/proxy/ClientBridge.js create mode 100644 src/proxy/ProxyServer.js create mode 100644 src/replay/StateReplayer.js create mode 100644 src/replay/replayChunks.js create mode 100644 src/replay/replayHelpers.js create mode 100644 src/session/ChunkAckManager.js create mode 100644 src/session/MovementRelay.js create mode 100644 src/session/ServerConnection.js create mode 100644 src/session/SessionManager.js create mode 100644 src/session/handoffFlow.js create mode 100644 src/sniffer/MitmProxy.js create mode 100644 src/sniffer/PacketLog.js create mode 100644 src/sniffer/StreamTap.js create mode 100644 src/sniffer/TransparentProxy.js create mode 100644 src/sniffer/index.js create mode 100644 src/sniffer/mitmEncryption.js create mode 100644 src/sniffer/mitmGate.js create mode 100644 src/sniffer/mitmLogin.js create mode 100644 src/sniffer/mitmRelay.js create mode 100644 src/sniffer/mitmSession.js create mode 100644 src/sniffer/mitmUpstream.js create mode 100644 src/state/ChunkCache.js create mode 100644 src/state/EntityCache.js create mode 100644 src/state/InventoryCache.js create mode 100644 src/state/JoinSyncCache.js create mode 100644 src/state/MiscCache.js create mode 100644 src/state/PlayerStateCache.js create mode 100644 src/state/ScoreboardCache.js create mode 100644 src/state/WorldBorderCache.js create mode 100644 src/state/WorldStateCache.js create mode 100644 src/utils/angles.js create mode 100644 src/utils/chatRelay.js create mode 100644 src/utils/clientDisconnect.js create mode 100644 src/utils/handoffSync.js create mode 100644 src/utils/logger.js create mode 100644 src/utils/positionSync.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc7a6e0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +tools/fernflower.jar +serversSrc/ +logs/ +versions/ +tools/ +scripts/ +.cursor/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..62a0350 --- /dev/null +++ b/README.md @@ -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 + 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). diff --git a/codebase_map.md b/codebase_map.md new file mode 100644 index 0000000..0c9e59e --- /dev/null +++ b/codebase_map.md @@ -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. | diff --git a/config.json b/config.json new file mode 100644 index 0000000..166a238 --- /dev/null +++ b/config.json @@ -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 + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..81d989b --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fb63b03 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/protocol.md b/protocol.md new file mode 100644 index 0000000..6d08b11 --- /dev/null +++ b/protocol.md @@ -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-.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. diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..4b59eab --- /dev/null +++ b/src/config.js @@ -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 }; diff --git a/src/constants/rawPackets.js b/src/constants/rawPackets.js new file mode 100644 index 0000000..9f555d6 --- /dev/null +++ b/src/constants/rawPackets.js @@ -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 }; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..7fc5f9f --- /dev/null +++ b/src/index.js @@ -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}`); +}); diff --git a/src/proxy/ClientBridge.js b/src/proxy/ClientBridge.js new file mode 100644 index 0000000..1f845b2 --- /dev/null +++ b/src/proxy/ClientBridge.js @@ -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 }; diff --git a/src/proxy/ProxyServer.js b/src/proxy/ProxyServer.js new file mode 100644 index 0000000..66a8133 --- /dev/null +++ b/src/proxy/ProxyServer.js @@ -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 }; diff --git a/src/replay/StateReplayer.js b/src/replay/StateReplayer.js new file mode 100644 index 0000000..11a56cf --- /dev/null +++ b/src/replay/StateReplayer.js @@ -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} + */ + 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 24โ€“28 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 }; diff --git a/src/replay/replayChunks.js b/src/replay/replayChunks.js new file mode 100644 index 0000000..2ff188c --- /dev/null +++ b/src/replay/replayChunks.js @@ -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} + */ +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 }; diff --git a/src/replay/replayHelpers.js b/src/replay/replayHelpers.js new file mode 100644 index 0000000..b28a035 --- /dev/null +++ b/src/replay/replayHelpers.js @@ -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, +}; diff --git a/src/session/ChunkAckManager.js b/src/session/ChunkAckManager.js new file mode 100644 index 0000000..7daa941 --- /dev/null +++ b/src/session/ChunkAckManager.js @@ -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 }; diff --git a/src/session/MovementRelay.js b/src/session/MovementRelay.js new file mode 100644 index 0000000..d6120cb --- /dev/null +++ b/src/session/MovementRelay.js @@ -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} + */ +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 }; diff --git a/src/session/ServerConnection.js b/src/session/ServerConnection.js new file mode 100644 index 0000000..3b0231b --- /dev/null +++ b/src/session/ServerConnection.js @@ -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 }; diff --git a/src/session/SessionManager.js b/src/session/SessionManager.js new file mode 100644 index 0000000..4916b10 --- /dev/null +++ b/src/session/SessionManager.js @@ -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 }; diff --git a/src/session/handoffFlow.js b/src/session/handoffFlow.js new file mode 100644 index 0000000..06daeb7 --- /dev/null +++ b/src/session/handoffFlow.js @@ -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} 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 }; diff --git a/src/sniffer/MitmProxy.js b/src/sniffer/MitmProxy.js new file mode 100644 index 0000000..f4c9a9c --- /dev/null +++ b/src/sniffer/MitmProxy.js @@ -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 }; diff --git a/src/sniffer/PacketLog.js b/src/sniffer/PacketLog.js new file mode 100644 index 0000000..cce6e48 --- /dev/null +++ b/src/sniffer/PacketLog.js @@ -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 }; diff --git a/src/sniffer/StreamTap.js b/src/sniffer/StreamTap.js new file mode 100644 index 0000000..4a821bd --- /dev/null +++ b/src/sniffer/StreamTap.js @@ -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 }; diff --git a/src/sniffer/TransparentProxy.js b/src/sniffer/TransparentProxy.js new file mode 100644 index 0000000..494c376 --- /dev/null +++ b/src/sniffer/TransparentProxy.js @@ -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 }; diff --git a/src/sniffer/index.js b/src/sniffer/index.js new file mode 100644 index 0000000..1b24f95 --- /dev/null +++ b/src/sniffer/index.js @@ -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')); diff --git a/src/sniffer/mitmEncryption.js b/src/sniffer/mitmEncryption.js new file mode 100644 index 0000000..df41617 --- /dev/null +++ b/src/sniffer/mitmEncryption.js @@ -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} 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 }; diff --git a/src/sniffer/mitmGate.js b/src/sniffer/mitmGate.js new file mode 100644 index 0000000..80895ef --- /dev/null +++ b/src/sniffer/mitmGate.js @@ -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, +}; diff --git a/src/sniffer/mitmLogin.js b/src/sniffer/mitmLogin.js new file mode 100644 index 0000000..c1a2676 --- /dev/null +++ b/src/sniffer/mitmLogin.js @@ -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 }; diff --git a/src/sniffer/mitmRelay.js b/src/sniffer/mitmRelay.js new file mode 100644 index 0000000..65b05bc --- /dev/null +++ b/src/sniffer/mitmRelay.js @@ -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, +}; diff --git a/src/sniffer/mitmSession.js b/src/sniffer/mitmSession.js new file mode 100644 index 0000000..28a3111 --- /dev/null +++ b/src/sniffer/mitmSession.js @@ -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 }; diff --git a/src/sniffer/mitmUpstream.js b/src/sniffer/mitmUpstream.js new file mode 100644 index 0000000..5ec58bd --- /dev/null +++ b/src/sniffer/mitmUpstream.js @@ -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 }; diff --git a/src/state/ChunkCache.js b/src/state/ChunkCache.js new file mode 100644 index 0000000..d284d44 --- /dev/null +++ b/src/state/ChunkCache.js @@ -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} key "x,z" -> raw packet data */ + this.chunks = new Map(); + /** @type {Map} 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 }; diff --git a/src/state/EntityCache.js b/src/state/EntityCache.js new file mode 100644 index 0000000..b693977 --- /dev/null +++ b/src/state/EntityCache.js @@ -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} 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 }; diff --git a/src/state/InventoryCache.js b/src/state/InventoryCache.js new file mode 100644 index 0000000..84dfc81 --- /dev/null +++ b/src/state/InventoryCache.js @@ -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 }; diff --git a/src/state/JoinSyncCache.js b/src/state/JoinSyncCache.js new file mode 100644 index 0000000..153d46e --- /dev/null +++ b/src/state/JoinSyncCache.js @@ -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 }; diff --git a/src/state/MiscCache.js b/src/state/MiscCache.js new file mode 100644 index 0000000..ce2e496 --- /dev/null +++ b/src/state/MiscCache.js @@ -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 }; diff --git a/src/state/PlayerStateCache.js b/src/state/PlayerStateCache.js new file mode 100644 index 0000000..b31ece7 --- /dev/null +++ b/src/state/PlayerStateCache.js @@ -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 24โ€“28). + */ + 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, +}; diff --git a/src/state/ScoreboardCache.js b/src/state/ScoreboardCache.js new file mode 100644 index 0000000..21f3da2 --- /dev/null +++ b/src/state/ScoreboardCache.js @@ -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 }; diff --git a/src/state/WorldBorderCache.js b/src/state/WorldBorderCache.js new file mode 100644 index 0000000..b68d254 --- /dev/null +++ b/src/state/WorldBorderCache.js @@ -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 }; diff --git a/src/state/WorldStateCache.js b/src/state/WorldStateCache.js new file mode 100644 index 0000000..6585a2c --- /dev/null +++ b/src/state/WorldStateCache.js @@ -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 }; diff --git a/src/utils/angles.js b/src/utils/angles.js new file mode 100644 index 0000000..1112fda --- /dev/null +++ b/src/utils/angles.js @@ -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 }; diff --git a/src/utils/chatRelay.js b/src/utils/chatRelay.js new file mode 100644 index 0000000..dceb8eb --- /dev/null +++ b/src/utils/chatRelay.js @@ -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, +}; diff --git a/src/utils/clientDisconnect.js b/src/utils/clientDisconnect.js new file mode 100644 index 0000000..356c6fd --- /dev/null +++ b/src/utils/clientDisconnect.js @@ -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, +}; diff --git a/src/utils/handoffSync.js b/src/utils/handoffSync.js new file mode 100644 index 0000000..56022eb --- /dev/null +++ b/src/utils/handoffSync.js @@ -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, +}; diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100644 index 0000000..6ff1d20 --- /dev/null +++ b/src/utils/logger.js @@ -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 }; diff --git a/src/utils/positionSync.js b/src/utils/positionSync.js new file mode 100644 index 0000000..f73268b --- /dev/null +++ b/src/utils/positionSync.js @@ -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, +};