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}