feat(assets): decode research tables and calculate candidate costs
This commit is contained in:
5 files changed
+1981
-23
No files matched your search
@@ -28,6 +28,8 @@ pub use fparkan_terrain_format::{BuildCategory, TerrainFormatError};
|
|||||||
use fparkan_texm::{decode_mip_rgba8, decode_texm, TexmDocument, TexmError};
|
use fparkan_texm::{decode_mip_rgba8, decode_texm, TexmDocument, TexmError};
|
||||||
pub use fparkan_texm::{PageRect, RgbaImage};
|
pub use fparkan_texm::{PageRect, RgbaImage};
|
||||||
use fparkan_vfs::DirectoryVfs;
|
use fparkan_vfs::DirectoryVfs;
|
||||||
|
mod research;
|
||||||
|
pub use research::{calc_full_research_cost, ResearchCost, ResearchError, ResearchTrfDocument};
|
||||||
use std::collections::{hash_map::Entry, HashMap};
|
use std::collections::{hash_map::Entry, HashMap};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
@@ -4478,6 +4480,956 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn research_trf_reader_preserves_rows_and_uses_low_u16_alias_link() {
|
||||||
|
let bytes = research_cycle_fixture(3, &[0; 5]);
|
||||||
|
let document = ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice()))
|
||||||
|
.expect("synthetic TRF");
|
||||||
|
|
||||||
|
assert_eq!(document.row_count(), 5);
|
||||||
|
assert_eq!(document.find_key(b"a").expect("key lookup"), Some(0));
|
||||||
|
let row = document.row_words(0).expect("first raw row");
|
||||||
|
assert_eq!(row[0], 10.0_f32.to_bits());
|
||||||
|
assert_eq!(row[1], 1.0_f32.to_bits());
|
||||||
|
assert_eq!(row[8], 0x09FF_0000);
|
||||||
|
assert_eq!(document.row_words(2).expect("third row")[8], 0x09FF_0002);
|
||||||
|
assert_eq!(document.flag_byte(0), Some(0));
|
||||||
|
assert_eq!(document.relation_2_3(0).expect("ordered links"), [3, 1]);
|
||||||
|
assert!(document
|
||||||
|
.relation_4_5(0)
|
||||||
|
.expect("empty optional stream")
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn research_trf_reader_rejects_the_native_trf0_attribute_guard() {
|
||||||
|
let bytes = research_cycle_fixture(4, &[0; 5]);
|
||||||
|
|
||||||
|
assert!(ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn research_cost_matches_native_cycle_dedup_nonzero_result() {
|
||||||
|
let bytes = research_cycle_fixture(3, &[0; 5]);
|
||||||
|
let research = ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice()))
|
||||||
|
.expect("synthetic TRF");
|
||||||
|
let candidate = research_unit_dat(&[b"A", b"B", b"C", b"B"], &[3, 0, 0, 0], &[1; 4]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
calc_full_research_cost(&candidate, &research).expect("cost"),
|
||||||
|
ResearchCost {
|
||||||
|
ore: 15.0,
|
||||||
|
power: 150.0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn research_trf_reader_preserves_first_alias_raw_link_and_unused_tails() {
|
||||||
|
let costs = [(1.0, 10.0), (2.0, 20.0)];
|
||||||
|
let aliases = [(b"Key".as_slice(), 0), (b"kEy".as_slice(), 1)];
|
||||||
|
let row_alias_links = [1, 0];
|
||||||
|
let empty_groups = [&[][..], &[][..]];
|
||||||
|
let bytes = research_fixture(ResearchFixtureSpec {
|
||||||
|
trf0_attr2: 3,
|
||||||
|
flags: &[0x84, 0x05],
|
||||||
|
costs: &costs,
|
||||||
|
aliases: &aliases,
|
||||||
|
row_alias_links: &row_alias_links,
|
||||||
|
relations_23: &empty_groups,
|
||||||
|
relations_45: &empty_groups,
|
||||||
|
include_trf3: true,
|
||||||
|
include_trf5: true,
|
||||||
|
omitted_chunks: &[],
|
||||||
|
});
|
||||||
|
let document = ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice()))
|
||||||
|
.expect("synthetic TRF with unused payload tails");
|
||||||
|
|
||||||
|
assert_eq!(document.row_count(), 2);
|
||||||
|
assert_eq!(document.find_key(b"KEY").expect("alias lookup"), Some(0));
|
||||||
|
assert_eq!(document.find_key(b"missing").expect("missing lookup"), None);
|
||||||
|
assert_eq!(document.row_name(0).expect("raw low-u16 link"), b"kEy");
|
||||||
|
assert_eq!(document.row_words(0).expect("raw row")[8], 0x09FF_0001);
|
||||||
|
assert_eq!(document.flag_byte(0), Some(0x84));
|
||||||
|
assert_eq!(document.relation_2_3(0).expect("empty group"), &[]);
|
||||||
|
assert_eq!(document.relation_4_5(0).expect("empty second group"), &[]);
|
||||||
|
|
||||||
|
let trf0 = document
|
||||||
|
.archive()
|
||||||
|
.entries()
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.meta().type_id == u32::from_le_bytes(*b"TRF0"))
|
||||||
|
.expect("retained TRF0 chunk");
|
||||||
|
assert!(document
|
||||||
|
.archive()
|
||||||
|
.payload(trf0.id())
|
||||||
|
.expect("retained payload")
|
||||||
|
.ends_with(RESEARCH_TEST_TAIL));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn research_trf_reader_rejects_required_chunk_and_attribute_errors() {
|
||||||
|
let cycle_costs = [
|
||||||
|
(1.0, 10.0),
|
||||||
|
(2.0, 20.0),
|
||||||
|
(3.0, 30.0),
|
||||||
|
(4.0, 40.0),
|
||||||
|
(5.0, 50.0),
|
||||||
|
];
|
||||||
|
let names: [&[u8]; 5] = [b"A", b"B", b"C", b"D", b"E"];
|
||||||
|
let aliases: Vec<_> = names
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, name)| (*name, u16::try_from(index).expect("row id")))
|
||||||
|
.collect();
|
||||||
|
let row_alias_links = [0, 1, 2, 3, 4];
|
||||||
|
let no_groups = [&[][..]; 5];
|
||||||
|
|
||||||
|
let invalid_attr = research_fixture(ResearchFixtureSpec {
|
||||||
|
trf0_attr2: 4,
|
||||||
|
flags: &[0; 5],
|
||||||
|
costs: &cycle_costs,
|
||||||
|
aliases: &aliases,
|
||||||
|
row_alias_links: &row_alias_links,
|
||||||
|
relations_23: &no_groups,
|
||||||
|
relations_45: &no_groups,
|
||||||
|
include_trf3: true,
|
||||||
|
include_trf5: true,
|
||||||
|
omitted_chunks: &[],
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
ResearchTrfDocument::decode(Arc::from(invalid_attr.into_boxed_slice())),
|
||||||
|
Err(ResearchError::InvalidTrf0Attribute { got: 4 })
|
||||||
|
));
|
||||||
|
|
||||||
|
let missing_required = research_fixture(ResearchFixtureSpec {
|
||||||
|
trf0_attr2: 3,
|
||||||
|
flags: &[0; 5],
|
||||||
|
costs: &cycle_costs,
|
||||||
|
aliases: &aliases,
|
||||||
|
row_alias_links: &row_alias_links,
|
||||||
|
relations_23: &no_groups,
|
||||||
|
relations_45: &no_groups,
|
||||||
|
include_trf3: true,
|
||||||
|
include_trf5: true,
|
||||||
|
omitted_chunks: &[u32::from_le_bytes(*b"TRF8")],
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
ResearchTrfDocument::decode(Arc::from(missing_required.into_boxed_slice())),
|
||||||
|
Err(ResearchError::MissingChunk { type_id })
|
||||||
|
if type_id == u32::from_le_bytes(*b"TRF8")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn research_trf_reader_zero_fills_short_flags_and_rejects_long_flags() {
|
||||||
|
let costs = [(1.0, 10.0), (2.0, 20.0)];
|
||||||
|
let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)];
|
||||||
|
let row_alias_links = [0, 1];
|
||||||
|
let no_groups = [&[][..], &[][..]];
|
||||||
|
let short = research_fixture(ResearchFixtureSpec {
|
||||||
|
trf0_attr2: 3,
|
||||||
|
flags: &[0x84],
|
||||||
|
costs: &costs,
|
||||||
|
aliases: &aliases,
|
||||||
|
row_alias_links: &row_alias_links,
|
||||||
|
relations_23: &no_groups,
|
||||||
|
relations_45: &no_groups,
|
||||||
|
include_trf3: true,
|
||||||
|
include_trf5: true,
|
||||||
|
omitted_chunks: &[],
|
||||||
|
});
|
||||||
|
let document = ResearchTrfDocument::decode(Arc::from(short.into_boxed_slice()))
|
||||||
|
.expect("short TRF1 is zero-filled by native loader");
|
||||||
|
assert_eq!(document.flag_byte(0), Some(0x84));
|
||||||
|
assert_eq!(document.flag_byte(1), Some(0));
|
||||||
|
|
||||||
|
let long = research_fixture(ResearchFixtureSpec {
|
||||||
|
trf0_attr2: 3,
|
||||||
|
flags: &[0; 3],
|
||||||
|
costs: &costs,
|
||||||
|
aliases: &aliases,
|
||||||
|
row_alias_links: &row_alias_links,
|
||||||
|
relations_23: &no_groups,
|
||||||
|
relations_45: &no_groups,
|
||||||
|
include_trf3: true,
|
||||||
|
include_trf5: true,
|
||||||
|
omitted_chunks: &[],
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
ResearchTrfDocument::decode(Arc::from(long.into_boxed_slice())),
|
||||||
|
Err(ResearchError::FlagsTooLong {
|
||||||
|
row_count: 2,
|
||||||
|
actual: 3
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn research_trf_reader_checks_alias_and_pair_bounds_and_non_ascii_policy() {
|
||||||
|
let costs = [(1.0, 10.0), (2.0, 20.0)];
|
||||||
|
let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)];
|
||||||
|
let row_alias_links = [0, 1];
|
||||||
|
let no_groups = [&[][..], &[][..]];
|
||||||
|
let make_bytes = || {
|
||||||
|
research_fixture(ResearchFixtureSpec {
|
||||||
|
trf0_attr2: 3,
|
||||||
|
flags: &[0; 2],
|
||||||
|
costs: &costs,
|
||||||
|
aliases: &aliases,
|
||||||
|
row_alias_links: &row_alias_links,
|
||||||
|
relations_23: &no_groups,
|
||||||
|
relations_45: &no_groups,
|
||||||
|
include_trf3: true,
|
||||||
|
include_trf5: true,
|
||||||
|
omitted_chunks: &[],
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut invalid_name_offset = make_bytes();
|
||||||
|
research_set_payload_u16(
|
||||||
|
&mut invalid_name_offset,
|
||||||
|
u32::from_le_bytes(*b"TRFB"),
|
||||||
|
0,
|
||||||
|
u16::MAX,
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
ResearchTrfDocument::decode(Arc::from(invalid_name_offset.into_boxed_slice())),
|
||||||
|
Err(ResearchError::InvalidNameOffset {
|
||||||
|
pair_index: 0,
|
||||||
|
offset: u16::MAX
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut unterminated = make_bytes();
|
||||||
|
research_set_payload_byte(&mut unterminated, u32::from_le_bytes(*b"TRF6"), 1, b'X');
|
||||||
|
research_set_payload_byte(&mut unterminated, u32::from_le_bytes(*b"TRF6"), 3, b'X');
|
||||||
|
assert!(matches!(
|
||||||
|
ResearchTrfDocument::decode(Arc::from(unterminated.into_boxed_slice())),
|
||||||
|
Err(ResearchError::UnterminatedName {
|
||||||
|
pair_index: 0,
|
||||||
|
offset: 0
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut invalid_record_pair = make_bytes();
|
||||||
|
research_set_payload_u32(
|
||||||
|
&mut invalid_record_pair,
|
||||||
|
u32::from_le_bytes(*b"TRF0"),
|
||||||
|
32,
|
||||||
|
0x09FF_0002,
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
ResearchTrfDocument::decode(Arc::from(invalid_record_pair.into_boxed_slice())),
|
||||||
|
Err(ResearchError::InvalidRecordPair {
|
||||||
|
record_index: 0,
|
||||||
|
pair_index: 2,
|
||||||
|
pair_count: 2
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut invalid_alias_row = make_bytes();
|
||||||
|
research_set_payload_u16(&mut invalid_alias_row, u32::from_le_bytes(*b"TRFB"), 2, 7);
|
||||||
|
let document = ResearchTrfDocument::decode(Arc::from(invalid_alias_row.into_boxed_slice()))
|
||||||
|
.expect("alias row is checked when selected");
|
||||||
|
assert!(matches!(
|
||||||
|
document.find_key(b"A"),
|
||||||
|
Err(ResearchError::InvalidResearchIndex {
|
||||||
|
type_id,
|
||||||
|
index: 7,
|
||||||
|
row_count: 2
|
||||||
|
}) if type_id == u32::from_le_bytes(*b"TRFB")
|
||||||
|
));
|
||||||
|
|
||||||
|
let non_ascii_aliases = [(b"\xC4".as_slice(), 0), (b"A".as_slice(), 1)];
|
||||||
|
let non_ascii = research_fixture(ResearchFixtureSpec {
|
||||||
|
trf0_attr2: 3,
|
||||||
|
flags: &[0; 2],
|
||||||
|
costs: &costs,
|
||||||
|
aliases: &non_ascii_aliases,
|
||||||
|
row_alias_links: &row_alias_links,
|
||||||
|
relations_23: &no_groups,
|
||||||
|
relations_45: &no_groups,
|
||||||
|
include_trf3: true,
|
||||||
|
include_trf5: true,
|
||||||
|
omitted_chunks: &[],
|
||||||
|
});
|
||||||
|
let document = ResearchTrfDocument::decode(Arc::from(non_ascii.into_boxed_slice()))
|
||||||
|
.expect("non-ASCII names are preserved");
|
||||||
|
assert!(matches!(
|
||||||
|
document.find_key(b"A"),
|
||||||
|
Err(ResearchError::UnsupportedNonAsciiLookup { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
document.find_key(b"\xE4"),
|
||||||
|
Err(ResearchError::UnsupportedNonAsciiLookup { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn research_trf_reader_checks_optional_streams_group_bounds_and_relation_ids() {
|
||||||
|
let costs = [(1.0, 10.0), (2.0, 20.0)];
|
||||||
|
let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)];
|
||||||
|
let row_alias_links = [0, 1];
|
||||||
|
let no_groups = [&[][..], &[][..]];
|
||||||
|
let missing_zero_streams = research_fixture(ResearchFixtureSpec {
|
||||||
|
trf0_attr2: 3,
|
||||||
|
flags: &[0; 2],
|
||||||
|
costs: &costs,
|
||||||
|
aliases: &aliases,
|
||||||
|
row_alias_links: &row_alias_links,
|
||||||
|
relations_23: &no_groups,
|
||||||
|
relations_45: &no_groups,
|
||||||
|
include_trf3: false,
|
||||||
|
include_trf5: false,
|
||||||
|
omitted_chunks: &[],
|
||||||
|
});
|
||||||
|
let document =
|
||||||
|
ResearchTrfDocument::decode(Arc::from(missing_zero_streams.into_boxed_slice()))
|
||||||
|
.expect("missing optional streams with zero IDs are empty");
|
||||||
|
assert_eq!(
|
||||||
|
document.relation_2_3(0).expect("empty optional stream"),
|
||||||
|
&[]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
document.relation_4_5(0).expect("empty optional stream"),
|
||||||
|
&[]
|
||||||
|
);
|
||||||
|
|
||||||
|
let positive_group = [&[1_u32][..], &[][..]];
|
||||||
|
let missing_trf3 = research_fixture(ResearchFixtureSpec {
|
||||||
|
trf0_attr2: 3,
|
||||||
|
flags: &[0; 2],
|
||||||
|
costs: &costs,
|
||||||
|
aliases: &aliases,
|
||||||
|
row_alias_links: &row_alias_links,
|
||||||
|
relations_23: &positive_group,
|
||||||
|
relations_45: &no_groups,
|
||||||
|
include_trf3: false,
|
||||||
|
include_trf5: true,
|
||||||
|
omitted_chunks: &[],
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
ResearchTrfDocument::decode(Arc::from(missing_trf3.into_boxed_slice())),
|
||||||
|
Err(ResearchError::MissingRelationStream { type_id, required_ids: 1 })
|
||||||
|
if type_id == u32::from_le_bytes(*b"TRF3")
|
||||||
|
));
|
||||||
|
|
||||||
|
let out_of_range_23 = [&[7_u32][..], &[][..]];
|
||||||
|
let out_of_range_45 = [&[][..], &[9_u32][..]];
|
||||||
|
let invalid_ids = research_fixture(ResearchFixtureSpec {
|
||||||
|
trf0_attr2: 3,
|
||||||
|
flags: &[0; 2],
|
||||||
|
costs: &costs,
|
||||||
|
aliases: &aliases,
|
||||||
|
row_alias_links: &row_alias_links,
|
||||||
|
relations_23: &out_of_range_23,
|
||||||
|
relations_45: &out_of_range_45,
|
||||||
|
include_trf3: true,
|
||||||
|
include_trf5: true,
|
||||||
|
omitted_chunks: &[],
|
||||||
|
});
|
||||||
|
let document = ResearchTrfDocument::decode(Arc::from(invalid_ids.into_boxed_slice()))
|
||||||
|
.expect("indices are checked when a group is accessed");
|
||||||
|
assert!(matches!(
|
||||||
|
document.relation_2_3(0),
|
||||||
|
Err(ResearchError::InvalidResearchIndex { type_id, index: 7, row_count: 2 })
|
||||||
|
if type_id == u32::from_le_bytes(*b"TRF3")
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
document.relation_4_5(1),
|
||||||
|
Err(ResearchError::InvalidResearchIndex { type_id, index: 9, row_count: 2 })
|
||||||
|
if type_id == u32::from_le_bytes(*b"TRF5")
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
document.relation_2_3(usize::MAX),
|
||||||
|
Err(ResearchError::MissingRelationGroup { type_id, .. })
|
||||||
|
if type_id == u32::from_le_bytes(*b"TRF2")
|
||||||
|
));
|
||||||
|
|
||||||
|
let one_group = [&[][..]];
|
||||||
|
let short_table = research_fixture(ResearchFixtureSpec {
|
||||||
|
trf0_attr2: 3,
|
||||||
|
flags: &[0; 2],
|
||||||
|
costs: &costs,
|
||||||
|
aliases: &aliases,
|
||||||
|
row_alias_links: &row_alias_links,
|
||||||
|
relations_23: &one_group,
|
||||||
|
relations_45: &one_group,
|
||||||
|
include_trf3: true,
|
||||||
|
include_trf5: true,
|
||||||
|
omitted_chunks: &[],
|
||||||
|
});
|
||||||
|
let document = ResearchTrfDocument::decode(Arc::from(short_table.into_boxed_slice()))
|
||||||
|
.expect("relation tables have their own counts");
|
||||||
|
assert!(matches!(
|
||||||
|
document.relation_2_3(1),
|
||||||
|
Err(ResearchError::MissingRelationGroup { type_id, index: 1, group_count: 1 })
|
||||||
|
if type_id == u32::from_le_bytes(*b"TRF2")
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
document.relation_4_5(1),
|
||||||
|
Err(ResearchError::MissingRelationGroup { type_id, index: 1, group_count: 1 })
|
||||||
|
if type_id == u32::from_le_bytes(*b"TRF4")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn research_trf_reader_checks_count_prefixes_without_large_allocations() {
|
||||||
|
let costs = [(1.0, 10.0), (2.0, 20.0)];
|
||||||
|
let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)];
|
||||||
|
let row_alias_links = [0, 1];
|
||||||
|
let no_groups = [&[][..], &[][..]];
|
||||||
|
let mut bytes = research_fixture(ResearchFixtureSpec {
|
||||||
|
trf0_attr2: 3,
|
||||||
|
flags: &[0; 2],
|
||||||
|
costs: &costs,
|
||||||
|
aliases: &aliases,
|
||||||
|
row_alias_links: &row_alias_links,
|
||||||
|
relations_23: &no_groups,
|
||||||
|
relations_45: &no_groups,
|
||||||
|
include_trf3: true,
|
||||||
|
include_trf5: true,
|
||||||
|
omitted_chunks: &[],
|
||||||
|
});
|
||||||
|
research_set_nres_attr1(&mut bytes, u32::from_le_bytes(*b"TRF2"), 3);
|
||||||
|
assert!(matches!(
|
||||||
|
ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())),
|
||||||
|
Err(ResearchError::Binary(
|
||||||
|
fparkan_binary::DecodeError::UnexpectedEof { .. }
|
||||||
|
))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn research_cost_preserves_native_tree_order_and_f32_rounding() {
|
||||||
|
let costs = [(16_777_216.0, 20.0), (-16_777_216.0, 30.0), (1.0, 10.0)];
|
||||||
|
let aliases_forward = [
|
||||||
|
(b"A".as_slice(), 2),
|
||||||
|
(b"B".as_slice(), 0),
|
||||||
|
(b"C".as_slice(), 1),
|
||||||
|
];
|
||||||
|
let aliases_reversed = [
|
||||||
|
(b"A".as_slice(), 0),
|
||||||
|
(b"B".as_slice(), 1),
|
||||||
|
(b"C".as_slice(), 2),
|
||||||
|
];
|
||||||
|
let row_alias_links = [0, 1, 2];
|
||||||
|
let no_groups = [&[][..], &[][..], &[][..]];
|
||||||
|
let make_research = |aliases: &[(&[u8], u16)]| {
|
||||||
|
let bytes = research_fixture(ResearchFixtureSpec {
|
||||||
|
trf0_attr2: 3,
|
||||||
|
flags: &[0x05; 3],
|
||||||
|
costs: &costs,
|
||||||
|
aliases,
|
||||||
|
row_alias_links: &row_alias_links,
|
||||||
|
relations_23: &no_groups,
|
||||||
|
relations_45: &no_groups,
|
||||||
|
include_trf3: true,
|
||||||
|
include_trf5: true,
|
||||||
|
omitted_chunks: &[],
|
||||||
|
});
|
||||||
|
ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice())).expect("f32 order TRF")
|
||||||
|
};
|
||||||
|
let candidate = research_unit_dat(&[b"A", b"B", b"C", b"B"], &[3, 0, 0, 0], &[1; 4]);
|
||||||
|
|
||||||
|
let forward = calc_full_research_cost(&candidate, &make_research(&aliases_forward))
|
||||||
|
.expect("forward order cost");
|
||||||
|
assert_eq!(forward.ore.to_bits(), 0.0_f32.to_bits());
|
||||||
|
assert_eq!(forward.power.to_bits(), 60.0_f32.to_bits());
|
||||||
|
|
||||||
|
let reversed = calc_full_research_cost(&candidate, &make_research(&aliases_reversed))
|
||||||
|
.expect("reversed order cost");
|
||||||
|
assert_eq!(reversed.ore.to_bits(), 1.0_f32.to_bits());
|
||||||
|
assert_eq!(reversed.power.to_bits(), 60.0_f32.to_bits());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn research_cost_obeys_subtree_and_record_gates() {
|
||||||
|
let costs = [
|
||||||
|
(1.0, 10.0),
|
||||||
|
(2.0, 20.0),
|
||||||
|
(3.0, 30.0),
|
||||||
|
(4.0, 40.0),
|
||||||
|
(5.0, 50.0),
|
||||||
|
];
|
||||||
|
let names: [&[u8]; 5] = [b"A", b"B", b"C", b"D", b"E"];
|
||||||
|
let aliases: Vec<_> = names
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, name)| (*name, u16::try_from(index).expect("row id")))
|
||||||
|
.collect();
|
||||||
|
let row_alias_links = [0, 1, 2, 3, 4];
|
||||||
|
let relations_45 = [&[][..]; 5];
|
||||||
|
let candidate = research_unit_dat(&[b"A", b"B", b"C", b"B"], &[3, 0, 0, 0], &[1; 4]);
|
||||||
|
|
||||||
|
let recursion_gate = research_cycle_fixture(3, &[0x05, 0x05, 0x05, 0x04, 0x04]);
|
||||||
|
let research = ResearchTrfDocument::decode(Arc::from(recursion_gate.into_boxed_slice()))
|
||||||
|
.expect("recursion-gate TRF");
|
||||||
|
assert_eq!(
|
||||||
|
calc_full_research_cost(&candidate, &research).expect("cost"),
|
||||||
|
ResearchCost {
|
||||||
|
ore: 6.0,
|
||||||
|
power: 60.0
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let include_gate = research_cycle_fixture(3, &[0x04, 0x05, 0x05, 0x06, 0x04]);
|
||||||
|
let research = ResearchTrfDocument::decode(Arc::from(include_gate.into_boxed_slice()))
|
||||||
|
.expect("include-gate TRF");
|
||||||
|
assert_eq!(
|
||||||
|
calc_full_research_cost(&candidate, &research).expect("cost"),
|
||||||
|
ResearchCost {
|
||||||
|
ore: 6.0,
|
||||||
|
power: 60.0
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let no_groups = [&[][..]; 5];
|
||||||
|
let isolated = research_fixture(ResearchFixtureSpec {
|
||||||
|
trf0_attr2: 3,
|
||||||
|
flags: &[0x04; 5],
|
||||||
|
costs: &costs,
|
||||||
|
aliases: &aliases,
|
||||||
|
row_alias_links: &row_alias_links,
|
||||||
|
relations_23: &no_groups,
|
||||||
|
relations_45: &relations_45,
|
||||||
|
include_trf3: true,
|
||||||
|
include_trf5: true,
|
||||||
|
omitted_chunks: &[],
|
||||||
|
});
|
||||||
|
let research = ResearchTrfDocument::decode(Arc::from(isolated.into_boxed_slice()))
|
||||||
|
.expect("isolated TRF");
|
||||||
|
let child_pruned = research_unit_dat(&[b"A", b"B", b"C"], &[1, 1, 0], &[1, 0, 1]);
|
||||||
|
assert_eq!(
|
||||||
|
calc_full_research_cost(&child_pruned, &research).expect("pruned child cost"),
|
||||||
|
ResearchCost {
|
||||||
|
ore: 1.0,
|
||||||
|
power: 10.0
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let root_pruned = research_unit_dat(&[b"A", b"B"], &[1, 0], &[0, 1]);
|
||||||
|
assert_eq!(
|
||||||
|
calc_full_research_cost(&root_pruned, &research).expect("pruned root cost"),
|
||||||
|
ResearchCost {
|
||||||
|
ore: 0.0,
|
||||||
|
power: 0.0
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn research_cost_rejects_unknown_keys_unterminated_keys_and_missing_groups() {
|
||||||
|
let costs = [(1.0, 10.0), (2.0, 20.0)];
|
||||||
|
let aliases = [(b"A".as_slice(), 0), (b"B".as_slice(), 1)];
|
||||||
|
let row_alias_links = [0, 1];
|
||||||
|
let no_groups = [&[][..], &[][..]];
|
||||||
|
let make_document = |groups: &[&[u32]]| {
|
||||||
|
let bytes = research_fixture(ResearchFixtureSpec {
|
||||||
|
trf0_attr2: 3,
|
||||||
|
flags: &[0x04; 2],
|
||||||
|
costs: &costs,
|
||||||
|
aliases: &aliases,
|
||||||
|
row_alias_links: &row_alias_links,
|
||||||
|
relations_23: groups,
|
||||||
|
relations_45: &no_groups,
|
||||||
|
include_trf3: true,
|
||||||
|
include_trf5: true,
|
||||||
|
omitted_chunks: &[],
|
||||||
|
});
|
||||||
|
ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice()))
|
||||||
|
.expect("research table")
|
||||||
|
};
|
||||||
|
|
||||||
|
let no_group_document = make_document(&[]);
|
||||||
|
let candidate = research_unit_dat(&[b"A"], &[0], &[1]);
|
||||||
|
assert!(matches!(
|
||||||
|
calc_full_research_cost(&candidate, &no_group_document),
|
||||||
|
Err(ResearchError::MissingRelationGroup {
|
||||||
|
type_id,
|
||||||
|
index: 0,
|
||||||
|
group_count: 0
|
||||||
|
}) if type_id == u32::from_le_bytes(*b"TRF2")
|
||||||
|
));
|
||||||
|
|
||||||
|
let valid_groups = [&[][..], &[][..]];
|
||||||
|
let document = make_document(&valid_groups);
|
||||||
|
let unknown = research_unit_dat(&[b"Z"], &[0], &[1]);
|
||||||
|
assert!(matches!(
|
||||||
|
calc_full_research_cost(&unknown, &document),
|
||||||
|
Err(ResearchError::UnknownResearchKey {
|
||||||
|
component_index: 0,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
let unterminated = research_unit_dat(&[&[b'A'; 32]], &[0], &[1]);
|
||||||
|
assert!(matches!(
|
||||||
|
calc_full_research_cost(&unterminated, &document),
|
||||||
|
Err(ResearchError::MissingCandidateKeyTerminator { component_index: 0 })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires licensed Part 1 corpus"]
|
||||||
|
fn licensed_scream_trf_and_12hel1_dat_exercise_research_cost() {
|
||||||
|
let root = fixture_root("IS");
|
||||||
|
let trf =
|
||||||
|
std::fs::read(root.join("MISSIONS/SCRIPTS/scream.trf")).expect("licensed scream.trf");
|
||||||
|
let research = ResearchTrfDocument::decode(Arc::from(trf.into_boxed_slice()))
|
||||||
|
.expect("decode licensed research table");
|
||||||
|
assert_eq!(research.row_count(), 368);
|
||||||
|
assert_eq!(research.find_key(b"R_T_02").expect("R_T_02"), Some(205));
|
||||||
|
assert_eq!(research.row_name(205).expect("row 205 name"), b"R_T_02");
|
||||||
|
assert_eq!(
|
||||||
|
&research.row_words(205).expect("row 205 words")[..4],
|
||||||
|
&[0, 0, 0x40A0_0000, 0x4140_0000]
|
||||||
|
);
|
||||||
|
assert_eq!(research.flag_byte(205), Some(0x07));
|
||||||
|
assert!(research
|
||||||
|
.relation_2_3(205)
|
||||||
|
.expect("row 205 TRF2/TRF3 group")
|
||||||
|
.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
research.relation_4_5(205).expect("row 205 second group"),
|
||||||
|
&[196]
|
||||||
|
);
|
||||||
|
|
||||||
|
let bytes =
|
||||||
|
std::fs::read(root.join("UNITS/UNITS/AI/12hel1.dat")).expect("licensed 12hel1.dat");
|
||||||
|
let candidate = fparkan_prototype::decode_unit_dat(&bytes).expect("decode Unit DAT");
|
||||||
|
calc_full_research_cost(&candidate, &research).expect("calculate candidate research cost");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires licensed Part 1 corpus"]
|
||||||
|
fn licensed_part1_research_trf_files_all_decode() {
|
||||||
|
fn collect_trf_files(directory: &std::path::Path, files: &mut Vec<PathBuf>) {
|
||||||
|
for entry in std::fs::read_dir(directory).expect("read licensed directory") {
|
||||||
|
let entry = entry.expect("read directory entry");
|
||||||
|
let path = entry.path();
|
||||||
|
let kind = entry.file_type().expect("read entry type");
|
||||||
|
if kind.is_dir() {
|
||||||
|
collect_trf_files(&path, files);
|
||||||
|
} else if !kind.is_symlink()
|
||||||
|
&& path
|
||||||
|
.extension()
|
||||||
|
.map(|extension| extension.to_string_lossy().eq_ignore_ascii_case("trf"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = fixture_root("IS");
|
||||||
|
let mut files = Vec::new();
|
||||||
|
collect_trf_files(&root, &mut files);
|
||||||
|
files.sort();
|
||||||
|
assert!(!files.is_empty(), "licensed corpus contains no .trf files");
|
||||||
|
for path in files {
|
||||||
|
let bytes = std::fs::read(&path).expect("read TRF resource");
|
||||||
|
ResearchTrfDocument::decode(Arc::from(bytes.into_boxed_slice()))
|
||||||
|
.unwrap_or_else(|error| panic!("{}: {error}", path.display()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESEARCH_TEST_TAIL: &[u8] = &[0xA5, 0x5A];
|
||||||
|
|
||||||
|
struct ResearchFixtureSpec<'a> {
|
||||||
|
trf0_attr2: u32,
|
||||||
|
flags: &'a [u8],
|
||||||
|
costs: &'a [(f32, f32)],
|
||||||
|
aliases: &'a [(&'a [u8], u16)],
|
||||||
|
row_alias_links: &'a [u16],
|
||||||
|
relations_23: &'a [&'a [u32]],
|
||||||
|
relations_45: &'a [&'a [u32]],
|
||||||
|
include_trf3: bool,
|
||||||
|
include_trf5: bool,
|
||||||
|
omitted_chunks: &'a [u32],
|
||||||
|
}
|
||||||
|
|
||||||
|
fn research_fixture(spec: ResearchFixtureSpec<'_>) -> Vec<u8> {
|
||||||
|
assert_eq!(spec.costs.len(), spec.row_alias_links.len());
|
||||||
|
|
||||||
|
let mut rows = Vec::with_capacity(spec.costs.len());
|
||||||
|
for ((ore, power), pair_index) in spec.costs.iter().zip(spec.row_alias_links) {
|
||||||
|
let mut row = [0_u32; 10];
|
||||||
|
row[0] = power.to_bits();
|
||||||
|
row[1] = ore.to_bits();
|
||||||
|
row[8] = 0x09FF_0000 | u32::from(*pair_index);
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
let rows_payload = research_words_payload(
|
||||||
|
&rows
|
||||||
|
.iter()
|
||||||
|
.flat_map(|row| row.iter().copied())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut name_pool = Vec::new();
|
||||||
|
let mut pairs = Vec::with_capacity(spec.aliases.len());
|
||||||
|
for (name, row_index) in spec.aliases {
|
||||||
|
let name_offset = u16::try_from(name_pool.len()).expect("test pool offset");
|
||||||
|
name_pool.extend_from_slice(name);
|
||||||
|
name_pool.push(0);
|
||||||
|
pairs.push((name_offset, *row_index));
|
||||||
|
}
|
||||||
|
let pairs_payload = research_pairs_payload(&pairs);
|
||||||
|
let (counts_23, ids_23) = research_relation_payload(spec.relations_23);
|
||||||
|
let (counts_45, ids_45) = research_relation_payload(spec.relations_45);
|
||||||
|
let with_tail = |mut payload: Vec<u8>| {
|
||||||
|
payload.extend_from_slice(RESEARCH_TEST_TAIL);
|
||||||
|
payload
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut payloads = vec![
|
||||||
|
(
|
||||||
|
u32::from_le_bytes(*b"TRF0"),
|
||||||
|
with_tail(rows_payload),
|
||||||
|
spec.trf0_attr2,
|
||||||
|
),
|
||||||
|
(u32::from_le_bytes(*b"TRF1"), spec.flags.to_vec(), 0),
|
||||||
|
(u32::from_le_bytes(*b"TRFB"), with_tail(pairs_payload), 0),
|
||||||
|
(u32::from_le_bytes(*b"TRF6"), name_pool, 0),
|
||||||
|
(u32::from_le_bytes(*b"TRF7"), Vec::new(), 0),
|
||||||
|
(u32::from_le_bytes(*b"TRF8"), Vec::new(), 0),
|
||||||
|
(u32::from_le_bytes(*b"TRF9"), Vec::new(), 0),
|
||||||
|
(u32::from_le_bytes(*b"TRFA"), Vec::new(), 0),
|
||||||
|
(u32::from_le_bytes(*b"TRF2"), with_tail(counts_23), 0),
|
||||||
|
(u32::from_le_bytes(*b"TRF4"), with_tail(counts_45), 0),
|
||||||
|
];
|
||||||
|
if spec.include_trf3 {
|
||||||
|
payloads.push((u32::from_le_bytes(*b"TRF3"), with_tail(ids_23), 0));
|
||||||
|
}
|
||||||
|
if spec.include_trf5 {
|
||||||
|
payloads.push((u32::from_le_bytes(*b"TRF5"), with_tail(ids_45), 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries: Vec<_> = payloads
|
||||||
|
.iter()
|
||||||
|
.filter(|(type_id, _, _)| !spec.omitted_chunks.contains(type_id))
|
||||||
|
.map(|(type_id, payload, attr2)| TestNresEntry {
|
||||||
|
name: b"ResTree",
|
||||||
|
payload,
|
||||||
|
type_id: *type_id,
|
||||||
|
attr2: *attr2,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut archive = build_nres_with_meta(&entries);
|
||||||
|
for (type_id, count) in [
|
||||||
|
(u32::from_le_bytes(*b"TRF0"), spec.costs.len()),
|
||||||
|
(u32::from_le_bytes(*b"TRF1"), spec.flags.len()),
|
||||||
|
(u32::from_le_bytes(*b"TRFB"), spec.aliases.len()),
|
||||||
|
(u32::from_le_bytes(*b"TRF2"), spec.relations_23.len()),
|
||||||
|
(u32::from_le_bytes(*b"TRF4"), spec.relations_45.len()),
|
||||||
|
] {
|
||||||
|
if !spec.omitted_chunks.contains(&type_id) {
|
||||||
|
research_set_nres_attr1(
|
||||||
|
&mut archive,
|
||||||
|
type_id,
|
||||||
|
u32::try_from(count).expect("test count"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if spec.include_trf3 && !spec.omitted_chunks.contains(&u32::from_le_bytes(*b"TRF3")) {
|
||||||
|
let count: usize = spec.relations_23.iter().map(|group| group.len()).sum();
|
||||||
|
research_set_nres_attr1(
|
||||||
|
&mut archive,
|
||||||
|
u32::from_le_bytes(*b"TRF3"),
|
||||||
|
u32::try_from(count).expect("test dependency count"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if spec.include_trf5 && !spec.omitted_chunks.contains(&u32::from_le_bytes(*b"TRF5")) {
|
||||||
|
let count: usize = spec.relations_45.iter().map(|group| group.len()).sum();
|
||||||
|
research_set_nres_attr1(
|
||||||
|
&mut archive,
|
||||||
|
u32::from_le_bytes(*b"TRF5"),
|
||||||
|
u32::try_from(count).expect("test dependency count"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
archive
|
||||||
|
}
|
||||||
|
|
||||||
|
fn research_relation_payload(groups: &[&[u32]]) -> (Vec<u8>, Vec<u8>) {
|
||||||
|
let counts: Vec<_> = groups
|
||||||
|
.iter()
|
||||||
|
.map(|group| u32::try_from(group.len()).expect("test dependency group count"))
|
||||||
|
.collect();
|
||||||
|
let ids: Vec<_> = groups
|
||||||
|
.iter()
|
||||||
|
.flat_map(|group| group.iter().copied())
|
||||||
|
.collect();
|
||||||
|
(
|
||||||
|
research_words_payload(&counts),
|
||||||
|
research_words_payload(&ids),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn research_set_payload_bytes(bytes: &mut [u8], type_id: u32, offset: usize, value: &[u8]) {
|
||||||
|
let entry_count = u32::from_le_bytes(bytes[8..12].try_into().expect("entry count"));
|
||||||
|
let total_size = u32::from_le_bytes(bytes[12..16].try_into().expect("total size"));
|
||||||
|
let directory_start = usize::try_from(total_size - entry_count * 64).expect("directory");
|
||||||
|
for entry in bytes[directory_start..].chunks_exact(64) {
|
||||||
|
if u32::from_le_bytes(entry[..4].try_into().expect("type id")) != type_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let payload_start = usize::try_from(u32::from_le_bytes(
|
||||||
|
entry[56..60].try_into().expect("payload offset"),
|
||||||
|
))
|
||||||
|
.expect("payload offset");
|
||||||
|
let payload_len = usize::try_from(u32::from_le_bytes(
|
||||||
|
entry[12..16].try_into().expect("payload size"),
|
||||||
|
))
|
||||||
|
.expect("payload size");
|
||||||
|
let end = offset.checked_add(value.len()).expect("payload end");
|
||||||
|
assert!(end <= payload_len, "test mutation stays within payload");
|
||||||
|
bytes[payload_start + offset..payload_start + end].copy_from_slice(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panic!("NRes entry type {type_id:#010x} was not built");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn research_set_payload_byte(bytes: &mut [u8], type_id: u32, offset: usize, value: u8) {
|
||||||
|
research_set_payload_bytes(bytes, type_id, offset, &[value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn research_set_payload_u16(bytes: &mut [u8], type_id: u32, offset: usize, value: u16) {
|
||||||
|
research_set_payload_bytes(bytes, type_id, offset, &value.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn research_set_payload_u32(bytes: &mut [u8], type_id: u32, offset: usize, value: u32) {
|
||||||
|
research_set_payload_bytes(bytes, type_id, offset, &value.to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn research_cycle_fixture(trf0_attr2: u32, flags: &[u8]) -> Vec<u8> {
|
||||||
|
let costs = [
|
||||||
|
(1.0_f32, 10.0_f32),
|
||||||
|
(2.0_f32, 20.0_f32),
|
||||||
|
(3.0_f32, 30.0_f32),
|
||||||
|
(4.0_f32, 40.0_f32),
|
||||||
|
(5.0_f32, 50.0_f32),
|
||||||
|
];
|
||||||
|
let mut pool = Vec::new();
|
||||||
|
let mut pairs = Vec::new();
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for (index, ((ore, power), name)) in costs
|
||||||
|
.iter()
|
||||||
|
.zip([b"A".as_slice(), b"B", b"C", b"D", b"E"])
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let offset = u16::try_from(pool.len()).expect("pool offset");
|
||||||
|
pool.extend_from_slice(name);
|
||||||
|
pool.push(0);
|
||||||
|
pairs.push((offset, u16::try_from(index).expect("row index")));
|
||||||
|
rows.push([
|
||||||
|
power.to_bits(),
|
||||||
|
ore.to_bits(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0x09FF_0000 | u32::try_from(index).expect("pair index"),
|
||||||
|
0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows_payload = research_words_payload(
|
||||||
|
&rows
|
||||||
|
.iter()
|
||||||
|
.flat_map(|row| row.iter().copied())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
let pairs_payload = research_pairs_payload(&pairs);
|
||||||
|
let count_23 = [2_u32, 0, 0, 1, 1];
|
||||||
|
let ids_23 = [3_u32, 1, 4, 0];
|
||||||
|
let count_45 = [0_u32; 5];
|
||||||
|
let count_23_payload = research_words_payload(&count_23);
|
||||||
|
let ids_23_payload = research_words_payload(&ids_23);
|
||||||
|
let count_45_payload = research_words_payload(&count_45);
|
||||||
|
let payloads: [(u32, &[u8], u32); 11] = [
|
||||||
|
(
|
||||||
|
u32::from_le_bytes(*b"TRF0"),
|
||||||
|
rows_payload.as_slice(),
|
||||||
|
trf0_attr2,
|
||||||
|
),
|
||||||
|
(u32::from_le_bytes(*b"TRF1"), flags, 0),
|
||||||
|
(u32::from_le_bytes(*b"TRFB"), pairs_payload.as_slice(), 0),
|
||||||
|
(u32::from_le_bytes(*b"TRF6"), pool.as_slice(), 0),
|
||||||
|
(u32::from_le_bytes(*b"TRF7"), &[][..], 0),
|
||||||
|
(u32::from_le_bytes(*b"TRF8"), &[][..], 0),
|
||||||
|
(u32::from_le_bytes(*b"TRF9"), &[][..], 0),
|
||||||
|
(u32::from_le_bytes(*b"TRFA"), &[][..], 0),
|
||||||
|
(u32::from_le_bytes(*b"TRF2"), &count_23_payload, 0),
|
||||||
|
(u32::from_le_bytes(*b"TRF3"), &ids_23_payload, 0),
|
||||||
|
(u32::from_le_bytes(*b"TRF4"), &count_45_payload, 0),
|
||||||
|
];
|
||||||
|
let entries: Vec<_> = payloads
|
||||||
|
.iter()
|
||||||
|
.map(|(type_id, payload, attr2)| TestNresEntry {
|
||||||
|
name: b"ResTree",
|
||||||
|
payload,
|
||||||
|
type_id: *type_id,
|
||||||
|
attr2: *attr2,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut archive = build_nres_with_meta(&entries);
|
||||||
|
for (type_id, count) in [
|
||||||
|
(u32::from_le_bytes(*b"TRF0"), 5),
|
||||||
|
(u32::from_le_bytes(*b"TRF1"), 5),
|
||||||
|
(u32::from_le_bytes(*b"TRFB"), 5),
|
||||||
|
(u32::from_le_bytes(*b"TRF2"), 5),
|
||||||
|
(u32::from_le_bytes(*b"TRF3"), 3),
|
||||||
|
(u32::from_le_bytes(*b"TRF4"), 5),
|
||||||
|
] {
|
||||||
|
research_set_nres_attr1(&mut archive, type_id, count);
|
||||||
|
}
|
||||||
|
archive
|
||||||
|
}
|
||||||
|
|
||||||
|
fn research_words_payload(words: &[u32]) -> Vec<u8> {
|
||||||
|
words.iter().flat_map(|word| word.to_le_bytes()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn research_pairs_payload(pairs: &[(u16, u16)]) -> Vec<u8> {
|
||||||
|
pairs
|
||||||
|
.iter()
|
||||||
|
.flat_map(|(offset, index)| offset.to_le_bytes().into_iter().chain(index.to_le_bytes()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn research_set_nres_attr1(bytes: &mut [u8], type_id: u32, count: u32) {
|
||||||
|
let entry_count = u32::from_le_bytes(bytes[8..12].try_into().expect("entry count"));
|
||||||
|
let total_size = u32::from_le_bytes(bytes[12..16].try_into().expect("total size"));
|
||||||
|
let directory_start = usize::try_from(total_size - entry_count * 64).expect("directory");
|
||||||
|
for entry in bytes[directory_start..].chunks_exact_mut(64) {
|
||||||
|
if u32::from_le_bytes(entry[..4].try_into().expect("type id")) == type_id {
|
||||||
|
entry[4..8].copy_from_slice(&count.to_le_bytes());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("NRes entry type {type_id:#010x} was not built");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn research_unit_dat(
|
||||||
|
keys: &[&[u8]],
|
||||||
|
child_counts: &[u32],
|
||||||
|
kinds: &[u32],
|
||||||
|
) -> fparkan_prototype::UnitDat {
|
||||||
|
assert_eq!(keys.len(), child_counts.len());
|
||||||
|
assert_eq!(keys.len(), kinds.len());
|
||||||
|
let mut bytes = vec![0; 8 + keys.len() * 112];
|
||||||
|
bytes[..4].copy_from_slice(&0x0000_F0F1_u32.to_le_bytes());
|
||||||
|
for (index, key) in keys.iter().enumerate() {
|
||||||
|
let offset = 8 + index * 112;
|
||||||
|
bytes[offset + 32..offset + 32 + key.len()].copy_from_slice(key);
|
||||||
|
bytes[offset + 64..offset + 68].copy_from_slice(&kinds[index].to_le_bytes());
|
||||||
|
bytes[offset + 108..offset + 112].copy_from_slice(&child_counts[index].to_le_bytes());
|
||||||
|
}
|
||||||
|
fparkan_prototype::decode_unit_dat(&bytes).expect("candidate Unit DAT")
|
||||||
|
}
|
||||||
|
|
||||||
fn resource_device_record(tag: u32, key: u32, base_bits: u32) -> PreparedControlTypedRecord {
|
fn resource_device_record(tag: u32, key: u32, base_bits: u32) -> PreparedControlTypedRecord {
|
||||||
let mut fixed_words = [0; 44];
|
let mut fixed_words = [0; 44];
|
||||||
fixed_words[1] = key;
|
fixed_words[1] = key;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -177,7 +177,11 @@ token и типизированную `float`/`DWORD` ячейку. Неизве
|
|||||||
всей цепочки кампании. Обычная `Single.01` доходит до следующего blocker-а:
|
всей цепочки кампании. Обычная `Single.01` доходит до следующего blocker-а:
|
||||||
`Handler(43)` в clan 1, `Init` instruction 10. Rust не перескакивает его;
|
`Handler(43)` в clan 1, `Init` instruction 10. Rust не перескакивает его;
|
||||||
preview оставляет varset этого клана на defaults, а полный load завершает ошибкой
|
preview оставляет varset этого клана на defaults, а полный load завершает ошибкой
|
||||||
с rollback. Это ещё не загрузка/запуск игры и не общий dispatcher.
|
с rollback. Для самого расчёта уже есть отдельный bounded reader research-таблицы
|
||||||
|
и pure ore/power core в `fparkan-assets`. Они принимают один Unit DAT и не
|
||||||
|
исполняют script instruction: runtime по-прежнему не создаёт владельца живого
|
||||||
|
контекста, не перечисляет AI candidates и не выбирает результат Handler(43).
|
||||||
|
Это ещё не загрузка/запуск игры и не общий dispatcher.
|
||||||
|
|
||||||
Для GOG-сборки также найден статический host call path в `Init`. В
|
Для GOG-сборки также найден статический host call path в `Init`. В
|
||||||
`iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) одна ветвь сначала проходит записи
|
`iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) одна ветвь сначала проходит записи
|
||||||
@@ -752,15 +756,36 @@ command/event captures для меню, HUD, briefing и диалогов.
|
|||||||
|
|
||||||
## Research, economy and properties
|
## Research, economy and properties
|
||||||
|
|
||||||
Экспорты `LoadResearch`, `CalcFullResearchCost`, TRF/preload resources и TMA
|
Для GOG `ArealMap.dll` SHA-256
|
||||||
properties доказывают отдельный слой исследований, стоимости, добычи и
|
`a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea`
|
||||||
производственных параметров. Формулы стоимости, dependency graph технологий,
|
`CalcFullResearchCost` по RVA `0x16280` имеет подтверждённый ABI
|
||||||
inventory/economy transitions и точная типизация всех 16-byte property values
|
stdcall `[context, candidate, out1, out2]`, `RET 0x10`, и восстановленный
|
||||||
не закрыты.
|
алгоритм для одного candidate tree: zero-kind subtree pruning, direct preorder,
|
||||||
|
общая дедупликация, ordered DFS по `TRF2`/`TRF3`, raw gates из `TRF1` и
|
||||||
|
последовательное `f32` сложение. Принятый набор из 23 synthetic native cases
|
||||||
|
включал Calc и два AI predicate; для контролируемых сравнений x87 word был
|
||||||
|
установлен в `0x007f`. У набора synthetic context, поэтому он не измеряет
|
||||||
|
реальную миссионную стоимость.
|
||||||
|
|
||||||
Закрывающий эксперимент: сопоставить research functions с ресурсами и UI,
|
`fparkan-assets` декодирует используемые NRes chunks, сохраняя сырые TRF0 words
|
||||||
снять изменения state на контролируемых покупках/исследованиях и построить
|
и непрозрачные данные. Native cost outputs сопоставлены только с двумя raw
|
||||||
typed schema свойств по consumers, а не по одному имени.
|
words: out1 складывает TRF0 word 1 и подписан как ore, out2 складывает word 0
|
||||||
|
и подписан как power. Смыслы остальных полей полного 32-byte callback record
|
||||||
|
не установлены. Non-ASCII case folding research keys также остаётся открытым;
|
||||||
|
Rust явно возвращает unsupported для таких сравнений.
|
||||||
|
|
||||||
|
Открыты host binding полного Handler(43), factory/ownership/lifetime реального
|
||||||
|
исследовательского context при запуске миссии, AI candidate enumeration и tie
|
||||||
|
selection, live x87 policy, inventory/economy transitions и точная типизация
|
||||||
|
всех 16-byte TMA property values. Контролируемый тест с настоящими
|
||||||
|
`scream.trf` и `12hel1.dat` проверяет разбор и API одного candidate, но не
|
||||||
|
сравнивает результат с native вызовом реального миссионного context.
|
||||||
|
|
||||||
|
Закрывающий эксперимент для оставшихся границ: связать startup owner, research
|
||||||
|
context и AI candidate selection в реальном `Init`; затем сравнить контекст,
|
||||||
|
порядок кандидатов и cost outputs оригинала с Rust на одной миссии. Покупки,
|
||||||
|
inventory transitions и typed property schema требуют отдельных контролируемых
|
||||||
|
исследований.
|
||||||
|
|
||||||
## Rare branches
|
## Rare branches
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,60 @@ VM state;
|
|||||||
`.trf` — NRes tables, чей framing подтверждён, а field semantics местами лишь
|
`.trf` — NRes tables, чей framing подтверждён, а field semantics местами лишь
|
||||||
consumer-inferred.
|
consumer-inferred.
|
||||||
|
|
||||||
|
### Исследовательский профиль TRF и стоимость кандидата
|
||||||
|
|
||||||
|
Для одной ветви `Handler(43)` уже восстановлен путь вычисления стоимости.
|
||||||
|
Результат привязан к GOG `ArealMap.dll`, SHA-256
|
||||||
|
`a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea`,
|
||||||
|
`CalcFullResearchCost` по RVA `0x16280`. Реальный ABI — четыре аргумента
|
||||||
|
stdcall на стеке: `[context, candidate, out1, out2]`; функция снимает с него
|
||||||
|
`0x10` байт и не принимает `candidate` через `ECX`.
|
||||||
|
|
||||||
|
Источник этих таблиц привязан к GOG `MisLoad.dll`, SHA-256
|
||||||
|
`c607762bb67db14ca530927c8e1347339b62b0a163ac90c708ee83142292e2bf`.
|
||||||
|
`FUN_10002fe0` (RVA `0x2fe0`) загружает required TRF chunks. Внутренний
|
||||||
|
research context разрешает ключ через `FUN_10002a40` (RVA `0x2a40`), выдаёт
|
||||||
|
32-byte row record через `FUN_10002aa0` (RVA `0x2aa0`) и возвращает ordered
|
||||||
|
dependency array через `FUN_10002cf0` (RVA `0x2cf0`). Эти методы соответствуют
|
||||||
|
виртуальным слотам context `+0x08`, `+0x0c` и `+0x24`; native Calc получает их
|
||||||
|
через таблицу вызовов, а не напрямую из файлов.
|
||||||
|
|
||||||
|
`ResearchTrfDocument` в `fparkan-assets` повторно использует NRes reader и
|
||||||
|
сохраняет исходный архив. `TRF0.attr1` задаёт число строк по 40 байт; native
|
||||||
|
загрузчик требует `TRF0.attr2 == 3`. `TRF1` задаёт один флаговый byte на строку:
|
||||||
|
Rust воспроизводит native zero-fill при коротком payload и отклоняет более
|
||||||
|
длинный. `TRFB.attr1` задаёт пары little-endian `(u16 name_offset, u16 row)`;
|
||||||
|
имя берётся из NUL-terminated pool `TRF6`. Поиск идёт по исходному порядку и
|
||||||
|
берёт первую ASCII-case-insensitive пару. Связь строки с парой использует
|
||||||
|
только младшие 16 бит сырого слова `TRF0` по смещению `+0x20`.
|
||||||
|
|
||||||
|
Две таблицы зависимостей имеют собственные размеры: `TRF2` считает группы,
|
||||||
|
`TRF3` содержит их плоский упорядоченный поток индексов; `TRF4`/`TRF5` образуют
|
||||||
|
отдельную пару. Только `TRF2`/`TRF3` участвуют в `CalcFullResearchCost`.
|
||||||
|
Отсутствующий `TRF3` или `TRF5` означает пустой поток лишь при нулевой сумме
|
||||||
|
счётчиков соответствующей таблицы. Required `TRF7`–`TRFA` остаются
|
||||||
|
непрозрачными. Reader проверяет фактически читаемые срезы, строки, ссылки и
|
||||||
|
размеры выделений для памяти Rust; это дополнительная безопасность, а не
|
||||||
|
утверждение, что native loader отвергает те же плохие файлы.
|
||||||
|
|
||||||
|
Из флагового byte `TRF1` native provider строит слова record `+0`, `+4` и `+8`
|
||||||
|
из битов `(1, 0, 2)`. `CalcFullResearchCost` использует первые два как raw gate,
|
||||||
|
не присваивая им общего игрового имени: `+0` определяет, добавлять ли строку,
|
||||||
|
`+4` — раскрывать ли её зависимости. Нулевой `kind` компонента Unit DAT
|
||||||
|
отсекает весь узел с потомками. Иначе дерево сканируется в preorder; найденные
|
||||||
|
строки добавляются один раз, затем для каждой прямой строки выполняется
|
||||||
|
упорядоченный DFS по `TRF2`/`TRF3`. Общая таблица посещений завершает циклы и
|
||||||
|
удаляет повторы.
|
||||||
|
|
||||||
|
По debug-строкам `out1` — ore и суммирует `TRF0` word 1, а `out2` — power и
|
||||||
|
суммирует word 0. Native код хранит каждый шаг суммы как `f32`; один cost-вектор
|
||||||
|
`1, 2^24, -2^24` даёт первый результат `0`, а в другом порядке
|
||||||
|
`2^24, -2^24, 1` даёт `1`. Rust core сохраняет тот же порядок накопления. В
|
||||||
|
принятом наборе из 23 синтетических вызовов оригинальных DLL были случаи
|
||||||
|
`CalcFullResearchCost` и два AI-предиката; x87 control word был установлен в
|
||||||
|
`0x007f` для контролируемых сравнений. Набор проверяет выбранные ветви на
|
||||||
|
синтетическом контексте, но не использует реальный контекст миссии.
|
||||||
|
|
||||||
## Безопасная модель исполнения
|
## Безопасная модель исполнения
|
||||||
|
|
||||||
Целевая VM должна разделять immutable package (bytecode, symbols, events,
|
Целевая VM должна разделять immutable package (bytecode, symbols, events,
|
||||||
@@ -285,9 +339,12 @@ TMA properties остаются four raw `u32` words плюс имя, пока c
|
|||||||
сохраняются `NOT USED`; corpus подтверждает `Invulnerability`, life state,
|
сохраняются `NOT USED`; corpus подтверждает `Invulnerability`, life state,
|
||||||
`ClanID`, ore, speed и free-time properties.
|
`ClanID`, ore, speed и free-time properties.
|
||||||
|
|
||||||
Research/economy работают в simulation: `LoadResearch` и
|
Для одного кандидата и одной research-таблицы порядок prerequisite-closure,
|
||||||
`CalcFullResearchCost` доказывают данные и вычислимую стоимость, но не полный
|
дедупликация и две выходные стоимости `CalcFullResearchCost` восстановлены и
|
||||||
layout prerequisites/modifiers/unlocks. General formula evaluator требует
|
проверены отдельно, как описано в
|
||||||
|
[исследовательском профиле TRF](#исследовательский-профиль-trf-и-стоимость-кандидата).
|
||||||
|
Это не закрывает production-modifier/unlock data, экономические переходы или
|
||||||
|
полное исполнение Handler(43). Общий evaluator формул всё ещё требует
|
||||||
versioned grammar, typed operands, deterministic numeric policy, bounded stack
|
versioned grammar, typed operands, deterministic numeric policy, bounded stack
|
||||||
и явных errors; x87-compatible rounding нужен там, где оно выбирает ветку.
|
и явных errors; x87-compatible rounding нужен там, где оно выбирает ветку.
|
||||||
Описанный ниже literal-путь первого `Init` не закрывает общий evaluator.
|
Описанный ниже literal-путь первого `Init` не закрывает общий evaluator.
|
||||||
@@ -448,8 +505,11 @@ Rust сохраняет всю таблицу строк, но вычисляе
|
|||||||
полную загрузку кампании Mission.01 после этих присваиваний; у обычной
|
полную загрузку кампании Mission.01 после этих присваиваний; у обычной
|
||||||
`Single.01` clan 1 после формул остаётся `Handler(43)` в instruction 10, который
|
`Single.01` clan 1 после формул остаётся `Handler(43)` в instruction 10, который
|
||||||
не поддержан и не обходится. Preview оставляет значения этого клана без
|
не поддержан и не обходится. Preview оставляет значения этого клана без
|
||||||
частичных записей, а полный load возвращает ошибку транзакционно. Это не
|
частичных записей, а полный load возвращает ошибку транзакционно. В
|
||||||
подтверждение запуска самой игры или полной VM.
|
`fparkan-assets` теперь отдельно доступны bounded TRF reader и pure cost core
|
||||||
|
для одного Unit DAT, но script/runtime пока не создают нужный research context,
|
||||||
|
не перечисляют AI candidates и не выполняют этот handler. Это не подтверждение
|
||||||
|
запуска самой игры или полной VM.
|
||||||
|
|
||||||
### Первый вызов Behavior и сценарного `Init`
|
### Первый вызов Behavior и сценарного `Init`
|
||||||
|
|
||||||
|
|||||||
@@ -485,16 +485,53 @@ Tag выбирает конкретное правило. Для tag 1 запи
|
|||||||
|
|
||||||
### TRF и preload-данные
|
### TRF и preload-данные
|
||||||
|
|
||||||
TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial
|
TRF-архивы используют несколько потребителей; один подтверждённый путь ведёт
|
||||||
variants имеют сигнатуру [NRes](../reference/nres.md) и содержат большие
|
из `Handler(43)` к вычислению стоимости исследования. В проверенной сборке GOG
|
||||||
таблицы имён игровых прототипов: оружия, башен, сооружений и других объектов.
|
`CalcFullResearchCost` из `ArealMap.dll` получает таблицу, загруженную через
|
||||||
Также найдены preload-записи, ANI и SKE resources.
|
`MisLoad.dll`. Для этого пути `TRF0` содержит строки по десять сырых `u32`,
|
||||||
|
`TRF1` — байты флагов, `TRFB` — пары «смещение имени и номер строки», а
|
||||||
|
`TRF6` — пул строк с нулевыми окончаниями. Слово 8 строки связывает её с
|
||||||
|
`TRFB` через младшие 16 бит; старшая половина сохраняется как неизвестное
|
||||||
|
значение. `TRF2` и `TRF3` образуют упорядоченные списки предварительных
|
||||||
|
исследований, которые читает этот путь стоимости. `TRF4`/`TRF5` образуют
|
||||||
|
отдельную пару таблиц, но `CalcFullResearchCost` её не использует. Непрозрачные
|
||||||
|
`TRF7`–`TRFA` остаются в архиве и требуются загрузчику.
|
||||||
|
|
||||||
По содержимому, порядку загрузки и consumers TRF с высокой вероятностью
|
Модуль `fparkan-assets` разбирает этот профиль в `ResearchTrfDocument` и
|
||||||
предоставляет AI/сценарному слою заранее подготовленную таблицу типов и
|
вычисляет пару результатов для одного Unit DAT:
|
||||||
связанных данных. Framing и имена подтверждены corpus-ом, но полная семантика
|
|
||||||
каждой TRF-записи ещё не закрыта. Имена должны разрешаться через тот же
|
```rust
|
||||||
resource registry, что и миссионные объекты.
|
let table = ResearchTrfDocument::decode(archive_bytes)?;
|
||||||
|
let cost = calc_full_research_cost(&candidate, &table)?;
|
||||||
|
```
|
||||||
|
|
||||||
|
Расчёт проходит компоненты Unit DAT в порядке дерева. Компонент с нулевым
|
||||||
|
`kind` завершает весь свой участок вместе с потомками. Для остальных ключ
|
||||||
|
ресурса ищется среди имён `TRFB`; повтор строки добавляется лишь один раз.
|
||||||
|
После прямых компонентов алгоритм обходит список `TRF2`/`TRF3` в его исходном
|
||||||
|
порядке, углубляясь сразу в найденные предварительные исследования. Общая
|
||||||
|
таблица посещений не даёт повторно добавлять узел и завершает циклы. Два бита
|
||||||
|
флагов `TRF1` управляют включением строки и обходом её зависимостей; остальные
|
||||||
|
сырые биты и слова не получают игровых имён без отдельного доказательства.
|
||||||
|
|
||||||
|
Результат первого native-выхода подписан в отладочной строке как ore и
|
||||||
|
суммирует слово 1 `TRF0`, второй подписан как power и суммирует слово 0. Каждое
|
||||||
|
сложение сохраняется как `f32` в порядке найденной последовательности. В
|
||||||
|
принятом наборе из 23 синтетических тестов оригинальных DLL проверялись
|
||||||
|
`CalcFullResearchCost` и два AI-предиката; именно вызовы `CalcFullResearchCost`
|
||||||
|
подтвердили порядок обхода, дедупликацию, циклы, флаги и отсечение дерева.
|
||||||
|
Две проверки суммы показали чувствительность округления: одна и та же сумма
|
||||||
|
`1, 2^24, -2^24` даёт `0`, а в другом порядке `2^24, -2^24, 1` даёт `1`.
|
||||||
|
|
||||||
|
Эта проверка использовала синтетический исследовательский контекст и заданное
|
||||||
|
для опыта x87-слово управления `0x007f`; она не запускала штатный `Handler(43)`.
|
||||||
|
Локальный Rust-core принимает таблицу и одного Unit DAT, но пока не выбирает
|
||||||
|
кандидата в AI-каталоге и не связывает таблицу с владельцем полного старта
|
||||||
|
миссии. Поэтому `Single.01` всё ещё останавливается на неподдержанном
|
||||||
|
`Handler(43)`. Разбор с настоящими `scream.trf` и `12hel1.dat` проверяет
|
||||||
|
совместимость ресурса с API, но сам по себе не сравнивает стоимость с
|
||||||
|
оригинальным живым контекстом. Проверки границ байтов защищают Rust от плохих
|
||||||
|
входных данных и не приписываются нативному загрузчику.
|
||||||
|
|
||||||
### Стабильность AI-слоя
|
### Стабильность AI-слоя
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user