Genesis
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
tools/fernflower.jar
|
||||
serversSrc/
|
||||
logs/
|
||||
versions/
|
||||
tools/
|
||||
scripts/
|
||||
.cursor/
|
||||
202
README.md
Normal file
202
README.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# 🎮 FlayerProxy
|
||||
|
||||
> **A seamless Minecraft Bot-to-Proxy handoff bridge.** Keep your Minecraft character online 24/7, and instantly take control from a standard client whenever you join.
|
||||
|
||||
```text
|
||||
_____ _ ____
|
||||
| ___| | __ _ _ _ ___ _ _| _ \ _ __ _____ ___ _
|
||||
| |_ | |/ _` | | | |/ _ \ '__| |_) | '__/ _ \ \/ / | | |
|
||||
| _| | | (_| | |_| | __/ | | __/| | | (_) > <| |_| |
|
||||
|_| |_|\__,_|\__, |\___|_| |_| |_| \___/_/\_\\__, |
|
||||
|___/ |___/
|
||||
```
|
||||
|
||||
**FlayerProxy** bridges [Mineflayer](https://github.com/PrismarineJS/mineflayer) (a powerful Minecraft bot framework) and [minecraft-protocol](https://github.com/PrismarineJS/node-minecraft-protocol). It connects to a target Minecraft server, maintains a persistent online session, runs anti-AFK tasks, caches the surrounding world state, and lets you connect using a standard Minecraft client to take control of your character on the fly—without disconnecting from the server.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 How It Works
|
||||
|
||||
FlayerProxy operates as a stateful proxy between your client and the target server. The lifecycle revolves around four major states:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> INIT : Start Application
|
||||
INIT --> BOT_MODE : Connects to Server
|
||||
BOT_MODE --> HANDOFF : Proxy Client Connects
|
||||
HANDOFF --> CLIENT_MODE : State Replayed & Teleport Confirmed
|
||||
CLIENT_MODE --> BOT_MODE : Proxy Client Disconnects
|
||||
CLIENT_MODE --> INIT : Server Disconnects
|
||||
BOT_MODE --> INIT : Server Disconnects
|
||||
```
|
||||
|
||||
### 1. `INIT`
|
||||
* The bot initiates a connection to the upstream target server.
|
||||
* The local proxy server begins listening for incoming client connections.
|
||||
|
||||
### 2. `BOT_MODE`
|
||||
* No human player is connected.
|
||||
* The bot holds the session, runs anti-AFK behavior (if configured), and handles physics.
|
||||
* **State Caching**: The proxy continuously captures and updates a local cache of the world (chunks, entities, inventory, scoreboards, player position, etc.).
|
||||
|
||||
### 3. `HANDOFF`
|
||||
* A standard Minecraft client connects to the proxy.
|
||||
* Bot AI/physics are immediately disabled to prevent conflict.
|
||||
* **State Replay**: The `StateReplayer` sends the cached world state sequentially to the client, reproducing the exact state of the environment without reloading.
|
||||
* The client is synchronized to the bot's coordinates and confirms the teleport.
|
||||
|
||||
### 4. `CLIENT_MODE`
|
||||
* The handoff is completed.
|
||||
* A bidirectional `ClientBridge` is established to pipe raw network packets directly between your client and the target server.
|
||||
* You play normally with low latency, while the cache continues to monitor updates in the background.
|
||||
* When you disconnect, the proxy reverts to `BOT_MODE`, re-enabling the bot's anti-AFK AI.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ State Cache System
|
||||
|
||||
To make the client transition seamless and prevent loading/rendering glitches, FlayerProxy caches a comprehensive set of packet states:
|
||||
|
||||
| Cache Component | Monitored Packets & Data | Eviction / Strategy |
|
||||
| :--- | :--- | :--- |
|
||||
| **Chunks** | `map_chunk`, `update_light`, `unload_chunk`, `block_change`, `multi_block_change` | LRU cache (default max 1024 chunks) with active block change overlays. |
|
||||
| **Entities** | `spawn_entity`, `entity_metadata`, `entity_equipment`, `entity_effect`, `set_passengers`, `entity_destroy`, relative movements / teleports | Tracks positions, gear, mounts, and status effects. |
|
||||
| **Player State** | `login`, `position`, `update_health`, `experience`, `abilities`, `difficulty`, `respawn` | Caches player attributes to sync client UI and positioning. |
|
||||
| **Inventory** | `window_items`, `set_slot`, `held_item_slot`, `set_player_inventory`, `set_cursor_item` | Captures open container, inventory contents, and hand slots. |
|
||||
| **Misc / Environment**| `update_time`, `game_state_change`, `initialize_world_border`, `player_info` (tab list), `scoreboard_*`, `teams`, `boss_bar`, `tags` | Keeps track of scoreboard rankings, player lists, time of day, world borders, and registry tags. |
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
Copy the template structure and configure your parameters in `config.json` in the root directory:
|
||||
|
||||
```json
|
||||
{
|
||||
"server": {
|
||||
"host": "192.168.178.58",
|
||||
"port": 25565,
|
||||
"version": "1.21.10"
|
||||
},
|
||||
"auth": {
|
||||
"username": "FlayerBot",
|
||||
"auth": "microsoft"
|
||||
},
|
||||
"proxy": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 25566,
|
||||
"onlineMode": false,
|
||||
"maxClients": 1
|
||||
},
|
||||
"bot": {
|
||||
"antiAfk": true,
|
||||
"antiAfkInterval": 30000,
|
||||
"viewDistance": 10
|
||||
},
|
||||
"cache": {
|
||||
"maxChunks": 1024,
|
||||
"trackEntities": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Config Options Reference
|
||||
|
||||
* **`server`**: Upstream Minecraft server details. `version` must match the server version.
|
||||
* **`auth`**: Credentials. `auth` can be set to `"microsoft"` or `"offline"`.
|
||||
* **`proxy`**: Local proxy server settings.
|
||||
* `onlineMode`: If true, proxy checks Mojang authentication for incoming clients (requires client to match bot username or have appropriate credentials depending on target server configuration). Set to `false` for simple local offline-mode connections.
|
||||
* **`bot`**: Bot behavior settings.
|
||||
* `antiAfk`: Keeps the bot moving or performing minor actions so it doesn't get kicked for inactivity.
|
||||
* **`cache`**: Memory usage controls for caching the world.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```text
|
||||
├── config.json # Configuration settings
|
||||
├── codebase_map.md # Comprehensive function & class map
|
||||
├── package.json # Dependencies and scripts
|
||||
└── src
|
||||
├── index.js # Entry point; boots session and handles process signals
|
||||
├── config.js # Configuration loader & validator
|
||||
├── constants/ # Constants, e.g. packets requiring raw forwarding
|
||||
├── proxy/
|
||||
│ ├── ProxyServer.js # Wrapper for minecraft-protocol server
|
||||
│ └── ClientBridge.js # Pipes client-to-server & server-to-client packets
|
||||
├── session/
|
||||
│ ├── SessionManager.js # Orchestrates states (BOT_MODE <-> CLIENT_MODE)
|
||||
│ ├── ServerConnection.js # Wraps Mineflayer bot and captures raw packets
|
||||
│ ├── ChunkAckManager.js # Intercepts and controls chunk acks
|
||||
│ ├── MovementRelay.js # Syncs client and bot coordinates
|
||||
│ └── handoffFlow.js # Step-by-step handoff sequence runner
|
||||
├── state/
|
||||
│ ├── WorldStateCache.js # Main caching coordinator
|
||||
│ ├── ChunkCache.js # Map chunks, lights, and block edits (LRU)
|
||||
│ ├── EntityCache.js # Track mobs, players, metadata, and positions
|
||||
│ ├── InventoryCache.js # Items, equipment slots, and cursor items
|
||||
│ ├── PlayerStateCache.js # XP, health, spawning details, and coords
|
||||
│ ├── JoinSyncCache.js # Recipes and advancements sent at login
|
||||
│ ├── MiscCache.js # Scoreboards, world borders, time, and headers
|
||||
│ ├── ScoreboardCache.js # Tracks scoreboard entries and teams
|
||||
│ └── WorldBorderCache.js # Tracks world border constraints
|
||||
├── replay/
|
||||
│ ├── StateReplayer.js # Replays cached packets to clients on handoff
|
||||
│ ├── replayChunks.js # Streams chunk and light packets
|
||||
│ └── replayHelpers.js # Replay yielding and wait conditions
|
||||
├── sniffer/ # Packet sniffer & MITM interception tool
|
||||
│ ├── index.js # Sniffer launcher and entry point
|
||||
│ ├── MitmProxy.js # Decrypts/parses both legs (Mitm mode)
|
||||
│ ├── TransparentProxy.js # Transparent TCP proxy (Transparent mode)
|
||||
│ ├── StreamTap.js # Unmodified stream frame parser
|
||||
│ ├── PacketLog.js # Structured JSONL log writer
|
||||
│ └── mitm*.js # Login, encryption, gate, relay and session logic
|
||||
└── utils/
|
||||
├── logger.js # Structured logging utility
|
||||
├── angles.js # Minecraft rotation & angle utility functions
|
||||
├── chatRelay.js # Chat re-signing for the bot connection
|
||||
├── clientDisconnect.js # Safe error-handling disconnect wrappers
|
||||
├── handoffSync.js # Temporary play-phase socket relay helper
|
||||
└── positionSync.js # Coordinates and confirms client positioning
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚦 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
* [Node.js](https://nodejs.org/) (v18 or higher recommended)
|
||||
* A valid Minecraft account (if connecting to online-mode servers)
|
||||
|
||||
### Installation
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd flayerproxy
|
||||
```
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Running the Proxy
|
||||
1. Create and customize your `config.json` in the root.
|
||||
2. Start the proxy server:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
3. Open Minecraft, click **Direct Connection** (or Add Server), and connect to `localhost:25566` (or whichever port you configured).
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Technical Notes & Packet Handling
|
||||
|
||||
* **Config-Phase Capture**: During the server's handshake/configuration phase, FlayerProxy intercept `registry_data` and metadata packets. These are later passed verbatim to your joining client so that custom blocks, biomes, and registry configurations match the target server exactly.
|
||||
* **Keep-Alive Filtering**: The Mineflayer bot automatically responds to upstream `keep_alive` checks. The proxy blocks `keep_alive` packets from being sent to/from the local client to prevent sequence mismatches that would trigger an immediate kick.
|
||||
* **Position Synchronization**: During the handoff, the client must be instantly moved to the exact coords of the bot. The proxy writes a `position` packet to the client, pauses packet forwarding, waits for the client to return a `teleport_confirm` packet, and then opens the bidirectional stream. This ensures you spawn safely in the world without falling or glitching.
|
||||
|
||||
---
|
||||
|
||||
## 📜 License
|
||||
This project is licensed under the [ISC License](LICENSE).
|
||||
649
codebase_map.md
Normal file
649
codebase_map.md
Normal file
@@ -0,0 +1,649 @@
|
||||
# 🗺️ FlayerProxy Codebase Map
|
||||
|
||||
This document provides a comprehensive mapping of all the classes, functions, and files in the `src/` directory of **FlayerProxy**, along with Mermaid diagrams showing how the components interact during different modes of operation.
|
||||
|
||||
---
|
||||
|
||||
## 📑 Table of Contents
|
||||
|
||||
- [Architecture & Interaction Diagrams](#-architecture--interaction-diagrams)
|
||||
- [High-Level Core System](#1-high-level-core-system-diagram)
|
||||
- [Client Handoff Flow](#2-client-handoff-flow-diagram)
|
||||
- [MITM Sniffer Architecture](#3-mitm-sniffer-architecture-diagram)
|
||||
- [Detailed File Mapping](#-detailed-file-mapping)
|
||||
- [Root Scripts & Configuration](#1-root-scripts--configuration) — `src/index.js`, `src/config.js`
|
||||
- [session — Session State Machine](#2-session--session-state-machine) — `SessionManager`, `ServerConnection`, `ChunkAckManager`, `MovementRelay`, `handoffFlow`
|
||||
- [proxy — Client Connection Proxy](#3-proxy--client-connection-proxy) — `ProxyServer`, `ClientBridge`
|
||||
- [state — World State Caching](#4-state--world-state-caching) — `WorldStateCache`, `ChunkCache`, `EntityCache`, `PlayerStateCache`, `InventoryCache`, `MiscCache`, `JoinSyncCache`, `WorldBorderCache`, `ScoreboardCache`
|
||||
- [replay — Client Handoff Replay](#5-replay--client-handoff-replay) — `StateReplayer`, `replayChunks`, `replayHelpers`
|
||||
- [utils — Helper Utilities](#6-utils--helper-utilities) — `angles`, `chatRelay`, `clientDisconnect`, `handoffSync`, `logger`, `positionSync`
|
||||
- [sniffer — MITM Packet Sniffer](#7-sniffer--mitm-packet-sniffer) — `MitmProxy`, `TransparentProxy`, `StreamTap`, `PacketLog`, and relay modules
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture & Interaction Diagrams
|
||||
|
||||
### 1. High-Level Core System Diagram
|
||||
|
||||
This diagram shows how the core components of FlayerProxy (`SessionManager`, `ProxyServer`, `ServerConnection`, `WorldStateCache`, `StateReplayer`, and `ClientBridge`) cooperate to manage the proxy's dual-mode lifecycle.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
classDef main fill:#d4e1f5,stroke:#1a5f7a,stroke-width:2px;
|
||||
classDef helper fill:#f5f0d4,stroke:#7a6b1a,stroke-width:1px;
|
||||
classDef external fill:#e1d5e7,stroke:#9673a6,stroke-width:1px;
|
||||
|
||||
Client["Minecraft Client (Port 25566)"]:::external
|
||||
Server["Target Minecraft Server"]:::external
|
||||
|
||||
subgraph FlayerProxy ["FlayerProxy Core"]
|
||||
SM["[SessionManager]"]:::main
|
||||
SC["[ServerConnection]"]:::main
|
||||
PS["[ProxyServer]"]:::main
|
||||
WSC["[WorldStateCache]"]:::main
|
||||
SR["[StateReplayer]"]:::main
|
||||
CB["[ClientBridge]"]:::main
|
||||
end
|
||||
|
||||
SM -->|Orchestrates| SC
|
||||
SM -->|Orchestrates| PS
|
||||
SM -->|Orchestrates| WSC
|
||||
SM -->|Orchestrates| SR
|
||||
SM -->|Coordinates Handoff| CB
|
||||
|
||||
SC -->|Holds session with| Bot["Mineflayer Bot"]:::external
|
||||
Bot -->|Connects to| Server
|
||||
SC -->|Captures play packets to| WSC
|
||||
PS -->|Listens for incoming| Client
|
||||
SR -->|Replays cache from WSC to| Client
|
||||
CB -->|Pipes C2S / S2C play packets| Client
|
||||
CB -->|Pipes C2S / S2C play packets| SC
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Client Handoff Flow Diagram
|
||||
|
||||
This sequence diagram shows the step-by-step handoff process when a standard Minecraft client connects to the proxy, disabling the bot's AI, replaying the cached state, and establishing the packet-forwarding bridge.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor Player as Minecraft Client
|
||||
participant PS as ProxyServer
|
||||
participant SM as SessionManager
|
||||
participant SC as ServerConnection
|
||||
participant WSC as WorldStateCache
|
||||
participant SR as StateReplayer
|
||||
participant Server as Upstream Server
|
||||
|
||||
Note over SM,SC: State: BOT_MODE (Bot AI holds session)
|
||||
Player->>PS: Connect to Proxy (Port 25566)
|
||||
PS->>SM: _onClientConnect(client)
|
||||
Note over SM: State -> HANDOFF
|
||||
SM->>SC: setBotControl(false) [Disable Bot physics/AI]
|
||||
SM->>SM: _primeChunksNearBot() [Confirm position & await chunks]
|
||||
SM->>SR: replay(client)
|
||||
SR->>Player: Send 'login' packet (join_game)
|
||||
SR->>Player: Send difficulty, abilities, permission level
|
||||
SR->>Player: Send early misc packets (tags, commands, server_data, time)
|
||||
SR->>Player: Send recipe & advancement sync packets
|
||||
SR->>Player: Send initial 'position' teleport packet
|
||||
Player-->>SR: Send 'teleport_confirm' packet
|
||||
SR->>Player: Send tab list (player_info)
|
||||
SR->>Player: Send level info (world border, spawn, time)
|
||||
SR->>Player: Send chunks (chunk_batch_start, map_chunks, light, block_changes, chunk_batch_finished)
|
||||
SR->>Player: Send spawned entities (metadata, equipment, effects, passengers)
|
||||
SR->>Player: Send experience, health, and status effects
|
||||
SR->>Player: Send inventory snapshot (window_items, set_slot, held_item_slot)
|
||||
Note over SR: State replay complete
|
||||
SM->>SC: syncProxyClientPosition(client) [Final snap]
|
||||
SC->>Player: Send updated 'position' + 'update_view_position'
|
||||
Player-->>SC: Send 'teleport_confirm'
|
||||
SM->>SC: confirmServerPosition() [Confirm to Server]
|
||||
SC->>Server: Send serverbound 'position_look'
|
||||
SM->>Server: Send 'player_loaded' (hasClientLoaded)
|
||||
Note over SM: State -> CLIENT_MODE
|
||||
SM->>CB: Start ClientBridge(client, serverConn, worldState)
|
||||
Note over CB: Bidirectional Packet Piping Active
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. MITM Sniffer Architecture Diagram
|
||||
|
||||
The Packet Sniffer has two modes of operation:
|
||||
|
||||
* **MitmProxy**: Decrypts both the client leg and the server leg using custom negotiated encryption keys to print and inspect packet structures in real time.
|
||||
* **TransparentProxy**: Pipes TCP streams directly without decryption, using a non-disruptive `StreamTap` to record unencrypted packets (like status handshakes or offline logins).
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
classDef sniffer fill:#d4f5d4,stroke:#1a7a1a,stroke-width:2px;
|
||||
classDef external fill:#e1d5e7,stroke:#9673a6,stroke-width:1px;
|
||||
|
||||
Client["Minecraft Client (Port 25567)"]:::external
|
||||
Server["Target Minecraft Server"]:::external
|
||||
|
||||
subgraph MitmProxyApp ["MitmProxy (Packet Sniffer)"]
|
||||
MProxy["MitmProxy (mc.createServer)"]:::sniffer
|
||||
PacketLog["PacketLog (JSONL log file)"]:::sniffer
|
||||
StreamTap["StreamTap (C2S & S2C frame parsing)"]:::sniffer
|
||||
end
|
||||
|
||||
Client -->|connects| MProxy
|
||||
MProxy -->|initiates upstream client| UpstreamClient["mc.createClient"]:::sniffer
|
||||
UpstreamClient -->|connects| Server
|
||||
|
||||
MProxy -->|logs packets| PacketLog
|
||||
MProxy -->|taps packets for state tracking| StreamTap
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ Detailed File Mapping
|
||||
|
||||
### 1. Root Scripts & Configuration
|
||||
|
||||
#### 📄 [src/index.js](file:///home/seb/flayerproxy/src/index.js)
|
||||
|
||||
The main entry point for the application. Loads system config and handles errors, starts the core [SessionManager](file:///home/seb/flayerproxy/src/session/SessionManager.js), hooks process event signals (`SIGINT`, `SIGTERM`) for a graceful shutdown, and captures `uncaughtException` / `unhandledRejection` warnings.
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `shutdown(signal)` | Gracefully stops the proxy and exits the node process. |
|
||||
|
||||
#### 📄 [src/config.js](file:///home/seb/flayerproxy/src/config.js)
|
||||
|
||||
Handles configuration loading, validation, and parsing.
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `loadConfig()` | Synchronously reads `config.json`, validates host, port, version, and auth configuration, applies default options, and returns the configuration object. |
|
||||
|
||||
---
|
||||
|
||||
### 2. session — Session State Machine
|
||||
|
||||
#### 🧩 [SessionManager](file:///home/seb/flayerproxy/src/session/SessionManager.js) `class`
|
||||
|
||||
Orchestrates the dual-mode proxy state machine: `INIT` ↔ `BOT_MODE` ↔ `HANDOFF` ↔ `CLIENT_MODE`.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor(config)` | Initializes state machine with configuration. |
|
||||
| `start()` | Establishes connections to the upstream Minecraft server and starts the proxy server. |
|
||||
| `_scheduleReconnect(delaySec)` | Schedules a timed reconnect sequence if the connection is lost. |
|
||||
| `_setupServerEvents()` | Hooks upstream server events (`connected`, `disconnected`, `kicked`, `error`, `death`, `respawn`). |
|
||||
| `_primeChunksNearBot()` | Triggers server movement packets to verify that the chunk cache is loaded near the bot before handing off. |
|
||||
| `_refreshClientAfterBotRespawn()` | Re-aligns position and client view in case the bot respawns while a client is connected. |
|
||||
| `_onClientConnect(client)` | Initiates the handoff sequence to transition from `BOT_MODE` to `CLIENT_MODE`. |
|
||||
| `_cleanupClient()` | Stops the packet bridge and cleans up client-related event listeners. |
|
||||
| `_transitionTo(newState)` | Transitions the machine state and logs status summaries. |
|
||||
| `stop()` | Gracefully halts all services. |
|
||||
|
||||
#### 🧩 [ServerConnection](file:///home/seb/flayerproxy/src/session/ServerConnection.js) `class` extends `EventEmitter`
|
||||
|
||||
Manages the persistent Mineflayer bot connection to the target server.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor(config, worldState)` | Initializes bot connection manager with config and world state reference. |
|
||||
| `connect()` | Spawns the bot connection via Mineflayer. |
|
||||
| `_setupConfigCapture()` | Caches configuration-phase registry data and tags. |
|
||||
| `_setupPacketCapture()` | Hooks play packets and routes them directly to the state cache. |
|
||||
| `_setupBotEvents()` | Listens for bot lifecycle events (`spawn`, `end`, `kicked`, `error`, `death`, and chat logging). |
|
||||
| `setBotControl(enabled)` | Enables/disables physics and AI on the Mineflayer bot. |
|
||||
| `setClientDrivesChunkBatchAck(clientDrives)` | Delegates chunk batch acknowledgement control between client and Mineflayer. |
|
||||
| `flushChunkBatchAck()` | Unblocks the server chunk sender. |
|
||||
| `refreshProxyClientPermissions(client)` | Sends player permissions status packets. |
|
||||
| `syncProxyClientPosition(client)` | Snaps client position coordinates to the bot. |
|
||||
| `confirmServerPosition()` | Confirms final block coordinates back to the server. |
|
||||
| `setProxyClientChunkAck(enabled)` | Configures the [ChunkAckManager](file:///home/seb/flayerproxy/src/session/ChunkAckManager.js). |
|
||||
| `relayClientMovement(name, data)` | Translates Notchian/mineflayer coordinates and forwards client movements upstream. |
|
||||
| `writeToServer(name, data)` | Writes raw packets directly upstream. |
|
||||
| `disconnect()` | Safely disconnects the bot. |
|
||||
|
||||
#### 🧩 [ChunkAckManager](file:///home/seb/flayerproxy/src/session/ChunkAckManager.js) `class`
|
||||
|
||||
Intercepts Mineflayer's chunk batch acknowledgement listeners to prevent double-acknowledging packets during the client session.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor()` | Initializes acknowledgement state. |
|
||||
| `disable(rawClient)` | Disables auto-acknowledgement on the client stream. |
|
||||
| `restore(rawClient)` | Restores Mineflayer auto-acknowledgement. |
|
||||
| `flush(rawClient)` | Sends a manual chunk batch received acknowledgement packet. |
|
||||
|
||||
#### 📄 [MovementRelay.js](file:///home/seb/flayerproxy/src/session/MovementRelay.js) `functions`
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `relayClientMovement(bot, rawClient, name, data)` | Applies client movement packets to the bot entity structure and writes updates upstream. |
|
||||
| `syncProxyClientPosition(bot, worldState, client)` | Sends a teleport/position packet to the client and awaits a matching `teleport_confirm`. |
|
||||
| `confirmServerPosition(bot, rawClient, connected)` | Writes a `position_look` verification packet to the upstream server. |
|
||||
|
||||
#### 📄 [handoffFlow.js](file:///home/seb/flayerproxy/src/session/handoffFlow.js) `functions`
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `performHandoff({...})` | Coordinates the sequential handoff sequence: installs temporary upstream forwarding rules, primes nearby chunks, triggers the [StateReplayer](file:///home/seb/flayerproxy/src/replay/StateReplayer.js), aligns player coordinates/permissions, and spawns the [ClientBridge](file:///home/seb/flayerproxy/src/proxy/ClientBridge.js). |
|
||||
|
||||
---
|
||||
|
||||
### 3. proxy — Client Connection Proxy
|
||||
|
||||
#### 🧩 [ProxyServer](file:///home/seb/flayerproxy/src/proxy/ProxyServer.js) `class`
|
||||
|
||||
Listens for connection attempts from standard Minecraft Java clients.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor(config, onClientConnect, worldState)` | Initializes proxy server with config, client callback, and world state. |
|
||||
| `start()` | Initializes the local minecraft-protocol server (`mc.createServer`), configures pre-login listeners to replay raw config packets, and registers joining players. |
|
||||
| `updateRegistryCodec(codec)` | Replaces the registry codec object in the protocol handler options. |
|
||||
| `stop()` | Halts client listening sockets. |
|
||||
|
||||
#### 🧩 [ClientBridge](file:///home/seb/flayerproxy/src/proxy/ClientBridge.js) `class`
|
||||
|
||||
Manages bidirectional packet pipelines when in `CLIENT_MODE`.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor(client, serverConn, worldState)` | Initializes bridge with client, server connection, and world state references. |
|
||||
| `_getViewDistance()` | Resolves the view distance from configuration or state records. |
|
||||
| `_syncClientViewFromBlockCoords(blockX, blockZ)` | Computes chunk coordinates and updates client view center position. |
|
||||
| `_syncClientViewFromBot()` | Aligns client view center with the bot entity position. |
|
||||
| `_playerBlockCoordsForView()` | Returns coordinates representing the client player's view anchor. |
|
||||
| `_ensureViewIncludesChunk(chunkX, chunkZ)` | Ensures the client's view includes target coordinates prior to sending map chunks. |
|
||||
| `enableMovement()` | Authorizes client movement and syncs view center alignment. |
|
||||
| `start()` | Sets up packet intercept listeners to route packets between legs, excluding blocked elements (e.g. `keep_alive`, `teleport_confirm`, `message_acknowledgement`) and re-signing client chat events. |
|
||||
| `_shouldForwardPlayerInfo(data)` | Filters latency updates for unknown players. |
|
||||
| `stop()` | Tears down the forwarding pipe. |
|
||||
|
||||
---
|
||||
|
||||
### 4. state — World State Caching
|
||||
|
||||
#### 🧩 [WorldStateCache](file:///home/seb/flayerproxy/src/state/WorldStateCache.js) `class`
|
||||
|
||||
Master coordinator for world cache segments. Integrates and clears sub-caches on server switches.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor(config)` | Initializes all sub-caches from configuration. |
|
||||
| `handleConfigPacket(name, data)` | Caches parsed config-phase packets. |
|
||||
| `buildRegistryCodec()` | Combines cached config packets to build a custom Minecraft registry codec. |
|
||||
| `handleRawConfigPacket(name, buffer)` | Appends raw configuration buffers. |
|
||||
| `getRawConfigPacketsForReplay()` | Filters and returns config-phase buffers for client replay. |
|
||||
| `hasRawConfigPackets()` | Returns `true` if raw configuration buffers are cached. |
|
||||
| `handleServerPacket(name, data, buffer)` | Evaluates incoming play packets and routes them to sub-caches. |
|
||||
| `getSummary()` | Returns cache sizing info for log monitoring. |
|
||||
| `clear()` | Wipes all cached structures. |
|
||||
|
||||
#### 🧩 [ChunkCache](file:///home/seb/flayerproxy/src/state/ChunkCache.js) `class`
|
||||
|
||||
Manages loaded map chunks, light maps, and block overlays using an LRU cache.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor(maxChunks)` | Initializes chunk storage with LRU capacity limit. |
|
||||
| `_key(x, z)` | Computes string key for map lookups. |
|
||||
| `handleMapChunk(data, rawBuffer)` | Caches chunk data and marks it as active in the LRU tracking queue. |
|
||||
| `handleUpdateLight(data, rawBuffer)` | Caches light updates. |
|
||||
| `handleUnloadChunk(data)` | Evicts chunk records from cache. |
|
||||
| `handleBlockChange(data)` | Appends single block changes as an overlay. |
|
||||
| `handleMultiBlockChange(data)` | Appends multiblock edits as an overlay. |
|
||||
| `_buildChunkEntry(chunkData)` | Assembles raw data, block edits, and light arrays. |
|
||||
| `getChunksForReplay(centerChunkX, centerChunkZ, viewDistance)` | Returns cached chunks sorting closest-first. |
|
||||
| `hasChunkAtBlock(x, z)` | Verifies if a chunk is loaded. |
|
||||
| `clear()` | Wipes chunk maps. |
|
||||
|
||||
#### 🧩 [EntityCache](file:///home/seb/flayerproxy/src/state/EntityCache.js) `class`
|
||||
|
||||
Tracks entities, positions, gear, and status effects.
|
||||
|
||||
| Method | Category | Description |
|
||||
|---|---|---|
|
||||
| `constructor()` | — | Initializes entity tracking maps. |
|
||||
| `handleSpawnEntity(data)` | Lifecycle | Instantiates a new tracked entity. |
|
||||
| `handleEntityDestroy(data)` | Lifecycle | Removes entities. |
|
||||
| `handleEntityMetadata(data)` | State | Updates metadata flags. |
|
||||
| `handleEntityEquipment(data)` | State | Caches equipment items. |
|
||||
| `handleEntityEffect(data)` | State | Adds status effects. |
|
||||
| `handleRemoveEntityEffect(data)` | State | Removes status effects. |
|
||||
| `handleSetPassengers(data)` | State | Caches passenger mounting structures. |
|
||||
| `handleEntityPosition(data)` | Movement | Updates entity coordinates. |
|
||||
| `handleSyncEntityPosition(data)` | Movement | Translates and maps sync positions. |
|
||||
| `handleRelEntityMove(data)` | Movement | Applies relative move differentials. |
|
||||
| `handleEntityMoveLook(data)` | Movement | Applies move and rotation differentials. |
|
||||
| `handleEntityTeleport(data)` | Movement | Sets absolute coordinates. |
|
||||
| `getAllEntities()` | Query | Returns sanitized entity arrays. |
|
||||
| `removePlayerEntity(entityId)` | Query | Excludes own entity. |
|
||||
| `clear()` | — | Clears entity maps. |
|
||||
|
||||
#### 🧩 [PlayerStateCache](file:///home/seb/flayerproxy/src/state/PlayerStateCache.js) `class`
|
||||
|
||||
Caches player-specific attributes.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor()` | Initializes player state storage. |
|
||||
| `handleLogin(data)` | Stores the initial login join_game packet. |
|
||||
| `handlePosition(data)` | Stores coordinate positions. |
|
||||
| `handleUpdateHealth(data)` | Stores health/hunger levels. |
|
||||
| `handleExperience(data)` | Caches XP level values. |
|
||||
| `handleAbilities(data)` | Caches flight capabilities and speeds. |
|
||||
| `handleEntityStatus(data)` | Caches permissions status. |
|
||||
| `handleSpawnPosition(data)` | Caches spawn points. |
|
||||
| `handleDifficulty(data)` | Caches difficulty levels. |
|
||||
| `handleGameStateChange(data)` | Captures gamemode updates. |
|
||||
| `handleRespawn(data)` | Resets active position, health, and status values on player respawn. |
|
||||
| `handleEntityEffect(data)` | Caches player status effects. |
|
||||
| `handleRemoveEntityEffect(data)` | Evicts player status effects. |
|
||||
| `getState()` | Returns all active player data. |
|
||||
| `clear()` | Wipes player state. |
|
||||
|
||||
#### 🧩 [InventoryCache](file:///home/seb/flayerproxy/src/state/InventoryCache.js) `class`
|
||||
|
||||
Caches slot lists, inventories, and held items.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor()` | Initializes inventory storage. |
|
||||
| `handleWindowItems(data)` | Caches full item lists. |
|
||||
| `handleSetSlot(data)` | Caches individual slot updates. |
|
||||
| `handleHeldItemSlot(data)` | Stores current hotbar slot index. |
|
||||
| `handleSetPlayerInventory(data)` | Stores inventory slots. |
|
||||
| `handleSetCursorItem(data)` | Stores cursor items. |
|
||||
| `getReplayPackets()` | Assembles sequence of inventory packet states. |
|
||||
| `clear()` | Clears inventory states. |
|
||||
|
||||
#### 🧩 [MiscCache](file:///home/seb/flayerproxy/src/state/MiscCache.js) `class`
|
||||
|
||||
Coordinates world data, tab lists, scoreboards, tags, and bossbars.
|
||||
|
||||
| Method | Category | Description |
|
||||
|---|---|---|
|
||||
| `constructor()` | — | Initializes miscellaneous state storage. |
|
||||
| `handleUpdateTime(data)` | World | Caches time variables. |
|
||||
| `handleGameStateChange(data)` | World | Tracks weather triggers. |
|
||||
| `handleSimulationDistance(data)` | World | Caches simulation distance settings. |
|
||||
| `handleUpdateViewDistance(data)` | World | Caches view distance settings. |
|
||||
| `handleUpdateViewPosition(data)` | World | Caches view position markers. |
|
||||
| `handlePlayerInfo(data)` | Tab list | Tracks player additions. |
|
||||
| `handlePlayerRemove(data)` | Tab list | Tracks player removals. |
|
||||
| `handlePlayerListHeader(data)` | Tab list | Caches tab list headers. |
|
||||
| `handleBossBar(data)` | UI | Caches bossbar indicators. |
|
||||
| `handleTags(data)` | Registry | Caches server tag structures. |
|
||||
| `handleServerData(data)` | Registry | Caches server description parameters. |
|
||||
| `handleDeclareCommands(data)` | Registry | Caches commands registry. |
|
||||
| `getReplayPackets()` | Replay | Compiles time, weather, border, teams, scoreboard, and bossbar packets. |
|
||||
| `getPlayerInfoReplayPackets()` | Replay | Compiles player_info packets. |
|
||||
| `getKnownPlayerUuids()` | Query | Identifies player UUIDs. |
|
||||
| `clear()` | — | Clears variables. |
|
||||
|
||||
#### 🧩 [JoinSyncCache](file:///home/seb/flayerproxy/src/state/JoinSyncCache.js) `class`
|
||||
|
||||
Holds configuration elements that are typically sent only once at login, such as advancement criteria and recipe lists.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor()` | Initializes join-sync storage. |
|
||||
| `handlePacket(name, data)` | Identifies and caches advancements and recipe book packets. |
|
||||
| `getReplayPackets()` | Returns advancement and recipe packets. |
|
||||
| `clear()` | Clears variables. |
|
||||
|
||||
#### 🧩 [WorldBorderCache](file:///home/seb/flayerproxy/src/state/WorldBorderCache.js) `class`
|
||||
|
||||
Caches world border coordinates and warn margins.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor()` | Initializes border state storage. |
|
||||
| `handleInitWorldBorder(data)` | Caches initial world border packet. |
|
||||
| `handleWorldBorderCenter(data)` | Caches center coordinates. |
|
||||
| `handleWorldBorderSize(data)` | Caches border size. |
|
||||
| `handleWorldBorderLerpSize(data)` | Caches border interpolation size. |
|
||||
| `handleWorldBorderWarningDelay(data)` | Caches warning delay. |
|
||||
| `handleWorldBorderWarningReach(data)` | Caches warning reach distance. |
|
||||
| `getReplayPackets()` | Returns border initialization packets. |
|
||||
| `clear()` | Resets variables. |
|
||||
|
||||
#### 🧩 [ScoreboardCache](file:///home/seb/flayerproxy/src/state/ScoreboardCache.js) `class`
|
||||
|
||||
Caches teams, scores, objectives, and scoreboard layouts.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor()` | Initializes scoreboard storage. |
|
||||
| `handleScoreboardObjective(data)` | Caches objective structures. |
|
||||
| `handleScoreboardDisplayObjective(data)` | Caches display positions. |
|
||||
| `handleScoreboardScore(data)` | Caches score updates. |
|
||||
| `handleResetScore(data)` | Removes score records. |
|
||||
| `handleTeams(data)` | Caches teams. |
|
||||
| `getReplayPackets()` | Aggregates objectives, displays, scores, and teams. |
|
||||
| `clear()` | Clears variables. |
|
||||
|
||||
---
|
||||
|
||||
### 5. replay — Client Handoff Replay
|
||||
|
||||
#### 🧩 [StateReplayer](file:///home/seb/flayerproxy/src/replay/StateReplayer.js) `class`
|
||||
|
||||
Replays the cached world state to a connecting client.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor(worldState, serverConn)` | Initializes replayer with world state and server connection references. |
|
||||
| `replay(client)` | Coordinates sequential packet delivery (see replay sequence below). |
|
||||
|
||||
**`replay()` sequence:**
|
||||
|
||||
| Step | Packets sent |
|
||||
|---|---|
|
||||
| 1 | `login` packet (join_game) |
|
||||
| 2 | Difficulty, abilities, and permission level |
|
||||
| 3 | Pre-level metadata |
|
||||
| 4 | Active hotbar selection |
|
||||
| 5 | Recipe books and advancements |
|
||||
| 6 | Teleport client player → await `teleport_confirm` |
|
||||
| 7 | Tab list names |
|
||||
| 8 | Time, spawn, world border, weather, view distance |
|
||||
| 9 | Chunk loading start → replay chunks |
|
||||
| 10 | Spawned entities (metadata, passengers, effects) |
|
||||
| 11 | XP, health, and status effects |
|
||||
| 12 | Full inventory (`window_items`) |
|
||||
|
||||
#### 📄 [replayChunks.js](file:///home/seb/flayerproxy/src/replay/replayChunks.js) `functions`
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `replayChunks(write, writeRaw, chunks, center, totalCached)` | Loops through chunk arrays, sending raw chunk/light buffers and block overlay edits, and issues a final `chunk_batch_finished` packet. |
|
||||
|
||||
#### 📄 [replayHelpers.js](file:///home/seb/flayerproxy/src/replay/replayHelpers.js) `functions`
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `yieldEventLoop()` | Yields the event loop using `setImmediate`. |
|
||||
| `replayPacketData(client, name, data)` | Overrides `enforcesSecureChat` to `false` for clients connecting without secure Mojang keys. |
|
||||
| `getPlayerChunkCenter(playerState, misc, bot)` | Resolves chunk coordinates for position center checks. |
|
||||
| `splitMiscReplayPackets(packets)` | Splits misc packets into early configurations, level coordinates, and weather variables. |
|
||||
| `waitForClientTeleportConfirm(client)` | Awaits the client's `teleport_confirm` packet. |
|
||||
|
||||
---
|
||||
|
||||
### 6. utils — Helper Utilities
|
||||
|
||||
#### 📄 [angles.js](file:///home/seb/flayerproxy/src/utils/angles.js) `functions`
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `toByteAngle(value)` | Converts float degrees to i8 byte angles. |
|
||||
| `sanitizeSpawnEntity(spawnData)` | Normalizes angle properties of entity spawn packets. |
|
||||
|
||||
#### 📄 [chatRelay.js](file:///home/seb/flayerproxy/src/utils/chatRelay.js) `functions`
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `disableInboundChatValidation(client)` | Removes message acknowledgement checks to prevent chat validation kicks. |
|
||||
| `extractChatText(name, data)` | Extracts text string parameter values from chat packets. |
|
||||
| `relayClientChatAsUpstream(serverConn, name, data, log)` | Re-signs and forwards client messages using the bot's credentials. |
|
||||
|
||||
#### 📄 [clientDisconnect.js](file:///home/seb/flayerproxy/src/utils/clientDisconnect.js) `functions`
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `disconnectReasonText(reason)` | Formats error reason arguments into plain text strings. |
|
||||
| `wrapClientEnd(client)` | Wraps connection end methods to prevent formatting exceptions. |
|
||||
| `safeEndClient(client, reason)` | Disconnects client sockets. |
|
||||
|
||||
#### 📄 [handoffSync.js](file:///home/seb/flayerproxy/src/utils/handoffSync.js) `functions`
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `installHandoffUpstreamRelay(client, serverConn, log)` | Pipes chunk batch and player loaded packets directly to the server connection during handoff. |
|
||||
| `removeHandoffUpstreamRelay(client, handler)` | Removes the handoff forwarding pipe. |
|
||||
| `sendPermissionStatusToClient(client, permissionStatus, log)` | Sends the player's OP status packet to the client. |
|
||||
|
||||
#### 📄 [logger.js](file:///home/seb/flayerproxy/src/utils/logger.js) `functions`
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `createLogger(module)` | Returns console logging utilities formatted with module tags and colors. |
|
||||
|
||||
#### 📄 [positionSync.js](file:///home/seb/flayerproxy/src/utils/positionSync.js) `functions`
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `buildClientboundPositionPacket(bot, teleportId)` | Creates a player teleport packet from the bot's current coordinates. |
|
||||
| `waitForClientTeleportConfirm(client, timeoutMs, log)` | Awaits the client's position confirmation. |
|
||||
| `movementFlags(onGround, hasHorizontalCollision)` | Builds movement flags objects. |
|
||||
| `buildServerboundPositionLook(bot)` | Creates a serverbound player position verification packet. |
|
||||
| `distanceSq(a, b)` | Computes squared distance. |
|
||||
| `chunkCoordsFromBlock(x, z)` | Resolves block coordinates to chunk coordinates. |
|
||||
| `isChunkWithinViewDistance(centerChunkX, centerChunkZ, chunkX, chunkZ, viewDistance)` | Checks if a chunk lies within view distance limits. |
|
||||
| `updateClientViewPosition(client, chunkX, chunkZ, lastView)` | Sends `update_view_position` to the client. |
|
||||
| `ensureClientViewIncludesChunk(client, playerBlockX, playerBlockZ, chunkX, chunkZ, viewDistance, lastView)` | Snaps the client view center forward if a target chunk is about to load outside of its radius. |
|
||||
|
||||
---
|
||||
|
||||
### 7. sniffer — MITM Packet Sniffer
|
||||
|
||||
#### 📄 [src/sniffer/index.js](file:///home/seb/flayerproxy/src/sniffer/index.js)
|
||||
|
||||
Main launcher file for the sniffer tool. Parses arguments, applies default configurations for local hosting and logging directory, launches the [MitmProxy](file:///home/seb/flayerproxy/src/sniffer/MitmProxy.js) engine, and sets up shutdown listeners.
|
||||
|
||||
#### 🧩 [MitmProxy](file:///home/seb/flayerproxy/src/sniffer/MitmProxy.js) `class`
|
||||
|
||||
Configures an interception proxy that decrypts packet bytes on both connections.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor(config)` | Initializes the MITM proxy with configuration. |
|
||||
| `start()` | Initializes the server. |
|
||||
| `_onConnection(client)` | Sets up packet listeners, configures log files, and spawns the upstream server client. |
|
||||
| `_startUpstream(session, cleanup)` | Begins the upstream connection flow. |
|
||||
| `_tryBeginJavaCrypto(session, cleanup)` | Checks state before negotiating encryption. |
|
||||
| `_doJavaCrypto(session, cleanup)` | Sets up local encryption filters. |
|
||||
| `stop()` | Shuts down proxy services. |
|
||||
|
||||
#### 🧩 [TransparentProxy](file:///home/seb/flayerproxy/src/sniffer/TransparentProxy.js) `class`
|
||||
|
||||
Transparently forwards TCP bytes between the client and server while feeding a copy to the `StreamTap` to extract and log packets.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor(config)` | Initializes transparent proxy with configuration. |
|
||||
| `start()` | Binds the transparent port listener. |
|
||||
| `_onClientConnect(clientSocket, targetHost, targetPort, sniffer)` | Pipes client and server sockets together and hooks up stream taps. |
|
||||
| `stop()` | Shuts down socket handlers. |
|
||||
|
||||
#### 🧩 [StreamTap](file:///home/seb/flayerproxy/src/sniffer/StreamTap.js) `class`
|
||||
|
||||
Parses packet streams without modifying the underlying bytes.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor(dir, version, packetLog, hooks)` | Initializes stream tap with direction, version, logger, and hooks. |
|
||||
| `feed(chunk)` | Passes byte chunks to the framer/splitter. |
|
||||
| `_syncState()` | Aligns state variables. |
|
||||
| `_parser()` | Creates deserializers. |
|
||||
| `_parseFrame(frame)` | Decompresses frame data. |
|
||||
| `_onFrame(frame)` | Parses parameters, extracts compression settings and login handshakes, and writes entries to logs. |
|
||||
| `_setState(next)` | Sets protocol states. |
|
||||
| `_advanceState(name, data)` | Advances states. |
|
||||
|
||||
#### 🧩 [PacketLog](file:///home/seb/flayerproxy/src/sniffer/PacketLog.js) `class`
|
||||
|
||||
Formats and writes packet data into JSONL logs.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `constructor(opts)` | Initializes log output streams. |
|
||||
| `writeMeta(record)` | Writes metadata entries. |
|
||||
| `logUnparsed(dir, state, frame, message)` | Logs parser errors. |
|
||||
| `logOpaque(dir, bytes, extra)` | Summarizes encrypted play traffic volume. |
|
||||
| `logPacket(dir, meta, data, rawBuffer, extra)` | Records details for parsed packets. |
|
||||
| `close(reason)` | Safely closes output write streams. |
|
||||
|
||||
#### 📄 [mitmEncryption.js](file:///home/seb/flayerproxy/src/sniffer/mitmEncryption.js) `functions`
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `enableJavaEncryption(client, server, options)` | Negotiates the encryption phase on the client connection. |
|
||||
|
||||
#### 📄 [mitmGate.js](file:///home/seb/flayerproxy/src/sniffer/mitmGate.js) `functions`
|
||||
|
||||
Packet gating logic — controls buffering, ordering, and flushing of packets during connection state transitions.
|
||||
|
||||
| Function | Category | Description |
|
||||
|---|---|---|
|
||||
| `canRelayC2S(session, meta)` | C2S | Checks if a client packet should be sent upstream. |
|
||||
| `c2sForwardLabel(session, meta)` | C2S | Labels C2S traffic. |
|
||||
| `classifyS2C(session, meta)` | S2C | Classifies S2C traffic. |
|
||||
| `shouldBufferS2C(session, meta)` | S2C | Checks if S2C packets should be buffered. |
|
||||
| `queueBufferedS2C(session, data, meta, buffer)` | S2C | Appends packets to buffer arrays. |
|
||||
| `queueHeldS2C(session, data, meta, buffer)` | S2C | Queues packets while encryption is pending. |
|
||||
| `isStalePlayS2C(meta)` | S2C | Filters out stale packets. |
|
||||
| `flushQueue(session, queue)` | Flush | Flushes a packet queue to the client. |
|
||||
| `flushPendingConfig(session)` | Flush | Flushes configuration packets. |
|
||||
| `flushPendingPlay(session)` | Flush | Flushes play packets to client. |
|
||||
| `sortPlayPending(pending)` | Ordering | Sorts play-phase packets to match join sequence requirements. |
|
||||
| `partitionAfterCrypto(pendingS2C)` | Ordering | Partitions packets by state. |
|
||||
| `hasPendingSuccess(session)` | State | Checks if a login success packet is waiting. |
|
||||
| `onJavaLoginAcknowledged(session)` | State | Shifts connection states to `CONFIGURATION`. |
|
||||
| `onJavaFinishConfiguration(session, packetLog)` | State | Shifts connection states to `PLAY`. |
|
||||
|
||||
#### 📄 [mitmLogin.js](file:///home/seb/flayerproxy/src/sniffer/mitmLogin.js) `functions`
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `applyLoginStartIdentity(client, packet, server, options)` | Verifies Mojang public key signatures. |
|
||||
|
||||
#### 📄 [mitmRelay.js](file:///home/seb/flayerproxy/src/sniffer/mitmRelay.js) `functions`
|
||||
|
||||
Handles low-level packet relay, compression synchronization, and raw buffer forwarding decisions.
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `shouldWriteRaw(meta, buffer)` | Decides if a packet should be written as a raw buffer. |
|
||||
| `relayPacket(target, meta, data, buffer)` | Writes a packet parsed or raw. |
|
||||
| `syncCompression(target, name, data)` | Synchronizes compression threshold. |
|
||||
| `sortLoginPending(pending)` | Sorts login packets. |
|
||||
| `relayLoginCompressToJava(client, meta, data, buffer)` | Writes compression setting. |
|
||||
| `relayToJava(client, meta, data, buffer)` | Safely writes packets to Java. |
|
||||
|
||||
#### 📄 [mitmSession.js](file:///home/seb/flayerproxy/src/sniffer/mitmSession.js) `functions`
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `createMitmSession(client, packetLog)` | Initializes a sniffer session. |
|
||||
| `createSessionCleanup(session, packetLog, proxy)` | Returns a session cleanup function. |
|
||||
|
||||
#### 📄 [mitmUpstream.js](file:///home/seb/flayerproxy/src/sniffer/mitmUpstream.js) `functions`
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `startStatusPipe(session, config, packetLog, proxy)` | Pipes status pings at the TCP socket layer. |
|
||||
| `startUpstream(session, config, cleanup, callbacks)` | Starts the upstream server connection. |
|
||||
32
config.json
Normal file
32
config.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"server": {
|
||||
"host": "192.168.178.58",
|
||||
"port": 25565,
|
||||
"version": "1.21.10"
|
||||
},
|
||||
"auth": {
|
||||
"username": "FlayerBot",
|
||||
"auth": "microsoft"
|
||||
},
|
||||
"proxy": {
|
||||
"port": 25566,
|
||||
"onlineMode": true,
|
||||
"maxClients": 1
|
||||
},
|
||||
"sniffer": {
|
||||
"port": 25567,
|
||||
"onlineMode": false,
|
||||
"upstreamAuth": "microsoft",
|
||||
"logDir": "logs/sniffer",
|
||||
"includePayload": true
|
||||
},
|
||||
"bot": {
|
||||
"antiAfk": true,
|
||||
"antiAfkInterval": 30000,
|
||||
"viewDistance": 10
|
||||
},
|
||||
"cache": {
|
||||
"maxChunks": 1024,
|
||||
"trackEntities": true
|
||||
}
|
||||
}
|
||||
959
package-lock.json
generated
Normal file
959
package-lock.json
generated
Normal file
@@ -0,0 +1,959 @@
|
||||
{
|
||||
"name": "flayerproxy",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flayerproxy",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"minecraft-data": "^3.110.2",
|
||||
"minecraft-protocol": "^1.66.2",
|
||||
"mineflayer": "^4.37.1",
|
||||
"prismarine-chunk": "^1.40.0",
|
||||
"prismarine-world": "^3.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/msal-common": {
|
||||
"version": "14.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.1.tgz",
|
||||
"integrity": "sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/msal-node": {
|
||||
"version": "2.16.3",
|
||||
"resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.16.3.tgz",
|
||||
"integrity": "sha512-CO+SE4weOsfJf+C5LM8argzvotrXw252/ZU6SM2Tz63fEblhH1uuVaaO4ISYFuN4Q6BhTo7I3qIdi8ydUQCqhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@azure/msal-common": "14.16.1",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"uuid": "^8.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.0.tgz",
|
||||
"integrity": "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-rsa": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-rsa/-/node-rsa-1.1.4.tgz",
|
||||
"integrity": "sha512-dB0ECel6JpMnq5ULvpUTunx3yNm8e/dIkv8Zu9p2c8me70xIRUUG3q+qXRwcSf9rN3oqamv4116iHy90dJGRpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/readable-stream": {
|
||||
"version": "4.0.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz",
|
||||
"integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@xboxreplay/xboxlive-auth": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@xboxreplay/xboxlive-auth/-/xboxlive-auth-5.1.0.tgz",
|
||||
"integrity": "sha512-UngHHsehZbiTjyyNmo8HvdoUDKMID1U9uVfrpFWUK/2UxPuVTKy5n+CzZQ3S488sW5vOhgh0lHqqynT8ouwgvw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.5"
|
||||
}
|
||||
},
|
||||
"node_modules/aes-js": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz",
|
||||
"integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz",
|
||||
"integrity": "sha512-6i37w/+EhlWlGUJff3T/Q8u1RGmP5wgbiwYnOnbOqvtrPxT63/sYFyP9RcpxtxGymtfA075IvmOnL7ycNOWl3w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.1.tgz",
|
||||
"integrity": "sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/discontinuous-range": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz",
|
||||
"integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/endian-toggle": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/endian-toggle/-/endian-toggle-0.0.0.tgz",
|
||||
"integrity": "sha512-ShfqhXeHRE4TmggSlHXG8CMGIcsOsqDw/GcoPcosToE59Rm9e4aXaMhEQf2kPBsBRrKem1bbOAv5gOKnkliMFQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^4.0.1",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
"lodash.isnumber": "^3.0.3",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.once": "^4.0.0",
|
||||
"ms": "^2.1.1",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.includes": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isinteger": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnumber": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isstring": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.once": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.reduce": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.reduce/-/lodash.reduce-4.6.0.tgz",
|
||||
"integrity": "sha512-6raRe2vxCYBhpBu+B+TtNGUzah+hQjVdu3E17wfusjyrXBka2nBS8OH/gjVZ5PvHOhWmIZTYri09Z6n/QfnNMw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/macaddress": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/macaddress/-/macaddress-0.5.4.tgz",
|
||||
"integrity": "sha512-i8xVWoUjj2woYU8kbpQby86Kq7uF7xl2brtKREXUBWpfgqx1fKXEeYzDiVMVxA/IufC1d3xxwJRHtFCX+9IspA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/minecraft-data": {
|
||||
"version": "3.110.2",
|
||||
"resolved": "https://registry.npmjs.org/minecraft-data/-/minecraft-data-3.110.2.tgz",
|
||||
"integrity": "sha512-u0aCCSpQWVreGnZGU/Lu0jmZmc0Y37M0Fvw6eQVQY0BdS/BGRDDU+ug6/qP3QDuZRJCSzi8wNW8ODnOhwpnkpA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/minecraft-folder-path": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/minecraft-folder-path/-/minecraft-folder-path-1.2.0.tgz",
|
||||
"integrity": "sha512-qaUSbKWoOsH9brn0JQuBhxNAzTDMwrOXorwuRxdJKKKDYvZhtml+6GVCUrY5HRiEsieBEjCUnhVpDuQiKsiFaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/minecraft-protocol": {
|
||||
"version": "1.66.2",
|
||||
"resolved": "https://registry.npmjs.org/minecraft-protocol/-/minecraft-protocol-1.66.2.tgz",
|
||||
"integrity": "sha512-keY1IY1E2AeurcekCfcXrg0TDbykGVFiMe1E4wR8QkNtQRieNwfr2xaF3g3vT9ChkwzvENqp3jxgmtFCKSUKPg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@types/node-rsa": "^1.1.4",
|
||||
"@types/readable-stream": "^4.0.0",
|
||||
"aes-js": "^3.1.2",
|
||||
"buffer-equal": "^1.0.0",
|
||||
"debug": "^4.3.2",
|
||||
"endian-toggle": "^0.0.0",
|
||||
"lodash.merge": "^4.3.0",
|
||||
"minecraft-data": "^3.78.0",
|
||||
"minecraft-folder-path": "^1.2.0",
|
||||
"node-fetch": "^2.6.1",
|
||||
"node-rsa": "^0.4.2",
|
||||
"prismarine-auth": "^3.1.1",
|
||||
"prismarine-chat": "^1.10.0",
|
||||
"prismarine-nbt": "^2.5.0",
|
||||
"prismarine-realms": "^1.2.0",
|
||||
"protodef": "^1.17.0",
|
||||
"readable-stream": "^4.1.0",
|
||||
"uuid-1345": "^1.0.1",
|
||||
"yggdrasil": "^1.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/mineflayer": {
|
||||
"version": "4.37.1",
|
||||
"resolved": "https://registry.npmjs.org/mineflayer/-/mineflayer-4.37.1.tgz",
|
||||
"integrity": "sha512-kchZCJb1znzz8ZhE0+gLQ3e2t/9xUsqUy/IM/sGfceINxi3h6KXKY9luaUEa59vnD/x0OKwYdERY4sscm0ErNQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minecraft-data": "^3.108.0",
|
||||
"minecraft-protocol": "^1.66.0",
|
||||
"mojangson": "^2.0.4",
|
||||
"prismarine-biome": "^1.1.1",
|
||||
"prismarine-block": "^1.22.0",
|
||||
"prismarine-chat": "^1.7.1",
|
||||
"prismarine-chunk": "^1.39.0",
|
||||
"prismarine-entity": "^2.5.0",
|
||||
"prismarine-item": "^1.17.0",
|
||||
"prismarine-nbt": "^2.0.0",
|
||||
"prismarine-physics": "^1.9.0",
|
||||
"prismarine-recipe": "^1.5.0",
|
||||
"prismarine-registry": "^1.10.0",
|
||||
"prismarine-windows": "^2.9.0",
|
||||
"prismarine-world": "^3.6.0",
|
||||
"protodef": "^1.18.0",
|
||||
"typed-emitter": "^1.0.0",
|
||||
"uuid-1345": "^1.0.2",
|
||||
"vec3": "^0.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/mojangson": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/mojangson/-/mojangson-2.0.4.tgz",
|
||||
"integrity": "sha512-HYmhgDjr1gzF7trGgvcC/huIg2L8FsVbi/KacRe6r1AswbboGVZDS47SOZlomPuMWvZLas8m9vuHHucdZMwTmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nearley": "^2.19.5"
|
||||
}
|
||||
},
|
||||
"node_modules/moo": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz",
|
||||
"integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nearley": {
|
||||
"version": "2.20.1",
|
||||
"resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz",
|
||||
"integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^2.19.0",
|
||||
"moo": "^0.5.0",
|
||||
"railroad-diagrams": "^1.0.0",
|
||||
"randexp": "0.4.6"
|
||||
},
|
||||
"bin": {
|
||||
"nearley-railroad": "bin/nearley-railroad.js",
|
||||
"nearley-test": "bin/nearley-test.js",
|
||||
"nearley-unparse": "bin/nearley-unparse.js",
|
||||
"nearleyc": "bin/nearleyc.js"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://nearley.js.org/#give-to-nearley"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-rsa": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-0.4.2.tgz",
|
||||
"integrity": "sha512-Bvso6Zi9LY4otIZefYrscsUpo2mUpiAVIEmSZV2q41sP8tHZoert3Yu6zv4f/RXJqMNZQKCtnhDugIuCma23YA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asn1": "0.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/prismarine-auth": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/prismarine-auth/-/prismarine-auth-3.1.1.tgz",
|
||||
"integrity": "sha512-NuNrMGZdoigFKsvi1ZZgAEvNYNuE5qe6lo/tw+bqeNbkhpjHC0u1JNxLEujnfqduXI18e19PvUtWNMDl/gH7yw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@azure/msal-node": "^2.0.2",
|
||||
"@xboxreplay/xboxlive-auth": "^5.1.0",
|
||||
"debug": "^4.3.3",
|
||||
"smart-buffer": "^4.1.0",
|
||||
"uuid-1345": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/prismarine-biome": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/prismarine-biome/-/prismarine-biome-1.4.0.tgz",
|
||||
"integrity": "sha512-fD2WmjN8Zr/xA/jeMInReLgaDlznwA5xlaK529PzWuGzgjpc5ijVu1Lp1oqHyZn3WxOG/bRVtW1bU+tmgCurWA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"prismarine-registry": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prismarine-block": {
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/prismarine-block/-/prismarine-block-1.23.0.tgz",
|
||||
"integrity": "sha512-j2UoU4KbXMvNlBw+aLkMOnEuMayYefznUfbrfv1VIbckG3RA9LpNWltOMHXuOR5YkHp8uIZPOclj95XC88jgGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minecraft-data": "^3.38.0",
|
||||
"prismarine-biome": "^1.1.0",
|
||||
"prismarine-chat": "^1.5.0",
|
||||
"prismarine-item": "^1.10.1",
|
||||
"prismarine-nbt": "^2.0.0",
|
||||
"prismarine-registry": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prismarine-chat": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/prismarine-chat/-/prismarine-chat-1.13.0.tgz",
|
||||
"integrity": "sha512-tvDbrQmJEoy09yLE5nnedGhQYxnRDaPRePMv7W39dFaHr2LGcA2JfCmH0vG5193+BsEFz3a5+0EpQSK8OW7YmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.2",
|
||||
"mojangson": "^2.0.1",
|
||||
"prismarine-nbt": "^2.0.0",
|
||||
"prismarine-registry": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prismarine-chunk": {
|
||||
"version": "1.40.0",
|
||||
"resolved": "https://registry.npmjs.org/prismarine-chunk/-/prismarine-chunk-1.40.0.tgz",
|
||||
"integrity": "sha512-TtT84Bys7+aGA94HwcK0QDp+jkWcLOLErKYtaWWl+EJya28NqPoBASr5L/lPZ8ZWLQUugg/aFIefZI/rEhEQWw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prismarine-biome": "^1.2.0",
|
||||
"prismarine-block": "^1.14.1",
|
||||
"prismarine-nbt": "^2.2.1",
|
||||
"prismarine-registry": "^1.1.0",
|
||||
"smart-buffer": "^4.1.0",
|
||||
"uint4": "^0.1.2",
|
||||
"vec3": "^0.1.3",
|
||||
"xxhash-wasm": "^0.4.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/prismarine-entity": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/prismarine-entity/-/prismarine-entity-2.6.0.tgz",
|
||||
"integrity": "sha512-/LlZRLOpACiXk+GqoaKi0XPBFnNMjb1d4OIzuSCSEgNMK6FUo3Wnin5yeSZ7ff3Ztt7yagN9lX2jSOafn6IIzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prismarine-chat": "^1.4.1",
|
||||
"prismarine-item": "^1.11.2",
|
||||
"prismarine-registry": "^1.4.0",
|
||||
"vec3": "^0.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/prismarine-item": {
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/prismarine-item/-/prismarine-item-1.18.0.tgz",
|
||||
"integrity": "sha512-8pEq6YfcneVvarvUFnex09a3+MR8/4NCQVyawIKAa3kh/g9dHLexoEcpQEgM3cmpg4gbLmspSiARGwed5uGhlg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prismarine-nbt": "^2.0.0",
|
||||
"prismarine-registry": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prismarine-nbt": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/prismarine-nbt/-/prismarine-nbt-2.8.0.tgz",
|
||||
"integrity": "sha512-5D6FUZq0PNtf3v/41ImDlwThVesOv5adyqCRMZLzmkUGEmRJNNh5C6AsnvrClBftXs+IF0yqPnZoj8kcNPiMGg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protodef": "^1.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prismarine-physics": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/prismarine-physics/-/prismarine-physics-1.11.0.tgz",
|
||||
"integrity": "sha512-P25VSDi3kJHQAb/AJBiJCQuxyRCVXRSdEiDjx56ywocgt65N/exatVTiJjOK5HgEKHJSfw0sXSAohQhvutnGAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minecraft-data": "^3.0.0",
|
||||
"prismarine-nbt": "^2.0.0",
|
||||
"vec3": "^0.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/prismarine-realms": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/prismarine-realms/-/prismarine-realms-1.6.0.tgz",
|
||||
"integrity": "sha512-AwemW0vwxG9hQaFtg1twSV7eymB6QtYxGK0jjpxfdA2sdK15kU8jh8uD1o5XF0oxSMU+BbpzZMCmXtXq4QE6bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.3",
|
||||
"node-fetch": "^2.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prismarine-recipe": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/prismarine-recipe/-/prismarine-recipe-1.5.0.tgz",
|
||||
"integrity": "sha512-GRZHbsyBIUgVNF10vFRv2YWZj86vokCT5EWX6iK6gfx6h4FapgZT29V2DNkjv5+hmdzBCLZvfx1/RYr8VPeoGQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"prismarine-registry": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prismarine-registry": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/prismarine-registry/-/prismarine-registry-1.12.0.tgz",
|
||||
"integrity": "sha512-OC5U6YrflY6OcAWRZEqe2HGZuNp0bIuP7H+oKEHD6rLfKNDxo8Ymx5eh2VvrZWnMVugpwID1Qj/UjA4MoCzNDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minecraft-data": "^3.70.0",
|
||||
"prismarine-block": "^1.17.1",
|
||||
"prismarine-nbt": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prismarine-windows": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/prismarine-windows/-/prismarine-windows-2.10.0.tgz",
|
||||
"integrity": "sha512-ssXLGAr7W9JLvvLjYMoo1j4j6AdJaoIb0/HlqkWMWlQqvZJeiS4zyBjJY6+GtR4OzpjkEf6IvF5cNXhHFpbcZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prismarine-item": "^1.12.2",
|
||||
"prismarine-registry": "^1.7.0",
|
||||
"typed-emitter": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prismarine-windows/node_modules/typed-emitter": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
|
||||
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"rxjs": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/prismarine-world": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/prismarine-world/-/prismarine-world-3.7.0.tgz",
|
||||
"integrity": "sha512-M5euvNjQ3vIk689BSa0YC6PBwpVY35Oc6q6KyZ0IqyFtI+cQ9em+8l5OTAK/uu9/gzDDhR7cmm9L2WXgTXBQCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vec3": "^0.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process": {
|
||||
"version": "0.11.10",
|
||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/protodef": {
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmjs.org/protodef/-/protodef-1.19.0.tgz",
|
||||
"integrity": "sha512-94f3GR7pk4Qi5YVLaLvWBfTGUIzzO8hyo7vFVICQuu5f5nwKtgGDaeC1uXIu49s5to/49QQhEYeL0aigu1jEGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash.reduce": "^4.6.0",
|
||||
"protodef-validator": "^1.3.0",
|
||||
"readable-stream": "^4.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/protodef-validator": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/protodef-validator/-/protodef-validator-1.4.0.tgz",
|
||||
"integrity": "sha512-2y2coBolqCEuk5Kc3QwO7ThR+/7TZiOit4FrpAgl+vFMvq8w76nDhh09z08e2NQOdrgPLsN2yzXsvRvtADgUZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^6.5.4"
|
||||
},
|
||||
"bin": {
|
||||
"protodef-validator": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/railroad-diagrams": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz",
|
||||
"integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/randexp": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz",
|
||||
"integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"discontinuous-range": "1.0.0",
|
||||
"ret": "~0.1.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"events": "^3.3.0",
|
||||
"process": "^0.11.10",
|
||||
"string_decoder": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ret": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
|
||||
"integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/smart-buffer": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/typed-emitter": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-1.4.0.tgz",
|
||||
"integrity": "sha512-weBmoo3HhpKGgLBOYwe8EB31CzDFuaK7CCL+axXhUYhn4jo6DSkHnbefboCF5i4DQ2aMFe0C/FdTWcPdObgHyg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uint4": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/uint4/-/uint4-0.1.2.tgz",
|
||||
"integrity": "sha512-lhEx78gdTwFWG+mt6cWAZD/R6qrIj0TTBeH5xwyuDJyswLNlGe+KVlUPQ6+mx5Ld332pS0AMUTo9hIly7YsWxQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uri-js": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid-1345": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid-1345/-/uuid-1345-1.0.2.tgz",
|
||||
"integrity": "sha512-bA5zYZui+3nwAc0s3VdGQGBfbVsJLVX7Np7ch2aqcEWFi5lsAEcmO3+lx3djM1npgpZI8KY2FITZ2uYTnYUYyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"macaddress": "^0.5.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vec3": {
|
||||
"version": "0.1.10",
|
||||
"resolved": "https://registry.npmjs.org/vec3/-/vec3-0.1.10.tgz",
|
||||
"integrity": "sha512-Sr1U3mYtMqCOonGd3LAN9iqy0qF6C+Gjil92awyK/i2OwiUo9bm7PnLgFpafymun50mOjnDcg4ToTgRssrlTcw==",
|
||||
"license": "BSD"
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xxhash-wasm": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-0.4.2.tgz",
|
||||
"integrity": "sha512-/eyHVRJQCirEkSZ1agRSCwriMhwlyUcFkXD5TPVSLP+IPzjsqMVzZwdoczLp1SoQU0R3dxz1RpIK+4YNQbCVOA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yggdrasil": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/yggdrasil/-/yggdrasil-1.8.0.tgz",
|
||||
"integrity": "sha512-r5bKOhkZ52DJ6q034uSkdsdZLoFVhOmfDOagRs6h/JX5W7+XIPOMb+peCbElhLEoIckwt43NCUoNQbydOzuPcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.6.1",
|
||||
"uuid": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yggdrasil/node_modules/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
||||
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
package.json
Normal file
22
package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "flayerproxy",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"sniffer": "node src/sniffer/index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"install:fernflower": "bash scripts/install-fernflower.sh"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"minecraft-data": "^3.110.2",
|
||||
"minecraft-protocol": "^1.66.2",
|
||||
"mineflayer": "^4.37.1",
|
||||
"prismarine-chunk": "^1.40.0",
|
||||
"prismarine-world": "^3.7.0"
|
||||
}
|
||||
}
|
||||
264
protocol.md
Normal file
264
protocol.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# Minecraft Server: Login Flow & World Synchronization
|
||||
|
||||
This document outlines the detailed protocol sequences, state transitions, and network synchronization mechanisms used in the Minecraft server, based on the codebase in `serversSrc`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Connection Handshake & Protocol Transition
|
||||
|
||||
When a client initiates a connection to a Minecraft server, it starts in the **Handshake** protocol. This is handled by [ServerHandshakePacketListenerImpl](file:///home/seb/flayerproxy/serversSrc/net/minecraft/server/network/ServerHandshakePacketListenerImpl.java).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant Client
|
||||
participant Server (Handshake)
|
||||
participant Server (Login)
|
||||
participant Mojang Session Service
|
||||
participant Server (Config)
|
||||
|
||||
Client->>Server (Handshake): ClientIntentionPacket (Intention=LOGIN, protocolVersion)
|
||||
Note over Server (Handshake): Validates protocol version
|
||||
Server (Handshake)->>Server (Login): Instantiate ServerLoginPacketListenerImpl
|
||||
Client->>Server (Login): ServerboundHelloPacket (name)
|
||||
alt Offline Mode
|
||||
Server (Login)->>Server (Login): startClientVerification (Offline UUID)
|
||||
else Online Mode
|
||||
Server (Login)-->>Client: ClientboundHelloPacket (ServerID, public key, challenge)
|
||||
Client->>Server (Login): ServerboundKeyPacket (encrypted shared secret, encrypted challenge)
|
||||
Note over Server (Login): Decrypts secret & sets up AES encryption
|
||||
Server (Login)->>Mojang Session Service: hasJoinedServer(username, digest, IP)
|
||||
Mojang Session Service-->>Server (Login): GameProfile (UUID, textures)
|
||||
end
|
||||
Note over Server (Login): verifyLoginAndFinishConnectionSetup
|
||||
Server (Login)-->>Client: ClientboundLoginCompressionPacket (optional)
|
||||
Server (Login)-->>Client: ClientboundLoginFinishedPacket
|
||||
Client->>Server (Login): ServerboundLoginAcknowledgedPacket
|
||||
Server (Login)->>Server (Config): Switch protocol, instantiate ServerConfigurationPacketListenerImpl
|
||||
```
|
||||
|
||||
### Protocol Steps:
|
||||
1. **Client Intention**: The client sends a `ClientIntentionPacket` indicating its target state:
|
||||
- `STATUS`: The client is pinging the server for info (MOTD, online players).
|
||||
- `LOGIN`/`TRANSFER`: The client wants to connect to the game server.
|
||||
2. **Version Verification**: The server verifies that the client's protocol version matches the server's current version:
|
||||
- If mismatched, the server sends a `ClientboundLoginDisconnectPacket` and closes the socket.
|
||||
- If matching, the server transitions the connection to the `Login` protocol state and spawns a [ServerLoginPacketListenerImpl](file:///home/seb/flayerproxy/serversSrc/net/minecraft/server/network/ServerLoginPacketListenerImpl.java).
|
||||
|
||||
---
|
||||
|
||||
## 2. Login Protocol Phase
|
||||
|
||||
The login protocol handles authentication, encryption setup, and duplicate connection handling.
|
||||
|
||||
### Step-by-Step Flow:
|
||||
1. **Hello**: The client sends its username inside a `ServerboundHelloPacket`.
|
||||
2. **Authentication Determination**:
|
||||
- **Offline Mode**: The server bypasses encryption/auth, creates an offline UUID profile, and starts verification.
|
||||
- **Online Mode**: The server transitions to the `KEY` state and sends a `ClientboundHelloPacket` containing a random challenge token, the server's public key, and server ID.
|
||||
3. **Encryption Setup**:
|
||||
- The client generates a shared secret symmetric key (AES), encrypts it and the challenge token using the server's RSA public key, and sends it back in a `ServerboundKeyPacket`.
|
||||
- The server decrypts the shared secret and challenge token using its private key. It verifies the challenge matches.
|
||||
- Symmetric AES encryption is initialized on the network socket (`connection.setEncryptionKey(...)`).
|
||||
4. **Session Verification**:
|
||||
- The server computes a SHA-1 hash (server ID + shared secret + server public key) and sends a request to Mojang's session servers to verify if the client has successfully authenticated their session (`hasJoinedServer`).
|
||||
- If verified, the server receives the client's official `GameProfile` (UUID, username, skin textures, etc.).
|
||||
5. **Verifying and Compression**:
|
||||
- The server verifies if the player is allowed to connect (checks `UserBanList`, `IpBanList`, `UserWhiteList`, and server full limitations via `PlayerList.canPlayerLogin`).
|
||||
- If a compression threshold is configured in `server.properties`, the server sends a `ClientboundLoginCompressionPacket` and turns on network compression.
|
||||
- **Duplicate Connection Check**: The server disconnects any existing players with the same UUID.
|
||||
6. **Finished Protocol Transition**:
|
||||
- The server sends a `ClientboundLoginFinishedPacket` to notify the client that the login phase is complete.
|
||||
- The client acknowledges this by responding with `ServerboundLoginAcknowledgedPacket`.
|
||||
- The server switches the connection to the **Configuration** protocol and creates a [ServerConfigurationPacketListenerImpl](file:///home/seb/flayerproxy/serversSrc/net/minecraft/server/network/ServerConfigurationPacketListenerImpl.java).
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration Phase
|
||||
|
||||
The server configuration phase is a task-based queue that sets up registry entries, client/server resource settings, and initial spawn calculations before the player actually joins the world.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant Client
|
||||
participant Server (Config)
|
||||
|
||||
Note over Server (Config): startConfiguration()
|
||||
Server (Config)-->>Client: Brand / Server Links / Update Features Packets
|
||||
Server (Config)-->>Client: Registry/Pack select request (SynchronizeRegistriesTask)
|
||||
Client->>Server (Config): ServerboundSelectKnownPacks
|
||||
Note over Server (Config): PrepareSpawnTask (Loads player data & chunks)
|
||||
Note over Server (Config): JoinWorldTask (Sends finish config packet)
|
||||
Server (Config)-->>Client: ClientboundFinishConfigurationPacket
|
||||
Client->>Server (Config): ServerboundFinishConfigurationPacket
|
||||
Note over Server (Config): Transition connection to Play state
|
||||
```
|
||||
|
||||
### Configuration Task Queue:
|
||||
- **Server Identity**: The server sends initial information like brand name (`ClientboundCustomPayloadPacket` with `BrandPayload`) and links (`ClientboundServerLinksPacket`).
|
||||
- **SynchronizeRegistriesTask**: Sends the server's known resource packs and waits for the client to acknowledge with `ServerboundSelectKnownPacks`. The server then replies with `ClientboundRegistryDataPacket` and `ClientboundUpdateTagsPacket`.
|
||||
- **Optional Tasks**:
|
||||
- `ServerCodeOfConductConfigurationTask`: Prompts client to accept terms.
|
||||
- `ServerResourcePackConfigurationTask`: Sends resource pack download prompts.
|
||||
- **PrepareSpawnTask**:
|
||||
1. Loads player data (position, rotation, dimension).
|
||||
2. Asynchronously requests spawn chunk loading (radius of 3 chunks around player spawn position).
|
||||
3. Holds configuration tick execution until the client's immediate spawn area is fully loaded and ready.
|
||||
- **JoinWorldTask**: Sends `ClientboundFinishConfigurationPacket` to trigger play state transition.
|
||||
- **Transition**:
|
||||
- The client responds with `ServerboundFinishConfigurationPacket`.
|
||||
- The server updates the network handler to the **Play** protocol template (`GameProtocols.CLIENTBOUND_TEMPLATE.bind(...)`).
|
||||
- The server spawns the player into the level and hands control to [ServerGamePacketListenerImpl](file:///home/seb/flayerproxy/serversSrc/net/minecraft/server/network/ServerGamePacketListenerImpl.java).
|
||||
|
||||
---
|
||||
|
||||
## 4. Play State Transition (Initial World Sync)
|
||||
|
||||
When the connection transitions to the **Play** state, [PlayerList.placeNewPlayer()](file:///home/seb/flayerproxy/serversSrc/net/minecraft/server/players/PlayerList.java#L141-L194) sends a dense stream of packets to synchronize the player's HUD, environment, inventory, and initial chunks.
|
||||
|
||||
```
|
||||
[ Client ] [ Server (Play) ]
|
||||
| |
|
||||
| <----------- ClientboundLoginPacket -------------| (EID, difficulty, dimensions...)
|
||||
| <------- ClientboundChangeDifficultyPacket ------| (Current difficulty state)
|
||||
| <------ ClientboundPlayerAbilitiesPacket -------| (Flying/creative capabilities)
|
||||
| <-------- ClientboundSetHeldSlotPacket ----------| (Active hotbar slot index)
|
||||
| <------- ClientboundUpdateRecipesPacket ---------| (Synchronize recipes)
|
||||
| <--------- Send Commands Tree Packet ------------| (Command syntax helper)
|
||||
| <------ ClientboundPlayerInfoUpdatePacket -------| (Initialize online tab list)
|
||||
| <--------- Teleport Packet to Spawn -------------| (Pos/Rot snap location)
|
||||
| |
|
||||
| (Send World Info) |
|
||||
| <----- ClientboundInitializeBorderPacket --------| (World border settings)
|
||||
| <------------- Synchronize Clock Packet ---------| (World time / day-night tick)
|
||||
| <---- ClientboundSetDefaultSpawnPositionPacket --| (World spawn coordinate)
|
||||
| <--------- ClientboundGameEventPacket -----------| (Weather updates: Rain/Thunder)
|
||||
| <-------- LEVEL_CHUNKS_LOAD_START Event ---------| (Trigger client chunk loading)
|
||||
| |
|
||||
| (Active Entities & Chunks) |
|
||||
| <------- ClientboundUpdateMobEffectPacket -------| (Apply ongoing status effects)
|
||||
| <--------- Initialize Inventory Packet ----------| (Fill inventory UI slots)
|
||||
```
|
||||
|
||||
1. **ClientboundLoginPacket**: Sets up core game parameters (Entity ID, hardcore mode, view distance, simulation distance).
|
||||
2. **ClientboundChangeDifficultyPacket & ClientboundPlayerAbilitiesPacket**: Syncs world difficulty and character flight/speed settings.
|
||||
3. **ClientboundSetHeldSlotPacket**: Syncs the player's currently selected hotbar slot.
|
||||
4. **ClientboundUpdateRecipesPacket**: Syncs stonecutter and generic crafting recipes.
|
||||
5. **Commands & Permissions**: Sends operator status and command syntax mappings.
|
||||
6. **Scoreboard & Teams**: Sends objective lists and color styling data (`updateEntireScoreboard`).
|
||||
7. **Player List Info**: Sends `ClientboundPlayerInfoUpdatePacket` to add the joining player and all current players to the tab list.
|
||||
8. **Position Teleport**: Teleports the player's local camera to the spawn position.
|
||||
9. **Environment/Weather**: Syncs the world border, time of day, default spawn points, and weather conditions (e.g., `START_RAINING`, `RAIN_LEVEL_CHANGE`).
|
||||
10. **Start Level Sync**: Sends a game event of type `LEVEL_CHUNKS_LOAD_START` to indicate to the client that it should prepare to receive chunk data.
|
||||
|
||||
---
|
||||
|
||||
## 5. Live World Synchronization Mechanisms
|
||||
|
||||
Once a player is in the world, the server continuously updates the player's client via three loops: **Chunk Sync**, **Entity Sync**, and **World State Sync**.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant Client
|
||||
participant Server (Play)
|
||||
|
||||
Note over Server (Play): PlayerList.placeNewPlayer()
|
||||
Server (Play)-->>Client: ClientboundLoginPacket (Entity ID, Dimension keys, View distance...)
|
||||
Server (Play)-->>Client: ChangeDifficulty / PlayerAbilities / SetHeldSlot / UpdateRecipes Packets
|
||||
Server (Play)-->>Client: Teleport (initial position)
|
||||
Server (Play)-->>Client: InitializeBorder / Sync Time / DefaultSpawnPosition Packets
|
||||
Server (Play)-->>Client: LEVEL_CHUNKS_LOAD_START Game Event
|
||||
|
||||
rect rgb(200, 220, 240)
|
||||
Note over Server (Play), Client: Chunk Synchronization Loop
|
||||
Server (Play)-->>Client: ClientboundChunkBatchStartPacket
|
||||
Server (Play)-->>Client: ClientboundLevelChunkWithLightPacket (multiple chunks)
|
||||
Server (Play)-->>Client: ClientboundChunkBatchFinishedPacket
|
||||
end
|
||||
|
||||
rect rgb(220, 240, 200)
|
||||
Note over Server (Play), Client: Entity Tracking Loop
|
||||
Server (Play)-->>Client: ClientboundBundlePacket (Spawn Entity, Metadata, Attributes, Equipment)
|
||||
Server (Play)-->>Client: ClientboundMoveEntityPacket / EntityPositionSyncPacket / RotateHeadPacket
|
||||
Server (Play)-->>Client: ClientboundRemoveEntitiesPacket (when entity out of range)
|
||||
end
|
||||
|
||||
rect rgb(240, 200, 220)
|
||||
Note over Server (Play), Client: Dynamic World State Loop
|
||||
Server (Play)-->>Client: ClientboundBlockUpdatePacket (single block)
|
||||
Server (Play)-->>Client: ClientboundSectionBlocksUpdatePacket (multiple blocks in section)
|
||||
Server (Play)-->>Client: ClientboundLightUpdatePacket (lighting recalculations)
|
||||
end
|
||||
```
|
||||
|
||||
### A. Chunk Synchronization
|
||||
Minecraft manages which chunks are loaded on the client through the player's view distance and [ChunkTrackingView](file:///home/seb/flayerproxy/serversSrc/net/minecraft/server/level/ChunkMap.java#L1031-L1065).
|
||||
- **Movement Tracking**: As a player walks, the difference between their previous `ChunkTrackingView` and current `ChunkTrackingView` is computed:
|
||||
- **New Chunks**: Scheduled for sending via `markChunkPendingToSend(player, chunk)`.
|
||||
- **Old Chunks**: Cleared from the client via `ClientboundForgetLevelChunkPacket`.
|
||||
- **Chunk Batching**: To prevent network congestion, the server uses [PlayerChunkSender](file:///home/seb/flayerproxy/serversSrc/net/minecraft/server/network/PlayerChunkSender.java) to batch chunks:
|
||||
1. Sends a `ClientboundChunkBatchStartPacket`.
|
||||
2. Sends individual chunk data using `ClientboundLevelChunkWithLightPacket` (containing blocks, state mappings, tile entities, and light values).
|
||||
3. Sends `ClientboundChunkBatchFinishedPacket` confirming the batch size.
|
||||
4. Waits for the client to acknowledge before sending the next batch (the batch rate dynamically throttles based on the client's processing feedback).
|
||||
|
||||
---
|
||||
|
||||
### B. Entity Tracking & Synchronization
|
||||
The server tracks close entities (players, items, projectiles, mobs) on a per-player basis using [ChunkMap.TrackedEntity](file:///home/seb/flayerproxy/serversSrc/net/minecraft/server/level/ChunkMap.java#L1287-L1405).
|
||||
|
||||
#### 1. Spawn Sync (Adding a Pairing)
|
||||
When an entity enters a player's tracking range:
|
||||
1. The server calls `addPairing(player)`, collecting all initialization packets.
|
||||
2. It packs them inside a `ClientboundBundlePacket` to guarantee atomic client rendering:
|
||||
- `getAddEntityPacket(...)`: Spawns the visual representation of the entity.
|
||||
- `ClientboundSetEntityDataPacket`: Syncs metadata values (e.g., if a creeper is ignited, if a wolf is sitting).
|
||||
- `ClientboundUpdateAttributesPacket`: Syncs movement speed, health limits, etc.
|
||||
- `ClientboundSetEquipmentPacket`: Syncs armor, shield, and hand items.
|
||||
- `ClientboundSetPassengersPacket`: Syncs riding links.
|
||||
|
||||
#### 2. Position & State Sync (Incremental Updates)
|
||||
Every tick, the server runs `sendChanges()` inside [ServerEntity](file:///home/seb/flayerproxy/serversSrc/net/minecraft/server/level/ServerEntity.java#L89-L227):
|
||||
- **Relative Movement**: If the movement delta since the last packet is small, the server encodes it using a `VecDeltaCodec` (fitting into a `short` representation) and sends:
|
||||
- `ClientboundMoveEntityPacket.Pos` (position only)
|
||||
- `ClientboundMoveEntityPacket.Rot` (rotation only)
|
||||
- `ClientboundMoveEntityPacket.PosRot` (both position and rotation)
|
||||
- **Hard Synced Teleportation**: If the displacement exceeds the short delta limit, the riding/grounded state changes, or 400 ticks (`FORCED_TELEPORT_PERIOD`) have passed, the server sends a `ClientboundEntityPositionSyncPacket` to force-snap the position.
|
||||
- **Head Rotation**: Head yaw changes are tracked separately and sent via `ClientboundRotateHeadPacket`.
|
||||
- **Velocity**: Real-time motion forces (like knockback) are sent using `ClientboundSetEntityMotionPacket`.
|
||||
|
||||
#### 3. Despawn Sync (Removing a Pairing)
|
||||
When an entity is destroyed or moves out of the player's tracking range, `removePairing(player)` is executed, sending a `ClientboundRemoveEntitiesPacket` to free memory on the client.
|
||||
|
||||
---
|
||||
|
||||
### C. Dynamic World State Updates
|
||||
Real-time edits to the environment (player building, chest placements, water flows) are pushed block-by-block using [ChunkHolder](file:///home/seb/flayerproxy/serversSrc/net/minecraft/server/level/ChunkHolder.java#L116-L232):
|
||||
- **Block Change Recording**: When a block updates, `blockChanged(pos)` records the position relative to its 16x16x16 section.
|
||||
- **Broadcasting Updates**:
|
||||
- **Single Block Change**: The server sends a `ClientboundBlockUpdatePacket` containing the coordinate and the new block state.
|
||||
- **Multiple Block Changes**: If multiple blocks update in the same section within a single tick, they are consolidated and sent as a `ClientboundSectionBlocksUpdatePacket` to save bandwidth.
|
||||
- **Tile Entities**: If the updated block holds a block entity (like a sign, container, or banner), the server fetches and broadcasts its NBT tag via `ClientboundBlockEntityDataPacket`.
|
||||
- **Light Recalculation**: If block updates affect ambient brightness, a `ClientboundLightUpdatePacket` is broadcasted.
|
||||
|
||||
---
|
||||
|
||||
## Packet sniffer proxy (development)
|
||||
|
||||
MITM proxy: the Java client connects to a local `minecraft-protocol` server; the sniffer opens a second authenticated client to `config.server` and relays decrypted packets both ways while logging to JSONL.
|
||||
|
||||
```bash
|
||||
npm run sniffer
|
||||
```
|
||||
|
||||
- Listens on `config.sniffer.port` (default **25567**); upstream target is `config.server`.
|
||||
- Connect the Java client to the sniffer (not 25566). One client at a time.
|
||||
- Logs: `logs/sniffer/session-<timestamp>.jsonl` with `"type":"packet"` entries (`dir`, `state`, `name`, payload or summary).
|
||||
- `sniffer.upstreamAuth`: `"microsoft"` (default) or `"offline"` for the upstream leg.
|
||||
- `sniffer.onlineMode`: `false` (default) lets the Java client join the sniffer without Mojang checking the sniffer itself; upstream still uses `upstreamAuth`.
|
||||
- Server list **ping** (`nextState: 1`) uses a raw TCP pass-through; **Join** (`nextState: 2`) runs the MITM path (login, `registry_data`, `map_chunk`, …).
|
||||
- `registry_data` / chunk packets are relayed with `writeRaw` where needed so NBT stays byte-identical.
|
||||
|
||||
For the main FlayerProxy handoff path (25566), captured upstream config is still replayed with `writeRaw` so registry NBT stays byte-identical.
|
||||
34
src/config.js
Normal file
34
src/config.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const CONFIG_PATH = path.join(__dirname, '..', 'config.json');
|
||||
|
||||
function loadConfig() {
|
||||
if (!fs.existsSync(CONFIG_PATH)) {
|
||||
throw new Error(`Config file not found: ${CONFIG_PATH}`);
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
|
||||
// Validate required fields
|
||||
if (!config.server || !config.server.host || !config.server.port) {
|
||||
throw new Error('config.json must specify server.host and server.port');
|
||||
}
|
||||
if (!config.server.version) {
|
||||
throw new Error('config.json must specify server.version');
|
||||
}
|
||||
if (!config.auth || !config.auth.username) {
|
||||
throw new Error('config.json must specify auth.username');
|
||||
}
|
||||
|
||||
// Apply defaults
|
||||
config.proxy = Object.assign({ host: '0.0.0.0', port: 25566, onlineMode: false, maxClients: 1 }, config.proxy);
|
||||
config.bot = Object.assign({ antiAfk: true, antiAfkInterval: 30000, viewDistance: 10 }, config.bot);
|
||||
config.cache = Object.assign({ maxChunks: 1024, trackEntities: true }, config.cache);
|
||||
config.auth.auth = config.auth.auth || 'offline';
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
module.exports = { loadConfig };
|
||||
11
src/constants/rawPackets.js
Normal file
11
src/constants/rawPackets.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** Play packets that must be forwarded with writeRaw to survive NBT/chunk re-serialization */
|
||||
const RAW_FORWARD_PACKETS = new Set([
|
||||
'map_chunk',
|
||||
'update_light',
|
||||
'unload_chunk',
|
||||
'chunk_batch_start',
|
||||
'chunk_batch_finished',
|
||||
'update_view_position',
|
||||
]);
|
||||
|
||||
module.exports = { RAW_FORWARD_PACKETS };
|
||||
49
src/index.js
Normal file
49
src/index.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const { loadConfig } = require('./config');
|
||||
const { SessionManager } = require('./session/SessionManager');
|
||||
const { createLogger } = require('./utils/logger');
|
||||
|
||||
const log = createLogger('Main');
|
||||
|
||||
// ─── Banner ──────────────────────────────────────────
|
||||
console.log(`
|
||||
\x1b[33m _____ _ ____
|
||||
| ___| | __ _ _ _ ___ _ _| _ \\ _ __ _____ ___ _
|
||||
| |_ | |/ _\` | | | |/ _ \\ '__| |_) | '__/ _ \\ \\/ / | | |
|
||||
| _| | | (_| | |_| | __/ | | __/| | | (_) > <| |_| |
|
||||
|_| |_|\\__,_|\\__, |\\___|_| |_| |_| \\___/_/\\_\\\\__, |
|
||||
|___/ |___/ \x1b[0m
|
||||
`);
|
||||
|
||||
// ─── Load config ─────────────────────────────────────
|
||||
let config;
|
||||
try {
|
||||
config = loadConfig();
|
||||
log.info(`Loaded config: server=${config.server.host}:${config.server.port} version=${config.server.version}`);
|
||||
log.info(`Proxy will listen on port ${config.proxy.port}`);
|
||||
} catch (err) {
|
||||
log.error(`Failed to load config: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ─── Start session manager ──────────────────────────
|
||||
const session = new SessionManager(config);
|
||||
session.start();
|
||||
|
||||
// ─── Graceful shutdown ──────────────────────────────
|
||||
function shutdown(signal) {
|
||||
log.info(`Received ${signal}, shutting down...`);
|
||||
session.stop();
|
||||
setTimeout(() => process.exit(0), 2000);
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
log.error(`Uncaught exception: ${err.message}`);
|
||||
log.error(err.stack);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
log.error(`Unhandled rejection: ${reason}`);
|
||||
});
|
||||
292
src/proxy/ClientBridge.js
Normal file
292
src/proxy/ClientBridge.js
Normal file
@@ -0,0 +1,292 @@
|
||||
const { createLogger } = require('../utils/logger');
|
||||
const { RAW_FORWARD_PACKETS } = require('../constants/rawPackets');
|
||||
const {
|
||||
CHAT_SESSION_PACKETS,
|
||||
disableInboundChatValidation,
|
||||
relayClientChatAsUpstream,
|
||||
} = require('../utils/chatRelay');
|
||||
const {
|
||||
chunkCoordsFromBlock,
|
||||
updateClientViewPosition,
|
||||
ensureClientViewIncludesChunk,
|
||||
} = require('../utils/positionSync');
|
||||
|
||||
const log = createLogger('ClientBridge');
|
||||
|
||||
/**
|
||||
* Manages bidirectional packet forwarding between a connected Java client
|
||||
* and the upstream server connection.
|
||||
*
|
||||
* In ClientMode: client→server and server→client packets are piped through.
|
||||
* The WorldStateCache continues to be updated from server packets.
|
||||
*/
|
||||
class ClientBridge {
|
||||
/**
|
||||
* @param {object} client - The minecraft-protocol client from the proxy server
|
||||
* @param {import('../session/ServerConnection').ServerConnection} serverConn
|
||||
* @param {import('../state/WorldStateCache').WorldStateCache} worldState
|
||||
*/
|
||||
constructor(client, serverConn, worldState) {
|
||||
this.client = client;
|
||||
this.serverConn = serverConn;
|
||||
this.worldState = worldState;
|
||||
this.active = false;
|
||||
|
||||
this._clientPacketHandler = null;
|
||||
this._serverPacketHandler = null;
|
||||
this._clientEndHandler = null;
|
||||
|
||||
/** UUIDs the client has seen via player_info add_player */
|
||||
this.knownPlayerUuids = new Set(worldState.misc.getKnownPlayerUuids());
|
||||
|
||||
// Packets the mineflayer bot already handles on the upstream connection.
|
||||
// Forwarding them again from the proxy client causes duplicate responses and kicks.
|
||||
this._blockedClientPackets = new Set([
|
||||
'keep_alive',
|
||||
'teleport_confirm',
|
||||
'message_acknowledgement',
|
||||
]);
|
||||
|
||||
/** Must reach the server for chunk streaming (PlayerChunkSender / hasClientLoaded) */
|
||||
this._priorityClientPackets = new Set([
|
||||
'chunk_batch_received',
|
||||
'player_loaded',
|
||||
]);
|
||||
|
||||
/** Block movement until client matches server (set true after syncProxyClientPosition) */
|
||||
this._movementSynced = false;
|
||||
this._movementPackets = new Set([
|
||||
'position',
|
||||
'position_look',
|
||||
'look',
|
||||
'flying',
|
||||
'vehicle_move',
|
||||
'steer_vehicle',
|
||||
'paddle_boat',
|
||||
]);
|
||||
|
||||
// Packets from server that should NOT be forwarded to client
|
||||
// (these are internal to the bot)
|
||||
this._blockedServerPackets = new Set([]);
|
||||
|
||||
/** Proxy client view center — map_chunk outside this range is ignored by vanilla */
|
||||
this._clientView = { chunkX: null, chunkZ: null };
|
||||
/** Last block coords from client movement (for view center ahead of server) */
|
||||
this._lastClientBlock = { x: null, z: null };
|
||||
}
|
||||
|
||||
_getViewDistance() {
|
||||
return (
|
||||
this.worldState.misc.viewDistance?.viewDistance ??
|
||||
this.serverConn.config?.bot?.viewDistance ??
|
||||
10
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ClientboundSetChunkCacheCenterPacket — vanilla ignores map_chunk outside this center.
|
||||
* Use the moving player's block coords (client packet), not a lagging bot read.
|
||||
*/
|
||||
_syncClientViewFromBlockCoords(blockX, blockZ) {
|
||||
if (blockX == null || blockZ == null) return;
|
||||
const { chunkX, chunkZ } = chunkCoordsFromBlock(blockX, blockZ);
|
||||
updateClientViewPosition(this.client, chunkX, chunkZ, this._clientView);
|
||||
this._lastClientBlock.x = blockX;
|
||||
this._lastClientBlock.z = blockZ;
|
||||
}
|
||||
|
||||
_syncClientViewFromBot() {
|
||||
const pos = this.serverConn.bot?.entity?.position;
|
||||
if (!pos) return;
|
||||
this._syncClientViewFromBlockCoords(pos.x, pos.z);
|
||||
}
|
||||
|
||||
/** Block coords to anchor view center (client ahead of bot, else bot). */
|
||||
_playerBlockCoordsForView() {
|
||||
if (this._lastClientBlock.x != null) {
|
||||
return { x: this._lastClientBlock.x, z: this._lastClientBlock.z };
|
||||
}
|
||||
const pos = this.serverConn.bot?.entity?.position;
|
||||
if (!pos) return null;
|
||||
return { x: pos.x, z: pos.z };
|
||||
}
|
||||
|
||||
/**
|
||||
* If a map_chunk would be outside the client's cache radius, send center first.
|
||||
* Matches server ChunkMap sending ClientboundSetChunkCacheCenterPacket before batches.
|
||||
*/
|
||||
_ensureViewIncludesChunk(chunkX, chunkZ) {
|
||||
const player = this._playerBlockCoordsForView();
|
||||
if (!player) return;
|
||||
ensureClientViewIncludesChunk(
|
||||
this.client,
|
||||
player.x,
|
||||
player.z,
|
||||
chunkX,
|
||||
chunkZ,
|
||||
this._getViewDistance(),
|
||||
this._clientView
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow client movement packets to reach the server.
|
||||
*/
|
||||
enableMovement() {
|
||||
this._movementSynced = true;
|
||||
this._syncClientViewFromBot();
|
||||
log.info('Client movement forwarding enabled');
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.active) return;
|
||||
this.active = true;
|
||||
|
||||
this.serverConn.setClientDrivesChunkBatchAck(true);
|
||||
this.serverConn.flushChunkBatchAck();
|
||||
|
||||
log.info('Client bridge started — forwarding packets');
|
||||
disableInboundChatValidation(this.client);
|
||||
|
||||
// Client → Server
|
||||
this._clientPacketHandler = (data, meta) => {
|
||||
if (!this.active) return;
|
||||
if (meta.state !== 'play') return;
|
||||
if (this._blockedClientPackets.has(meta.name)) return;
|
||||
if (!this._movementSynced && this._movementPackets.has(meta.name)) return;
|
||||
|
||||
try {
|
||||
if (this._movementPackets.has(meta.name)) {
|
||||
// Update view center from client coords before relay — server sends center on
|
||||
// chunk boundary (ChunkMap.applyChunkTrackingView) but map_chunk may arrive first.
|
||||
if (
|
||||
(meta.name === 'position' || meta.name === 'position_look' || meta.name === 'vehicle_move') &&
|
||||
data.x != null &&
|
||||
data.z != null
|
||||
) {
|
||||
this._syncClientViewFromBlockCoords(data.x, data.z);
|
||||
}
|
||||
|
||||
const ok = this.serverConn.relayClientMovement(meta.name, data);
|
||||
if (!ok) {
|
||||
this.serverConn.confirmServerPosition();
|
||||
this.serverConn.syncProxyClientPosition(this.client).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (CHAT_SESSION_PACKETS.has(meta.name)) {
|
||||
if (meta.name === 'message_acknowledgement') {
|
||||
return;
|
||||
}
|
||||
relayClientChatAsUpstream(this.serverConn, meta.name, data, log);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._priorityClientPackets.has(meta.name)) {
|
||||
this.serverConn.writeToServer(meta.name, data);
|
||||
return;
|
||||
}
|
||||
|
||||
this.serverConn.writeToServer(meta.name, data);
|
||||
} catch (err) {
|
||||
log.error(`Error forwarding client→server packet '${meta.name}':`, err.message);
|
||||
}
|
||||
};
|
||||
// Run before minecraft-protocol server chat validation (registered at login).
|
||||
this.client.prependListener('packet', this._clientPacketHandler);
|
||||
|
||||
// Server → Client
|
||||
this._serverPacketHandler = (name, data, buffer) => {
|
||||
if (!this.active) return;
|
||||
if (this._blockedServerPackets.has(name)) return;
|
||||
if (name === 'player_info' && !this._shouldForwardPlayerInfo(data)) return;
|
||||
|
||||
if (name === 'position') {
|
||||
this._movementSynced = true;
|
||||
if (data.x != null && data.z != null) {
|
||||
this._syncClientViewFromBlockCoords(data.x, data.z);
|
||||
}
|
||||
}
|
||||
|
||||
if (name === 'update_view_position') {
|
||||
this._clientView.chunkX = data.chunkX;
|
||||
this._clientView.chunkZ = data.chunkZ;
|
||||
}
|
||||
|
||||
if (name === 'map_chunk' && data.x != null && data.z != null) {
|
||||
this._ensureViewIncludesChunk(data.x, data.z);
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.client.state !== 'play') return;
|
||||
|
||||
// update_view_position must arrive before map_chunk in the same batch (ChunkMap.java)
|
||||
if (buffer && RAW_FORWARD_PACKETS.has(name)) {
|
||||
this.client.writeRaw(buffer);
|
||||
return;
|
||||
}
|
||||
this.client.write(name, data);
|
||||
} catch (err) {
|
||||
log.error(`Error forwarding server→client packet '${name}':`, err.message);
|
||||
}
|
||||
};
|
||||
this.serverConn.on('serverPacket', this._serverPacketHandler);
|
||||
|
||||
// Client disconnect
|
||||
this._clientEndHandler = () => {
|
||||
log.info('Client connection ended');
|
||||
this.stop();
|
||||
};
|
||||
this.client.on('end', this._clientEndHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward player_info adds; skip latency-only updates for unknown UUIDs.
|
||||
*/
|
||||
_shouldForwardPlayerInfo(data) {
|
||||
const action = data.action;
|
||||
const entries = data.data || [];
|
||||
|
||||
if (action && typeof action === 'object' && action.add_player) {
|
||||
for (const entry of entries) {
|
||||
if (entry.uuid) this.knownPlayerUuids.add(entry.uuid);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entries.length === 0) return true;
|
||||
|
||||
const allKnown = entries.every((e) => e.uuid && this.knownPlayerUuids.has(e.uuid));
|
||||
if (allKnown) return true;
|
||||
|
||||
const anyKnown = entries.some((e) => e.uuid && this.knownPlayerUuids.has(e.uuid));
|
||||
if (anyKnown) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop bridging and clean up listeners.
|
||||
*/
|
||||
stop() {
|
||||
if (!this.active) return;
|
||||
this.active = false;
|
||||
|
||||
this.serverConn.setClientDrivesChunkBatchAck(false);
|
||||
|
||||
if (this._clientPacketHandler) {
|
||||
this.client.removeListener('packet', this._clientPacketHandler);
|
||||
}
|
||||
if (this._serverPacketHandler) {
|
||||
this.serverConn.removeListener('serverPacket', this._serverPacketHandler);
|
||||
}
|
||||
if (this._clientEndHandler) {
|
||||
this.client.removeListener('end', this._clientEndHandler);
|
||||
}
|
||||
|
||||
log.info('Client bridge stopped');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ClientBridge };
|
||||
109
src/proxy/ProxyServer.js
Normal file
109
src/proxy/ProxyServer.js
Normal file
@@ -0,0 +1,109 @@
|
||||
const mc = require('minecraft-protocol');
|
||||
const { createLogger } = require('../utils/logger');
|
||||
const { wrapClientEnd, safeEndClient } = require('../utils/clientDisconnect');
|
||||
const { disableInboundChatValidation } = require('../utils/chatRelay');
|
||||
|
||||
const log = createLogger('ProxyServer');
|
||||
|
||||
class ProxyServer {
|
||||
constructor(config, onClientConnect, worldState) {
|
||||
this.config = config;
|
||||
this.onClientConnect = onClientConnect;
|
||||
this.worldState = worldState;
|
||||
this.server = null;
|
||||
this.activeClient = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.server = mc.createServer({
|
||||
host: this.config.proxy.host || '0.0.0.0',
|
||||
'online-mode': this.config.proxy.onlineMode,
|
||||
// Java client chat is re-signed for the bot upstream; do not validate client signatures here.
|
||||
enforceSecureProfile: false,
|
||||
port: this.config.proxy.port,
|
||||
version: this.config.server.version,
|
||||
maxPlayers: this.config.proxy.maxClients,
|
||||
motd: '§6FlayerProxy',
|
||||
hideErrors: true,
|
||||
errorHandler: (client, err) => {
|
||||
log.error(`Client error (${client.username || 'unknown'}):`, err.message);
|
||||
safeEndClient(client, err);
|
||||
},
|
||||
});
|
||||
|
||||
// Replay upstream server's raw config packets before minecraft-protocol's parsed registry.
|
||||
this.server.on('login', (client) => {
|
||||
client.prependOnceListener('login_acknowledged', () => {
|
||||
const packets = this.worldState.getRawConfigPacketsForReplay();
|
||||
if (packets.length === 0) return;
|
||||
|
||||
for (const { name, buffer } of packets) {
|
||||
try {
|
||||
client.writeRaw(buffer);
|
||||
} catch (err) {
|
||||
log.error(`Failed to write raw config packet '${name}':`, err.message);
|
||||
}
|
||||
}
|
||||
log.info(`Sent ${packets.length} raw config packets to ${client.username}`);
|
||||
});
|
||||
});
|
||||
|
||||
this.server.on('playerJoin', (client) => {
|
||||
wrapClientEnd(client);
|
||||
disableInboundChatValidation(client);
|
||||
log.info(`Client ready: ${client.username}`);
|
||||
|
||||
if (this.activeClient) {
|
||||
client.end('Another client is already connected.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeClient = client;
|
||||
|
||||
client.on('end', () => {
|
||||
log.info(`Client disconnected: ${client.username}`);
|
||||
if (this.activeClient === client) {
|
||||
this.activeClient = null;
|
||||
}
|
||||
});
|
||||
|
||||
this.onClientConnect(client);
|
||||
});
|
||||
|
||||
this.server.on('error', (err) => {
|
||||
log.error('Proxy server error:', err.message);
|
||||
});
|
||||
|
||||
this.server.on('listening', () => {
|
||||
log.info(`Proxy server listening on port ${this.config.proxy.port}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the registry codec sent to clients during configuration.
|
||||
* Must be called after the bot has received registry_data from the upstream server.
|
||||
* @param {object} codec
|
||||
*/
|
||||
updateRegistryCodec(codec) {
|
||||
if (!this.server?.options) return;
|
||||
this.server.options.registryCodec = codec;
|
||||
const count = codec.codec ? 1 : Object.keys(codec).length;
|
||||
if (count === 0) {
|
||||
log.info('Proxy registry disabled (using raw upstream config packets)');
|
||||
} else {
|
||||
log.info(`Proxy registry updated from server (${count} registries)`);
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.activeClient) {
|
||||
try { this.activeClient.end('Proxy shutting down'); } catch (e) {}
|
||||
this.activeClient = null;
|
||||
}
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ProxyServer };
|
||||
216
src/replay/StateReplayer.js
Normal file
216
src/replay/StateReplayer.js
Normal file
@@ -0,0 +1,216 @@
|
||||
const { createLogger } = require('../utils/logger');
|
||||
const { buildClientboundPositionPacket } = require('../utils/positionSync');
|
||||
const { LEVEL_CHUNKS_LOAD_START } = require('../utils/handoffSync');
|
||||
const {
|
||||
POST_REPLAY_SETTLE_MS,
|
||||
replayPacketData,
|
||||
getPlayerChunkCenter,
|
||||
splitMiscReplayPackets,
|
||||
waitForClientTeleportConfirm,
|
||||
} = require('./replayHelpers');
|
||||
const { replayChunks } = require('./replayChunks');
|
||||
|
||||
const log = createLogger('StateReplayer');
|
||||
|
||||
/**
|
||||
* Replays cached world state to a freshly connected client.
|
||||
* Sends packets in the correct order so the vanilla client initializes properly.
|
||||
*/
|
||||
class StateReplayer {
|
||||
/**
|
||||
* @param {import('../state/WorldStateCache').WorldStateCache} worldState
|
||||
* @param {import('../session/ServerConnection').ServerConnection} serverConn
|
||||
*/
|
||||
constructor(worldState, serverConn) {
|
||||
this.worldState = worldState;
|
||||
this.serverConn = serverConn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay all cached state to the given client connection.
|
||||
* The client should be in the 'play' state already.
|
||||
*
|
||||
* @param {object} client - minecraft-protocol client connection (from proxy server)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async replay(client) {
|
||||
const ws = this.worldState;
|
||||
const bot = this.serverConn?.bot;
|
||||
const playerState = ws.player.getState();
|
||||
|
||||
if (!playerState.loginPacket) {
|
||||
log.error('Cannot replay: no login packet cached');
|
||||
return;
|
||||
}
|
||||
|
||||
log.info('Starting state replay...');
|
||||
let packetCount = 0;
|
||||
|
||||
const write = (name, data) => {
|
||||
const payload = replayPacketData(client, name, data);
|
||||
if (payload !== data && data?.enforcesSecureChat) {
|
||||
log.info(`Replay ${name}: cleared enforcesSecureChat (proxy client has no profile keys)`);
|
||||
}
|
||||
try {
|
||||
client.write(name, payload);
|
||||
packetCount++;
|
||||
} catch (err) {
|
||||
log.error(`Failed to write packet '${name}':`, err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const writeRaw = (buffer, label) => {
|
||||
try {
|
||||
client.writeRaw(buffer);
|
||||
packetCount++;
|
||||
} catch (err) {
|
||||
log.error(`Failed to write raw packet '${label}':`, err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Login packet (join_game)
|
||||
write('login', { ...playerState.loginPacket });
|
||||
|
||||
// 2. Difficulty
|
||||
if (playerState.difficulty) {
|
||||
write('difficulty', playerState.difficulty);
|
||||
}
|
||||
|
||||
// 3. Abilities + permission level (entity_status 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 };
|
||||
69
src/replay/replayChunks.js
Normal file
69
src/replay/replayChunks.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const { createLogger } = require('../utils/logger');
|
||||
const { CHUNK_YIELD_EVERY, yieldEventLoop } = require('./replayHelpers');
|
||||
|
||||
const log = createLogger('StateReplayer');
|
||||
|
||||
/**
|
||||
* Replay cached chunks (with light, block changes) to a client.
|
||||
*
|
||||
* @param {function(string, object): void} write - named packet writer
|
||||
* @param {function(Buffer, string): void} writeRaw - raw buffer writer
|
||||
* @param {object[]} chunks - chunks from ChunkCache.getChunksForReplay
|
||||
* @param {{ chunkX: number, chunkZ: number }} center - player chunk center
|
||||
* @param {number} totalCached - total chunks in cache (for logging)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function replayChunks(write, writeRaw, chunks, center, totalCached) {
|
||||
if (totalCached > chunks.length) {
|
||||
log.info(
|
||||
`Filtered ${totalCached - chunks.length} cached chunks outside view distance of bot at (${center.chunkX}, ${center.chunkZ})`
|
||||
);
|
||||
}
|
||||
if (chunks.length === 0) {
|
||||
log.warn(
|
||||
`No cached chunks near bot at (${center.chunkX}, ${center.chunkZ}) — terrain will stream live from server after handoff`
|
||||
);
|
||||
} else {
|
||||
log.info(`Replaying ${chunks.length} chunks around (${center.chunkX}, ${center.chunkZ})...`);
|
||||
}
|
||||
|
||||
let lightCount = 0;
|
||||
let rawChunkCount = 0;
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i];
|
||||
if (chunk.rawMapChunkBuffer) {
|
||||
writeRaw(chunk.rawMapChunkBuffer, `map_chunk ${chunk.packetData.x},${chunk.packetData.z}`);
|
||||
rawChunkCount++;
|
||||
} else {
|
||||
write('map_chunk', chunk.packetData);
|
||||
}
|
||||
if (chunk.rawLightBuffer) {
|
||||
writeRaw(chunk.rawLightBuffer, `update_light ${chunk.packetData.x},${chunk.packetData.z}`);
|
||||
lightCount++;
|
||||
} else if (chunk.lightData) {
|
||||
write('update_light', chunk.lightData);
|
||||
lightCount++;
|
||||
}
|
||||
|
||||
for (const bc of chunk.blockChanges) {
|
||||
write('block_change', bc);
|
||||
}
|
||||
for (const mbc of chunk.multiBlockChanges) {
|
||||
write('multi_block_change', mbc);
|
||||
}
|
||||
|
||||
if ((i + 1) % CHUNK_YIELD_EVERY === 0) {
|
||||
await yieldEventLoop();
|
||||
}
|
||||
}
|
||||
|
||||
write('chunk_batch_finished', { batchSize: chunks.length });
|
||||
if (rawChunkCount > 0) {
|
||||
log.info(`Replayed ${rawChunkCount}/${chunks.length} chunks from raw buffers`);
|
||||
}
|
||||
if (lightCount > 0) {
|
||||
log.info(`Replayed ${lightCount} update_light packets`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { replayChunks };
|
||||
107
src/replay/replayHelpers.js
Normal file
107
src/replay/replayHelpers.js
Normal file
@@ -0,0 +1,107 @@
|
||||
const TELEPORT_CONFIRM_TIMEOUT_MS = 15000;
|
||||
const CHUNK_YIELD_EVERY = 32;
|
||||
/** Vanilla keeps "Loading Terrain" at least ~2s after chunks start loading */
|
||||
const POST_REPLAY_SETTLE_MS = 2500;
|
||||
|
||||
function yieldEventLoop() {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy clients without Mojang profile keys cannot satisfy enforcesSecureChat.
|
||||
* Strip the flag on replay so vanilla does not disable chat locally.
|
||||
*/
|
||||
function replayPacketData(client, name, data) {
|
||||
if (client.profileKeys || !data || typeof data !== 'object') return data;
|
||||
if ((name === 'login' || name === 'server_data') && data.enforcesSecureChat) {
|
||||
return { ...data, enforcesSecureChat: false };
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function getPlayerChunkCenter(playerState, misc, bot) {
|
||||
if (bot?.entity?.position) {
|
||||
const p = bot.entity.position;
|
||||
return {
|
||||
chunkX: Math.floor(p.x / 16),
|
||||
chunkZ: Math.floor(p.z / 16),
|
||||
};
|
||||
}
|
||||
if (playerState.position) {
|
||||
return {
|
||||
chunkX: Math.floor(playerState.position.x / 16),
|
||||
chunkZ: Math.floor(playerState.position.z / 16),
|
||||
};
|
||||
}
|
||||
if (misc.viewPosition) {
|
||||
return {
|
||||
chunkX: misc.viewPosition.chunkX,
|
||||
chunkZ: misc.viewPosition.chunkZ,
|
||||
};
|
||||
}
|
||||
if (playerState.spawnPosition?.location) {
|
||||
const loc = playerState.spawnPosition.location;
|
||||
return {
|
||||
chunkX: Math.floor(loc.x / 16),
|
||||
chunkZ: Math.floor(loc.z / 16),
|
||||
};
|
||||
}
|
||||
return { chunkX: 0, chunkZ: 0 };
|
||||
}
|
||||
|
||||
/** Split misc replay to match placeNewPlayer: HUD first, border/time after teleport */
|
||||
function splitMiscReplayPackets(packets) {
|
||||
const beforeLevel = [];
|
||||
const levelInfo = [];
|
||||
const weatherPackets = [];
|
||||
for (const pkt of packets) {
|
||||
if (
|
||||
pkt.name === 'initialize_world_border' ||
|
||||
pkt.name === 'world_border_center' ||
|
||||
pkt.name === 'world_border_size' ||
|
||||
pkt.name === 'update_time'
|
||||
) {
|
||||
levelInfo.push(pkt);
|
||||
} else if (
|
||||
pkt.name === 'game_state_change' &&
|
||||
pkt.data?.reason != null &&
|
||||
[1, 7, 8].includes(pkt.data.reason)
|
||||
) {
|
||||
weatherPackets.push(pkt);
|
||||
} else if (pkt.name === 'update_view_distance') {
|
||||
continue;
|
||||
} else {
|
||||
beforeLevel.push(pkt);
|
||||
}
|
||||
}
|
||||
return { beforeLevel, levelInfo, weatherPackets };
|
||||
}
|
||||
|
||||
function waitForClientTeleportConfirm(client) {
|
||||
return new Promise((resolve) => {
|
||||
if (!client || client.ended) return resolve();
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
client.removeListener('teleport_confirm', onConfirm);
|
||||
resolve();
|
||||
}, TELEPORT_CONFIRM_TIMEOUT_MS);
|
||||
|
||||
const onConfirm = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
};
|
||||
|
||||
client.once('teleport_confirm', onConfirm);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TELEPORT_CONFIRM_TIMEOUT_MS,
|
||||
CHUNK_YIELD_EVERY,
|
||||
POST_REPLAY_SETTLE_MS,
|
||||
yieldEventLoop,
|
||||
replayPacketData,
|
||||
getPlayerChunkCenter,
|
||||
splitMiscReplayPackets,
|
||||
waitForClientTeleportConfirm,
|
||||
};
|
||||
59
src/session/ChunkAckManager.js
Normal file
59
src/session/ChunkAckManager.js
Normal file
@@ -0,0 +1,59 @@
|
||||
const { createLogger } = require('../utils/logger');
|
||||
|
||||
const log = createLogger('ServerConn');
|
||||
|
||||
/**
|
||||
* Manages mineflayer's chunk_batch_finished auto-ack listener.
|
||||
*
|
||||
* While a Java proxy client is connected, only the client should send
|
||||
* chunk_batch_received (see PlayerChunkSender.onChunkBatchReceivedByClient).
|
||||
* This class saves and restores the mineflayer-installed listeners so we can
|
||||
* toggle ack ownership between bot and client.
|
||||
*/
|
||||
class ChunkAckManager {
|
||||
constructor() {
|
||||
/** @type {Function[]|null} */
|
||||
this._savedListeners = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable mineflayer's chunk_batch_finished auto-ack.
|
||||
* @param {object} rawClient - minecraft-protocol client
|
||||
*/
|
||||
disable(rawClient) {
|
||||
if (!rawClient) return;
|
||||
if (!this._savedListeners) {
|
||||
this._savedListeners = rawClient.listeners('chunk_batch_finished').slice();
|
||||
}
|
||||
rawClient.removeAllListeners('chunk_batch_finished');
|
||||
log.debug('Disabled mineflayer chunk_batch_finished auto-ack');
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore mineflayer's chunk_batch_finished auto-ack.
|
||||
* @param {object} rawClient - minecraft-protocol client
|
||||
*/
|
||||
restore(rawClient) {
|
||||
if (!rawClient || !this._savedListeners) return;
|
||||
rawClient.removeAllListeners('chunk_batch_finished');
|
||||
for (const fn of this._savedListeners) {
|
||||
rawClient.on('chunk_batch_finished', fn);
|
||||
}
|
||||
log.debug('Restored mineflayer chunk_batch_finished auto-ack');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a chunk_batch_received to unblock PlayerChunkSender.
|
||||
* @param {object} rawClient - minecraft-protocol client
|
||||
*/
|
||||
flush(rawClient) {
|
||||
if (!rawClient) return;
|
||||
try {
|
||||
rawClient.write('chunk_batch_received', { chunksPerTick: 9.0 });
|
||||
} catch (err) {
|
||||
log.debug('flushChunkBatchAck failed:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ChunkAckManager };
|
||||
156
src/session/MovementRelay.js
Normal file
156
src/session/MovementRelay.js
Normal file
@@ -0,0 +1,156 @@
|
||||
const { createLogger } = require('../utils/logger');
|
||||
const conv = require('mineflayer/lib/conversions');
|
||||
const {
|
||||
buildClientboundPositionPacket,
|
||||
buildServerboundPositionLook,
|
||||
waitForClientTeleportConfirm,
|
||||
movementFlags,
|
||||
distanceSq,
|
||||
MAX_CLIENT_MOVEMENT_WARN_DELTA,
|
||||
} = require('../utils/positionSync');
|
||||
|
||||
const log = createLogger('ServerConn');
|
||||
|
||||
/**
|
||||
* Apply a proxy client's movement packet to the bot entity, then send serverbound packets
|
||||
* using the client's coordinates so ChunkMap.move() tracks where the player walks.
|
||||
* @param {import('mineflayer').Bot} bot
|
||||
* @param {object} rawClient - minecraft-protocol client
|
||||
* @param {string} name - packet name
|
||||
* @param {object} data - packet data
|
||||
* @returns {boolean} false only when the bot entity is not ready
|
||||
*/
|
||||
function relayClientMovement(bot, rawClient, name, data) {
|
||||
if (!bot?.entity?.position) return false;
|
||||
|
||||
const entity = bot.entity;
|
||||
|
||||
if (name === 'position' || name === 'position_look') {
|
||||
const target = { x: data.x, y: data.y, z: data.z };
|
||||
const dist = Math.sqrt(distanceSq(target, entity.position));
|
||||
if (dist > MAX_CLIENT_MOVEMENT_WARN_DELTA) {
|
||||
log.warn(
|
||||
`Client ${dist.toFixed(1)} blocks ahead of bot — forwarding anyway so server streams chunks`
|
||||
);
|
||||
}
|
||||
entity.position.set(target.x, target.y, target.z);
|
||||
}
|
||||
|
||||
if (name === 'position_look' || name === 'look') {
|
||||
if (data.yaw !== undefined) entity.yaw = conv.fromNotchianYaw(data.yaw);
|
||||
if (data.pitch !== undefined) entity.pitch = conv.fromNotchianPitch(data.pitch);
|
||||
}
|
||||
|
||||
const onGround = data.onGround ?? data.flags?.onGround;
|
||||
if (onGround !== undefined) entity.onGround = onGround;
|
||||
|
||||
const flags = movementFlags(
|
||||
onGround ?? entity.onGround,
|
||||
data.flags?.hasHorizontalCollision
|
||||
);
|
||||
|
||||
try {
|
||||
if (name === 'flying' && data.x === undefined) {
|
||||
rawClient.write('flying', { flags });
|
||||
} else if (name === 'look') {
|
||||
rawClient.write('look', {
|
||||
yaw: conv.toNotchianYaw(entity.yaw),
|
||||
pitch: conv.toNotchianPitch(entity.pitch),
|
||||
flags,
|
||||
});
|
||||
} else if (name === 'position') {
|
||||
rawClient.write('position', {
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
z: data.z,
|
||||
flags,
|
||||
});
|
||||
} else if (name === 'position_look') {
|
||||
rawClient.write('position_look', {
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
z: data.z,
|
||||
yaw: data.yaw,
|
||||
pitch: data.pitch,
|
||||
flags,
|
||||
});
|
||||
} else {
|
||||
rawClient.write(name, data);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
log.error(`Failed to relay movement '${name}':`, err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap the proxy client to the bot's current server-side position.
|
||||
* Call after replay and before enabling movement forwarding.
|
||||
* @param {import('mineflayer').Bot} bot
|
||||
* @param {{ player: import('../state/PlayerStateCache').PlayerStateCache }} worldState
|
||||
* @param {object} client - minecraft-protocol client
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function syncProxyClientPosition(bot, worldState, client) {
|
||||
if (!bot?.entity?.position) {
|
||||
log.warn('Cannot sync client position: bot entity not ready');
|
||||
return false;
|
||||
}
|
||||
|
||||
const cached = worldState.player.position;
|
||||
const teleportId = (cached?.teleportId ?? 0) + 1;
|
||||
const packet = buildClientboundPositionPacket(bot, teleportId);
|
||||
if (!packet) return false;
|
||||
|
||||
const { x, y, z } = bot.entity.position;
|
||||
const chunkX = Math.floor(x / 16);
|
||||
const chunkZ = Math.floor(z / 16);
|
||||
|
||||
try {
|
||||
client.write('position', packet);
|
||||
worldState.player.handlePosition(packet);
|
||||
} catch (err) {
|
||||
log.error('Failed to write position sync to client:', err.message);
|
||||
return false;
|
||||
}
|
||||
|
||||
await waitForClientTeleportConfirm(client, 10000, log);
|
||||
|
||||
try {
|
||||
client.write('update_view_position', { chunkX, chunkZ });
|
||||
} catch (err) {
|
||||
log.error('Failed to write update_view_position after sync:', err.message);
|
||||
}
|
||||
|
||||
log.info(
|
||||
`Synced client to bot position (${x.toFixed(2)}, ${y.toFixed(2)}, ${z.toFixed(2)}) chunk (${chunkX}, ${chunkZ})`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the server the bot's current position (serverbound position_look).
|
||||
* @param {import('mineflayer').Bot} bot
|
||||
* @param {object} rawClient - minecraft-protocol client
|
||||
* @param {boolean} connected
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function confirmServerPosition(bot, rawClient, connected) {
|
||||
if (!rawClient || !connected) return false;
|
||||
|
||||
const packet = buildServerboundPositionLook(bot);
|
||||
if (!packet) return false;
|
||||
|
||||
try {
|
||||
rawClient.write('position_look', packet);
|
||||
const { x, y, z } = bot.entity.position;
|
||||
log.info(`Confirmed server position (${x.toFixed(2)}, ${y.toFixed(2)}, ${z.toFixed(2)})`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
log.error('Failed to confirm server position:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { relayClientMovement, syncProxyClientPosition, confirmServerPosition };
|
||||
257
src/session/ServerConnection.js
Normal file
257
src/session/ServerConnection.js
Normal file
@@ -0,0 +1,257 @@
|
||||
const EventEmitter = require('events');
|
||||
const mineflayer = require('mineflayer');
|
||||
const { createLogger } = require('../utils/logger');
|
||||
const { relayClientMovement, syncProxyClientPosition, confirmServerPosition } = require('./MovementRelay');
|
||||
const { ChunkAckManager } = require('./ChunkAckManager');
|
||||
|
||||
const log = createLogger('ServerConn');
|
||||
|
||||
/**
|
||||
* Manages the persistent connection to the Minecraft server via a Mineflayer bot.
|
||||
* Provides access to both the high-level bot API and the raw minecraft-protocol client.
|
||||
*/
|
||||
class ServerConnection extends EventEmitter {
|
||||
/**
|
||||
* @param {object} config - Full config object
|
||||
* @param {import('../state/WorldStateCache').WorldStateCache} worldState
|
||||
*/
|
||||
constructor(config, worldState) {
|
||||
super();
|
||||
this.config = config;
|
||||
this.worldState = worldState;
|
||||
this.bot = null;
|
||||
this.rawClient = null;
|
||||
this.connected = false;
|
||||
this._botControlEnabled = true;
|
||||
/** True after first spawn; later spawns are respawns on the same connection */
|
||||
this._initialSpawnDone = false;
|
||||
this._chunkAck = new ChunkAckManager();
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect the bot to the Minecraft server.
|
||||
*/
|
||||
connect() {
|
||||
log.info(`Connecting to ${this.config.server.host}:${this.config.server.port} as ${this.config.auth.username}...`);
|
||||
this._initialSpawnDone = false;
|
||||
|
||||
this.bot = mineflayer.createBot({
|
||||
host: this.config.server.host,
|
||||
port: this.config.server.port,
|
||||
username: this.config.auth.username,
|
||||
auth: this.config.auth.auth,
|
||||
version: this.config.server.version,
|
||||
viewDistance: this.config.bot.viewDistance,
|
||||
checkTimeoutInterval: 60000,
|
||||
hideErrors: false,
|
||||
});
|
||||
|
||||
this.rawClient = this.bot._client;
|
||||
|
||||
this._setupConfigCapture();
|
||||
this._setupPacketCapture();
|
||||
this._setupBotEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture configuration-phase packets for later replay.
|
||||
*/
|
||||
_setupConfigCapture() {
|
||||
const configPacketNames = new Set([
|
||||
'registry_data', 'feature_flags', 'tags', 'finish_configuration',
|
||||
'custom_payload', 'reset_chat',
|
||||
]);
|
||||
|
||||
// Capture raw buffers so proxy clients get byte-identical registry data.
|
||||
this.rawClient.on('packet', (data, meta, buffer) => {
|
||||
if (meta.state !== 'configuration') return;
|
||||
if (!configPacketNames.has(meta.name)) return;
|
||||
|
||||
this.worldState.handleRawConfigPacket(meta.name, buffer);
|
||||
this.worldState.handleConfigPacket(meta.name, data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook into raw packet events to feed the world state cache.
|
||||
*/
|
||||
_setupPacketCapture() {
|
||||
this.rawClient.on('packet', (data, meta, buffer) => {
|
||||
if (meta.state !== 'play') return;
|
||||
|
||||
// Feed every server->client play packet to the world state cache
|
||||
this.worldState.handleServerPacket(meta.name, data, buffer);
|
||||
|
||||
// Forward to any connected client (include raw buffer for chunk packets)
|
||||
this.emit('serverPacket', meta.name, data, buffer);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup high-level bot events.
|
||||
*/
|
||||
_setupBotEvents() {
|
||||
this.bot.on('spawn', () => {
|
||||
log.info('Bot spawned in world');
|
||||
this.connected = true;
|
||||
if (!this._initialSpawnDone) {
|
||||
this._initialSpawnDone = true;
|
||||
this.emit('connected');
|
||||
} else {
|
||||
this.emit('respawn');
|
||||
}
|
||||
});
|
||||
|
||||
this.bot.on('end', (reason) => {
|
||||
log.warn(`Bot disconnected: ${reason}`);
|
||||
this.connected = false;
|
||||
this.emit('disconnected', reason);
|
||||
});
|
||||
|
||||
this.bot.on('kicked', (reason) => {
|
||||
log.error(`Bot kicked: ${JSON.stringify(reason)}`);
|
||||
this.connected = false;
|
||||
this.emit('kicked', reason);
|
||||
});
|
||||
|
||||
this.bot.on('error', (err) => {
|
||||
log.error(`Bot error: ${err.message}`);
|
||||
this.emit('error', err);
|
||||
});
|
||||
|
||||
this.bot.on('death', () => {
|
||||
log.warn('Bot died');
|
||||
this.emit('death');
|
||||
if (this._botControlEnabled) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
this.bot.respawn();
|
||||
} catch (e) {
|
||||
log.error('Failed to respawn:', e.message);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
this.bot.on('messagestr', (text, messageType) => {
|
||||
if (!this.connected || !text) return;
|
||||
const label =
|
||||
messageType === 'chat' ? 'Chat' :
|
||||
messageType === 'system' ? 'Server' :
|
||||
messageType === 'game_info' ? 'ActionBar' :
|
||||
messageType;
|
||||
const line = `[${label}] ${text}`;
|
||||
if (messageType === 'game_info') {
|
||||
log.debug(line);
|
||||
} else {
|
||||
log.info(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Enable/disable bot AI control.
|
||||
* When disabled, the bot stops all autonomous behavior.
|
||||
*/
|
||||
setBotControl(enabled) {
|
||||
this._botControlEnabled = enabled;
|
||||
if (enabled) {
|
||||
log.info('Bot control ENABLED (bot mode)');
|
||||
if (this.bot) this.bot.physicsEnabled = true;
|
||||
} else {
|
||||
log.info('Bot control DISABLED (client taking over)');
|
||||
if (this.bot) {
|
||||
this.bot.physicsEnabled = false;
|
||||
try {
|
||||
this.bot.clearControlStates();
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When true, the Java client forwards chunk_batch_received and mineflayer must not auto-ack.
|
||||
* When false, mineflayer acks batches on the bot connection (required during handoff/replay).
|
||||
*/
|
||||
setClientDrivesChunkBatchAck(clientDrives) {
|
||||
this.setProxyClientChunkAck(!clientDrives);
|
||||
}
|
||||
|
||||
/** Unblock PlayerChunkSender if a batch finished without an ack yet. */
|
||||
flushChunkBatchAck() {
|
||||
this._chunkAck.flush(this.rawClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-send permission entity_status after /op or on handoff (PlayerList.sendPlayerPermissionLevel).
|
||||
*/
|
||||
refreshProxyClientPermissions(client) {
|
||||
const status = this.worldState.player.permissionStatus;
|
||||
if (!status || !client) return false;
|
||||
try {
|
||||
client.write('entity_status', { ...status });
|
||||
return true;
|
||||
} catch (err) {
|
||||
log.error('Failed to refresh client permissions:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap the proxy client to the bot's current server-side position.
|
||||
* Call after replay and before enabling movement forwarding.
|
||||
*/
|
||||
async syncProxyClientPosition(client) {
|
||||
return syncProxyClientPosition(this.bot, this.worldState, client);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the server the bot's current position (serverbound position_look).
|
||||
*/
|
||||
confirmServerPosition() {
|
||||
return confirmServerPosition(this.bot, this.rawClient, this.connected);
|
||||
}
|
||||
|
||||
/**
|
||||
* While a Java client is connected, only the client should send chunk_batch_received
|
||||
* (see PlayerChunkSender.onChunkBatchReceivedByClient). Mineflayer auto-acks otherwise.
|
||||
*/
|
||||
setProxyClientChunkAck(enabled) {
|
||||
if (enabled) {
|
||||
this._chunkAck.restore(this.rawClient);
|
||||
} else {
|
||||
this._chunkAck.disable(this.rawClient);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a proxy client's movement packet to the bot entity, then send serverbound packets
|
||||
* using the client's coordinates so ChunkMap.move() tracks where the player walks.
|
||||
* @returns {boolean} false only when the bot entity is not ready
|
||||
*/
|
||||
relayClientMovement(name, data) {
|
||||
return relayClientMovement(this.bot, this.rawClient, name, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a packet to the upstream server.
|
||||
*/
|
||||
writeToServer(name, data) {
|
||||
if (this.rawClient && this.connected) {
|
||||
this.rawClient.write(name, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully close the connection.
|
||||
*/
|
||||
disconnect() {
|
||||
if (this.bot) {
|
||||
this.bot.quit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ServerConnection };
|
||||
316
src/session/SessionManager.js
Normal file
316
src/session/SessionManager.js
Normal file
@@ -0,0 +1,316 @@
|
||||
const { createLogger } = require('../utils/logger');
|
||||
const { ServerConnection } = require('./ServerConnection');
|
||||
const { ProxyServer } = require('../proxy/ProxyServer');
|
||||
const { WorldStateCache } = require('../state/WorldStateCache');
|
||||
const { StateReplayer } = require('../replay/StateReplayer');
|
||||
const { performHandoff } = require('./handoffFlow');
|
||||
const { removeHandoffUpstreamRelay } = require('../utils/handoffSync');
|
||||
const { disconnectReasonText } = require('../utils/clientDisconnect');
|
||||
|
||||
const log = createLogger('Session');
|
||||
|
||||
/**
|
||||
* Session states
|
||||
*/
|
||||
const State = {
|
||||
INIT: 'INIT',
|
||||
BOT_MODE: 'BOT_MODE',
|
||||
HANDOFF: 'HANDOFF',
|
||||
CLIENT_MODE: 'CLIENT_MODE',
|
||||
};
|
||||
|
||||
/**
|
||||
* Orchestrates the lifecycle: bot mode ↔ client mode.
|
||||
*
|
||||
* - INIT: Connecting to server
|
||||
* - BOT_MODE: No client connected, bot holds the session
|
||||
* - HANDOFF: Client just connected, replaying cached state
|
||||
* - CLIENT_MODE: Client is in control, packets piped bidirectionally
|
||||
*/
|
||||
class SessionManager {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.state = State.INIT;
|
||||
this._shuttingDown = false;
|
||||
this._reconnectTimer = null;
|
||||
|
||||
// Core components
|
||||
this.worldState = new WorldStateCache(config);
|
||||
this.serverConn = new ServerConnection(config, this.worldState);
|
||||
this.proxyServer = new ProxyServer(config, (client) => this._onClientConnect(client), this.worldState);
|
||||
this.replayer = new StateReplayer(this.worldState, this.serverConn);
|
||||
|
||||
// Current client bridge (if in CLIENT_MODE)
|
||||
this.clientBridge = null;
|
||||
this.currentClient = null;
|
||||
|
||||
this._setupServerEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot up: connect to server and start proxy.
|
||||
*/
|
||||
start() {
|
||||
log.info('Starting FlayerProxy...');
|
||||
this.serverConn.connect();
|
||||
this.proxyServer.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a reconnect, cancelling any previous pending one.
|
||||
*/
|
||||
_scheduleReconnect(delaySec) {
|
||||
if (this._shuttingDown) return;
|
||||
|
||||
// Cancel any existing timer
|
||||
if (this._reconnectTimer) {
|
||||
clearTimeout(this._reconnectTimer);
|
||||
this._reconnectTimer = null;
|
||||
}
|
||||
|
||||
log.info(`Reconnecting in ${delaySec} seconds...`);
|
||||
this._reconnectTimer = setTimeout(() => {
|
||||
this._reconnectTimer = null;
|
||||
if (this._shuttingDown) return;
|
||||
this.worldState.clear();
|
||||
this.serverConn.connect();
|
||||
}, delaySec * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event handlers for server connection lifecycle.
|
||||
*/
|
||||
_setupServerEvents() {
|
||||
this.serverConn.on('connected', () => {
|
||||
log.info('Server connection established');
|
||||
if (this.worldState.hasRawConfigPackets()) {
|
||||
const packets = this.worldState.getRawConfigPacketsForReplay();
|
||||
const registryCount = packets.filter(p => p.name === 'registry_data').length;
|
||||
this.proxyServer.updateRegistryCodec({});
|
||||
log.info(`Captured ${packets.length} raw config packets (${registryCount} registries) from server`);
|
||||
} else {
|
||||
const registryCodec = this.worldState.buildRegistryCodec();
|
||||
if (registryCodec) {
|
||||
this.proxyServer.updateRegistryCodec(registryCodec);
|
||||
} else {
|
||||
log.warn('No registry_data captured from server — proxy clients will use minecraft-data defaults');
|
||||
}
|
||||
}
|
||||
this._transitionTo(State.BOT_MODE);
|
||||
});
|
||||
|
||||
this.serverConn.on('disconnected', (reason) => {
|
||||
log.warn(`Server disconnected: ${reason}`);
|
||||
if (this.state === State.INIT) return; // Already handled by kicked
|
||||
|
||||
// Kick any connected client
|
||||
if (this.currentClient) {
|
||||
try {
|
||||
this.currentClient.end(`Server disconnected: ${disconnectReasonText(reason)}`);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
this._cleanupClient();
|
||||
this._transitionTo(State.INIT);
|
||||
this._scheduleReconnect(5);
|
||||
});
|
||||
|
||||
this.serverConn.on('kicked', (reason) => {
|
||||
log.error(`Kicked from server: ${JSON.stringify(reason)}`);
|
||||
|
||||
if (this.currentClient) {
|
||||
try {
|
||||
this.currentClient.end(`Kicked from server: ${disconnectReasonText(reason)}`);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
this._cleanupClient();
|
||||
this._transitionTo(State.INIT);
|
||||
this._scheduleReconnect(15);
|
||||
});
|
||||
|
||||
this.serverConn.on('error', (err) => {
|
||||
log.error(`Server error: ${err.message}`);
|
||||
});
|
||||
|
||||
this.serverConn.on('death', () => {
|
||||
if (this.currentClient && this.state === State.CLIENT_MODE) {
|
||||
log.warn('Bot died while client is connected — will resync when bot respawns');
|
||||
}
|
||||
});
|
||||
|
||||
this.serverConn.on('respawn', () => {
|
||||
if (this.currentClient && this.state === State.CLIENT_MODE) {
|
||||
this._refreshClientAfterBotRespawn().catch((err) => {
|
||||
log.error('Failed to refresh session after respawn:', err.message);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the server for chunks at the bot's current position if the cache is empty there.
|
||||
*/
|
||||
async _primeChunksNearBot() {
|
||||
const bot = this.serverConn.bot;
|
||||
if (!bot?.entity?.position) return;
|
||||
|
||||
const cx = Math.floor(bot.entity.position.x / 16);
|
||||
const cz = Math.floor(bot.entity.position.z / 16);
|
||||
|
||||
if (this.worldState.chunks.getChunksForReplay(cx, cz, 2).length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`No cached chunks at bot (${cx}, ${cz}) — nudging server chunk loader...`);
|
||||
// Server has no serverbound view-center packet; movement triggers ChunkMap.move().
|
||||
this.serverConn.confirmServerPosition();
|
||||
|
||||
const rawClient = this.serverConn.rawClient;
|
||||
if (!rawClient) return;
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
rawClient.removeListener('packet', onPacket);
|
||||
resolve();
|
||||
}, 1500);
|
||||
|
||||
const onPacket = (data, meta) => {
|
||||
if (meta.state !== 'play' || meta.name !== 'map_chunk') return;
|
||||
if (this.worldState.chunks.getChunksForReplay(cx, cz, 2).length > 0) {
|
||||
clearTimeout(timeout);
|
||||
rawClient.removeListener('packet', onPacket);
|
||||
log.info('Received chunks from server for handoff');
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
rawClient.on('packet', onPacket);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bot respawned on the same server connection while a client is attached.
|
||||
*/
|
||||
async _refreshClientAfterBotRespawn() {
|
||||
const client = this.currentClient;
|
||||
if (!client) return;
|
||||
|
||||
log.info('Bot respawned — resyncing client to new position');
|
||||
this.worldState.entities.clear();
|
||||
|
||||
await this.serverConn.syncProxyClientPosition(client);
|
||||
this.serverConn.confirmServerPosition();
|
||||
|
||||
if (this.clientBridge) {
|
||||
this.clientBridge._syncClientViewFromBot();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a new Java client connection from the proxy server.
|
||||
*/
|
||||
async _onClientConnect(client) {
|
||||
if (this.state === State.INIT) {
|
||||
log.warn('Client connected but bot is not ready yet — rejecting');
|
||||
client.end('Proxy is still connecting to the server. Try again in a moment.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state === State.HANDOFF || this.state === State.CLIENT_MODE) {
|
||||
log.warn('Client connected but another client is active — rejecting');
|
||||
client.end('Another client session is active.');
|
||||
return;
|
||||
}
|
||||
|
||||
// BOT_MODE → HANDOFF
|
||||
log.info(`Client ${client.username} connected — starting handoff`);
|
||||
this._transitionTo(State.HANDOFF);
|
||||
this.currentClient = client;
|
||||
|
||||
// Disable bot physics; keep mineflayer chunk_batch ack until the bridge takes over
|
||||
this.serverConn.setBotControl(false);
|
||||
|
||||
// Handle client disconnect during handoff
|
||||
const onDisconnect = () => {
|
||||
log.info('Client disconnected during handoff');
|
||||
this._cleanupClient();
|
||||
this._transitionTo(State.BOT_MODE);
|
||||
this.serverConn.setBotControl(true);
|
||||
};
|
||||
client.once('end', onDisconnect);
|
||||
|
||||
const result = await performHandoff({
|
||||
client,
|
||||
serverConn: this.serverConn,
|
||||
worldState: this.worldState,
|
||||
replayer: this.replayer,
|
||||
proxyServer: this.proxyServer,
|
||||
primeChunks: () => this._primeChunksNearBot(),
|
||||
isHandoffState: () => this.state === State.HANDOFF,
|
||||
});
|
||||
|
||||
client.removeListener('end', onDisconnect);
|
||||
|
||||
if (!result) {
|
||||
this._cleanupClient();
|
||||
this._transitionTo(State.BOT_MODE);
|
||||
this.serverConn.setBotControl(true);
|
||||
return;
|
||||
}
|
||||
|
||||
this._transitionTo(State.CLIENT_MODE);
|
||||
this.clientBridge = result.bridge;
|
||||
|
||||
// Handle client disconnect in client mode
|
||||
client.on('end', () => {
|
||||
log.info('Client disconnected — returning to bot mode');
|
||||
this._cleanupClient();
|
||||
this._transitionTo(State.BOT_MODE);
|
||||
this.serverConn.setBotControl(true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up client bridge and references.
|
||||
*/
|
||||
_cleanupClient() {
|
||||
if (this.clientBridge) {
|
||||
this.clientBridge.stop();
|
||||
this.clientBridge = null;
|
||||
}
|
||||
this.currentClient = null;
|
||||
this.proxyServer.activeClient = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition to a new state.
|
||||
*/
|
||||
_transitionTo(newState) {
|
||||
const oldState = this.state;
|
||||
this.state = newState;
|
||||
log.info(`State: ${oldState} → ${newState}`);
|
||||
|
||||
if (newState === State.BOT_MODE) {
|
||||
const summary = this.worldState.getSummary();
|
||||
log.info(`Cache status: ${summary.chunks} chunks, ${summary.entities} entities, position: ${summary.hasPosition}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully shut down everything.
|
||||
*/
|
||||
stop() {
|
||||
this._shuttingDown = true;
|
||||
if (this._reconnectTimer) {
|
||||
clearTimeout(this._reconnectTimer);
|
||||
this._reconnectTimer = null;
|
||||
}
|
||||
log.info('Shutting down FlayerProxy...');
|
||||
this._cleanupClient();
|
||||
this.proxyServer.stop();
|
||||
this.serverConn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SessionManager };
|
||||
67
src/session/handoffFlow.js
Normal file
67
src/session/handoffFlow.js
Normal file
@@ -0,0 +1,67 @@
|
||||
const { createLogger } = require('../utils/logger');
|
||||
const {
|
||||
installHandoffUpstreamRelay,
|
||||
removeHandoffUpstreamRelay,
|
||||
sendPermissionStatusToClient,
|
||||
} = require('../utils/handoffSync');
|
||||
const { ClientBridge } = require('../proxy/ClientBridge');
|
||||
|
||||
const log = createLogger('Session');
|
||||
|
||||
/**
|
||||
* Execute the handoff sequence: prime chunks → replay → position sync → permissions → bridge.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {object} opts.client - minecraft-protocol proxy client
|
||||
* @param {import('./ServerConnection').ServerConnection} opts.serverConn
|
||||
* @param {import('../state/WorldStateCache').WorldStateCache} opts.worldState
|
||||
* @param {import('../replay/StateReplayer').StateReplayer} opts.replayer
|
||||
* @param {import('../proxy/ProxyServer').ProxyServer} opts.proxyServer
|
||||
* @param {function(): Promise<void>} opts.primeChunks - primes chunks near bot
|
||||
* @param {function(): boolean} opts.isHandoffState - returns true if still in HANDOFF state
|
||||
* @returns {Promise<{ bridge: ClientBridge, upstreamRelay: object }|null>} null on failure
|
||||
*/
|
||||
async function performHandoff({ client, serverConn, worldState, replayer, proxyServer, primeChunks, isHandoffState }) {
|
||||
const upstreamRelay = installHandoffUpstreamRelay(client, serverConn, log);
|
||||
|
||||
try {
|
||||
await primeChunks();
|
||||
// Replay cached state (placeNewPlayer order: teleport → level info → chunks)
|
||||
await replayer.replay(client);
|
||||
|
||||
if (!isHandoffState()) return null;
|
||||
|
||||
await serverConn.syncProxyClientPosition(client);
|
||||
serverConn.confirmServerPosition();
|
||||
|
||||
if (
|
||||
!sendPermissionStatusToClient(
|
||||
client,
|
||||
worldState.player.permissionStatus,
|
||||
log
|
||||
)
|
||||
) {
|
||||
log.warn(
|
||||
'No OP permission cached for client — run /op FlayerBot on the server (not your launcher username), then reconnect'
|
||||
);
|
||||
}
|
||||
|
||||
serverConn.writeToServer('player_loaded', {});
|
||||
log.info('Sent player_loaded to server (hasClientLoaded)');
|
||||
|
||||
removeHandoffUpstreamRelay(client, upstreamRelay);
|
||||
|
||||
const bridge = new ClientBridge(client, serverConn, worldState);
|
||||
bridge.start();
|
||||
bridge.enableMovement();
|
||||
|
||||
log.info(`Session handed off to ${client.username}`);
|
||||
return { bridge, upstreamRelay: null };
|
||||
} catch (err) {
|
||||
log.error('Error during handoff:', err);
|
||||
removeHandoffUpstreamRelay(client, upstreamRelay);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { performHandoff };
|
||||
256
src/sniffer/MitmProxy.js
Normal file
256
src/sniffer/MitmProxy.js
Normal file
@@ -0,0 +1,256 @@
|
||||
const mc = require('minecraft-protocol');
|
||||
const { createLogger } = require('../utils/logger');
|
||||
const { PacketLog } = require('./PacketLog');
|
||||
const { relayPacket, sortLoginPending, relayToJava } = require('./mitmRelay');
|
||||
const { enableJavaEncryption } = require('./mitmEncryption');
|
||||
const { applyLoginStartIdentity } = require('./mitmLogin');
|
||||
const {
|
||||
GATE,
|
||||
canRelayC2S,
|
||||
c2sForwardLabel,
|
||||
hasPendingSuccess,
|
||||
onJavaLoginAcknowledged,
|
||||
onJavaFinishConfiguration,
|
||||
partitionAfterCrypto,
|
||||
} = require('./mitmGate');
|
||||
const { createMitmSession, createSessionCleanup } = require('./mitmSession');
|
||||
const { startStatusPipe, startUpstream } = require('./mitmUpstream');
|
||||
|
||||
const log = createLogger('Sniffer');
|
||||
const states = mc.states;
|
||||
|
||||
/**
|
||||
* MITM sniffer: Java ↔ node server ↔ upstream client ↔ real server.
|
||||
* Each leg is decrypted by minecraft-protocol so packets can be logged by name.
|
||||
*/
|
||||
class MitmProxy {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.server = null;
|
||||
this.activeSession = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
const sniffer = this.config.sniffer;
|
||||
this.server = mc.createServer({
|
||||
host: sniffer.host || '0.0.0.0',
|
||||
'online-mode': sniffer.onlineMode === true,
|
||||
port: sniffer.port,
|
||||
version: this.config.server.version,
|
||||
maxPlayers: 1,
|
||||
motd: '§eMITM Sniffer',
|
||||
kickTimeout: 120000,
|
||||
checkTimeoutInterval: 10000,
|
||||
hideErrors: true,
|
||||
errorHandler: (_client, err) => {
|
||||
log.error('Client error:', err.message);
|
||||
},
|
||||
});
|
||||
|
||||
this.server.on('connection', (client) => this._onConnection(client));
|
||||
|
||||
this.server.on('listening', () => {
|
||||
log.info(
|
||||
`MITM sniffer on ${sniffer.host || '0.0.0.0'}:${sniffer.port} → ${this.config.server.host}:${this.config.server.port}`,
|
||||
);
|
||||
log.info(`Upstream auth: ${sniffer.upstreamAuth || 'microsoft'}`);
|
||||
log.info(`Logs: ${sniffer.logDir}`);
|
||||
});
|
||||
|
||||
this.server.on('error', (err) => {
|
||||
log.error('Sniffer listen error:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
_onConnection(client) {
|
||||
const addr = client.socket?.remoteAddress || '?';
|
||||
|
||||
if (this.activeSession) {
|
||||
log.warn(`Rejecting ${addr} — session active`);
|
||||
client.end('Sniffer allows one client at a time.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Do not run local login — upstream is the real server session.
|
||||
client.removeAllListeners('login_start');
|
||||
|
||||
const packetLog = new PacketLog({
|
||||
logDir: this.config.sniffer.logDir,
|
||||
sessionId: `session-${Date.now()}`,
|
||||
clientUsername: 'unknown',
|
||||
server: `${this.config.server.host}:${this.config.server.port}`,
|
||||
version: this.config.server.version,
|
||||
includePayload: this.config.sniffer.includePayload,
|
||||
});
|
||||
|
||||
const session = createMitmSession(client, packetLog);
|
||||
this.activeSession = session;
|
||||
|
||||
const cleanup = createSessionCleanup(session, packetLog, this);
|
||||
|
||||
client.on('end', () => {
|
||||
log.info(`Client disconnected ${session.username} (${addr})`);
|
||||
cleanup('client_end');
|
||||
});
|
||||
client.on('error', (err) => {
|
||||
log.error(`Client error: ${err.message}`);
|
||||
cleanup('client_error');
|
||||
});
|
||||
|
||||
client.on('packet', (data, meta, buffer) => {
|
||||
packetLog.logPacket('C2S', meta, data, buffer, {
|
||||
forwarded: c2sForwardLabel(session, meta),
|
||||
clientState: client.state,
|
||||
upstreamState: session.upstream?.state,
|
||||
gate: session.gate,
|
||||
});
|
||||
|
||||
if (meta.state === states.HANDSHAKING && meta.name === 'set_protocol' && data.nextState === 1) {
|
||||
startStatusPipe(session, this.config, packetLog, this);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session.upstream && meta.state === states.LOGIN && meta.name === 'login_start') {
|
||||
try {
|
||||
applyLoginStartIdentity(client, data, this.server, this.server.options);
|
||||
} catch (err) {
|
||||
log.error(`login_start rejected: ${err.message}`);
|
||||
client.end('Invalid login');
|
||||
return;
|
||||
}
|
||||
session.username = data.username;
|
||||
packetLog.writeMeta({ type: 'username', username: data.username });
|
||||
packetLog.writeMeta({ type: 'handshake_intent', mode: 'login' });
|
||||
this._startUpstream(session, cleanup);
|
||||
return;
|
||||
}
|
||||
|
||||
if (meta.name === 'login_acknowledged') {
|
||||
try {
|
||||
if (onJavaLoginAcknowledged(session)) {
|
||||
log.info(`Java login acknowledged → configuration for ${session.username}`);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error(`login_acknowledged error: ${err.message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (meta.name === 'finish_configuration') {
|
||||
try {
|
||||
if (onJavaFinishConfiguration(session, packetLog)) {
|
||||
log.info(`MITM bridge active (play) for ${session.username}`);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error(`finish_configuration error: ${err.message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.upstream && canRelayC2S(session, meta)) {
|
||||
try {
|
||||
relayPacket(session.upstream, meta, data, buffer);
|
||||
} catch (err) {
|
||||
log.error(`C2S relay error (${meta.name}):`, err.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
log.info(`Client connected ${addr} → ${packetLog.filePath}`);
|
||||
}
|
||||
|
||||
_startUpstream(session, cleanup) {
|
||||
const tryBegin = () => this._tryBeginJavaCrypto(session, cleanup);
|
||||
startUpstream(session, this.config, cleanup, {
|
||||
GATE_LOGIN: GATE.LOGIN,
|
||||
onCompressBeforeCrypto: tryBegin,
|
||||
onEncryptionBegin: tryBegin,
|
||||
onSuccessWhileHeld: tryBegin,
|
||||
onSuccessNoEncryption: (s) => {
|
||||
s.gate = GATE.AWAIT_LOGIN_ACK;
|
||||
log.info(`Login success sent (no upstream encryption) for ${s.username}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
_tryBeginJavaCrypto(session, cleanup) {
|
||||
if (!session.waitingJavaCrypto || session.javaCryptoStarting || session.gate !== GATE.LOGIN) return;
|
||||
|
||||
if (!hasPendingSuccess(session)) return;
|
||||
|
||||
this._doJavaCrypto(session, cleanup);
|
||||
}
|
||||
|
||||
async _doJavaCrypto(session, cleanup) {
|
||||
if (session.javaCryptoStarting || session.gate !== GATE.LOGIN) return;
|
||||
session.javaCryptoStarting = true;
|
||||
|
||||
sortLoginPending(session.pendingS2C);
|
||||
const heldLogin = [];
|
||||
for (const item of session.pendingS2C) {
|
||||
const { meta } = item;
|
||||
if (meta.name === 'encryption_begin') continue;
|
||||
if (meta.name === 'compress' && meta.state === states.LOGIN) {
|
||||
session.relayedCompress = true;
|
||||
try {
|
||||
relayToJava(session.client, item.meta, item.data, item.buffer);
|
||||
} catch (err) {
|
||||
log.error(`S2C pre-crypto compress error:`, err.message);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
heldLogin.push(item);
|
||||
}
|
||||
session.pendingS2C.length = 0;
|
||||
const { login: afterCrypto, config: heldConfig } = partitionAfterCrypto(heldLogin);
|
||||
session.pendingConfig.push(...heldConfig);
|
||||
|
||||
try {
|
||||
await enableJavaEncryption(session.client, this.server, this.server.options);
|
||||
} catch (err) {
|
||||
session.javaCryptoStarting = false;
|
||||
log.error('Java encryption setup failed:', err.message);
|
||||
session.client.end('Sniffer encryption setup failed');
|
||||
cleanup('encryption_error');
|
||||
return;
|
||||
}
|
||||
|
||||
session.holdS2C = false;
|
||||
session.waitingJavaCrypto = false;
|
||||
session.gate = GATE.AWAIT_LOGIN_ACK;
|
||||
session.packetLog.writeMeta({ type: 'java_crypto_ready' });
|
||||
log.info(`Java crypto ready for ${session.username}, awaiting login_acknowledged`);
|
||||
|
||||
const successPackets = [
|
||||
...afterCrypto.filter((p) => p.meta.name === 'success'),
|
||||
...session.pendingS2C.filter((p) => p.meta.name === 'success'),
|
||||
];
|
||||
session.pendingS2C = session.pendingS2C.filter((p) => p.meta.name !== 'success');
|
||||
|
||||
for (const { data, meta, buffer } of successPackets) {
|
||||
try {
|
||||
relayToJava(session.client, meta, data, buffer);
|
||||
} catch (err) {
|
||||
log.error(`S2C success flush error:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.activeSession) {
|
||||
const session = this.activeSession;
|
||||
this.activeSession = null;
|
||||
try { session.client.end('Sniffer shutting down'); } catch (_) {}
|
||||
if (session.upstream && !session.upstream.ended) {
|
||||
try { session.upstream.end('Sniffer shutting down'); } catch (_) {}
|
||||
}
|
||||
session.packetLog.close('shutdown');
|
||||
}
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
this.server = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { MitmProxy };
|
||||
250
src/sniffer/PacketLog.js
Normal file
250
src/sniffer/PacketLog.js
Normal file
@@ -0,0 +1,250 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/** JSONL lines longer than this go to sessionDir/line-NNNNNN.jsonl with a short ref in the main log. */
|
||||
const MAX_INLINE_LINE = 180;
|
||||
|
||||
const LARGE_PACKETS = new Set([
|
||||
'map_chunk',
|
||||
'chunk_data',
|
||||
'level_chunk_with_light',
|
||||
'light_update',
|
||||
'custom_payload',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Append-only JSONL packet log for login / handoff analysis.
|
||||
*/
|
||||
class PacketLog {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.logDir
|
||||
* @param {string} opts.sessionId
|
||||
* @param {boolean} [opts.includePayload=true]
|
||||
*/
|
||||
constructor(opts) {
|
||||
this.includePayload = opts.includePayload !== false;
|
||||
this.sessionId = opts.sessionId;
|
||||
this._seq = 0;
|
||||
|
||||
const dir = path.resolve(opts.logDir);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, `${opts.sessionId}.jsonl`);
|
||||
this._spillDir = path.join(dir, opts.sessionId);
|
||||
fs.mkdirSync(this._spillDir, { recursive: true });
|
||||
this._spillCount = 0;
|
||||
this._stream = fs.createWriteStream(file, { flags: 'a' });
|
||||
this._closed = false;
|
||||
this.filePath = file;
|
||||
this.spillDir = this._spillDir;
|
||||
|
||||
this.writeMeta({
|
||||
type: 'session_start',
|
||||
sessionId: opts.sessionId,
|
||||
clientUsername: opts.clientUsername,
|
||||
server: opts.server,
|
||||
version: opts.version,
|
||||
});
|
||||
}
|
||||
|
||||
writeMeta(record) {
|
||||
this._write({ ...record, t: new Date().toISOString() });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {'C2S'|'S2C'} dir
|
||||
* @param {object} meta - minecraft-protocol packet meta
|
||||
* @param {object} data - parsed params
|
||||
* @param {Buffer} [rawBuffer]
|
||||
* @param {object} [extra]
|
||||
*/
|
||||
logUnparsed(dir, state, frame, message) {
|
||||
this._write({
|
||||
type: 'parse_error',
|
||||
seq: ++this._seq,
|
||||
t: new Date().toISOString(),
|
||||
dir,
|
||||
state,
|
||||
frameBytes: frame.length,
|
||||
headHex: frame.subarray(0, Math.min(16, frame.length)).toString('hex'),
|
||||
error: message,
|
||||
forwarded: 'tcp',
|
||||
});
|
||||
}
|
||||
|
||||
logOpaque(dir, bytes, extra = {}) {
|
||||
if (extra.encrypted) {
|
||||
this._encryptedOpaque = this._encryptedOpaque || { C2S: 0, S2C: 0, bytes: { C2S: 0, S2C: 0 } };
|
||||
this._encryptedOpaque[dir]++;
|
||||
this._encryptedOpaque.bytes[dir] += bytes;
|
||||
const n = this._encryptedOpaque[dir];
|
||||
if (n !== 1 && n !== 5 && n % 100 !== 0) return;
|
||||
this._write({
|
||||
type: 'opaque_summary',
|
||||
seq: ++this._seq,
|
||||
t: new Date().toISOString(),
|
||||
dir,
|
||||
encryptedChunks: n,
|
||||
encryptedBytes: this._encryptedOpaque.bytes[dir],
|
||||
forwarded: 'tcp',
|
||||
note: 'Encrypted play traffic (map_chunk etc.) is forwarded but not decoded on a transparent pipe.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
this._write({
|
||||
type: 'opaque',
|
||||
seq: ++this._seq,
|
||||
t: new Date().toISOString(),
|
||||
dir,
|
||||
bytes,
|
||||
forwarded: 'tcp',
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
logPacket(dir, meta, data, rawBuffer, extra = {}) {
|
||||
const entry = {
|
||||
type: 'packet',
|
||||
seq: ++this._seq,
|
||||
t: new Date().toISOString(),
|
||||
dir,
|
||||
state: meta.state,
|
||||
name: meta.name,
|
||||
clientState: extra.clientState,
|
||||
upstreamState: extra.upstreamState,
|
||||
forwarded: extra.forwarded ?? null,
|
||||
};
|
||||
|
||||
if (rawBuffer) {
|
||||
entry.rawBytes = rawBuffer.length;
|
||||
}
|
||||
|
||||
if (this.includePayload && !LARGE_PACKETS.has(meta.name)) {
|
||||
entry.data = summarizePacket(meta.name, data);
|
||||
} else {
|
||||
entry.summary = summarizeLargePacket(meta.name, data, rawBuffer);
|
||||
}
|
||||
|
||||
this._write(entry);
|
||||
}
|
||||
|
||||
close(reason) {
|
||||
if (this._closed) return;
|
||||
this.writeMeta({ type: 'session_end', reason: reason || 'closed' });
|
||||
this._closed = true;
|
||||
this._stream.end();
|
||||
}
|
||||
|
||||
_write(obj) {
|
||||
if (this._closed) return;
|
||||
const line = JSON.stringify(obj);
|
||||
if (line.length + 1 <= MAX_INLINE_LINE) {
|
||||
this._writeRaw(line);
|
||||
return;
|
||||
}
|
||||
const spillFile = `line-${String(++this._spillCount).padStart(6, '0')}.jsonl`;
|
||||
fs.writeFileSync(path.join(this._spillDir, spillFile), `${line}\n`);
|
||||
this._writeRaw(JSON.stringify(buildSpillRef(obj, spillFile)));
|
||||
}
|
||||
|
||||
_writeRaw(line) {
|
||||
const ok = this._stream.write(`${line}\n`);
|
||||
if (!ok) {
|
||||
this._stream.once('drain', () => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildPreview(obj) {
|
||||
if (obj.type === 'packet') {
|
||||
const parts = [obj.dir, obj.state, obj.name].filter(Boolean).join(' ');
|
||||
const extra = obj.rawBytes != null ? ` ${obj.rawBytes}b` : '';
|
||||
const fwd = obj.forwarded ? ` →${obj.forwarded}` : '';
|
||||
return `${parts}${extra}${fwd}`.trim();
|
||||
}
|
||||
if (obj.type === 'session_start') {
|
||||
return `${obj.clientUsername ?? '?'} → ${obj.server ?? '?'}`;
|
||||
}
|
||||
if (obj.summary && typeof obj.summary === 'object') {
|
||||
const s = obj.summary;
|
||||
if (s.id) return `${s.name ?? 'packet'} id=${s.id}`;
|
||||
if (s.channel) return `channel=${s.channel}`;
|
||||
return JSON.stringify(s);
|
||||
}
|
||||
if (obj.reason) return String(obj.reason);
|
||||
if (obj.username) return String(obj.username);
|
||||
const compact = JSON.stringify(obj.data ?? obj);
|
||||
return compact.length > 48 ? `${compact.slice(0, 45)}…` : compact;
|
||||
}
|
||||
|
||||
/** Compact pointer + preview kept in the main log (≤ MAX_INLINE_LINE). */
|
||||
function buildSpillRef(obj, spillFile) {
|
||||
const ref = { _spill: spillFile, type: obj.type };
|
||||
if (obj.seq != null) ref.seq = obj.seq;
|
||||
ref.preview = buildPreview(obj);
|
||||
let encoded = JSON.stringify(ref);
|
||||
while (encoded.length > MAX_INLINE_LINE && ref.preview.length > 6) {
|
||||
ref.preview = `${ref.preview.slice(0, ref.preview.length - 3)}…`;
|
||||
encoded = JSON.stringify(ref);
|
||||
}
|
||||
return ref;
|
||||
}
|
||||
|
||||
function summarizePacket(name, data) {
|
||||
if (data == null) return data;
|
||||
if (Buffer.isBuffer(data)) {
|
||||
return { _type: 'Buffer', length: data.length };
|
||||
}
|
||||
if (typeof data !== 'object') return data;
|
||||
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(data, replacer));
|
||||
} catch {
|
||||
return { _type: 'Unserializable', name };
|
||||
}
|
||||
}
|
||||
|
||||
function replacer(_key, value) {
|
||||
if (Buffer.isBuffer(value)) {
|
||||
return { _type: 'Buffer', length: value.length };
|
||||
}
|
||||
if (typeof value === 'string' && value.length > 512) {
|
||||
return `${value.slice(0, 512)}…(${value.length} chars)`;
|
||||
}
|
||||
if (Array.isArray(value) && value.length > 32) {
|
||||
return { _type: 'Array', length: value.length, sample: value.slice(0, 3) };
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function summarizeLargePacket(name, data, rawBuffer) {
|
||||
const summary = { name };
|
||||
if (rawBuffer) summary.rawBytes = rawBuffer.length;
|
||||
if (!data || typeof data !== 'object') return summary;
|
||||
|
||||
if (name === 'map_chunk' || name === 'level_chunk_with_light') {
|
||||
summary.x = data.x;
|
||||
summary.z = data.z;
|
||||
if (data.groundUp != null) summary.groundUp = data.groundUp;
|
||||
} else if (name === 'registry_data') {
|
||||
summary.id = data.id;
|
||||
if (data.codec) summary.hasCodec = true;
|
||||
} else if (name === 'player_info') {
|
||||
summary.action = data.action;
|
||||
summary.entryCount = data.data?.length ?? 0;
|
||||
} else if (name === 'custom_payload') {
|
||||
summary.channel = data.channel;
|
||||
if (data.data) {
|
||||
summary.dataLength = Buffer.isBuffer(data.data) ? data.data.length : String(data.data).length;
|
||||
}
|
||||
} else {
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (Buffer.isBuffer(v)) summary[k] = { bytes: v.length };
|
||||
else if (typeof v === 'string' && v.length > 64) summary[k] = `${v.length} chars`;
|
||||
else summary[k] = v;
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
module.exports = { PacketLog };
|
||||
164
src/sniffer/StreamTap.js
Normal file
164
src/sniffer/StreamTap.js
Normal file
@@ -0,0 +1,164 @@
|
||||
const mc = require('minecraft-protocol');
|
||||
const { createSplitter } = require('minecraft-protocol/src/transforms/framing');
|
||||
const { createDeserializer } = require('minecraft-protocol/src/transforms/serializer');
|
||||
|
||||
const states = mc.states;
|
||||
|
||||
const S2C_CONFIGURATION_PACKETS = new Set([
|
||||
'registry_data',
|
||||
'feature_flags',
|
||||
'tags',
|
||||
'finish_configuration',
|
||||
'custom_payload',
|
||||
'reset_chat',
|
||||
'code_of_conduct',
|
||||
'server_data',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Parse-only tap: frames are split and parsed for logs; bytes are not modified.
|
||||
*/
|
||||
class StreamTap {
|
||||
constructor(dir, version, packetLog, hooks = {}) {
|
||||
this.dir = dir;
|
||||
this.version = version;
|
||||
this.packetLog = packetLog;
|
||||
this.hooks = hooks;
|
||||
this.session = hooks.session || {
|
||||
state: states.HANDSHAKING,
|
||||
compressionThreshold: -1,
|
||||
encrypted: false,
|
||||
};
|
||||
|
||||
this.state = this.session.state;
|
||||
this.stats = hooks.stats;
|
||||
|
||||
this.splitter = createSplitter();
|
||||
this._parsers = new Map();
|
||||
this.splitter.on('data', (frame) => this._onFrame(frame));
|
||||
}
|
||||
|
||||
feed(chunk) {
|
||||
if (this.session.encrypted) {
|
||||
this.stats.encryptedBytes[this.dir] += chunk.length;
|
||||
this.stats.encryptedChunks[this.dir] += 1;
|
||||
this.packetLog.logOpaque(this.dir, chunk.length, { encrypted: true });
|
||||
return;
|
||||
}
|
||||
this.stats.rawBytes[this.dir] += chunk.length;
|
||||
this.splitter.write(chunk);
|
||||
}
|
||||
|
||||
_syncState() {
|
||||
this.state = this.session.state;
|
||||
}
|
||||
|
||||
_parser() {
|
||||
this._syncState();
|
||||
const key = `${this.state}:${this.dir}`;
|
||||
if (!this._parsers.has(key)) {
|
||||
this._parsers.set(
|
||||
key,
|
||||
createDeserializer({
|
||||
state: this.state,
|
||||
isServer: this.dir === 'C2S',
|
||||
version: this.version,
|
||||
noErrorLogging: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return this._parsers.get(key);
|
||||
}
|
||||
|
||||
_parseFrame(frame) {
|
||||
const payload =
|
||||
this.session.compressionThreshold >= 0 ? this._decompressFrame(frame) : frame;
|
||||
return this._parser().parsePacketBuffer(payload);
|
||||
}
|
||||
|
||||
_decompressFrame(frame) {
|
||||
const { readVarInt } = require('protodef').types.varint;
|
||||
const zlib = require('zlib');
|
||||
const { value, size } = readVarInt(frame, 0);
|
||||
if (value === 0) return frame.slice(size);
|
||||
return zlib.inflateSync(frame.slice(size), { finishFlush: 2 });
|
||||
}
|
||||
|
||||
_onFrame(frame) {
|
||||
if (this.session.encrypted) return;
|
||||
|
||||
this.stats.frames[this.dir] += 1;
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = this._parseFrame(frame);
|
||||
} catch (err) {
|
||||
this.stats.parseErrors[this.dir] += 1;
|
||||
this.packetLog.logUnparsed(this.dir, this.state, frame, err.message);
|
||||
return;
|
||||
}
|
||||
if (!parsed) return;
|
||||
|
||||
const name = parsed.data.name;
|
||||
const data = parsed.data.params;
|
||||
this.stats.packets[this.dir] += 1;
|
||||
|
||||
this.packetLog.logPacket(this.dir, { state: this.state, name }, data, frame, {
|
||||
forwarded: 'tcp',
|
||||
});
|
||||
|
||||
if (name === 'login_start' && data.username) {
|
||||
this.hooks.onUsername?.(data.username);
|
||||
}
|
||||
if (name === 'set_protocol' && data.serverHost) {
|
||||
const mode = data.nextState === 1 ? 'status_ping' : data.nextState === 2 ? 'login' : `nextState_${data.nextState}`;
|
||||
this.packetLog.writeMeta({
|
||||
type: 'handshake_intent',
|
||||
mode,
|
||||
serverHost: data.serverHost,
|
||||
serverPort: data.serverPort,
|
||||
protocolVersion: data.protocolVersion,
|
||||
});
|
||||
}
|
||||
if ((name === 'set_compression' || name === 'compress') && data.threshold != null) {
|
||||
this.session.compressionThreshold = data.threshold;
|
||||
this._parsers.clear();
|
||||
this.packetLog.writeMeta({ type: 'compression', threshold: data.threshold, dir: this.dir });
|
||||
}
|
||||
// Encryption starts after the client sends its encryption response (C2S), not on S2C offer.
|
||||
if (this.dir === 'C2S' && name === 'encryption_begin') {
|
||||
this.session.encrypted = true;
|
||||
this.packetLog.writeMeta({ type: 'encryption_started', dir: this.dir });
|
||||
}
|
||||
|
||||
this._advanceState(name, data);
|
||||
}
|
||||
|
||||
_setState(next) {
|
||||
if (next === this.session.state) return;
|
||||
this.session.state = next;
|
||||
this.state = next;
|
||||
this._parsers.clear();
|
||||
}
|
||||
|
||||
_advanceState(name, data) {
|
||||
if (this.dir === 'C2S') {
|
||||
if (this.state === states.HANDSHAKING && name === 'set_protocol') {
|
||||
this._setState(data.nextState === 1 ? states.STATUS : states.LOGIN);
|
||||
} else if (this.state === states.LOGIN && name === 'login_acknowledged') {
|
||||
this._setState(states.CONFIGURATION);
|
||||
} else if (this.state === states.CONFIGURATION && name === 'finish_configuration') {
|
||||
this._setState(states.PLAY);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state === states.LOGIN && S2C_CONFIGURATION_PACKETS.has(name)) {
|
||||
this._setState(states.CONFIGURATION);
|
||||
} else if (this.state === states.CONFIGURATION && name === 'finish_configuration') {
|
||||
this._setState(states.PLAY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { StreamTap };
|
||||
176
src/sniffer/TransparentProxy.js
Normal file
176
src/sniffer/TransparentProxy.js
Normal file
@@ -0,0 +1,176 @@
|
||||
const net = require('net');
|
||||
const mc = require('minecraft-protocol');
|
||||
const { createLogger } = require('../utils/logger');
|
||||
const { PacketLog } = require('./PacketLog');
|
||||
const { StreamTap } = require('./StreamTap');
|
||||
|
||||
const log = createLogger('Sniffer');
|
||||
|
||||
/**
|
||||
* TCP-transparent proxy: bytes forwarded unchanged; tap parses for JSONL only.
|
||||
*/
|
||||
class TransparentProxy {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.server = null;
|
||||
this.activeSession = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
const sniffer = this.config.sniffer;
|
||||
const targetHost = this.config.server.host;
|
||||
const targetPort = this.config.server.port;
|
||||
|
||||
this.server = net.createServer((clientSocket) => {
|
||||
this._onClientConnect(clientSocket, targetHost, targetPort, sniffer);
|
||||
});
|
||||
|
||||
this.server.on('error', (err) => {
|
||||
log.error('Sniffer listen error:', err.message);
|
||||
});
|
||||
|
||||
this.server.listen(sniffer.port, sniffer.host || '0.0.0.0', () => {
|
||||
log.info(
|
||||
`TCP sniffer on ${sniffer.host || '0.0.0.0'}:${sniffer.port} → ${targetHost}:${targetPort} (${this.config.server.version})`,
|
||||
);
|
||||
log.info('Join the server in-game (not just refresh the server list)');
|
||||
log.info(`Logs: ${sniffer.logDir}`);
|
||||
});
|
||||
}
|
||||
|
||||
_onClientConnect(clientSocket, targetHost, targetPort, sniffer) {
|
||||
const addr = clientSocket.remoteAddress || '?';
|
||||
|
||||
if (this.activeSession) {
|
||||
log.warn(`Rejecting connection from ${addr} — session already active`);
|
||||
clientSocket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
let clientUsername = 'unknown';
|
||||
const packetLog = new PacketLog({
|
||||
logDir: sniffer.logDir,
|
||||
sessionId: `session-${Date.now()}`,
|
||||
clientUsername,
|
||||
server: `${targetHost}:${targetPort}`,
|
||||
version: this.config.server.version,
|
||||
includePayload: sniffer.includePayload,
|
||||
});
|
||||
|
||||
const session = {
|
||||
state: mc.states.HANDSHAKING,
|
||||
compressionThreshold: -1,
|
||||
encrypted: false,
|
||||
};
|
||||
const stats = {
|
||||
rawBytes: { C2S: 0, S2C: 0 },
|
||||
frames: { C2S: 0, S2C: 0 },
|
||||
packets: { C2S: 0, S2C: 0 },
|
||||
parseErrors: { C2S: 0, S2C: 0 },
|
||||
encryptedBytes: { C2S: 0, S2C: 0 },
|
||||
encryptedChunks: { C2S: 0, S2C: 0 },
|
||||
};
|
||||
|
||||
const c2sTap = new StreamTap('C2S', this.config.server.version, packetLog, {
|
||||
session,
|
||||
stats,
|
||||
onUsername: (name) => {
|
||||
clientUsername = name;
|
||||
packetLog.writeMeta({ type: 'username', username: name });
|
||||
},
|
||||
});
|
||||
const s2cTap = new StreamTap('S2C', this.config.server.version, packetLog, { session, stats });
|
||||
|
||||
const upstreamSocket = net.connect({ host: targetHost, port: targetPort });
|
||||
let upstreamReady = false;
|
||||
const pendingToUpstream = [];
|
||||
|
||||
this.activeSession = { clientSocket, upstreamSocket, packetLog };
|
||||
|
||||
let cleaned = false;
|
||||
const cleanup = (reason) => {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
packetLog.writeMeta({
|
||||
type: 'session_stats',
|
||||
reason,
|
||||
username: clientUsername,
|
||||
protocolState: session.state,
|
||||
encrypted: session.encrypted,
|
||||
stats,
|
||||
});
|
||||
try { clientSocket.destroy(); } catch (_) {}
|
||||
try { upstreamSocket.destroy(); } catch (_) {}
|
||||
packetLog.close(reason);
|
||||
this.activeSession = null;
|
||||
};
|
||||
|
||||
const flushUpstream = () => {
|
||||
for (const buf of pendingToUpstream) {
|
||||
upstreamSocket.write(buf);
|
||||
}
|
||||
pendingToUpstream.length = 0;
|
||||
};
|
||||
|
||||
clientSocket.on('data', (chunk) => {
|
||||
if (upstreamReady && !upstreamSocket.destroyed) {
|
||||
upstreamSocket.write(chunk);
|
||||
} else {
|
||||
pendingToUpstream.push(chunk);
|
||||
}
|
||||
c2sTap.feed(chunk);
|
||||
});
|
||||
|
||||
upstreamSocket.on('data', (chunk) => {
|
||||
if (!clientSocket.destroyed) clientSocket.write(chunk);
|
||||
s2cTap.feed(chunk);
|
||||
});
|
||||
|
||||
upstreamSocket.on('connect', () => {
|
||||
upstreamReady = true;
|
||||
flushUpstream();
|
||||
log.info(`Upstream TCP connected (${addr} → ${targetHost}:${targetPort})`);
|
||||
packetLog.writeMeta({ type: 'upstream_connect' });
|
||||
});
|
||||
|
||||
upstreamSocket.on('error', (err) => {
|
||||
log.error(`Upstream error: ${err.message}`);
|
||||
cleanup('upstream_error');
|
||||
});
|
||||
|
||||
clientSocket.on('error', (err) => {
|
||||
log.error(`Client socket error: ${err.message}`);
|
||||
cleanup('client_error');
|
||||
});
|
||||
|
||||
upstreamSocket.on('close', () => {
|
||||
log.info(`Session ended (upstream closed) ${clientUsername} (${addr})`);
|
||||
cleanup('upstream_close');
|
||||
});
|
||||
|
||||
clientSocket.on('close', () => {
|
||||
if (!cleaned) {
|
||||
log.info(`Session ended (client closed) ${clientUsername} (${addr})`);
|
||||
cleanup('client_close');
|
||||
}
|
||||
});
|
||||
|
||||
log.info(`Client connected ${addr} — logging to ${packetLog.filePath}`);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.activeSession) {
|
||||
const { clientSocket, upstreamSocket, packetLog } = this.activeSession;
|
||||
try { clientSocket.destroy(); } catch (_) {}
|
||||
try { upstreamSocket.destroy(); } catch (_) {}
|
||||
packetLog.close('shutdown');
|
||||
this.activeSession = null;
|
||||
}
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
this.server = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { TransparentProxy };
|
||||
47
src/sniffer/index.js
Normal file
47
src/sniffer/index.js
Normal file
@@ -0,0 +1,47 @@
|
||||
const path = require('path');
|
||||
const { loadConfig } = require('../config');
|
||||
const { createLogger } = require('../utils/logger');
|
||||
const { MitmProxy } = require('./MitmProxy');
|
||||
|
||||
const log = createLogger('SnifferMain');
|
||||
|
||||
console.log(`
|
||||
\x1b[36m Packet Sniffer Proxy\x1b[0m
|
||||
MITM mode — decrypt both legs, log packet names
|
||||
`);
|
||||
|
||||
let config;
|
||||
try {
|
||||
config = loadConfig();
|
||||
config.sniffer = Object.assign(
|
||||
{
|
||||
host: '0.0.0.0',
|
||||
port: 25567,
|
||||
onlineMode: false,
|
||||
upstreamAuth: 'microsoft',
|
||||
logDir: path.join(__dirname, '..', '..', 'logs', 'sniffer'),
|
||||
includePayload: true,
|
||||
},
|
||||
config.sniffer,
|
||||
);
|
||||
log.info(`Upstream: ${config.server.host}:${config.server.port} (${config.server.version})`);
|
||||
log.info(`Listen: ${config.sniffer.host || '0.0.0.0'}:${config.sniffer.port}`);
|
||||
log.info(`Client online-mode: ${config.sniffer.onlineMode}`);
|
||||
log.info(`Upstream auth: ${config.sniffer.upstreamAuth}`);
|
||||
log.info(`Logs: ${path.resolve(config.sniffer.logDir)}`);
|
||||
} catch (err) {
|
||||
log.error(err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const proxy = new MitmProxy(config);
|
||||
proxy.start();
|
||||
|
||||
function shutdown(signal) {
|
||||
log.info(`${signal}, shutting down...`);
|
||||
proxy.stop();
|
||||
setTimeout(() => process.exit(0), 500);
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
69
src/sniffer/mitmEncryption.js
Normal file
69
src/sniffer/mitmEncryption.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const crypto = require('crypto');
|
||||
const NodeRSA = require('node-rsa');
|
||||
|
||||
/**
|
||||
* Offer encryption_begin to the Java client using the sniffer's RSA key (MITM leg).
|
||||
* @param {import('minecraft-protocol').Client} client - server-side peer (Java)
|
||||
* @param {import('minecraft-protocol').Server} server
|
||||
* @param {object} options - createServer options
|
||||
* @returns {Promise<void>} resolves when Java leg encryption is active
|
||||
*/
|
||||
function enableJavaEncryption(client, server, options) {
|
||||
const onlineMode = options['online-mode'] === true;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const serverId = onlineMode ? crypto.randomBytes(4).toString('hex') : '-';
|
||||
client.verifyToken = crypto.randomBytes(4);
|
||||
const publicKeyStrArr = server.serverKey.exportKey('pkcs8-public-pem').split('\n');
|
||||
let publicKeyStr = '';
|
||||
for (let i = 1; i < publicKeyStrArr.length - 1; i++) {
|
||||
publicKeyStr += publicKeyStrArr[i];
|
||||
}
|
||||
client.publicKey = Buffer.from(publicKeyStr, 'base64');
|
||||
|
||||
client.once('encryption_begin', (packet) => {
|
||||
try {
|
||||
const sharedSecret = decryptSharedSecret(server, client, packet);
|
||||
client.setEncryption(sharedSecret);
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
client.write('encryption_begin', {
|
||||
serverId,
|
||||
publicKey: client.publicKey,
|
||||
verifyToken: client.verifyToken,
|
||||
shouldAuthenticate: onlineMode,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function decryptSharedSecret(server, client, packet) {
|
||||
const keyRsa = new NodeRSA(server.serverKey.exportKey('pkcs1'), 'private', {
|
||||
encryptionScheme: 'pkcs1',
|
||||
});
|
||||
keyRsa.setOptions({ environment: 'browser' });
|
||||
|
||||
if (packet.hasVerifyToken === false && packet.crypto) {
|
||||
const { concat } = require('minecraft-protocol/src/transforms/binaryStream');
|
||||
const signable = concat('buffer', client.verifyToken, 'i64', packet.crypto.salt);
|
||||
if (
|
||||
client.profileKeys &&
|
||||
!crypto.verify('sha256WithRSAEncryption', signable, client.profileKeys.public, packet.crypto.messageSignature)
|
||||
) {
|
||||
throw new Error('invalid_public_key_signature');
|
||||
}
|
||||
} else if (packet.verifyToken != null) {
|
||||
const encryptedToken = packet.hasVerifyToken ? packet.crypto?.verifyToken : packet.verifyToken;
|
||||
const decryptedToken = keyRsa.decrypt(encryptedToken);
|
||||
if (!client.verifyToken.equals(decryptedToken)) {
|
||||
throw new Error('invalid_verify_token');
|
||||
}
|
||||
}
|
||||
|
||||
return keyRsa.decrypt(packet.sharedSecret);
|
||||
}
|
||||
|
||||
module.exports = { enableJavaEncryption };
|
||||
198
src/sniffer/mitmGate.js
Normal file
198
src/sniffer/mitmGate.js
Normal file
@@ -0,0 +1,198 @@
|
||||
const mc = require('minecraft-protocol');
|
||||
const { relayToJava } = require('./mitmRelay');
|
||||
|
||||
const states = mc.states;
|
||||
|
||||
/** Java client join phases after upstream login (1.20.2+ configuration). */
|
||||
const GATE = {
|
||||
LOGIN: 'login',
|
||||
AWAIT_LOGIN_ACK: 'await_login_ack',
|
||||
CONFIGURATION: 'configuration',
|
||||
PLAY: 'play',
|
||||
};
|
||||
|
||||
const PREBRIDGE_C2S = new Set(['login_plugin_response', 'cookie_response']);
|
||||
|
||||
/** Upstream mc client (keepAlive: true) auto-responds; relaying from Java duplicates and kicks. */
|
||||
const UPSTREAM_OWNED_C2S = new Set(['keep_alive']);
|
||||
|
||||
/**
|
||||
* Upstream is a separate mc client that already completed its own login/config.
|
||||
* Only relay Java C2S once both legs are in play.
|
||||
*/
|
||||
function canRelayC2S(session, meta) {
|
||||
if (UPSTREAM_OWNED_C2S.has(meta.name)) return false;
|
||||
if (session.gate === GATE.PLAY) return true;
|
||||
return PREBRIDGE_C2S.has(meta.name);
|
||||
}
|
||||
|
||||
function c2sForwardLabel(session, meta) {
|
||||
if (UPSTREAM_OWNED_C2S.has(meta.name)) return 'blocked';
|
||||
if (canRelayC2S(session, meta)) return 'mitm';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
/** How to handle upstream S2C for logging and routing. */
|
||||
function classifyS2C(session, meta) {
|
||||
if (session.gate === GATE.PLAY) return 'relay';
|
||||
if (session.gate === GATE.CONFIGURATION && meta.state === states.CONFIGURATION) return 'relay';
|
||||
if (shouldBufferS2C(session, meta)) return 'buffer';
|
||||
return 'hold';
|
||||
}
|
||||
|
||||
function shouldBufferS2C(session, meta) {
|
||||
if (session.gate === GATE.PLAY) return false;
|
||||
if (session.gate === GATE.CONFIGURATION) return meta.state === states.PLAY;
|
||||
if (session.gate === GATE.AWAIT_LOGIN_ACK) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function flushQueue(session, queue) {
|
||||
for (const { data, meta, buffer } of queue) {
|
||||
try {
|
||||
relayToJava(session.client, meta, data, buffer);
|
||||
} catch (err) {
|
||||
throw new Error(`${meta.state}.${meta.name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
queue.length = 0;
|
||||
}
|
||||
|
||||
function flushPendingConfig(session) {
|
||||
flushQueue(session, session.pendingConfig);
|
||||
}
|
||||
|
||||
/** Join-critical play packets before terrain chunks. */
|
||||
const PLAY_JOIN_ORDER = {
|
||||
login: 0,
|
||||
custom_payload: 1,
|
||||
server_data: 2,
|
||||
difficulty: 3,
|
||||
abilities: 4,
|
||||
held_item_slot: 5,
|
||||
recipe_book_settings: 6,
|
||||
recipe_book_add: 7,
|
||||
entity_status: 8,
|
||||
declare_recipes: 9,
|
||||
position: 10,
|
||||
player_info: 11,
|
||||
update_view_distance: 12,
|
||||
simulation_distance: 13,
|
||||
spawn_position: 14,
|
||||
initialize_world_border: 15,
|
||||
update_time: 16,
|
||||
game_state_change: 17,
|
||||
set_ticking_state: 18,
|
||||
step_tick: 19,
|
||||
window_items: 20,
|
||||
set_slot: 21,
|
||||
system_chat: 22,
|
||||
declare_commands: 23,
|
||||
update_health: 24,
|
||||
experience: 25,
|
||||
};
|
||||
|
||||
const PLAY_CHUNK_PACKETS = new Set([
|
||||
'map_chunk',
|
||||
'update_light',
|
||||
'unload_chunk',
|
||||
'chunk_batch_start',
|
||||
'chunk_batch_finished',
|
||||
]);
|
||||
|
||||
function sortPlayPending(pending) {
|
||||
pending.sort((a, b) => {
|
||||
const oa = PLAY_JOIN_ORDER[a.meta.name] ?? 100;
|
||||
const ob = PLAY_JOIN_ORDER[b.meta.name] ?? 100;
|
||||
return oa - ob;
|
||||
});
|
||||
}
|
||||
|
||||
function isStalePlayS2C(meta) {
|
||||
return meta.name === 'keep_alive';
|
||||
}
|
||||
|
||||
function flushPendingPlay(session) {
|
||||
sortPlayPending(session.pendingPlay);
|
||||
const join = [];
|
||||
const world = [];
|
||||
for (const item of session.pendingPlay) {
|
||||
if (isStalePlayS2C(item.meta)) continue;
|
||||
if (PLAY_CHUNK_PACKETS.has(item.meta.name)) world.push(item);
|
||||
else join.push(item);
|
||||
}
|
||||
session.pendingPlay.length = 0;
|
||||
flushQueue(session, join);
|
||||
if (world.length) {
|
||||
setImmediate(() => {
|
||||
flushQueue(session, world);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function queueBufferedS2C(session, data, meta, buffer) {
|
||||
if (isStalePlayS2C(meta)) return;
|
||||
const item = { data, meta, buffer };
|
||||
if (meta.state === states.PLAY) {
|
||||
session.pendingPlay.push(item);
|
||||
} else {
|
||||
session.pendingConfig.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
function hasPendingSuccess(session) {
|
||||
return session.pendingS2C.some((p) => p.meta.name === 'success');
|
||||
}
|
||||
|
||||
function onJavaLoginAcknowledged(session) {
|
||||
if (session.gate !== GATE.AWAIT_LOGIN_ACK) return false;
|
||||
session.client.state = states.CONFIGURATION;
|
||||
session.gate = GATE.CONFIGURATION;
|
||||
flushPendingConfig(session);
|
||||
return true;
|
||||
}
|
||||
|
||||
function onJavaFinishConfiguration(session, packetLog) {
|
||||
if (session.gate !== GATE.CONFIGURATION) return false;
|
||||
session.client.state = states.PLAY;
|
||||
session.gate = GATE.PLAY;
|
||||
session.bridged = true;
|
||||
flushPendingPlay(session);
|
||||
packetLog.writeMeta({ type: 'bridge_active' });
|
||||
return true;
|
||||
}
|
||||
|
||||
function queueHeldS2C(session, data, meta, buffer) {
|
||||
const item = { data, meta, buffer };
|
||||
if (meta.state === states.CONFIGURATION) {
|
||||
session.pendingConfig.push(item);
|
||||
} else {
|
||||
session.pendingS2C.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
function partitionAfterCrypto(pendingS2C) {
|
||||
const login = [];
|
||||
const config = [];
|
||||
for (const item of pendingS2C) {
|
||||
if (item.meta.state === states.CONFIGURATION) config.push(item);
|
||||
else login.push(item);
|
||||
}
|
||||
return { login, config };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GATE,
|
||||
canRelayC2S,
|
||||
c2sForwardLabel,
|
||||
classifyS2C,
|
||||
shouldBufferS2C,
|
||||
hasPendingSuccess,
|
||||
flushPendingConfig,
|
||||
flushPendingPlay,
|
||||
onJavaLoginAcknowledged,
|
||||
onJavaFinishConfiguration,
|
||||
queueHeldS2C,
|
||||
queueBufferedS2C,
|
||||
partitionAfterCrypto,
|
||||
};
|
||||
54
src/sniffer/mitmLogin.js
Normal file
54
src/sniffer/mitmLogin.js
Normal file
@@ -0,0 +1,54 @@
|
||||
const crypto = require('crypto');
|
||||
const uuid = require('minecraft-protocol/src/datatypes/uuid');
|
||||
const { concat } = require('minecraft-protocol/src/transforms/binaryStream');
|
||||
const { mojangPublicKeyPem } = require('minecraft-protocol/src/server/constants');
|
||||
|
||||
/**
|
||||
* Apply login_start fields without completing local login (no success packet).
|
||||
*/
|
||||
function applyLoginStartIdentity(client, packet, _server, options) {
|
||||
const mcData = require('minecraft-data')(client.version);
|
||||
client.supportFeature = mcData.supportFeature;
|
||||
client.username = packet.username;
|
||||
|
||||
if (packet.playerUUID) {
|
||||
client.uuid = packet.playerUUID;
|
||||
}
|
||||
|
||||
if (packet.signature && mcData.supportFeature('signatureEncryption')) {
|
||||
if (packet.signature.timestamp < BigInt(Date.now())) {
|
||||
throw new Error('expired_public_key');
|
||||
}
|
||||
const publicKey = crypto.createPublicKey({
|
||||
key: packet.signature.publicKey,
|
||||
format: 'der',
|
||||
type: 'spki',
|
||||
});
|
||||
const signable = mcData.supportFeature('profileKeySignatureV2')
|
||||
? concat('UUID', packet.playerUUID, 'i64', packet.signature.timestamp, 'buffer', publicKey.export({ type: 'spki', format: 'der' }))
|
||||
: Buffer.from(`${packet.signature.timestamp}${publicKeyToPem(packet.signature.publicKey)}`, 'utf8');
|
||||
|
||||
if (!crypto.verify('RSA-SHA1', signable, crypto.createPublicKey(mojangPublicKeyPem), packet.signature.signature)) {
|
||||
throw new Error('invalid_public_key_signature');
|
||||
}
|
||||
client.profileKeys = { public: publicKey };
|
||||
}
|
||||
|
||||
if (options['online-mode'] !== true && !client.uuid) {
|
||||
client.uuid = uuid.nameToMcOfflineUUID(client.username);
|
||||
}
|
||||
}
|
||||
|
||||
function publicKeyToPem(mcPubKeyBuffer) {
|
||||
let pem = '-----BEGIN RSA PUBLIC KEY-----\n';
|
||||
let base64PubKey = mcPubKeyBuffer.toString('base64');
|
||||
const maxLineLength = 64;
|
||||
while (base64PubKey.length > 0) {
|
||||
pem += `${base64PubKey.substring(0, maxLineLength)}\n`;
|
||||
base64PubKey = base64PubKey.substring(maxLineLength);
|
||||
}
|
||||
pem += '-----END RSA PUBLIC KEY-----\n';
|
||||
return pem;
|
||||
}
|
||||
|
||||
module.exports = { applyLoginStartIdentity };
|
||||
108
src/sniffer/mitmRelay.js
Normal file
108
src/sniffer/mitmRelay.js
Normal file
@@ -0,0 +1,108 @@
|
||||
const { RAW_FORWARD_PACKETS } = require('../constants/rawPackets');
|
||||
|
||||
/** Configuration packets forwarded with writeRaw (byte-identical NBT). */
|
||||
const CONFIG_RAW_PACKETS = new Set([
|
||||
'registry_data',
|
||||
'feature_flags',
|
||||
'tags',
|
||||
'finish_configuration',
|
||||
'custom_payload',
|
||||
'reset_chat',
|
||||
'code_of_conduct',
|
||||
'server_data',
|
||||
]);
|
||||
|
||||
const COMPRESS_PACKETS = new Set(['compress', 'set_compression']);
|
||||
|
||||
function shouldWriteRaw(meta, buffer) {
|
||||
if (!buffer || buffer.length === 0) return false;
|
||||
if (meta.state === 'configuration' && CONFIG_RAW_PACKETS.has(meta.name)) return true;
|
||||
if (meta.state === 'play' && RAW_FORWARD_PACKETS.has(meta.name)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('minecraft-protocol').Client} target
|
||||
*/
|
||||
function relayPacket(target, meta, data, buffer) {
|
||||
if (shouldWriteRaw(meta, buffer)) {
|
||||
target.writeRaw(buffer);
|
||||
return 'raw';
|
||||
}
|
||||
// Large login success (skins) must stay byte-identical once compression is negotiated.
|
||||
if (buffer?.length && meta.state === 'login' && meta.name === 'success') {
|
||||
target.writeRaw(buffer);
|
||||
return 'raw';
|
||||
}
|
||||
// Play-phase MITM: prefer wire-identical bytes (movement, chat, etc.).
|
||||
if (buffer?.length && meta.state === 'play') {
|
||||
target.writeRaw(buffer);
|
||||
return 'raw';
|
||||
}
|
||||
target.write(meta.name, data, meta.state);
|
||||
return 'parsed';
|
||||
}
|
||||
|
||||
function syncCompression(target, name, data) {
|
||||
if (!COMPRESS_PACKETS.has(name) || data.threshold == null) return;
|
||||
target.compressionThreshold = data.threshold;
|
||||
}
|
||||
|
||||
/** Login-phase S2C order the Java client expects (lower = earlier). */
|
||||
const LOGIN_FORWARD_ORDER = {
|
||||
compress: 0,
|
||||
encryption_begin: 1,
|
||||
success: 2,
|
||||
login_plugin_request: 3,
|
||||
cookie_request: 4,
|
||||
disconnect: 99,
|
||||
};
|
||||
|
||||
function sortLoginPending(pending) {
|
||||
pending.sort((a, b) => {
|
||||
const oa = LOGIN_FORWARD_ORDER[a.meta.name] ?? 50;
|
||||
const ob = LOGIN_FORWARD_ORDER[b.meta.name] ?? 50;
|
||||
return oa - ob;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Login compress must hit the wire before the compressor is enabled; otherwise
|
||||
* writeRaw adds a spurious 0-length prefix and the client fails to decode.
|
||||
*/
|
||||
function relayLoginCompressToJava(client, meta, data, buffer) {
|
||||
if (client.cipher != null) {
|
||||
return 'skipped_late_compress';
|
||||
}
|
||||
if (client.compressor != null) {
|
||||
syncCompression(client, meta.name, data);
|
||||
return relayPacket(client, meta, data, buffer);
|
||||
}
|
||||
if (buffer?.length) {
|
||||
client.writeRaw(buffer);
|
||||
} else {
|
||||
client.write(meta.name, data, meta.state);
|
||||
}
|
||||
syncCompression(client, meta.name, data);
|
||||
return 'login_compress';
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward S2C to Java; skip late login compress after encryption is already on.
|
||||
*/
|
||||
function relayToJava(client, meta, data, buffer) {
|
||||
if (meta.name === 'compress' && meta.state === 'login') {
|
||||
return relayLoginCompressToJava(client, meta, data, buffer);
|
||||
}
|
||||
return relayPacket(client, meta, data, buffer);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CONFIG_RAW_PACKETS,
|
||||
shouldWriteRaw,
|
||||
relayPacket,
|
||||
syncCompression,
|
||||
sortLoginPending,
|
||||
relayLoginCompressToJava,
|
||||
relayToJava,
|
||||
};
|
||||
59
src/sniffer/mitmSession.js
Normal file
59
src/sniffer/mitmSession.js
Normal file
@@ -0,0 +1,59 @@
|
||||
const { GATE } = require('./mitmGate');
|
||||
|
||||
/**
|
||||
* Build the per-connection MITM session state bag.
|
||||
* @param {object} client - minecraft-protocol client
|
||||
* @param {import('./PacketLog').PacketLog} packetLog
|
||||
* @returns {object} session state
|
||||
*/
|
||||
function createMitmSession(client, packetLog) {
|
||||
return {
|
||||
client,
|
||||
upstream: null,
|
||||
bridged: false,
|
||||
gate: GATE.LOGIN,
|
||||
holdS2C: false,
|
||||
pendingS2C: [],
|
||||
pendingConfig: [],
|
||||
pendingPlay: [],
|
||||
waitingJavaCrypto: false,
|
||||
javaCryptoStarting: false,
|
||||
relayedCompress: false,
|
||||
statusPipe: null,
|
||||
packetLog,
|
||||
username: 'unknown',
|
||||
cleaned: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the cleanup closure for a MITM session.
|
||||
* @param {object} session
|
||||
* @param {import('./PacketLog').PacketLog} packetLog
|
||||
* @param {{ activeSession: object|null }} proxy - MitmProxy instance (mutated on cleanup)
|
||||
* @returns {function(string): void}
|
||||
*/
|
||||
function createSessionCleanup(session, packetLog, proxy) {
|
||||
return (reason) => {
|
||||
if (session.cleaned) return;
|
||||
session.cleaned = true;
|
||||
|
||||
if (session.statusPipe) {
|
||||
try { session.statusPipe.client.destroy(); } catch (_) {}
|
||||
try { session.statusPipe.upstream.destroy(); } catch (_) {}
|
||||
}
|
||||
if (session.upstream && !session.upstream.ended) {
|
||||
try { session.upstream.end(reason); } catch (_) {}
|
||||
}
|
||||
packetLog.writeMeta({
|
||||
type: 'session_stats',
|
||||
reason,
|
||||
username: session.username,
|
||||
bridged: session.bridged,
|
||||
});
|
||||
packetLog.close(reason);
|
||||
if (proxy.activeSession === session) proxy.activeSession = null;
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createMitmSession, createSessionCleanup };
|
||||
155
src/sniffer/mitmUpstream.js
Normal file
155
src/sniffer/mitmUpstream.js
Normal file
@@ -0,0 +1,155 @@
|
||||
const net = require('net');
|
||||
const mc = require('minecraft-protocol');
|
||||
const { createLogger } = require('../utils/logger');
|
||||
const { relayToJava, syncCompression } = require('./mitmRelay');
|
||||
const { classifyS2C, queueHeldS2C, queueBufferedS2C } = require('./mitmGate');
|
||||
|
||||
const log = createLogger('Sniffer');
|
||||
const states = mc.states;
|
||||
|
||||
/**
|
||||
* Pipe a status/ping handshake directly at the TCP level (no decryption needed).
|
||||
* @param {object} session
|
||||
* @param {{ server: { host: string, port: number } }} config
|
||||
* @param {import('./PacketLog').PacketLog} packetLog
|
||||
* @param {{ activeSession: object|null }} proxy - MitmProxy instance
|
||||
*/
|
||||
function startStatusPipe(session, config, packetLog, proxy) {
|
||||
const { host, port } = config.server;
|
||||
const upstream = net.connect({ host, port });
|
||||
session.statusPipe = { client: session.client.socket, upstream };
|
||||
|
||||
packetLog.writeMeta({ type: 'handshake_intent', mode: 'status_ping' });
|
||||
|
||||
session.client.socket.pipe(upstream);
|
||||
upstream.pipe(session.client.socket);
|
||||
|
||||
upstream.on('connect', () => packetLog.writeMeta({ type: 'upstream_connect', mode: 'status_tcp' }));
|
||||
let statusDone = false;
|
||||
const endStatus = () => {
|
||||
if (statusDone) return;
|
||||
statusDone = true;
|
||||
if (proxy.activeSession === session) proxy.activeSession = null;
|
||||
packetLog.close('status_done');
|
||||
};
|
||||
upstream.on('close', endStatus);
|
||||
session.client.socket.on('close', endStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the upstream mc.createClient and wire the S2C packet relay.
|
||||
* @param {object} session
|
||||
* @param {{ server: { host: string, port: number, version: string }, sniffer: { upstreamAuth?: string } }} config
|
||||
* @param {function(string): void} cleanup
|
||||
* @param {object} callbacks - { onCompressBeforeCrypto, onEncryptionBegin, onSuccessNoEncryption }
|
||||
*/
|
||||
function startUpstream(session, config, cleanup, callbacks) {
|
||||
const { host, port, version } = config.server;
|
||||
const auth = config.sniffer.upstreamAuth || 'microsoft';
|
||||
|
||||
const upstream = mc.createClient({
|
||||
host,
|
||||
port,
|
||||
username: session.username,
|
||||
version,
|
||||
auth,
|
||||
hideErrors: true,
|
||||
keepAlive: true,
|
||||
checkTimeoutInterval: 60000,
|
||||
});
|
||||
session.upstream = upstream;
|
||||
|
||||
upstream.on('connect', () => {
|
||||
log.info(`Upstream connected for ${session.username}`);
|
||||
session.packetLog.writeMeta({ type: 'upstream_connect' });
|
||||
});
|
||||
|
||||
upstream.on('packet', (data, meta, buffer) => {
|
||||
const s2cAction = classifyS2C(session, meta);
|
||||
session.packetLog.logPacket('S2C', meta, data, buffer, {
|
||||
forwarded: s2cAction,
|
||||
clientState: session.client.state,
|
||||
upstreamState: upstream.state,
|
||||
gate: session.gate,
|
||||
});
|
||||
syncCompression(upstream, meta.name, data);
|
||||
|
||||
if (s2cAction === 'relay') {
|
||||
try {
|
||||
relayToJava(session.client, meta, data, buffer);
|
||||
} catch (err) {
|
||||
log.error(`S2C relay error (${meta.name}):`, err.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (s2cAction === 'buffer') {
|
||||
queueBufferedS2C(session, data, meta, buffer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.gate !== callbacks.GATE_LOGIN) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (meta.name === 'compress' && meta.state === states.LOGIN) {
|
||||
session.relayedCompress = true;
|
||||
try {
|
||||
relayToJava(session.client, meta, data, buffer);
|
||||
} catch (err) {
|
||||
log.error(`S2C compress error:`, err.message);
|
||||
}
|
||||
callbacks.onCompressBeforeCrypto(session);
|
||||
return;
|
||||
}
|
||||
|
||||
if (meta.name === 'encryption_begin') {
|
||||
session.holdS2C = true;
|
||||
session.waitingJavaCrypto = true;
|
||||
callbacks.onEncryptionBegin(session);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.holdS2C) {
|
||||
queueHeldS2C(session, data, meta, buffer);
|
||||
if (meta.name === 'success') {
|
||||
callbacks.onSuccessWhileHeld(session);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (meta.name === 'success') {
|
||||
try {
|
||||
relayToJava(session.client, meta, data, buffer);
|
||||
callbacks.onSuccessNoEncryption(session);
|
||||
} catch (err) {
|
||||
log.error(`S2C success error:`, err.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
relayToJava(session.client, meta, data, buffer);
|
||||
} catch (err) {
|
||||
log.error(`S2C relay error (${meta.name}):`, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
upstream.on('end', () => {
|
||||
log.info(`Upstream closed for ${session.username}`);
|
||||
if (!session.cleaned && !session.client.ended) {
|
||||
try { session.client.end('Upstream disconnected'); } catch (_) {}
|
||||
}
|
||||
cleanup('upstream_end');
|
||||
});
|
||||
|
||||
upstream.on('error', (err) => {
|
||||
log.error(`Upstream error: ${err.message}`);
|
||||
if (!session.cleaned && !session.client.ended) {
|
||||
try { session.client.end(err.message); } catch (_) {}
|
||||
}
|
||||
cleanup('upstream_error');
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { startStatusPipe, startUpstream };
|
||||
160
src/state/ChunkCache.js
Normal file
160
src/state/ChunkCache.js
Normal file
@@ -0,0 +1,160 @@
|
||||
const { createLogger } = require('../utils/logger');
|
||||
const log = createLogger('ChunkCache');
|
||||
|
||||
/**
|
||||
* Caches chunk column data keyed by "x,z".
|
||||
* Stores the raw packet data so we can replay it directly to a connecting client.
|
||||
*/
|
||||
class ChunkCache {
|
||||
constructor(maxChunks = 1024) {
|
||||
this.maxChunks = maxChunks;
|
||||
/** @type {Map<string, object>} key "x,z" -> raw packet data */
|
||||
this.chunks = new Map();
|
||||
/** @type {Map<string, object>} key "x,z" -> update_light packet data */
|
||||
this.lights = new Map();
|
||||
/** Track access order for LRU eviction */
|
||||
this.accessOrder = [];
|
||||
}
|
||||
|
||||
/** @returns {string} */
|
||||
_key(x, z) {
|
||||
return `${x},${z}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a chunk from a map_chunk packet.
|
||||
* We store the entire packet data object so we can replay it verbatim.
|
||||
*/
|
||||
handleMapChunk(data, rawBuffer) {
|
||||
const key = this._key(data.x, data.z);
|
||||
this.chunks.set(key, {
|
||||
packetData: structuredClone(data),
|
||||
rawBuffer: rawBuffer ? Buffer.from(rawBuffer) : null,
|
||||
});
|
||||
this._touch(key);
|
||||
this._evictIfNeeded();
|
||||
}
|
||||
|
||||
handleUpdateLight(data, rawBuffer) {
|
||||
const key = this._key(data.chunkX, data.chunkZ);
|
||||
this.lights.set(key, {
|
||||
packetData: structuredClone(data),
|
||||
rawBuffer: rawBuffer ? Buffer.from(rawBuffer) : null,
|
||||
});
|
||||
if (this.chunks.has(key)) {
|
||||
this._touch(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle chunk unload — remove from cache.
|
||||
*/
|
||||
handleUnloadChunk(data) {
|
||||
const key = this._key(data.chunkX, data.chunkZ);
|
||||
this.chunks.delete(key);
|
||||
this.lights.delete(key);
|
||||
this.accessOrder = this.accessOrder.filter(k => k !== key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a single block_change to the cached chunk data.
|
||||
* We don't modify the raw chunk buffer — instead we store block changes
|
||||
* as a separate overlay. On replay, we send chunks then block changes.
|
||||
*/
|
||||
handleBlockChange(data) {
|
||||
const chunkX = Math.floor(data.location.x / 16);
|
||||
const chunkZ = Math.floor(data.location.z / 16);
|
||||
const key = this._key(chunkX, chunkZ);
|
||||
const stored = this.chunks.get(key);
|
||||
if (stored) {
|
||||
if (!stored._blockChanges) stored._blockChanges = [];
|
||||
stored._blockChanges.push(structuredClone(data));
|
||||
}
|
||||
}
|
||||
|
||||
handleMultiBlockChange(data) {
|
||||
const chunkX = data.chunkCoordinates?.x;
|
||||
const chunkZ = data.chunkCoordinates?.z;
|
||||
if (chunkX == null || chunkZ == null) return;
|
||||
const key = this._key(chunkX, chunkZ);
|
||||
const stored = this.chunks.get(key);
|
||||
if (stored) {
|
||||
if (!stored._multiBlockChanges) stored._multiBlockChanges = [];
|
||||
stored._multiBlockChanges.push(structuredClone(data));
|
||||
}
|
||||
}
|
||||
|
||||
_buildChunkEntry(chunkData) {
|
||||
const blockChanges = chunkData._blockChanges || [];
|
||||
const multiBlockChanges = chunkData._multiBlockChanges || [];
|
||||
const lightEntry = this.lights.get(this._key(chunkData.packetData.x, chunkData.packetData.z));
|
||||
return {
|
||||
packetData: chunkData.packetData,
|
||||
rawMapChunkBuffer: chunkData.rawBuffer,
|
||||
blockChanges,
|
||||
multiBlockChanges,
|
||||
lightData: lightEntry?.packetData ?? null,
|
||||
rawLightBuffer: lightEntry?.rawBuffer ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached chunks within view distance of a center, sorted nearest-first.
|
||||
* Vanilla ignores map_chunk outside the current view center — always set
|
||||
* update_view_position before sending these.
|
||||
*/
|
||||
getChunksForReplay(centerChunkX, centerChunkZ, viewDistance) {
|
||||
const result = [];
|
||||
for (const [key, stored] of this.chunks) {
|
||||
const [x, z] = key.split(',').map(Number);
|
||||
if (Math.abs(x - centerChunkX) > viewDistance || Math.abs(z - centerChunkZ) > viewDistance) {
|
||||
continue;
|
||||
}
|
||||
result.push(this._buildChunkEntry(stored));
|
||||
}
|
||||
result.sort((a, b) => {
|
||||
const distA = Math.max(
|
||||
Math.abs(a.packetData.x - centerChunkX),
|
||||
Math.abs(a.packetData.z - centerChunkZ)
|
||||
);
|
||||
const distB = Math.max(
|
||||
Math.abs(b.packetData.x - centerChunkX),
|
||||
Math.abs(b.packetData.z - centerChunkZ)
|
||||
);
|
||||
return distA - distB;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
hasChunkAtBlock(x, z) {
|
||||
const chunkX = Math.floor(x / 16);
|
||||
const chunkZ = Math.floor(z / 16);
|
||||
return this.chunks.has(this._key(chunkX, chunkZ));
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.chunks.size;
|
||||
}
|
||||
|
||||
_touch(key) {
|
||||
this.accessOrder = this.accessOrder.filter(k => k !== key);
|
||||
this.accessOrder.push(key);
|
||||
}
|
||||
|
||||
_evictIfNeeded() {
|
||||
while (this.chunks.size > this.maxChunks && this.accessOrder.length > 0) {
|
||||
const oldest = this.accessOrder.shift();
|
||||
this.chunks.delete(oldest);
|
||||
this.lights.delete(oldest);
|
||||
log.debug(`Evicted chunk ${oldest} (cache full: ${this.chunks.size}/${this.maxChunks})`);
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.chunks.clear();
|
||||
this.lights.clear();
|
||||
this.accessOrder = [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ChunkCache };
|
||||
166
src/state/EntityCache.js
Normal file
166
src/state/EntityCache.js
Normal file
@@ -0,0 +1,166 @@
|
||||
const { createLogger } = require('../utils/logger');
|
||||
const { toByteAngle, sanitizeSpawnEntity } = require('../utils/angles');
|
||||
const log = createLogger('EntityCache');
|
||||
|
||||
/**
|
||||
* Tracks entities received from the server.
|
||||
* Stores spawn data, metadata, equipment, effects, and position updates.
|
||||
*/
|
||||
class EntityCache {
|
||||
constructor() {
|
||||
/** @type {Map<number, object>} entityId -> entity state */
|
||||
this.entities = new Map();
|
||||
}
|
||||
|
||||
handleSpawnEntity(data) {
|
||||
const entityId = data.entityId;
|
||||
this.entities.set(entityId, {
|
||||
spawnData: { ...data },
|
||||
metadata: null,
|
||||
equipment: null,
|
||||
effects: [],
|
||||
passengers: null,
|
||||
});
|
||||
}
|
||||
|
||||
handleEntityMetadata(data) {
|
||||
const entity = this.entities.get(data.entityId);
|
||||
if (entity) {
|
||||
entity.metadata = { ...data };
|
||||
}
|
||||
}
|
||||
|
||||
handleEntityEquipment(data) {
|
||||
const entity = this.entities.get(data.entityId);
|
||||
if (entity) {
|
||||
entity.equipment = { ...data };
|
||||
}
|
||||
}
|
||||
|
||||
handleEntityEffect(data) {
|
||||
const entity = this.entities.get(data.entityId);
|
||||
if (entity) {
|
||||
// Replace existing effect of same type, or add
|
||||
entity.effects = entity.effects.filter(e => e.effectId !== data.effectId);
|
||||
entity.effects.push({ ...data });
|
||||
}
|
||||
}
|
||||
|
||||
handleRemoveEntityEffect(data) {
|
||||
const entity = this.entities.get(data.entityId);
|
||||
if (entity) {
|
||||
entity.effects = entity.effects.filter(e => e.effectId !== data.effectId);
|
||||
}
|
||||
}
|
||||
|
||||
handleEntityDestroy(data) {
|
||||
// data.entityIds is an array of entity IDs to destroy
|
||||
const ids = data.entityIds || [];
|
||||
for (const id of ids) {
|
||||
this.entities.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
handleSetPassengers(data) {
|
||||
const entity = this.entities.get(data.entityId);
|
||||
if (entity) {
|
||||
entity.passengers = { ...data };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update entity position from various movement packets.
|
||||
* We update the spawn data so replay sends correct initial position.
|
||||
*/
|
||||
handleEntityPosition(data) {
|
||||
const entity = this.entities.get(data.entityId);
|
||||
if (entity && entity.spawnData) {
|
||||
if (data.x !== undefined) entity.spawnData.x = data.x;
|
||||
if (data.y !== undefined) entity.spawnData.y = data.y;
|
||||
if (data.z !== undefined) entity.spawnData.z = data.z;
|
||||
if (data.yaw !== undefined) entity.spawnData.yaw = data.yaw;
|
||||
if (data.pitch !== undefined) entity.spawnData.pitch = data.pitch;
|
||||
}
|
||||
}
|
||||
|
||||
handleSyncEntityPosition(data) {
|
||||
const entity = this.entities.get(data.entityId);
|
||||
if (entity && entity.spawnData) {
|
||||
if (data.x !== undefined) entity.spawnData.x = data.x;
|
||||
if (data.y !== undefined) entity.spawnData.y = data.y;
|
||||
if (data.z !== undefined) entity.spawnData.z = data.z;
|
||||
// sync_entity_position uses f32 degrees; spawn_entity expects i8 byte angles
|
||||
if (data.yaw !== undefined) entity.spawnData.yaw = toByteAngle(data.yaw);
|
||||
if (data.pitch !== undefined) entity.spawnData.pitch = toByteAngle(data.pitch);
|
||||
}
|
||||
}
|
||||
|
||||
handleRelEntityMove(data) {
|
||||
const entity = this.entities.get(data.entityId);
|
||||
if (entity && entity.spawnData) {
|
||||
// delta values are fixed-point (divided by 4096)
|
||||
entity.spawnData.x += (data.dX || 0) / 4096;
|
||||
entity.spawnData.y += (data.dY || 0) / 4096;
|
||||
entity.spawnData.z += (data.dZ || 0) / 4096;
|
||||
}
|
||||
}
|
||||
|
||||
handleEntityMoveLook(data) {
|
||||
const entity = this.entities.get(data.entityId);
|
||||
if (entity && entity.spawnData) {
|
||||
entity.spawnData.x += (data.dX || 0) / 4096;
|
||||
entity.spawnData.y += (data.dY || 0) / 4096;
|
||||
entity.spawnData.z += (data.dZ || 0) / 4096;
|
||||
if (data.yaw !== undefined) entity.spawnData.yaw = data.yaw;
|
||||
if (data.pitch !== undefined) entity.spawnData.pitch = data.pitch;
|
||||
}
|
||||
}
|
||||
|
||||
handleEntityTeleport(data) {
|
||||
const entity = this.entities.get(data.entityId);
|
||||
if (entity && entity.spawnData) {
|
||||
entity.spawnData.x = data.x;
|
||||
entity.spawnData.y = data.y;
|
||||
entity.spawnData.z = data.z;
|
||||
entity.spawnData.yaw = data.yaw;
|
||||
entity.spawnData.pitch = data.pitch;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all entities for replay.
|
||||
* Returns spawn packets + metadata + equipment + effects.
|
||||
*/
|
||||
getAllEntities() {
|
||||
const result = [];
|
||||
for (const [entityId, entity] of this.entities) {
|
||||
result.push({
|
||||
entityId,
|
||||
spawnData: sanitizeSpawnEntity(entity.spawnData),
|
||||
metadata: entity.metadata,
|
||||
equipment: entity.equipment,
|
||||
effects: entity.effects,
|
||||
passengers: entity.passengers,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the bot's own entity ID from tracking
|
||||
* (the player entity is handled separately via player state).
|
||||
*/
|
||||
removePlayerEntity(entityId) {
|
||||
this.entities.delete(entityId);
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.entities.size;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.entities.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { EntityCache };
|
||||
84
src/state/InventoryCache.js
Normal file
84
src/state/InventoryCache.js
Normal file
@@ -0,0 +1,84 @@
|
||||
const { createLogger } = require('../utils/logger');
|
||||
const log = createLogger('InventoryCache');
|
||||
|
||||
/**
|
||||
* Caches inventory state: window items, individual slot updates, held item.
|
||||
*/
|
||||
class InventoryCache {
|
||||
constructor() {
|
||||
/** Full window_items packet data (usually inventory slot 0) */
|
||||
this.windowItems = null;
|
||||
/** Individual set_slot updates (keyed by "windowId:slot") */
|
||||
this.slotUpdates = new Map();
|
||||
/** Currently held item slot index */
|
||||
this.heldItemSlot = null;
|
||||
/** set_player_inventory packet data */
|
||||
this.playerInventory = null;
|
||||
/** set_cursor_item packet data */
|
||||
this.cursorItem = null;
|
||||
}
|
||||
|
||||
handleWindowItems(data) {
|
||||
this.windowItems = { ...data };
|
||||
// Clear individual slot updates since we have a full snapshot
|
||||
this.slotUpdates.clear();
|
||||
}
|
||||
|
||||
handleSetSlot(data) {
|
||||
const key = `${data.windowId}:${data.slot}`;
|
||||
this.slotUpdates.set(key, { ...data });
|
||||
}
|
||||
|
||||
handleHeldItemSlot(data) {
|
||||
this.heldItemSlot = { ...data };
|
||||
}
|
||||
|
||||
handleSetPlayerInventory(data) {
|
||||
this.playerInventory = { ...data };
|
||||
}
|
||||
|
||||
handleSetCursorItem(data) {
|
||||
this.cursorItem = { ...data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the packets needed to replay inventory state.
|
||||
* @returns {Array<{name: string, data: object}>}
|
||||
*/
|
||||
getReplayPackets() {
|
||||
const packets = [];
|
||||
|
||||
if (this.windowItems) {
|
||||
packets.push({ name: 'window_items', data: this.windowItems });
|
||||
}
|
||||
|
||||
// Apply any set_slot updates that came after window_items
|
||||
for (const [, slotData] of this.slotUpdates) {
|
||||
packets.push({ name: 'set_slot', data: slotData });
|
||||
}
|
||||
|
||||
if (this.heldItemSlot) {
|
||||
packets.push({ name: 'held_item_slot', data: this.heldItemSlot });
|
||||
}
|
||||
|
||||
if (this.playerInventory) {
|
||||
packets.push({ name: 'set_player_inventory', data: this.playerInventory });
|
||||
}
|
||||
|
||||
if (this.cursorItem) {
|
||||
packets.push({ name: 'set_cursor_item', data: this.cursorItem });
|
||||
}
|
||||
|
||||
return packets;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.windowItems = null;
|
||||
this.slotUpdates.clear();
|
||||
this.heldItemSlot = null;
|
||||
this.playerInventory = null;
|
||||
this.cursorItem = null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { InventoryCache };
|
||||
80
src/state/JoinSyncCache.js
Normal file
80
src/state/JoinSyncCache.js
Normal file
@@ -0,0 +1,80 @@
|
||||
const { createLogger } = require('../utils/logger');
|
||||
const log = createLogger('JoinSyncCache');
|
||||
|
||||
/**
|
||||
* Caches play packets from PlayerList.placeNewPlayer / PlayerAdvancements
|
||||
* that the proxy client would otherwise never see (bot got them at login).
|
||||
*/
|
||||
class JoinSyncCache {
|
||||
constructor() {
|
||||
/** @type {{ name: string, data: object }|null} */
|
||||
this.updateRecipes = null;
|
||||
/** @type {Array<{ name: string, data: object }>} */
|
||||
this.advancementPackets = [];
|
||||
/** @type {Array<{ name: string, data: object }>} */
|
||||
this.recipeBookAdd = [];
|
||||
this.recipeBookSettings = null;
|
||||
}
|
||||
|
||||
handlePacket(name, data) {
|
||||
switch (name) {
|
||||
case 'update_recipes':
|
||||
case 'declare_recipes':
|
||||
this.updateRecipes = { name, data: structuredClone(data) };
|
||||
log.debug(`Cached ${name}`);
|
||||
break;
|
||||
|
||||
case 'advancements':
|
||||
if (data.reset) {
|
||||
this.advancementPackets = [{ name, data: structuredClone(data) }];
|
||||
log.info('Cached full advancements snapshot (reset)');
|
||||
} else {
|
||||
this.advancementPackets.push({ name, data: structuredClone(data) });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'recipe_book_add':
|
||||
if (data.replace) {
|
||||
this.recipeBookAdd = [{ name, data: structuredClone(data) }];
|
||||
} else {
|
||||
this.recipeBookAdd.push({ name, data: structuredClone(data) });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'recipe_book_settings':
|
||||
this.recipeBookSettings = structuredClone(data);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Packets to send after inventory, before teleport (placeNewPlayer order).
|
||||
* @returns {Array<{ name: string, data: object }>}
|
||||
*/
|
||||
getReplayPackets() {
|
||||
const packets = [];
|
||||
if (this.updateRecipes) packets.push(this.updateRecipes);
|
||||
if (this.recipeBookSettings) {
|
||||
packets.push({ name: 'recipe_book_settings', data: this.recipeBookSettings });
|
||||
}
|
||||
for (const pkt of this.recipeBookAdd) {
|
||||
packets.push(pkt);
|
||||
}
|
||||
for (const pkt of this.advancementPackets) {
|
||||
packets.push(pkt);
|
||||
}
|
||||
return packets;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.updateRecipes = null;
|
||||
this.advancementPackets = [];
|
||||
this.recipeBookAdd = [];
|
||||
this.recipeBookSettings = null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { JoinSyncCache };
|
||||
274
src/state/MiscCache.js
Normal file
274
src/state/MiscCache.js
Normal file
@@ -0,0 +1,274 @@
|
||||
const { createLogger } = require('../utils/logger');
|
||||
const { ScoreboardCache } = require('./ScoreboardCache');
|
||||
const { WorldBorderCache } = require('./WorldBorderCache');
|
||||
|
||||
const log = createLogger('MiscCache');
|
||||
|
||||
/**
|
||||
* Caches miscellaneous world state:
|
||||
* time, weather, world border, scoreboard, boss bars, tab list, tags, etc.
|
||||
*/
|
||||
class MiscCache {
|
||||
constructor() {
|
||||
// Time
|
||||
this.time = null;
|
||||
|
||||
// Weather (from game_state_change: reason 1 = begin rain, 2 = end rain,
|
||||
// 7 = rain level, 8 = thunder level)
|
||||
this.weather = {
|
||||
raining: false,
|
||||
rainLevel: null,
|
||||
thunderLevel: null,
|
||||
};
|
||||
|
||||
// World border (delegated)
|
||||
this._worldBorder = new WorldBorderCache();
|
||||
|
||||
// Tab list
|
||||
this.playerInfo = new Map(); // UUID -> merged player_info data
|
||||
/** @type {Array<{action: object, data: object[]}>} verbatim packets for replay */
|
||||
this.playerInfoPackets = [];
|
||||
this.playerListHeader = null; // playerlist_header
|
||||
|
||||
// Scoreboard (delegated)
|
||||
this._scoreboard = new ScoreboardCache();
|
||||
|
||||
// Boss bars
|
||||
this.bossBars = new Map(); // UUID -> boss_bar data
|
||||
|
||||
// Tags
|
||||
this.tags = null;
|
||||
|
||||
// Server data
|
||||
this.serverData = null;
|
||||
|
||||
// Simulation distance / view distance
|
||||
this.simulationDistance = null;
|
||||
this.viewDistance = null;
|
||||
|
||||
// Declare commands
|
||||
this.declareCommands = null;
|
||||
|
||||
// Update view position
|
||||
this.viewPosition = null;
|
||||
|
||||
// Player remove tracking
|
||||
this.removedPlayers = new Set();
|
||||
}
|
||||
|
||||
handleUpdateTime(data) {
|
||||
this.time = { ...data };
|
||||
}
|
||||
|
||||
handleGameStateChange(data) {
|
||||
switch (data.reason) {
|
||||
case 1: // Begin rain
|
||||
this.weather.raining = true;
|
||||
break;
|
||||
case 2: // End rain
|
||||
this.weather.raining = false;
|
||||
break;
|
||||
case 7: // Rain level
|
||||
this.weather.rainLevel = data.gameMode;
|
||||
break;
|
||||
case 8: // Thunder level
|
||||
this.weather.thunderLevel = data.gameMode;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// World border packets — delegate to WorldBorderCache
|
||||
handleInitWorldBorder(data) { this._worldBorder.handleInitWorldBorder(data); }
|
||||
handleWorldBorderCenter(data) { this._worldBorder.handleWorldBorderCenter(data); }
|
||||
handleWorldBorderSize(data) { this._worldBorder.handleWorldBorderSize(data); }
|
||||
handleWorldBorderLerpSize(data) { this._worldBorder.handleWorldBorderLerpSize(data); }
|
||||
handleWorldBorderWarningDelay(data) { this._worldBorder.handleWorldBorderWarningDelay(data); }
|
||||
handleWorldBorderWarningReach(data) { this._worldBorder.handleWorldBorderWarningReach(data); }
|
||||
|
||||
// Tab list
|
||||
handlePlayerInfo(data) {
|
||||
this.playerInfoPackets.push({
|
||||
action: structuredClone(data.action),
|
||||
data: structuredClone(data.data || []),
|
||||
});
|
||||
|
||||
if (data.data) {
|
||||
for (const entry of data.data) {
|
||||
const uuid = entry.uuid;
|
||||
if (!uuid) continue;
|
||||
const existing = this.playerInfo.get(uuid) || {};
|
||||
this.playerInfo.set(uuid, { ...existing, ...entry });
|
||||
this.removedPlayers.delete(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handlePlayerRemove(data) {
|
||||
if (data.players) {
|
||||
for (const uuid of data.players) {
|
||||
this.playerInfo.delete(uuid);
|
||||
this.removedPlayers.add(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handlePlayerListHeader(data) {
|
||||
this.playerListHeader = { ...data };
|
||||
}
|
||||
|
||||
// Scoreboard — delegate to ScoreboardCache
|
||||
handleScoreboardObjective(data) { this._scoreboard.handleScoreboardObjective(data); }
|
||||
handleScoreboardDisplayObjective(data) { this._scoreboard.handleScoreboardDisplayObjective(data); }
|
||||
handleScoreboardScore(data) { this._scoreboard.handleScoreboardScore(data); }
|
||||
handleResetScore(data) { this._scoreboard.handleResetScore(data); }
|
||||
handleTeams(data) { this._scoreboard.handleTeams(data); }
|
||||
|
||||
// Boss bars
|
||||
handleBossBar(data) {
|
||||
if (data.action === 1) {
|
||||
// Remove
|
||||
this.bossBars.delete(data.entityUUID);
|
||||
} else {
|
||||
const existing = this.bossBars.get(data.entityUUID) || {};
|
||||
this.bossBars.set(data.entityUUID, { ...existing, ...data });
|
||||
}
|
||||
}
|
||||
|
||||
handleTags(data) {
|
||||
this.tags = { ...data };
|
||||
}
|
||||
|
||||
handleServerData(data) {
|
||||
this.serverData = { ...data };
|
||||
}
|
||||
|
||||
handleSimulationDistance(data) {
|
||||
this.simulationDistance = { ...data };
|
||||
}
|
||||
|
||||
handleUpdateViewDistance(data) {
|
||||
this.viewDistance = { ...data };
|
||||
}
|
||||
|
||||
handleDeclareCommands(data) {
|
||||
this.declareCommands = { ...data };
|
||||
}
|
||||
|
||||
handleUpdateViewPosition(data) {
|
||||
this.viewPosition = { ...data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all replay packets for misc state.
|
||||
* @returns {Array<{name: string, data: object}>}
|
||||
*/
|
||||
getReplayPackets() {
|
||||
const packets = [];
|
||||
|
||||
if (this.tags) {
|
||||
packets.push({ name: 'tags', data: this.tags });
|
||||
}
|
||||
|
||||
if (this.declareCommands) {
|
||||
packets.push({ name: 'declare_commands', data: this.declareCommands });
|
||||
}
|
||||
|
||||
if (this.serverData) {
|
||||
packets.push({ name: 'server_data', data: this.serverData });
|
||||
}
|
||||
|
||||
if (this.time) {
|
||||
packets.push({ name: 'update_time', data: this.time });
|
||||
}
|
||||
|
||||
// Weather
|
||||
if (this.weather.raining) {
|
||||
packets.push({ name: 'game_state_change', data: { reason: 1, gameMode: 0 } });
|
||||
if (this.weather.rainLevel != null) {
|
||||
packets.push({ name: 'game_state_change', data: { reason: 7, gameMode: this.weather.rainLevel } });
|
||||
}
|
||||
if (this.weather.thunderLevel != null) {
|
||||
packets.push({ name: 'game_state_change', data: { reason: 8, gameMode: this.weather.thunderLevel } });
|
||||
}
|
||||
}
|
||||
|
||||
// World border
|
||||
packets.push(...this._worldBorder.getReplayPackets());
|
||||
|
||||
// Simulation distance + view distance
|
||||
if (this.simulationDistance) {
|
||||
packets.push({ name: 'simulation_distance', data: this.simulationDistance });
|
||||
}
|
||||
if (this.viewDistance) {
|
||||
packets.push({ name: 'update_view_distance', data: this.viewDistance });
|
||||
}
|
||||
|
||||
// update_view_position is sent by StateReplayer after chunks + player position
|
||||
|
||||
// player_info is replayed verbatim via getPlayerInfoReplayPackets()
|
||||
|
||||
if (this.playerListHeader) {
|
||||
packets.push({ name: 'playerlist_header', data: this.playerListHeader });
|
||||
}
|
||||
|
||||
// Scoreboard
|
||||
packets.push(...this._scoreboard.getReplayPackets());
|
||||
|
||||
// Boss bars
|
||||
for (const [, bar] of this.bossBars) {
|
||||
// Send as "add" action
|
||||
packets.push({ name: 'boss_bar', data: { ...bar, action: 0 } });
|
||||
}
|
||||
|
||||
return packets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay player_info packets exactly as received from the server.
|
||||
* @returns {Array<{name: string, data: object}>}
|
||||
*/
|
||||
getPlayerInfoReplayPackets() {
|
||||
return this.playerInfoPackets.map((pkt) => ({
|
||||
name: 'player_info',
|
||||
data: pkt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* UUIDs that were added via player_info (for filtering live updates).
|
||||
*/
|
||||
getKnownPlayerUuids() {
|
||||
const uuids = new Set();
|
||||
for (const pkt of this.playerInfoPackets) {
|
||||
if (pkt.action?.add_player) {
|
||||
for (const entry of pkt.data) {
|
||||
if (entry.uuid) uuids.add(entry.uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [uuid, entry] of this.playerInfo) {
|
||||
if (entry.player) uuids.add(uuid);
|
||||
}
|
||||
return uuids;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.time = null;
|
||||
this.weather = { raining: false, rainLevel: null, thunderLevel: null };
|
||||
this._worldBorder.clear();
|
||||
this.playerInfo.clear();
|
||||
this.playerInfoPackets = [];
|
||||
this.playerListHeader = null;
|
||||
this._scoreboard.clear();
|
||||
this.bossBars.clear();
|
||||
this.tags = null;
|
||||
this.serverData = null;
|
||||
this.simulationDistance = null;
|
||||
this.viewDistance = null;
|
||||
this.declareCommands = null;
|
||||
this.viewPosition = null;
|
||||
this.removedPlayers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { MiscCache };
|
||||
134
src/state/PlayerStateCache.js
Normal file
134
src/state/PlayerStateCache.js
Normal file
@@ -0,0 +1,134 @@
|
||||
const { createLogger } = require('../utils/logger');
|
||||
const log = createLogger('PlayerStateCache');
|
||||
|
||||
/** ClientboundEntityEventPacket — PlayerList.sendPlayerPermissionLevel (EntityEvent.java) */
|
||||
const PERMISSION_STATUS_MIN = 24;
|
||||
const PERMISSION_STATUS_MAX = 28;
|
||||
|
||||
/**
|
||||
* Caches player-specific state: position, health, XP, abilities, gamemode.
|
||||
*/
|
||||
class PlayerStateCache {
|
||||
constructor() {
|
||||
this.loginPacket = null; // The login/join_game packet
|
||||
this.position = null; // { x, y, z, yaw, pitch, flags, teleportId }
|
||||
this.health = null; // { health, food, foodSaturation }
|
||||
this.experience = null; // { experienceBar, level, totalExperience }
|
||||
this.abilities = null; // { flags, flyingSpeed, walkingSpeed }
|
||||
this.spawnPosition = null; // { location, angle }
|
||||
this.gameMode = null; // from game_state_change
|
||||
this.difficulty = null; // { difficulty, difficultyLocked }
|
||||
this.entityId = null; // The player's entity ID from login
|
||||
/** entity_status for game-mode switcher / command permission UI */
|
||||
this.permissionStatus = null; // { entityId, entityStatus }
|
||||
this.effects = []; // player status effects
|
||||
}
|
||||
|
||||
handleLogin(data) {
|
||||
this.loginPacket = { ...data };
|
||||
this.entityId = data.entityId;
|
||||
log.info(`Player entity ID: ${this.entityId}`);
|
||||
}
|
||||
|
||||
handlePosition(data) {
|
||||
this.position = { ...data };
|
||||
}
|
||||
|
||||
handleUpdateHealth(data) {
|
||||
this.health = { ...data };
|
||||
}
|
||||
|
||||
handleExperience(data) {
|
||||
this.experience = { ...data };
|
||||
}
|
||||
|
||||
handleAbilities(data) {
|
||||
this.abilities = { ...data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission level for the local player (entity_status 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,
|
||||
};
|
||||
73
src/state/ScoreboardCache.js
Normal file
73
src/state/ScoreboardCache.js
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Caches scoreboard state: objectives, display slots, scores, and teams.
|
||||
*/
|
||||
class ScoreboardCache {
|
||||
constructor() {
|
||||
this.objectives = new Map(); // name -> objective data
|
||||
this.displays = new Map(); // position -> display data
|
||||
this.scores = new Map(); // "name:objective" -> score data
|
||||
this.teams = new Map(); // name -> team data
|
||||
}
|
||||
|
||||
handleScoreboardObjective(data) {
|
||||
if (data.action === 1) {
|
||||
// Remove objective
|
||||
this.objectives.delete(data.name);
|
||||
} else {
|
||||
this.objectives.set(data.name, { ...data });
|
||||
}
|
||||
}
|
||||
|
||||
handleScoreboardDisplayObjective(data) {
|
||||
this.displays.set(data.position, { ...data });
|
||||
}
|
||||
|
||||
handleScoreboardScore(data) {
|
||||
const key = `${data.itemName || data.entity}:${data.scoreName || data.objective}`;
|
||||
this.scores.set(key, { ...data });
|
||||
}
|
||||
|
||||
handleResetScore(data) {
|
||||
const key = `${data.itemName || data.entity}:${data.scoreName || data.objective}`;
|
||||
this.scores.delete(key);
|
||||
}
|
||||
|
||||
handleTeams(data) {
|
||||
if (data.mode === 1) {
|
||||
// Remove team
|
||||
this.teams.delete(data.team);
|
||||
} else {
|
||||
const existing = this.teams.get(data.team) || {};
|
||||
this.teams.set(data.team, { ...existing, ...data });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array<{name: string, data: object}>}
|
||||
*/
|
||||
getReplayPackets() {
|
||||
const packets = [];
|
||||
for (const [, obj] of this.objectives) {
|
||||
packets.push({ name: 'scoreboard_objective', data: obj });
|
||||
}
|
||||
for (const [, display] of this.displays) {
|
||||
packets.push({ name: 'scoreboard_display_objective', data: display });
|
||||
}
|
||||
for (const [, score] of this.scores) {
|
||||
packets.push({ name: 'scoreboard_score', data: score });
|
||||
}
|
||||
for (const [, team] of this.teams) {
|
||||
packets.push({ name: 'teams', data: team });
|
||||
}
|
||||
return packets;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.objectives.clear();
|
||||
this.displays.clear();
|
||||
this.scores.clear();
|
||||
this.teams.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ScoreboardCache };
|
||||
65
src/state/WorldBorderCache.js
Normal file
65
src/state/WorldBorderCache.js
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Caches world border state: initialize, center, size, lerp, and warnings.
|
||||
*/
|
||||
class WorldBorderCache {
|
||||
constructor() {
|
||||
this.initBorder = null; // initialize_world_border
|
||||
this.center = null; // world_border_center
|
||||
this.size = null; // world_border_size
|
||||
this.lerpSize = null; // world_border_lerp_size
|
||||
this.warningDelay = null;
|
||||
this.warningReach = null;
|
||||
}
|
||||
|
||||
handleInitWorldBorder(data) {
|
||||
this.initBorder = { ...data };
|
||||
}
|
||||
|
||||
handleWorldBorderCenter(data) {
|
||||
this.center = { ...data };
|
||||
}
|
||||
|
||||
handleWorldBorderSize(data) {
|
||||
this.size = { ...data };
|
||||
}
|
||||
|
||||
handleWorldBorderLerpSize(data) {
|
||||
this.lerpSize = { ...data };
|
||||
}
|
||||
|
||||
handleWorldBorderWarningDelay(data) {
|
||||
this.warningDelay = { ...data };
|
||||
}
|
||||
|
||||
handleWorldBorderWarningReach(data) {
|
||||
this.warningReach = { ...data };
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array<{name: string, data: object}>}
|
||||
*/
|
||||
getReplayPackets() {
|
||||
const packets = [];
|
||||
if (this.initBorder) {
|
||||
packets.push({ name: 'initialize_world_border', data: this.initBorder });
|
||||
}
|
||||
if (this.center) {
|
||||
packets.push({ name: 'world_border_center', data: this.center });
|
||||
}
|
||||
if (this.size) {
|
||||
packets.push({ name: 'world_border_size', data: this.size });
|
||||
}
|
||||
return packets;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.initBorder = null;
|
||||
this.center = null;
|
||||
this.size = null;
|
||||
this.lerpSize = null;
|
||||
this.warningDelay = null;
|
||||
this.warningReach = null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { WorldBorderCache };
|
||||
337
src/state/WorldStateCache.js
Normal file
337
src/state/WorldStateCache.js
Normal file
@@ -0,0 +1,337 @@
|
||||
const { createLogger } = require('../utils/logger');
|
||||
const { ChunkCache } = require('./ChunkCache');
|
||||
const { EntityCache } = require('./EntityCache');
|
||||
const { PlayerStateCache } = require('./PlayerStateCache');
|
||||
const { InventoryCache } = require('./InventoryCache');
|
||||
const { MiscCache } = require('./MiscCache');
|
||||
const { JoinSyncCache } = require('./JoinSyncCache');
|
||||
|
||||
const log = createLogger('WorldState');
|
||||
|
||||
function cloneConfigData(data) {
|
||||
return structuredClone(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Master cache coordinator.
|
||||
* Routes incoming server packets to the appropriate sub-cache.
|
||||
*/
|
||||
class WorldStateCache {
|
||||
constructor(config) {
|
||||
this.chunks = new ChunkCache(config.cache.maxChunks);
|
||||
this.entities = new EntityCache();
|
||||
this.player = new PlayerStateCache();
|
||||
this.inventory = new InventoryCache();
|
||||
this.misc = new MiscCache();
|
||||
this.joinSync = new JoinSyncCache();
|
||||
|
||||
/** Parsed configuration-phase packets (fallback if raw capture unavailable) */
|
||||
this.configPackets = [];
|
||||
|
||||
/** Raw packet buffers from upstream server config phase, in receive order */
|
||||
this.rawConfigPackets = [];
|
||||
|
||||
/** Track whether we've received the login packet */
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a configuration-phase packet for replay.
|
||||
*/
|
||||
handleConfigPacket(name, data) {
|
||||
const cloned = cloneConfigData(data);
|
||||
// Replace existing packet of same name, or append
|
||||
const idx = this.configPackets.findIndex(p => p.name === name);
|
||||
if (name === 'registry_data') {
|
||||
// Registry data can have multiple packets (one per registry)
|
||||
this.configPackets.push({ name, data: cloned });
|
||||
} else if (idx >= 0) {
|
||||
this.configPackets[idx] = { name, data: cloned };
|
||||
} else {
|
||||
this.configPackets.push({ name, data: cloned });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a registryCodec object from captured server registry_data packets.
|
||||
* Used by the proxy so clients receive the real server's registries, not minecraft-data defaults.
|
||||
* @returns {object|null}
|
||||
*/
|
||||
buildRegistryCodec() {
|
||||
const registryPackets = this.configPackets.filter(p => p.name === 'registry_data');
|
||||
if (registryPackets.length === 0) return null;
|
||||
|
||||
const first = registryPackets[0].data;
|
||||
if (first.codec) {
|
||||
return first;
|
||||
}
|
||||
|
||||
const codec = {};
|
||||
for (const { data } of registryPackets) {
|
||||
if (data.id) {
|
||||
codec[data.id] = data;
|
||||
}
|
||||
}
|
||||
return Object.keys(codec).length > 0 ? codec : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a raw configuration packet buffer exactly as received from the server.
|
||||
*/
|
||||
handleRawConfigPacket(name, buffer) {
|
||||
this.rawConfigPackets.push({ name, buffer: Buffer.from(buffer) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw config packets to replay to proxy clients (excludes finish_configuration).
|
||||
* @returns {Array<{name: string, buffer: Buffer}>}
|
||||
*/
|
||||
getRawConfigPacketsForReplay() {
|
||||
return this.rawConfigPackets.filter(p => p.name !== 'finish_configuration');
|
||||
}
|
||||
|
||||
hasRawConfigPackets() {
|
||||
return this.getRawConfigPacketsForReplay().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a server->client play packet and route to appropriate cache.
|
||||
* @param {string} name - packet name
|
||||
* @param {object} data - packet data
|
||||
* @param {Buffer} [buffer] - raw packet bytes from the server
|
||||
*/
|
||||
handleServerPacket(name, data, buffer) {
|
||||
switch (name) {
|
||||
// Player state
|
||||
case 'login':
|
||||
this.player.handleLogin(data);
|
||||
this.initialized = true;
|
||||
break;
|
||||
case 'position':
|
||||
this.player.handlePosition(data);
|
||||
break;
|
||||
case 'update_health':
|
||||
this.player.handleUpdateHealth(data);
|
||||
break;
|
||||
case 'experience':
|
||||
this.player.handleExperience(data);
|
||||
break;
|
||||
case 'abilities':
|
||||
this.player.handleAbilities(data);
|
||||
break;
|
||||
case 'entity_status':
|
||||
this.player.handleEntityStatus(data);
|
||||
break;
|
||||
case 'spawn_position':
|
||||
this.player.handleSpawnPosition(data);
|
||||
break;
|
||||
case 'difficulty':
|
||||
this.player.handleDifficulty(data);
|
||||
break;
|
||||
case 'respawn':
|
||||
this.player.handleRespawn(data);
|
||||
this.entities.clear();
|
||||
break;
|
||||
|
||||
// Chunks
|
||||
case 'map_chunk':
|
||||
this.chunks.handleMapChunk(data, buffer);
|
||||
break;
|
||||
case 'update_light':
|
||||
this.chunks.handleUpdateLight(data, buffer);
|
||||
break;
|
||||
case 'unload_chunk':
|
||||
this.chunks.handleUnloadChunk(data);
|
||||
break;
|
||||
case 'block_change':
|
||||
this.chunks.handleBlockChange(data);
|
||||
break;
|
||||
case 'multi_block_change':
|
||||
this.chunks.handleMultiBlockChange(data);
|
||||
break;
|
||||
|
||||
// Entities
|
||||
case 'spawn_entity':
|
||||
this.entities.handleSpawnEntity(data);
|
||||
break;
|
||||
case 'entity_metadata':
|
||||
this.entities.handleEntityMetadata(data);
|
||||
break;
|
||||
case 'entity_equipment':
|
||||
this.entities.handleEntityEquipment(data);
|
||||
break;
|
||||
case 'entity_effect':
|
||||
this.player.handleEntityEffect(data);
|
||||
this.entities.handleEntityEffect(data);
|
||||
break;
|
||||
case 'remove_entity_effect':
|
||||
this.player.handleRemoveEntityEffect(data);
|
||||
this.entities.handleRemoveEntityEffect(data);
|
||||
break;
|
||||
case 'entity_destroy':
|
||||
this.entities.handleEntityDestroy(data);
|
||||
break;
|
||||
case 'set_passengers':
|
||||
this.entities.handleSetPassengers(data);
|
||||
break;
|
||||
case 'entity_teleport':
|
||||
this.entities.handleEntityTeleport(data);
|
||||
break;
|
||||
case 'rel_entity_move':
|
||||
this.entities.handleRelEntityMove(data);
|
||||
break;
|
||||
case 'entity_move_look':
|
||||
this.entities.handleEntityMoveLook(data);
|
||||
break;
|
||||
case 'sync_entity_position':
|
||||
this.entities.handleSyncEntityPosition(data);
|
||||
break;
|
||||
|
||||
// Inventory
|
||||
case 'window_items':
|
||||
this.inventory.handleWindowItems(data);
|
||||
break;
|
||||
case 'set_slot':
|
||||
this.inventory.handleSetSlot(data);
|
||||
break;
|
||||
case 'held_item_slot':
|
||||
this.inventory.handleHeldItemSlot(data);
|
||||
break;
|
||||
case 'set_player_inventory':
|
||||
this.inventory.handleSetPlayerInventory(data);
|
||||
break;
|
||||
case 'set_cursor_item':
|
||||
this.inventory.handleSetCursorItem(data);
|
||||
break;
|
||||
|
||||
// Time & weather
|
||||
case 'update_time':
|
||||
this.misc.handleUpdateTime(data);
|
||||
break;
|
||||
case 'game_state_change':
|
||||
this.player.handleGameStateChange(data);
|
||||
this.misc.handleGameStateChange(data);
|
||||
break;
|
||||
|
||||
// World border
|
||||
case 'initialize_world_border':
|
||||
this.misc.handleInitWorldBorder(data);
|
||||
break;
|
||||
case 'world_border_center':
|
||||
this.misc.handleWorldBorderCenter(data);
|
||||
break;
|
||||
case 'world_border_size':
|
||||
this.misc.handleWorldBorderSize(data);
|
||||
break;
|
||||
case 'world_border_lerp_size':
|
||||
this.misc.handleWorldBorderLerpSize(data);
|
||||
break;
|
||||
case 'world_border_warning_delay':
|
||||
this.misc.handleWorldBorderWarningDelay(data);
|
||||
break;
|
||||
case 'world_border_warning_reach':
|
||||
this.misc.handleWorldBorderWarningReach(data);
|
||||
break;
|
||||
|
||||
// Tab list
|
||||
case 'player_info':
|
||||
this.misc.handlePlayerInfo(data);
|
||||
break;
|
||||
case 'player_remove':
|
||||
this.misc.handlePlayerRemove(data);
|
||||
break;
|
||||
case 'playerlist_header':
|
||||
this.misc.handlePlayerListHeader(data);
|
||||
break;
|
||||
|
||||
// Scoreboard
|
||||
case 'scoreboard_objective':
|
||||
this.misc.handleScoreboardObjective(data);
|
||||
break;
|
||||
case 'scoreboard_display_objective':
|
||||
this.misc.handleScoreboardDisplayObjective(data);
|
||||
break;
|
||||
case 'scoreboard_score':
|
||||
this.misc.handleScoreboardScore(data);
|
||||
break;
|
||||
case 'reset_score':
|
||||
this.misc.handleResetScore(data);
|
||||
break;
|
||||
case 'teams':
|
||||
this.misc.handleTeams(data);
|
||||
break;
|
||||
|
||||
// Boss bar
|
||||
case 'boss_bar':
|
||||
this.misc.handleBossBar(data);
|
||||
break;
|
||||
|
||||
// Tags
|
||||
case 'tags':
|
||||
this.misc.handleTags(data);
|
||||
break;
|
||||
|
||||
// Server data
|
||||
case 'server_data':
|
||||
this.misc.handleServerData(data);
|
||||
break;
|
||||
|
||||
// View distance / simulation
|
||||
case 'simulation_distance':
|
||||
this.misc.handleSimulationDistance(data);
|
||||
break;
|
||||
case 'update_view_distance':
|
||||
this.misc.handleUpdateViewDistance(data);
|
||||
break;
|
||||
case 'declare_commands':
|
||||
this.misc.handleDeclareCommands(data);
|
||||
break;
|
||||
case 'update_view_position':
|
||||
this.misc.handleUpdateViewPosition(data);
|
||||
break;
|
||||
|
||||
case 'update_recipes':
|
||||
case 'declare_recipes':
|
||||
case 'advancements':
|
||||
case 'recipe_book_add':
|
||||
case 'recipe_book_settings':
|
||||
this.joinSync.handlePacket(name, data);
|
||||
break;
|
||||
|
||||
// Packets we intentionally don't cache (ephemeral):
|
||||
// sound_effect, entity_sound_effect, world_particles, animation,
|
||||
// block_break_animation, explosion, world_event, player_chat,
|
||||
// system_chat, etc.
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary of cached state for logging.
|
||||
*/
|
||||
getSummary() {
|
||||
return {
|
||||
chunks: this.chunks.size,
|
||||
entities: this.entities.size,
|
||||
initialized: this.initialized,
|
||||
hasPosition: !!this.player.position,
|
||||
hasInventory: !!this.inventory.windowItems,
|
||||
playerInfoEntries: this.misc.playerInfo.size,
|
||||
};
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.chunks.clear();
|
||||
this.entities.clear();
|
||||
this.player.clear();
|
||||
this.inventory.clear();
|
||||
this.misc.clear();
|
||||
this.joinSync.clear();
|
||||
this.configPackets = [];
|
||||
this.rawConfigPackets = [];
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { WorldStateCache };
|
||||
33
src/utils/angles.js
Normal file
33
src/utils/angles.js
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Convert yaw/pitch to the i8 byte format used by spawn_entity and similar packets.
|
||||
* Movement packets may send f32 degrees (e.g. sync_entity_position); spawn uses i8.
|
||||
*/
|
||||
function toByteAngle(value) {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return 0;
|
||||
|
||||
// Already a protocol byte angle (-128..127, integer)
|
||||
if (value >= -128 && value <= 127 && Math.abs(value - Math.round(value)) < 1e-6) {
|
||||
return Math.round(value);
|
||||
}
|
||||
|
||||
// Notchian degrees (f32) -> byte: floor(angle * 256 / 360)
|
||||
let byte = Math.floor((value % 360) * 256 / 360);
|
||||
if (byte > 127) byte -= 256;
|
||||
if (byte < -128) byte += 256;
|
||||
return byte;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare spawn_entity packet data for serialization.
|
||||
*/
|
||||
function sanitizeSpawnEntity(spawnData) {
|
||||
if (!spawnData) return spawnData;
|
||||
return {
|
||||
...spawnData,
|
||||
yaw: toByteAngle(spawnData.yaw),
|
||||
pitch: toByteAngle(spawnData.pitch),
|
||||
headPitch: toByteAngle(spawnData.headPitch),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { toByteAngle, sanitizeSpawnEntity };
|
||||
171
src/utils/chatRelay.js
Normal file
171
src/utils/chatRelay.js
Normal file
@@ -0,0 +1,171 @@
|
||||
const { computeChatChecksum } = require('minecraft-protocol/src/datatypes/checksums');
|
||||
|
||||
const CHAT_C2S_PACKETS = new Set([
|
||||
'chat_message',
|
||||
'chat_command',
|
||||
'chat_command_signed',
|
||||
]);
|
||||
|
||||
/** Inbound packets handled locally; never forward to upstream bot. */
|
||||
const CHAT_SESSION_PACKETS = new Set([
|
||||
...CHAT_C2S_PACKETS,
|
||||
'message_acknowledgement',
|
||||
]);
|
||||
|
||||
/**
|
||||
* minecraft-protocol server chat plugin always listens for message_acknowledgement
|
||||
* even when enforceSecureProfile is false, and kicks with chat_validation_failed.
|
||||
*/
|
||||
function disableInboundChatValidation(client) {
|
||||
if (!client) return;
|
||||
client.removeAllListeners('message_acknowledgement');
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain text or command string from a proxy client chat packet.
|
||||
*/
|
||||
function extractChatText(name, data) {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
|
||||
if (name === 'chat_message') {
|
||||
return typeof data.message === 'string' ? data.message : null;
|
||||
}
|
||||
|
||||
if (name === 'chat_command' || name === 'chat_command_signed') {
|
||||
if (typeof data.command !== 'string' || !data.command.length) return null;
|
||||
return data.command.startsWith('/') ? data.command : `/${data.command}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectUpstreamAcknowledgements(rawClient) {
|
||||
const lsm = rawClient._lastSeenMessages;
|
||||
if (!lsm) return [];
|
||||
|
||||
const acks = [];
|
||||
const cap = lsm.capacity ?? lsm.length;
|
||||
for (let i = 0; i < cap; i++) {
|
||||
const entry = lsm[i];
|
||||
if (Buffer.isBuffer(entry)) acks.push(entry);
|
||||
else if (entry?.signature && Buffer.isBuffer(entry.signature)) acks.push(entry.signature);
|
||||
}
|
||||
return acks;
|
||||
}
|
||||
|
||||
function buildAcknowledgedBitset(rawClient) {
|
||||
const lsm = rawClient._lastSeenMessages;
|
||||
if (!lsm) return Buffer.alloc(3);
|
||||
|
||||
let acc = 0;
|
||||
const cap = lsm.capacity ?? lsm.length;
|
||||
for (let i = 0; i < cap; i++) {
|
||||
if (lsm[i]) acc |= 1 << i;
|
||||
}
|
||||
const bitset = Buffer.allocUnsafe(3);
|
||||
bitset[0] = acc & 0xff;
|
||||
bitset[1] = (acc >> 8) & 0xff;
|
||||
bitset[2] = (acc >> 16) & 0xff;
|
||||
return bitset;
|
||||
}
|
||||
|
||||
/** Fallback when mineflayer bot.chat is unavailable. */
|
||||
function sendUpstreamSignedChat(rawClient, text, options) {
|
||||
const mcData = require('minecraft-data')(rawClient.version);
|
||||
const timestamp = options.timestamp ?? BigInt(Date.now());
|
||||
const salt = options.salt ?? 1n;
|
||||
|
||||
if (!rawClient.profileKeys?.private) {
|
||||
throw new Error('Upstream bot has no chat signing keys');
|
||||
}
|
||||
if (mcData.supportFeature('useChatSessions') && !rawClient._session?.uuid) {
|
||||
throw new Error('Upstream chat session not initialized');
|
||||
}
|
||||
|
||||
const acknowledgements = collectUpstreamAcknowledgements(rawClient);
|
||||
const acknowledged = buildAcknowledgedBitset(rawClient);
|
||||
const checksum = computeChatChecksum(rawClient._lastSeenMessages ?? []);
|
||||
|
||||
if (text.startsWith('/')) {
|
||||
const command = text.slice(1);
|
||||
const canSign = mcData.supportFeature('useChatSessions') && rawClient._session;
|
||||
const packetName =
|
||||
mcData.supportFeature('seperateSignedChatCommandPacket') && canSign
|
||||
? 'chat_command_signed'
|
||||
: 'chat_command';
|
||||
rawClient.write(packetName, {
|
||||
command,
|
||||
timestamp,
|
||||
salt,
|
||||
argumentSignatures: [],
|
||||
messageCount: rawClient._lastSeenMessages?.pending ?? 0,
|
||||
checksum,
|
||||
acknowledged,
|
||||
});
|
||||
if (rawClient._lastSeenMessages) rawClient._lastSeenMessages.pending = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mcData.supportFeature('useChatSessions')) {
|
||||
if (typeof rawClient._signedChat === 'function') {
|
||||
rawClient._signedChat(text, { timestamp, salt });
|
||||
return;
|
||||
}
|
||||
throw new Error('Unsupported chat protocol version');
|
||||
}
|
||||
|
||||
const signature = rawClient.signMessage(text, timestamp, salt, undefined, acknowledgements);
|
||||
rawClient.write('chat_message', {
|
||||
message: text,
|
||||
timestamp,
|
||||
salt,
|
||||
signature,
|
||||
offset: rawClient._lastSeenMessages?.pending ?? 0,
|
||||
checksum,
|
||||
acknowledged,
|
||||
});
|
||||
if (rawClient._lastSeenMessages) rawClient._lastSeenMessages.pending = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-sign chat for the upstream bot session (FlayerBot), not the Java client's account.
|
||||
*/
|
||||
function relayClientChatAsUpstream(serverConn, name, data, log) {
|
||||
if (!CHAT_C2S_PACKETS.has(name)) return false;
|
||||
|
||||
if (!serverConn.connected) {
|
||||
log?.warn?.('Cannot relay chat: upstream not connected');
|
||||
return true;
|
||||
}
|
||||
|
||||
const text = extractChatText(name, data);
|
||||
if (text == null) {
|
||||
log?.warn?.(`Ignoring ${name} with no message/command`);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (serverConn.bot?.chat) {
|
||||
serverConn.bot.chat(text);
|
||||
} else if (serverConn.rawClient) {
|
||||
sendUpstreamSignedChat(serverConn.rawClient, text, {
|
||||
timestamp: data.timestamp,
|
||||
salt: data.salt,
|
||||
});
|
||||
} else {
|
||||
throw new Error('No upstream chat path');
|
||||
}
|
||||
log?.info?.(`Chat sent upstream as bot (${text.length > 80 ? `${text.slice(0, 77)}…` : text})`);
|
||||
} catch (err) {
|
||||
log?.error?.(`Chat re-sign failed: ${err.message}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CHAT_SESSION_PACKETS,
|
||||
CHAT_C2S_PACKETS,
|
||||
disableInboundChatValidation,
|
||||
extractChatText,
|
||||
relayClientChatAsUpstream,
|
||||
};
|
||||
48
src/utils/clientDisconnect.js
Normal file
48
src/utils/clientDisconnect.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* minecraft-protocol's default errorHandler calls client.end(err), which crashes
|
||||
* kick_disconnect serialization (reason must be string/JSON, not an Error).
|
||||
*/
|
||||
|
||||
function disconnectReasonText(reason) {
|
||||
if (reason instanceof Error) return reason.message || 'Disconnected';
|
||||
if (reason == null) return 'Disconnected';
|
||||
if (typeof reason === 'string') return reason;
|
||||
try {
|
||||
return JSON.stringify(reason);
|
||||
} catch {
|
||||
return String(reason);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap client.end so Error objects are never passed to kick_disconnect.
|
||||
*/
|
||||
function wrapClientEnd(client) {
|
||||
const protoEnd = client.end.bind(client);
|
||||
client.end = function safeEnd(endReason, fullReason) {
|
||||
return protoEnd(disconnectReasonText(endReason), fullReason);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* End a proxy client without throwing if the socket is already closed.
|
||||
*/
|
||||
function safeEndClient(client, reason) {
|
||||
if (!client || client.ended) return;
|
||||
const text = disconnectReasonText(reason);
|
||||
try {
|
||||
client.end(text);
|
||||
} catch {
|
||||
try {
|
||||
client._end(text);
|
||||
} catch {
|
||||
/* socket already gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
disconnectReasonText,
|
||||
wrapClientEnd,
|
||||
safeEndClient,
|
||||
};
|
||||
53
src/utils/handoffSync.js
Normal file
53
src/utils/handoffSync.js
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Helpers aligned with vanilla join flow (PlayerList.placeNewPlayer, PlayerChunkSender).
|
||||
*/
|
||||
|
||||
function installHandoffUpstreamRelay(client, serverConn, log) {
|
||||
const handler = (data, meta) => {
|
||||
if (meta.state !== 'play') return;
|
||||
if (meta.name === 'chunk_batch_received') {
|
||||
serverConn.writeToServer('chunk_batch_received', data);
|
||||
if (log) log.info('Forwarded client chunk_batch_received to server');
|
||||
} else if (meta.name === 'player_loaded') {
|
||||
serverConn.writeToServer('player_loaded', data);
|
||||
if (log) log.info('Forwarded client player_loaded to server');
|
||||
}
|
||||
};
|
||||
client.on('packet', handler);
|
||||
return handler;
|
||||
}
|
||||
|
||||
function removeHandoffUpstreamRelay(client, handler) {
|
||||
if (handler) client.removeListener('packet', handler);
|
||||
}
|
||||
|
||||
/** ClientboundGameEventPacket.LEVEL_CHUNKS_LOAD_START (reason 13) */
|
||||
const LEVEL_CHUNKS_LOAD_START = { reason: 13, gameMode: 0 };
|
||||
|
||||
/** EntityEvent.PERMISSION_LEVEL_ADMINS — typical vanilla OP */
|
||||
const PERMISSION_LEVEL_ADMINS = 27;
|
||||
|
||||
/**
|
||||
* Push cached permission entity_status to the proxy client (game mode switcher, etc.).
|
||||
*/
|
||||
function sendPermissionStatusToClient(client, permissionStatus, log) {
|
||||
if (!permissionStatus || !client || client.ended || client.state !== 'play') return false;
|
||||
try {
|
||||
client.write('entity_status', { ...permissionStatus });
|
||||
if (log) {
|
||||
log.info(`Sent permission entity_status ${permissionStatus.entityStatus} to client`);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (log) log.error('Failed to send permission entity_status:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
installHandoffUpstreamRelay,
|
||||
removeHandoffUpstreamRelay,
|
||||
LEVEL_CHUNKS_LOAD_START,
|
||||
PERMISSION_LEVEL_ADMINS,
|
||||
sendPermissionStatusToClient,
|
||||
};
|
||||
43
src/utils/logger.js
Normal file
43
src/utils/logger.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const COLORS = {
|
||||
reset: '\x1b[0m',
|
||||
dim: '\x1b[2m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
magenta: '\x1b[35m',
|
||||
cyan: '\x1b[36m',
|
||||
white: '\x1b[37m',
|
||||
};
|
||||
|
||||
const LEVEL_COLORS = {
|
||||
DEBUG: COLORS.dim,
|
||||
INFO: COLORS.green,
|
||||
WARN: COLORS.yellow,
|
||||
ERROR: COLORS.red,
|
||||
};
|
||||
|
||||
function timestamp() {
|
||||
return new Date().toISOString().replace('T', ' ').replace('Z', '');
|
||||
}
|
||||
|
||||
function createLogger(module) {
|
||||
const tag = `[${module}]`;
|
||||
|
||||
function log(level, ...args) {
|
||||
const color = LEVEL_COLORS[level] || COLORS.white;
|
||||
const ts = COLORS.dim + timestamp() + COLORS.reset;
|
||||
const lvl = color + level.padEnd(5) + COLORS.reset;
|
||||
const mod = COLORS.cyan + tag + COLORS.reset;
|
||||
console.log(`${ts} ${lvl} ${mod}`, ...args);
|
||||
}
|
||||
|
||||
return {
|
||||
debug: (...args) => log('DEBUG', ...args),
|
||||
info: (...args) => log('INFO', ...args),
|
||||
warn: (...args) => log('WARN', ...args),
|
||||
error: (...args) => log('ERROR', ...args),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createLogger };
|
||||
161
src/utils/positionSync.js
Normal file
161
src/utils/positionSync.js
Normal file
@@ -0,0 +1,161 @@
|
||||
const conv = require('mineflayer/lib/conversions');
|
||||
|
||||
const ABSOLUTE_FLAGS = {
|
||||
x: false,
|
||||
y: false,
|
||||
z: false,
|
||||
yaw: false,
|
||||
pitch: false,
|
||||
dx: false,
|
||||
dy: false,
|
||||
dz: false,
|
||||
yawDelta: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a clientbound position packet from the bot's live entity state.
|
||||
* @param {import('mineflayer').Bot} bot
|
||||
* @param {number} teleportId
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function buildClientboundPositionPacket(bot, teleportId) {
|
||||
const entity = bot?.entity;
|
||||
if (!entity?.position) return null;
|
||||
|
||||
return {
|
||||
teleportId,
|
||||
x: entity.position.x,
|
||||
y: entity.position.y,
|
||||
z: entity.position.z,
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
dz: 0,
|
||||
yaw: conv.toNotchianYaw(entity.yaw),
|
||||
pitch: conv.toNotchianPitch(entity.pitch),
|
||||
flags: { ...ABSOLUTE_FLAGS },
|
||||
};
|
||||
}
|
||||
|
||||
function waitForClientTeleportConfirm(client, timeoutMs, log) {
|
||||
return new Promise((resolve) => {
|
||||
if (!client || client.ended) return resolve(false);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
client.removeListener('teleport_confirm', onConfirm);
|
||||
if (log) log.warn('Timed out waiting for client teleport_confirm');
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
|
||||
const onConfirm = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
client.once('teleport_confirm', onConfirm);
|
||||
});
|
||||
}
|
||||
|
||||
function movementFlags(onGround, hasHorizontalCollision) {
|
||||
return {
|
||||
onGround: !!onGround,
|
||||
hasHorizontalCollision: hasHorizontalCollision ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serverbound position_look from bot entity (what the server expects).
|
||||
*/
|
||||
function buildServerboundPositionLook(bot) {
|
||||
const entity = bot?.entity;
|
||||
if (!entity?.position) return null;
|
||||
|
||||
return {
|
||||
x: entity.position.x,
|
||||
y: entity.position.y,
|
||||
z: entity.position.z,
|
||||
yaw: conv.toNotchianYaw(entity.yaw),
|
||||
pitch: conv.toNotchianPitch(entity.pitch),
|
||||
flags: movementFlags(entity.onGround),
|
||||
};
|
||||
}
|
||||
|
||||
function distanceSq(a, b) {
|
||||
const dx = a.x - b.x;
|
||||
const dy = a.y - b.y;
|
||||
const dz = a.z - b.z;
|
||||
return dx * dx + dy * dy + dz * dz;
|
||||
}
|
||||
|
||||
/** Log when client diverges further than this from the bot (still forwarded to server) */
|
||||
const MAX_CLIENT_MOVEMENT_WARN_DELTA = 12;
|
||||
|
||||
function chunkCoordsFromBlock(x, z) {
|
||||
return {
|
||||
chunkX: Math.floor(x / 16),
|
||||
chunkZ: Math.floor(z / 16),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Same test as ChunkTrackingView.isWithinDistance(..., includeNeighbors=false) on the server.
|
||||
*/
|
||||
function isChunkWithinViewDistance(centerChunkX, centerChunkZ, chunkX, chunkZ, viewDistance) {
|
||||
const bufferRange = 1;
|
||||
const deltaX = Math.max(0, Math.abs(chunkX - centerChunkX) - bufferRange);
|
||||
const deltaZ = Math.max(0, Math.abs(chunkZ - centerChunkZ) - bufferRange);
|
||||
return deltaX * deltaX + deltaZ * deltaZ < viewDistance * viewDistance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the proxy client's chunk view center aligned so map_chunk packets are accepted.
|
||||
* @returns {boolean} true if the packet was sent
|
||||
*/
|
||||
function updateClientViewPosition(client, chunkX, chunkZ, lastView) {
|
||||
if (!client || client.ended || client.state !== 'play') return false;
|
||||
if (lastView && lastView.chunkX === chunkX && lastView.chunkZ === chunkZ) return false;
|
||||
|
||||
try {
|
||||
client.write('update_view_position', { chunkX, chunkZ });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (lastView) {
|
||||
lastView.chunkX = chunkX;
|
||||
lastView.chunkZ = chunkZ;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move view center to the player's chunk if missing or a chunk would be rejected.
|
||||
* @returns {boolean} true if update_view_position was sent
|
||||
*/
|
||||
function ensureClientViewIncludesChunk(client, playerBlockX, playerBlockZ, chunkX, chunkZ, viewDistance, lastView) {
|
||||
if (!client || client.ended || client.state !== 'play') return false;
|
||||
|
||||
const playerChunk = chunkCoordsFromBlock(playerBlockX, playerBlockZ);
|
||||
|
||||
if (lastView?.chunkX == null) {
|
||||
return updateClientViewPosition(client, playerChunk.chunkX, playerChunk.chunkZ, lastView);
|
||||
}
|
||||
|
||||
if (!isChunkWithinViewDistance(lastView.chunkX, lastView.chunkZ, chunkX, chunkZ, viewDistance)) {
|
||||
return updateClientViewPosition(client, playerChunk.chunkX, playerChunk.chunkZ, lastView);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildClientboundPositionPacket,
|
||||
buildServerboundPositionLook,
|
||||
waitForClientTeleportConfirm,
|
||||
movementFlags,
|
||||
distanceSq,
|
||||
MAX_CLIENT_MOVEMENT_WARN_DELTA,
|
||||
chunkCoordsFromBlock,
|
||||
isChunkWithinViewDistance,
|
||||
updateClientViewPosition,
|
||||
ensureClientViewIncludesChunk,
|
||||
ABSOLUTE_FLAGS,
|
||||
};
|
||||
Reference in New Issue
Block a user