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}