feat(assets): restore ordered research candidate selection
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s
This commit is contained in:
5 files changed
+880
-27
No files matched your search
@@ -29,7 +29,10 @@ use fparkan_texm::{decode_mip_rgba8, decode_texm, TexmDocument, TexmError};
|
|||||||
pub use fparkan_texm::{PageRect, RgbaImage};
|
pub use fparkan_texm::{PageRect, RgbaImage};
|
||||||
use fparkan_vfs::DirectoryVfs;
|
use fparkan_vfs::DirectoryVfs;
|
||||||
mod research;
|
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::collections::{hash_map::Entry, HashMap};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
@@ -4524,6 +4527,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]
|
#[test]
|
||||||
fn research_trf_reader_preserves_first_alias_raw_link_and_unused_tails() {
|
fn research_trf_reader_preserves_first_alias_raw_link_and_unused_tails() {
|
||||||
let costs = [(1.0, 10.0), (2.0, 20.0)];
|
let costs = [(1.0, 10.0), (2.0, 20.0)];
|
||||||
@@ -5100,6 +5502,72 @@ mod tests {
|
|||||||
calc_full_research_cost(&candidate, &research).expect("calculate candidate research cost");
|
calc_full_research_cost(&candidate, &research).expect("calculate candidate research cost");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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]
|
#[test]
|
||||||
#[ignore = "requires licensed Part 1 corpus"]
|
#[ignore = "requires licensed Part 1 corpus"]
|
||||||
fn licensed_part1_research_trf_files_all_decode() {
|
fn licensed_part1_research_trf_files_all_decode() {
|
||||||
@@ -5148,6 +5616,56 @@ mod tests {
|
|||||||
omitted_chunks: &'a [u32],
|
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> {
|
fn research_fixture(spec: ResearchFixtureSpec<'_>) -> Vec<u8> {
|
||||||
assert_eq!(spec.costs.len(), spec.row_alias_links.len());
|
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");
|
panic!("NRes entry type {type_id:#010x} was not built");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn research_unit_dat(
|
fn research_unit_dat_payload(keys: &[&[u8]], child_counts: &[u32], kinds: &[u32]) -> Vec<u8> {
|
||||||
keys: &[&[u8]],
|
|
||||||
child_counts: &[u32],
|
|
||||||
kinds: &[u32],
|
|
||||||
) -> fparkan_prototype::UnitDat {
|
|
||||||
assert_eq!(keys.len(), child_counts.len());
|
assert_eq!(keys.len(), child_counts.len());
|
||||||
assert_eq!(keys.len(), kinds.len());
|
assert_eq!(keys.len(), kinds.len());
|
||||||
let mut bytes = vec![0; 8 + keys.len() * 112];
|
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 + 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());
|
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 {
|
fn resource_device_record(tag: u32, key: u32, base_bits: u32) -> PreparedControlTypedRecord {
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
use crate::{decode_nres_payload, NresDocument};
|
use crate::{decode_nres_payload, NresDocument};
|
||||||
use fparkan_binary::{checked_allocation_len, checked_count_bytes, Cursor, DecodeError, Limits};
|
use fparkan_binary::{checked_allocation_len, checked_count_bytes, Cursor, DecodeError, Limits};
|
||||||
use fparkan_nres::{EntryId, NresEntry};
|
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::collections::HashMap;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::sync::Arc;
|
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 TRF9: u32 = u32::from_le_bytes(*b"TRF9");
|
||||||
const TRFA: u32 = u32::from_le_bytes(*b"TRFA");
|
const TRFA: u32 = u32::from_le_bytes(*b"TRFA");
|
||||||
const TRFB: u32 = u32::from_le_bytes(*b"TRFB");
|
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] = [
|
const RESEARCH_CHUNKS: [u32; 12] = [
|
||||||
TRF0, TRF1, TRF2, TRF3, TRF4, TRF5, TRF6, TRF7, TRF8, TRF9, TRFA, TRFB,
|
TRF0, TRF1, TRF2, TRF3, TRF4, TRF5, TRF6, TRF7, TRF8, TRF9, TRFA, TRFB,
|
||||||
@@ -44,6 +53,88 @@ pub struct ResearchTrfDocument {
|
|||||||
relation_4_5: ResearchRelations,
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
struct ResearchAlias {
|
struct ResearchAlias {
|
||||||
name_range: std::ops::Range<usize>,
|
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.
|
/// Calculates the ordered ore and power cost for one candidate Unit DAT tree.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
|||||||
@@ -177,11 +177,12 @@ token и типизированную `float`/`DWORD` ячейку. Неизве
|
|||||||
всей цепочки кампании. Обычная `Single.01` доходит до следующего blocker-а:
|
всей цепочки кампании. Обычная `Single.01` доходит до следующего blocker-а:
|
||||||
`Handler(43)` в clan 1, `Init` instruction 10. Rust не перескакивает его;
|
`Handler(43)` в clan 1, `Init` instruction 10. Rust не перескакивает его;
|
||||||
preview оставляет varset этого клана на defaults, а полный load завершает ошибкой
|
preview оставляет varset этого клана на defaults, а полный load завершает ошибкой
|
||||||
с rollback. Для самого расчёта уже есть отдельный bounded reader research-таблицы
|
с rollback. В `fparkan-assets` теперь есть bounded TRF reader, pure ore/power
|
||||||
и pure ore/power core в `fparkan-assets`. Они принимают один Unit DAT и не
|
core и `ResearchCandidateSet::refresh_selection`: последний получает уже
|
||||||
исполняют script instruction: runtime по-прежнему не создаёт владельца живого
|
упорядоченные entries и отдельный TRF-контекст, затем читает Unit DAT через VFS.
|
||||||
контекста, не перечисляет AI candidates и не выбирает результат Handler(43).
|
Он не строит AI-каталог и не исполняет script instruction. Runtime всё ещё не
|
||||||
Это ещё не загрузка/запуск игры и не общий dispatcher.
|
создаёт владельца живого контекста, не передаёт реальные AI candidates и не
|
||||||
|
выбирает результат Handler(43). Это не общий dispatcher и не загрузка игры.
|
||||||
|
|
||||||
Для GOG-сборки также найден статический host call path в `Init`. В
|
Для GOG-сборки также найден статический host call path в `Init`. В
|
||||||
`iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) одна ветвь сначала проходит записи
|
`iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) одна ветвь сначала проходит записи
|
||||||
@@ -767,6 +768,15 @@ stdcall `[context, candidate, out1, out2]`, `RET 0x10`, и восстановл
|
|||||||
установлен в `0x007f`. У набора synthetic context, поэтому он не измеряет
|
установлен в `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
|
`fparkan-assets` декодирует используемые NRes chunks, сохраняя сырые TRF0 words
|
||||||
и непрозрачные данные. Native cost outputs сопоставлены только с двумя raw
|
и непрозрачные данные. Native cost outputs сопоставлены только с двумя raw
|
||||||
words: out1 складывает TRF0 word 1 и подписан как ore, out2 складывает word 0
|
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 также остаётся открытым;
|
не установлены. Non-ASCII case folding research keys также остаётся открытым;
|
||||||
Rust явно возвращает unsupported для таких сравнений.
|
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 реального
|
Открыты host binding полного Handler(43), factory/ownership/lifetime реального
|
||||||
исследовательского context при запуске миссии, AI candidate enumeration и tie
|
исследовательского context при запуске миссии, построение AI candidate list и
|
||||||
selection, live x87 policy, inventory/economy transitions и точная типизация
|
его штатный порядок, live x87 policy, inventory/economy transitions и точная типизация
|
||||||
всех 16-byte TMA property values. Контролируемый тест с настоящими
|
всех 16-byte TMA property values. Strict ore comparison и выбор первого
|
||||||
`scream.trf` и `12hel1.dat` проверяет разбор и API одного candidate, но не
|
кандидата при равенстве известны только для supplied-order chooser.
|
||||||
сравнивает результат с native вызовом реального миссионного context.
|
Ignored acceptance проверяет реальные Part 1 AI DAT prefix и `scream.trf`, но
|
||||||
|
byte-sorted порядок не заявляется как native catalog order и не сравнивает
|
||||||
|
цену с вызовом chooser-а в mission context.
|
||||||
|
|
||||||
Закрывающий эксперимент для оставшихся границ: связать startup owner, research
|
Закрывающий эксперимент для оставшихся границ: связать startup owner, research
|
||||||
context и AI candidate selection в реальном `Init`; затем сравнить контекст,
|
context и AI candidate selection в реальном `Init`; затем сравнить контекст,
|
||||||
|
|||||||
@@ -471,6 +471,15 @@ struct UnitDatRecord112 {
|
|||||||
Требование обязательного terminator применяется только к полям, где оно доказано
|
Требование обязательного 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
|
Проверено 425 файлов / 5 219 records Части 1 и 676 файлов / 8 145 records
|
||||||
Части 2. Все соответствуют формуле размера, `kind == 1` и
|
Части 2. Все соответствуют формуле размера, `kind == 1` и
|
||||||
`archive_name == "objects.rlb"`.
|
`archive_name == "objects.rlb"`.
|
||||||
|
|||||||
@@ -523,15 +523,40 @@ let cost = calc_full_research_cost(&candidate, &table)?;
|
|||||||
Две проверки суммы показали чувствительность округления: одна и та же сумма
|
Две проверки суммы показали чувствительность округления: одна и та же сумма
|
||||||
`1, 2^24, -2^24` даёт `0`, а в другом порядке `2^24, -2^24, 1` даёт `1`.
|
`1, 2^24, -2^24` даёт `0`, а в другом порядке `2^24, -2^24, 1` даёт `1`.
|
||||||
|
|
||||||
Эта проверка использовала синтетический исследовательский контекст и заданное
|
Эти результаты относятся к синтетическому исследовательскому контексту;
|
||||||
для опыта x87-слово управления `0x007f`; она не запускала штатный `Handler(43)`.
|
штатный Handler(43) и запуск миссии здесь не проверялись.
|
||||||
Локальный Rust-core принимает таблицу и одного Unit DAT, но пока не выбирает
|
|
||||||
кандидата в AI-каталоге и не связывает таблицу с владельцем полного старта
|
Отдельная frozen-матрица непосредственно вызвала chooser GOG `ai.dll` SHA-256
|
||||||
миссии. Поэтому `Single.01` всё ещё останавливается на неподдержанном
|
`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`: функция
|
||||||
`Handler(43)`. Разбор с настоящими `scream.trf` и `12hel1.dat` проверяет
|
`FUN_10010f90` (RVA `0x10f90`) прошла три guard-case и все 15 проверок.
|
||||||
совместимость ресурса с API, но сам по себе не сравнивает стоимость с
|
Конструктор находится в RVA `0x10480`, raw DAT parser — `0x102c0`,
|
||||||
оригинальным живым контекстом. Проверки границ байтов защищают Rust от плохих
|
предикаты — `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-слоя
|
### Стабильность AI-слоя
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user