Skip to main content

rakata_core/
resref.rs

1use std::fmt::{Display, Formatter};
2use std::str::FromStr;
3use thiserror::Error;
4
5use crate::text::{decode_text, encode_text, EncodeTextError, TextEncoding};
6
7/// Maximum number of bytes for a KotOR resource reference.
8pub const MAX_RESREF_LEN: usize = 16;
9
10/// Error returned when constructing a [`ResRef`] fails validation.
11#[derive(Debug, Clone, PartialEq, Eq, Error)]
12pub enum ResRefError {
13    /// Input exceeded the maximum allowed resref length, measured in
14    /// the Windows-1252 encoding the engine actually stores.
15    #[error("resref length {len} exceeds maximum {max} (Windows-1252 bytes)")]
16    TooLong {
17        /// Actual input length in Windows-1252 bytes.
18        len: usize,
19        /// Maximum allowed length.
20        max: usize,
21    },
22    /// Input contained a Unicode character with no representation in
23    /// the engine's Windows-1252 encoding (e.g., Chinese, emoji).
24    #[error("invalid resref character '{ch}': no Windows-1252 mapping")]
25    InvalidChar {
26        /// The unencodable character.
27        ch: char,
28    },
29}
30
31impl From<EncodeTextError> for ResRefError {
32    fn from(err: EncodeTextError) -> Self {
33        Self::InvalidChar { ch: err.character }
34    }
35}
36
37/// Canonicalized resource reference.
38///
39/// KotOR resource references are case-insensitive identifiers up to
40/// 16 bytes long, stored as Windows-1252-encoded bytes (the engine's
41/// native encoding). This type holds the bytes in an inline fixed-size
42/// buffer, making it `Copy` and zero-allocation.
43///
44/// ## Validation rules
45///
46/// Accepts any input that round-trips through Windows-1252 encoding.
47/// ASCII bytes pass straight through; non-ASCII chars that have a
48/// Windows-1252 representation (`é`, `ü`, `£`, etc.) get transcoded
49/// to their single-byte Windows-1252 form. Characters with no
50/// Windows-1252 mapping (Chinese, emoji, etc.) are rejected.
51///
52/// The engine itself performs no character validation at all (verbatim
53/// memcpy into a 16-byte buffer). Validation here exists so that every
54/// constructed resref can be stored in the engine-native encoding.
55/// For the full engine audit, see the
56/// **ResRef Validation** section of
57/// `docs/src/formats/resource_system.md`.
58///
59/// ## Storage and access
60///
61/// Use [`Self::as_bytes`] for byte-level work (writing to disk,
62/// hashing, lint inspection, byte-level comparison). For a string
63/// view, use the `Display` impl (e.g. `format!("{resref}")` or
64/// `resref.to_string()`); it decodes Windows-1252 → UTF-8 on demand.
65#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
66#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
67#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
68pub struct ResRef {
69    bytes: [u8; MAX_RESREF_LEN],
70    len: u8,
71}
72
73impl ResRef {
74    /// Creates and validates a new resource reference.
75    ///
76    /// The input is transcoded from UTF-8 to Windows-1252 (the engine's
77    /// native encoding); ASCII letters are lowercased for the
78    /// engine's case-insensitive lookup. An empty string produces a
79    /// blank ResRef.
80    ///
81    /// # Errors
82    ///
83    /// [`ResRefError::InvalidChar`] when a character has no
84    /// Windows-1252 mapping (Chinese, emoji). [`ResRefError::TooLong`]
85    /// when the encoded form exceeds 16 bytes, which is measured after
86    /// transcoding, so a name of 16 characters can still be too long.
87    pub fn new(value: impl AsRef<str>) -> Result<Self, ResRefError> {
88        let encoded = encode_text(value.as_ref(), TextEncoding::Windows1252)?;
89        if encoded.len() > MAX_RESREF_LEN {
90            return Err(ResRefError::TooLong {
91                len: encoded.len(),
92                max: MAX_RESREF_LEN,
93            });
94        }
95        let mut bytes = [0u8; MAX_RESREF_LEN];
96        for (i, &b) in encoded.iter().enumerate() {
97            // Lowercase ASCII letters; leave Windows-1252 0x80+ bytes
98            // untouched. The engine's case folding for the extended
99            // range would need an unaudited Windows-1252 case table,
100            // and vanilla content does not use these bytes.
101            bytes[i] = b.to_ascii_lowercase();
102        }
103        let len = u8::try_from(encoded.len()).expect("len already bounded by MAX_RESREF_LEN");
104        Ok(Self { bytes, len })
105    }
106
107    /// `const`-friendly version of [`Self::new`] for declaring
108    /// `pub const` resref constants at compile time.
109    ///
110    /// Restricted to ASCII-only input (Windows-1252 transcoding is
111    /// not const-friendly). Use [`Self::new`] for runtime input
112    /// that may contain extended Windows-1252 characters.
113    ///
114    /// # Errors
115    ///
116    /// [`ResRefError::TooLong`] above 16 bytes, and
117    /// [`ResRefError::InvalidChar`] for any non-ASCII byte. That second
118    /// case is narrower than [`Self::new`]'s: a character this rejects
119    /// may well be one the engine can store.
120    pub const fn const_new(value: &str) -> Result<Self, ResRefError> {
121        let raw = value.as_bytes();
122        if raw.len() > MAX_RESREF_LEN {
123            return Err(ResRefError::TooLong {
124                len: raw.len(),
125                max: MAX_RESREF_LEN,
126            });
127        }
128        let mut bytes = [0u8; MAX_RESREF_LEN];
129        let mut i = 0;
130        while i < raw.len() {
131            let b = raw[i];
132            if !b.is_ascii() {
133                // CLIPPY: char::from(u8) is not const-stable, and every
134                // u8 is a valid Unicode scalar value (latin-1 range) so
135                // the cast is sound.
136                #[allow(clippy::as_conversions)]
137                return Err(ResRefError::InvalidChar { ch: b as char });
138            }
139            bytes[i] = b.to_ascii_lowercase();
140            i += 1;
141        }
142        // CLIPPY: u8::try_from is not const-stable as of 1.85, and the
143        // length is already bounded above by MAX_RESREF_LEN = 16, so
144        // truncation cannot occur.
145        #[allow(clippy::as_conversions)]
146        let len = raw.len() as u8;
147        Ok(Self { bytes, len })
148    }
149
150    /// Returns an empty resource reference.
151    pub const fn blank() -> Self {
152        Self {
153            bytes: [0u8; MAX_RESREF_LEN],
154            len: 0,
155        }
156    }
157
158    /// Returns the canonical Windows-1252 bytes that make up this
159    /// resref.
160    ///
161    /// This is the engine-actual storage form. Use this for byte-level
162    /// work (writing to disk, hashing, lint inspection of byte
163    /// patterns, comparing against a byte-string literal).
164    pub fn as_bytes(&self) -> &[u8] {
165        &self.bytes[..usize::from(self.len)]
166    }
167
168    /// The resref in the fixed-width field every container stores it in.
169    ///
170    /// Sixteen bytes, zero-padded. Every format holding a resref holds it this
171    /// way, and four writers had each open-coded the padding, so a fifth
172    /// getting it subtly wrong was a matter of time.
173    ///
174    /// No length check: [`Self::new`] refuses anything longer, so a resref
175    /// that would not fit cannot exist.
176    pub fn to_padded(&self) -> [u8; MAX_RESREF_LEN] {
177        let mut field = [0_u8; MAX_RESREF_LEN];
178        let bytes = self.as_bytes();
179        field[..bytes.len()].copy_from_slice(bytes);
180        field
181    }
182
183    /// Returns `true` when the resource reference is empty.
184    pub const fn is_blank(&self) -> bool {
185        self.len == 0
186    }
187
188    /// Returns `true` when the resource reference is empty.
189    ///
190    /// Alias for [`is_blank`](Self::is_blank) matching the standard collection API.
191    pub const fn is_empty(&self) -> bool {
192        self.len == 0
193    }
194
195    /// Returns the length in Windows-1252 bytes.
196    ///
197    /// For ASCII-only resrefs (the overwhelming majority) this equals
198    /// the character count. For extended-character resrefs it is the
199    /// engine-storage byte count, not the UTF-8 byte count of the
200    /// Display form.
201    #[allow(clippy::as_conversions)]
202    pub const fn len(&self) -> usize {
203        // u8 to usize is a lossless widening; usize::from is not yet const-stable
204        // as of 1.93, so `as` is the only option in a const context.
205        self.len as usize
206    }
207}
208
209impl std::fmt::Debug for ResRef {
210    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
211        let decoded = decode_text(self.as_bytes(), TextEncoding::Windows1252);
212        write!(f, "ResRef({decoded:?})")
213    }
214}
215
216impl Default for ResRef {
217    fn default() -> Self {
218        Self::blank()
219    }
220}
221
222impl Display for ResRef {
223    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
224        let decoded = decode_text(self.as_bytes(), TextEncoding::Windows1252);
225        f.write_str(&decoded)
226    }
227}
228
229impl FromStr for ResRef {
230    type Err = ResRefError;
231
232    fn from_str(s: &str) -> Result<Self, Self::Err> {
233        Self::new(s)
234    }
235}
236
237impl TryFrom<&str> for ResRef {
238    type Error = ResRefError;
239
240    fn try_from(value: &str) -> Result<Self, Self::Error> {
241        Self::new(value)
242    }
243}
244
245impl TryFrom<String> for ResRef {
246    type Error = ResRefError;
247
248    fn try_from(value: String) -> Result<Self, Self::Error> {
249        Self::new(value)
250    }
251}
252
253impl From<ResRef> for String {
254    fn from(val: ResRef) -> Self {
255        val.to_string()
256    }
257}
258
259// Convenience byte-comparison impls for assertions and lookups against
260// literals like `resref == "module"` or `resref == b"module"`. These
261// compare the canonical Windows-1252 bytes directly. For ASCII inputs
262// (the overwhelming majority of resrefs) this matches both the engine
263// semantics and what the caller intuitively expects. For an extended
264// Windows-1252 byte (`é` = 0xE9), comparing against a UTF-8 string
265// literal (`"é"` = 0xC3 0xA9) returns false, which is the correct
266// outcome since the byte sequences genuinely differ.
267impl PartialEq<str> for ResRef {
268    fn eq(&self, other: &str) -> bool {
269        self.as_bytes() == other.as_bytes()
270    }
271}
272
273impl PartialEq<&str> for ResRef {
274    fn eq(&self, other: &&str) -> bool {
275        self.as_bytes() == other.as_bytes()
276    }
277}
278
279impl PartialEq<ResRef> for str {
280    fn eq(&self, other: &ResRef) -> bool {
281        other == self
282    }
283}
284
285impl PartialEq<ResRef> for &str {
286    fn eq(&self, other: &ResRef) -> bool {
287        other == self
288    }
289}
290
291impl PartialEq<[u8]> for ResRef {
292    fn eq(&self, other: &[u8]) -> bool {
293        self.as_bytes() == other
294    }
295}
296
297impl PartialEq<&[u8]> for ResRef {
298    fn eq(&self, other: &&[u8]) -> bool {
299        self.as_bytes() == *other
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn accepts_valid_resref() {
309        let parsed = ResRef::new("P_Bastila").expect("valid resref");
310        assert_eq!(parsed.as_bytes(), b"p_bastila");
311    }
312
313    #[test]
314    fn rejects_too_long_resref() {
315        let err = ResRef::new("this_name_is_longer_than_sixteen").expect_err("must fail");
316        assert!(matches!(err, ResRefError::TooLong { .. }));
317    }
318
319    #[test]
320    fn accepts_arbitrary_ascii_characters() {
321        // The engine has no character whitelist (Ghidra-audited;
322        // see docs/src/formats/resource_system.md). Vanilla content
323        // uses bytes a conservative whitelist would reject: `+` in
324        // chitin.key upgrade-modifier resrefs, `!` in RIM key tables.
325        // All single ASCII bytes round-trip.
326        for name in [
327            "g_w_lghtsbr+1",
328            "bad!name",
329            "with space",
330            "punct.dot",
331            "amp&char",
332            "t3-m4",
333        ] {
334            let parsed = ResRef::new(name).unwrap_or_else(|e| {
335                panic!("`{name}` should be valid (any ASCII byte allowed): {e:?}")
336            });
337            assert_eq!(parsed.as_bytes(), name.as_bytes());
338        }
339    }
340
341    #[test]
342    fn accepts_extended_windows_1252_input() {
343        // `é` has a single-byte Windows-1252 representation (0xE9), so
344        // it is acceptable even though the UTF-8 form is multi-byte.
345        let parsed = ResRef::new("café").expect("é round-trips through Windows-1252");
346        assert_eq!(parsed.as_bytes(), &[b'c', b'a', b'f', 0xE9]);
347    }
348
349    #[test]
350    fn rejects_unencodable_input() {
351        // Characters with no Windows-1252 mapping (CJK, emoji) cannot
352        // be stored in the engine's byte buffer, so they are rejected
353        // at the API boundary.
354        let err = ResRef::new("名前").expect_err("CJK has no Windows-1252 mapping");
355        assert!(matches!(err, ResRefError::InvalidChar { .. }));
356    }
357
358    #[test]
359    fn const_new_rejects_non_ascii_input() {
360        // const_new is ASCII-only by design (no const Windows-1252
361        // transcoding), so even a Windows-1252-encodable char like
362        // `é` is rejected at compile time.
363        let err = ResRef::const_new("café").expect_err("non-ASCII rejected at compile time");
364        assert!(matches!(err, ResRefError::InvalidChar { .. }));
365    }
366
367    #[test]
368    fn blank_is_empty() {
369        let r = ResRef::blank();
370        assert!(r.is_blank());
371        assert_eq!(r.as_bytes(), b"");
372        assert_eq!(r.len(), 0);
373    }
374
375    #[test]
376    fn max_length_accepted() {
377        let parsed = ResRef::new("a23456789_123456").expect("16 chars is valid");
378        assert_eq!(parsed.as_bytes(), b"a23456789_123456");
379        assert_eq!(parsed.len(), 16);
380    }
381
382    #[test]
383    fn is_copy() {
384        fn takes_copy<T: Copy>(_: T) {}
385        takes_copy(ResRef::blank());
386    }
387
388    #[test]
389    fn roundtrip_through_string() {
390        let original = ResRef::new("test_resref").expect("valid");
391        let s: String = original.into();
392        assert_eq!(s, "test_resref");
393        let back: ResRef = s.try_into().expect("valid");
394        assert_eq!(back, original);
395    }
396
397    #[test]
398    fn const_new_accepts_valid_resref() {
399        let parsed = ResRef::const_new("itempropdef").expect("valid");
400        assert_eq!(parsed.as_bytes(), b"itempropdef");
401    }
402
403    #[test]
404    fn const_new_lowercases_uppercase_input() {
405        let parsed = ResRef::const_new("ItemPropDef").expect("valid");
406        assert_eq!(parsed.as_bytes(), b"itempropdef");
407    }
408
409    #[test]
410    fn const_new_rejects_too_long() {
411        let err = ResRef::const_new("this_name_is_longer_than_sixteen").expect_err("must fail");
412        assert!(matches!(err, ResRefError::TooLong { .. }));
413    }
414
415    #[test]
416    fn const_new_matches_runtime_new_for_valid_inputs() {
417        for name in ["appearance", "Hk-47", "iprp_damagecost", "T3-M4"] {
418            let runtime = ResRef::new(name).expect("valid");
419            let compile_time = ResRef::const_new(name).expect("valid");
420            assert_eq!(runtime, compile_time, "mismatch for `{name}`");
421        }
422    }
423
424    #[test]
425    fn const_new_can_be_called_in_const_context() {
426        // The point of `const_new`: this declaration would fail to
427        // compile if the function weren't usable in a `const`.
428        const APPEARANCE: Result<ResRef, ResRefError> = ResRef::const_new("appearance");
429        let parsed = APPEARANCE.expect("compile-time-valid");
430        assert_eq!(parsed.as_bytes(), b"appearance");
431    }
432}