Skip to main content

steel_registry/
resolvable_profile.rs

1//! Vanilla resolvable player profiles used by item components and entity data.
2
3use std::io::{Cursor, Error, Result, Write};
4use std::ops::Deref;
5
6use simdnbt::owned::{NbtCompound, NbtList, NbtTag};
7use simdnbt::{FromNbtTag, ToNbtTag};
8use steel_utils::Identifier;
9use steel_utils::UuidExt as _;
10use steel_utils::codec::VarInt;
11use steel_utils::hash::{ComponentHasher, HashComponent, HashEntry, sort_map_entries};
12use steel_utils::serial::{PrefixedRead as _, PrefixedWrite as _, ReadFrom, WriteTo};
13use uuid::Uuid;
14
15const MAX_PROPERTIES: usize = 16;
16const MAX_PROPERTY_NAME: usize = 64;
17const MAX_PROPERTY_VALUE: usize = 32_767;
18const MAX_PROPERTY_SIGNATURE: usize = 1_024;
19const MAX_PLAYER_NAME: usize = 16;
20
21/// One authlib game-profile property.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct ProfileProperty {
24    name: String,
25    value: String,
26    signature: Option<String>,
27}
28
29impl ProfileProperty {
30    pub fn new(name: String, value: String, signature: Option<String>) -> Result<Self> {
31        validate_string(&name, MAX_PROPERTY_NAME, "Profile property name")?;
32        validate_string(&value, MAX_PROPERTY_VALUE, "Profile property value")?;
33        if let Some(signature) = &signature {
34            validate_string(
35                signature,
36                MAX_PROPERTY_SIGNATURE,
37                "Profile property signature",
38            )?;
39        }
40        Ok(Self {
41            name,
42            value,
43            signature,
44        })
45    }
46
47    #[must_use]
48    pub fn name(&self) -> &str {
49        &self.name
50    }
51
52    #[must_use]
53    pub fn value(&self) -> &str {
54        &self.value
55    }
56
57    #[must_use]
58    pub fn signature(&self) -> Option<&str> {
59        self.signature.as_deref()
60    }
61
62    fn to_nbt_compound(&self) -> NbtCompound {
63        let mut compound = NbtCompound::new();
64        compound.insert("name", self.name.clone());
65        compound.insert("value", self.value.clone());
66        if let Some(signature) = &self.signature {
67            compound.insert("signature", signature.clone());
68        }
69        compound
70    }
71
72    fn from_nbt_compound(compound: &NbtCompound) -> Option<Self> {
73        Self::new(
74            compound.get("name")?.string()?.to_string(),
75            compound.get("value")?.string()?.to_string(),
76            match compound.get("signature") {
77                Some(tag) => Some(tag.string()?.to_string()),
78                None => None,
79            },
80        )
81        .ok()
82    }
83}
84
85impl HashComponent for ProfileProperty {
86    fn hash_component(&self, hasher: &mut ComponentHasher) {
87        let mut entries = Vec::with_capacity(3);
88        push_hash_entry(&mut entries, "name", &self.name);
89        push_hash_entry(&mut entries, "value", &self.value);
90        if let Some(signature) = &self.signature {
91            push_hash_entry(&mut entries, "signature", signature);
92        }
93        hash_entries(hasher, &mut entries);
94    }
95}
96
97/// Authlib property-map values grouped by first key occurrence.
98#[derive(Debug, Default, Clone)]
99struct ProfileProperties(Vec<ProfileProperty>);
100
101impl ProfileProperties {
102    fn new(properties: Vec<ProfileProperty>) -> Result<Self> {
103        validate_properties(&properties)?;
104        let mut grouped = Vec::with_capacity(properties.len());
105        for property in properties {
106            let insertion_index = grouped
107                .iter()
108                .rposition(|existing: &ProfileProperty| existing.name() == property.name())
109                .map(|index| index + 1);
110            if let Some(index) = insertion_index {
111                grouped.insert(index, property);
112            } else {
113                grouped.push(property);
114            }
115        }
116        Ok(Self(grouped))
117    }
118}
119
120impl Deref for ProfileProperties {
121    type Target = [ProfileProperty];
122
123    fn deref(&self) -> &Self::Target {
124        &self.0
125    }
126}
127
128impl PartialEq for ProfileProperties {
129    fn eq(&self, other: &Self) -> bool {
130        if self.len() != other.len() {
131            return false;
132        }
133
134        let mut left_start = 0;
135        while let Some(first) = self.get(left_start) {
136            let name = first.name();
137            let left_end = property_group_end(self, left_start);
138            let Some(right_start) = other.iter().position(|property| property.name() == name)
139            else {
140                return false;
141            };
142            let right_end = property_group_end(other, right_start);
143            if self[left_start..left_end] != other[right_start..right_end] {
144                return false;
145            }
146            left_start = left_end;
147        }
148        true
149    }
150}
151
152impl Eq for ProfileProperties {}
153
154fn property_group_end(properties: &[ProfileProperty], start: usize) -> usize {
155    let Some(first) = properties.get(start) else {
156        return start;
157    };
158    properties[start + 1..]
159        .iter()
160        .position(|property| property.name() != first.name())
161        .map_or(properties.len(), |offset| start + offset + 1)
162}
163
164/// Complete authlib profile contents.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct StoredGameProfile {
167    id: Uuid,
168    name: String,
169    properties: ProfileProperties,
170}
171
172impl StoredGameProfile {
173    pub fn new(id: Uuid, name: String, properties: Vec<ProfileProperty>) -> Result<Self> {
174        validate_player_name(&name)?;
175        Ok(Self {
176            id,
177            name,
178            properties: ProfileProperties::new(properties)?,
179        })
180    }
181
182    #[must_use]
183    pub const fn id(&self) -> Uuid {
184        self.id
185    }
186
187    #[must_use]
188    pub fn name(&self) -> &str {
189        &self.name
190    }
191
192    #[must_use]
193    pub fn properties(&self) -> &[ProfileProperty] {
194        &self.properties
195    }
196}
197
198/// Profile fields that may still require resolution.
199#[derive(Debug, Default, Clone, PartialEq, Eq)]
200pub struct PartialProfile {
201    name: Option<String>,
202    id: Option<Uuid>,
203    properties: ProfileProperties,
204}
205
206impl PartialProfile {
207    pub fn new(
208        name: Option<String>,
209        id: Option<Uuid>,
210        properties: Vec<ProfileProperty>,
211    ) -> Result<Self> {
212        if let Some(name) = &name {
213            validate_player_name(name)?;
214        }
215        Ok(Self {
216            name,
217            id,
218            properties: ProfileProperties::new(properties)?,
219        })
220    }
221
222    #[must_use]
223    pub fn name(&self) -> Option<&str> {
224        self.name.as_deref()
225    }
226
227    #[must_use]
228    pub const fn id(&self) -> Option<Uuid> {
229        self.id
230    }
231
232    #[must_use]
233    pub fn properties(&self) -> &[ProfileProperty] {
234        &self.properties
235    }
236}
237
238/// Optional resource-pack skin model override.
239#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
240pub enum PlayerModelType {
241    Slim,
242    #[default]
243    Wide,
244}
245
246impl PlayerModelType {
247    #[must_use]
248    pub const fn serialized_name(self) -> &'static str {
249        match self {
250            Self::Slim => "slim",
251            Self::Wide => "wide",
252        }
253    }
254
255    const fn from_serialized_name(name: &str) -> Option<Self> {
256        match name {
257            "slim" => Some(Self::Slim),
258            "wide" => Some(Self::Wide),
259            _ => None,
260        }
261    }
262}
263
264/// Resource-pack skin assets applied over a resolved profile.
265#[derive(Debug, Default, Clone, PartialEq, Eq)]
266pub struct PlayerSkinPatch {
267    texture: Option<Identifier>,
268    cape: Option<Identifier>,
269    elytra: Option<Identifier>,
270    model: Option<PlayerModelType>,
271}
272
273impl PlayerSkinPatch {
274    #[must_use]
275    pub const fn new(
276        texture: Option<Identifier>,
277        cape: Option<Identifier>,
278        elytra: Option<Identifier>,
279        model: Option<PlayerModelType>,
280    ) -> Self {
281        Self {
282            texture,
283            cape,
284            elytra,
285            model,
286        }
287    }
288
289    #[must_use]
290    pub const fn texture(&self) -> Option<&Identifier> {
291        self.texture.as_ref()
292    }
293
294    #[must_use]
295    pub const fn cape(&self) -> Option<&Identifier> {
296        self.cape.as_ref()
297    }
298
299    #[must_use]
300    pub const fn elytra(&self) -> Option<&Identifier> {
301        self.elytra.as_ref()
302    }
303
304    #[must_use]
305    pub const fn model(&self) -> Option<PlayerModelType> {
306        self.model
307    }
308}
309
310/// Resolved, partial, or dynamically resolvable profile contents.
311#[derive(Debug, Clone, PartialEq, Eq)]
312pub enum ResolvableProfileContents {
313    DynamicName(String),
314    DynamicId(Uuid),
315    StaticFull(StoredGameProfile),
316    StaticPartial(PartialProfile),
317}
318
319/// Profile component with an optional resource-pack skin patch.
320#[derive(Debug, Clone, PartialEq, Eq)]
321pub struct ResolvableProfile {
322    contents: ResolvableProfileContents,
323    skin_patch: PlayerSkinPatch,
324}
325
326impl Default for ResolvableProfile {
327    fn default() -> Self {
328        Self::static_partial(PartialProfile::default(), PlayerSkinPatch::default())
329    }
330}
331
332impl ResolvableProfile {
333    pub fn dynamic_name(name: String, skin_patch: PlayerSkinPatch) -> Result<Self> {
334        validate_player_name(&name)?;
335        Ok(Self {
336            contents: ResolvableProfileContents::DynamicName(name),
337            skin_patch,
338        })
339    }
340
341    #[must_use]
342    pub const fn dynamic_id(id: Uuid, skin_patch: PlayerSkinPatch) -> Self {
343        Self {
344            contents: ResolvableProfileContents::DynamicId(id),
345            skin_patch,
346        }
347    }
348
349    #[must_use]
350    pub const fn static_full(profile: StoredGameProfile, skin_patch: PlayerSkinPatch) -> Self {
351        Self {
352            contents: ResolvableProfileContents::StaticFull(profile),
353            skin_patch,
354        }
355    }
356
357    #[must_use]
358    pub const fn static_partial(profile: PartialProfile, skin_patch: PlayerSkinPatch) -> Self {
359        Self {
360            contents: ResolvableProfileContents::StaticPartial(profile),
361            skin_patch,
362        }
363    }
364
365    #[must_use]
366    pub const fn contents(&self) -> &ResolvableProfileContents {
367        &self.contents
368    }
369
370    #[must_use]
371    pub const fn skin_patch(&self) -> &PlayerSkinPatch {
372        &self.skin_patch
373    }
374
375    fn from_partial(profile: PartialProfile, skin_patch: PlayerSkinPatch) -> Self {
376        if profile.properties.is_empty() {
377            match (&profile.name, profile.id) {
378                (Some(name), None) => {
379                    return Self {
380                        contents: ResolvableProfileContents::DynamicName(name.clone()),
381                        skin_patch,
382                    };
383                }
384                (None, Some(id)) => return Self::dynamic_id(id, skin_patch),
385                (Some(_), Some(_)) | (None, None) => {}
386            }
387        }
388        Self::static_partial(profile, skin_patch)
389    }
390
391    fn to_nbt_tag_ref(&self) -> NbtTag {
392        let mut compound = NbtCompound::new();
393        match &self.contents {
394            ResolvableProfileContents::StaticFull(profile) => {
395                compound.insert("id", uuid_to_nbt(profile.id));
396                compound.insert("name", profile.name.clone());
397                insert_properties(&mut compound, &profile.properties);
398            }
399            ResolvableProfileContents::DynamicName(name) => {
400                compound.insert("name", name.clone());
401            }
402            ResolvableProfileContents::DynamicId(id) => {
403                compound.insert("id", uuid_to_nbt(*id));
404            }
405            ResolvableProfileContents::StaticPartial(profile) => {
406                if let Some(name) = &profile.name {
407                    compound.insert("name", name.clone());
408                }
409                if let Some(id) = profile.id {
410                    compound.insert("id", uuid_to_nbt(id));
411                }
412                insert_properties(&mut compound, &profile.properties);
413            }
414        }
415        insert_skin_patch(&mut compound, &self.skin_patch);
416        NbtTag::Compound(compound)
417    }
418
419    fn from_owned_nbt(tag: &NbtTag) -> Option<Self> {
420        if let Some(name) = tag.string() {
421            return Self::dynamic_name(name.to_string(), PlayerSkinPatch::default()).ok();
422        }
423
424        let compound = tag.compound()?;
425        let name = match compound.get("name") {
426            Some(tag) => Some(tag.string()?.to_string()),
427            None => None,
428        };
429        let id = match compound.get("id") {
430            Some(tag) => Some(uuid_from_nbt(tag)?),
431            None => None,
432        };
433        let properties = read_properties(compound.get("properties"))?;
434        let skin_patch = read_skin_patch(compound)?;
435
436        if let (Some(id), Some(name)) = (id, &name) {
437            return Some(Self::static_full(
438                StoredGameProfile::new(id, name.clone(), properties).ok()?,
439                skin_patch,
440            ));
441        }
442        let partial = PartialProfile::new(name, id, properties).ok()?;
443        Some(Self::from_partial(partial, skin_patch))
444    }
445}
446
447impl WriteTo for ResolvableProfile {
448    fn write(&self, writer: &mut impl Write) -> Result<()> {
449        match &self.contents {
450            ResolvableProfileContents::StaticFull(profile) => {
451                true.write(writer)?;
452                write_full_profile(profile, writer)?;
453            }
454            ResolvableProfileContents::DynamicName(name) => {
455                false.write(writer)?;
456                write_partial_profile_fields(Some(name), None, &[], writer)?;
457            }
458            ResolvableProfileContents::DynamicId(id) => {
459                false.write(writer)?;
460                write_partial_profile_fields(None, Some(*id), &[], writer)?;
461            }
462            ResolvableProfileContents::StaticPartial(profile) => {
463                false.write(writer)?;
464                write_partial_profile(profile, writer)?;
465            }
466        }
467        write_skin_patch(&self.skin_patch, writer)
468    }
469}
470
471impl ReadFrom for ResolvableProfile {
472    fn read(data: &mut Cursor<&[u8]>) -> Result<Self> {
473        let contents = if bool::read(data)? {
474            ResolvableProfileContents::StaticFull(read_full_profile(data)?)
475        } else {
476            let partial = read_partial_profile(data)?;
477            let patch = read_skin_patch_network(data)?;
478            return Ok(Self::from_partial(partial, patch));
479        };
480        Ok(Self {
481            contents,
482            skin_patch: read_skin_patch_network(data)?,
483        })
484    }
485}
486
487impl ToNbtTag for ResolvableProfile {
488    fn to_nbt_tag(self) -> NbtTag {
489        self.to_nbt_tag_ref()
490    }
491}
492
493impl FromNbtTag for ResolvableProfile {
494    fn from_nbt_tag(tag: simdnbt::borrow::NbtTag) -> Option<Self> {
495        Self::from_owned_nbt(&tag.to_owned())
496    }
497}
498
499impl HashComponent for ResolvableProfile {
500    fn hash_component(&self, hasher: &mut ComponentHasher) {
501        let mut entries = Vec::new();
502        match &self.contents {
503            ResolvableProfileContents::StaticFull(profile) => {
504                push_uuid_hash_entry(&mut entries, "id", profile.id);
505                push_hash_entry(&mut entries, "name", &profile.name);
506                push_properties_hash_entry(&mut entries, &profile.properties);
507            }
508            ResolvableProfileContents::DynamicName(name) => {
509                push_hash_entry(&mut entries, "name", name);
510            }
511            ResolvableProfileContents::DynamicId(id) => {
512                push_uuid_hash_entry(&mut entries, "id", *id);
513            }
514            ResolvableProfileContents::StaticPartial(profile) => {
515                if let Some(name) = &profile.name {
516                    push_hash_entry(&mut entries, "name", name);
517                }
518                if let Some(id) = profile.id {
519                    push_uuid_hash_entry(&mut entries, "id", id);
520                }
521                push_properties_hash_entry(&mut entries, &profile.properties);
522            }
523        }
524        if let Some(texture) = &self.skin_patch.texture {
525            push_hash_entry(&mut entries, "texture", texture);
526        }
527        if let Some(cape) = &self.skin_patch.cape {
528            push_hash_entry(&mut entries, "cape", cape);
529        }
530        if let Some(elytra) = &self.skin_patch.elytra {
531            push_hash_entry(&mut entries, "elytra", elytra);
532        }
533        if let Some(model) = self.skin_patch.model {
534            push_hash_entry(&mut entries, "model", model.serialized_name());
535        }
536        hash_entries(hasher, &mut entries);
537    }
538}
539
540fn validate_player_name(name: &str) -> Result<()> {
541    if name.encode_utf16().count() > MAX_PLAYER_NAME
542        || name
543            .chars()
544            .any(|character| character <= ' ' || character >= '\u{7f}')
545    {
546        return Err(Error::other("Invalid player profile name"));
547    }
548    Ok(())
549}
550
551fn validate_properties(properties: &[ProfileProperty]) -> Result<()> {
552    if properties.len() > MAX_PROPERTIES {
553        return Err(Error::other("A profile may contain at most 16 properties"));
554    }
555    Ok(())
556}
557
558fn validate_string(value: &str, max: usize, name: &str) -> Result<()> {
559    if value.encode_utf16().count() > max || value.len() > max.saturating_mul(3) {
560        return Err(Error::other(format!(
561            "{name} exceeds its {max}-character limit"
562        )));
563    }
564    Ok(())
565}
566
567fn uuid_to_nbt(id: Uuid) -> NbtTag {
568    NbtTag::IntArray(id.to_int_array().to_vec())
569}
570
571fn uuid_from_nbt(tag: &NbtTag) -> Option<Uuid> {
572    Uuid::from_int_array(tag.int_array()?)
573}
574
575fn insert_properties(compound: &mut NbtCompound, properties: &[ProfileProperty]) {
576    if !properties.is_empty() {
577        compound.insert(
578            "properties",
579            NbtList::Compound(
580                properties
581                    .iter()
582                    .map(ProfileProperty::to_nbt_compound)
583                    .collect(),
584            ),
585        );
586    }
587}
588
589fn read_properties(tag: Option<&NbtTag>) -> Option<Vec<ProfileProperty>> {
590    let Some(tag) = tag else {
591        return Some(Vec::new());
592    };
593    if let Some(list) = tag.list() {
594        let list = list.as_nbt_tags();
595        if list.len() > MAX_PROPERTIES {
596            return None;
597        }
598        return list
599            .iter()
600            .map(|tag| ProfileProperty::from_nbt_compound(tag.compound()?))
601            .collect();
602    }
603
604    let map = tag.compound()?;
605    if map.len() > MAX_PROPERTIES {
606        return None;
607    }
608    let mut properties = Vec::new();
609    for (name, values) in map.iter() {
610        let values = values.list()?;
611        match values {
612            NbtList::Empty => {}
613            NbtList::String(values) => {
614                for value in values {
615                    properties.push(
616                        ProfileProperty::new(name.to_string(), value.to_string(), None).ok()?,
617                    );
618                }
619            }
620            _ => return None,
621        }
622        if properties.len() > MAX_PROPERTIES {
623            return None;
624        }
625    }
626    Some(properties)
627}
628
629fn insert_skin_patch(compound: &mut NbtCompound, patch: &PlayerSkinPatch) {
630    if let Some(texture) = &patch.texture {
631        compound.insert("texture", texture.to_string());
632    }
633    if let Some(cape) = &patch.cape {
634        compound.insert("cape", cape.to_string());
635    }
636    if let Some(elytra) = &patch.elytra {
637        compound.insert("elytra", elytra.to_string());
638    }
639    if let Some(model) = patch.model {
640        compound.insert("model", model.serialized_name());
641    }
642}
643
644fn read_skin_patch(compound: &NbtCompound) -> Option<PlayerSkinPatch> {
645    Some(PlayerSkinPatch::new(
646        read_optional_identifier(compound.get("texture"))?,
647        read_optional_identifier(compound.get("cape"))?,
648        read_optional_identifier(compound.get("elytra"))?,
649        match compound.get("model") {
650            Some(tag) => Some(PlayerModelType::from_serialized_name(
651                &tag.string()?.to_string(),
652            )?),
653            None => None,
654        },
655    ))
656}
657
658#[expect(
659    clippy::option_option,
660    reason = "the outer option reports codec failure while the inner option represents an absent field"
661)]
662fn read_optional_identifier(tag: Option<&NbtTag>) -> Option<Option<Identifier>> {
663    match tag {
664        Some(tag) => Some(Some(tag.string()?.to_string().parse().ok()?)),
665        None => Some(None),
666    }
667}
668
669fn write_full_profile(profile: &StoredGameProfile, writer: &mut impl Write) -> Result<()> {
670    profile.id.write(writer)?;
671    write_string(&profile.name, MAX_PLAYER_NAME, writer)?;
672    write_properties_network(&profile.properties, writer)
673}
674
675fn read_full_profile(data: &mut Cursor<&[u8]>) -> Result<StoredGameProfile> {
676    StoredGameProfile::new(
677        Uuid::read(data)?,
678        read_string(data, MAX_PLAYER_NAME)?,
679        read_properties_network(data)?,
680    )
681}
682
683fn write_partial_profile(profile: &PartialProfile, writer: &mut impl Write) -> Result<()> {
684    write_partial_profile_fields(
685        profile.name.as_deref(),
686        profile.id,
687        &profile.properties,
688        writer,
689    )
690}
691
692fn write_partial_profile_fields(
693    name: Option<&str>,
694    id: Option<Uuid>,
695    properties: &[ProfileProperty],
696    writer: &mut impl Write,
697) -> Result<()> {
698    name.is_some().write(writer)?;
699    if let Some(name) = name {
700        write_string(name, MAX_PLAYER_NAME, writer)?;
701    }
702    id.is_some().write(writer)?;
703    if let Some(id) = id {
704        id.write(writer)?;
705    }
706    write_properties_network(properties, writer)
707}
708
709fn read_partial_profile(data: &mut Cursor<&[u8]>) -> Result<PartialProfile> {
710    let name = if bool::read(data)? {
711        Some(read_string(data, MAX_PLAYER_NAME)?)
712    } else {
713        None
714    };
715    let id = if bool::read(data)? {
716        Some(Uuid::read(data)?)
717    } else {
718        None
719    };
720    PartialProfile::new(name, id, read_properties_network(data)?)
721}
722
723fn write_properties_network(properties: &[ProfileProperty], writer: &mut impl Write) -> Result<()> {
724    validate_properties(properties)?;
725    VarInt(properties.len() as i32).write(writer)?;
726    for property in properties {
727        write_string(&property.name, MAX_PROPERTY_NAME, writer)?;
728        write_string(&property.value, MAX_PROPERTY_VALUE, writer)?;
729        property.signature.is_some().write(writer)?;
730        if let Some(signature) = &property.signature {
731            write_string(signature, MAX_PROPERTY_SIGNATURE, writer)?;
732        }
733    }
734    Ok(())
735}
736
737fn read_properties_network(data: &mut Cursor<&[u8]>) -> Result<Vec<ProfileProperty>> {
738    let count = VarInt::read(data)?.0;
739    let count = usize::try_from(count)
740        .map_err(|_| Error::other(format!("Negative profile property count: {count}")))?;
741    if count > MAX_PROPERTIES {
742        return Err(Error::other("A profile may contain at most 16 properties"));
743    }
744    let mut properties = Vec::with_capacity(count);
745    for _ in 0..count {
746        properties.push(ProfileProperty::new(
747            read_string(data, MAX_PROPERTY_NAME)?,
748            read_string(data, MAX_PROPERTY_VALUE)?,
749            if bool::read(data)? {
750                Some(read_string(data, MAX_PROPERTY_SIGNATURE)?)
751            } else {
752                None
753            },
754        )?);
755    }
756    Ok(properties)
757}
758
759fn write_skin_patch(patch: &PlayerSkinPatch, writer: &mut impl Write) -> Result<()> {
760    for texture in [&patch.texture, &patch.cape, &patch.elytra] {
761        texture.is_some().write(writer)?;
762        if let Some(texture) = texture {
763            texture.write(writer)?;
764        }
765    }
766    patch.model.is_some().write(writer)?;
767    if let Some(model) = patch.model {
768        (model == PlayerModelType::Slim).write(writer)?;
769    }
770    Ok(())
771}
772
773fn read_skin_patch_network(data: &mut Cursor<&[u8]>) -> Result<PlayerSkinPatch> {
774    let texture = read_optional_identifier_network(data)?;
775    let cape = read_optional_identifier_network(data)?;
776    let elytra = read_optional_identifier_network(data)?;
777    let model = if bool::read(data)? {
778        Some(if bool::read(data)? {
779            PlayerModelType::Slim
780        } else {
781            PlayerModelType::Wide
782        })
783    } else {
784        None
785    };
786    Ok(PlayerSkinPatch::new(texture, cape, elytra, model))
787}
788
789fn read_optional_identifier_network(data: &mut Cursor<&[u8]>) -> Result<Option<Identifier>> {
790    if bool::read(data)? {
791        Ok(Some(Identifier::read(data)?))
792    } else {
793        Ok(None)
794    }
795}
796
797fn write_string(value: &str, max: usize, writer: &mut impl Write) -> Result<()> {
798    validate_string(value, max, "Profile string")?;
799    value.write_prefixed::<VarInt>(writer)
800}
801
802fn read_string(data: &mut Cursor<&[u8]>, max: usize) -> Result<String> {
803    let value = String::read_prefixed_bound::<VarInt>(data, max.saturating_mul(3))?;
804    validate_string(&value, max, "Profile string")?;
805    Ok(value)
806}
807
808struct ProfilePropertyList<'a>(&'a [ProfileProperty]);
809
810impl HashComponent for ProfilePropertyList<'_> {
811    fn hash_component(&self, hasher: &mut ComponentHasher) {
812        hasher.start_list();
813        for property in self.0 {
814            hasher.put_component_hash(property);
815        }
816        hasher.end_list();
817    }
818}
819
820fn push_uuid_hash_entry(entries: &mut Vec<HashEntry>, key: &str, id: Uuid) {
821    let values = id.to_int_array();
822    let mut key_hasher = ComponentHasher::new();
823    key_hasher.put_string(key);
824    let mut value_hasher = ComponentHasher::new();
825    value_hasher.put_int_array(&values);
826    entries.push(HashEntry::new(key_hasher, value_hasher));
827}
828
829fn push_properties_hash_entry(entries: &mut Vec<HashEntry>, properties: &[ProfileProperty]) {
830    if !properties.is_empty() {
831        push_hash_entry(entries, "properties", &ProfilePropertyList(properties));
832    }
833}
834
835fn push_hash_entry<T: HashComponent + ?Sized>(entries: &mut Vec<HashEntry>, key: &str, value: &T) {
836    let mut key_hasher = ComponentHasher::new();
837    key_hasher.put_string(key);
838    let mut value_hasher = ComponentHasher::new();
839    value.hash_component(&mut value_hasher);
840    entries.push(HashEntry::new(key_hasher, value_hasher));
841}
842
843fn hash_entries(hasher: &mut ComponentHasher, entries: &mut [HashEntry]) {
844    sort_map_entries(entries);
845    hasher.start_map();
846    for entry in entries {
847        hasher.put_raw_bytes(&entry.key_bytes);
848        hasher.put_raw_bytes(&entry.value_bytes);
849    }
850    hasher.end_map();
851}
852
853#[cfg(test)]
854mod tests {
855    use std::io::Cursor;
856
857    use simdnbt::{FromNbtTag as _, ToNbtTag as _};
858    use steel_utils::Identifier;
859    use steel_utils::hash::HashComponent as _;
860    use steel_utils::serial::{ReadFrom as _, WriteTo as _};
861    use uuid::Uuid;
862
863    use super::{
864        PlayerModelType, PlayerSkinPatch, ProfileProperty, ResolvableProfile,
865        ResolvableProfileContents, StoredGameProfile, read_properties_network,
866        write_properties_network,
867    };
868
869    fn parse(tag: simdnbt::owned::NbtTag) -> Option<ResolvableProfile> {
870        let mut bytes = Vec::new();
871        tag.write(&mut bytes);
872        let borrowed = simdnbt::borrow::read_tag(&mut Cursor::new(bytes.as_slice())).ok()?;
873        ResolvableProfile::from_nbt_tag(borrowed.as_tag())
874    }
875
876    #[test]
877    fn full_profile_round_trips_both_codecs_and_hashes_primary_shape() {
878        let profile = ResolvableProfile::static_full(
879            StoredGameProfile::new(
880                Uuid::from_u128(1),
881                "Steve".to_owned(),
882                vec![
883                    ProfileProperty::new(
884                        "textures".to_owned(),
885                        "value".to_owned(),
886                        Some("signature".to_owned()),
887                    )
888                    .expect("valid property"),
889                ],
890            )
891            .expect("valid profile"),
892            PlayerSkinPatch::new(
893                Some(Identifier::vanilla_static("steve")),
894                None,
895                None,
896                Some(PlayerModelType::Wide),
897            ),
898        );
899        let nbt = profile.clone().to_nbt_tag();
900        assert_eq!(parse(nbt.clone()), Some(profile.clone()));
901        assert_eq!(profile.compute_hash(), nbt.compute_hash());
902
903        let mut network = Vec::new();
904        profile.write(&mut network).expect("profile should encode");
905        assert_eq!(
906            ResolvableProfile::read(&mut Cursor::new(network.as_slice()))
907                .expect("profile should decode"),
908            profile
909        );
910    }
911
912    #[test]
913    fn player_name_alternative_normalizes_to_dynamic_profile() {
914        let parsed = parse(simdnbt::owned::NbtTag::String("Alex".into()))
915            .expect("name alternative should decode");
916        assert_eq!(
917            parsed.contents(),
918            &ResolvableProfileContents::DynamicName("Alex".to_owned())
919        );
920        assert_eq!(parse(parsed.clone().to_nbt_tag()), Some(parsed));
921    }
922
923    #[test]
924    fn properties_use_authlib_multimap_order_and_equality() {
925        let first = StoredGameProfile::new(
926            Uuid::nil(),
927            "Alex".to_owned(),
928            vec![
929                ProfileProperty::new("textures".to_owned(), "first".to_owned(), None)
930                    .expect("valid property"),
931                ProfileProperty::new("cape".to_owned(), "only".to_owned(), None)
932                    .expect("valid property"),
933                ProfileProperty::new("textures".to_owned(), "second".to_owned(), None)
934                    .expect("valid property"),
935            ],
936        )
937        .expect("valid profile");
938        let cross_key_reordered = StoredGameProfile::new(
939            Uuid::nil(),
940            "Alex".to_owned(),
941            vec![
942                ProfileProperty::new("cape".to_owned(), "only".to_owned(), None)
943                    .expect("valid property"),
944                ProfileProperty::new("textures".to_owned(), "first".to_owned(), None)
945                    .expect("valid property"),
946                ProfileProperty::new("textures".to_owned(), "second".to_owned(), None)
947                    .expect("valid property"),
948            ],
949        )
950        .expect("valid profile");
951        let same_key_reordered = StoredGameProfile::new(
952            Uuid::nil(),
953            "Alex".to_owned(),
954            vec![
955                ProfileProperty::new("textures".to_owned(), "second".to_owned(), None)
956                    .expect("valid property"),
957                ProfileProperty::new("textures".to_owned(), "first".to_owned(), None)
958                    .expect("valid property"),
959                ProfileProperty::new("cape".to_owned(), "only".to_owned(), None)
960                    .expect("valid property"),
961            ],
962        )
963        .expect("valid profile");
964
965        assert_eq!(
966            first
967                .properties()
968                .iter()
969                .map(|property| (property.name(), property.value()))
970                .collect::<Vec<_>>(),
971            vec![
972                ("textures", "first"),
973                ("textures", "second"),
974                ("cape", "only")
975            ]
976        );
977        assert_eq!(first, cross_key_reordered);
978        assert_ne!(first, same_key_reordered);
979
980        let mut network = Vec::new();
981        write_properties_network(first.properties(), &mut network)
982            .expect("properties should encode");
983        let decoded = read_properties_network(&mut Cursor::new(network.as_slice()))
984            .expect("properties should decode");
985        assert_eq!(decoded, first.properties());
986    }
987
988    #[test]
989    fn profile_limits_prevent_values_that_cannot_be_reencoded() {
990        assert!(
991            ResolvableProfile::dynamic_name("a".repeat(17), PlayerSkinPatch::default()).is_err()
992        );
993        let properties = (0..17)
994            .map(|index| {
995                ProfileProperty::new(format!("p{index}"), "value".to_owned(), None)
996                    .expect("test property should be valid")
997            })
998            .collect();
999        assert!(StoredGameProfile::new(Uuid::nil(), "A".to_owned(), properties).is_err());
1000    }
1001}