Skip to main content

rakata_core/
twoda_name.rs

1//! Validated 2DA table names and the known vanilla table set.
2//!
3//! Which 2DA tables exist and what they are called is game-content
4//! knowledge, the same kind of thing [`ResourceType`] already carries.
5//! It is not knowledge about how files get mounted, so it does not
6//! belong with the cache that mounts them, and a 2DA parser has no
7//! business knowing `appearance.2da` exists either.
8//!
9//! [`TwoDaName`] and the [`tables`] constants move together: the
10//! constants are typed, not `&str`.
11//!
12//! [`ResourceType`]: crate::ResourceType
13
14use std::fmt::{Display, Formatter};
15
16use crate::resref::{ResRef, ResRefError};
17
18/// A validated 2DA table name (e.g. `racialtypes`, `appearance`).
19///
20/// Wraps [`ResRef`] so cache keys are distinguishable from arbitrary
21/// resrefs at the type level. Layers an ASCII-only check on top of
22/// `ResRef`'s broader Windows-1252 acceptance: vanilla K1 2DA names
23/// are pure ASCII, and `chitin.key`'s filename table is ASCII-only,
24/// so an extended-character 2DA name would not resolve anyway.
25///
26/// The name does **not** include the `.2da` extension -- the
27/// [`ResourceTypeCode`](crate::ResourceTypeCode) supplies that at
28/// lookup time.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
30pub struct TwoDaName(ResRef);
31
32impl TwoDaName {
33    /// Creates a 2DA name from a string.
34    ///
35    /// 2DA filenames are conventionally ASCII; this constructor
36    /// enforces that invariant on top of [`ResRef`]'s broader
37    /// Windows-1252 acceptance, since extended-character 2DA names
38    /// would not round-trip through `chitin.key`'s ASCII-only
39    /// filename table anyway.
40    ///
41    /// # Errors
42    ///
43    /// Whatever [`ResRef::new`] reports, plus [`ResRefError::InvalidChar`] for
44    /// a Windows-1252 character outside ASCII, naming the first one. A name
45    /// `ResRef` accepts can therefore still be refused here.
46    pub fn new(name: impl AsRef<str>) -> Result<Self, ResRefError> {
47        let resref = ResRef::new(name)?;
48        if !resref.as_bytes().iter().all(u8::is_ascii) {
49            // Find the first non-ASCII byte to report which character
50            // tripped the rejection.
51            let bad = resref
52                .as_bytes()
53                .iter()
54                .find(|b| !b.is_ascii())
55                .copied()
56                .expect("just verified at least one byte fails is_ascii");
57            return Err(ResRefError::InvalidChar {
58                ch: char::from(bad),
59            });
60        }
61        Ok(Self(resref))
62    }
63
64    /// Returns a reference to the underlying [`ResRef`] for resolver
65    /// lookups that take `&ResRef` directly.
66    pub fn as_resref(&self) -> &ResRef {
67        &self.0
68    }
69
70    /// Returns the canonical lowercase string form of the name,
71    /// suitable for handing to a table lookup that takes `&str`
72    /// without an extra round trip through [`TwoDaName::new`].
73    ///
74    /// `TwoDaName` enforces an ASCII-only invariant on construction
75    /// (2DA filenames are conventionally ASCII), so this can return
76    /// a borrowed `&str` directly.
77    pub fn as_str(&self) -> &str {
78        // SAFETY-equivalent: TwoDaName::new and ::from_static both
79        // verify the bytes are ASCII before constructing, so the
80        // underlying ResRef bytes are guaranteed valid UTF-8.
81        std::str::from_utf8(self.0.as_bytes()).expect("TwoDaName bytes are ASCII by construction")
82    }
83
84    /// `const`-friendly constructor for `pub const` table-name
85    /// declarations.
86    ///
87    /// Validation runs at compile time via [`ResRef::const_new`]; an
88    /// invalid input panics during compilation rather than at first
89    /// use. Mirrors `http::HeaderName::from_static`. Use this only
90    /// for hardcoded vanilla / known table names whose validity you
91    /// can verify by inspection -- runtime-discovered names should
92    /// go through [`Self::new`] instead.
93    ///
94    /// # Panics
95    ///
96    /// Panics where `name` is longer than sixteen bytes or is not ASCII. In a
97    /// `const` context that is a compile error rather than a runtime one,
98    /// which is the whole reason to reach for this over [`Self::new`].
99    pub const fn from_static(name: &'static str) -> Self {
100        match ResRef::const_new(name) {
101            Ok(resref) => Self(resref),
102            // String literal failed ResRef validation -- this is a
103            // programmer error, not a recoverable runtime condition.
104            Err(_) => panic!("invalid 2DA name passed to TwoDaName::from_static"),
105        }
106    }
107}
108
109impl AsRef<str> for TwoDaName {
110    fn as_ref(&self) -> &str {
111        self.as_str()
112    }
113}
114
115impl Display for TwoDaName {
116    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
117        Display::fmt(&self.0, f)
118    }
119}
120
121impl AsRef<ResRef> for TwoDaName {
122    fn as_ref(&self) -> &ResRef {
123        &self.0
124    }
125}
126
127/// Compile-time-validated [`TwoDaName`] constants for the vanilla
128/// K1 2DA tables the workspace currently consumes.
129///
130/// Constants land here as new tables get used; growing the module is
131/// a one-line `pub const FOO: TwoDaName = TwoDaName::from_static("foo");`
132/// addition. Mod-extended or runtime-discovered tables go through
133/// [`TwoDaName::new`] at the call site instead.
134pub mod tables {
135    use super::TwoDaName;
136
137    /// `appearance.2da` -- character / creature appearance table.
138    pub const APPEARANCE: TwoDaName = TwoDaName::from_static("appearance");
139    /// `baseitems.2da` -- base item type table (model, equip slot,
140    /// weapon class, etc.).
141    pub const BASEITEMS: TwoDaName = TwoDaName::from_static("baseitems");
142    /// `classes.2da` -- character class definitions.
143    pub const CLASSES: TwoDaName = TwoDaName::from_static("classes");
144    /// `feat.2da` -- feat definitions (combat techniques, weapon and
145    /// armour proficiencies, Force feats, droid upgrades, etc.).
146    pub const FEAT: TwoDaName = TwoDaName::from_static("feat");
147    /// `genericdoors.2da` -- door appearance / model table.
148    pub const GENERICDOORS: TwoDaName = TwoDaName::from_static("genericdoors");
149    /// `itempropdef.2da` -- master item-property kind table; root of
150    /// the per-property subtype dispatch chain.
151    pub const ITEMPROPDEF: TwoDaName = TwoDaName::from_static("itempropdef");
152    /// `placeables.2da` -- placeable appearance table.
153    pub const PLACEABLES: TwoDaName = TwoDaName::from_static("placeables");
154    /// `portraits.2da` -- character portrait list.
155    pub const PORTRAITS: TwoDaName = TwoDaName::from_static("portraits");
156    /// `racialtypes.2da` -- creature race table.
157    pub const RACIALTYPES: TwoDaName = TwoDaName::from_static("racialtypes");
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn accepts_vanilla_table_names() {
166        for name in [
167            "appearance",
168            "racialtypes",
169            "classes",
170            "baseitems",
171            "spells",
172            "feat",
173            "itempropdef",
174            "iprp_damagecost",
175        ] {
176            let parsed = TwoDaName::new(name).expect("vanilla 2da name must validate");
177            assert_eq!(parsed.to_string(), name);
178        }
179    }
180
181    #[test]
182    fn rejects_non_ascii_input() {
183        // ResRef accepts any byte that round-trips through Windows-1252,
184        // so `é` (0xE9) is a valid resref byte. TwoDaName layers an
185        // ASCII-only check on top because 2DA filenames in chitin.key
186        // are conventionally ASCII, so the constructor still rejects
187        // here even though plain ResRef::new would accept.
188        let err = TwoDaName::new("café").expect_err("must reject");
189        assert!(matches!(err, ResRefError::InvalidChar { .. }));
190    }
191
192    #[test]
193    fn lowercases_to_match_resref_canonicalisation() {
194        let name = TwoDaName::new("Appearance").expect("valid name");
195        assert_eq!(name.to_string(), "appearance");
196    }
197
198    #[test]
199    fn as_str_returns_canonical_form() {
200        let name = TwoDaName::new("Appearance").expect("valid name");
201        assert_eq!(name.as_str(), "appearance");
202    }
203
204    #[test]
205    fn implements_asref_resref_for_resolver_calls() {
206        fn takes_resref(r: &ResRef) -> Vec<u8> {
207            r.as_bytes().to_vec()
208        }
209        let name = TwoDaName::new("baseitems").expect("valid name");
210        assert_eq!(takes_resref(name.as_ref()), b"baseitems");
211    }
212
213    #[test]
214    fn from_static_produces_valid_name_for_known_tables() {
215        // Every `tables::*` entry must round-trip without panic. If a
216        // future addition contains an invalid character, the const
217        // initialization would fail at compile time -- this test is a
218        // belt-and-suspenders check that the runtime values match.
219        assert_eq!(tables::ITEMPROPDEF.as_str(), "itempropdef");
220        assert_eq!(tables::BASEITEMS.as_str(), "baseitems");
221        assert_eq!(tables::PORTRAITS.as_str(), "portraits");
222        assert_eq!(tables::APPEARANCE.as_str(), "appearance");
223    }
224}