Skip to main content

netget/client/minecraft/
mod.rs

1//! Minecraft Java Edition client: server-list pings (modern and legacy) and login probes,
2//! each over a fresh TCP connection, as a launcher's server list does.
3pub mod actions;
4use crate::client::llm_budget::call_llm_for_client;
5use crate::llm::actions::client_trait::{Client, ClientActionResult};
6use crate::logging::emit::Log;
7use crate::protocol::{ConnectContext, Event};
8use crate::server::minecraft::wire;
9use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
10use crate::state::{AccessLogOwner, ClientStatus};
11pub use actions::MinecraftClientProtocol;
12use anyhow::{Context, Result};
13use serde_json::{json, Value};
14use std::net::SocketAddr;
15use tokio::{
16    io::{AsyncReadExt, AsyncWriteExt},
17    net::TcpStream,
18    sync::mpsc,
19};
20
21/// Packets read during one login attempt before giving up on reaching a verdict.
22pub const MAX_LOGIN_PACKETS: usize = 16;
23
24pub async fn connect(ctx: ConnectContext) -> Result<SocketAddr> {
25    let (host, port) = split_host_port(&ctx.remote_addr)?;
26    tokio::net::lookup_host((host.as_str(), port))
27        .await
28        .context("resolve the server address")?
29        .next()
30        .context("the server address resolved to nothing")?;
31    ctx.state
32        .update_client_status(ctx.client_id, ClientStatus::Connected)
33        .await;
34    let external =
35        crate::client::command_support::register_command_channel(&ctx.state, ctx.client_id).await;
36    let (internal_tx, internal_rx) = mpsc::channel::<Value>(16);
37    let (event_tx, mut event_rx) = mpsc::channel::<Event>(16);
38    event_tx.try_send(Event::new(
39        &actions::READY_EVENT,
40        json!({"remote_addr": ctx.remote_addr}),
41    ))?;
42    let events_ctx = ctx.clone();
43    let dispatcher = tokio::spawn(async move {
44        let protocol = MinecraftClientProtocol;
45        while let Some(event) = event_rx.recv().await {
46            let instruction = events_ctx
47                .state
48                .get_instruction_for_client(events_ctx.client_id)
49                .await
50                .unwrap_or_default();
51            let memory = events_ctx
52                .state
53                .get_memory_for_client(events_ctx.client_id)
54                .await
55                .unwrap_or_default();
56            match call_llm_for_client(
57                &events_ctx.llm_client,
58                &events_ctx.state,
59                events_ctx.client_id.to_string(),
60                &instruction,
61                &memory,
62                Some(&event),
63                &protocol,
64                &events_ctx.status_tx,
65            )
66            .await
67            {
68                Ok(result) => {
69                    if let Some(memory) = result.memory_updates {
70                        events_ctx
71                            .state
72                            .set_memory_for_client(events_ctx.client_id, memory)
73                            .await;
74                    }
75                    for action in result.actions {
76                        if internal_tx.send(action).await.is_err() {
77                            return;
78                        }
79                    }
80                }
81                Err(e) => Log::new(Some(&events_ctx.status_tx))
82                    .warn(format!("Minecraft client handler: {e}")),
83            }
84        }
85    });
86    let dispatcher_abort = dispatcher.abort_handle();
87    ctx.state
88        .register_client_task(ctx.client_id, dispatcher)
89        .await;
90    let session_ctx = ctx.clone();
91    let task = tokio::spawn(async move {
92        let result = session(&session_ctx, &host, port, external, internal_rx, event_tx).await;
93        if result.is_err() {
94            dispatcher_abort.abort();
95        }
96        let status = match result {
97            Ok(()) => ClientStatus::Disconnected,
98            Err(e) => {
99                Log::new(Some(&session_ctx.status_tx)).warn(format!("Minecraft client ended: {e}"));
100                ClientStatus::Error(e.to_string())
101            }
102        };
103        session_ctx
104            .state
105            .update_client_status(session_ctx.client_id, status)
106            .await;
107        session_ctx
108            .state
109            .remove_client_handle(session_ctx.client_id)
110            .await;
111        let _ = session_ctx.status_tx.send("__UPDATE_UI__".into());
112    });
113    ctx.state.register_client_task(ctx.client_id, task).await;
114    Ok("0.0.0.0:0".parse()?)
115}
116
117/// `host:port`, `[v6]:port`, or a bare host (port 25565).
118pub fn split_host_port(addr: &str) -> Result<(String, u16)> {
119    if let Some(rest) = addr.strip_prefix('[') {
120        let (host, tail) = rest.split_once(']').context("unclosed [ in address")?;
121        let port = match tail.strip_prefix(':') {
122            Some(p) => p.parse().context("port")?,
123            None => 25565,
124        };
125        return Ok((host.to_string(), port));
126    }
127    match addr.rsplit_once(':') {
128        Some((host, port)) if !host.contains(':') => {
129            Ok((host.to_string(), port.parse().context("port")?))
130        }
131        _ => Ok((addr.to_string(), 25565)),
132    }
133}
134
135/// Per-connection framing state: compression switches on during login.
136struct Conn {
137    stream: TcpStream,
138    threshold: Option<i32>,
139}
140
141impl Conn {
142    async fn open(host: &str, port: u16) -> Result<Self> {
143        let stream = tokio::time::timeout(wire::IO_TIMEOUT, TcpStream::connect((host, port)))
144            .await
145            .context("Minecraft connect deadline")??;
146        Ok(Self {
147            stream,
148            threshold: None,
149        })
150    }
151
152    async fn write(&mut self, bytes: &[u8]) -> Result<()> {
153        tokio::time::timeout(wire::IO_TIMEOUT, self.stream.write_all(bytes))
154            .await
155            .context("Minecraft write deadline")??;
156        Ok(())
157    }
158
159    /// Send one packet, in the compressed layout once compression is on (always below any
160    /// threshold here, so uncompressed with a zero data length).
161    async fn send(&mut self, id: i32, body: &[u8]) -> Result<()> {
162        let bytes = if self.threshold.is_some() {
163            let mut inner = vec![0u8];
164            wire::put_varint(&mut inner, id);
165            inner.extend_from_slice(body);
166            let mut out = Vec::new();
167            wire::put_varint(&mut out, inner.len() as i32);
168            out.extend_from_slice(&inner);
169            out
170        } else {
171            wire::frame(id, body)
172        };
173        self.write(&bytes).await
174    }
175
176    async fn recv(&mut self) -> Result<(i32, Vec<u8>)> {
177        let raw = wire::read_raw(
178            &mut self.stream,
179            wire::MAX_CLIENTBOUND_PACKET,
180            wire::IO_TIMEOUT,
181        )
182        .await?
183        .context("server closed the connection")?;
184        if self.threshold.is_none() {
185            return wire::split_id(&raw);
186        }
187        let (data_len, n) = wire::get_varint(&raw)?;
188        if data_len == 0 {
189            return wire::split_id(&raw[n..]);
190        }
191        anyhow::ensure!(
192            data_len > 0 && data_len as usize <= wire::MAX_CLIENTBOUND_PACKET,
193            "compressed packet declares {data_len} bytes"
194        );
195        let mut inflated = Vec::new();
196        flate2::read::ZlibDecoder::new(&raw[n..])
197            .take(data_len as u64 + 1)
198            .read_to_end(&mut inflated)
199            .context("inflate a compressed packet")?;
200        anyhow::ensure!(
201            inflated.len() == data_len as usize,
202            "compressed packet inflated to {} bytes, not {data_len}",
203            inflated.len()
204        );
205        wire::split_id(&inflated)
206    }
207}
208
209use std::io::Read as _;
210
211async fn status(host: &str, port: u16, protocol: i32) -> Result<Value> {
212    let mut conn = Conn::open(host, port).await?;
213    let mut out = wire::encode_handshake(&wire::Handshake {
214        protocol_version: protocol,
215        server_address: host.to_string(),
216        server_port: port,
217        next_state: wire::STATE_STATUS,
218    });
219    out.extend_from_slice(&wire::frame(0x00, &[]));
220    conn.write(&out).await?;
221    let (id, body) = conn.recv().await?;
222    anyhow::ensure!(id == 0x00, "status answered with packet 0x{id:02x}");
223    let text = wire::Reader::new(&body).string(wire::MAX_JSON_CHARS)?;
224    let status: Value = serde_json::from_str(&text).context("status is not JSON")?;
225    let token = crate::utils::clock::SystemTime::now()
226        .duration_since(crate::utils::clock::UNIX_EPOCH)
227        .map(|d| d.as_millis() as i64)
228        .unwrap_or(1);
229    let started = crate::utils::clock::Instant::now();
230    conn.send(0x01, &token.to_be_bytes()).await?;
231    // A server that answers status but not ping still gave its entry; latency stays null.
232    let latency = match conn.recv().await {
233        Ok((0x01, body)) if body == token.to_be_bytes() => {
234            Some(started.elapsed().as_secs_f64() * 1000.0)
235        }
236        _ => None,
237    };
238    let sample: Vec<Value> = status["players"]["sample"]
239        .as_array()
240        .map(|s| {
241            s.iter()
242                .take(wire::MAX_SAMPLE)
243                .map(|p| json!({"name": p["name"], "id": p["id"]}))
244                .collect()
245        })
246        .unwrap_or_default();
247    Ok(json!({
248        "legacy": false,
249        "version_name": status["version"]["name"],
250        "protocol": status["version"]["protocol"],
251        "online_players": status["players"]["online"],
252        "max_players": status["players"]["max"],
253        "motd": wire::plain_text(&status["description"]),
254        "sample": sample,
255        "has_favicon": status.get("favicon").is_some(),
256        "enforces_secure_chat": status.get("enforcesSecureChat"),
257        "latency_ms": latency,
258    }))
259}
260
261async fn legacy_status(host: &str, port: u16) -> Result<Value> {
262    let mut conn = Conn::open(host, port).await?;
263    conn.write(&wire::encode_legacy_ping(host, port)).await?;
264    let reply = tokio::time::timeout(wire::IO_TIMEOUT, async {
265        let kind = conn.stream.read_u8().await?;
266        anyhow::ensure!(kind == 0xFF, "legacy ping answered with 0x{kind:02x}");
267        let units = conn.stream.read_u16().await? as usize;
268        let mut text = vec![0u8; units * 2];
269        conn.stream.read_exact(&mut text).await?;
270        Ok(text)
271    })
272    .await
273    .context("legacy ping deadline")??;
274    let mut event = wire::decode_legacy_reply(&reply)?;
275    event["legacy"] = Value::Bool(true);
276    event["motd"] = json!(event["motd"].as_str().unwrap_or_default());
277    Ok(event)
278}
279
280async fn login(
281    host: &str,
282    port: u16,
283    protocol: i32,
284    username: &str,
285    uuid: [u8; 16],
286) -> Result<Value> {
287    let mut conn = Conn::open(host, port).await?;
288    let mut out = wire::encode_handshake(&wire::Handshake {
289        protocol_version: protocol,
290        server_address: host.to_string(),
291        server_port: port,
292        next_state: wire::STATE_LOGIN,
293    });
294    out.extend_from_slice(&wire::encode_login_start(protocol, username, &uuid));
295    conn.write(&out).await?;
296    let mut event = json!({});
297    for _ in 0..MAX_LOGIN_PACKETS {
298        let (id, body) = conn.recv().await?;
299        let mut r = wire::Reader::new(&body);
300        match id {
301            0x00 => {
302                let text = r.string(wire::MAX_JSON_CHARS)?;
303                let reason = serde_json::from_str::<Value>(&text)
304                    .map(|v| wire::plain_text(&v))
305                    .unwrap_or(text);
306                event["outcome"] = json!("disconnected");
307                event["reason"] = json!(reason);
308                return Ok(event);
309            }
310            0x01 => {
311                event["outcome"] = json!("encryption_required");
312                return Ok(event);
313            }
314            0x02 => {
315                let uuid = if protocol >= 735 {
316                    wire::format_uuid(r.bytes(16)?)
317                } else {
318                    r.string(36)?
319                };
320                event["outcome"] = json!("accepted");
321                event["uuid"] = json!(uuid);
322                event["username"] = json!(r.string(wire::MAX_USERNAME_CHARS)?);
323                return Ok(event);
324            }
325            0x03 => {
326                let threshold = r.varint()?;
327                event["compression_threshold"] = json!(threshold);
328                conn.threshold = Some(threshold);
329            }
330            // Login plugin request: answer "not understood", as a vanilla client does.
331            0x04 => {
332                let message_id = r.varint()?;
333                let mut reply = Vec::new();
334                wire::put_varint(&mut reply, message_id);
335                reply.push(0);
336                conn.send(0x02, &reply).await?;
337            }
338            // Cookie request (1.20.5+): answer with no cookie.
339            0x05 => {
340                let key = r.string(32767)?;
341                let mut reply = Vec::new();
342                wire::put_string(&mut reply, &key);
343                reply.push(0);
344                conn.send(0x04, &reply).await?;
345            }
346            other => anyhow::bail!("unexpected login packet 0x{other:02x}"),
347        }
348    }
349    anyhow::bail!("no login verdict within {MAX_LOGIN_PACKETS} packets")
350}
351
352async fn run(host: &str, port: u16, action: &Value) -> Result<(Event, Value)> {
353    match action["type"].as_str() {
354        Some("minecraft_status") => {
355            let data = status(host, port, actions::protocol_version(action)?).await?;
356            Ok((Event::new(&actions::STATUS_EVENT, data.clone()), data))
357        }
358        Some("minecraft_legacy_status") => {
359            let data = legacy_status(host, port).await?;
360            Ok((Event::new(&actions::STATUS_EVENT, data.clone()), data))
361        }
362        Some("minecraft_login") => {
363            let uuid = match action["uuid"].as_str() {
364                Some(u) => wire::parse_uuid(u)?,
365                None => [0u8; 16],
366            };
367            let data = login(
368                host,
369                port,
370                actions::protocol_version(action)?,
371                action["username"].as_str().unwrap_or_default(),
372                uuid,
373            )
374            .await?;
375            Ok((Event::new(&actions::LOGIN_EVENT, data.clone()), data))
376        }
377        _ => anyhow::bail!("Unknown Minecraft client action"),
378    }
379}
380
381async fn session(
382    ctx: &ConnectContext,
383    host: &str,
384    port: u16,
385    mut external: mpsc::Receiver<ClientCommand>,
386    mut internal: mpsc::Receiver<Value>,
387    events: mpsc::Sender<Event>,
388) -> Result<()> {
389    loop {
390        let (action, mut injected) = tokio::select! {
391            command = external.recv() => match command {
392                Some(c) => (c.action.clone(), Some(c)),
393                None => return Ok(()),
394            },
395            action = internal.recv() => match action {
396                Some(a) => (a, None),
397                None => return Ok(()),
398            },
399        };
400        match MinecraftClientProtocol.execute_action(action.clone()) {
401            Ok(ClientActionResult::Disconnect) => {
402                if let Some(command) = injected.take() {
403                    crate::client::command_support::reply(
404                        command,
405                        Ok(ClientSendOutcome::Disconnected),
406                    );
407                }
408                return Ok(());
409            }
410            Ok(_) => {}
411            Err(e) => {
412                if let Some(command) = injected.take() {
413                    crate::client::command_support::reply(
414                        command,
415                        Ok(ClientSendOutcome::Rejected {
416                            error: e.to_string(),
417                        }),
418                    );
419                }
420                continue;
421            }
422        }
423        let name = action["type"].as_str().unwrap_or_default().to_string();
424        if injected.is_some() {
425            ctx.state
426                .record_access_log(
427                    AccessLogOwner::Client(ctx.client_id.as_u32()),
428                    "Minecraft",
429                    None,
430                    "injected_action",
431                    json!({"action": name}),
432                    vec![],
433                )
434                .await;
435        }
436        // An unreachable or misbehaving server is logged; the client stays up for the next try.
437        match run(host, port, &action).await {
438            Ok((event, data)) => {
439                if let Some(command) = injected.take() {
440                    crate::client::command_support::reply(
441                        command,
442                        Ok(ClientSendOutcome::Executed {
443                            detail: data.to_string(),
444                        }),
445                    );
446                }
447                Log::new(Some(&ctx.status_tx)).info(format!("Minecraft {name}: {data}"));
448                events
449                    .try_send(event)
450                    .context("Minecraft event queue full; consumer stalled")?;
451            }
452            Err(e) => {
453                if let Some(command) = injected.take() {
454                    crate::client::command_support::reply(
455                        command,
456                        Err(anyhow::anyhow!(e.to_string())),
457                    );
458                }
459                Log::new(Some(&ctx.status_tx)).warn(format!("Minecraft {name} failed: {e:#}"));
460            }
461        }
462    }
463}