aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-10-31 03:28:05 +0300
committerValentin Popov <valentin@popov.link>2026-10-31 03:28:05 +0300
commit97c73118a1390c8a08cc34f4ce9435ba71df2af3 (patch)
treefe1078c414e1f5e63a391b243eaa1cb65cb06a97 /crates
parentdbb59b00d13d18a8c94b2bdacd6c14b4dc20df29 (diff)
downloadfparkan-97c73118a1390c8a08cc34f4ce9435ba71df2af3.tar.xz
fparkan-97c73118a1390c8a08cc34f4ce9435ba71df2af3.zip
feat(assets): decode research tables and calculate candidate costs
Diffstat (limited to 'crates')
-rw-r--r--crates/fparkan-assets/src/lib.rs952
-rw-r--r--crates/fparkan-assets/src/research.rs884
2 files changed, 1836 insertions, 0 deletions
diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs
index 2ee829f..7673e2a 100644
--- a/crates/fparkan-assets/src/lib.rs
+++ b/crates/fparkan-assets/src/lib.rs
@@ -28,6 +28,8 @@ pub use fparkan_terrain_format::{BuildCategory, TerrainFormatError};
use fparkan_texm::{decode_mip_rgba8, decode_texm, TexmDocument, TexmError};
pub use fparkan_texm::{PageRect, RgbaImage};
use fparkan_vfs::DirectoryVfs;
+mod research;
+pub use research::{calc_full_research_cost, ResearchCost, ResearchError, ResearchTrfDocument};
use std::collections::{hash_map::Entry, HashMap};
use std::fmt;
use std::hash::{Hash, Hasher};
@@ -4478,6 +4480,956 @@ mod tests {
));
}
+ #[test]
+ fn research_trf_reader_preserves_rows_and_uses_low_u16_alias_link() {
+ let bytes = research_cycle_fixture(3, &[0; 5]);
+ let document = ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice()))
+ .expect("synthetic TRF");
+
+ assert_eq!(document.row_count(), 5);
+ assert_eq!(document.find_key(b"a").expect("key lookup"), Some(0));
+ let row = document.row_words(0).expect("first raw row");
+ assert_eq!(row[0], 10.0_f32.to_bits());
+ assert_eq!(row[1], 1.0_f32.to_bits());
+ assert_eq!(row[8], 0x09FF_0000);
+ assert_eq!(document.row_words(2).expect("third row")[8], 0x09FF_0002);
+ assert_eq!(document.flag_byte(0), Some(0));
+ assert_eq!(document.relation_2_3(0).expect("ordered links"), [3, 1]);
+ assert!(document
+ .relation_4_5(0)
+ .expect("empty optional stream")
+ .is_empty());
+ }
+
+ #[test]
+ fn research_trf_reader_rejects_the_native_trf0_attribute_guard() {
+ let bytes = research_cycle_fixture(4, &[0; 5]);
+
+ assert!(ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())).is_err());
+ }
+
+ #[test]
+ fn research_cost_matches_native_cycle_dedup_nonzero_result() {
+ let bytes = research_cycle_fixture(3, &[0; 5]);
+ let research = ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice()))
+ .expect("synthetic TRF");
+ let candidate = research_unit_dat(&[b"A", b"B", b"C", b"B"], &[3, 0, 0, 0], &[1; 4]);
+
+ assert_eq!(
+ calc_full_research_cost(&candidate, &research).expect("cost"),
+ ResearchCost {
+ ore: 15.0,
+ power: 150.0,
+ }
+ );
+ }
+
+ #[test]
+ fn research_trf_reader_preserves_first_alias_raw_link_and_unused_tails() {
+ let costs = [(1.0, 10.0), (2.0, 20.0)];
+ let aliases = [(b"Key".as_slice(), 0), (b"kEy".as_slice(), 1)];
+ let row_alias_links = [1, 0];
+ let empty_groups = [&[][..], &[][..]];
+ let bytes = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0x84, 0x05],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &empty_groups,
+ relations_45: &empty_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ let document = ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice()))
+ .expect("synthetic TRF with unused payload tails");
+
+ assert_eq!(document.row_count(), 2);
+ assert_eq!(document.find_key(b"KEY").expect("alias lookup"), Some(0));
+ assert_eq!(document.find_key(b"missing").expect("missing lookup"), None);
+ assert_eq!(document.row_name(0).expect("raw low-u16 link"), b"kEy");
+ assert_eq!(document.row_words(0).expect("raw row")[8], 0x09FF_0001);
+ assert_eq!(document.flag_byte(0), Some(0x84));
+ assert_eq!(document.relation_2_3(0).expect("empty group"), &[]);
+ assert_eq!(document.relation_4_5(0).expect("empty second group"), &[]);
+
+ let trf0 = document
+ .archive()
+ .entries()
+ .iter()
+ .find(|entry| entry.meta().type_id == u32::from_le_bytes(*b"TRF0"))
+ .expect("retained TRF0 chunk");
+ assert!(document
+ .archive()
+ .payload(trf0.id())
+ .expect("retained payload")
+ .ends_with(RESEARCH_TEST_TAIL));
+ }
+
+ #[test]
+ fn research_trf_reader_rejects_required_chunk_and_attribute_errors() {
+ let cycle_costs = [
+ (1.0, 10.0),
+ (2.0, 20.0),
+ (3.0, 30.0),
+ (4.0, 40.0),
+ (5.0, 50.0),
+ ];
+ let names: [&[u8]; 5] = [b"A", b"B", b"C", b"D", b"E"];
+ let aliases: Vec<_> = names
+ .iter()
+ .enumerate()
+ .map(|(index, name)| (*name, u16::try_from(index).expect("row id")))
+ .collect();
+ let row_alias_links = [0, 1, 2, 3, 4];
+ let no_groups = [&[][..]; 5];
+
+ let invalid_attr = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 4,
+ flags: &[0; 5],
+ costs: &cycle_costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ assert!(matches!(
+ ResearchTrfDocument::decode(Arc::from(invalid_attr.into_boxed_slice())),
+ Err(ResearchError::InvalidTrf0Attribute { got: 4 })
+ ));
+
+ let missing_required = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 5],
+ costs: &cycle_costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[u32::from_le_bytes(*b"TRF8")],
+ });
+ assert!(matches!(
+ ResearchTrfDocument::decode(Arc::from(missing_required.into_boxed_slice())),
+ Err(ResearchError::MissingChunk { type_id })
+ if type_id == u32::from_le_bytes(*b"TRF8")
+ ));
+ }
+
+ #[test]
+ fn research_trf_reader_zero_fills_short_flags_and_rejects_long_flags() {
+ let costs = [(1.0, 10.0), (2.0, 20.0)];
+ let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)];
+ let row_alias_links = [0, 1];
+ let no_groups = [&[][..], &[][..]];
+ let short = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0x84],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ let document = ResearchTrfDocument::decode(Arc::from(short.into_boxed_slice()))
+ .expect("short TRF1 is zero-filled by native loader");
+ assert_eq!(document.flag_byte(0), Some(0x84));
+ assert_eq!(document.flag_byte(1), Some(0));
+
+ let long = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 3],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ assert!(matches!(
+ ResearchTrfDocument::decode(Arc::from(long.into_boxed_slice())),
+ Err(ResearchError::FlagsTooLong {
+ row_count: 2,
+ actual: 3
+ })
+ ));
+ }
+
+ #[test]
+ fn research_trf_reader_checks_alias_and_pair_bounds_and_non_ascii_policy() {
+ let costs = [(1.0, 10.0), (2.0, 20.0)];
+ let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)];
+ let row_alias_links = [0, 1];
+ let no_groups = [&[][..], &[][..]];
+ let make_bytes = || {
+ research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 2],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ })
+ };
+
+ let mut invalid_name_offset = make_bytes();
+ research_set_payload_u16(
+ &mut invalid_name_offset,
+ u32::from_le_bytes(*b"TRFB"),
+ 0,
+ u16::MAX,
+ );
+ assert!(matches!(
+ ResearchTrfDocument::decode(Arc::from(invalid_name_offset.into_boxed_slice())),
+ Err(ResearchError::InvalidNameOffset {
+ pair_index: 0,
+ offset: u16::MAX
+ })
+ ));
+
+ let mut unterminated = make_bytes();
+ research_set_payload_byte(&mut unterminated, u32::from_le_bytes(*b"TRF6"), 1, b'X');
+ research_set_payload_byte(&mut unterminated, u32::from_le_bytes(*b"TRF6"), 3, b'X');
+ assert!(matches!(
+ ResearchTrfDocument::decode(Arc::from(unterminated.into_boxed_slice())),
+ Err(ResearchError::UnterminatedName {
+ pair_index: 0,
+ offset: 0
+ })
+ ));
+
+ let mut invalid_record_pair = make_bytes();
+ research_set_payload_u32(
+ &mut invalid_record_pair,
+ u32::from_le_bytes(*b"TRF0"),
+ 32,
+ 0x09FF_0002,
+ );
+ assert!(matches!(
+ ResearchTrfDocument::decode(Arc::from(invalid_record_pair.into_boxed_slice())),
+ Err(ResearchError::InvalidRecordPair {
+ record_index: 0,
+ pair_index: 2,
+ pair_count: 2
+ })
+ ));
+
+ let mut invalid_alias_row = make_bytes();
+ research_set_payload_u16(&mut invalid_alias_row, u32::from_le_bytes(*b"TRFB"), 2, 7);
+ let document = ResearchTrfDocument::decode(Arc::from(invalid_alias_row.into_boxed_slice()))
+ .expect("alias row is checked when selected");
+ assert!(matches!(
+ document.find_key(b"A"),
+ Err(ResearchError::InvalidResearchIndex {
+ type_id,
+ index: 7,
+ row_count: 2
+ }) if type_id == u32::from_le_bytes(*b"TRFB")
+ ));
+
+ let non_ascii_aliases = [(b"\xC4".as_slice(), 0), (b"A".as_slice(), 1)];
+ let non_ascii = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 2],
+ costs: &costs,
+ aliases: &non_ascii_aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ let document = ResearchTrfDocument::decode(Arc::from(non_ascii.into_boxed_slice()))
+ .expect("non-ASCII names are preserved");
+ assert!(matches!(
+ document.find_key(b"A"),
+ Err(ResearchError::UnsupportedNonAsciiLookup { .. })
+ ));
+ assert!(matches!(
+ document.find_key(b"\xE4"),
+ Err(ResearchError::UnsupportedNonAsciiLookup { .. })
+ ));
+ }
+
+ #[test]
+ fn research_trf_reader_checks_optional_streams_group_bounds_and_relation_ids() {
+ let costs = [(1.0, 10.0), (2.0, 20.0)];
+ let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)];
+ let row_alias_links = [0, 1];
+ let no_groups = [&[][..], &[][..]];
+ let missing_zero_streams = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 2],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: false,
+ include_trf5: false,
+ omitted_chunks: &[],
+ });
+ let document =
+ ResearchTrfDocument::decode(Arc::from(missing_zero_streams.into_boxed_slice()))
+ .expect("missing optional streams with zero IDs are empty");
+ assert_eq!(
+ document.relation_2_3(0).expect("empty optional stream"),
+ &[]
+ );
+ assert_eq!(
+ document.relation_4_5(0).expect("empty optional stream"),
+ &[]
+ );
+
+ let positive_group = [&[1_u32][..], &[][..]];
+ let missing_trf3 = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 2],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &positive_group,
+ relations_45: &no_groups,
+ include_trf3: false,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ assert!(matches!(
+ ResearchTrfDocument::decode(Arc::from(missing_trf3.into_boxed_slice())),
+ Err(ResearchError::MissingRelationStream { type_id, required_ids: 1 })
+ if type_id == u32::from_le_bytes(*b"TRF3")
+ ));
+
+ let out_of_range_23 = [&[7_u32][..], &[][..]];
+ let out_of_range_45 = [&[][..], &[9_u32][..]];
+ let invalid_ids = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 2],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &out_of_range_23,
+ relations_45: &out_of_range_45,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ let document = ResearchTrfDocument::decode(Arc::from(invalid_ids.into_boxed_slice()))
+ .expect("indices are checked when a group is accessed");
+ assert!(matches!(
+ document.relation_2_3(0),
+ Err(ResearchError::InvalidResearchIndex { type_id, index: 7, row_count: 2 })
+ if type_id == u32::from_le_bytes(*b"TRF3")
+ ));
+ assert!(matches!(
+ document.relation_4_5(1),
+ Err(ResearchError::InvalidResearchIndex { type_id, index: 9, row_count: 2 })
+ if type_id == u32::from_le_bytes(*b"TRF5")
+ ));
+ assert!(matches!(
+ document.relation_2_3(usize::MAX),
+ Err(ResearchError::MissingRelationGroup { type_id, .. })
+ if type_id == u32::from_le_bytes(*b"TRF2")
+ ));
+
+ let one_group = [&[][..]];
+ let short_table = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 2],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &one_group,
+ relations_45: &one_group,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ let document = ResearchTrfDocument::decode(Arc::from(short_table.into_boxed_slice()))
+ .expect("relation tables have their own counts");
+ assert!(matches!(
+ document.relation_2_3(1),
+ Err(ResearchError::MissingRelationGroup { type_id, index: 1, group_count: 1 })
+ if type_id == u32::from_le_bytes(*b"TRF2")
+ ));
+ assert!(matches!(
+ document.relation_4_5(1),
+ Err(ResearchError::MissingRelationGroup { type_id, index: 1, group_count: 1 })
+ if type_id == u32::from_le_bytes(*b"TRF4")
+ ));
+ }
+
+ #[test]
+ fn research_trf_reader_checks_count_prefixes_without_large_allocations() {
+ let costs = [(1.0, 10.0), (2.0, 20.0)];
+ let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)];
+ let row_alias_links = [0, 1];
+ let no_groups = [&[][..], &[][..]];
+ let mut bytes = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0; 2],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ research_set_nres_attr1(&mut bytes, u32::from_le_bytes(*b"TRF2"), 3);
+ assert!(matches!(
+ ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())),
+ Err(ResearchError::Binary(
+ fparkan_binary::DecodeError::UnexpectedEof { .. }
+ ))
+ ));
+ }
+
+ #[test]
+ fn research_cost_preserves_native_tree_order_and_f32_rounding() {
+ let costs = [(16_777_216.0, 20.0), (-16_777_216.0, 30.0), (1.0, 10.0)];
+ let aliases_forward = [
+ (b"A".as_slice(), 2),
+ (b"B".as_slice(), 0),
+ (b"C".as_slice(), 1),
+ ];
+ let aliases_reversed = [
+ (b"A".as_slice(), 0),
+ (b"B".as_slice(), 1),
+ (b"C".as_slice(), 2),
+ ];
+ let row_alias_links = [0, 1, 2];
+ let no_groups = [&[][..], &[][..], &[][..]];
+ let make_research = |aliases: &[(&[u8], u16)]| {
+ let bytes = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0x05; 3],
+ costs: &costs,
+ aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())).expect("f32 order TRF")
+ };
+ let candidate = research_unit_dat(&[b"A", b"B", b"C", b"B"], &[3, 0, 0, 0], &[1; 4]);
+
+ let forward = calc_full_research_cost(&candidate, &make_research(&aliases_forward))
+ .expect("forward order cost");
+ assert_eq!(forward.ore.to_bits(), 0.0_f32.to_bits());
+ assert_eq!(forward.power.to_bits(), 60.0_f32.to_bits());
+
+ let reversed = calc_full_research_cost(&candidate, &make_research(&aliases_reversed))
+ .expect("reversed order cost");
+ assert_eq!(reversed.ore.to_bits(), 1.0_f32.to_bits());
+ assert_eq!(reversed.power.to_bits(), 60.0_f32.to_bits());
+ }
+
+ #[test]
+ fn research_cost_obeys_subtree_and_record_gates() {
+ let costs = [
+ (1.0, 10.0),
+ (2.0, 20.0),
+ (3.0, 30.0),
+ (4.0, 40.0),
+ (5.0, 50.0),
+ ];
+ let names: [&[u8]; 5] = [b"A", b"B", b"C", b"D", b"E"];
+ let aliases: Vec<_> = names
+ .iter()
+ .enumerate()
+ .map(|(index, name)| (*name, u16::try_from(index).expect("row id")))
+ .collect();
+ let row_alias_links = [0, 1, 2, 3, 4];
+ let relations_45 = [&[][..]; 5];
+ let candidate = research_unit_dat(&[b"A", b"B", b"C", b"B"], &[3, 0, 0, 0], &[1; 4]);
+
+ let recursion_gate = research_cycle_fixture(3, &[0x05, 0x05, 0x05, 0x04, 0x04]);
+ let research = ResearchTrfDocument::decode(Arc::from(recursion_gate.into_boxed_slice()))
+ .expect("recursion-gate TRF");
+ assert_eq!(
+ calc_full_research_cost(&candidate, &research).expect("cost"),
+ ResearchCost {
+ ore: 6.0,
+ power: 60.0
+ }
+ );
+
+ let include_gate = research_cycle_fixture(3, &[0x04, 0x05, 0x05, 0x06, 0x04]);
+ let research = ResearchTrfDocument::decode(Arc::from(include_gate.into_boxed_slice()))
+ .expect("include-gate TRF");
+ assert_eq!(
+ calc_full_research_cost(&candidate, &research).expect("cost"),
+ ResearchCost {
+ ore: 6.0,
+ power: 60.0
+ }
+ );
+
+ let no_groups = [&[][..]; 5];
+ let isolated = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0x04; 5],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: &no_groups,
+ relations_45: &relations_45,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ let research = ResearchTrfDocument::decode(Arc::from(isolated.into_boxed_slice()))
+ .expect("isolated TRF");
+ let child_pruned = research_unit_dat(&[b"A", b"B", b"C"], &[1, 1, 0], &[1, 0, 1]);
+ assert_eq!(
+ calc_full_research_cost(&child_pruned, &research).expect("pruned child cost"),
+ ResearchCost {
+ ore: 1.0,
+ power: 10.0
+ }
+ );
+ let root_pruned = research_unit_dat(&[b"A", b"B"], &[1, 0], &[0, 1]);
+ assert_eq!(
+ calc_full_research_cost(&root_pruned, &research).expect("pruned root cost"),
+ ResearchCost {
+ ore: 0.0,
+ power: 0.0
+ }
+ );
+ }
+
+ #[test]
+ fn research_cost_rejects_unknown_keys_unterminated_keys_and_missing_groups() {
+ let costs = [(1.0, 10.0), (2.0, 20.0)];
+ let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)];
+ let row_alias_links = [0, 1];
+ let no_groups = [&[][..], &[][..]];
+ let make_document = |groups: &[&[u32]]| {
+ let bytes = research_fixture(ResearchFixtureSpec {
+ trf0_attr2: 3,
+ flags: &[0x04; 2],
+ costs: &costs,
+ aliases: &aliases,
+ row_alias_links: &row_alias_links,
+ relations_23: groups,
+ relations_45: &no_groups,
+ include_trf3: true,
+ include_trf5: true,
+ omitted_chunks: &[],
+ });
+ ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice()))
+ .expect("research table")
+ };
+
+ let no_group_document = make_document(&[]);
+ let candidate = research_unit_dat(&[b"A"], &[0], &[1]);
+ assert!(matches!(
+ calc_full_research_cost(&candidate, &no_group_document),
+ Err(ResearchError::MissingRelationGroup {
+ type_id,
+ index: 0,
+ group_count: 0
+ }) if type_id == u32::from_le_bytes(*b"TRF2")
+ ));
+
+ let valid_groups = [&[][..], &[][..]];
+ let document = make_document(&valid_groups);
+ let unknown = research_unit_dat(&[b"Z"], &[0], &[1]);
+ assert!(matches!(
+ calc_full_research_cost(&unknown, &document),
+ Err(ResearchError::UnknownResearchKey {
+ component_index: 0,
+ ..
+ })
+ ));
+ let unterminated = research_unit_dat(&[&[b'A'; 32]], &[0], &[1]);
+ assert!(matches!(
+ calc_full_research_cost(&unterminated, &document),
+ Err(ResearchError::MissingCandidateKeyTerminator { component_index: 0 })
+ ));
+ }
+
+ #[test]
+ #[ignore = "requires licensed Part 1 corpus"]
+ fn licensed_scream_trf_and_12hel1_dat_exercise_research_cost() {
+ let root = fixture_root("IS");
+ let trf =
+ std::fs::read(root.join("MISSIONS/SCRIPTS/scream.trf")).expect("licensed scream.trf");
+ let research = ResearchTrfDocument::decode(Arc::from(trf.into_boxed_slice()))
+ .expect("decode licensed research table");
+ assert_eq!(research.row_count(), 368);
+ assert_eq!(research.find_key(b"R_T_02").expect("R_T_02"), Some(205));
+ assert_eq!(research.row_name(205).expect("row 205 name"), b"R_T_02");
+ assert_eq!(
+ &research.row_words(205).expect("row 205 words")[..4],
+ &[0, 0, 0x40A0_0000, 0x4140_0000]
+ );
+ assert_eq!(research.flag_byte(205), Some(0x07));
+ assert!(research
+ .relation_2_3(205)
+ .expect("row 205 TRF2/TRF3 group")
+ .is_empty());
+ assert_eq!(
+ research.relation_4_5(205).expect("row 205 second group"),
+ &[196]
+ );
+
+ let bytes =
+ std::fs::read(root.join("UNITS/UNITS/AI/12hel1.dat")).expect("licensed 12hel1.dat");
+ let candidate = fparkan_prototype::decode_unit_dat(&bytes).expect("decode Unit DAT");
+ calc_full_research_cost(&candidate, &research).expect("calculate candidate research cost");
+ }
+
+ #[test]
+ #[ignore = "requires licensed Part 1 corpus"]
+ fn licensed_part1_research_trf_files_all_decode() {
+ fn collect_trf_files(directory: &std::path::Path, files: &mut Vec<PathBuf>) {
+ for entry in std::fs::read_dir(directory).expect("read licensed directory") {
+ let entry = entry.expect("read directory entry");
+ let path = entry.path();
+ let kind = entry.file_type().expect("read entry type");
+ if kind.is_dir() {
+ collect_trf_files(&path, files);
+ } else if !kind.is_symlink()
+ && path
+ .extension()
+ .map(|extension| extension.to_string_lossy().eq_ignore_ascii_case("trf"))
+ .unwrap_or(false)
+ {
+ files.push(path);
+ }
+ }
+ }
+
+ let root = fixture_root("IS");
+ let mut files = Vec::new();
+ collect_trf_files(&root, &mut files);
+ files.sort();
+ assert!(!files.is_empty(), "licensed corpus contains no .trf files");
+ for path in files {
+ let bytes = std::fs::read(&path).expect("read TRF resource");
+ ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice()))
+ .unwrap_or_else(|error| panic!("{}: {error}", path.display()));
+ }
+ }
+
+ const RESEARCH_TEST_TAIL: &[u8] = &[0xA5, 0x5A];
+
+ struct ResearchFixtureSpec<'a> {
+ trf0_attr2: u32,
+ flags: &'a [u8],
+ costs: &'a [(f32, f32)],
+ aliases: &'a [(&'a [u8], u16)],
+ row_alias_links: &'a [u16],
+ relations_23: &'a [&'a [u32]],
+ relations_45: &'a [&'a [u32]],
+ include_trf3: bool,
+ include_trf5: bool,
+ omitted_chunks: &'a [u32],
+ }
+
+ fn research_fixture(spec: ResearchFixtureSpec<'_>) -> Vec<u8> {
+ assert_eq!(spec.costs.len(), spec.row_alias_links.len());
+
+ let mut rows = Vec::with_capacity(spec.costs.len());
+ for ((ore, power), pair_index) in spec.costs.iter().zip(spec.row_alias_links) {
+ let mut row = [0_u32; 10];
+ row[0] = power.to_bits();
+ row[1] = ore.to_bits();
+ row[8] = 0x09FF_0000 | u32::from(*pair_index);
+ rows.push(row);
+ }
+ let rows_payload = research_words_payload(
+ &rows
+ .iter()
+ .flat_map(|row| row.iter().copied())
+ .collect::<Vec<_>>(),
+ );
+
+ let mut name_pool = Vec::new();
+ let mut pairs = Vec::with_capacity(spec.aliases.len());
+ for (name, row_index) in spec.aliases {
+ let name_offset = u16::try_from(name_pool.len()).expect("test pool offset");
+ name_pool.extend_from_slice(name);
+ name_pool.push(0);
+ pairs.push((name_offset, *row_index));
+ }
+ let pairs_payload = research_pairs_payload(&pairs);
+ let (counts_23, ids_23) = research_relation_payload(spec.relations_23);
+ let (counts_45, ids_45) = research_relation_payload(spec.relations_45);
+ let with_tail = |mut payload: Vec<u8>| {
+ payload.extend_from_slice(RESEARCH_TEST_TAIL);
+ payload
+ };
+
+ let mut payloads = vec![
+ (
+ u32::from_le_bytes(*b"TRF0"),
+ with_tail(rows_payload),
+ spec.trf0_attr2,
+ ),
+ (u32::from_le_bytes(*b"TRF1"), spec.flags.to_vec(), 0),
+ (u32::from_le_bytes(*b"TRFB"), with_tail(pairs_payload), 0),
+ (u32::from_le_bytes(*b"TRF6"), name_pool, 0),
+ (u32::from_le_bytes(*b"TRF7"), Vec::new(), 0),
+ (u32::from_le_bytes(*b"TRF8"), Vec::new(), 0),
+ (u32::from_le_bytes(*b"TRF9"), Vec::new(), 0),
+ (u32::from_le_bytes(*b"TRFA"), Vec::new(), 0),
+ (u32::from_le_bytes(*b"TRF2"), with_tail(counts_23), 0),
+ (u32::from_le_bytes(*b"TRF4"), with_tail(counts_45), 0),
+ ];
+ if spec.include_trf3 {
+ payloads.push((u32::from_le_bytes(*b"TRF3"), with_tail(ids_23), 0));
+ }
+ if spec.include_trf5 {
+ payloads.push((u32::from_le_bytes(*b"TRF5"), with_tail(ids_45), 0));
+ }
+
+ let entries: Vec<_> = payloads
+ .iter()
+ .filter(|(type_id, _, _)| !spec.omitted_chunks.contains(type_id))
+ .map(|(type_id, payload, attr2)| TestNresEntry {
+ name: b"ResTree",
+ payload,
+ type_id: *type_id,
+ attr2: *attr2,
+ })
+ .collect();
+ let mut archive = build_nres_with_meta(&entries);
+ for (type_id, count) in [
+ (u32::from_le_bytes(*b"TRF0"), spec.costs.len()),
+ (u32::from_le_bytes(*b"TRF1"), spec.flags.len()),
+ (u32::from_le_bytes(*b"TRFB"), spec.aliases.len()),
+ (u32::from_le_bytes(*b"TRF2"), spec.relations_23.len()),
+ (u32::from_le_bytes(*b"TRF4"), spec.relations_45.len()),
+ ] {
+ if !spec.omitted_chunks.contains(&type_id) {
+ research_set_nres_attr1(
+ &mut archive,
+ type_id,
+ u32::try_from(count).expect("test count"),
+ );
+ }
+ }
+ if spec.include_trf3 && !spec.omitted_chunks.contains(&u32::from_le_bytes(*b"TRF3")) {
+ let count: usize = spec.relations_23.iter().map(|group| group.len()).sum();
+ research_set_nres_attr1(
+ &mut archive,
+ u32::from_le_bytes(*b"TRF3"),
+ u32::try_from(count).expect("test dependency count"),
+ );
+ }
+ if spec.include_trf5 && !spec.omitted_chunks.contains(&u32::from_le_bytes(*b"TRF5")) {
+ let count: usize = spec.relations_45.iter().map(|group| group.len()).sum();
+ research_set_nres_attr1(
+ &mut archive,
+ u32::from_le_bytes(*b"TRF5"),
+ u32::try_from(count).expect("test dependency count"),
+ );
+ }
+ archive
+ }
+
+ fn research_relation_payload(groups: &[&[u32]]) -> (Vec<u8>, Vec<u8>) {
+ let counts: Vec<_> = groups
+ .iter()
+ .map(|group| u32::try_from(group.len()).expect("test dependency group count"))
+ .collect();
+ let ids: Vec<_> = groups
+ .iter()
+ .flat_map(|group| group.iter().copied())
+ .collect();
+ (
+ research_words_payload(&counts),
+ research_words_payload(&ids),
+ )
+ }
+
+ fn research_set_payload_bytes(bytes: &mut [u8], type_id: u32, offset: usize, value: &[u8]) {
+ let entry_count = u32::from_le_bytes(bytes[8..12].try_into().expect("entry count"));
+ let total_size = u32::from_le_bytes(bytes[12..16].try_into().expect("total size"));
+ let directory_start = usize::try_from(total_size - entry_count * 64).expect("directory");
+ for entry in bytes[directory_start..].chunks_exact(64) {
+ if u32::from_le_bytes(entry[..4].try_into().expect("type id")) != type_id {
+ continue;
+ }
+ let payload_start = usize::try_from(u32::from_le_bytes(
+ entry[56..60].try_into().expect("payload offset"),
+ ))
+ .expect("payload offset");
+ let payload_len = usize::try_from(u32::from_le_bytes(
+ entry[12..16].try_into().expect("payload size"),
+ ))
+ .expect("payload size");
+ let end = offset.checked_add(value.len()).expect("payload end");
+ assert!(end <= payload_len, "test mutation stays within payload");
+ bytes[payload_start + offset..payload_start + end].copy_from_slice(value);
+ return;
+ }
+ panic!("NRes entry type {type_id:#010x} was not built");
+ }
+
+ fn research_set_payload_byte(bytes: &mut [u8], type_id: u32, offset: usize, value: u8) {
+ research_set_payload_bytes(bytes, type_id, offset, &[value]);
+ }
+
+ fn research_set_payload_u16(bytes: &mut [u8], type_id: u32, offset: usize, value: u16) {
+ research_set_payload_bytes(bytes, type_id, offset, &value.to_le_bytes());
+ }
+
+ fn research_set_payload_u32(bytes: &mut [u8], type_id: u32, offset: usize, value: u32) {
+ research_set_payload_bytes(bytes, type_id, offset, &value.to_le_bytes());
+ }
+
+ fn research_cycle_fixture(trf0_attr2: u32, flags: &[u8]) -> Vec<u8> {
+ let costs = [
+ (1.0_f32, 10.0_f32),
+ (2.0_f32, 20.0_f32),
+ (3.0_f32, 30.0_f32),
+ (4.0_f32, 40.0_f32),
+ (5.0_f32, 50.0_f32),
+ ];
+ let mut pool = Vec::new();
+ let mut pairs = Vec::new();
+ let mut rows = Vec::new();
+ for (index, ((ore, power), name)) in costs
+ .iter()
+ .zip([b"A".as_slice(), b"B", b"C", b"D", b"E"])
+ .enumerate()
+ {
+ let offset = u16::try_from(pool.len()).expect("pool offset");
+ pool.extend_from_slice(name);
+ pool.push(0);
+ pairs.push((offset, u16::try_from(index).expect("row index")));
+ rows.push([
+ power.to_bits(),
+ ore.to_bits(),
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0x09FF_0000 | u32::try_from(index).expect("pair index"),
+ 0,
+ ]);
+ }
+
+ let rows_payload = research_words_payload(
+ &rows
+ .iter()
+ .flat_map(|row| row.iter().copied())
+ .collect::<Vec<_>>(),
+ );
+ let pairs_payload = research_pairs_payload(&pairs);
+ let count_23 = [2_u32, 0, 0, 1, 1];
+ let ids_23 = [3_u32, 1, 4, 0];
+ let count_45 = [0_u32; 5];
+ let count_23_payload = research_words_payload(&count_23);
+ let ids_23_payload = research_words_payload(&ids_23);
+ let count_45_payload = research_words_payload(&count_45);
+ let payloads: [(u32, &[u8], u32); 11] = [
+ (
+ u32::from_le_bytes(*b"TRF0"),
+ rows_payload.as_slice(),
+ trf0_attr2,
+ ),
+ (u32::from_le_bytes(*b"TRF1"), flags, 0),
+ (u32::from_le_bytes(*b"TRFB"), pairs_payload.as_slice(), 0),
+ (u32::from_le_bytes(*b"TRF6"), pool.as_slice(), 0),
+ (u32::from_le_bytes(*b"TRF7"), &[][..], 0),
+ (u32::from_le_bytes(*b"TRF8"), &[][..], 0),
+ (u32::from_le_bytes(*b"TRF9"), &[][..], 0),
+ (u32::from_le_bytes(*b"TRFA"), &[][..], 0),
+ (u32::from_le_bytes(*b"TRF2"), &count_23_payload, 0),
+ (u32::from_le_bytes(*b"TRF3"), &ids_23_payload, 0),
+ (u32::from_le_bytes(*b"TRF4"), &count_45_payload, 0),
+ ];
+ let entries: Vec<_> = payloads
+ .iter()
+ .map(|(type_id, payload, attr2)| TestNresEntry {
+ name: b"ResTree",
+ payload,
+ type_id: *type_id,
+ attr2: *attr2,
+ })
+ .collect();
+ let mut archive = build_nres_with_meta(&entries);
+ for (type_id, count) in [
+ (u32::from_le_bytes(*b"TRF0"), 5),
+ (u32::from_le_bytes(*b"TRF1"), 5),
+ (u32::from_le_bytes(*b"TRFB"), 5),
+ (u32::from_le_bytes(*b"TRF2"), 5),
+ (u32::from_le_bytes(*b"TRF3"), 3),
+ (u32::from_le_bytes(*b"TRF4"), 5),
+ ] {
+ research_set_nres_attr1(&mut archive, type_id, count);
+ }
+ archive
+ }
+
+ fn research_words_payload(words: &[u32]) -> Vec<u8> {
+ words.iter().flat_map(|word| word.to_le_bytes()).collect()
+ }
+
+ fn research_pairs_payload(pairs: &[(u16, u16)]) -> Vec<u8> {
+ pairs
+ .iter()
+ .flat_map(|(offset, index)| offset.to_le_bytes().into_iter().chain(index.to_le_bytes()))
+ .collect()
+ }
+
+ fn research_set_nres_attr1(bytes: &mut [u8], type_id: u32, count: u32) {
+ let entry_count = u32::from_le_bytes(bytes[8..12].try_into().expect("entry count"));
+ let total_size = u32::from_le_bytes(bytes[12..16].try_into().expect("total size"));
+ let directory_start = usize::try_from(total_size - entry_count * 64).expect("directory");
+ for entry in bytes[directory_start..].chunks_exact_mut(64) {
+ if u32::from_le_bytes(entry[..4].try_into().expect("type id")) == type_id {
+ entry[4..8].copy_from_slice(&count.to_le_bytes());
+ return;
+ }
+ }
+ panic!("NRes entry type {type_id:#010x} was not built");
+ }
+
+ fn research_unit_dat(
+ keys: &[&[u8]],
+ child_counts: &[u32],
+ kinds: &[u32],
+ ) -> fparkan_prototype::UnitDat {
+ assert_eq!(keys.len(), child_counts.len());
+ assert_eq!(keys.len(), kinds.len());
+ let mut bytes = vec![0; 8 + keys.len() * 112];
+ bytes[..4].copy_from_slice(&0x0000_F0F1_u32.to_le_bytes());
+ for (index, key) in keys.iter().enumerate() {
+ let offset = 8 + index * 112;
+ bytes[offset + 32..offset + 32 + key.len()].copy_from_slice(key);
+ bytes[offset + 64..offset + 68].copy_from_slice(&kinds[index].to_le_bytes());
+ bytes[offset + 108..offset + 112].copy_from_slice(&child_counts[index].to_le_bytes());
+ }
+ fparkan_prototype::decode_unit_dat(&bytes).expect("candidate Unit DAT")
+ }
+
fn resource_device_record(tag: u32, key: u32, base_bits: u32) -> PreparedControlTypedRecord {
let mut fixed_words = [0; 44];
fixed_words[1] = key;
diff --git a/crates/fparkan-assets/src/research.rs b/crates/fparkan-assets/src/research.rs
new file mode 100644
index 0000000..7b2b9fc
--- /dev/null
+++ b/crates/fparkan-assets/src/research.rs
@@ -0,0 +1,884 @@
+use crate::{decode_nres_payload, NresDocument};
+use fparkan_binary::{checked_allocation_len, checked_count_bytes, Cursor, DecodeError, Limits};
+use fparkan_nres::{EntryId, NresEntry};
+use fparkan_prototype::{unit_component_tree, PrototypeError, UnitComponentTreeNode, UnitDat};
+use std::collections::HashMap;
+use std::fmt;
+use std::sync::Arc;
+
+const TRF0: u32 = u32::from_le_bytes(*b"TRF0");
+const TRF1: u32 = u32::from_le_bytes(*b"TRF1");
+const TRF2: u32 = u32::from_le_bytes(*b"TRF2");
+const TRF3: u32 = u32::from_le_bytes(*b"TRF3");
+const TRF4: u32 = u32::from_le_bytes(*b"TRF4");
+const TRF5: u32 = u32::from_le_bytes(*b"TRF5");
+const TRF6: u32 = u32::from_le_bytes(*b"TRF6");
+const TRF7: u32 = u32::from_le_bytes(*b"TRF7");
+const TRF8: u32 = u32::from_le_bytes(*b"TRF8");
+const TRF9: u32 = u32::from_le_bytes(*b"TRF9");
+const TRFA: u32 = u32::from_le_bytes(*b"TRFA");
+const TRFB: u32 = u32::from_le_bytes(*b"TRFB");
+
+const RESEARCH_CHUNKS: [u32; 12] = [
+ TRF0, TRF1, TRF2, TRF3, TRF4, TRF5, TRF6, TRF7, TRF8, TRF9, TRFA, TRFB,
+];
+
+/// Ore and power outputs from one native CalcFullResearchCost call.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ResearchCost {
+ /// First native output, labeled ore by the Calc debug string.
+ pub ore: f32,
+ /// Second native output, labeled power by the Calc debug string.
+ pub power: f32,
+}
+
+/// One decoded MisLoad research table and its original NRes archive.
+#[derive(Debug)]
+pub struct ResearchTrfDocument {
+ archive: NresDocument,
+ records: Vec<[u32; 10]>,
+ flags: Vec<u8>,
+ aliases: Vec<ResearchAlias>,
+ name_pool: Vec<u8>,
+ relation_2_3: ResearchRelations,
+ relation_4_5: ResearchRelations,
+}
+
+#[derive(Clone, Debug)]
+struct ResearchAlias {
+ name_range: std::ops::Range<usize>,
+ row_index: u16,
+}
+
+#[derive(Debug)]
+struct ResearchRelations {
+ table_id: u32,
+ group_starts: Vec<usize>,
+ indices: Vec<u32>,
+}
+
+#[derive(Debug)]
+struct Chunk<'a> {
+ entry: &'a NresEntry,
+ payload: &'a [u8],
+}
+
+/// TRF decoding and cost-calculation error.
+#[derive(Debug)]
+pub enum ResearchError {
+ /// The outer NRes archive is malformed.
+ Nres(fparkan_nres::NresError),
+ /// A bounded TRF field is truncated or overflows.
+ Binary(DecodeError),
+ /// A native-required TRF chunk is absent.
+ MissingChunk { type_id: u32 },
+ /// A recognized TRF chunk occurs more than once.
+ DuplicateChunk { type_id: u32 },
+ /// The native loader requires TRF0 attribute 2 to equal three.
+ InvalidTrf0Attribute { got: u32 },
+ /// A TRF1 payload is too large for the native zero-initialized flag buffer.
+ FlagsTooLong { row_count: usize, actual: usize },
+ /// A TRFB pair points outside the TRF6 name pool.
+ InvalidNameOffset { pair_index: usize, offset: u16 },
+ /// A TRF6 string does not end in NUL before the pool ends.
+ UnterminatedName { pair_index: usize, offset: u16 },
+ /// A TRF0 row's low 16-bit pair link is outside TRFB.
+ InvalidRecordPair {
+ record_index: usize,
+ pair_index: u16,
+ pair_count: usize,
+ },
+ /// A key or relation resolves to a research row outside TRF0.
+ InvalidResearchIndex {
+ type_id: u32,
+ index: u32,
+ row_count: usize,
+ },
+ /// A relation table has no group at this research row.
+ MissingRelationGroup {
+ type_id: u32,
+ index: usize,
+ group_count: usize,
+ },
+ /// A required non-empty relation stream is absent.
+ MissingRelationStream { type_id: u32, required_ids: u64 },
+ /// A candidate Unit DAT resource field is not NUL-terminated.
+ MissingCandidateKeyTerminator { component_index: usize },
+ /// No TRFB key matches an enabled candidate component.
+ UnknownResearchKey {
+ component_index: usize,
+ key: Vec<u8>,
+ },
+ /// A non-ASCII lookup would require an unverified locale-specific fold.
+ UnsupportedNonAsciiLookup {
+ query: Vec<u8>,
+ alias: Option<Vec<u8>>,
+ },
+ /// The research table has no rows.
+ EmptyResearchTable,
+ /// The candidate Unit DAT has no root component.
+ EmptyCandidateTree,
+ /// The existing Unit DAT tree builder rejected the child-count framing.
+ CandidateTree(PrototypeError),
+}
+
+impl fmt::Display for ResearchError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Nres(source) => write!(formatter, "{source}"),
+ Self::Binary(source) => write!(formatter, "{source}"),
+ Self::MissingChunk { type_id } => {
+ write!(formatter, "research NRes is missing chunk {type_id:#010x}")
+ }
+ Self::DuplicateChunk { type_id } => {
+ write!(formatter, "research NRes has duplicate chunk {type_id:#010x}")
+ }
+ Self::InvalidTrf0Attribute { got } => {
+ write!(formatter, "TRF0 attribute 2 is {got}, expected 3")
+ }
+ Self::FlagsTooLong { row_count, actual } => write!(
+ formatter,
+ "TRF1 has {actual} flags, exceeding the {row_count}-byte native buffer"
+ ),
+ Self::InvalidNameOffset { pair_index, offset } => write!(
+ formatter,
+ "TRFB pair {pair_index} name offset {offset} is outside TRF6"
+ ),
+ Self::UnterminatedName { pair_index, offset } => write!(
+ formatter,
+ "TRFB pair {pair_index} name at TRF6 offset {offset} is not NUL-terminated"
+ ),
+ Self::InvalidRecordPair {
+ record_index,
+ pair_index,
+ pair_count,
+ } => write!(
+ formatter,
+ "TRF0 row {record_index} links to TRFB pair {pair_index}, but there are {pair_count} pairs"
+ ),
+ Self::InvalidResearchIndex {
+ type_id,
+ index,
+ row_count,
+ } => write!(
+ formatter,
+ "research index {index} from {type_id:#010x} exceeds TRF0 row count {row_count}"
+ ),
+ Self::MissingRelationGroup {
+ type_id,
+ index,
+ group_count,
+ } => write!(
+ formatter,
+ "{type_id:#010x} has no relation group for row {index} (group count {group_count})"
+ ),
+ Self::MissingRelationStream {
+ type_id,
+ required_ids,
+ } => write!(
+ formatter,
+ "{type_id:#010x} is absent but its count table requires {required_ids} indices"
+ ),
+ Self::MissingCandidateKeyTerminator { component_index } => write!(
+ formatter,
+ "candidate component {component_index} has no NUL in its 32-byte resource key"
+ ),
+ Self::UnknownResearchKey {
+ component_index,
+ key,
+ } => write!(
+ formatter,
+ "candidate component {component_index} key {:?} is absent from TRFB",
+ String::from_utf8_lossy(key)
+ ),
+ Self::UnsupportedNonAsciiLookup { query, alias } => {
+ if let Some(alias) = alias {
+ write!(
+ formatter,
+ "research key comparison between {:?} and {:?} needs unverified non-ASCII case folding",
+ String::from_utf8_lossy(query),
+ String::from_utf8_lossy(alias)
+ )
+ } else {
+ write!(
+ formatter,
+ "research key {:?} needs unverified non-ASCII case folding",
+ String::from_utf8_lossy(query)
+ )
+ }
+ }
+ Self::EmptyResearchTable => write!(formatter, "research table has no rows"),
+ Self::EmptyCandidateTree => write!(formatter, "candidate Unit DAT has no root component"),
+ Self::CandidateTree(source) => write!(formatter, "{source}"),
+ }
+ }
+}
+
+impl std::error::Error for ResearchError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Nres(source) => Some(source),
+ Self::Binary(source) => Some(source),
+ Self::CandidateTree(source) => Some(source),
+ Self::MissingChunk { .. }
+ | Self::DuplicateChunk { .. }
+ | Self::InvalidTrf0Attribute { .. }
+ | Self::FlagsTooLong { .. }
+ | Self::InvalidNameOffset { .. }
+ | Self::UnterminatedName { .. }
+ | Self::InvalidRecordPair { .. }
+ | Self::InvalidResearchIndex { .. }
+ | Self::MissingRelationGroup { .. }
+ | Self::MissingRelationStream { .. }
+ | Self::MissingCandidateKeyTerminator { .. }
+ | Self::UnknownResearchKey { .. }
+ | Self::UnsupportedNonAsciiLookup { .. }
+ | Self::EmptyResearchTable
+ | Self::EmptyCandidateTree => None,
+ }
+ }
+}
+
+impl From<fparkan_nres::NresError> for ResearchError {
+ fn from(value: fparkan_nres::NresError) -> Self {
+ Self::Nres(value)
+ }
+}
+
+impl From<DecodeError> for ResearchError {
+ fn from(value: DecodeError) -> Self {
+ Self::Binary(value)
+ }
+}
+
+impl From<PrototypeError> for ResearchError {
+ fn from(value: PrototypeError) -> Self {
+ Self::CandidateTree(value)
+ }
+}
+
+impl ResearchTrfDocument {
+ /// Decodes the native research table chunks from an NRes archive.
+ ///
+ /// NRes validates the archive framing and payload ranges. This reader then
+ /// validates only the TRF bytes needed by its row, key, and relation views.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error for malformed NRes framing, a missing required chunk,
+ /// an invalid TRF0 guard, or a referenced byte range outside its chunk.
+ pub fn decode(bytes: Arc<[u8]>) -> Result<Self, ResearchError> {
+ let archive = decode_nres_payload(bytes)?;
+ let mut chunk_ids = HashMap::new();
+ for entry in archive.entries() {
+ let type_id = entry.meta().type_id;
+ if RESEARCH_CHUNKS.contains(&type_id) && chunk_ids.insert(type_id, entry.id()).is_some()
+ {
+ return Err(ResearchError::DuplicateChunk { type_id });
+ }
+ }
+
+ let trf0 = required_chunk(&archive, &chunk_ids, TRF0)?;
+ if trf0.entry.meta().attr2 != 3 {
+ return Err(ResearchError::InvalidTrf0Attribute {
+ got: trf0.entry.meta().attr2,
+ });
+ }
+ let row_count = trf0.entry.meta().attr1;
+ let row_bytes = counted_prefix(trf0.payload, u64::from(row_count), 40)?;
+ let records = read_research_rows(row_bytes, row_count)?;
+
+ let trf1 = required_chunk(&archive, &chunk_ids, TRF1)?;
+ let flag_count = usize::try_from(row_count).map_err(|_| DecodeError::IntegerOverflow)?;
+ if trf1.payload.len() > flag_count {
+ return Err(ResearchError::FlagsTooLong {
+ row_count: flag_count,
+ actual: trf1.payload.len(),
+ });
+ }
+ let mut flags = vec![0; flag_count];
+ flags[..trf1.payload.len()].copy_from_slice(trf1.payload);
+
+ let trfb = required_chunk(&archive, &chunk_ids, TRFB)?;
+ let alias_count = trfb.entry.meta().attr1;
+ let pair_bytes = counted_prefix(trfb.payload, u64::from(alias_count), 4)?;
+ let trf6 = required_chunk(&archive, &chunk_ids, TRF6)?;
+ let name_pool = trf6.payload.to_vec();
+ let aliases = read_aliases(pair_bytes, &name_pool, alias_count)?;
+
+ for (record_index, record) in records.iter().enumerate() {
+ // The native reader casts the packed word to u16 before indexing TRFB.
+ let pair_index = record[8] as u16;
+ if usize::from(pair_index) >= aliases.len() {
+ return Err(ResearchError::InvalidRecordPair {
+ record_index,
+ pair_index,
+ pair_count: aliases.len(),
+ });
+ }
+ }
+
+ for type_id in [TRF7, TRF8, TRF9, TRFA] {
+ required_chunk(&archive, &chunk_ids, type_id)?;
+ }
+
+ let relation_2_3 = read_relations(&archive, &chunk_ids, TRF2, TRF3)?;
+ let relation_4_5 = read_relations(&archive, &chunk_ids, TRF4, TRF5)?;
+
+ Ok(Self {
+ archive,
+ records,
+ flags,
+ aliases,
+ name_pool,
+ relation_2_3,
+ relation_4_5,
+ })
+ }
+
+ /// Returns the retained NRes document, including opaque chunks and unused payload tails.
+ #[must_use]
+ pub fn archive(&self) -> &NresDocument {
+ &self.archive
+ }
+
+ /// Number of TRF0 rows, taken from that chunk's own attr1.
+ #[must_use]
+ pub fn row_count(&self) -> usize {
+ self.records.len()
+ }
+
+ /// Looks up the first ASCII-case-insensitive TRFB alias in source order.
+ ///
+ /// The shipped research keys are ASCII and use ASCII case-insensitive matching.
+ /// Comparisons involving non-ASCII bytes fail explicitly because native
+ /// locale-specific folding for those bytes is unknown.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if matching requires unverified non-ASCII folding or
+ /// the selected alias contains an out-of-range row id.
+ pub fn find_key(&self, key: &[u8]) -> Result<Option<usize>, ResearchError> {
+ let key = key.split(|byte| *byte == 0).next().unwrap_or_default();
+ if !key.is_ascii() {
+ return Err(ResearchError::UnsupportedNonAsciiLookup {
+ query: key.to_vec(),
+ alias: None,
+ });
+ }
+ for alias in &self.aliases {
+ let name = &self.name_pool[alias.name_range.clone()];
+ if !name.is_ascii() {
+ return Err(ResearchError::UnsupportedNonAsciiLookup {
+ query: key.to_vec(),
+ alias: Some(name.to_vec()),
+ });
+ }
+ if name.eq_ignore_ascii_case(key) {
+ let row_index = u32::from(alias.row_index);
+ if usize::from(alias.row_index) >= self.records.len() {
+ return Err(ResearchError::InvalidResearchIndex {
+ type_id: TRFB,
+ index: row_index,
+ row_count: self.records.len(),
+ });
+ }
+ return Ok(Some(usize::from(alias.row_index)));
+ }
+ }
+ Ok(None)
+ }
+
+ /// Returns the preserved ten raw TRF0 words for one row.
+ #[must_use]
+ pub fn row_words(&self, index: usize) -> Option<&[u32; 10]> {
+ self.records.get(index)
+ }
+
+ /// Returns the original TRF1 byte for one row.
+ #[must_use]
+ pub fn flag_byte(&self, index: usize) -> Option<u8> {
+ self.flags.get(index).copied()
+ }
+
+ /// Returns the raw name bytes selected by a row's low-16-bit TRFB link.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error when the row is outside TRF0.
+ pub fn row_name(&self, index: usize) -> Result<&[u8], ResearchError> {
+ let row = self
+ .records
+ .get(index)
+ .ok_or(ResearchError::InvalidResearchIndex {
+ type_id: TRF0,
+ index: u32::try_from(index).unwrap_or(u32::MAX),
+ row_count: self.records.len(),
+ })?;
+ let pair_index = usize::from(row[8] as u16);
+ let alias = self
+ .aliases
+ .get(pair_index)
+ .ok_or(ResearchError::InvalidRecordPair {
+ record_index: index,
+ pair_index: row[8] as u16,
+ pair_count: self.aliases.len(),
+ })?;
+ Ok(&self.name_pool[alias.name_range.clone()])
+ }
+
+ /// Returns the ordered TRF2/TRF3 indices used by CalcFullResearchCost.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if this row has no TRF2 group or its TRF3 stream
+ /// contains a row index outside TRF0.
+ pub fn relation_2_3(&self, index: usize) -> Result<&[u32], ResearchError> {
+ let related = self.relation_2_3.get(index)?;
+ validate_relation_indices(related, TRF3, self.records.len())?;
+ Ok(related)
+ }
+
+ /// Returns the separate ordered TRF4/TRF5 index group.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if this row has no TRF4 group or its TRF5 stream
+ /// contains a row index outside TRF0.
+ pub fn relation_4_5(&self, index: usize) -> Result<&[u32], ResearchError> {
+ let related = self.relation_4_5.get(index)?;
+ validate_relation_indices(related, TRF5, self.records.len())?;
+ Ok(related)
+ }
+
+ fn gates(&self, index: usize) -> Result<(u32, u32, u32), ResearchError> {
+ let flag = self
+ .flags
+ .get(index)
+ .copied()
+ .ok_or(ResearchError::InvalidResearchIndex {
+ type_id: TRF1,
+ index: u32::try_from(index).unwrap_or(u32::MAX),
+ row_count: self.records.len(),
+ })?;
+ Ok((
+ u32::from((flag >> 1) & 1),
+ u32::from(flag & 1),
+ u32::from((flag >> 2) & 1),
+ ))
+ }
+}
+
+/// Calculates the ordered ore and power cost for one candidate Unit DAT tree.
+///
+/// # Errors
+///
+/// Returns an error for an empty research table, malformed candidate tree,
+/// missing or unsupported key comparison, invalid relation group, or invalid
+/// row reference.
+pub fn calc_full_research_cost(
+ candidate: &UnitDat,
+ research: &ResearchTrfDocument,
+) -> Result<ResearchCost, ResearchError> {
+ if research.records.is_empty() {
+ return Err(ResearchError::EmptyResearchTable);
+ }
+ let tree = unit_component_tree(&candidate.records)?;
+ if tree.is_empty() {
+ return Err(ResearchError::EmptyCandidateTree);
+ }
+
+ let mut visited = vec![false; research.records.len()];
+ let direct = collect_direct_indices(candidate, &tree, research, &mut visited)?;
+ let mut closure = Vec::new();
+ for &index in &direct {
+ closure.push(index);
+ let (_, recurse, _) = research.gates(index)?;
+ if recurse == 0 {
+ append_relation_closure(index, research, &mut visited, &mut closure)?;
+ }
+ }
+
+ let mut ore = 0.0_f32;
+ let mut power = 0.0_f32;
+ for index in closure {
+ let (included, _, _) = research.gates(index)?;
+ if included == 0 {
+ let row = research
+ .records
+ .get(index)
+ .ok_or(ResearchError::InvalidResearchIndex {
+ type_id: TRF0,
+ index: u32::try_from(index).unwrap_or(u32::MAX),
+ row_count: research.records.len(),
+ })?;
+ ore = ore + f32::from_bits(row[1]);
+ power = power + f32::from_bits(row[0]);
+ }
+ }
+
+ Ok(ResearchCost { ore, power })
+}
+
+fn collect_direct_indices(
+ candidate: &UnitDat,
+ tree: &[UnitComponentTreeNode],
+ research: &ResearchTrfDocument,
+ visited: &mut [bool],
+) -> Result<Vec<usize>, ResearchError> {
+ let mut direct = Vec::new();
+ let mut stack = vec![0_usize];
+ let mut first_error = None;
+
+ while let Some(node_index) = stack.pop() {
+ let Some(node) = tree.get(node_index) else {
+ return Err(ResearchError::CandidateTree(
+ PrototypeError::InvalidUnitHierarchy(format!(
+ "component index {node_index} is outside the reconstructed tree"
+ )),
+ ));
+ };
+ let Some(component) = candidate.records.get(node.record_index) else {
+ return Err(ResearchError::CandidateTree(
+ PrototypeError::InvalidUnitHierarchy(format!(
+ "tree references missing component {}",
+ node.record_index
+ )),
+ ));
+ };
+
+ if component.kind == 0 {
+ continue;
+ }
+
+ let key_end = component.resource_raw.iter().position(|byte| *byte == 0);
+ let key = match key_end {
+ Some(end) => &component.resource_raw[..end],
+ None => {
+ if first_error.is_none() {
+ first_error = Some(ResearchError::MissingCandidateKeyTerminator {
+ component_index: node.record_index,
+ });
+ }
+ for child in node.children.iter().rev() {
+ stack.push(*child);
+ }
+ continue;
+ }
+ };
+
+ match research.find_key(key) {
+ Ok(Some(index)) => match research.gates(index) {
+ Ok((included, _, _)) => {
+ if included == 0 && !visited[index] {
+ visited[index] = true;
+ direct.push(index);
+ }
+ }
+ Err(error) => {
+ if first_error.is_none() {
+ first_error = Some(error);
+ }
+ }
+ },
+ Ok(None) => {
+ if first_error.is_none() {
+ first_error = Some(ResearchError::UnknownResearchKey {
+ component_index: node.record_index,
+ key: key.to_vec(),
+ });
+ }
+ }
+ Err(error) => {
+ if first_error.is_none() {
+ first_error = Some(error);
+ }
+ }
+ }
+
+ for child in node.children.iter().rev() {
+ stack.push(*child);
+ }
+ }
+
+ if let Some(error) = first_error {
+ return Err(error);
+ }
+ Ok(direct)
+}
+
+#[derive(Clone, Copy)]
+struct RelationFrame {
+ row_index: usize,
+ next_child: usize,
+}
+
+fn append_relation_closure(
+ root: usize,
+ research: &ResearchTrfDocument,
+ visited: &mut [bool],
+ closure: &mut Vec<usize>,
+) -> Result<(), ResearchError> {
+ let mut stack = vec![RelationFrame {
+ row_index: root,
+ next_child: 0,
+ }];
+
+ while let Some(frame) = stack.last().copied() {
+ let related = research.relation_2_3.get(frame.row_index)?;
+ if frame.next_child >= related.len() {
+ stack.pop();
+ continue;
+ }
+ let related_index = related[frame.next_child];
+ stack
+ .last_mut()
+ .expect("non-empty relation stack")
+ .next_child += 1;
+
+ let index = usize::try_from(related_index).map_err(|_| DecodeError::IntegerOverflow)?;
+ if index >= research.records.len() {
+ return Err(ResearchError::InvalidResearchIndex {
+ type_id: TRF3,
+ index: related_index,
+ row_count: research.records.len(),
+ });
+ }
+ let (included, recurse, _) = research.gates(index)?;
+ if included != 0 || visited[index] {
+ continue;
+ }
+
+ visited[index] = true;
+ closure.push(index);
+ if recurse == 0 {
+ stack.push(RelationFrame {
+ row_index: index,
+ next_child: 0,
+ });
+ }
+ }
+
+ Ok(())
+}
+
+impl ResearchRelations {
+ fn get(&self, index: usize) -> Result<&[u32], ResearchError> {
+ let group_count = self.group_starts.len().saturating_sub(1);
+ let missing_group = || ResearchError::MissingRelationGroup {
+ type_id: self.table_id,
+ index,
+ group_count,
+ };
+ let Some(end_index) = index.checked_add(1) else {
+ return Err(missing_group());
+ };
+ let Some(&start) = self.group_starts.get(index) else {
+ return Err(missing_group());
+ };
+ let Some(&end) = self.group_starts.get(end_index) else {
+ return Err(missing_group());
+ };
+ Ok(&self.indices[start..end])
+ }
+}
+
+fn validate_relation_indices(
+ related: &[u32],
+ type_id: u32,
+ row_count: usize,
+) -> Result<(), ResearchError> {
+ for &index in related {
+ if usize::try_from(index).map_or(true, |value| value >= row_count) {
+ return Err(ResearchError::InvalidResearchIndex {
+ type_id,
+ index,
+ row_count,
+ });
+ }
+ }
+ Ok(())
+}
+
+fn required_chunk<'a>(
+ archive: &'a NresDocument,
+ chunk_ids: &HashMap<u32, EntryId>,
+ type_id: u32,
+) -> Result<Chunk<'a>, ResearchError> {
+ let id = chunk_ids
+ .get(&type_id)
+ .copied()
+ .ok_or(ResearchError::MissingChunk { type_id })?;
+ let entry = archive
+ .entry(id)
+ .ok_or(fparkan_nres::NresError::EntryIdOutOfRange {
+ id: id.0,
+ entry_count: archive.header().entry_count,
+ })?;
+ let payload = archive.payload(id)?;
+ Ok(Chunk { entry, payload })
+}
+
+fn optional_chunk<'a>(
+ archive: &'a NresDocument,
+ chunk_ids: &HashMap<u32, EntryId>,
+ type_id: u32,
+) -> Result<Option<Chunk<'a>>, ResearchError> {
+ let Some(id) = chunk_ids.get(&type_id).copied() else {
+ return Ok(None);
+ };
+ let entry = archive
+ .entry(id)
+ .ok_or(fparkan_nres::NresError::EntryIdOutOfRange {
+ id: id.0,
+ entry_count: archive.header().entry_count,
+ })?;
+ let payload = archive.payload(id)?;
+ Ok(Some(Chunk { entry, payload }))
+}
+
+fn counted_prefix(payload: &[u8], count: u64, stride: u64) -> Result<&[u8], ResearchError> {
+ let remaining = u64::try_from(payload.len()).map_err(|_| DecodeError::IntegerOverflow)?;
+ let size = checked_count_bytes(count, stride, remaining)?;
+ Ok(&payload[..size])
+}
+
+fn read_research_rows(bytes: &[u8], row_count: u32) -> Result<Vec<[u32; 10]>, ResearchError> {
+ let capacity = checked_allocation_len(
+ u64::from(row_count),
+ u64::from(Limits::default().max_array_items),
+ )?;
+ let mut cursor = Cursor::new(bytes);
+ let mut records = Vec::with_capacity(capacity);
+ for _ in 0..row_count {
+ let mut row = [0_u32; 10];
+ for word in &mut row {
+ *word = cursor.read_u32_le()?;
+ }
+ records.push(row);
+ }
+ cursor.require_eof()?;
+ Ok(records)
+}
+
+fn read_aliases(
+ bytes: &[u8],
+ name_pool: &[u8],
+ alias_count: u32,
+) -> Result<Vec<ResearchAlias>, ResearchError> {
+ let capacity = checked_allocation_len(
+ u64::from(alias_count),
+ u64::from(Limits::default().max_array_items),
+ )?;
+ let mut cursor = Cursor::new(bytes);
+ let mut aliases = Vec::with_capacity(capacity);
+ for pair_index in 0..capacity {
+ let name_offset = cursor.read_u16_le()?;
+ let row_index = cursor.read_u16_le()?;
+ let offset = usize::from(name_offset);
+ let suffix = name_pool
+ .get(offset..)
+ .ok_or(ResearchError::InvalidNameOffset {
+ pair_index,
+ offset: name_offset,
+ })?;
+ let length =
+ suffix
+ .iter()
+ .position(|byte| *byte == 0)
+ .ok_or(ResearchError::UnterminatedName {
+ pair_index,
+ offset: name_offset,
+ })?;
+ aliases.push(ResearchAlias {
+ name_range: offset..offset + length,
+ row_index,
+ });
+ }
+ cursor.require_eof()?;
+ Ok(aliases)
+}
+
+fn read_relations(
+ archive: &NresDocument,
+ chunk_ids: &HashMap<u32, EntryId>,
+ count_type: u32,
+ index_type: u32,
+) -> Result<ResearchRelations, ResearchError> {
+ let count_chunk = required_chunk(archive, chunk_ids, count_type)?;
+ let count_count = count_chunk.entry.meta().attr1;
+ let count_bytes = counted_prefix(count_chunk.payload, u64::from(count_count), 4)?;
+ let mut cursor = Cursor::new(count_bytes);
+ let max_items = u64::from(Limits::default().max_array_items);
+ let group_count = u64::from(count_count);
+ let offset_count = group_count
+ .checked_add(1)
+ .ok_or(DecodeError::IntegerOverflow)?;
+ let offset_capacity = checked_allocation_len(
+ offset_count,
+ max_items
+ .checked_add(1)
+ .ok_or(DecodeError::IntegerOverflow)?,
+ )?;
+ let mut group_starts = Vec::with_capacity(offset_capacity);
+ group_starts.push(0_usize);
+ let mut total_ids = 0_u64;
+ for _ in 0..count_count {
+ let count = cursor.read_u32_le()?;
+ total_ids = total_ids
+ .checked_add(u64::from(count))
+ .ok_or(DecodeError::IntegerOverflow)?;
+ let next = checked_allocation_len(total_ids, max_items)?;
+ group_starts.push(next);
+ }
+
+ let index_chunk = optional_chunk(archive, chunk_ids, index_type)?;
+ let indices = match index_chunk {
+ None if total_ids == 0 => Vec::new(),
+ None => {
+ return Err(ResearchError::MissingRelationStream {
+ type_id: index_type,
+ required_ids: total_ids,
+ })
+ }
+ Some(chunk) => {
+ let index_bytes = counted_prefix(chunk.payload, total_ids, 4)?;
+ let mut cursor = Cursor::new(index_bytes);
+ let capacity = checked_allocation_len(total_ids, max_items)?;
+ let mut indices = Vec::with_capacity(capacity);
+ for _ in 0..total_ids {
+ indices.push(cursor.read_u32_le()?);
+ }
+ cursor.require_eof()?;
+ indices
+ }
+ };
+
+ Ok(ResearchRelations {
+ table_id: count_type,
+ group_starts,
+ indices,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn decoded_array_limits_fail_before_large_allocations() {
+ let limit = Limits::default().max_array_items;
+ let too_many = limit.checked_add(1).expect("test count");
+
+ assert!(matches!(
+ read_research_rows(&[], too_many),
+ Err(ResearchError::Binary(DecodeError::LimitExceeded { count, limit: actual_limit }))
+ if count == u64::from(too_many) && actual_limit == u64::from(limit)
+ ));
+ assert!(matches!(
+ read_aliases(&[], &[], too_many),
+ Err(ResearchError::Binary(DecodeError::LimitExceeded { count, limit: actual_limit }))
+ if count == u64::from(too_many) && actual_limit == u64::from(limit)
+ ));
+ }
+}