Skip to main content

rakata_extract/
twoda_cache.rs

1//! Resolver-backed lazy cache of parsed 2DA tables.
2//!
3//! Decoders that map raw engine integers (UTI property kinds, UTC class
4//! ids, TPC palette refs, ...) to typed semantics need repeated read
5//! access to the same handful of 2DA tables: `itempropdef.2da`,
6//! `baseitems.2da`, `iprp_*.2da`, etc. Re-resolving and re-parsing
7//! those tables on every property read is wasteful, and pushing the
8//! cache into each format consumer fragments the duplication.
9//!
10//! [`TwoDaCache`] is the shared primitive: borrow a [`Resolver`], call
11//! [`TwoDaCache::twoda`] with a bare table name (no extension), get
12//! back a borrowed [`TwoDa`]. The first call resolves, parses, and
13//! caches the table; subsequent calls hand back the cached parse.
14//! A single instance can serve a whole batch decode pass across
15//! multiple format decoders.
16
17use std::collections::hash_map::Entry;
18use std::collections::HashMap;
19use std::fmt::{Display, Formatter};
20
21use rakata_core::{ResRef, ResRefError, ResourceType, ResourceTypeCode};
22use rakata_formats::twoda::{read_twoda_from_bytes, TwoDa, TwoDaBinaryError};
23
24use crate::resolver::Resolver;
25
26/// A validated 2DA table name (e.g. `racialtypes`, `appearance`).
27///
28/// Wraps [`ResRef`] so cache keys are distinguishable from arbitrary
29/// resrefs at the type level. Layers an ASCII-only check on top of
30/// `ResRef`'s broader Windows-1252 acceptance: vanilla K1 2DA names
31/// are pure ASCII, and `chitin.key`'s filename table is ASCII-only,
32/// so an extended-character 2DA name would not resolve anyway.
33///
34/// The name does **not** include the `.2da` extension -- the
35/// [`ResourceTypeCode`] supplies that at resolver lookup time.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
37pub struct TwoDaName(ResRef);
38
39impl TwoDaName {
40    /// Creates a 2DA name from a string.
41    ///
42    /// 2DA filenames are conventionally ASCII; this constructor
43    /// enforces that invariant on top of [`ResRef`]'s broader
44    /// Windows-1252 acceptance, since extended-character 2DA names
45    /// would not round-trip through `chitin.key`'s ASCII-only
46    /// filename table anyway.
47    pub fn new(name: impl AsRef<str>) -> Result<Self, ResRefError> {
48        let resref = ResRef::new(name)?;
49        if !resref.as_bytes().iter().all(u8::is_ascii) {
50            // Find the first non-ASCII byte to report which character
51            // tripped the rejection.
52            let bad = resref
53                .as_bytes()
54                .iter()
55                .find(|b| !b.is_ascii())
56                .copied()
57                .expect("just verified at least one byte fails is_ascii");
58            return Err(ResRefError::InvalidChar {
59                ch: char::from(bad),
60            });
61        }
62        Ok(Self(resref))
63    }
64
65    /// Returns a reference to the underlying [`ResRef`] for resolver
66    /// lookups that take `&ResRef` directly.
67    pub fn as_resref(&self) -> &ResRef {
68        &self.0
69    }
70
71    /// Returns the canonical lowercase string form of the name,
72    /// suitable for passing to [`TwoDaCache::twoda`] without an
73    /// extra round trip through [`TwoDaName::new`].
74    ///
75    /// `TwoDaName` enforces an ASCII-only invariant on construction
76    /// (2DA filenames are conventionally ASCII), so this can return
77    /// a borrowed `&str` directly.
78    pub fn as_str(&self) -> &str {
79        // SAFETY-equivalent: TwoDaName::new and ::from_static both
80        // verify the bytes are ASCII before constructing, so the
81        // underlying ResRef bytes are guaranteed valid UTF-8.
82        std::str::from_utf8(self.0.as_bytes()).expect("TwoDaName bytes are ASCII by construction")
83    }
84
85    /// `const`-friendly constructor for `pub const` table-name
86    /// declarations.
87    ///
88    /// Validation runs at compile time via [`ResRef::const_new`]; an
89    /// invalid input panics during compilation rather than at first
90    /// use. Mirrors `http::HeaderName::from_static`. Use this only
91    /// for hardcoded vanilla / known table names whose validity you
92    /// can verify by inspection -- runtime-discovered names should
93    /// go through [`Self::new`] instead.
94    pub const fn from_static(name: &'static str) -> Self {
95        match ResRef::const_new(name) {
96            Ok(resref) => Self(resref),
97            // String literal failed ResRef validation -- this is a
98            // programmer error, not a recoverable runtime condition.
99            Err(_) => panic!("invalid 2DA name passed to TwoDaName::from_static"),
100        }
101    }
102}
103
104impl AsRef<str> for TwoDaName {
105    fn as_ref(&self) -> &str {
106        self.as_str()
107    }
108}
109
110impl Display for TwoDaName {
111    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
112        Display::fmt(&self.0, f)
113    }
114}
115
116impl AsRef<ResRef> for TwoDaName {
117    fn as_ref(&self) -> &ResRef {
118        &self.0
119    }
120}
121
122/// Errors produced by [`TwoDaCache`] operations.
123///
124/// These flow back to the decoder that requested the lookup; callers
125/// can choose to surface a diagnostic, fall back to a default decode
126/// (e.g. `Unknown` variant), or propagate depending on whether the
127/// missing table is fatal to the operation.
128#[derive(Debug, thiserror::Error)]
129pub enum TwoDaCacheError {
130    /// The supplied table name failed [`ResRef`] validation. Carries
131    /// the original (invalid) string so callers can surface it
132    /// verbatim in diagnostics.
133    #[error("invalid 2DA name `{name}`")]
134    InvalidName {
135        /// Caller-supplied name that failed validation.
136        name: String,
137        /// Underlying [`ResRef`] validation error.
138        #[source]
139        source: ResRefError,
140    },
141    /// The named 2DA could not be resolved through any configured source.
142    #[error("2DA `{name}` not found in resource tree")]
143    Missing {
144        /// Validated table name the caller requested.
145        name: TwoDaName,
146    },
147    /// The named 2DA was located but failed to parse.
148    #[error("2DA `{name}` failed to parse")]
149    Parse {
150        /// Validated table name whose bytes failed to parse.
151        name: TwoDaName,
152        /// Underlying 2DA parse error.
153        #[source]
154        source: TwoDaBinaryError,
155    },
156}
157
158/// Resolver-backed cache of parsed 2DA tables.
159///
160/// Borrows a [`Resolver`] for resref-to-bytes lookups and owns a
161/// lazily-populated [`HashMap`] of parsed [`TwoDa`] tables keyed by
162/// [`TwoDaName`]. Construct once, reuse across decode passes.
163///
164/// Single-threaded by design: [`Self::twoda`] takes `&mut self` so
165/// the cache can grow on miss without interior mutability or
166/// `Arc<Mutex<_>>` overhead. If a decoder needs data from two tables
167/// in one call, it should pull the typed primitives it needs out of
168/// the first borrow before requesting the next.
169pub struct TwoDaCache<'a> {
170    resolver: &'a Resolver<'a>,
171    cache: HashMap<TwoDaName, TwoDa>,
172}
173
174impl<'a> TwoDaCache<'a> {
175    /// Constructs a new cache backed by the given resolver.
176    ///
177    /// The resolver is borrowed for the lifetime of the cache, so the
178    /// caller is free to keep adding sources to the resolver before
179    /// constructing the cache but must not mutate it through that
180    /// reference once the cache exists.
181    pub fn new(resolver: &'a Resolver<'a>) -> Self {
182        Self {
183            resolver,
184            cache: HashMap::new(),
185        }
186    }
187
188    /// Returns a reference to the named 2DA, lazily loading and caching
189    /// it on first request.
190    ///
191    /// `name` is anything that yields a bare table name string without
192    /// the `.2da` extension. Two natural inputs:
193    /// - a `&str` literal (`cache.twoda("itempropdef")`),
194    /// - a [`TwoDaName`] constant from the [`tables`] module
195    ///   (`cache.twoda(tables::ITEMPROPDEF)`).
196    ///
197    /// On a cache miss the name is validated, the resolver is asked
198    /// for the table, and on hit the bytes are parsed via
199    /// [`read_twoda_from_bytes`] before being inserted into the
200    /// cache.
201    ///
202    /// Errors:
203    /// - [`TwoDaCacheError::InvalidName`] when the resolved string
204    ///   fails [`ResRef`] validation. Cannot fire when the input is a
205    ///   [`TwoDaName`] (already validated) or a [`tables`] constant
206    ///   (validated at compile time).
207    /// - [`TwoDaCacheError::Missing`] when no resolver source contains
208    ///   the requested table.
209    /// - [`TwoDaCacheError::Parse`] when the bytes are present but
210    ///   fail to parse as a valid 2DA.
211    pub fn twoda(&mut self, name: impl AsRef<str>) -> Result<&TwoDa, TwoDaCacheError> {
212        let name = name.as_ref();
213        let validated = TwoDaName::new(name).map_err(|source| TwoDaCacheError::InvalidName {
214            name: name.to_string(),
215            source,
216        })?;
217        match self.cache.entry(validated) {
218            Entry::Occupied(occupied) => Ok(occupied.into_mut()),
219            Entry::Vacant(vacant) => {
220                let result = self
221                    .resolver
222                    .resolve(
223                        validated.as_resref(),
224                        ResourceTypeCode::from(ResourceType::TwoDa),
225                    )
226                    .ok_or(TwoDaCacheError::Missing { name: validated })?;
227                let twoda = read_twoda_from_bytes(result.data.as_ref()).map_err(|source| {
228                    TwoDaCacheError::Parse {
229                        name: validated,
230                        source,
231                    }
232                })?;
233                Ok(vacant.insert(twoda))
234            }
235        }
236    }
237}
238
239/// Compile-time-validated [`TwoDaName`] constants for the vanilla
240/// K1 2DA tables the workspace currently consumes.
241///
242/// Constants land here as new tables get used; growing the module is
243/// a one-line `pub const FOO: TwoDaName = TwoDaName::from_static("foo");`
244/// addition. Mod-extended or runtime-discovered tables go through
245/// [`TwoDaName::new`] at the call site instead.
246pub mod tables {
247    use super::TwoDaName;
248
249    /// `appearance.2da` -- character / creature appearance table.
250    pub const APPEARANCE: TwoDaName = TwoDaName::from_static("appearance");
251    /// `baseitems.2da` -- base item type table (model, equip slot,
252    /// weapon class, etc.).
253    pub const BASEITEMS: TwoDaName = TwoDaName::from_static("baseitems");
254    /// `classes.2da` -- character class definitions.
255    pub const CLASSES: TwoDaName = TwoDaName::from_static("classes");
256    /// `feat.2da` -- feat definitions (combat techniques, weapon and
257    /// armour proficiencies, Force feats, droid upgrades, etc.).
258    pub const FEAT: TwoDaName = TwoDaName::from_static("feat");
259    /// `genericdoors.2da` -- door appearance / model table.
260    pub const GENERICDOORS: TwoDaName = TwoDaName::from_static("genericdoors");
261    /// `itempropdef.2da` -- master item-property kind table; root of
262    /// the per-property subtype dispatch chain.
263    pub const ITEMPROPDEF: TwoDaName = TwoDaName::from_static("itempropdef");
264    /// `placeables.2da` -- placeable appearance table.
265    pub const PLACEABLES: TwoDaName = TwoDaName::from_static("placeables");
266    /// `portraits.2da` -- character portrait list.
267    pub const PORTRAITS: TwoDaName = TwoDaName::from_static("portraits");
268    /// `racialtypes.2da` -- creature race table.
269    pub const RACIALTYPES: TwoDaName = TwoDaName::from_static("racialtypes");
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use crate::resolver::{OverrideSource, ResolverSourceRef};
276    use rakata_formats::twoda::{write_twoda_to_vec, TwoDaRow};
277
278    #[test]
279    fn twoda_name_accepts_vanilla_table_names() {
280        for name in [
281            "appearance",
282            "racialtypes",
283            "classes",
284            "baseitems",
285            "spells",
286            "feat",
287            "itempropdef",
288            "iprp_damagecost",
289        ] {
290            let parsed = TwoDaName::new(name).expect("vanilla 2da name must validate");
291            assert_eq!(parsed.to_string(), name);
292        }
293    }
294
295    #[test]
296    fn twoda_name_rejects_non_ascii_input() {
297        // ResRef accepts any byte that round-trips through Windows-1252,
298        // so `é` (0xE9) is a valid resref byte. TwoDaName layers an
299        // ASCII-only check on top because 2DA filenames in chitin.key
300        // are conventionally ASCII, so the constructor still rejects
301        // here even though plain ResRef::new would accept.
302        let err = TwoDaName::new("café").expect_err("must reject");
303        assert!(matches!(err, ResRefError::InvalidChar { .. }));
304    }
305
306    #[test]
307    fn twoda_name_lowercases_to_match_resref_canonicalisation() {
308        let name = TwoDaName::new("Appearance").expect("valid name");
309        assert_eq!(name.to_string(), "appearance");
310    }
311
312    #[test]
313    fn twoda_name_implements_asref_resref_for_resolver_calls() {
314        fn takes_resref(r: &ResRef) -> Vec<u8> {
315            r.as_bytes().to_vec()
316        }
317        let name = TwoDaName::new("baseitems").expect("valid name");
318        assert_eq!(takes_resref(name.as_ref()), b"baseitems");
319    }
320
321    fn fixture_table() -> TwoDa {
322        TwoDa {
323            headers: vec!["label".to_string(), "value".to_string()],
324            rows: vec![
325                TwoDaRow {
326                    label: "0".to_string(),
327                    cells: vec!["alpha".to_string(), "1".to_string()],
328                },
329                TwoDaRow {
330                    label: "1".to_string(),
331                    cells: vec!["beta".to_string(), "2".to_string()],
332                },
333            ],
334        }
335    }
336
337    fn override_with_table(name: &str, table: &TwoDa) -> OverrideSource {
338        let bytes = write_twoda_to_vec(table).expect("write 2da fixture");
339        let mut overrides = OverrideSource::new();
340        overrides
341            .add_entry(
342                name,
343                ResourceTypeCode::from(ResourceType::TwoDa),
344                bytes,
345                "test",
346            )
347            .expect("add override entry");
348        overrides
349    }
350
351    #[test]
352    fn twoda_returns_missing_when_no_source_contains_table() {
353        let resolver = Resolver::new();
354        let mut cache = TwoDaCache::new(&resolver);
355        let err = cache
356            .twoda("appearance")
357            .expect_err("empty resolver must surface missing");
358        assert!(matches!(
359            err,
360            TwoDaCacheError::Missing { name } if name.to_string() == "appearance"
361        ));
362    }
363
364    #[test]
365    fn twoda_returns_invalid_name_for_non_ascii_input() {
366        // The cache rejects only non-ASCII / multi-byte UTF-8 input;
367        // arbitrary ASCII bytes (`+`, `!`, space, etc.) are valid in
368        // resrefs per the engine audit. Use a multi-byte UTF-8 string
369        // to trigger the InvalidName path.
370        let resolver = Resolver::new();
371        let mut cache = TwoDaCache::new(&resolver);
372        let err = cache
373            .twoda("café")
374            .expect_err("non-ASCII name must surface InvalidName");
375        assert!(matches!(
376            err,
377            TwoDaCacheError::InvalidName { ref name, source: ResRefError::InvalidChar { .. } }
378                if name == "café"
379        ));
380    }
381
382    #[test]
383    fn twoda_loads_parses_and_caches_table_via_resolver() {
384        let table = fixture_table();
385        let overrides = override_with_table("appearance", &table);
386        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
387        let mut cache = TwoDaCache::new(&resolver);
388
389        let loaded = cache.twoda("appearance").expect("must resolve and parse");
390        assert_eq!(loaded.headers, vec!["label", "value"]);
391        assert_eq!(loaded.rows.len(), 2);
392        assert_eq!(loaded.rows[1].cells[0], "beta");
393
394        let first_ptr = std::ptr::from_ref(cache.twoda("appearance").expect("must resolve"));
395        let second_ptr = std::ptr::from_ref(cache.twoda("appearance").expect("must resolve"));
396        assert_eq!(
397            first_ptr, second_ptr,
398            "cache must return the same allocation on hit"
399        );
400    }
401
402    #[test]
403    fn twoda_canonicalises_name_so_case_does_not_split_cache() {
404        let table = fixture_table();
405        let overrides = override_with_table("appearance", &table);
406        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
407        let mut cache = TwoDaCache::new(&resolver);
408
409        let lower = std::ptr::from_ref(cache.twoda("appearance").expect("must resolve"));
410        let upper = std::ptr::from_ref(cache.twoda("Appearance").expect("must resolve"));
411        assert_eq!(
412            lower, upper,
413            "case variants must hash to the same cache slot"
414        );
415    }
416
417    #[test]
418    fn twoda_surfaces_parse_error_when_bytes_are_invalid() {
419        let mut overrides = OverrideSource::new();
420        overrides
421            .add_entry(
422                "broken",
423                ResourceTypeCode::from(ResourceType::TwoDa),
424                b"not a valid 2da file".to_vec(),
425                "test",
426            )
427            .expect("add override entry");
428        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
429        let mut cache = TwoDaCache::new(&resolver);
430
431        let err = cache.twoda("broken").expect_err("invalid bytes must error");
432        assert!(matches!(
433            err,
434            TwoDaCacheError::Parse { name, .. } if name.to_string() == "broken"
435        ));
436    }
437
438    #[test]
439    fn twoda_name_as_str_returns_canonical_form() {
440        let name = TwoDaName::new("Appearance").expect("valid name");
441        assert_eq!(name.as_str(), "appearance");
442    }
443
444    #[test]
445    fn twoda_accepts_as_str_round_trip_from_typed_name() {
446        // A caller holding a TwoDaName can pass `name.as_str()` to
447        // the &str entry point and reach the cached slot it would
448        // have reached by passing the literal directly.
449        let table = fixture_table();
450        let overrides = override_with_table("appearance", &table);
451        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
452        let mut cache = TwoDaCache::new(&resolver);
453
454        let name = TwoDaName::new("appearance").expect("valid name");
455        let via_literal = std::ptr::from_ref(cache.twoda("appearance").expect("must resolve"));
456        let via_typed = std::ptr::from_ref(cache.twoda(name.as_str()).expect("must resolve"));
457        assert_eq!(via_literal, via_typed);
458    }
459
460    #[test]
461    fn missing_error_displays_table_name() {
462        let name = TwoDaName::new("racialtypes").expect("valid name");
463        let err = TwoDaCacheError::Missing { name };
464        assert_eq!(
465            format!("{err}"),
466            "2DA `racialtypes` not found in resource tree"
467        );
468    }
469
470    #[test]
471    fn from_static_produces_valid_name_for_known_tables() {
472        // Every `tables::*` entry must round-trip without panic. If a
473        // future addition contains an invalid character, the const
474        // initialization would fail at compile time -- this test is a
475        // belt-and-suspenders check that the runtime values match.
476        assert_eq!(tables::ITEMPROPDEF.as_str(), "itempropdef");
477        assert_eq!(tables::BASEITEMS.as_str(), "baseitems");
478        assert_eq!(tables::PORTRAITS.as_str(), "portraits");
479        assert_eq!(tables::APPEARANCE.as_str(), "appearance");
480    }
481
482    #[test]
483    fn twoda_accepts_typed_constant_via_as_ref() {
484        // The cache method takes `impl AsRef<str>`, so a `TwoDaName`
485        // (which impls AsRef<str>) is interchangeable with a `&str`
486        // literal at the call site.
487        let table = fixture_table();
488        let overrides = override_with_table("itempropdef", &table);
489        let resolver = Resolver::new().with_source(ResolverSourceRef::Override(&overrides));
490        let mut cache = TwoDaCache::new(&resolver);
491
492        let via_constant = std::ptr::from_ref(
493            cache
494                .twoda(tables::ITEMPROPDEF)
495                .expect("constant must resolve"),
496        );
497        let via_literal =
498            std::ptr::from_ref(cache.twoda("itempropdef").expect("literal must resolve"));
499        assert_eq!(
500            via_constant, via_literal,
501            "typed constant and literal must hash to the same cache slot"
502        );
503    }
504
505    #[test]
506    fn invalid_name_error_displays_caller_input() {
507        let err = TwoDaCacheError::InvalidName {
508            name: "café".to_string(),
509            source: ResRefError::InvalidChar { ch: 'é' },
510        };
511        assert_eq!(format!("{err}"), "invalid 2DA name `café`");
512    }
513}