diff options
| author | Valentin Popov <valentin@popov.link> | 2026-10-31 04:40:52 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-10-31 04:40:52 +0300 |
| commit | 11dccef6ac6238668bb7eb775614092392e34a14 (patch) | |
| tree | a0b08bf903487df29f653491d0979b74bcb135d6 | |
| parent | 4bcbf570558b027ae8065e2383f84ecf2d436715 (diff) | |
| download | fparkan-11dccef6ac6238668bb7eb775614092392e34a14.tar.xz fparkan-11dccef6ac6238668bb7eb775614092392e34a14.zip | |
feat(assets): restore ordered research candidate selection
| -rw-r--r-- | crates/fparkan-assets/src/lib.rs | 537 | ||||
| -rw-r--r-- | crates/fparkan-assets/src/research.rs | 269 | ||||
| -rw-r--r-- | docs/appendices/knowledge-boundaries.md | 49 | ||||
| -rw-r--r-- | docs/tomes/03-resources.md | 9 | ||||
| -rw-r--r-- | docs/tomes/06-behavior.md | 43 |
5 files changed, 880 insertions, 27 deletions
diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs index 7673e2a..6ee6e03 100644 --- a/crates/fparkan-assets/src/lib.rs +++ b/crates/fparkan-assets/src/lib.rs @@ -29,7 +29,10 @@ 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}; +pub use research::{ + calc_full_research_cost, ResearchCandidate, ResearchCandidateSet, ResearchCost, ResearchError, + ResearchTrfDocument, +}; use std::collections::{hash_map::Entry, HashMap}; use std::fmt; use std::hash::{Hash, Hasher}; @@ -4525,6 +4528,405 @@ mod tests { } #[test] + fn research_candidate_set_selects_later_lower_cost_and_updates_native_statuses() { + let costs = [(200.0, 0.0), (40.0, 0.0)]; + let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)]; + let empty_groups = [&[][..], &[][..]]; + let bytes = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags: &[0x05, 0x05], + costs: &costs, + aliases: &aliases, + row_alias_links: &[0, 1], + relations_23: &empty_groups, + relations_45: &empty_groups, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + let research = ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())) + .expect("synthetic research table"); + let first_path = parse_path("units/first.dat").expect("first candidate path"); + let second_path = parse_path("units/second.dat").expect("second candidate path"); + let mut vfs = MemoryVfs::default(); + vfs.insert( + first_path.clone(), + Arc::from(research_unit_dat_payload(&[b"A"], &[0], &[1]).into_boxed_slice()), + ); + vfs.insert( + second_path.clone(), + Arc::from(research_unit_dat_payload(&[b"B"], &[0], &[1]).into_boxed_slice()), + ); + let mut candidates = ResearchCandidateSet { + entries: vec![ + ResearchCandidate { + path: first_path, + status: 0, + }, + ResearchCandidate { + path: second_path, + status: 0, + }, + ], + selected_index: 0x1234_5678, + loaded: true, + status: 0, + }; + + candidates + .refresh_selection(&vfs, Some(&research)) + .expect("refresh research candidates"); + + assert_eq!( + candidates.selected_index, 1, + "later lower-cost candidate wins" + ); + assert_eq!( + candidates.status, 0, + "candidate scan status is cleared before reads" + ); + assert_eq!( + candidates + .entries + .iter() + .map(|entry| entry.status) + .collect::<Vec<_>>(), + [0, 0], + "both eligible candidates pass predicate two with false status" + ); + } + + #[test] + fn research_candidate_set_guards_and_empty_catalog_match_native_state() { + let research = research_selector_document(&[0x05], &[(10.0, 0.0)], &[(b"A", 0)], &[0]); + let default = ResearchCandidateSet::default(); + assert!(default.entries.is_empty()); + assert_eq!(default.selected_index, 0); + assert!(!default.loaded); + assert_eq!(default.status, 0); + let mut candidates = research_candidate_set(&[("missing.dat", 7)], 41, false, 9); + candidates + .refresh_selection(&MemoryVfs::default(), Some(&research)) + .expect("unloaded catalog is a no-op"); + assert_eq!(candidates.selected_index, 41); + assert_eq!(candidates.status, 9); + assert_eq!(candidates.entries[0].status, 7); + + candidates.loaded = true; + candidates + .refresh_selection(&MemoryVfs::default(), None) + .expect("missing context is a no-op"); + assert_eq!(candidates.selected_index, 41); + assert_eq!(candidates.status, 9); + assert_eq!(candidates.entries[0].status, 7); + + let mut empty = research_candidate_set(&[], 63, true, 0); + empty + .refresh_selection(&MemoryVfs::default(), Some(&research)) + .expect("loaded empty catalog refresh"); + assert_eq!(empty.selected_index, 63); + assert_eq!(empty.status, 1); + } + + #[test] + fn research_candidate_set_skips_nonzero_status_without_reading_missing_path() { + let research = research_selector_document(&[0x05], &[(10.0, 0.0)], &[(b"A", 0)], &[0]); + let mut candidates = research_candidate_set(&[("missing.dat", 1)], 52, true, 0); + + candidates + .refresh_selection(&MemoryVfs::default(), Some(&research)) + .expect("nonzero entry status skips VFS read"); + + assert_eq!(candidates.selected_index, 52); + assert_eq!(candidates.status, 1); + assert_eq!(candidates.entries[0].status, 1); + } + + #[test] + fn research_candidate_set_short_circuits_false_predicates_on_parsed_dat() { + let research = research_selector_document(&[0x03], &[(10.0, 0.0)], &[(b"A", 0)], &[0]); + let first_path = "units/predicate-false.dat"; + let second_path = "units/missing-key.dat"; + let mut vfs = MemoryVfs::default(); + insert_research_dat( + &mut vfs, + first_path, + research_unit_dat_payload(&[b"A"], &[0], &[1]), + ); + insert_research_dat( + &mut vfs, + second_path, + research_unit_dat_payload(&[b"Z"], &[0], &[1]), + ); + let mut candidates = + research_candidate_set(&[(first_path, 0), (second_path, 0)], 0x1234_5678, true, 0); + + candidates + .refresh_selection(&vfs, Some(&research)) + .expect("false native predicates skip without cost errors"); + + assert_eq!(candidates.selected_index, 0x1234_5678); + assert_eq!(candidates.status, 0); + assert_eq!( + candidates + .entries + .iter() + .map(|entry| entry.status) + .collect::<Vec<_>>(), + [0, 0] + ); + } + + #[test] + fn research_candidate_set_prunes_kind_zero_subtree_before_non_ascii_lookup() { + let research = research_selector_document(&[0x05], &[(10.0, 0.0)], &[(b"A", 0)], &[0]); + let path = "units/pruned-non-ascii-grandchild.dat"; + let non_ascii = [0xC0]; + let keys: [&[u8]; 3] = [b"A", b"", &non_ascii]; + let mut vfs = MemoryVfs::default(); + insert_research_dat( + &mut vfs, + path, + research_unit_dat_payload(&keys, &[1, 1, 0], &[1, 0, 1]), + ); + let mut candidates = research_candidate_set(&[(path, 0)], 79, true, 0); + + candidates + .refresh_selection(&vfs, Some(&research)) + .expect("kind-zero subtree is pruned before its non-ASCII key"); + + assert_eq!(candidates.selected_index, 0); + assert_eq!(candidates.status, 0); + assert_eq!(candidates.entries[0].status, 0); + } + + #[test] + fn research_candidate_set_keeps_second_predicate_true_status_and_selection() { + let research = research_selector_document(&[0x07], &[(10.0, 0.0)], &[(b"A", 0)], &[0]); + let path = "units/second-predicate-true.dat"; + let mut vfs = MemoryVfs::default(); + insert_research_dat( + &mut vfs, + path, + research_unit_dat_payload(&[b"A"], &[0], &[1]), + ); + let mut candidates = research_candidate_set(&[(path, 0)], 73, true, 0); + + candidates + .refresh_selection(&vfs, Some(&research)) + .expect("second predicate true is a candidate skip"); + + assert_eq!(candidates.selected_index, 73); + assert_eq!(candidates.status, 0); + assert_eq!(candidates.entries[0].status, 1); + } + + #[test] + fn research_candidate_set_skips_read_and_parse_failures_then_selects_later_candidate() { + let research = research_selector_document( + &[0x05, 0x05], + &[(200.0, 0.0), (40.0, 0.0)], + &[(b"A", 0), (b"B", 1)], + &[0, 1], + ); + let wrong_magic = "units/wrong-magic.dat"; + let excessive_children = "units/excessive-children.dat"; + let winner = "units/winner.dat"; + let mut vfs = MemoryVfs::default(); + let mut wrong_magic_bytes = research_unit_dat_payload(&[b"A"], &[0], &[1]); + wrong_magic_bytes[..4].copy_from_slice(&0x1234_5678_u32.to_le_bytes()); + insert_research_dat(&mut vfs, wrong_magic, wrong_magic_bytes); + let mut excessive_children_bytes = research_unit_dat_payload(&[b"A"], &[0], &[1]); + excessive_children_bytes[8 + 108..8 + 112].copy_from_slice(&u32::MAX.to_le_bytes()); + insert_research_dat(&mut vfs, excessive_children, excessive_children_bytes); + insert_research_dat( + &mut vfs, + winner, + research_unit_dat_payload(&[b"B"], &[0], &[1]), + ); + let mut candidates = research_candidate_set( + &[ + ("units/missing.dat", 0), + (wrong_magic, 0), + (excessive_children, 0), + (winner, 0), + ], + 91, + true, + 0, + ); + + candidates + .refresh_selection(&vfs, Some(&research)) + .expect("read and parse failures are skipped"); + + assert_eq!(candidates.selected_index, 3); + assert_eq!(candidates.status, 0); + assert_eq!( + candidates + .entries + .iter() + .map(|entry| entry.status) + .collect::<Vec<_>>(), + [0, 0, 0, 0] + ); + } + + #[test] + fn research_candidate_set_uses_strict_bound_and_keeps_first_tie() { + let tie_research = research_selector_document( + &[0x05, 0x05], + &[(40.0, 0.0), (40.0, 0.0)], + &[(b"A", 0), (b"B", 1)], + &[0, 1], + ); + let mut tie_vfs = MemoryVfs::default(); + insert_research_dat( + &mut tie_vfs, + "units/tie-a.dat", + research_unit_dat_payload(&[b"A"], &[0], &[1]), + ); + insert_research_dat( + &mut tie_vfs, + "units/tie-b.dat", + research_unit_dat_payload(&[b"B"], &[0], &[1]), + ); + let mut tied = research_candidate_set( + &[("units/tie-a.dat", 0), ("units/tie-b.dat", 0)], + 99, + true, + 0, + ); + tied.refresh_selection(&tie_vfs, Some(&tie_research)) + .expect("strict comparison keeps first tied candidate"); + assert_eq!(tied.selected_index, 0); + + for ore in [f32::from_bits(0x4b18_967f), f32::NAN] { + let research = research_selector_document(&[0x05], &[(ore, 0.0)], &[(b"A", 0)], &[0]); + let mut vfs = MemoryVfs::default(); + insert_research_dat( + &mut vfs, + "units/no-improvement.dat", + research_unit_dat_payload(&[b"A"], &[0], &[1]), + ); + let mut candidates = + research_candidate_set(&[("units/no-improvement.dat", 0)], 0xABCD, true, 0); + candidates + .refresh_selection(&vfs, Some(&research)) + .expect("equal bound and NaN cost do not select"); + assert_eq!(candidates.selected_index, 0xABCD); + assert_eq!(candidates.entries[0].status, 0); + } + } + + #[test] + fn research_candidate_set_ignores_tail_after_first_root_tree() { + let research = research_selector_document( + &[0x05, 0x05], + &[(200.0, 0.0), (40.0, 0.0)], + &[(b"A", 0), (b"B", 1)], + &[0, 1], + ); + let trailing = "units/trailing-three-bytes.dat"; + let orphan = "units/orphan-record.dat"; + let mut vfs = MemoryVfs::default(); + let mut trailing_bytes = research_unit_dat_payload(&[b"A"], &[0], &[1]); + trailing_bytes[4..8].copy_from_slice(&0xA5A5_5A5A_u32.to_le_bytes()); + trailing_bytes.extend_from_slice(&[0xAA, 0xBB, 0xCC]); + let (decoded, tree) = research::decode_candidate_first_tree(&trailing_bytes) + .expect("first complete root with trailing bytes"); + assert_eq!( + &decoded.header_opaque[4..8], + &0xA5A5_5A5A_u32.to_le_bytes(), + "second header DWORD remains opaque" + ); + assert_eq!(decoded.records.len(), 1); + assert_eq!(tree.len(), 1); + insert_research_dat(&mut vfs, trailing, trailing_bytes); + + let mut orphan_bytes = research_unit_dat_payload(&[b"B"], &[0], &[1]); + let orphan_record = research_unit_dat_payload(&[b"Z"], &[0], &[1]); + orphan_bytes.extend_from_slice(&orphan_record[8..]); + insert_research_dat(&mut vfs, orphan, orphan_bytes); + let mut candidates = research_candidate_set(&[(trailing, 0), (orphan, 0)], 99, true, 0); + + candidates + .refresh_selection(&vfs, Some(&research)) + .expect("suffix records are outside the first root tree"); + + assert_eq!(candidates.selected_index, 1); + assert_eq!(candidates.status, 0); + } + + #[test] + fn research_candidate_set_preserves_incremental_state_on_non_ascii_error() { + let non_ascii = [0xC0]; + let research = research_selector_document( + &[0x05, 0x05], + &[(10.0, 0.0), (20.0, 0.0)], + &[(b"A", 0), (&non_ascii, 1)], + &[0, 1], + ); + let mut vfs = MemoryVfs::default(); + insert_research_dat( + &mut vfs, + "units/research-a.dat", + research_unit_dat_payload(&[b"A"], &[0], &[1]), + ); + insert_research_dat( + &mut vfs, + "units/research-non-ascii.dat", + research_unit_dat_payload(&[&non_ascii], &[0], &[1]), + ); + let mut candidates = research_candidate_set( + &[ + ("units/research-a.dat", 0), + ("units/research-non-ascii.dat", 0), + ], + 0xAABB_CCDD, + true, + 1, + ); + + assert!(matches!( + candidates.refresh_selection(&vfs, Some(&research)), + Err(ResearchError::UnsupportedNonAsciiLookup { .. }) + )); + + assert_eq!(candidates.selected_index, 0); + assert_eq!(candidates.status, 0); + assert_eq!( + candidates + .entries + .iter() + .map(|entry| entry.status) + .collect::<Vec<_>>(), + [0, 0] + ); + } + + #[test] + fn research_candidate_set_skips_empty_table_cost_after_pruned_tree() { + let research = research_selector_document(&[], &[], &[], &[]); + let path = "units/empty-table-kind-zero.dat"; + let mut vfs = MemoryVfs::default(); + insert_research_dat( + &mut vfs, + path, + research_unit_dat_payload(&[b""], &[0], &[0]), + ); + let mut candidates = research_candidate_set(&[(path, 0)], 37, true, 0); + + candidates + .refresh_selection(&vfs, Some(&research)) + .expect("native cost failure skips this candidate"); + + assert_eq!(candidates.selected_index, 37); + assert_eq!(candidates.status, 0); + assert_eq!(candidates.entries[0].status, 1); + } + + #[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)]; @@ -5102,6 +5504,72 @@ mod tests { #[test] #[ignore = "requires licensed Part 1 corpus"] + fn licensed_part1_ai_candidate_prefixes_and_ordered_research_scan() { + let root = fixture_root("IS"); + let vfs = DirectoryVfs::new(&root); + let table_path = parse_path("MISSIONS/SCRIPTS/scream.trf").expect("research path"); + let table_bytes = vfs.read(&table_path).expect("licensed scream.trf"); + let research = ResearchTrfDocument::decode(table_bytes).expect("decode scream.trf"); + let prefix = parse_path("UNITS/UNITS/AI").expect("AI unit directory"); + let mut candidate_paths = vfs + .list(&prefix) + .expect("list Part 1 AI assets") + .into_iter() + .filter_map(|entry| { + entry + .path + .as_bytes() + .to_ascii_lowercase() + .ends_with(b".dat") + .then_some(entry.path) + }) + .collect::<Vec<_>>(); + candidate_paths.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes())); + assert!( + !candidate_paths.is_empty(), + "Part 1 AI catalog has DAT files" + ); + assert!(candidate_paths + .iter() + .all(|path| path.as_bytes().is_ascii())); + assert!(candidate_paths + .windows(2) + .all(|pair| pair[0].as_bytes() <= pair[1].as_bytes())); + + for path in &candidate_paths { + let bytes = vfs + .read(path) + .unwrap_or_else(|error| panic!("{}: {error}", path.as_str())); + research::decode_candidate_first_tree(&bytes) + .unwrap_or_else(|error| panic!("{}: {error}", path.as_str())); + } + + let mut candidates = ResearchCandidateSet { + entries: candidate_paths + .iter() + .cloned() + .map(|path| ResearchCandidate { path, status: 0 }) + .collect(), + selected_index: 0, + loaded: true, + status: 0xA5, + }; + candidates + .refresh_selection(&vfs, Some(&research)) + .expect("scan Part 1 AI candidates in byte-sorted supplied order"); + assert!( + usize::try_from(candidates.selected_index) + .is_ok_and(|index| index < candidate_paths.len()), + "selected index remains within the supplied catalog" + ); + assert_eq!( + candidates.status, 0, + "at least one catalog entry was scanned" + ); + } + + #[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") { @@ -5148,6 +5616,56 @@ mod tests { omitted_chunks: &'a [u32], } + fn research_selector_document( + flags: &[u8], + costs: &[(f32, f32)], + aliases: &[(&[u8], u16)], + row_alias_links: &[u16], + ) -> ResearchTrfDocument { + let empty_groups = vec![&[][..]; costs.len()]; + let bytes = research_fixture(ResearchFixtureSpec { + trf0_attr2: 3, + flags, + costs, + aliases, + row_alias_links, + relations_23: &empty_groups, + relations_45: &empty_groups, + include_trf3: true, + include_trf5: true, + omitted_chunks: &[], + }); + ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())) + .expect("synthetic research selector table") + } + + fn research_candidate_set( + entries: &[(&str, u8)], + selected_index: u32, + loaded: bool, + status: u8, + ) -> ResearchCandidateSet { + ResearchCandidateSet { + entries: entries + .iter() + .map(|(path, status)| ResearchCandidate { + path: parse_path(path).expect("candidate path"), + status: *status, + }) + .collect(), + selected_index, + loaded, + status, + } + } + + fn insert_research_dat(vfs: &mut MemoryVfs, path: &str, bytes: Vec<u8>) { + vfs.insert( + parse_path(path).expect("candidate path"), + Arc::from(bytes.into_boxed_slice()), + ); + } + fn research_fixture(spec: ResearchFixtureSpec<'_>) -> Vec<u8> { assert_eq!(spec.costs.len(), spec.row_alias_links.len()); @@ -5412,11 +5930,7 @@ mod tests { panic!("NRes entry type {type_id:#010x} was not built"); } - fn research_unit_dat( - keys: &[&[u8]], - child_counts: &[u32], - kinds: &[u32], - ) -> fparkan_prototype::UnitDat { + fn research_unit_dat_payload(keys: &[&[u8]], child_counts: &[u32], kinds: &[u32]) -> Vec<u8> { assert_eq!(keys.len(), child_counts.len()); assert_eq!(keys.len(), kinds.len()); let mut bytes = vec![0; 8 + keys.len() * 112]; @@ -5427,7 +5941,16 @@ mod tests { 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") + bytes + } + + fn research_unit_dat( + keys: &[&[u8]], + child_counts: &[u32], + kinds: &[u32], + ) -> fparkan_prototype::UnitDat { + fparkan_prototype::decode_unit_dat(&research_unit_dat_payload(keys, child_counts, kinds)) + .expect("candidate Unit DAT") } fn resource_device_record(tag: u32, key: u32, base_bits: u32) -> PreparedControlTypedRecord { diff --git a/crates/fparkan-assets/src/research.rs b/crates/fparkan-assets/src/research.rs index 7b2b9fc..b261928 100644 --- a/crates/fparkan-assets/src/research.rs +++ b/crates/fparkan-assets/src/research.rs @@ -1,7 +1,12 @@ 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 fparkan_path::NormalizedPath; +use fparkan_prototype::{ + decode_unit_dat, unit_component_tree, PrototypeError, UnitComponentRecord, + UnitComponentTreeNode, UnitDat, +}; +use fparkan_vfs::Vfs; use std::collections::HashMap; use std::fmt; use std::sync::Arc; @@ -18,6 +23,10 @@ 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 UNIT_DAT_MAGIC: u32 = 0x0000_F0F1; +const UNIT_DAT_HEADER_BYTES: usize = 8; +const UNIT_DAT_RECORD_BYTES: usize = 112; +const INITIAL_RESEARCH_COST_BOUND: f32 = f32::from_bits(0x4b18_967f); const RESEARCH_CHUNKS: [u32; 12] = [ TRF0, TRF1, TRF2, TRF3, TRF4, TRF5, TRF6, TRF7, TRF8, TRF9, TRFA, TRFB, @@ -44,6 +53,88 @@ pub struct ResearchTrfDocument { relation_4_5: ResearchRelations, } +/// One ordered research candidate and its native per-entry status byte. +#[derive(Clone, Debug)] +pub struct ResearchCandidate { + /// Unit DAT path read through the caller's VFS. + pub path: NormalizedPath, + /// Native status: zero entries are considered by the next refresh. + pub status: u8, +} + +/// Mutable candidate catalog and selected-index state for the research chooser. +#[derive(Clone, Debug, Default)] +pub struct ResearchCandidateSet { + /// Candidate entries in native supplied order. + pub entries: Vec<ResearchCandidate>, + /// Selected candidate index, preserved when no lower-cost winner exists. + pub selected_index: u32, + /// Whether the candidate catalog has been loaded. + pub loaded: bool, + /// Native candidate-set scan status byte. + pub status: u8, +} + +impl ResearchCandidateSet { + /// Refreshes this set's native selector state from the supplied research table. + /// + /// # Errors + /// + /// Returns explicit errors for unsupported lookup semantics or malformed + /// research-table fields reached by the predicates. + /// Missing paths, invalid candidate DAT framing and an empty-table cost + /// failure skip only that entry. + pub fn refresh_selection( + &mut self, + vfs: &dyn Vfs, + research: Option<&ResearchTrfDocument>, + ) -> Result<(), ResearchError> { + let Some(research) = research else { + return Ok(()); + }; + if !self.loaded { + return Ok(()); + } + + self.status = 1; + let mut running_bound = INITIAL_RESEARCH_COST_BOUND; + for index in 0..self.entries.len() { + if self.entries[index].status != 0 { + continue; + } + + self.status = 0; + let Ok(bytes) = vfs.read(&self.entries[index].path) else { + continue; + }; + let Ok((candidate, tree)) = decode_candidate_first_tree(&bytes) else { + continue; + }; + + let first = first_research_predicate(&candidate, &tree, research)?; + self.entries[index].status = u8::from(first); + if !first { + continue; + } + + let second = second_research_predicate(&candidate, &tree, research)?; + self.entries[index].status = u8::from(second); + let cost = match calc_full_research_cost(&candidate, research) { + Ok(cost) => cost, + Err(ResearchError::EmptyResearchTable) => continue, + Err(error) => return Err(error), + }; + if !second && cost.ore < running_bound { + let selected_index = u32::try_from(index) + .map_err(|_| ResearchError::Binary(DecodeError::IntegerOverflow))?; + running_bound = cost.ore; + self.selected_index = selected_index; + } + } + Ok(()) + } +} + #[derive(Clone, Debug)] struct ResearchAlias { name_range: std::ops::Range<usize>, @@ -469,6 +560,182 @@ impl ResearchTrfDocument { } } +pub(super) fn decode_candidate_first_tree( + payload: &[u8], +) -> Result<(UnitDat, Vec<UnitComponentTreeNode>), PrototypeError> { + let limits = Limits::default(); + let payload_len = u64::try_from(payload.len()).map_err(|_| DecodeError::IntegerOverflow)?; + if payload_len > limits.max_file_bytes { + return Err(DecodeError::LimitExceeded { + count: payload_len, + limit: limits.max_file_bytes, + } + .into()); + } + if payload.len() < UNIT_DAT_HEADER_BYTES { + return Err(PrototypeError::InvalidSize); + } + let magic = u32::from_le_bytes( + payload[..4] + .try_into() + .map_err(|_| PrototypeError::InvalidSize)?, + ); + if magic != UNIT_DAT_MAGIC { + return Err(PrototypeError::InvalidUnitDatMagic(magic)); + } + + let available_body = payload.len() - UNIT_DAT_HEADER_BYTES; + let complete_records = available_body / UNIT_DAT_RECORD_BYTES; + if complete_records == 0 { + return Err(PrototypeError::InvalidSize); + } + let maximum_records = + usize::try_from(limits.max_entries).map_err(|_| DecodeError::IntegerOverflow)?; + let scan_records = complete_records.min(maximum_records); + let mut pending_nodes = 1_u64; + let mut first_tree_count = None; + + for record_index in 0..scan_records { + let record_offset = record_index + .checked_mul(UNIT_DAT_RECORD_BYTES) + .and_then(|offset| UNIT_DAT_HEADER_BYTES.checked_add(offset)) + .ok_or(DecodeError::IntegerOverflow)?; + let children_offset = record_offset + .checked_add(108) + .ok_or(DecodeError::IntegerOverflow)?; + let children_end = children_offset + .checked_add(4) + .ok_or(DecodeError::IntegerOverflow)?; + let child_count = u32::from_le_bytes( + payload + .get(children_offset..children_end) + .ok_or(PrototypeError::InvalidSize)? + .try_into() + .map_err(|_| PrototypeError::InvalidSize)?, + ); + pending_nodes = pending_nodes + .checked_sub(1) + .and_then(|pending| pending.checked_add(u64::from(child_count))) + .ok_or(DecodeError::IntegerOverflow)?; + if pending_nodes > u64::from(limits.max_entries) { + return Err(DecodeError::LimitExceeded { + count: pending_nodes, + limit: u64::from(limits.max_entries), + } + .into()); + } + if pending_nodes == 0 { + first_tree_count = Some( + record_index + .checked_add(1) + .ok_or(DecodeError::IntegerOverflow)?, + ); + break; + } + } + + let Some(record_count) = first_tree_count else { + if scan_records == maximum_records { + return Err(DecodeError::LimitExceeded { + count: u64::from(limits.max_entries) + 1, + limit: u64::from(limits.max_entries), + } + .into()); + } + return Err(PrototypeError::InvalidSize); + }; + let record_count = u64::try_from(record_count).map_err(|_| DecodeError::IntegerOverflow)?; + let _record_capacity = checked_allocation_len(record_count, u64::from(limits.max_entries))?; + let record_bytes = checked_count_bytes( + record_count, + UNIT_DAT_RECORD_BYTES as u64, + u64::try_from(available_body).map_err(|_| DecodeError::IntegerOverflow)?, + )?; + let record_size = u64::try_from(std::mem::size_of::<UnitComponentRecord>()) + .map_err(|_| DecodeError::IntegerOverflow)?; + let tree_record_size = std::mem::size_of::<UnitComponentTreeNode>() + .checked_add(2 * std::mem::size_of::<usize>()) + .ok_or(DecodeError::IntegerOverflow)?; + let tree_record_size = + u64::try_from(tree_record_size).map_err(|_| DecodeError::IntegerOverflow)?; + let decoded_bytes = record_size + .checked_add(tree_record_size) + .and_then(|size| size.checked_mul(record_count)) + .ok_or(DecodeError::IntegerOverflow)?; + let _decoded_capacity = checked_allocation_len(decoded_bytes, limits.max_decoded_bytes)?; + let prefix_end = UNIT_DAT_HEADER_BYTES + .checked_add(record_bytes) + .ok_or(DecodeError::IntegerOverflow)?; + + let candidate = decode_unit_dat(&payload[..prefix_end])?; + let tree = unit_component_tree(&candidate.records)?; + Ok((candidate, tree)) +} + +fn candidate_research_predicate( + candidate: &UnitDat, + tree: &[UnitComponentTreeNode], + research: &ResearchTrfDocument, + first_info_word: usize, + second_info_word: usize, +) -> Result<bool, ResearchError> { + let mut stack = vec![0_usize]; + 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 Some(key_end) = component.resource_raw.iter().position(|byte| *byte == 0) else { + return Err(ResearchError::MissingCandidateKeyTerminator { + component_index: node.record_index, + }); + }; + let Some(research_index) = research.find_key(&component.resource_raw[..key_end])? else { + return Ok(false); + }; + let (word0, word1, word2) = research.gates(research_index)?; + let info_words = [word0, word1, word2]; + if info_words[first_info_word] == 0 || info_words[second_info_word] == 0 { + return Ok(false); + } + for child in node.children.iter().rev() { + stack.push(*child); + } + } + Ok(true) +} + +fn first_research_predicate( + candidate: &UnitDat, + tree: &[UnitComponentTreeNode], + research: &ResearchTrfDocument, +) -> Result<bool, ResearchError> { + candidate_research_predicate(candidate, tree, research, 1, 2) +} + +fn second_research_predicate( + candidate: &UnitDat, + tree: &[UnitComponentTreeNode], + research: &ResearchTrfDocument, +) -> Result<bool, ResearchError> { + candidate_research_predicate(candidate, tree, research, 0, 2) +} + /// Calculates the ordered ore and power cost for one candidate Unit DAT tree. /// /// # Errors diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index bbf0e4b..1979a0e 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -177,11 +177,12 @@ token и типизированную `float`/`DWORD` ячейку. Неизве всей цепочки кампании. Обычная `Single.01` доходит до следующего blocker-а: `Handler(43)` в clan 1, `Init` instruction 10. Rust не перескакивает его; preview оставляет varset этого клана на defaults, а полный load завершает ошибкой -с rollback. Для самого расчёта уже есть отдельный bounded reader research-таблицы -и pure ore/power core в `fparkan-assets`. Они принимают один Unit DAT и не -исполняют script instruction: runtime по-прежнему не создаёт владельца живого -контекста, не перечисляет AI candidates и не выбирает результат Handler(43). -Это ещё не загрузка/запуск игры и не общий dispatcher. +с rollback. В `fparkan-assets` теперь есть bounded TRF reader, pure ore/power +core и `ResearchCandidateSet::refresh_selection`: последний получает уже +упорядоченные entries и отдельный TRF-контекст, затем читает Unit DAT через VFS. +Он не строит AI-каталог и не исполняет script instruction. Runtime всё ещё не +создаёт владельца живого контекста, не передаёт реальные AI candidates и не +выбирает результат Handler(43). Это не общий dispatcher и не загрузка игры. Для GOG-сборки также найден статический host call path в `Init`. В `iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) одна ветвь сначала проходит записи @@ -767,6 +768,15 @@ stdcall `[context, candidate, out1, out2]`, `RET 0x10`, и восстановлÐ установлен в `0x007f`. У набора synthetic context, поэтому он не измеряет реальную миссионную стоимость. +Отдельный прямой вызов chooser-а `ai.dll` SHA-256 +`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` +`FUN_10010f90` (RVA `0x10f90`) прошёл 3 guard-case и frozen-матрицу из 15 +проверок при заданном порядке кандидатов. Тот же код использует конструктор RVA +`0x10480`, parser первого Unit DAT дерева RVA `0x102c0` и предикаты RVA +`0x115f0`/`0x11530`. Wrapper Handler(43) по RVA `0xd54a` и catalog loader +`0x104e0` не вызывались. Эта матрица не подтверждает полный Handler(43), +миссионный источник списка или штатный порядок AI-каталога. + `fparkan-assets` декодирует используемые NRes chunks, сохраняя сырые TRF0 words и непрозрачные данные. Native cost outputs сопоставлены только с двумя raw words: out1 складывает TRF0 word 1 и подписан как ore, out2 складывает word 0 @@ -774,12 +784,31 @@ words: out1 складывает TRF0 word 1 и подписан как ore, out не установлены. Non-ASCII case folding research keys также остаётся открытым; Rust явно возвращает unsupported для таких сравнений. +Rust `ResearchCandidateSet` получает ordered entries, а TRF-context передаётся +отдельно. Ошибка чтения VFS, неверный Unit DAT magic или неполное первое +дерево пропускают entry; missing key даёт false predicate. Cost вызывается и +для пустой таблицы: он возвращает `EmptyResearchTable`, после чего этот entry +пропускается с сохранением второго status. +Другие ошибки таблицы и +`UnsupportedNonAsciiLookup` остаются явными Result-ошибками. Уже записанные +statuses и выбранный индекс на ошибке не откатываются; это политика Rust API, +не доказательство полного native error ABI. + +Два Rust-предиката повторно обходят один декодированный tree с той же +immutable TRF-таблицей. Общий helper может остановить второй проход при первом +ложном флаге; native RVA `0x11530` продолжает обход и накапливает +неиспользуемые float sums. Первый проход уже проверил ключи всех enabled +компонентов, поэтому подтверждается только эквивалентность state/selection +для такого входа, не идентичность callback trace или вычисления этих sums. + Открыты 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. +исследовательского context при запуске миссии, построение AI candidate list и +его штатный порядок, live x87 policy, inventory/economy transitions и точная типизация +всех 16-byte TMA property values. Strict ore comparison и выбор первого +кандидата при равенстве известны только для supplied-order chooser. +Ignored acceptance проверяет реальные Part 1 AI DAT prefix и `scream.trf`, но +byte-sorted порядок не заявляется как native catalog order и не сравнивает +цену с вызовом chooser-а в mission context. Закрывающий эксперимент для оставшихся границ: связать startup owner, research context и AI candidate selection в реальном `Init`; затем сравнить контекст, diff --git a/docs/tomes/03-resources.md b/docs/tomes/03-resources.md index a349a79..6668e95 100644 --- a/docs/tomes/03-resources.md +++ b/docs/tomes/03-resources.md @@ -471,6 +471,15 @@ struct UnitDatRecord112 { Требование обязательного terminator применяется только к полям, где оно доказано форматом. +У AI есть отдельный потребитель Unit DAT: исследовательский chooser читает +заголовок с magic `F0F1`, сохраняет второй DWORD как непрозрачный и завершает +разбор на границе первого полного корневого дерева. `immediate_child_count` +закрывает это дерево по preorder; байты после него, включая неполную запись или +orphan-record, chooser уже не читает. Поэтому Rust для этого пути находит +bounded 112-byte prefix и передаёт его существующим `decode_unit_dat` и +`unit_component_tree`. Общий декодер остаётся строгим к размеру всего файла +`8 + 112 * N`; частный framing AI не меняет этот контракт. + Проверено 425 файлов / 5 219 records Части 1 и 676 файлов / 8 145 records Части 2. Все соответствуют формуле размера, `kind == 1` и `archive_name == "objects.rlb"`. diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md index b4bddb7..2f7b62c 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -523,15 +523,40 @@ let cost = calc_full_research_cost(&candidate, &table)?; Две проверки суммы показали чувствительность округления: одна и та же сумма `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 от плохих -входных данных и не приписываются нативному загрузчику. +Эти результаты относятся к синтетическому исследовательскому контексту; +штатный Handler(43) и запуск миссии здесь не проверялись. + +Отдельная frozen-матрица непосредственно вызвала chooser GOG `ai.dll` SHA-256 +`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`: функция +`FUN_10010f90` (RVA `0x10f90`) прошла три guard-case и все 15 проверок. +Конструктор находится в RVA `0x10480`, raw DAT parser — `0x102c0`, +предикаты — `0x115f0` и `0x11530`. Матрица передавала кандидатов в заданном +порядке. Обёртка `Handler(43)` по RVA `0xd54a` и загрузчик AI-каталога по RVA +`0x104e0` не вызывались. Cost принадлежит `ArealMap.dll` SHA-256 +`a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea`, +`CalcFullResearchCost` RVA `0x16280`; для сравнения x87 был явно задан как +`0x007f`. + +`ResearchCandidateSet` держит упорядоченный каталог, выбранный индекс, байт +scan-status и флаг `loaded` каталога; TRF-контекст передаётся отдельно. +При наличии каталога и контекста set-status начинает со значения `1`, затем +перед чтением каждого entry со status `0` становится `0`. Ненулевой entry +пропускается. Первый предикат требует info words 1 и 2, соответствующие битам +TRF1 0 и 2; второй требует words 0 и 2, то есть биты 1 и 2. Второй status +сохраняется в entry, а cost вызывается даже при status `1`. Выбор записывается +сразу, когда second predicate ложен и ore строго ниже текущего минимума, +изначально `f32::from_bits(0x4b18967f)`. Равенство сохраняет первого, NaN индекс не меняет. + +В Rust ошибка VFS-чтения и невалидный DAT пропускают entry; неизвестный TRF- +ключ даёт false predicate. Ошибка стоимости `EmptyResearchTable` тоже +пропускает entry и сохраняет его второй status. Остальные ошибки таблицы и +`UnsupportedNonAsciiLookup` возвращаются явно, причём уже сделанные изменения +индекса и status остаются. Общий Rust-предикат может закончить обход раньше +native callback `0x11530`, который продолжает накапливать неиспользуемые float +sums. Первый проход уже проверил enabled keys в той же неизменной таблице, +поэтому подтверждается эквивалентность состояния и выбора для этого входа, +но не callback-trace parity. Это не подтверждение цены на живой миссии или +порядка AI-каталога. ### Стабильность AI-слоя |
