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}; use fparkan_texm::{decode_mip_rgba8, decode_texm, TexmDocument, TexmError};
pub use fparkan_texm::{PageRect, RgbaImage}; pub use fparkan_texm::{PageRect, RgbaImage};
use fparkan_vfs::DirectoryVfs; 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::collections::{hash_map::Entry, HashMap};
use std::fmt; use std::fmt;
use std::hash::{Hash, Hasher}; 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 { fn resource_device_record(tag: u32, key: u32, base_bits: u32) -> PreparedControlTypedRecord {
let mut fixed_words = [0; 44]; let mut fixed_words = [0; 44];
fixed_words[1] = key; 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)
));
}
}
+34 -9
View File
@@ -177,7 +177,11 @@ token и типизированную `float`/`DWORD` ячейку. Неизве
всей цепочки кампании. Обычная `Single.01` доходит до следующего blocker-а: всей цепочки кампании. Обычная `Single.01` доходит до следующего blocker-а:
`Handler(43)` в clan 1, `Init` instruction 10. Rust не перескакивает его; `Handler(43)` в clan 1, `Init` instruction 10. Rust не перескакивает его;
preview оставляет varset этого клана на defaults, а полный load завершает ошибкой preview оставляет varset этого клана на defaults, а полный load завершает ошибкой
с rollback. Это ещё не загрузка/запуск игры и не общий dispatcher. с rollback. Для самого расчёта уже есть отдельный bounded reader research-таблицы
и pure ore/power core в `fparkan-assets`. Они принимают один Unit DAT и не
исполняют script instruction: runtime по-прежнему не создаёт владельца живого
контекста, не перечисляет AI candidates и не выбирает результат Handler(43).
Это ещё не загрузка/запуск игры и не общий dispatcher.
Для GOG-сборки также найден статический host call path в `Init`. В Для GOG-сборки также найден статический host call path в `Init`. В
`iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) одна ветвь сначала проходит записи `iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) одна ветвь сначала проходит записи
@@ -752,15 +756,36 @@ command/event captures для меню, HUD, briefing и диалогов.
## Research, economy and properties ## Research, economy and properties
Экспорты `LoadResearch`, `CalcFullResearchCost`, TRF/preload resources и TMA Для GOG `ArealMap.dll` SHA-256
properties доказывают отдельный слой исследований, стоимости, добычи и `a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea`
производственных параметров. Формулы стоимости, dependency graph технологий, `CalcFullResearchCost` по RVA `0x16280` имеет подтверждённый ABI
inventory/economy transitions и точная типизация всех 16-byte property values stdcall `[context, candidate, out1, out2]`, `RET 0x10`, и восстановленный
не закрыты. алгоритм для одного candidate tree: zero-kind subtree pruning, direct preorder,
общая дедупликация, ordered DFS по `TRF2`/`TRF3`, raw gates из `TRF1` и
последовательное `f32` сложение. Принятый набор из 23 synthetic native cases
включал Calc и два AI predicate; для контролируемых сравнений x87 word был
установлен в `0x007f`. У набора synthetic context, поэтому он не измеряет
реальную миссионную стоимость.
Закрывающий эксперимент: сопоставить research functions с ресурсами и UI, `fparkan-assets` декодирует используемые NRes chunks, сохраняя сырые TRF0 words
снять изменения state на контролируемых покупках/исследованиях и построить и непрозрачные данные. Native cost outputs сопоставлены только с двумя raw
typed schema свойств по consumers, а не по одному имени. words: out1 складывает TRF0 word 1 и подписан как ore, out2 складывает word 0
и подписан как power. Смыслы остальных полей полного 32-byte callback record
не установлены. Non-ASCII case folding research keys также остаётся открытым;
Rust явно возвращает unsupported для таких сравнений.
Открыты host binding полного Handler(43), factory/ownership/lifetime реального
исследовательского context при запуске миссии, AI candidate enumeration и tie
selection, live x87 policy, inventory/economy transitions и точная типизация
всех 16-byte TMA property values. Контролируемый тест с настоящими
`scream.trf` и `12hel1.dat` проверяет разбор и API одного candidate, но не
сравнивает результат с native вызовом реального миссионного context.
Закрывающий эксперимент для оставшихся границ: связать startup owner, research
context и AI candidate selection в реальном `Init`; затем сравнить контекст,
порядок кандидатов и cost outputs оригинала с Rust на одной миссии. Покупки,
inventory transitions и typed property schema требуют отдельных контролируемых
исследований.
## Rare branches ## Rare branches
+65 -5
View File
@@ -34,6 +34,60 @@ VM state;
`.trf` — NRes tables, чей framing подтверждён, а field semantics местами лишь `.trf` — NRes tables, чей framing подтверждён, а field semantics местами лишь
consumer-inferred. consumer-inferred.
### Исследовательский профиль TRF и стоимость кандидата
Для одной ветви `Handler(43)` уже восстановлен путь вычисления стоимости.
Результат привязан к GOG `ArealMap.dll`, SHA-256
`a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea`,
`CalcFullResearchCost` по RVA `0x16280`. Реальный ABI — четыре аргумента
stdcall на стеке: `[context, candidate, out1, out2]`; функция снимает с него
`0x10` байт и не принимает `candidate` через `ECX`.
Источник этих таблиц привязан к GOG `MisLoad.dll`, SHA-256
`c607762bb67db14ca530927c8e1347339b62b0a163ac90c708ee83142292e2bf`.
`FUN_10002fe0` (RVA `0x2fe0`) загружает required TRF chunks. Внутренний
research context разрешает ключ через `FUN_10002a40` (RVA `0x2a40`), выдаёт
32-byte row record через `FUN_10002aa0` (RVA `0x2aa0`) и возвращает ordered
dependency array через `FUN_10002cf0` (RVA `0x2cf0`). Эти методы соответствуют
виртуальным слотам context `+0x08`, `+0x0c` и `+0x24`; native Calc получает их
через таблицу вызовов, а не напрямую из файлов.
`ResearchTrfDocument` в `fparkan-assets` повторно использует NRes reader и
сохраняет исходный архив. `TRF0.attr1` задаёт число строк по 40 байт; native
загрузчик требует `TRF0.attr2 == 3`. `TRF1` задаёт один флаговый byte на строку:
Rust воспроизводит native zero-fill при коротком payload и отклоняет более
длинный. `TRFB.attr1` задаёт пары little-endian `(u16 name_offset, u16 row)`;
имя берётся из NUL-terminated pool `TRF6`. Поиск идёт по исходному порядку и
берёт первую ASCII-case-insensitive пару. Связь строки с парой использует
только младшие 16 бит сырого слова `TRF0` по смещению `+0x20`.
Две таблицы зависимостей имеют собственные размеры: `TRF2` считает группы,
`TRF3` содержит их плоский упорядоченный поток индексов; `TRF4`/`TRF5` образуют
отдельную пару. Только `TRF2`/`TRF3` участвуют в `CalcFullResearchCost`.
Отсутствующий `TRF3` или `TRF5` означает пустой поток лишь при нулевой сумме
счётчиков соответствующей таблицы. Required `TRF7`–`TRFA` остаются
непрозрачными. Reader проверяет фактически читаемые срезы, строки, ссылки и
размеры выделений для памяти Rust; это дополнительная безопасность, а не
утверждение, что native loader отвергает те же плохие файлы.
Из флагового byte `TRF1` native provider строит слова record `+0`, `+4` и `+8`
из битов `(1, 0, 2)`. `CalcFullResearchCost` использует первые два как raw gate,
не присваивая им общего игрового имени: `+0` определяет, добавлять ли строку,
`+4` — раскрывать ли её зависимости. Нулевой `kind` компонента Unit DAT
отсекает весь узел с потомками. Иначе дерево сканируется в preorder; найденные
строки добавляются один раз, затем для каждой прямой строки выполняется
упорядоченный DFS по `TRF2`/`TRF3`. Общая таблица посещений завершает циклы и
удаляет повторы.
По debug-строкам `out1` — ore и суммирует `TRF0` word 1, а `out2` — power и
суммирует word 0. Native код хранит каждый шаг суммы как `f32`; один cost-вектор
`1, 2^24, -2^24` даёт первый результат `0`, а в другом порядке
`2^24, -2^24, 1` даёт `1`. Rust core сохраняет тот же порядок накопления. В
принятом наборе из 23 синтетических вызовов оригинальных DLL были случаи
`CalcFullResearchCost` и два AI-предиката; x87 control word был установлен в
`0x007f` для контролируемых сравнений. Набор проверяет выбранные ветви на
синтетическом контексте, но не использует реальный контекст миссии.
## Безопасная модель исполнения ## Безопасная модель исполнения
Целевая VM должна разделять immutable package (bytecode, symbols, events, Целевая VM должна разделять immutable package (bytecode, symbols, events,
@@ -285,9 +339,12 @@ TMA properties остаются four raw `u32` words плюс имя, пока c
сохраняются `NOT USED`; corpus подтверждает `Invulnerability`, life state, сохраняются `NOT USED`; corpus подтверждает `Invulnerability`, life state,
`ClanID`, ore, speed и free-time properties. `ClanID`, ore, speed и free-time properties.
Research/economy работают в simulation: `LoadResearch` и Для одного кандидата и одной research-таблицы порядок prerequisite-closure,
`CalcFullResearchCost` доказывают данные и вычислимую стоимость, но не полный дедупликация и две выходные стоимости `CalcFullResearchCost` восстановлены и
layout prerequisites/modifiers/unlocks. General formula evaluator требует проверены отдельно, как описано в
[исследовательском профиле TRF](#исследовательский-профиль-trf-и-стоимость-кандидата).
Это не закрывает production-modifier/unlock data, экономические переходы или
полное исполнение Handler(43). Общий evaluator формул всё ещё требует
versioned grammar, typed operands, deterministic numeric policy, bounded stack versioned grammar, typed operands, deterministic numeric policy, bounded stack
и явных errors; x87-compatible rounding нужен там, где оно выбирает ветку. и явных errors; x87-compatible rounding нужен там, где оно выбирает ветку.
Описанный ниже literal-путь первого `Init` не закрывает общий evaluator. Описанный ниже literal-путь первого `Init` не закрывает общий evaluator.
@@ -448,8 +505,11 @@ Rust сохраняет всю таблицу строк, но вычисляе
полную загрузку кампании Mission.01 после этих присваиваний; у обычной полную загрузку кампании Mission.01 после этих присваиваний; у обычной
`Single.01` clan 1 после формул остаётся `Handler(43)` в instruction 10, который `Single.01` clan 1 после формул остаётся `Handler(43)` в instruction 10, который
не поддержан и не обходится. Preview оставляет значения этого клана без не поддержан и не обходится. Preview оставляет значения этого клана без
частичных записей, а полный load возвращает ошибку транзакционно. Это не частичных записей, а полный load возвращает ошибку транзакционно. В
подтверждение запуска самой игры или полной VM. `fparkan-assets` теперь отдельно доступны bounded TRF reader и pure cost core
для одного Unit DAT, но script/runtime пока не создают нужный research context,
не перечисляют AI candidates и не выполняют этот handler. Это не подтверждение
запуска самой игры или полной VM.
### Первый вызов Behavior и сценарного `Init` ### Первый вызов Behavior и сценарного `Init`
+46 -9
View File
@@ -485,16 +485,53 @@ Tag выбирает конкретное правило. Для tag 1 запи
### TRF и preload-данные ### TRF и preload-данные
TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial TRF-архивы используют несколько потребителей; один подтверждённый путь ведёт
variants имеют сигнатуру [NRes](../reference/nres.md) и содержат большие из `Handler(43)` к вычислению стоимости исследования. В проверенной сборке GOG
таблицы имён игровых прототипов: оружия, башен, сооружений и других объектов. `CalcFullResearchCost` из `ArealMap.dll` получает таблицу, загруженную через
Также найдены preload-записи, ANI и SKE resources. `MisLoad.dll`. Для этого пути `TRF0` содержит строки по десять сырых `u32`,
`TRF1` — байты флагов, `TRFB` — пары «смещение имени и номер строки», а
`TRF6` — пул строк с нулевыми окончаниями. Слово 8 строки связывает её с
`TRFB` через младшие 16 бит; старшая половина сохраняется как неизвестное
значение. `TRF2` и `TRF3` образуют упорядоченные списки предварительных
исследований, которые читает этот путь стоимости. `TRF4`/`TRF5` образуют
отдельную пару таблиц, но `CalcFullResearchCost` её не использует. Непрозрачные
`TRF7`–`TRFA` остаются в архиве и требуются загрузчику.
По содержимому, порядку загрузки и consumers TRF с высокой вероятностью Модуль `fparkan-assets` разбирает этот профиль в `ResearchTrfDocument` и
предоставляет AI/сценарному слою заранее подготовленную таблицу типов и вычисляет пару результатов для одного Unit DAT:
связанных данных. Framing и имена подтверждены corpus-ом, но полная семантика
каждой TRF-записи ещё не закрыта. Имена должны разрешаться через тот же ```rust
resource registry, что и миссионные объекты. let table = ResearchTrfDocument::decode(archive_bytes)?;
let cost = calc_full_research_cost(&candidate, &table)?;
```
Расчёт проходит компоненты Unit DAT в порядке дерева. Компонент с нулевым
`kind` завершает весь свой участок вместе с потомками. Для остальных ключ
ресурса ищется среди имён `TRFB`; повтор строки добавляется лишь один раз.
После прямых компонентов алгоритм обходит список `TRF2`/`TRF3` в его исходном
порядке, углубляясь сразу в найденные предварительные исследования. Общая
таблица посещений не даёт повторно добавлять узел и завершает циклы. Два бита
флагов `TRF1` управляют включением строки и обходом её зависимостей; остальные
сырые биты и слова не получают игровых имён без отдельного доказательства.
Результат первого native-выхода подписан в отладочной строке как ore и
суммирует слово 1 `TRF0`, второй подписан как power и суммирует слово 0. Каждое
сложение сохраняется как `f32` в порядке найденной последовательности. В
принятом наборе из 23 синтетических тестов оригинальных DLL проверялись
`CalcFullResearchCost` и два AI-предиката; именно вызовы `CalcFullResearchCost`
подтвердили порядок обхода, дедупликацию, циклы, флаги и отсечение дерева.
Две проверки суммы показали чувствительность округления: одна и та же сумма
`1, 2^24, -2^24` даёт `0`, а в другом порядке `2^24, -2^24, 1` даёт `1`.
Эта проверка использовала синтетический исследовательский контекст и заданное
для опыта x87-слово управления `0x007f`; она не запускала штатный `Handler(43)`.
Локальный Rust-core принимает таблицу и одного Unit DAT, но пока не выбирает
кандидата в AI-каталоге и не связывает таблицу с владельцем полного старта
миссии. Поэтому `Single.01` всё ещё останавливается на неподдержанном
`Handler(43)`. Разбор с настоящими `scream.trf` и `12hel1.dat` проверяет
совместимость ресурса с API, но сам по себе не сравнивает стоимость с
оригинальным живым контекстом. Проверки границ байтов защищают Rust от плохих
входных данных и не приписываются нативному загрузчику.
### Стабильность AI-слоя ### Стабильность AI-слоя