diff options
| author | Valentin Popov <valentin@popov.link> | 2026-10-31 03:28:05 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-10-31 03:28:05 +0300 |
| commit | 97c73118a1390c8a08cc34f4ce9435ba71df2af3 (patch) | |
| tree | fe1078c414e1f5e63a391b243eaa1cb65cb06a97 | |
| parent | dbb59b00d13d18a8c94b2bdacd6c14b4dc20df29 (diff) | |
| download | fparkan-97c73118a1390c8a08cc34f4ce9435ba71df2af3.tar.xz fparkan-97c73118a1390c8a08cc34f4ce9435ba71df2af3.zip | |
feat(assets): decode research tables and calculate candidate costs
| -rw-r--r-- | crates/fparkan-assets/src/lib.rs | 952 | ||||
| -rw-r--r-- | crates/fparkan-assets/src/research.rs | 884 | ||||
| -rw-r--r-- | docs/appendices/knowledge-boundaries.md | 45 | ||||
| -rw-r--r-- | docs/appendices/script-vm.md | 70 | ||||
| -rw-r--r-- | docs/tomes/06-behavior.md | 57 |
5 files changed, 1983 insertions, 25 deletions
diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs index 2ee829f..7673e2a 100644 --- a/crates/fparkan-assets/src/lib.rs +++ b/crates/fparkan-assets/src/lib.rs @@ -28,6 +28,8 @@ pub use fparkan_terrain_format::{BuildCategory, TerrainFormatError}; use fparkan_texm::{decode_mip_rgba8, decode_texm, TexmDocument, TexmError}; pub use fparkan_texm::{PageRect, RgbaImage}; use fparkan_vfs::DirectoryVfs; +mod research; +pub use research::{calc_full_research_cost, ResearchCost, ResearchError, ResearchTrfDocument}; use std::collections::{hash_map::Entry, HashMap}; use std::fmt; use std::hash::{Hash, Hasher}; @@ -4478,6 +4480,956 @@ mod tests { )); } + #[test] + fn research_trf_reader_preserves_rows_and_uses_low_u16_alias_link() { + let bytes = research_cycle_fixture(3, &[0; 5]); + let document = ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())) + .expect("synthetic TRF"); + + assert_eq!(document.row_count(), 5); + assert_eq!(document.find_key(b"a").expect("key lookup"), Some(0)); + let row = document.row_words(0).expect("first raw row"); + assert_eq!(row[0], 10.0_f32.to_bits()); + assert_eq!(row[1], 1.0_f32.to_bits()); + assert_eq!(row[8], 0x09FF_0000); + assert_eq!(document.row_words(2).expect("third row")[8], 0x09FF_0002); + assert_eq!(document.flag_byte(0), Some(0)); + assert_eq!(document.relation_2_3(0).expect("ordered links"), [3, 1]); + assert!(document + .relation_4_5(0) + .expect("empty optional stream") + .is_empty()); + } + + #[test] + fn research_trf_reader_rejects_the_native_trf0_attribute_guard() { + let bytes = research_cycle_fixture(4, &[0; 5]); + + assert!(ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())).is_err()); + } + + #[test] + fn research_cost_matches_native_cycle_dedup_nonzero_result() { + let bytes = research_cycle_fixture(3, &[0; 5]); + let research = ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())) + .expect("synthetic TRF"); + let candidate = research_unit_dat(&[b"A", b"B", b"C", b"B"], &[3, 0, 0, 0], &[1; 4]); + + assert_eq!( + calc_full_research_cost(&candidate, &research).expect("cost"), + ResearchCost { + ore: 15.0, + power: 150.0, + } + ); + } + + #[test] + fn research_trf_reader_preserves_first_alias_raw_link_and_unused_tails() { + let costs = [(1.0, 10.0), (2.0, 20.0)]; + let aliases = [(b"Key".as_slice(), 0), (b"kEy".as_slice(), 1)]; + let row_alias_links = [1, 0]; + let empty_groups = [&[][..], &[][..]]; + let bytes = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0x84, 0x05], + costs: &costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: &empty_groups, + relations_45: &empty_groups, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + let document = ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())) + .expect("synthetic TRF with unused payload tails"); + + assert_eq!(document.row_count(), 2); + assert_eq!(document.find_key(b"KEY").expect("alias lookup"), Some(0)); + assert_eq!(document.find_key(b"missing").expect("missing lookup"), None); + assert_eq!(document.row_name(0).expect("raw low-u16 link"), b"kEy"); + assert_eq!(document.row_words(0).expect("raw row")[8], 0x09FF_0001); + assert_eq!(document.flag_byte(0), Some(0x84)); + assert_eq!(document.relation_2_3(0).expect("empty group"), &[]); + assert_eq!(document.relation_4_5(0).expect("empty second group"), &[]); + + let trf0 = document + .archive() + .entries() + .iter() + .find(|entry| entry.meta().type_id == u32::from_le_bytes(*b"TRF0")) + .expect("retained TRF0 chunk"); + assert!(document + .archive() + .payload(trf0.id()) + .expect("retained payload") + .ends_with(RESEARCH_TEST_TAIL)); + } + + #[test] + fn research_trf_reader_rejects_required_chunk_and_attribute_errors() { + let cycle_costs = [ + (1.0, 10.0), + (2.0, 20.0), + (3.0, 30.0), + (4.0, 40.0), + (5.0, 50.0), + ]; + let names: [&[u8]; 5] = [b"A", b"B", b"C", b"D", b"E"]; + let aliases: Vec<_> = names + .iter() + .enumerate() + .map(|(index, name)| (*name, u16::try_from(index).expect("row id"))) + .collect(); + let row_alias_links = [0, 1, 2, 3, 4]; + let no_groups = [&[][..]; 5]; + + let invalid_attr = research_fixture(ResearchFixtureSpec { + trf0_attr2: 4, + flags: &[0; 5], + costs: &cycle_costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: &no_groups, + relations_45: &no_groups, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + assert!(matches!( + ResearchTrfDocument::decode(Arc::from(invalid_attr.into_boxed_slice())), + Err(ResearchError::InvalidTrf0Attribute { got: 4 }) + )); + + let missing_required = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0; 5], + costs: &cycle_costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: &no_groups, + relations_45: &no_groups, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[u32::from_le_bytes(*b"TRF8")], + }); + assert!(matches!( + ResearchTrfDocument::decode(Arc::from(missing_required.into_boxed_slice())), + Err(ResearchError::MissingChunk { type_id }) + if type_id == u32::from_le_bytes(*b"TRF8") + )); + } + + #[test] + fn research_trf_reader_zero_fills_short_flags_and_rejects_long_flags() { + let costs = [(1.0, 10.0), (2.0, 20.0)]; + let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)]; + let row_alias_links = [0, 1]; + let no_groups = [&[][..], &[][..]]; + let short = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0x84], + costs: &costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: &no_groups, + relations_45: &no_groups, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + let document = ResearchTrfDocument::decode(Arc::from(short.into_boxed_slice())) + .expect("short TRF1 is zero-filled by native loader"); + assert_eq!(document.flag_byte(0), Some(0x84)); + assert_eq!(document.flag_byte(1), Some(0)); + + let long = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0; 3], + costs: &costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: &no_groups, + relations_45: &no_groups, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + assert!(matches!( + ResearchTrfDocument::decode(Arc::from(long.into_boxed_slice())), + Err(ResearchError::FlagsTooLong { + row_count: 2, + actual: 3 + }) + )); + } + + #[test] + fn research_trf_reader_checks_alias_and_pair_bounds_and_non_ascii_policy() { + let costs = [(1.0, 10.0), (2.0, 20.0)]; + let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)]; + let row_alias_links = [0, 1]; + let no_groups = [&[][..], &[][..]]; + let make_bytes = || { + research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0; 2], + costs: &costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: &no_groups, + relations_45: &no_groups, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }) + }; + + let mut invalid_name_offset = make_bytes(); + research_set_payload_u16( + &mut invalid_name_offset, + u32::from_le_bytes(*b"TRFB"), + 0, + u16::MAX, + ); + assert!(matches!( + ResearchTrfDocument::decode(Arc::from(invalid_name_offset.into_boxed_slice())), + Err(ResearchError::InvalidNameOffset { + pair_index: 0, + offset: u16::MAX + }) + )); + + let mut unterminated = make_bytes(); + research_set_payload_byte(&mut unterminated, u32::from_le_bytes(*b"TRF6"), 1, b'X'); + research_set_payload_byte(&mut unterminated, u32::from_le_bytes(*b"TRF6"), 3, b'X'); + assert!(matches!( + ResearchTrfDocument::decode(Arc::from(unterminated.into_boxed_slice())), + Err(ResearchError::UnterminatedName { + pair_index: 0, + offset: 0 + }) + )); + + let mut invalid_record_pair = make_bytes(); + research_set_payload_u32( + &mut invalid_record_pair, + u32::from_le_bytes(*b"TRF0"), + 32, + 0x09FF_0002, + ); + assert!(matches!( + ResearchTrfDocument::decode(Arc::from(invalid_record_pair.into_boxed_slice())), + Err(ResearchError::InvalidRecordPair { + record_index: 0, + pair_index: 2, + pair_count: 2 + }) + )); + + let mut invalid_alias_row = make_bytes(); + research_set_payload_u16(&mut invalid_alias_row, u32::from_le_bytes(*b"TRFB"), 2, 7); + let document = ResearchTrfDocument::decode(Arc::from(invalid_alias_row.into_boxed_slice())) + .expect("alias row is checked when selected"); + assert!(matches!( + document.find_key(b"A"), + Err(ResearchError::InvalidResearchIndex { + type_id, + index: 7, + row_count: 2 + }) if type_id == u32::from_le_bytes(*b"TRFB") + )); + + let non_ascii_aliases = [(b"\xC4".as_slice(), 0), (b"A".as_slice(), 1)]; + let non_ascii = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0; 2], + costs: &costs, + aliases: &non_ascii_aliases, + row_alias_links: &row_alias_links, + relations_23: &no_groups, + relations_45: &no_groups, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + let document = ResearchTrfDocument::decode(Arc::from(non_ascii.into_boxed_slice())) + .expect("non-ASCII names are preserved"); + assert!(matches!( + document.find_key(b"A"), + Err(ResearchError::UnsupportedNonAsciiLookup { .. }) + )); + assert!(matches!( + document.find_key(b"\xE4"), + Err(ResearchError::UnsupportedNonAsciiLookup { .. }) + )); + } + + #[test] + fn research_trf_reader_checks_optional_streams_group_bounds_and_relation_ids() { + let costs = [(1.0, 10.0), (2.0, 20.0)]; + let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)]; + let row_alias_links = [0, 1]; + let no_groups = [&[][..], &[][..]]; + let missing_zero_streams = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0; 2], + costs: &costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: &no_groups, + relations_45: &no_groups, + include_trf3: false, + include_trf5: false, + omitted_chunks: &[], + }); + let document = + ResearchTrfDocument::decode(Arc::from(missing_zero_streams.into_boxed_slice())) + .expect("missing optional streams with zero IDs are empty"); + assert_eq!( + document.relation_2_3(0).expect("empty optional stream"), + &[] + ); + assert_eq!( + document.relation_4_5(0).expect("empty optional stream"), + &[] + ); + + let positive_group = [&[1_u32][..], &[][..]]; + let missing_trf3 = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0; 2], + costs: &costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: &positive_group, + relations_45: &no_groups, + include_trf3: false, + include_trf5: true, + omitted_chunks: &[], + }); + assert!(matches!( + ResearchTrfDocument::decode(Arc::from(missing_trf3.into_boxed_slice())), + Err(ResearchError::MissingRelationStream { type_id, required_ids: 1 }) + if type_id == u32::from_le_bytes(*b"TRF3") + )); + + let out_of_range_23 = [&[7_u32][..], &[][..]]; + let out_of_range_45 = [&[][..], &[9_u32][..]]; + let invalid_ids = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0; 2], + costs: &costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: &out_of_range_23, + relations_45: &out_of_range_45, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + let document = ResearchTrfDocument::decode(Arc::from(invalid_ids.into_boxed_slice())) + .expect("indices are checked when a group is accessed"); + assert!(matches!( + document.relation_2_3(0), + Err(ResearchError::InvalidResearchIndex { type_id, index: 7, row_count: 2 }) + if type_id == u32::from_le_bytes(*b"TRF3") + )); + assert!(matches!( + document.relation_4_5(1), + Err(ResearchError::InvalidResearchIndex { type_id, index: 9, row_count: 2 }) + if type_id == u32::from_le_bytes(*b"TRF5") + )); + assert!(matches!( + document.relation_2_3(usize::MAX), + Err(ResearchError::MissingRelationGroup { type_id, .. }) + if type_id == u32::from_le_bytes(*b"TRF2") + )); + + let one_group = [&[][..]]; + let short_table = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0; 2], + costs: &costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: &one_group, + relations_45: &one_group, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + let document = ResearchTrfDocument::decode(Arc::from(short_table.into_boxed_slice())) + .expect("relation tables have their own counts"); + assert!(matches!( + document.relation_2_3(1), + Err(ResearchError::MissingRelationGroup { type_id, index: 1, group_count: 1 }) + if type_id == u32::from_le_bytes(*b"TRF2") + )); + assert!(matches!( + document.relation_4_5(1), + Err(ResearchError::MissingRelationGroup { type_id, index: 1, group_count: 1 }) + if type_id == u32::from_le_bytes(*b"TRF4") + )); + } + + #[test] + fn research_trf_reader_checks_count_prefixes_without_large_allocations() { + let costs = [(1.0, 10.0), (2.0, 20.0)]; + let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)]; + let row_alias_links = [0, 1]; + let no_groups = [&[][..], &[][..]]; + let mut bytes = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0; 2], + costs: &costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: &no_groups, + relations_45: &no_groups, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + research_set_nres_attr1(&mut bytes, u32::from_le_bytes(*b"TRF2"), 3); + assert!(matches!( + ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())), + Err(ResearchError::Binary( + fparkan_binary::DecodeError::UnexpectedEof { .. } + )) + )); + } + + #[test] + fn research_cost_preserves_native_tree_order_and_f32_rounding() { + let costs = [(16_777_216.0, 20.0), (-16_777_216.0, 30.0), (1.0, 10.0)]; + let aliases_forward = [ + (b"A".as_slice(), 2), + (b"B".as_slice(), 0), + (b"C".as_slice(), 1), + ]; + let aliases_reversed = [ + (b"A".as_slice(), 0), + (b"B".as_slice(), 1), + (b"C".as_slice(), 2), + ]; + let row_alias_links = [0, 1, 2]; + let no_groups = [&[][..], &[][..], &[][..]]; + let make_research = |aliases: &[(&[u8], u16)]| { + let bytes = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0x05; 3], + costs: &costs, + aliases, + row_alias_links: &row_alias_links, + relations_23: &no_groups, + relations_45: &no_groups, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())).expect("f32 order TRF") + }; + let candidate = research_unit_dat(&[b"A", b"B", b"C", b"B"], &[3, 0, 0, 0], &[1; 4]); + + let forward = calc_full_research_cost(&candidate, &make_research(&aliases_forward)) + .expect("forward order cost"); + assert_eq!(forward.ore.to_bits(), 0.0_f32.to_bits()); + assert_eq!(forward.power.to_bits(), 60.0_f32.to_bits()); + + let reversed = calc_full_research_cost(&candidate, &make_research(&aliases_reversed)) + .expect("reversed order cost"); + assert_eq!(reversed.ore.to_bits(), 1.0_f32.to_bits()); + assert_eq!(reversed.power.to_bits(), 60.0_f32.to_bits()); + } + + #[test] + fn research_cost_obeys_subtree_and_record_gates() { + let costs = [ + (1.0, 10.0), + (2.0, 20.0), + (3.0, 30.0), + (4.0, 40.0), + (5.0, 50.0), + ]; + let names: [&[u8]; 5] = [b"A", b"B", b"C", b"D", b"E"]; + let aliases: Vec<_> = names + .iter() + .enumerate() + .map(|(index, name)| (*name, u16::try_from(index).expect("row id"))) + .collect(); + let row_alias_links = [0, 1, 2, 3, 4]; + let relations_45 = [&[][..]; 5]; + let candidate = research_unit_dat(&[b"A", b"B", b"C", b"B"], &[3, 0, 0, 0], &[1; 4]); + + let recursion_gate = research_cycle_fixture(3, &[0x05, 0x05, 0x05, 0x04, 0x04]); + let research = ResearchTrfDocument::decode(Arc::from(recursion_gate.into_boxed_slice())) + .expect("recursion-gate TRF"); + assert_eq!( + calc_full_research_cost(&candidate, &research).expect("cost"), + ResearchCost { + ore: 6.0, + power: 60.0 + } + ); + + let include_gate = research_cycle_fixture(3, &[0x04, 0x05, 0x05, 0x06, 0x04]); + let research = ResearchTrfDocument::decode(Arc::from(include_gate.into_boxed_slice())) + .expect("include-gate TRF"); + assert_eq!( + calc_full_research_cost(&candidate, &research).expect("cost"), + ResearchCost { + ore: 6.0, + power: 60.0 + } + ); + + let no_groups = [&[][..]; 5]; + let isolated = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0x04; 5], + costs: &costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: &no_groups, + relations_45: &relations_45, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + let research = ResearchTrfDocument::decode(Arc::from(isolated.into_boxed_slice())) + .expect("isolated TRF"); + let child_pruned = research_unit_dat(&[b"A", b"B", b"C"], &[1, 1, 0], &[1, 0, 1]); + assert_eq!( + calc_full_research_cost(&child_pruned, &research).expect("pruned child cost"), + ResearchCost { + ore: 1.0, + power: 10.0 + } + ); + let root_pruned = research_unit_dat(&[b"A", b"B"], &[1, 0], &[0, 1]); + assert_eq!( + calc_full_research_cost(&root_pruned, &research).expect("pruned root cost"), + ResearchCost { + ore: 0.0, + power: 0.0 + } + ); + } + + #[test] + fn research_cost_rejects_unknown_keys_unterminated_keys_and_missing_groups() { + let costs = [(1.0, 10.0), (2.0, 20.0)]; + let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)]; + let row_alias_links = [0, 1]; + let no_groups = [&[][..], &[][..]]; + let make_document = |groups: &[&[u32]]| { + let bytes = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0x04; 2], + costs: &costs, + aliases: &aliases, + row_alias_links: &row_alias_links, + relations_23: groups, + relations_45: &no_groups, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())) + .expect("research table") + }; + + let no_group_document = make_document(&[]); + let candidate = research_unit_dat(&[b"A"], &[0], &[1]); + assert!(matches!( + calc_full_research_cost(&candidate, &no_group_document), + Err(ResearchError::MissingRelationGroup { + type_id, + index: 0, + group_count: 0 + }) if type_id == u32::from_le_bytes(*b"TRF2") + )); + + let valid_groups = [&[][..], &[][..]]; + let document = make_document(&valid_groups); + let unknown = research_unit_dat(&[b"Z"], &[0], &[1]); + assert!(matches!( + calc_full_research_cost(&unknown, &document), + Err(ResearchError::UnknownResearchKey { + component_index: 0, + .. + }) + )); + let unterminated = research_unit_dat(&[&[b'A'; 32]], &[0], &[1]); + assert!(matches!( + calc_full_research_cost(&unterminated, &document), + Err(ResearchError::MissingCandidateKeyTerminator { component_index: 0 }) + )); + } + + #[test] + #[ignore = "requires licensed Part 1 corpus"] + fn licensed_scream_trf_and_12hel1_dat_exercise_research_cost() { + let root = fixture_root("IS"); + let trf = + std::fs::read(root.join("MISSIONS/SCRIPTS/scream.trf")).expect("licensed scream.trf"); + let research = ResearchTrfDocument::decode(Arc::from(trf.into_boxed_slice())) + .expect("decode licensed research table"); + assert_eq!(research.row_count(), 368); + assert_eq!(research.find_key(b"R_T_02").expect("R_T_02"), Some(205)); + assert_eq!(research.row_name(205).expect("row 205 name"), b"R_T_02"); + assert_eq!( + &research.row_words(205).expect("row 205 words")[..4], + &[0, 0, 0x40A0_0000, 0x4140_0000] + ); + assert_eq!(research.flag_byte(205), Some(0x07)); + assert!(research + .relation_2_3(205) + .expect("row 205 TRF2/TRF3 group") + .is_empty()); + assert_eq!( + research.relation_4_5(205).expect("row 205 second group"), + &[196] + ); + + let bytes = + std::fs::read(root.join("UNITS/UNITS/AI/12hel1.dat")).expect("licensed 12hel1.dat"); + let candidate = fparkan_prototype::decode_unit_dat(&bytes).expect("decode Unit DAT"); + calc_full_research_cost(&candidate, &research).expect("calculate candidate research cost"); + } + + #[test] + #[ignore = "requires licensed Part 1 corpus"] + fn licensed_part1_research_trf_files_all_decode() { + fn collect_trf_files(directory: &std::path::Path, files: &mut Vec<PathBuf>) { + for entry in std::fs::read_dir(directory).expect("read licensed directory") { + let entry = entry.expect("read directory entry"); + let path = entry.path(); + let kind = entry.file_type().expect("read entry type"); + if kind.is_dir() { + collect_trf_files(&path, files); + } else if !kind.is_symlink() + && path + .extension() + .map(|extension| extension.to_string_lossy().eq_ignore_ascii_case("trf")) + .unwrap_or(false) + { + files.push(path); + } + } + } + + let root = fixture_root("IS"); + let mut files = Vec::new(); + collect_trf_files(&root, &mut files); + files.sort(); + assert!(!files.is_empty(), "licensed corpus contains no .trf files"); + for path in files { + let bytes = std::fs::read(&path).expect("read TRF resource"); + ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())) + .unwrap_or_else(|error| panic!("{}: {error}", path.display())); + } + } + + const RESEARCH_TEST_TAIL: &[u8] = &[0xA5, 0x5A]; + + struct ResearchFixtureSpec<'a> { + trf0_attr2: u32, + flags: &'a [u8], + costs: &'a [(f32, f32)], + aliases: &'a [(&'a [u8], u16)], + row_alias_links: &'a [u16], + relations_23: &'a [&'a [u32]], + relations_45: &'a [&'a [u32]], + include_trf3: bool, + include_trf5: bool, + omitted_chunks: &'a [u32], + } + + fn research_fixture(spec: ResearchFixtureSpec<'_>) -> Vec<u8> { + assert_eq!(spec.costs.len(), spec.row_alias_links.len()); + + let mut rows = Vec::with_capacity(spec.costs.len()); + for ((ore, power), pair_index) in spec.costs.iter().zip(spec.row_alias_links) { + let mut row = [0_u32; 10]; + row[0] = power.to_bits(); + row[1] = ore.to_bits(); + row[8] = 0x09FF_0000 | u32::from(*pair_index); + rows.push(row); + } + let rows_payload = research_words_payload( + &rows + .iter() + .flat_map(|row| row.iter().copied()) + .collect::<Vec<_>>(), + ); + + let mut name_pool = Vec::new(); + let mut pairs = Vec::with_capacity(spec.aliases.len()); + for (name, row_index) in spec.aliases { + let name_offset = u16::try_from(name_pool.len()).expect("test pool offset"); + name_pool.extend_from_slice(name); + name_pool.push(0); + pairs.push((name_offset, *row_index)); + } + let pairs_payload = research_pairs_payload(&pairs); + let (counts_23, ids_23) = research_relation_payload(spec.relations_23); + let (counts_45, ids_45) = research_relation_payload(spec.relations_45); + let with_tail = |mut payload: Vec<u8>| { + payload.extend_from_slice(RESEARCH_TEST_TAIL); + payload + }; + + let mut payloads = vec![ + ( + u32::from_le_bytes(*b"TRF0"), + with_tail(rows_payload), + spec.trf0_attr2, + ), + (u32::from_le_bytes(*b"TRF1"), spec.flags.to_vec(), 0), + (u32::from_le_bytes(*b"TRFB"), with_tail(pairs_payload), 0), + (u32::from_le_bytes(*b"TRF6"), name_pool, 0), + (u32::from_le_bytes(*b"TRF7"), Vec::new(), 0), + (u32::from_le_bytes(*b"TRF8"), Vec::new(), 0), + (u32::from_le_bytes(*b"TRF9"), Vec::new(), 0), + (u32::from_le_bytes(*b"TRFA"), Vec::new(), 0), + (u32::from_le_bytes(*b"TRF2"), with_tail(counts_23), 0), + (u32::from_le_bytes(*b"TRF4"), with_tail(counts_45), 0), + ]; + if spec.include_trf3 { + payloads.push((u32::from_le_bytes(*b"TRF3"), with_tail(ids_23), 0)); + } + if spec.include_trf5 { + payloads.push((u32::from_le_bytes(*b"TRF5"), with_tail(ids_45), 0)); + } + + let entries: Vec<_> = payloads + .iter() + .filter(|(type_id, _, _)| !spec.omitted_chunks.contains(type_id)) + .map(|(type_id, payload, attr2)| TestNresEntry { + name: b"ResTree", + payload, + type_id: *type_id, + attr2: *attr2, + }) + .collect(); + let mut archive = build_nres_with_meta(&entries); + for (type_id, count) in [ + (u32::from_le_bytes(*b"TRF0"), spec.costs.len()), + (u32::from_le_bytes(*b"TRF1"), spec.flags.len()), + (u32::from_le_bytes(*b"TRFB"), spec.aliases.len()), + (u32::from_le_bytes(*b"TRF2"), spec.relations_23.len()), + (u32::from_le_bytes(*b"TRF4"), spec.relations_45.len()), + ] { + if !spec.omitted_chunks.contains(&type_id) { + research_set_nres_attr1( + &mut archive, + type_id, + u32::try_from(count).expect("test count"), + ); + } + } + if spec.include_trf3 && !spec.omitted_chunks.contains(&u32::from_le_bytes(*b"TRF3")) { + let count: usize = spec.relations_23.iter().map(|group| group.len()).sum(); + research_set_nres_attr1( + &mut archive, + u32::from_le_bytes(*b"TRF3"), + u32::try_from(count).expect("test dependency count"), + ); + } + if spec.include_trf5 && !spec.omitted_chunks.contains(&u32::from_le_bytes(*b"TRF5")) { + let count: usize = spec.relations_45.iter().map(|group| group.len()).sum(); + research_set_nres_attr1( + &mut archive, + u32::from_le_bytes(*b"TRF5"), + u32::try_from(count).expect("test dependency count"), + ); + } + archive + } + + fn research_relation_payload(groups: &[&[u32]]) -> (Vec<u8>, Vec<u8>) { + let counts: Vec<_> = groups + .iter() + .map(|group| u32::try_from(group.len()).expect("test dependency group count")) + .collect(); + let ids: Vec<_> = groups + .iter() + .flat_map(|group| group.iter().copied()) + .collect(); + ( + research_words_payload(&counts), + research_words_payload(&ids), + ) + } + + fn research_set_payload_bytes(bytes: &mut [u8], type_id: u32, offset: usize, value: &[u8]) { + let entry_count = u32::from_le_bytes(bytes[8..12].try_into().expect("entry count")); + let total_size = u32::from_le_bytes(bytes[12..16].try_into().expect("total size")); + let directory_start = usize::try_from(total_size - entry_count * 64).expect("directory"); + for entry in bytes[directory_start..].chunks_exact(64) { + if u32::from_le_bytes(entry[..4].try_into().expect("type id")) != type_id { + continue; + } + let payload_start = usize::try_from(u32::from_le_bytes( + entry[56..60].try_into().expect("payload offset"), + )) + .expect("payload offset"); + let payload_len = usize::try_from(u32::from_le_bytes( + entry[12..16].try_into().expect("payload size"), + )) + .expect("payload size"); + let end = offset.checked_add(value.len()).expect("payload end"); + assert!(end <= payload_len, "test mutation stays within payload"); + bytes[payload_start + offset..payload_start + end].copy_from_slice(value); + return; + } + panic!("NRes entry type {type_id:#010x} was not built"); + } + + fn research_set_payload_byte(bytes: &mut [u8], type_id: u32, offset: usize, value: u8) { + research_set_payload_bytes(bytes, type_id, offset, &[value]); + } + + fn research_set_payload_u16(bytes: &mut [u8], type_id: u32, offset: usize, value: u16) { + research_set_payload_bytes(bytes, type_id, offset, &value.to_le_bytes()); + } + + fn research_set_payload_u32(bytes: &mut [u8], type_id: u32, offset: usize, value: u32) { + research_set_payload_bytes(bytes, type_id, offset, &value.to_le_bytes()); + } + + fn research_cycle_fixture(trf0_attr2: u32, flags: &[u8]) -> Vec<u8> { + let costs = [ + (1.0_f32, 10.0_f32), + (2.0_f32, 20.0_f32), + (3.0_f32, 30.0_f32), + (4.0_f32, 40.0_f32), + (5.0_f32, 50.0_f32), + ]; + let mut pool = Vec::new(); + let mut pairs = Vec::new(); + let mut rows = Vec::new(); + for (index, ((ore, power), name)) in costs + .iter() + .zip([b"A".as_slice(), b"B", b"C", b"D", b"E"]) + .enumerate() + { + let offset = u16::try_from(pool.len()).expect("pool offset"); + pool.extend_from_slice(name); + pool.push(0); + pairs.push((offset, u16::try_from(index).expect("row index"))); + rows.push([ + power.to_bits(), + ore.to_bits(), + 0, + 0, + 0, + 0, + 0, + 0, + 0x09FF_0000 | u32::try_from(index).expect("pair index"), + 0, + ]); + } + + let rows_payload = research_words_payload( + &rows + .iter() + .flat_map(|row| row.iter().copied()) + .collect::<Vec<_>>(), + ); + let pairs_payload = research_pairs_payload(&pairs); + let count_23 = [2_u32, 0, 0, 1, 1]; + let ids_23 = [3_u32, 1, 4, 0]; + let count_45 = [0_u32; 5]; + let count_23_payload = research_words_payload(&count_23); + let ids_23_payload = research_words_payload(&ids_23); + let count_45_payload = research_words_payload(&count_45); + let payloads: [(u32, &[u8], u32); 11] = [ + ( + u32::from_le_bytes(*b"TRF0"), + rows_payload.as_slice(), + trf0_attr2, + ), + (u32::from_le_bytes(*b"TRF1"), flags, 0), + (u32::from_le_bytes(*b"TRFB"), pairs_payload.as_slice(), 0), + (u32::from_le_bytes(*b"TRF6"), pool.as_slice(), 0), + (u32::from_le_bytes(*b"TRF7"), &[][..], 0), + (u32::from_le_bytes(*b"TRF8"), &[][..], 0), + (u32::from_le_bytes(*b"TRF9"), &[][..], 0), + (u32::from_le_bytes(*b"TRFA"), &[][..], 0), + (u32::from_le_bytes(*b"TRF2"), &count_23_payload, 0), + (u32::from_le_bytes(*b"TRF3"), &ids_23_payload, 0), + (u32::from_le_bytes(*b"TRF4"), &count_45_payload, 0), + ]; + let entries: Vec<_> = payloads + .iter() + .map(|(type_id, payload, attr2)| TestNresEntry { + name: b"ResTree", + payload, + type_id: *type_id, + attr2: *attr2, + }) + .collect(); + let mut archive = build_nres_with_meta(&entries); + for (type_id, count) in [ + (u32::from_le_bytes(*b"TRF0"), 5), + (u32::from_le_bytes(*b"TRF1"), 5), + (u32::from_le_bytes(*b"TRFB"), 5), + (u32::from_le_bytes(*b"TRF2"), 5), + (u32::from_le_bytes(*b"TRF3"), 3), + (u32::from_le_bytes(*b"TRF4"), 5), + ] { + research_set_nres_attr1(&mut archive, type_id, count); + } + archive + } + + fn research_words_payload(words: &[u32]) -> Vec<u8> { + words.iter().flat_map(|word| word.to_le_bytes()).collect() + } + + fn research_pairs_payload(pairs: &[(u16, u16)]) -> Vec<u8> { + pairs + .iter() + .flat_map(|(offset, index)| offset.to_le_bytes().into_iter().chain(index.to_le_bytes())) + .collect() + } + + fn research_set_nres_attr1(bytes: &mut [u8], type_id: u32, count: u32) { + let entry_count = u32::from_le_bytes(bytes[8..12].try_into().expect("entry count")); + let total_size = u32::from_le_bytes(bytes[12..16].try_into().expect("total size")); + let directory_start = usize::try_from(total_size - entry_count * 64).expect("directory"); + for entry in bytes[directory_start..].chunks_exact_mut(64) { + if u32::from_le_bytes(entry[..4].try_into().expect("type id")) == type_id { + entry[4..8].copy_from_slice(&count.to_le_bytes()); + return; + } + } + panic!("NRes entry type {type_id:#010x} was not built"); + } + + fn research_unit_dat( + keys: &[&[u8]], + child_counts: &[u32], + kinds: &[u32], + ) -> fparkan_prototype::UnitDat { + assert_eq!(keys.len(), child_counts.len()); + assert_eq!(keys.len(), kinds.len()); + let mut bytes = vec![0; 8 + keys.len() * 112]; + bytes[..4].copy_from_slice(&0x0000_F0F1_u32.to_le_bytes()); + for (index, key) in keys.iter().enumerate() { + let offset = 8 + index * 112; + bytes[offset + 32..offset + 32 + key.len()].copy_from_slice(key); + bytes[offset + 64..offset + 68].copy_from_slice(&kinds[index].to_le_bytes()); + bytes[offset + 108..offset + 112].copy_from_slice(&child_counts[index].to_le_bytes()); + } + fparkan_prototype::decode_unit_dat(&bytes).expect("candidate Unit DAT") + } + fn resource_device_record(tag: u32, key: u32, base_bits: u32) -> PreparedControlTypedRecord { let mut fixed_words = [0; 44]; fixed_words[1] = key; diff --git a/crates/fparkan-assets/src/research.rs b/crates/fparkan-assets/src/research.rs new file mode 100644 index 0000000..7b2b9fc --- /dev/null +++ b/crates/fparkan-assets/src/research.rs @@ -0,0 +1,884 @@ +use crate::{decode_nres_payload, NresDocument}; +use fparkan_binary::{checked_allocation_len, checked_count_bytes, Cursor, DecodeError, Limits}; +use fparkan_nres::{EntryId, NresEntry}; +use fparkan_prototype::{unit_component_tree, PrototypeError, UnitComponentTreeNode, UnitDat}; +use std::collections::HashMap; +use std::fmt; +use std::sync::Arc; + +const TRF0: u32 = u32::from_le_bytes(*b"TRF0"); +const TRF1: u32 = u32::from_le_bytes(*b"TRF1"); +const TRF2: u32 = u32::from_le_bytes(*b"TRF2"); +const TRF3: u32 = u32::from_le_bytes(*b"TRF3"); +const TRF4: u32 = u32::from_le_bytes(*b"TRF4"); +const TRF5: u32 = u32::from_le_bytes(*b"TRF5"); +const TRF6: u32 = u32::from_le_bytes(*b"TRF6"); +const TRF7: u32 = u32::from_le_bytes(*b"TRF7"); +const TRF8: u32 = u32::from_le_bytes(*b"TRF8"); +const TRF9: u32 = u32::from_le_bytes(*b"TRF9"); +const TRFA: u32 = u32::from_le_bytes(*b"TRFA"); +const TRFB: u32 = u32::from_le_bytes(*b"TRFB"); + +const RESEARCH_CHUNKS: [u32; 12] = [ + TRF0, TRF1, TRF2, TRF3, TRF4, TRF5, TRF6, TRF7, TRF8, TRF9, TRFA, TRFB, +]; + +/// Ore and power outputs from one native CalcFullResearchCost call. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ResearchCost { + /// First native output, labeled ore by the Calc debug string. + pub ore: f32, + /// Second native output, labeled power by the Calc debug string. + pub power: f32, +} + +/// One decoded MisLoad research table and its original NRes archive. +#[derive(Debug)] +pub struct ResearchTrfDocument { + archive: NresDocument, + records: Vec<[u32; 10]>, + flags: Vec<u8>, + aliases: Vec<ResearchAlias>, + name_pool: Vec<u8>, + relation_2_3: ResearchRelations, + relation_4_5: ResearchRelations, +} + +#[derive(Clone, Debug)] +struct ResearchAlias { + name_range: std::ops::Range<usize>, + row_index: u16, +} + +#[derive(Debug)] +struct ResearchRelations { + table_id: u32, + group_starts: Vec<usize>, + indices: Vec<u32>, +} + +#[derive(Debug)] +struct Chunk<'a> { + entry: &'a NresEntry, + payload: &'a [u8], +} + +/// TRF decoding and cost-calculation error. +#[derive(Debug)] +pub enum ResearchError { + /// The outer NRes archive is malformed. + Nres(fparkan_nres::NresError), + /// A bounded TRF field is truncated or overflows. + Binary(DecodeError), + /// A native-required TRF chunk is absent. + MissingChunk { type_id: u32 }, + /// A recognized TRF chunk occurs more than once. + DuplicateChunk { type_id: u32 }, + /// The native loader requires TRF0 attribute 2 to equal three. + InvalidTrf0Attribute { got: u32 }, + /// A TRF1 payload is too large for the native zero-initialized flag buffer. + FlagsTooLong { row_count: usize, actual: usize }, + /// A TRFB pair points outside the TRF6 name pool. + InvalidNameOffset { pair_index: usize, offset: u16 }, + /// A TRF6 string does not end in NUL before the pool ends. + UnterminatedName { pair_index: usize, offset: u16 }, + /// A TRF0 row's low 16-bit pair link is outside TRFB. + InvalidRecordPair { + record_index: usize, + pair_index: u16, + pair_count: usize, + }, + /// A key or relation resolves to a research row outside TRF0. + InvalidResearchIndex { + type_id: u32, + index: u32, + row_count: usize, + }, + /// A relation table has no group at this research row. + MissingRelationGroup { + type_id: u32, + index: usize, + group_count: usize, + }, + /// A required non-empty relation stream is absent. + MissingRelationStream { type_id: u32, required_ids: u64 }, + /// A candidate Unit DAT resource field is not NUL-terminated. + MissingCandidateKeyTerminator { component_index: usize }, + /// No TRFB key matches an enabled candidate component. + UnknownResearchKey { + component_index: usize, + key: Vec<u8>, + }, + /// A non-ASCII lookup would require an unverified locale-specific fold. + UnsupportedNonAsciiLookup { + query: Vec<u8>, + alias: Option<Vec<u8>>, + }, + /// The research table has no rows. + EmptyResearchTable, + /// The candidate Unit DAT has no root component. + EmptyCandidateTree, + /// The existing Unit DAT tree builder rejected the child-count framing. + CandidateTree(PrototypeError), +} + +impl fmt::Display for ResearchError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Nres(source) => write!(formatter, "{source}"), + Self::Binary(source) => write!(formatter, "{source}"), + Self::MissingChunk { type_id } => { + write!(formatter, "research NRes is missing chunk {type_id:#010x}") + } + Self::DuplicateChunk { type_id } => { + write!(formatter, "research NRes has duplicate chunk {type_id:#010x}") + } + Self::InvalidTrf0Attribute { got } => { + write!(formatter, "TRF0 attribute 2 is {got}, expected 3") + } + Self::FlagsTooLong { row_count, actual } => write!( + formatter, + "TRF1 has {actual} flags, exceeding the {row_count}-byte native buffer" + ), + Self::InvalidNameOffset { pair_index, offset } => write!( + formatter, + "TRFB pair {pair_index} name offset {offset} is outside TRF6" + ), + Self::UnterminatedName { pair_index, offset } => write!( + formatter, + "TRFB pair {pair_index} name at TRF6 offset {offset} is not NUL-terminated" + ), + Self::InvalidRecordPair { + record_index, + pair_index, + pair_count, + } => write!( + formatter, + "TRF0 row {record_index} links to TRFB pair {pair_index}, but there are {pair_count} pairs" + ), + Self::InvalidResearchIndex { + type_id, + index, + row_count, + } => write!( + formatter, + "research index {index} from {type_id:#010x} exceeds TRF0 row count {row_count}" + ), + Self::MissingRelationGroup { + type_id, + index, + group_count, + } => write!( + formatter, + "{type_id:#010x} has no relation group for row {index} (group count {group_count})" + ), + Self::MissingRelationStream { + type_id, + required_ids, + } => write!( + formatter, + "{type_id:#010x} is absent but its count table requires {required_ids} indices" + ), + Self::MissingCandidateKeyTerminator { component_index } => write!( + formatter, + "candidate component {component_index} has no NUL in its 32-byte resource key" + ), + Self::UnknownResearchKey { + component_index, + key, + } => write!( + formatter, + "candidate component {component_index} key {:?} is absent from TRFB", + String::from_utf8_lossy(key) + ), + Self::UnsupportedNonAsciiLookup { query, alias } => { + if let Some(alias) = alias { + write!( + formatter, + "research key comparison between {:?} and {:?} needs unverified non-ASCII case folding", + String::from_utf8_lossy(query), + String::from_utf8_lossy(alias) + ) + } else { + write!( + formatter, + "research key {:?} needs unverified non-ASCII case folding", + String::from_utf8_lossy(query) + ) + } + } + Self::EmptyResearchTable => write!(formatter, "research table has no rows"), + Self::EmptyCandidateTree => write!(formatter, "candidate Unit DAT has no root component"), + Self::CandidateTree(source) => write!(formatter, "{source}"), + } + } +} + +impl std::error::Error for ResearchError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Nres(source) => Some(source), + Self::Binary(source) => Some(source), + Self::CandidateTree(source) => Some(source), + Self::MissingChunk { .. } + | Self::DuplicateChunk { .. } + | Self::InvalidTrf0Attribute { .. } + | Self::FlagsTooLong { .. } + | Self::InvalidNameOffset { .. } + | Self::UnterminatedName { .. } + | Self::InvalidRecordPair { .. } + | Self::InvalidResearchIndex { .. } + | Self::MissingRelationGroup { .. } + | Self::MissingRelationStream { .. } + | Self::MissingCandidateKeyTerminator { .. } + | Self::UnknownResearchKey { .. } + | Self::UnsupportedNonAsciiLookup { .. } + | Self::EmptyResearchTable + | Self::EmptyCandidateTree => None, + } + } +} + +impl From<fparkan_nres::NresError> for ResearchError { + fn from(value: fparkan_nres::NresError) -> Self { + Self::Nres(value) + } +} + +impl From<DecodeError> for ResearchError { + fn from(value: DecodeError) -> Self { + Self::Binary(value) + } +} + +impl From<PrototypeError> for ResearchError { + fn from(value: PrototypeError) -> Self { + Self::CandidateTree(value) + } +} + +impl ResearchTrfDocument { + /// Decodes the native research table chunks from an NRes archive. + /// + /// NRes validates the archive framing and payload ranges. This reader then + /// validates only the TRF bytes needed by its row, key, and relation views. + /// + /// # Errors + /// + /// Returns an error for malformed NRes framing, a missing required chunk, + /// an invalid TRF0 guard, or a referenced byte range outside its chunk. + pub fn decode(bytes: Arc<[u8]>) -> Result<Self, ResearchError> { + let archive = decode_nres_payload(bytes)?; + let mut chunk_ids = HashMap::new(); + for entry in archive.entries() { + let type_id = entry.meta().type_id; + if RESEARCH_CHUNKS.contains(&type_id) && chunk_ids.insert(type_id, entry.id()).is_some() + { + return Err(ResearchError::DuplicateChunk { type_id }); + } + } + + let trf0 = required_chunk(&archive, &chunk_ids, TRF0)?; + if trf0.entry.meta().attr2 != 3 { + return Err(ResearchError::InvalidTrf0Attribute { + got: trf0.entry.meta().attr2, + }); + } + let row_count = trf0.entry.meta().attr1; + let row_bytes = counted_prefix(trf0.payload, u64::from(row_count), 40)?; + let records = read_research_rows(row_bytes, row_count)?; + + let trf1 = required_chunk(&archive, &chunk_ids, TRF1)?; + let flag_count = usize::try_from(row_count).map_err(|_| DecodeError::IntegerOverflow)?; + if trf1.payload.len() > flag_count { + return Err(ResearchError::FlagsTooLong { + row_count: flag_count, + actual: trf1.payload.len(), + }); + } + let mut flags = vec![0; flag_count]; + flags[..trf1.payload.len()].copy_from_slice(trf1.payload); + + let trfb = required_chunk(&archive, &chunk_ids, TRFB)?; + let alias_count = trfb.entry.meta().attr1; + let pair_bytes = counted_prefix(trfb.payload, u64::from(alias_count), 4)?; + let trf6 = required_chunk(&archive, &chunk_ids, TRF6)?; + let name_pool = trf6.payload.to_vec(); + let aliases = read_aliases(pair_bytes, &name_pool, alias_count)?; + + for (record_index, record) in records.iter().enumerate() { + // The native reader casts the packed word to u16 before indexing TRFB. + let pair_index = record[8] as u16; + if usize::from(pair_index) >= aliases.len() { + return Err(ResearchError::InvalidRecordPair { + record_index, + pair_index, + pair_count: aliases.len(), + }); + } + } + + for type_id in [TRF7, TRF8, TRF9, TRFA] { + required_chunk(&archive, &chunk_ids, type_id)?; + } + + let relation_2_3 = read_relations(&archive, &chunk_ids, TRF2, TRF3)?; + let relation_4_5 = read_relations(&archive, &chunk_ids, TRF4, TRF5)?; + + Ok(Self { + archive, + records, + flags, + aliases, + name_pool, + relation_2_3, + relation_4_5, + }) + } + + /// Returns the retained NRes document, including opaque chunks and unused payload tails. + #[must_use] + pub fn archive(&self) -> &NresDocument { + &self.archive + } + + /// Number of TRF0 rows, taken from that chunk's own attr1. + #[must_use] + pub fn row_count(&self) -> usize { + self.records.len() + } + + /// Looks up the first ASCII-case-insensitive TRFB alias in source order. + /// + /// The shipped research keys are ASCII and use ASCII case-insensitive matching. + /// Comparisons involving non-ASCII bytes fail explicitly because native + /// locale-specific folding for those bytes is unknown. + /// + /// # Errors + /// + /// Returns an error if matching requires unverified non-ASCII folding or + /// the selected alias contains an out-of-range row id. + pub fn find_key(&self, key: &[u8]) -> Result<Option<usize>, ResearchError> { + let key = key.split(|byte| *byte == 0).next().unwrap_or_default(); + if !key.is_ascii() { + return Err(ResearchError::UnsupportedNonAsciiLookup { + query: key.to_vec(), + alias: None, + }); + } + for alias in &self.aliases { + let name = &self.name_pool[alias.name_range.clone()]; + if !name.is_ascii() { + return Err(ResearchError::UnsupportedNonAsciiLookup { + query: key.to_vec(), + alias: Some(name.to_vec()), + }); + } + if name.eq_ignore_ascii_case(key) { + let row_index = u32::from(alias.row_index); + if usize::from(alias.row_index) >= self.records.len() { + return Err(ResearchError::InvalidResearchIndex { + type_id: TRFB, + index: row_index, + row_count: self.records.len(), + }); + } + return Ok(Some(usize::from(alias.row_index))); + } + } + Ok(None) + } + + /// Returns the preserved ten raw TRF0 words for one row. + #[must_use] + pub fn row_words(&self, index: usize) -> Option<&[u32; 10]> { + self.records.get(index) + } + + /// Returns the original TRF1 byte for one row. + #[must_use] + pub fn flag_byte(&self, index: usize) -> Option<u8> { + self.flags.get(index).copied() + } + + /// Returns the raw name bytes selected by a row's low-16-bit TRFB link. + /// + /// # Errors + /// + /// Returns an error when the row is outside TRF0. + pub fn row_name(&self, index: usize) -> Result<&[u8], ResearchError> { + let row = self + .records + .get(index) + .ok_or(ResearchError::InvalidResearchIndex { + type_id: TRF0, + index: u32::try_from(index).unwrap_or(u32::MAX), + row_count: self.records.len(), + })?; + let pair_index = usize::from(row[8] as u16); + let alias = self + .aliases + .get(pair_index) + .ok_or(ResearchError::InvalidRecordPair { + record_index: index, + pair_index: row[8] as u16, + pair_count: self.aliases.len(), + })?; + Ok(&self.name_pool[alias.name_range.clone()]) + } + + /// Returns the ordered TRF2/TRF3 indices used by CalcFullResearchCost. + /// + /// # Errors + /// + /// Returns an error if this row has no TRF2 group or its TRF3 stream + /// contains a row index outside TRF0. + pub fn relation_2_3(&self, index: usize) -> Result<&[u32], ResearchError> { + let related = self.relation_2_3.get(index)?; + validate_relation_indices(related, TRF3, self.records.len())?; + Ok(related) + } + + /// Returns the separate ordered TRF4/TRF5 index group. + /// + /// # Errors + /// + /// Returns an error if this row has no TRF4 group or its TRF5 stream + /// contains a row index outside TRF0. + pub fn relation_4_5(&self, index: usize) -> Result<&[u32], ResearchError> { + let related = self.relation_4_5.get(index)?; + validate_relation_indices(related, TRF5, self.records.len())?; + Ok(related) + } + + fn gates(&self, index: usize) -> Result<(u32, u32, u32), ResearchError> { + let flag = self + .flags + .get(index) + .copied() + .ok_or(ResearchError::InvalidResearchIndex { + type_id: TRF1, + index: u32::try_from(index).unwrap_or(u32::MAX), + row_count: self.records.len(), + })?; + Ok(( + u32::from((flag >> 1) & 1), + u32::from(flag & 1), + u32::from((flag >> 2) & 1), + )) + } +} + +/// Calculates the ordered ore and power cost for one candidate Unit DAT tree. +/// +/// # Errors +/// +/// Returns an error for an empty research table, malformed candidate tree, +/// missing or unsupported key comparison, invalid relation group, or invalid +/// row reference. +pub fn calc_full_research_cost( + candidate: &UnitDat, + research: &ResearchTrfDocument, +) -> Result<ResearchCost, ResearchError> { + if research.records.is_empty() { + return Err(ResearchError::EmptyResearchTable); + } + let tree = unit_component_tree(&candidate.records)?; + if tree.is_empty() { + return Err(ResearchError::EmptyCandidateTree); + } + + let mut visited = vec![false; research.records.len()]; + let direct = collect_direct_indices(candidate, &tree, research, &mut visited)?; + let mut closure = Vec::new(); + for &index in &direct { + closure.push(index); + let (_, recurse, _) = research.gates(index)?; + if recurse == 0 { + append_relation_closure(index, research, &mut visited, &mut closure)?; + } + } + + let mut ore = 0.0_f32; + let mut power = 0.0_f32; + for index in closure { + let (included, _, _) = research.gates(index)?; + if included == 0 { + let row = research + .records + .get(index) + .ok_or(ResearchError::InvalidResearchIndex { + type_id: TRF0, + index: u32::try_from(index).unwrap_or(u32::MAX), + row_count: research.records.len(), + })?; + ore = ore + f32::from_bits(row[1]); + power = power + f32::from_bits(row[0]); + } + } + + Ok(ResearchCost { ore, power }) +} + +fn collect_direct_indices( + candidate: &UnitDat, + tree: &[UnitComponentTreeNode], + research: &ResearchTrfDocument, + visited: &mut [bool], +) -> Result<Vec<usize>, ResearchError> { + let mut direct = Vec::new(); + let mut stack = vec![0_usize]; + let mut first_error = None; + + while let Some(node_index) = stack.pop() { + let Some(node) = tree.get(node_index) else { + return Err(ResearchError::CandidateTree( + PrototypeError::InvalidUnitHierarchy(format!( + "component index {node_index} is outside the reconstructed tree" + )), + )); + }; + let Some(component) = candidate.records.get(node.record_index) else { + return Err(ResearchError::CandidateTree( + PrototypeError::InvalidUnitHierarchy(format!( + "tree references missing component {}", + node.record_index + )), + )); + }; + + if component.kind == 0 { + continue; + } + + let key_end = component.resource_raw.iter().position(|byte| *byte == 0); + let key = match key_end { + Some(end) => &component.resource_raw[..end], + None => { + if first_error.is_none() { + first_error = Some(ResearchError::MissingCandidateKeyTerminator { + component_index: node.record_index, + }); + } + for child in node.children.iter().rev() { + stack.push(*child); + } + continue; + } + }; + + match research.find_key(key) { + Ok(Some(index)) => match research.gates(index) { + Ok((included, _, _)) => { + if included == 0 && !visited[index] { + visited[index] = true; + direct.push(index); + } + } + Err(error) => { + if first_error.is_none() { + first_error = Some(error); + } + } + }, + Ok(None) => { + if first_error.is_none() { + first_error = Some(ResearchError::UnknownResearchKey { + component_index: node.record_index, + key: key.to_vec(), + }); + } + } + Err(error) => { + if first_error.is_none() { + first_error = Some(error); + } + } + } + + for child in node.children.iter().rev() { + stack.push(*child); + } + } + + if let Some(error) = first_error { + return Err(error); + } + Ok(direct) +} + +#[derive(Clone, Copy)] +struct RelationFrame { + row_index: usize, + next_child: usize, +} + +fn append_relation_closure( + root: usize, + research: &ResearchTrfDocument, + visited: &mut [bool], + closure: &mut Vec<usize>, +) -> Result<(), ResearchError> { + let mut stack = vec![RelationFrame { + row_index: root, + next_child: 0, + }]; + + while let Some(frame) = stack.last().copied() { + let related = research.relation_2_3.get(frame.row_index)?; + if frame.next_child >= related.len() { + stack.pop(); + continue; + } + let related_index = related[frame.next_child]; + stack + .last_mut() + .expect("non-empty relation stack") + .next_child += 1; + + let index = usize::try_from(related_index).map_err(|_| DecodeError::IntegerOverflow)?; + if index >= research.records.len() { + return Err(ResearchError::InvalidResearchIndex { + type_id: TRF3, + index: related_index, + row_count: research.records.len(), + }); + } + let (included, recurse, _) = research.gates(index)?; + if included != 0 || visited[index] { + continue; + } + + visited[index] = true; + closure.push(index); + if recurse == 0 { + stack.push(RelationFrame { + row_index: index, + next_child: 0, + }); + } + } + + Ok(()) +} + +impl ResearchRelations { + fn get(&self, index: usize) -> Result<&[u32], ResearchError> { + let group_count = self.group_starts.len().saturating_sub(1); + let missing_group = || ResearchError::MissingRelationGroup { + type_id: self.table_id, + index, + group_count, + }; + let Some(end_index) = index.checked_add(1) else { + return Err(missing_group()); + }; + let Some(&start) = self.group_starts.get(index) else { + return Err(missing_group()); + }; + let Some(&end) = self.group_starts.get(end_index) else { + return Err(missing_group()); + }; + Ok(&self.indices[start..end]) + } +} + +fn validate_relation_indices( + related: &[u32], + type_id: u32, + row_count: usize, +) -> Result<(), ResearchError> { + for &index in related { + if usize::try_from(index).map_or(true, |value| value >= row_count) { + return Err(ResearchError::InvalidResearchIndex { + type_id, + index, + row_count, + }); + } + } + Ok(()) +} + +fn required_chunk<'a>( + archive: &'a NresDocument, + chunk_ids: &HashMap<u32, EntryId>, + type_id: u32, +) -> Result<Chunk<'a>, ResearchError> { + let id = chunk_ids + .get(&type_id) + .copied() + .ok_or(ResearchError::MissingChunk { type_id })?; + let entry = archive + .entry(id) + .ok_or(fparkan_nres::NresError::EntryIdOutOfRange { + id: id.0, + entry_count: archive.header().entry_count, + })?; + let payload = archive.payload(id)?; + Ok(Chunk { entry, payload }) +} + +fn optional_chunk<'a>( + archive: &'a NresDocument, + chunk_ids: &HashMap<u32, EntryId>, + type_id: u32, +) -> Result<Option<Chunk<'a>>, ResearchError> { + let Some(id) = chunk_ids.get(&type_id).copied() else { + return Ok(None); + }; + let entry = archive + .entry(id) + .ok_or(fparkan_nres::NresError::EntryIdOutOfRange { + id: id.0, + entry_count: archive.header().entry_count, + })?; + let payload = archive.payload(id)?; + Ok(Some(Chunk { entry, payload })) +} + +fn counted_prefix(payload: &[u8], count: u64, stride: u64) -> Result<&[u8], ResearchError> { + let remaining = u64::try_from(payload.len()).map_err(|_| DecodeError::IntegerOverflow)?; + let size = checked_count_bytes(count, stride, remaining)?; + Ok(&payload[..size]) +} + +fn read_research_rows(bytes: &[u8], row_count: u32) -> Result<Vec<[u32; 10]>, ResearchError> { + let capacity = checked_allocation_len( + u64::from(row_count), + u64::from(Limits::default().max_array_items), + )?; + let mut cursor = Cursor::new(bytes); + let mut records = Vec::with_capacity(capacity); + for _ in 0..row_count { + let mut row = [0_u32; 10]; + for word in &mut row { + *word = cursor.read_u32_le()?; + } + records.push(row); + } + cursor.require_eof()?; + Ok(records) +} + +fn read_aliases( + bytes: &[u8], + name_pool: &[u8], + alias_count: u32, +) -> Result<Vec<ResearchAlias>, ResearchError> { + let capacity = checked_allocation_len( + u64::from(alias_count), + u64::from(Limits::default().max_array_items), + )?; + let mut cursor = Cursor::new(bytes); + let mut aliases = Vec::with_capacity(capacity); + for pair_index in 0..capacity { + let name_offset = cursor.read_u16_le()?; + let row_index = cursor.read_u16_le()?; + let offset = usize::from(name_offset); + let suffix = name_pool + .get(offset..) + .ok_or(ResearchError::InvalidNameOffset { + pair_index, + offset: name_offset, + })?; + let length = + suffix + .iter() + .position(|byte| *byte == 0) + .ok_or(ResearchError::UnterminatedName { + pair_index, + offset: name_offset, + })?; + aliases.push(ResearchAlias { + name_range: offset..offset + length, + row_index, + }); + } + cursor.require_eof()?; + Ok(aliases) +} + +fn read_relations( + archive: &NresDocument, + chunk_ids: &HashMap<u32, EntryId>, + count_type: u32, + index_type: u32, +) -> Result<ResearchRelations, ResearchError> { + let count_chunk = required_chunk(archive, chunk_ids, count_type)?; + let count_count = count_chunk.entry.meta().attr1; + let count_bytes = counted_prefix(count_chunk.payload, u64::from(count_count), 4)?; + let mut cursor = Cursor::new(count_bytes); + let max_items = u64::from(Limits::default().max_array_items); + let group_count = u64::from(count_count); + let offset_count = group_count + .checked_add(1) + .ok_or(DecodeError::IntegerOverflow)?; + let offset_capacity = checked_allocation_len( + offset_count, + max_items + .checked_add(1) + .ok_or(DecodeError::IntegerOverflow)?, + )?; + let mut group_starts = Vec::with_capacity(offset_capacity); + group_starts.push(0_usize); + let mut total_ids = 0_u64; + for _ in 0..count_count { + let count = cursor.read_u32_le()?; + total_ids = total_ids + .checked_add(u64::from(count)) + .ok_or(DecodeError::IntegerOverflow)?; + let next = checked_allocation_len(total_ids, max_items)?; + group_starts.push(next); + } + + let index_chunk = optional_chunk(archive, chunk_ids, index_type)?; + let indices = match index_chunk { + None if total_ids == 0 => Vec::new(), + None => { + return Err(ResearchError::MissingRelationStream { + type_id: index_type, + required_ids: total_ids, + }) + } + Some(chunk) => { + let index_bytes = counted_prefix(chunk.payload, total_ids, 4)?; + let mut cursor = Cursor::new(index_bytes); + let capacity = checked_allocation_len(total_ids, max_items)?; + let mut indices = Vec::with_capacity(capacity); + for _ in 0..total_ids { + indices.push(cursor.read_u32_le()?); + } + cursor.require_eof()?; + indices + } + }; + + Ok(ResearchRelations { + table_id: count_type, + group_starts, + indices, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decoded_array_limits_fail_before_large_allocations() { + let limit = Limits::default().max_array_items; + let too_many = limit.checked_add(1).expect("test count"); + + assert!(matches!( + read_research_rows(&[], too_many), + Err(ResearchError::Binary(DecodeError::LimitExceeded { count, limit: actual_limit })) + if count == u64::from(too_many) && actual_limit == u64::from(limit) + )); + assert!(matches!( + read_aliases(&[], &[], too_many), + Err(ResearchError::Binary(DecodeError::LimitExceeded { count, limit: actual_limit })) + if count == u64::from(too_many) && actual_limit == u64::from(limit) + )); + } +} diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index a64b68e..bbf0e4b 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -177,7 +177,11 @@ token и типизированную `float`/`DWORD` ячейку. Неизве всей цепочки кампании. Обычная `Single.01` доходит до следующего blocker-а: `Handler(43)` в clan 1, `Init` instruction 10. Rust не перескакивает его; 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`. В `iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) одна ветвь сначала проходит записи @@ -752,15 +756,36 @@ command/event captures для меню, HUD, briefing и диалогов. ## Research, economy and properties -Экспорты `LoadResearch`, `CalcFullResearchCost`, TRF/preload resources и TMA -properties доказывают отдельный слой исследований, стоимости, добычи и -производственных параметров. Формулы стоимости, dependency graph технологий, -inventory/economy transitions и точная типизация всех 16-byte property values -не закрыты. - -Закрывающий эксперимент: сопоставить research functions с ресурсами и UI, -снять изменения state на контролируемых покупках/исследованиях и построить -typed schema свойств по consumers, а не по одному имени. +Для GOG `ArealMap.dll` SHA-256 +`a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea` +`CalcFullResearchCost` по RVA `0x16280` имеет подтверждённый ABI +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, поэтому он не измеряет +реальную миссионную стоимость. + +`fparkan-assets` декодирует используемые NRes chunks, сохраняя сырые TRF0 words +и непрозрачные данные. Native cost outputs сопоставлены только с двумя raw +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 diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md index 49af347..be8c8b3 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -34,6 +34,60 @@ VM state; `.trf` — NRes tables, чей framing подтверждён, а field semantics местами лишь 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, @@ -285,9 +339,12 @@ TMA properties остаются four raw `u32` words плюс имя, пока c сохраняются `NOT USED`; corpus подтверждает `Invulnerability`, life state, `ClanID`, ore, speed и free-time properties. -Research/economy работают в simulation: `LoadResearch` и -`CalcFullResearchCost` доказывают данные и вычислимую стоимость, но не полный -layout prerequisites/modifiers/unlocks. General formula evaluator требует +Для одного кандидата и одной research-таблицы порядок prerequisite-closure, +дедупликация и две выходные стоимости `CalcFullResearchCost` восстановлены и +проверены отдельно, как описано в +[исследовательском профиле TRF](#исследовательский-профиль-trf-и-стоимость-кандидата). +Это не закрывает production-modifier/unlock data, экономические переходы или +полное исполнение Handler(43). Общий evaluator формул всё ещё требует versioned grammar, typed operands, deterministic numeric policy, bounded stack и явных errors; x87-compatible rounding нужен там, где оно выбирает ветку. Описанный ниже literal-путь первого `Init` не закрывает общий evaluator. @@ -448,8 +505,11 @@ Rust сохраняет всю таблицу строк, но вычисляеÑ полную загрузку кампании Mission.01 после этих присваиваний; у обычной `Single.01` clan 1 после формул остаётся `Handler(43)` в instruction 10, который не поддержан и не обходится. Preview оставляет значения этого клана без -частичных записей, а полный load возвращает ошибку транзакционно. Это не -подтверждение запуска самой игры или полной VM. +частичных записей, а полный load возвращает ошибку транзакционно. В +`fparkan-assets` теперь отдельно доступны bounded TRF reader и pure cost core +для одного Unit DAT, но script/runtime пока не создают нужный research context, +не перечисляют AI candidates и не выполняют этот handler. Это не подтверждение +запуска самой игры или полной VM. ### Первый вызов Behavior и сценарного `Init` diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md index 9350d94..b4bddb7 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -485,16 +485,53 @@ Tag выбирает конкретное правило. Для tag 1 запиÑ ### TRF и preload-данные -TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial -variants имеют сигнатуру [NRes](../reference/nres.md) и содержат большие -таблицы имён игровых прототипов: оружия, башен, сооружений и других объектов. -Также найдены preload-записи, ANI и SKE resources. - -По содержимому, порядку загрузки и consumers TRF с высокой вероятностью -предоставляет AI/сценарному слою заранее подготовленную таблицу типов и -связанных данных. Framing и имена подтверждены corpus-ом, но полная семантика -каждой TRF-записи ещё не закрыта. Имена должны разрешаться через тот же -resource registry, что и миссионные объекты. +TRF-архивы используют несколько потребителей; один подтверждённый путь ведёт +из `Handler(43)` к вычислению стоимости исследования. В проверенной сборке GOG +`CalcFullResearchCost` из `ArealMap.dll` получает таблицу, загруженную через +`MisLoad.dll`. Для этого пути `TRF0` содержит строки по десять сырых `u32`, +`TRF1` — байты флагов, `TRFB` — пары «смещение имени и номер строки», а +`TRF6` — пул строк с нулевыми окончаниями. Слово 8 строки связывает её с +`TRFB` через младшие 16 бит; старшая половина сохраняется как неизвестное +значение. `TRF2` и `TRF3` образуют упорядоченные списки предварительных +исследований, которые читает этот путь стоимости. `TRF4`/`TRF5` образуют +отдельную пару таблиц, но `CalcFullResearchCost` её не использует. Непрозрачные +`TRF7`–`TRFA` остаются в архиве и требуются загрузчику. + +Модуль `fparkan-assets` разбирает этот профиль в `ResearchTrfDocument` и +вычисляет пару результатов для одного Unit DAT: + +```rust +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-слоя |
