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}