feat(assets): decode research tables and calculate candidate costs

This commit is contained in:
Valentin Popov committed 2026-10-31 04:28:05 +04:00
1 parent dbb59b00d1
commit 97c73118a1
5 files changed
+1981 -23

No files matched your search

+952
View File
@@ -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;
+884
View File
@@ -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)
));
}
}