Skip to main content

steel_protocol/packets/game/
c_player_info_update.rs

1use steel_macros::ClientPacket;
2use steel_registry::GameProfile;
3use steel_registry::packets::play::C_PLAYER_INFO_UPDATE;
4use steel_utils::codec::VarInt;
5use steel_utils::serial::PrefixedWrite;
6use text_components::TextComponent;
7use uuid::Uuid;
8
9// Import RemoteChatSessionData for chat session transmission
10use super::ProtocolRemoteChatSessionData;
11
12/// Actions for the player info update packet.
13/// These match the vanilla Java ClientboundPlayerInfoUpdatePacket.Action enum.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15#[repr(u8)]
16pub enum PlayerInfoAction {
17    AddPlayer = 0x01,
18    InitializeChat = 0x02,
19    UpdateGameMode = 0x04,
20    UpdateListed = 0x08,
21    UpdateLatency = 0x10,
22    UpdateDisplayName = 0x20,
23    UpdateListOrder = 0x40,
24    UpdateHat = 0x80,
25}
26
27/// Bitmask combining all actions needed when a player first joins.
28/// This matches vanilla's `createPlayerInitializing()` method.
29pub const PLAYER_INFO_INIT_ACTIONS: u8 = PlayerInfoAction::AddPlayer as u8
30    | PlayerInfoAction::InitializeChat as u8
31    | PlayerInfoAction::UpdateGameMode as u8
32    | PlayerInfoAction::UpdateListed as u8
33    | PlayerInfoAction::UpdateLatency as u8
34    | PlayerInfoAction::UpdateDisplayName as u8
35    | PlayerInfoAction::UpdateListOrder as u8
36    | PlayerInfoAction::UpdateHat as u8;
37
38/// Represents the display name state for a player.
39#[derive(Debug, Clone)]
40pub enum PlayerDisplayName {
41    /// Use the player's default username (no custom display name).
42    Reset,
43    /// Use a custom display name.
44    Custom(Box<TextComponent>),
45}
46
47impl From<Option<TextComponent>> for PlayerDisplayName {
48    fn from(opt: Option<TextComponent>) -> Self {
49        match opt {
50            Some(component) => Self::Custom(Box::new(component)),
51            None => Self::Reset,
52        }
53    }
54}
55
56/// The player a [`PlayerInfoEntry`] refers to.
57#[derive(Debug, Clone)]
58pub enum PlayerInfoTarget {
59    /// A player the client already knows
60    Uuid(Uuid),
61    /// Required when the packet actions include the add player action
62    Profile(GameProfile),
63}
64
65impl PlayerInfoTarget {
66    /// The UUID sent for this entry.
67    #[must_use]
68    pub const fn uuid(&self) -> Uuid {
69        match self {
70            Self::Uuid(uuid) => *uuid,
71            Self::Profile(profile) => profile.id(),
72        }
73    }
74}
75
76#[derive(Debug, Clone)]
77pub struct PlayerInfoEntry {
78    pub target: PlayerInfoTarget,
79    pub chat_session: Option<ProtocolRemoteChatSessionData>,
80    pub game_mode: Option<VarInt>,
81    pub listed: Option<bool>,
82    pub latency: Option<VarInt>,
83    pub display_name: Option<PlayerDisplayName>,
84    pub list_order: Option<VarInt>,
85    pub show_hat: Option<bool>,
86}
87
88impl PlayerInfoEntry {
89    /// Creates a new entry with only the UUID filled in.
90    #[must_use]
91    pub const fn new(uuid: Uuid) -> Self {
92        Self {
93            target: PlayerInfoTarget::Uuid(uuid),
94            chat_session: None,
95            game_mode: None,
96            listed: None,
97            latency: None,
98            display_name: None,
99            list_order: None,
100            show_hat: None,
101        }
102    }
103
104    /// Sets the latency field.
105    #[must_use]
106    pub const fn with_latency(mut self, latency: i32) -> Self {
107        self.latency = Some(VarInt(latency));
108        self
109    }
110
111    /// Sets the game mode field.
112    #[must_use]
113    pub const fn with_game_mode(mut self, game_mode: i32) -> Self {
114        self.game_mode = Some(VarInt(game_mode));
115        self
116    }
117
118    /// Sets the listed field.
119    #[must_use]
120    pub const fn with_listed(mut self, listed: bool) -> Self {
121        self.listed = Some(listed);
122        self
123    }
124
125    /// Sets the display name field.
126    #[must_use]
127    pub fn with_display_name(mut self, display_name: impl Into<PlayerDisplayName>) -> Self {
128        self.display_name = Some(display_name.into());
129        self
130    }
131
132    /// Sets the show hat field.
133    #[must_use]
134    pub const fn with_show_hat(mut self, show_hat: bool) -> Self {
135        self.show_hat = Some(show_hat);
136        self
137    }
138
139    /// Sets the list order field (controls sort order in tab list).
140    #[must_use]
141    pub const fn with_list_order(mut self, list_order: i32) -> Self {
142        self.list_order = Some(VarInt(list_order));
143        self
144    }
145
146    /// Sets the chat session field.
147    #[must_use]
148    pub fn with_chat_session(mut self, chat_session: ProtocolRemoteChatSessionData) -> Self {
149        self.chat_session = Some(chat_session);
150        self
151    }
152}
153
154#[derive(ClientPacket, Debug, Clone)]
155#[packet_id(Play = C_PLAYER_INFO_UPDATE)]
156pub struct CPlayerInfoUpdate {
157    pub actions: u8, // Bitmask of PlayerInfoAction
158    pub entries: Vec<PlayerInfoEntry>,
159}
160
161impl CPlayerInfoUpdate {
162    /// Creates the full initializing entry for a single player.
163    #[must_use]
164    pub fn create_player_initializing_entry(
165        profile: GameProfile,
166        game_mode: i32,
167        latency: i32,
168        display_name: Option<TextComponent>,
169        show_hat: bool,
170    ) -> PlayerInfoEntry {
171        PlayerInfoEntry {
172            target: PlayerInfoTarget::Profile(profile),
173            chat_session: None,
174            game_mode: Some(VarInt(game_mode)),
175            listed: Some(true),
176            latency: Some(VarInt(latency)),
177            display_name: Some(display_name.into()),
178            list_order: Some(VarInt(0)),
179            show_hat: Some(show_hat),
180        }
181    }
182
183    /// Creates a full player initializing packet with all information.
184    /// This is sent when a player joins to add them to the tab list.
185    #[must_use]
186    pub fn create_player_initializing(
187        profile: GameProfile,
188        game_mode: i32,
189        latency: i32,
190        display_name: Option<TextComponent>,
191        show_hat: bool,
192    ) -> Self {
193        Self {
194            actions: PLAYER_INFO_INIT_ACTIONS,
195            entries: vec![Self::create_player_initializing_entry(
196                profile,
197                game_mode,
198                latency,
199                display_name,
200                show_hat,
201            )],
202        }
203    }
204
205    /// Creates a packet to update a player's chat session.
206    #[must_use]
207    pub fn update_chat_session(uuid: Uuid, chat_session: ProtocolRemoteChatSessionData) -> Self {
208        Self {
209            actions: PlayerInfoAction::InitializeChat as u8,
210            entries: vec![PlayerInfoEntry::new(uuid).with_chat_session(chat_session)],
211        }
212    }
213
214    /// Creates a packet to update latency for multiple players.
215    /// This is sent periodically (every 600 ticks) to update ping display.
216    #[must_use]
217    pub fn update_latency(entries: Vec<(Uuid, i32)>) -> Self {
218        Self {
219            actions: PlayerInfoAction::UpdateLatency as u8,
220            entries: entries
221                .into_iter()
222                .map(|(uuid, latency)| PlayerInfoEntry::new(uuid).with_latency(latency))
223                .collect(),
224        }
225    }
226
227    /// Creates a packet to update a player's game mode.
228    #[must_use]
229    pub fn update_game_mode(uuid: Uuid, game_mode: i32) -> Self {
230        Self {
231            actions: PlayerInfoAction::UpdateGameMode as u8,
232            entries: vec![PlayerInfoEntry::new(uuid).with_game_mode(game_mode)],
233        }
234    }
235
236    /// Creates a packet to update a player's listed status (show/hide in tab list).
237    #[must_use]
238    pub fn update_listed(uuid: Uuid, listed: bool) -> Self {
239        Self {
240            actions: PlayerInfoAction::UpdateListed as u8,
241            entries: vec![PlayerInfoEntry::new(uuid).with_listed(listed)],
242        }
243    }
244
245    /// Creates a packet to update a player's display name.
246    #[must_use]
247    pub fn update_display_name(uuid: Uuid, display_name: Option<TextComponent>) -> Self {
248        Self {
249            actions: PlayerInfoAction::UpdateDisplayName as u8,
250            entries: vec![PlayerInfoEntry::new(uuid).with_display_name(display_name)],
251        }
252    }
253
254    /// Creates a packet to update a player's hat visibility.
255    #[must_use]
256    pub fn update_hat(uuid: Uuid, show_hat: bool) -> Self {
257        Self {
258            actions: PlayerInfoAction::UpdateHat as u8,
259            entries: vec![PlayerInfoEntry::new(uuid).with_show_hat(show_hat)],
260        }
261    }
262
263    /// Creates a packet to update a player's list order (sort position in tab list).
264    #[must_use]
265    pub fn update_list_order(uuid: Uuid, list_order: i32) -> Self {
266        Self {
267            actions: PlayerInfoAction::UpdateListOrder as u8,
268            entries: vec![PlayerInfoEntry::new(uuid).with_list_order(list_order)],
269        }
270    }
271}
272
273impl steel_utils::serial::WriteTo for CPlayerInfoUpdate {
274    fn write(&self, writer: &mut impl std::io::Write) -> std::io::Result<()> {
275        self.actions.write(writer)?;
276        VarInt(self.entries.len() as i32).write(writer)?;
277
278        for entry in &self.entries {
279            entry.target.uuid().write(writer)?;
280
281            if self.actions & (PlayerInfoAction::AddPlayer as u8) != 0 {
282                let PlayerInfoTarget::Profile(profile) = &entry.target else {
283                    return Err(std::io::Error::other(
284                        "AddPlayer entry requires a game profile",
285                    ));
286                };
287                profile.name().write_prefixed::<VarInt>(writer)?;
288                profile.write_properties(writer)?;
289            }
290
291            if self.actions & (PlayerInfoAction::InitializeChat as u8) != 0 {
292                // Write nullable chat session data
293                if let Some(session_data) = &entry.chat_session {
294                    true.write(writer)?;
295                    session_data.write(writer)?;
296                } else {
297                    false.write(writer)?;
298                }
299            }
300
301            if self.actions & (PlayerInfoAction::UpdateGameMode as u8) != 0 {
302                let game_mode = entry.game_mode.unwrap_or(VarInt(0));
303                game_mode.write(writer)?;
304            }
305
306            if self.actions & (PlayerInfoAction::UpdateListed as u8) != 0 {
307                let listed = entry.listed.unwrap_or(true);
308                listed.write(writer)?;
309            }
310
311            if self.actions & (PlayerInfoAction::UpdateLatency as u8) != 0 {
312                let latency = entry.latency.unwrap_or(VarInt(0));
313                latency.write(writer)?;
314            }
315
316            if self.actions & (PlayerInfoAction::UpdateDisplayName as u8) != 0 {
317                // Write as optional TextComponent (boolean + component if present)
318                match &entry.display_name {
319                    Some(PlayerDisplayName::Custom(display_name)) => {
320                        true.write(writer)?;
321                        display_name.write(writer)?;
322                    }
323                    Some(PlayerDisplayName::Reset) | None => {
324                        false.write(writer)?;
325                    }
326                }
327            }
328
329            if self.actions & (PlayerInfoAction::UpdateListOrder as u8) != 0 {
330                let list_order = entry.list_order.unwrap_or(VarInt(0));
331                list_order.write(writer)?;
332            }
333
334            if self.actions & (PlayerInfoAction::UpdateHat as u8) != 0 {
335                let show_hat = entry.show_hat.unwrap_or(true);
336                show_hat.write(writer)?;
337            }
338        }
339
340        Ok(())
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use std::io::Cursor;
347
348    use steel_utils::{
349        codec::VarInt,
350        serial::{PrefixedRead as _, ReadFrom as _, WriteTo as _},
351    };
352    use uuid::Uuid;
353
354    use steel_registry::{GameProfile, ProfileProperty};
355
356    use super::{CPlayerInfoUpdate, PLAYER_INFO_INIT_ACTIONS};
357
358    #[test]
359    fn initial_player_info_preserves_signed_texture_property() {
360        let uuid = Uuid::from_u128(1);
361        let profile = GameProfile::new(
362            uuid,
363            "CapePlayer".to_owned(),
364            vec![
365                ProfileProperty::new(
366                    "textures".to_owned(),
367                    "signed-texture-payload".to_owned(),
368                    Some("texture-signature".to_owned()),
369                )
370                .expect("valid property"),
371            ],
372        )
373        .expect("valid profile");
374        let packet = CPlayerInfoUpdate::create_player_initializing(profile, 0, 0, None, true);
375        let mut bytes = Vec::new();
376        packet.write(&mut bytes).expect("player info should encode");
377
378        let mut cursor = Cursor::new(bytes.as_slice());
379        assert_eq!(
380            u8::read(&mut cursor).expect("actions should decode"),
381            PLAYER_INFO_INIT_ACTIONS
382        );
383        assert_eq!(
384            VarInt::read(&mut cursor)
385                .expect("entry count should decode")
386                .0,
387            1
388        );
389        assert_eq!(
390            Uuid::read(&mut cursor).expect("entry UUID should decode"),
391            uuid
392        );
393        assert_eq!(
394            String::read_prefixed::<VarInt>(&mut cursor).expect("profile name should decode"),
395            "CapePlayer"
396        );
397        assert_eq!(
398            VarInt::read(&mut cursor)
399                .expect("property count should decode")
400                .0,
401            1
402        );
403        assert_eq!(
404            String::read_prefixed::<VarInt>(&mut cursor).expect("property name should decode"),
405            "textures"
406        );
407        assert_eq!(
408            String::read_prefixed::<VarInt>(&mut cursor).expect("property value should decode"),
409            "signed-texture-payload"
410        );
411        assert!(bool::read(&mut cursor).expect("signature presence should decode"));
412        assert_eq!(
413            String::read_prefixed::<VarInt>(&mut cursor).expect("signature should decode"),
414            "texture-signature"
415        );
416    }
417}