#[cfg(feature = "vfs")]
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::RwLock;
use gettext::Catalog;
use itertools::Itertools;
use pickled::HashableValue;
use pickled::Value;
use pickled::object::DictObject;
use pickled::value::Shared;
#[cfg(feature = "vfs")]
use tracing::debug;
trait ValueDictExt {
fn dict_or_object_dict(&self) -> Option<Shared<BTreeMap<HashableValue, Value>>>;
}
impl ValueDictExt for Value {
fn dict_or_object_dict(&self) -> Option<Shared<BTreeMap<HashableValue, Value>>> {
match self {
Value::Dict(d) => Some(d.clone()),
Value::Object(o) => {
let inner = o.inner();
let dict_obj = inner.as_any().downcast_ref::<DictObject>()?;
Some(Shared::new(dict_obj.state().clone()))
}
_ => None,
}
}
}
use crate::Rc;
#[cfg(feature = "vfs")]
use crate::data::DataFileWithCallback;
use crate::data::ResourceLoader;
use crate::error::GameDataError;
use crate::game_params::convert::game_params_to_pickle;
use crate::game_types::GameParamId;
use crate::game_types::Vec2;
use crate::game_types::Vec3;
use crate::rpc::entitydefs::EntitySpec;
#[cfg(feature = "vfs")]
use crate::rpc::entitydefs::parse_scripts;
use super::keys;
use super::types::*;
pub struct GameMetadataProvider {
params: GameParams,
param_id_to_translation_id: HashMap<GameParamId, String>,
translations: RwLock<Option<Catalog>>,
specs: Arc<Vec<EntitySpec>>,
}
impl GameParamProvider for GameMetadataProvider {
fn game_param_by_id(&self, id: GameParamId) -> Option<Rc<Param>> {
self.params.game_param_by_id(id)
}
fn game_param_by_index(&self, index: &str) -> Option<Rc<Param>> {
self.params.game_param_by_index(index)
}
fn game_param_by_name(&self, name: &str) -> Option<Rc<Param>> {
self.params.game_param_by_name(name)
}
fn params(&self) -> &[Rc<Param>] {
self.params.params()
}
}
impl ResourceLoader for GameMetadataProvider {
fn localized_name_from_param(&self, param: &Param) -> Option<String> {
self.param_localization_id(param.id()).and_then(|id| self.translate(id))
}
fn localized_name_from_id(&self, id: &str) -> Option<String> {
self.translate(id)
}
fn game_param_by_id(&self, id: GameParamId) -> Option<Rc<Param>> {
self.params.game_param_by_id(id)
}
fn entity_specs(&self) -> &[EntitySpec] {
self.specs.as_slice()
}
}
macro_rules! game_param_to_type {
($params:ident, $key:expr, String) => {
game_param_to_type!($params, $key, string_ref, String).inner().to_string()
};
($params:ident, $key:expr, i8) => {
game_param_to_type!($params, $key, i64) as i8
};
($params:ident, $key:expr, i16) => {
game_param_to_type!($params, $key, i64) as i16
};
($params:ident, $key:expr, i32) => {
game_param_to_type!($params, $key, i64) as i32
};
($params:ident, $key:expr, u8) => {
game_param_to_type!($params, $key, i64) as u8
};
($params:ident, $key:expr, u16) => {
game_param_to_type!($params, $key, i64) as u16
};
($params:ident, $key:expr, u32) => {
game_param_to_type!($params, $key, i64) as u32
};
($params:ident, $key:expr, u64) => {
game_param_to_type!($params, $key, i64) as u64
};
($params:ident, $key:expr, usize) => {
game_param_to_type!($params, $key, i64) as usize
};
($params:ident, $key:expr, isize) => {
game_param_to_type!($params, $key, i64) as isize
};
($params:ident, $key:expr, f32) => {
game_param_to_type!($params, $key, f64) as f32
};
($params:ident, $key:expr, i64) => {
*$params
.get(&HashableValue::String($key.to_string().into()))
.unwrap_or_else(|| panic!("could not get {}", $key))
.i64_ref()
.unwrap_or_else(|| panic!("{} is not an i64", $key))
};
($params:ident, $key:expr, f64) => {
*$params
.get(&HashableValue::String($key.to_string().into()))
.unwrap_or_else(|| panic!("could not get {}", $key))
.f64_ref()
.unwrap_or_else(|| panic!("{} is not a f64", $key))
};
($params:ident, $key:expr, bool) => {
*$params
.get(&HashableValue::String($key.to_string().into()))
.unwrap_or_else(|| panic!("could not get {}", $key))
.bool_ref()
.unwrap_or_else(|| panic!("{} is not a bool", $key))
};
($params:ident, $key:expr, Option<HashMap<(), ()>>) => {
$params
.get(&HashableValue::String($key.to_string().into()))
.filter(|value| !value.is_none())
.map(|_| game_param_to_type!($params, $key, HashMap<(), ()>))
};
($params:ident, $key:expr, Option<$ty:tt>) => {
$params
.get(&HashableValue::String($key.to_string().into()))
.and_then(|value| {
if value.is_none() {
None
} else {
Some(game_param_to_type!($params, $key, $ty))
}
})
};
($params:ident, $key:expr, HashMap<(), ()>) => {
$params
.get(&HashableValue::String($key.to_string().into()))
.unwrap_or_else(|| panic!("could not get {}", $key))
.dict_or_object_dict()
.unwrap_or_else(|| panic!("{} is not a {}", $key, stringify!(HashMap<(), ()>)))
};
($params:ident, $key:expr, &[()]) => {
game_param_to_type!($params, $key, list_ref, &[()])
};
($args:ident, $key:expr, $conversion_func:ident, $ty:ty) => {
$args
.get(&HashableValue::String($key.to_string().into()))
.unwrap_or_else(|| panic!("could not get {}", $key))
.$conversion_func()
.unwrap_or_else(|| panic!("{} is not a {}", $key, stringify!($ty)))
};
}
fn build_skill_modifiers(modifiers: &BTreeMap<HashableValue, Value>) -> Vec<CrewSkillModifier> {
let excluded_consumables: Vec<String> = modifiers
.get(&HashableValue::String("excludedConsumables".to_owned().into()))
.and_then(|v| v.list_ref())
.map(|list| list.inner().iter().filter_map(|item| item.string_ref().map(|s| s.inner().to_owned())).collect())
.unwrap_or_default();
modifiers
.iter()
.filter_map(|(modifier_name, modifier_data)| {
let modifier_name = modifier_name.string_ref().expect("modifier name is not a string").to_owned();
let modifier_name = modifier_name.inner();
if modifier_name == "excludedConsumables" {
return None;
}
let mk_uniform = |v: f32| {
CrewSkillModifier::builder()
.name(modifier_name.to_owned())
.aircraft_carrier(v)
.auxiliary(v)
.battleship(v)
.cruiser(v)
.destroyer(v)
.submarine(v)
.excluded_consumables(excluded_consumables.clone())
.build()
};
if let Some(common_value) = modifier_data.i64_ref().cloned() {
Some(mk_uniform(common_value as f32))
} else if let Some(common_value) = modifier_data.f64_ref().cloned() {
Some(mk_uniform(common_value as f32))
} else if let Some(modifier_data) = modifier_data.dict_or_object_dict() {
let modifier_data = modifier_data.inner();
modifier_data.get(&HashableValue::String("AirCarrier".to_owned().into()))?;
let read_species = |key: &str| -> f32 {
modifier_data
.get(&HashableValue::String(key.to_owned().into()))
.and_then(|v| v.f64_ref().map(|f| *f as f32).or_else(|| v.i64_ref().map(|i| *i as f32)))
.unwrap_or(1.0)
};
Some(
CrewSkillModifier::builder()
.name(modifier_name.to_owned())
.aircraft_carrier(read_species("AirCarrier"))
.auxiliary(read_species("Auxiliary"))
.battleship(read_species("Battleship"))
.cruiser(read_species("Cruiser"))
.destroyer(read_species("Destroyer"))
.submarine(read_species("Submarine"))
.excluded_consumables(excluded_consumables.clone())
.build(),
)
} else {
None
}
})
.collect()
}
const SKILL_STRUCTURAL_KEYS: &[&str] = &[
"column",
"skillType",
"tier",
"turnOffOnRetraining",
"modifiers",
"LogicTrigger",
"canBeLearned",
"isEpic",
"uiTreatAsTrigger",
];
fn build_crew_skills(skills: &BTreeMap<HashableValue, Value>) -> Vec<CrewSkill> {
skills
.iter()
.filter_map(|(hashable_skill_name, skill_data)| {
let skill_name = hashable_skill_name.string_ref().expect("hashable_skill_name is not a String").to_owned();
let skill_name = skill_name.inner();
if skill_data.is_none() {
return None;
}
let skill_data = skill_data.dict_or_object_dict().expect("skill data is not dictionary");
let skill_data = skill_data.inner();
let logic_trigger_data = game_param_to_type!(skill_data, "LogicTrigger", Option<HashMap<(), ()>>);
let logic_trigger = logic_trigger_data.map(|logic_trigger_data| {
let logic_trigger_data = logic_trigger_data.inner();
let trigger_modifiers = game_param_to_type!(logic_trigger_data, "modifiers", Option<HashMap<(), ()>>)
.map(|m| build_skill_modifiers(&m.inner()));
CrewSkillLogicTrigger::builder()
.maybe_burn_count(game_param_to_type!(logic_trigger_data, "burnCount", Option<usize>))
.maybe_change_priority_target_penalty(game_param_to_type!(
logic_trigger_data,
"changePriorityTargetPenalty",
Option<f32>
))
.consumable_type(game_param_to_type!(logic_trigger_data, "consumableType", String))
.cooling_delay(game_param_to_type!(logic_trigger_data, "coolingDelay", f32))
.cooling_interpolator(Vec::default())
.maybe_divider_type(game_param_to_type!(logic_trigger_data, "dividerType", Option<String>))
.maybe_divider_value(game_param_to_type!(logic_trigger_data, "dividerValue", Option<f32>))
.duration(game_param_to_type!(logic_trigger_data, "duration", f32))
.energy_coeff(game_param_to_type!(logic_trigger_data, "energyCoeff", f32))
.maybe_flood_count(game_param_to_type!(logic_trigger_data, "floodCount", Option<usize>))
.maybe_health_factor(game_param_to_type!(logic_trigger_data, "healthFactor", Option<f32>))
.heat_interpolator(Vec::default())
.maybe_modifiers(trigger_modifiers)
.trigger_desc_ids(game_param_to_type!(logic_trigger_data, "triggerDescIds", String))
.trigger_type(game_param_to_type!(logic_trigger_data, "triggerType", String))
.build()
});
let modifiers = match skill_data.get(&pk("modifiers")).and_then(|v| v.dict_or_object_dict()) {
Some(modifiers) => Some(build_skill_modifiers(&modifiers.inner())),
None => {
let flat: BTreeMap<HashableValue, Value> = skill_data
.iter()
.filter(|(key, _)| {
key.string_ref()
.map(|s| !SKILL_STRUCTURAL_KEYS.contains(&s.inner().as_str()))
.unwrap_or(false)
})
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
(!flat.is_empty()).then(|| build_skill_modifiers(&flat))
}
};
let tier = match skill_data.get(&pk("tier")).and_then(|v| v.dict_or_object_dict()) {
Some(tier_data) => {
let tier_data = tier_data.inner();
CrewSkillTiers::builder()
.aircraft_carrier(
SkillPointCost::new(game_param_to_type!(tier_data, "AirCarrier", usize) as u8),
)
.auxiliary(SkillPointCost::new(game_param_to_type!(tier_data, "Auxiliary", usize) as u8))
.battleship(SkillPointCost::new(game_param_to_type!(tier_data, "Battleship", usize) as u8))
.cruiser(SkillPointCost::new(game_param_to_type!(tier_data, "Cruiser", usize) as u8))
.destroyer(SkillPointCost::new(game_param_to_type!(tier_data, "Destroyer", usize) as u8))
.submarine(SkillPointCost::new(game_param_to_type!(tier_data, "Submarine", usize) as u8))
.build()
}
None => {
let t =
skill_data.get(&pk("tier")).and_then(|v| v.i64_ref()).map(|&v| v as usize).unwrap_or_default();
let cost = SkillPointCost::new(t as u8);
CrewSkillTiers::builder()
.aircraft_carrier(cost)
.auxiliary(cost)
.battleship(cost)
.cruiser(cost)
.destroyer(cost)
.submarine(cost)
.build()
}
};
Some(
CrewSkill::builder()
.internal_name(CrewSkillName::from(skill_name.to_owned()))
.can_be_learned(game_param_to_type!(skill_data, "canBeLearned", Option<bool>).unwrap_or_default())
.is_epic(game_param_to_type!(skill_data, "isEpic", Option<bool>).unwrap_or_default())
.skill_type(CrewSkillType::new(
game_param_to_type!(skill_data, "skillType", Option<usize>).unwrap_or_default() as u32,
))
.ui_treat_as_trigger(
game_param_to_type!(skill_data, "uiTreatAsTrigger", Option<bool>).unwrap_or_default(),
)
.tier(tier)
.maybe_modifiers(modifiers)
.maybe_logic_trigger(logic_trigger)
.build(),
)
})
.collect()
}
fn string_list_field(dict: &BTreeMap<HashableValue, Value>, key: &str) -> Vec<String> {
dict.get(&HashableValue::String(key.to_owned().into()))
.and_then(|value| value.list_ref())
.map(|list| list.inner().iter().filter_map(|v| v.string_ref().map(|s| s.inner().to_owned())).collect())
.unwrap_or_default()
}
fn build_crew_personality(personality: &BTreeMap<HashableValue, Value>) -> CrewPersonality {
let ships = game_param_to_type!(personality, "ships", HashMap<(), ()>);
let ships = ships.inner();
let ships = CrewPersonalityShips::builder()
.groups(string_list_field(&ships, "groups"))
.nation(string_list_field(&ships, "nation"))
.peculiarity(string_list_field(&ships, "peculiarity"))
.ships(string_list_field(&ships, "ships"))
.build();
CrewPersonality::builder()
.can_reset_skills_for_free(
game_param_to_type!(personality, "canResetSkillsForFree", Option<bool>).unwrap_or_default(),
)
.cost_credits(game_param_to_type!(personality, "costCR", Option<usize>).unwrap_or_default())
.cost_elite_xp(game_param_to_type!(personality, "costELXP", Option<usize>).unwrap_or_default())
.cost_gold(game_param_to_type!(personality, "costGold", Option<usize>).unwrap_or_default())
.cost_xp(game_param_to_type!(personality, "costXP", Option<usize>).unwrap_or_default())
.maybe_has_custom_background(personality.get(&pk("hasCustomBackground")).and_then(|v| v.bool_ref().copied()))
.maybe_has_overlay(personality.get(&pk("hasOverlay")).and_then(|v| v.bool_ref().copied()))
.maybe_has_rank(personality.get(&pk("hasRank")).and_then(|v| v.bool_ref().copied()))
.maybe_has_sample_voiceover(personality.get(&pk("hasSampleVO")).and_then(|v| v.bool_ref().copied()))
.maybe_is_animated(personality.get(&pk("isAnimated")).and_then(|v| v.bool_ref().copied()))
.maybe_is_person(personality.get(&pk("isPerson")).and_then(|v| v.bool_ref().copied()))
.maybe_is_retrainable(personality.get(&pk("isRetrainable")).and_then(|v| v.bool_ref().copied()))
.maybe_is_unique(personality.get(&pk("isUnique")).and_then(|v| v.bool_ref().copied()))
.maybe_peculiarity(
personality.get(&pk("peculiarity")).and_then(|v| v.string_ref()).map(|s| s.inner().to_string()),
)
.maybe_permissions(personality.get(&pk("permissions")).and_then(|v| v.i64_ref()).map(|&v| v as u32))
.person_name(game_param_to_type!(personality, "personName", Option<String>).unwrap_or_default())
.maybe_subnation(personality.get(&pk("subnation")).and_then(|v| v.string_ref()).map(|s| s.inner().to_string()))
.tags(
personality
.get(&pk("tags"))
.and_then(|v| v.list_ref())
.map(|list| {
list.inner().iter().filter_map(|value| value.string_ref().map(|s| s.inner().to_owned())).collect()
})
.unwrap_or_default(),
)
.ships(ships)
.build()
}
fn build_ability_category(category_data: &BTreeMap<HashableValue, Value>) -> AbilityCategory {
let reload_time =
if let Some(reload_time) = category_data.get(&HashableValue::String("reloadTime".to_owned().into())) {
if let Some(reload_time) = reload_time.i64_ref() {
*reload_time as f32
} else {
*reload_time.f64_ref().expect("workTime is not a f64") as f32
}
} else {
panic!("could not get reloadTime");
};
let work_time = if let Some(work_time) = category_data.get(&HashableValue::String("workTime".to_owned().into())) {
if let Some(work_time) = work_time.i64_ref() {
*work_time as f32
} else {
*work_time.f64_ref().expect("workTime is not a f64") as f32
}
} else {
panic!("could not get reloadTime");
};
let logic = category_data.get(&pk("logic")).and_then(|v| v.dict_or_object_dict());
let read_f32 = |key: &str| -> Option<f32> {
let hk = pk(key);
let conv = |v: &Value| v.f64_ref().map(|f| *f as f32).or_else(|| v.i64_ref().map(|i| *i as f32));
logic.as_ref().and_then(|l| l.inner().get(&hk).and_then(conv)).or_else(|| category_data.get(&hk).and_then(conv))
};
let dist_ship = read_f32("distShip").map(BigWorldDistance::from);
let dist_torpedo = read_f32("distTorpedo").map(BigWorldDistance::from);
let hydrophone_wave_radius = read_f32("hydrophoneWaveRadius").map(Meters::from);
let patrol_radius = read_f32("radius").map(BigWorldDistance::from);
let num = |v: &Value| v.f64_ref().map(|f| *f as f32).or_else(|| v.i64_ref().map(|i| *i as f32));
let mut effect_fields: BTreeMap<String, f32> = BTreeMap::new();
for (key, value) in category_data.iter() {
if let (Some(name), Some(n)) = (key.string_ref(), num(value)) {
effect_fields.insert(name.inner().to_owned(), n);
}
}
if let Some(logic) = logic.as_ref() {
for (key, value) in logic.inner().iter() {
if let (Some(name), Some(n)) = (key.string_ref(), num(value)) {
effect_fields.insert(name.inner().to_owned(), n);
}
}
}
let tactical = category_data.get(&pk("tacticalParams")).and_then(|v| v.dict_or_object_dict());
if let Some(tactical) = tactical.as_ref() {
for (key, value) in tactical.inner().iter() {
if let (Some(name), Some(n)) = (key.string_ref(), num(value)) {
effect_fields.insert(name.inner().to_owned(), n);
}
}
}
if let Some(logic) = logic.as_ref()
&& let Some(modifiers) = logic.inner().get(&pk("modifiers")).and_then(|v| v.dict_or_object_dict())
{
for (key, value) in modifiers.inner().iter() {
if let (Some(name), Some(n)) = (key.string_ref(), num(value)) {
effect_fields.insert(name.inner().to_owned(), n);
}
}
}
AbilityCategory::builder()
.effect_fields(effect_fields)
.maybe_special_sound_id(game_param_to_type!(category_data, "SpecialSoundID", Option<String>))
.consumable_type(game_param_to_type!(category_data, "consumableType", Option<String>).unwrap_or_default())
.group(game_param_to_type!(category_data, "group", Option<String>).unwrap_or_default())
.icon_id(game_param_to_type!(category_data, "iconIDs", Option<String>).unwrap_or_default())
.num_consumables(game_param_to_type!(category_data, "numConsumables", Option<isize>).unwrap_or_default())
.preparation_time(game_param_to_type!(category_data, "preparationTime", Option<f32>).unwrap_or_default())
.reload_time(reload_time)
.work_time(work_time)
.maybe_dist_ship(dist_ship)
.maybe_dist_torpedo(dist_torpedo)
.maybe_hydrophone_wave_radius(hydrophone_wave_radius)
.maybe_patrol_radius(patrol_radius)
.maybe_regeneration_hp_speed(read_f32("regenerationHPSpeed"))
.maybe_regeneration_hp_speed_units(read_f32("regenerationHPSpeedUnits"))
.build()
}
fn build_ability(ability_data: &BTreeMap<HashableValue, Value>) -> Ability {
let test_key = HashableValue::String("numConsumables".to_string().into());
let categories: HashMap<String, AbilityCategory> =
HashMap::from_iter(ability_data.iter().filter_map(|(key, value)| {
let dict = value.dict_or_object_dict()?;
let inner = dict.inner();
if inner.contains_key(&test_key) {
Some((key.string_ref().unwrap().inner().to_owned(), build_ability_category(&inner)))
} else {
None
}
}));
Ability::builder()
.can_buy(game_param_to_type!(ability_data, "canBuy", bool))
.cost_credits(game_param_to_type!(ability_data, "costCR", isize))
.cost_gold(game_param_to_type!(ability_data, "costGold", isize))
.is_free(game_param_to_type!(ability_data, "freeOfCharge", bool))
.categories(categories)
.build()
}
fn pk(key: &str) -> HashableValue {
HashableValue::String(key.to_string().into())
}
fn read_float(dict: &BTreeMap<HashableValue, Value>, key: &str) -> Option<f32> {
dict.get(&pk(key)).and_then(|v| v.f64_ref().map(|f| *f as f32).or_else(|| v.i64_ref().map(|i| *i as f32)))
}
fn read_first_string(val: &Value) -> Option<String> {
let list = val.list_ref()?;
list.inner().first().and_then(|v| v.string_ref().map(|s| s.inner().clone()))
}
fn read_all_strings(val: &Value) -> Vec<String> {
let Some(list) = val.list_ref() else {
return Vec::new();
};
list.inner().iter().filter_map(|v| v.string_ref().map(|s| s.inner().clone())).collect()
}
fn read_string(dict: &BTreeMap<HashableValue, Value>, key: &str) -> Option<String> {
dict.get(&pk(key)).and_then(|v| v.string_ref()).map(|s| s.inner().to_string())
}
fn value_f32(v: &Value) -> Option<f32> {
v.f64_ref().map(|f| *f as f32).or_else(|| v.i64_ref().map(|i| *i as f32))
}
fn read_first_float(v: &Value) -> Option<f32> {
if let Some(l) = v.list_ref() {
l.inner().first().and_then(value_f32)
} else if let Some(t) = v.tuple_ref() {
t.inner().first().and_then(value_f32)
} else {
None
}
}
fn read_pair_both(v: &Value) -> Option<[f32; 2]> {
if let Some(l) = v.list_ref() {
let g = l.inner();
Some([value_f32(g.first()?)?, value_f32(g.get(1)?)?])
} else if let Some(t) = v.tuple_ref() {
let s = t.inner();
Some([value_f32(s.first()?)?, value_f32(s.get(1)?)?])
} else {
None
}
}
fn read_vec3_at(v: &Value, idx: usize) -> Option<[f32; 3]> {
fn triple(elem: &Value) -> Option<[f32; 3]> {
if let Some(l) = elem.list_ref() {
let g = l.inner();
if g.len() != 3 {
return None;
}
Some([value_f32(&g[0])?, value_f32(&g[1])?, value_f32(&g[2])?])
} else if let Some(t) = elem.tuple_ref() {
let s = t.inner();
if s.len() != 3 {
return None;
}
Some([value_f32(&s[0])?, value_f32(&s[1])?, value_f32(&s[2])?])
} else {
None
}
}
if let Some(l) = v.list_ref() {
triple(l.inner().get(idx)?)
} else if let Some(t) = v.tuple_ref() {
triple(t.inner().get(idx)?)
} else {
None
}
}
fn read_flood_prob(hull_data: &BTreeMap<HashableValue, Value>) -> Option<f32> {
use crate::game_params::ttx::constants::DEFAULT_UW_DAMAGE_COEFF;
let node0 = read_vec3_at(hull_data.get(&pk(keys::FLOOD_NODES))?, 0)?[0];
if node0 == DEFAULT_UW_DAMAGE_COEFF {
Some(0.0)
} else {
Some((DEFAULT_UW_DAMAGE_COEFF - node0) / DEFAULT_UW_DAMAGE_COEFF)
}
}
fn read_submarine_battery(hull_data: &BTreeMap<HashableValue, Value>) -> (Option<f32>, Option<f32>) {
let Some(battery) = hull_data.get(&pk(keys::SUBMARINE_BATTERY)).and_then(|v| v.dict_or_object_dict()) else {
return (None, None);
};
let battery = battery.inner();
(read_float(&battery, keys::BATTERY_CAPACITY), read_float(&battery, keys::BATTERY_REGEN_RATE))
}
fn read_visibility_by_periscope(hull_data: &BTreeMap<HashableValue, Value>) -> Option<f32> {
let dict = hull_data.get(&pk(keys::VISIBILITY_FACTORS_BY_SUBMARINE)).and_then(|v| v.dict_or_object_dict())?;
let dict = dict.inner();
read_float(&dict, keys::VISIBILITY_PERISCOPE)
}
fn read_ignore_height(v: Option<&Value>) -> bool {
v.map(|v| if let Some(b) = v.bool_ref() { *b } else { v.i64_ref().map(|i| *i != 0).unwrap_or(false) })
.unwrap_or(false)
}
fn read_trajectory_geometry(traj: &Value) -> Option<TrajectoryGeometry> {
let dict = traj.dict_or_object_dict()?;
let guard = dict.inner();
let pc_val = guard.get(&pk("posCenter"));
let pc0 = pc_val.and_then(|v| read_vec3_at(v, 0))?;
let pc1 = pc_val.and_then(|v| read_vec3_at(v, 1))?;
let h = guard.get(&pk("semiAxisH")).and_then(read_pair_both)?;
let v = guard.get(&pk("semiAxisV")).and_then(read_pair_both)?;
Some(TrajectoryGeometry {
pos_center: [Vec3::new(pc0[0], pc0[1], pc0[2]), Vec3::new(pc1[0], pc1[1], pc1[2])],
semi_axes: [Vec2::new(h[0], v[0]), Vec2::new(h[1], v[1])],
ignore_height_multiplier: read_ignore_height(guard.get(&pk("ignoreHeightMultiplier"))),
})
}
pub fn read_camera_trajectories(ship_data: &BTreeMap<HashableValue, Value>) -> Vec<(String, CameraTrajectory)> {
let Some(cameras) = ship_data.get(&pk("Cameras")).and_then(|v| v.dict_or_object_dict()) else {
return Vec::new();
};
let mut out: Vec<(String, CameraTrajectory)> = Vec::new();
let cameras_guard = cameras.inner();
for (key, val) in cameras_guard.iter() {
let Some(name) = key.string_ref().map(|s| s.inner().to_string()) else {
continue;
};
let Some(mode) = val.dict_or_object_dict() else {
continue;
};
let mode_guard = mode.inner();
let Some(inner) = mode_guard.get(&pk("InnerTrajectory")).and_then(read_trajectory_geometry) else {
continue;
};
let tags =
mode_guard.get(&pk("tags")).and_then(|v| v.string_ref()).map(|s| s.inner().to_string()).unwrap_or_default();
let outer = mode_guard.get(&pk("OuterTrajectory")).and_then(read_trajectory_geometry);
out.push((
name,
CameraTrajectory {
pos_center: inner.pos_center,
semi_axes: inner.semi_axes,
tags,
ignore_height_multiplier: inner.ignore_height_multiplier,
outer,
},
));
}
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
fn parse_pitch_dead_zones(mount_dict: &BTreeMap<HashableValue, Value>) -> Vec<[f32; 4]> {
fn as_f32(v: &Value) -> Option<f32> {
v.f64_ref().map(|f| *f as f32).or_else(|| v.i64_ref().map(|i| *i as f32))
}
fn extract_entries(items: &[Value]) -> Vec<[f32; 4]> {
items
.iter()
.filter_map(|entry| {
let inner: Vec<f32> = if let Some(l) = entry.list_ref() {
l.inner().iter().filter_map(as_f32).collect()
} else if let Some(t) = entry.tuple_ref() {
t.inner().iter().filter_map(as_f32).collect()
} else {
return None;
};
if inner.len() == 4 { Some([inner[0], inner[1], inner[2], inner[3]]) } else { None }
})
.collect()
}
let Some(val) = mount_dict.get(&pk("pitchDeadZones")) else {
return Vec::new();
};
if let Some(l) = val.list_ref() {
extract_entries(&l.inner())
} else if let Some(t) = val.tuple_ref() {
extract_entries(t.inner())
} else {
Vec::new()
}
}
fn parse_armor_dict(dict: &BTreeMap<HashableValue, Value>) -> ArmorMap {
use std::collections::BTreeMap;
let mut by_material: HashMap<u32, BTreeMap<u32, f32>> = HashMap::new();
for (k, v) in dict.iter() {
let raw_key: u32 =
match k.string_ref().and_then(|s| s.inner().parse().ok()).or_else(|| k.i64_ref().map(|&i| i as u32)) {
Some(k) => k,
None => continue,
};
let value = match v.f64_ref().map(|f| *f as f32).or_else(|| v.i64_ref().map(|i| *i as f32)) {
Some(v) => v,
None => continue,
};
let model_index = raw_key >> 16;
let material_id = raw_key & 0xFFFF;
by_material.entry(material_id).or_default().insert(model_index, value);
}
by_material
}
fn extract_mounts(ship_data: &BTreeMap<HashableValue, Value>, component_name: &str) -> Vec<MountPoint> {
let Some(comp_data) = ship_data.get(&pk(component_name)).and_then(|v| v.dict_or_object_dict()) else {
return Vec::new();
};
comp_data
.inner()
.iter()
.filter_map(|(k, v)| {
let key_str = k.string_ref()?.inner();
if !key_str.starts_with(keys::HP_PREFIX) {
return None;
}
let mount_dict = v.dict_or_object_dict()?;
let mount_inner = mount_dict.inner();
let model_path = read_string(&mount_inner, keys::MODEL)?;
let mount_armor: Option<ArmorMap> = mount_inner
.get(&pk(keys::ARMOR))
.and_then(|v| v.dict_or_object_dict())
.map(|d| parse_armor_dict(&d.inner()));
let species = mount_inner
.get(&pk("typeinfo"))
.and_then(|v| v.dict_or_object_dict())
.and_then(|d| read_string(&d.inner(), "species"))
.and_then(|s| MountSpecies::from_gp_str(&s));
let pitch_dead_zones: Vec<[f32; 4]> = parse_pitch_dead_zones(&mount_inner);
Some(MountPoint::with_armor(key_str.clone(), model_path, mount_armor, species, pitch_dead_zones))
})
.collect()
}
fn hardpoint_number(key: &str) -> u32 {
let digits: String = key.chars().rev().take_while(|c| c.is_ascii_digit()).collect::<String>();
if digits.is_empty() {
return u32::MAX;
}
let reversed: String = digits.chars().rev().collect();
reversed.parse().unwrap_or(u32::MAX)
}
fn build_ship(ship_data: &BTreeMap<HashableValue, Value>) -> Vehicle {
let ability_data = game_param_to_type!(ship_data, keys::SHIP_ABILITIES, Option<HashMap<(), ()>>);
let abilities: Option<Vec<Vec<(String, String)>>> = ability_data.map(|abilities_data| {
abilities_data
.inner()
.iter()
.filter_map(|(slot_name, slot_data)| {
let _slot_name = slot_name.string_ref().expect("ship ability slot name is not a string");
if slot_data.is_none() {
return None;
}
let slot_data = slot_data.dict_or_object_dict().expect("slot data is not a dictionary");
let slot_data = slot_data.inner();
let slot = game_param_to_type!(slot_data, "slot", usize);
let abils = game_param_to_type!(slot_data, "abils", &[()]).inner();
let abils: Vec<(String, String)> = abils
.iter()
.map(|abil| {
let map_abil = |abil: &Vec<Value>| {
(
abil[0].string_ref().expect("abil[0] is not a string").inner().clone(),
abil[1].string_ref().expect("abil[1] is not a string").inner().clone(),
)
};
match abil {
Value::Tuple(inner) => map_abil(inner.inner()),
Value::List(inner) => map_abil(&inner.inner()),
_ => panic!("abil is not a list/tuple"),
}
})
.collect();
Some((slot, abils))
})
.sorted_by(|a, b| a.0.cmp(&b.0))
.map(|abil| abil.1)
.collect()
});
let upgrade_data = game_param_to_type!(ship_data, keys::SHIP_UPGRADE_INFO, HashMap<(), ()>);
let upgrades: Vec<String> = upgrade_data
.inner()
.keys()
.map(|upgrade_name| upgrade_name.string_ref().expect("upgrade name is not a string").inner().to_owned())
.collect();
let level = game_param_to_type!(ship_data, "level", u32);
let group = game_param_to_type!(ship_data, "group", String);
let mut hull_upgrades = HashMap::new();
let mut ttx_components = crate::game_params::ttx::components::ShipTtxComponents::default();
for (upgrade_name_val, upgrade_value) in upgrade_data.inner().iter() {
let Some(upgrade_name) = upgrade_name_val.string_ref().map(|s| s.inner().clone()) else {
continue;
};
let Some(upgrade_dict) = upgrade_value.dict_or_object_dict() else {
continue;
};
let upgrade_dict = upgrade_dict.inner();
let Some(uc_type) =
upgrade_dict.get(&pk(keys::UC_TYPE)).and_then(|v| v.string_ref().map(|s| s.inner().clone()))
else {
continue;
};
let is_stock = match upgrade_dict.get(&pk(keys::PREV)) {
Some(v) => v.string_ref().map(|s| s.inner().is_empty()).unwrap_or(true),
None => true,
};
if is_stock {
let slot = match uc_type.as_str() {
keys::UC_TYPE_HULL => Some(&mut ttx_components.stock_selection.hull),
keys::UC_TYPE_ENGINE => Some(&mut ttx_components.stock_selection.engine),
keys::UC_TYPE_ARTILLERY => Some(&mut ttx_components.stock_selection.artillery),
keys::UC_TYPE_TORPEDOES => Some(&mut ttx_components.stock_selection.torpedoes),
keys::UC_TYPE_SUO => Some(&mut ttx_components.stock_selection.fire_control),
_ => None,
};
if let Some(slot) = slot {
slot.get_or_insert_with(|| upgrade_name.clone());
}
}
if uc_type == keys::UC_TYPE_ENGINE
&& let Some(eng_comp) = upgrade_dict
.get(&pk(keys::COMPONENTS))
.and_then(|v| v.dict_or_object_dict())
.and_then(|c| c.inner().get(&pk(keys::COMP_ENGINE)).and_then(read_first_string))
&& let Some(eng_data) = ship_data.get(&pk(&eng_comp)).and_then(|v| v.dict_or_object_dict())
{
ttx_components.engines.insert(
upgrade_name.clone(),
crate::game_params::ttx::components::EngineComponentStats {
speed_coef: read_float(&eng_data.inner(), keys::SPEED_COEF),
},
);
}
if uc_type == keys::UC_TYPE_TORPEDOES
&& let Some(torp_comp) = upgrade_dict
.get(&pk(keys::COMPONENTS))
.and_then(|v| v.dict_or_object_dict())
.and_then(|c| c.inner().get(&pk(keys::COMP_TORPEDOES)).and_then(read_first_string))
&& let Some(torp_data) = ship_data.get(&pk(&torp_comp)).and_then(|v| v.dict_or_object_dict())
{
let mut launchers = Vec::new();
for (key, val) in torp_data.inner().iter() {
let is_launcher = key.string_ref().is_some_and(|s| keys::is_torpedo_hardpoint(s.inner()));
if !is_launcher {
continue;
}
let Some(gun) = val.dict_or_object_dict() else {
continue;
};
let gun = gun.inner();
let ammo = match gun.get(&pk(keys::AMMO_LIST)) {
Some(Value::Tuple(t)) => {
t.inner().iter().filter_map(|v| v.string_ref().map(|s| s.inner().clone())).collect()
}
Some(val) => read_all_strings(val),
None => Vec::new(),
};
launchers.push(crate::game_params::ttx::components::TorpedoLauncherStats {
shot_delay: read_float(&gun, keys::SHOT_DELAY).map(crate::game_params::ttx::model::Seconds::from),
rotation_speed: gun
.get(&pk(keys::ROTATION_SPEED))
.and_then(read_first_float)
.map(crate::game_params::ttx::model::DegreesPerSecond::from),
num_barrels: read_float(&gun, keys::NUM_BARRELS),
ammo_switch_coeff: read_float(&gun, keys::AMMO_SWITCH_COEFF),
ammo,
});
}
if !launchers.is_empty() {
ttx_components.torpedoes.insert(upgrade_name.clone(), launchers);
}
}
if uc_type == keys::UC_TYPE_ARTILLERY
&& let Some(arty_comp) = upgrade_dict
.get(&pk(keys::COMPONENTS))
.and_then(|v| v.dict_or_object_dict())
.and_then(|c| c.inner().get(&pk(keys::COMP_ARTILLERY)).and_then(read_first_string))
&& let Some(arty_data) = ship_data.get(&pk(&arty_comp)).and_then(|v| v.dict_or_object_dict())
{
let arty_data = arty_data.inner();
let mut guns = Vec::new();
for (key, val) in arty_data.iter() {
let is_gun = key.string_ref().is_some_and(|s| keys::is_main_gun_hardpoint(s.inner()));
if !is_gun {
continue;
}
let Some(gun) = val.dict_or_object_dict() else {
continue;
};
let gun = gun.inner();
let ammo = match gun.get(&pk(keys::AMMO_LIST)) {
Some(Value::Tuple(t)) => {
t.inner().iter().filter_map(|v| v.string_ref().map(|s| s.inner().clone())).collect()
}
Some(val) => read_all_strings(val),
None => Vec::new(),
};
guns.push(crate::game_params::ttx::components::ArtilleryGunStats {
shot_delay: read_float(&gun, keys::SHOT_DELAY).map(crate::game_params::ttx::model::Seconds::from),
rotation_speed: gun
.get(&pk(keys::ROTATION_SPEED))
.and_then(read_first_float)
.map(crate::game_params::ttx::model::DegreesPerSecond::from),
num_barrels: read_float(&gun, keys::NUM_BARRELS),
barrel_diameter: read_float(&gun, keys::BARREL_DIAMETER).map(Meters::from),
ammo_switch_coeff: read_float(&gun, keys::AMMO_SWITCH_COEFF),
min_radius: read_float(&gun, keys::MIN_RADIUS),
ideal_radius: read_float(&gun, keys::IDEAL_RADIUS),
ideal_distance: read_float(&gun, keys::IDEAL_DISTANCE),
ammo,
});
}
if !guns.is_empty() {
ttx_components.artillery.insert(
upgrade_name.clone(),
crate::game_params::ttx::components::ArtilleryComponentStats {
max_dist: read_float(&arty_data, keys::MAX_DIST).map(Meters::from),
guns,
},
);
}
}
if uc_type == keys::UC_TYPE_HULL
&& let Some(atba_comp) = upgrade_dict
.get(&pk(keys::COMPONENTS))
.and_then(|v| v.dict_or_object_dict())
.and_then(|c| c.inner().get(&pk(keys::COMP_ATBA)).and_then(read_first_string))
&& let Some(atba_data) = ship_data.get(&pk(&atba_comp)).and_then(|v| v.dict_or_object_dict())
{
let atba_data = atba_data.inner();
let mut guns = Vec::new();
for (key, val) in atba_data.iter() {
let is_gun = key.string_ref().is_some_and(|s| keys::is_secondary_gun_hardpoint(s.inner()));
if !is_gun {
continue;
}
let Some(gun) = val.dict_or_object_dict() else {
continue;
};
let gun = gun.inner();
let ammo = match gun.get(&pk(keys::AMMO_LIST)) {
Some(Value::Tuple(t)) => {
t.inner().iter().filter_map(|v| v.string_ref().map(|s| s.inner().clone())).collect()
}
Some(val) => read_all_strings(val),
None => Vec::new(),
};
guns.push(crate::game_params::ttx::components::ArtilleryGunStats {
shot_delay: read_float(&gun, keys::SHOT_DELAY).map(crate::game_params::ttx::model::Seconds::from),
rotation_speed: gun
.get(&pk(keys::ROTATION_SPEED))
.and_then(read_first_float)
.map(crate::game_params::ttx::model::DegreesPerSecond::from),
num_barrels: read_float(&gun, keys::NUM_BARRELS),
barrel_diameter: read_float(&gun, keys::BARREL_DIAMETER).map(Meters::from),
ammo_switch_coeff: read_float(&gun, keys::AMMO_SWITCH_COEFF),
min_radius: read_float(&gun, keys::MIN_RADIUS),
ideal_radius: read_float(&gun, keys::IDEAL_RADIUS),
ideal_distance: read_float(&gun, keys::IDEAL_DISTANCE),
ammo,
});
}
if !guns.is_empty() {
ttx_components.secondaries.insert(
upgrade_name.clone(),
crate::game_params::ttx::components::SecondaryComponentStats {
max_dist: read_float(&atba_data, keys::MAX_DIST).map(Meters::from),
guns,
},
);
}
}
if uc_type == keys::UC_TYPE_SUO
&& let Some(fc_comp) = upgrade_dict
.get(&pk(keys::COMPONENTS))
.and_then(|v| v.dict_or_object_dict())
.and_then(|c| c.inner().get(&pk(keys::COMP_FIRE_CONTROL)).and_then(read_first_string))
&& let Some(fc_data) = ship_data.get(&pk(&fc_comp)).and_then(|v| v.dict_or_object_dict())
&& let Some(coef) = read_float(&fc_data.inner(), keys::MAX_DIST_COEF)
{
ttx_components.fire_controls.insert(upgrade_name.clone(), coef);
}
if uc_type != keys::UC_TYPE_HULL {
continue;
}
let Some(components) = upgrade_dict.get(&pk(keys::COMPONENTS)).and_then(|v| v.dict_or_object_dict()) else {
continue;
};
let components = components.inner();
let mut config = crate::game_params::types::HullUpgradeConfig::default();
for ct in keys::ComponentType::ALL {
if let Some(val) = components.get(&pk(ct.key())) {
let all_names = read_all_strings(val);
if let Some(first) = all_names.first() {
config.component_names.insert(*ct, first.clone());
}
if all_names.len() > 1 {
config.component_alternatives.insert(*ct, all_names);
}
}
}
if let Some(hull_comp) = config.component_names.get(&keys::ComponentType::Hull)
&& let Some(hull_data) = ship_data.get(&pk(hull_comp)).and_then(|v| v.dict_or_object_dict())
{
let hull_data = hull_data.inner();
config.detection_km = Km::from(read_float(&hull_data, keys::VISIBILITY_FACTOR).unwrap_or(0.0));
config.air_detection_km = Km::from(read_float(&hull_data, keys::VISIBILITY_FACTOR_BY_PLANE).unwrap_or(0.0));
config.hull_model_path = read_string(&hull_data, keys::MODEL);
config.draft = read_float(&hull_data, keys::DRAFT).map(Meters::from);
config.dock_y_offset = read_float(&hull_data, keys::DOCK_Y_OFFSET);
let (battery_capacity, battery_regen_rate) = read_submarine_battery(&hull_data);
ttx_components.hulls.insert(
upgrade_name.clone(),
crate::game_params::ttx::components::HullComponentStats {
health: read_float(&hull_data, keys::HEALTH).map(crate::game_params::ttx::model::Hp::from),
max_speed: read_float(&hull_data, keys::MAX_SPEED).map(crate::game_params::ttx::model::Knots::from),
speed_coef: read_float(&hull_data, keys::SPEED_COEF),
turning_radius: read_float(&hull_data, keys::TURNING_RADIUS).map(Meters::from),
rudder_time: read_float(&hull_data, keys::RUDDER_TIME)
.map(crate::game_params::ttx::model::Seconds::from),
visibility_factor: read_float(&hull_data, keys::VISIBILITY_FACTOR).map(Km::from),
visibility_factor_by_plane: read_float(&hull_data, keys::VISIBILITY_FACTOR_BY_PLANE).map(Km::from),
visibility_coef_fire: read_float(&hull_data, keys::VISIBILITY_COEF_FIRE).map(Km::from),
visibility_coef_fire_by_plane: read_float(&hull_data, keys::VISIBILITY_COEF_FIRE_BY_PLANE)
.map(Km::from),
visibility_coef_gk: read_float(&hull_data, keys::VISIBILITY_COEF_GK).map(Km::from),
visibility_coef_gk_in_smoke: read_float(&hull_data, keys::VISIBILITY_COEF_GK_IN_SMOKE)
.map(Km::from),
visibility_factor_by_periscope: read_visibility_by_periscope(&hull_data).map(Km::from),
flood_prob: read_flood_prob(&hull_data),
battery_capacity,
battery_regen_rate,
},
);
}
for ct in keys::ComponentType::ALL {
if let Some(comp_name) = config.component_names.get(ct) {
let mounts = extract_mounts(ship_data, comp_name);
if !mounts.is_empty() {
config.mounts_by_type.insert(*ct, ComponentMounts::new(comp_name.clone(), mounts));
}
}
}
for alternatives in config.component_alternatives.values() {
for alt_name in alternatives.iter().skip(1) {
let mounts = extract_mounts(ship_data, alt_name);
if !mounts.is_empty() {
config.alternative_mounts.insert(alt_name.clone(), ComponentMounts::new(alt_name.clone(), mounts));
}
}
}
if config.detection_km.value() > 0.0 || !config.mounts_by_type.is_empty() {
hull_upgrades.insert(upgrade_name, config);
}
}
let mut torpedo_ammo = HashSet::new();
let mut main_battery_ammo = HashSet::new();
let mut secondary_battery_ammo = HashSet::new();
let mut max_main_battery_m: Option<Meters> = None;
let mut max_secondary_battery_m: Option<Meters> = None;
let mut secondary_guns_raw: Vec<(String, String)> = Vec::new();
for (_upgrade_name_val, upgrade_value) in upgrade_data.inner().iter() {
let Some(upgrade_dict) = upgrade_value.dict_or_object_dict() else {
continue;
};
let upgrade_dict = upgrade_dict.inner();
let Some(uc_type) =
upgrade_dict.get(&pk(keys::UC_TYPE)).and_then(|v| v.string_ref().map(|s| s.inner().clone()))
else {
continue;
};
let components = upgrade_dict.get(&pk(keys::COMPONENTS)).and_then(|v| v.dict_or_object_dict());
let Some(components) = components else {
continue;
};
if (uc_type == keys::UC_TYPE_HULL || uc_type == keys::UC_TYPE_ARTILLERY)
&& let Some(art_comp) = components.inner().get(&pk(keys::COMP_ARTILLERY)).and_then(read_first_string)
&& let Some(art_data) = ship_data.get(&pk(&art_comp)).and_then(|v| v.dict_or_object_dict())
{
if let Some(m) = read_float(&art_data.inner(), keys::MAX_DIST).map(Meters::from) {
max_main_battery_m = Some(match max_main_battery_m {
Some(prev) if prev.value() >= m.value() => prev,
_ => m,
});
}
for (_mount_key, mount_val) in art_data.inner().iter() {
let Some(mount_dict) = mount_val.dict_or_object_dict() else {
continue;
};
let mount_inner = mount_dict.inner();
let Some(ammo_val) = mount_inner.get(&pk(keys::AMMO_LIST)) else {
continue;
};
let mut insert_ammo = |item: &Value| {
if let Some(name) = item.string_ref() {
main_battery_ammo.insert(name.inner().clone());
}
};
match ammo_val {
Value::Tuple(t) => t.inner().iter().for_each(&mut insert_ammo),
Value::List(l) => l.inner().iter().for_each(&mut insert_ammo),
_ => {}
}
}
}
if uc_type == keys::UC_TYPE_HULL
&& let Some(atba_comp) = components.inner().get(&pk(keys::COMP_ATBA)).and_then(read_first_string)
&& let Some(atba_data) = ship_data.get(&pk(&atba_comp)).and_then(|v| v.dict_or_object_dict())
{
if let Some(m) = read_float(&atba_data.inner(), keys::MAX_DIST).map(Meters::from) {
max_secondary_battery_m = Some(match max_secondary_battery_m {
Some(prev) if prev.value() >= m.value() => prev,
_ => m,
});
}
let capture_per_gun = secondary_guns_raw.is_empty();
for (mount_key, mount_val) in atba_data.inner().iter() {
let Some(mount_dict) = mount_val.dict_or_object_dict() else {
continue;
};
let mount_inner = mount_dict.inner();
let Some(ammo_val) = mount_inner.get(&pk(keys::AMMO_LIST)) else {
continue;
};
let mut insert_ammo = |item: &Value| {
if let Some(name) = item.string_ref() {
secondary_battery_ammo.insert(name.inner().clone());
}
};
match ammo_val {
Value::Tuple(t) => t.inner().iter().for_each(&mut insert_ammo),
Value::List(l) => l.inner().iter().for_each(&mut insert_ammo),
_ => {}
}
if capture_per_gun {
let first_ammo = match ammo_val {
Value::Tuple(t) => t.inner().iter().find_map(|i| i.string_ref().map(|s| s.inner().clone())),
Value::List(l) => l.inner().iter().find_map(|i| i.string_ref().map(|s| s.inner().clone())),
_ => None,
};
if let (Some(name), Some(key_str)) = (first_ammo, mount_key.string_ref().map(|s| s.inner().clone()))
{
secondary_guns_raw.push((key_str, name));
}
}
}
}
if uc_type != keys::UC_TYPE_TORPEDOES {
continue;
}
let Some(components) = upgrade_dict.get(&pk(keys::COMPONENTS)).and_then(|v| v.dict_or_object_dict()) else {
continue;
};
let Some(torp_comp) = components.inner().get(&pk(keys::COMP_TORPEDOES)).and_then(read_first_string) else {
continue;
};
let Some(torp_data) = ship_data.get(&pk(&torp_comp)).and_then(|v| v.dict_or_object_dict()) else {
continue;
};
for (_key, val) in torp_data.inner().iter() {
let Some(launcher) = val.dict_or_object_dict() else {
continue;
};
let launcher_inner = launcher.inner();
let Some(ammo_val) = launcher_inner.get(&pk(keys::AMMO_LIST)) else {
continue;
};
let mut insert_ammo = |item: &Value| {
if let Some(name) = item.string_ref() {
torpedo_ammo.insert(name.inner().clone());
}
};
match ammo_val {
Value::Tuple(t) => t.inner().iter().for_each(&mut insert_ammo),
Value::List(l) => {
let items = l.inner();
items.iter().for_each(&mut insert_ammo);
}
_ => continue,
};
}
}
let first_hull_comp_name: Option<String> = hull_upgrades
.iter()
.min_by_key(|(k, _)| (*k).clone())
.and_then(|(_, config)| config.component_names.get(&keys::ComponentType::Hull).cloned());
let hull_comp_key = first_hull_comp_name.as_deref().unwrap_or(keys::A_HULL);
secondary_guns_raw.sort_by_key(|(k, _)| hardpoint_number(k));
let secondary_guns: Vec<String> = secondary_guns_raw.into_iter().map(|(_, name)| name).collect();
let config_data = if hull_upgrades.is_empty()
&& torpedo_ammo.is_empty()
&& main_battery_ammo.is_empty()
&& secondary_battery_ammo.is_empty()
&& max_main_battery_m.is_none()
&& max_secondary_battery_m.is_none()
{
None
} else {
Some(crate::game_params::types::ShipConfigData {
hull_upgrades,
main_battery_m: max_main_battery_m,
secondary_battery_m: max_secondary_battery_m,
torpedo_ammo,
main_battery_ammo,
secondary_battery_ammo,
secondary_guns,
})
};
let a_hull = ship_data.get(&pk(hull_comp_key)).and_then(|v| v.dict_or_object_dict());
let model_path: Option<String> = a_hull.as_ref().and_then(|hull_dict| read_string(&hull_dict.inner(), keys::MODEL));
let armor: Option<ArmorMap> = a_hull.as_ref().and_then(|hull_dict| {
hull_dict
.inner()
.get(&pk(keys::ARMOR))
.and_then(|v| v.dict_or_object_dict())
.map(|d| parse_armor_dict(&d.inner()))
});
let hit_locations: Option<HashMap<String, HitLocation>> = a_hull.as_ref().map(|hull_dict| {
hull_dict
.inner()
.iter()
.filter_map(|(k, v)| {
let name = k.string_ref()?.inner().to_string();
let group_shared = v.dict_or_object_dict()?;
let group_dict = group_shared.inner();
let hl_type = read_string(&group_dict, keys::HL_TYPE)?;
let max_hp = read_float(&group_dict, keys::MAX_HP).unwrap_or(0.0);
let regenerated_hp_part = read_float(&group_dict, keys::REGENERATED_HP_PART).unwrap_or(0.0);
let thickness = read_float(&group_dict, keys::THICKNESS).unwrap_or(0.0);
let splash_boxes: Vec<String> = group_dict
.get(&pk(keys::SPLASH_BOXES))
.map(|v| {
let extract = |items: &[Value]| -> Vec<String> {
items.iter().filter_map(|s| s.string_ref().map(|s| s.inner().to_string())).collect()
};
if let Some(list) = v.list_ref() {
extract(&list.inner())
} else if let Some(tuple) = v.tuple_ref() {
extract(tuple.inner())
} else {
Vec::new()
}
})
.unwrap_or_default();
Some((
name,
HitLocation::builder()
.max_hp(max_hp)
.hl_type(hl_type)
.regenerated_hp_part(regenerated_hp_part)
.thickness(thickness)
.splash_boxes(splash_boxes)
.build(),
))
})
.collect()
});
let permoflages: Vec<String> = ship_data
.get(&pk(keys::PERMOFLAGES))
.map(|val| {
let extract = |items: &[Value]| -> Vec<String> {
items.iter().filter_map(|item| item.string_ref().map(|s| s.inner().to_string())).collect()
};
if let Some(list) = val.list_ref() {
extract(&list.inner())
} else if let Some(tuple) = val.tuple_ref() {
extract(tuple.inner())
} else {
Vec::new()
}
})
.unwrap_or_default();
let camera_trajectories = read_camera_trajectories(ship_data);
Vehicle::builder()
.level(level)
.group(group)
.maybe_abilities(abilities)
.upgrades(upgrades)
.maybe_config_data(config_data)
.maybe_model_path(model_path)
.maybe_armor(armor)
.maybe_hit_locations(hit_locations)
.permoflages(permoflages)
.camera_trajectories(camera_trajectories)
.maybe_ttx_components((!ttx_components.is_empty()).then_some(ttx_components))
.build()
}
impl GameMetadataProvider {
#[cfg(feature = "vfs")]
pub fn from_vfs(vfs: &vfs::VfsPath) -> Result<GameMetadataProvider, GameDataError> {
debug!("deserializing gameparams");
let mut game_params_data = Vec::new();
vfs.join("content/GameParams.data")?.open_file()?.read_to_end(&mut game_params_data)?;
let params = Self::params_from_data(game_params_data)?;
Self::from_params_with_vfs(params, vfs)
}
pub fn params_from_data(game_params_data: Vec<u8>) -> Result<Vec<Param>, GameDataError> {
let pickled_params: Value = game_params_to_pickle(game_params_data)?;
let params_dict = if let Some(params_dict) = pickled_params.dict_or_object_dict() {
let inner = params_dict.inner();
if let Some(nested) = inner.get(&pk("")) {
nested.dict_or_object_dict().expect("game params is not a dict").clone()
} else {
params_dict.clone()
}
} else if let Some(params_list) = pickled_params.list_ref() {
let params = ¶ms_list.inner()[0];
params.dict_or_object_dict().expect("First element of GameParams list is not a dictionary").clone()
} else if let Some(params_tuple) = pickled_params.tuple_ref() {
let inner = params_tuple.inner();
let params = &inner[0];
params.dict_or_object_dict().expect("First element of GameParams tuple is not a dictionary").clone()
} else {
panic!("Root game params is not a dict, list, or tuple");
};
let new_params = params_dict
.inner()
.values()
.filter_map(|param| {
let param = param.clone();
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
Self::parse_single_param(¶m, ¶ms_dict)
}))
.unwrap_or_default()
})
.collect::<Vec<Param>>();
Ok(new_params)
}
fn parse_single_param(param: &Value, params_dict: &Shared<BTreeMap<HashableValue, Value>>) -> Option<Param> {
if param.is_none() {
return None;
}
let param_data = param.dict_or_object_dict().expect("Params root level dictionary values are not dictionaries");
let param_data = param_data.inner();
let (nation, species, typ) = param_data.get(&pk(keys::TYPEINFO)).and_then(|type_info| {
type_info.dict_or_object_dict().and_then(|type_info_main| {
let type_info = type_info_main.inner();
let (nation, species, ty) = (
type_info.get(&pk(keys::TYPEINFO_NATION))?,
type_info.get(&pk(keys::TYPEINFO_SPECIES))?,
type_info.get(&pk(keys::TYPEINFO_TYPE))?,
);
let (Value::String(nation), Value::String(ty)) = (nation, ty) else {
return None;
};
Some((nation.clone(), species.clone(), ty.clone()))
})
})?;
let param_type = ParamType::from_name(typ.inner().as_str())?;
let nation = nation.inner().clone();
let species = species.string_ref().map(|s| Species::from_name(s.inner().as_str()));
let parsed_param_data = match param_type {
ParamType::Ship => Some(ParamData::Vehicle(build_ship(¶m_data))),
ParamType::Crew => {
let money_training_level = game_param_to_type!(param_data, "moneyTrainingLevel", usize);
let personality = game_param_to_type!(param_data, "CrewPersonality", HashMap<(), ()>);
let personality = personality.inner();
let crew_personality = build_crew_personality(&personality);
let skills = game_param_to_type!(param_data, "Skills", Option<HashMap<(), ()>>);
let skills = skills.map(|skills| build_crew_skills(&skills.inner()));
Some(ParamData::Crew(
Crew::builder()
.money_training_level(money_training_level)
.personality(crew_personality)
.maybe_skills(skills)
.build(),
))
}
ParamType::Achievement => {
let is_group = game_param_to_type!(param_data, "group", Option<bool>).unwrap_or_default();
let one_per_battle = game_param_to_type!(param_data, "onePerBattle", Option<bool>).unwrap_or_default();
let ui_type = game_param_to_type!(param_data, "uiType", Option<String>).unwrap_or_default();
let ui_name = game_param_to_type!(param_data, "uiName", Option<String>).unwrap_or_default();
Some(ParamData::Achievement(
Achievement::builder()
.is_group(is_group)
.one_per_battle(one_per_battle)
.ui_type(ui_type)
.ui_name(ui_name)
.build(),
))
}
ParamType::Ability => Some(ParamData::Ability(build_ability(¶m_data))),
ParamType::Exterior => {
let camouflage = param_data
.get(&pk(keys::CAMOUFLAGE))
.and_then(|v| v.string_ref())
.map(|s| s.inner().to_string())
.filter(|s| !s.is_empty());
let title = param_data
.get(&pk(keys::TITLE))
.and_then(|v| v.string_ref())
.map(|s| s.inner().to_string())
.filter(|s| !s.is_empty());
let modifiers = param_data
.get(&pk("modifiers"))
.and_then(|v| v.dict_or_object_dict())
.map(|d| build_skill_modifiers(&d.inner()))
.unwrap_or_default();
Some(ParamData::Exterior(
Exterior::builder().maybe_camouflage(camouflage).maybe_title(title).modifiers(modifiers).build(),
))
}
ParamType::Modernization => {
let modifiers = param_data
.get(&pk("modifiers"))
.and_then(|v| v.dict_or_object_dict())
.map(|d| build_skill_modifiers(&d.inner()))
.unwrap_or_default();
let slot = param_data
.get(&pk("slot"))
.and_then(|v| v.i64_ref())
.and_then(|&v| if v >= 0 { Some(v as u8) } else { None });
let ship_levels = param_data
.get(&pk("shiplevel"))
.and_then(|v| v.list_ref())
.map(|a| a.inner().iter().filter_map(|x| x.i64_ref().map(|&n| n as u32)).collect())
.unwrap_or_default();
let ship_types = string_list_field(¶m_data, "shiptype");
let nations = string_list_field(¶m_data, "nation");
let groups = string_list_field(¶m_data, "group");
let ships = string_list_field(¶m_data, "ships");
let excludes = string_list_field(¶m_data, "excludes");
Some(ParamData::Modernization(super::types::Modernization::new(
modifiers,
slot,
ship_levels,
ship_types,
nations,
groups,
ships,
excludes,
)))
}
ParamType::Unit => {
let uc_type = param_data
.get(&pk(keys::UC_TYPE))
.and_then(|v| v.string_ref())
.map(|s| s.inner().to_string())
.filter(|s| !s.is_empty());
Some(ParamData::Unit(super::types::Unit::new(uc_type)))
}
ParamType::Drop => {
let marker_name_active = param_data
.get(&pk("markerNameActive"))
.and_then(|v| v.string_ref())
.map(|s| s.inner().to_string())
.unwrap_or_default();
let marker_name_inactive = param_data
.get(&pk("markerNameInactive"))
.and_then(|v| v.string_ref())
.map(|s| s.inner().to_string())
.unwrap_or_default();
let sorting = param_data.get(&pk("sorting")).and_then(|v| v.i64_ref()).copied().unwrap_or(0);
Some(ParamData::Drop(
super::types::BuffDrop::builder()
.marker_name_active(marker_name_active)
.marker_name_inactive(marker_name_inactive)
.sorting(sorting)
.build(),
))
}
ParamType::Aircraft => {
let subtypes: Vec<String> = param_data
.get(&pk("planeSubtype"))
.and_then(|v| v.list_ref())
.map(|list| {
list.inner()
.iter()
.filter_map(|item| item.string_ref().map(|s| s.inner().to_string()))
.collect()
})
.unwrap_or_default();
let category = if subtypes.iter().any(|s| s == "airsupport") {
PlaneCategory::Airsupport
} else if subtypes.iter().any(|s| s == "consumable") {
PlaneCategory::Consumable
} else {
PlaneCategory::Controllable
};
let ammo_type = param_data
.get(&pk("bombName"))
.and_then(|v| v.string_ref())
.filter(|s| !s.inner().is_empty())
.and_then(|bomb_name| {
params_dict
.inner()
.get(&HashableValue::String(bomb_name.clone()))
.and_then(|proj| proj.dict_or_object_dict())
.and_then(|proj_dict| {
proj_dict
.inner()
.get(&pk("ammoType"))
.and_then(|v| v.string_ref())
.map(|s| s.inner().to_string())
})
})
.unwrap_or_default();
Some(ParamData::Aircraft(Aircraft::builder().category(category).ammo_type(ammo_type).build()))
}
ParamType::Projectile => {
let ammo_type = param_data
.get(&pk("ammoType"))
.and_then(|v| v.string_ref())
.map(|s| s.inner().to_string())
.unwrap_or_default();
let max_dist = param_data
.get(&pk(keys::MAX_DIST))
.and_then(|v| v.f64_ref().map(|f| *f as f32).or_else(|| v.i64_ref().map(|i| *i as f32)))
.map(BigWorldDistance::from);
let read_opt_f32 = |key: &str| -> Option<f32> {
param_data
.get(&pk(key))
.and_then(|v| v.f64_ref().map(|f| *f as f32).or_else(|| v.i64_ref().map(|i| *i as f32)))
};
let read_opt_bool =
|key: &str| -> Option<bool> { param_data.get(&pk(key)).and_then(|v| v.bool_ref().copied()) };
let bullet_diametr = read_opt_f32("bulletDiametr");
let bullet_mass = read_opt_f32("bulletMass");
let bullet_speed = read_opt_f32("bulletSpeed");
let bullet_krupp = read_opt_f32("bulletKrupp");
let bullet_cap = read_opt_bool("bulletCap");
let bullet_cap_normalize_max_angle = read_opt_f32("bulletCapNormalizeMaxAngle");
let bullet_detonator = read_opt_f32("bulletDetonator");
let bullet_detonator_threshold = read_opt_f32("bulletDetonatorThreshold");
let bullet_ricochet_at = read_opt_f32("bulletRicochetAt");
let bullet_always_ricochet_at = read_opt_f32("bulletAlwaysRicochetAt");
let alpha_piercing_he = read_opt_f32("alphaPiercingHE");
let alpha_piercing_cs = read_opt_f32("alphaPiercingCS");
let alpha_damage = read_opt_f32("alphaDamage");
let burn_prob = read_opt_f32("burnProb");
let uw_critical = read_opt_f32("uwCritical");
let time_factor = read_opt_f32("timeFactor");
let bullet_air_drag = read_opt_f32("bulletAirDrag");
let speed = read_opt_f32("speed");
let damage = read_opt_f32("damage");
let visibility_factor = read_opt_f32("visibilityFactor");
let torpedo_type = param_data.get(&pk("torpedoType")).and_then(|v| v.i64_ref().copied());
let read_pairs = |key: &str| -> Option<Vec<(f32, f32)>> {
param_data.get(&pk(key)).and_then(|v| v.list_ref()).map(|list| {
list.inner()
.iter()
.filter_map(|item| {
let pair = item.list_ref()?;
let pair = pair.inner();
let coeff = pair.first().and_then(|v| v.f64_ref().map(|f| *f as f32))?;
let dist = pair.get(1).and_then(|v| v.f64_ref().map(|f| *f as f32))?;
Some((coeff, dist))
})
.collect()
})
};
let distance_of_damage = read_pairs("distanceOfDamage");
let ignore_classes = param_data.get(&pk("ignoreClasses")).and_then(|v| v.list_ref()).map(|list| {
list.inner()
.iter()
.filter_map(|item| item.string_ref().map(|s| s.inner().to_string()))
.collect::<Vec<_>>()
});
Some(ParamData::Projectile(
Projectile::builder()
.ammo_type(ammo_type)
.maybe_max_dist(max_dist)
.maybe_bullet_diametr(bullet_diametr)
.maybe_bullet_mass(bullet_mass)
.maybe_bullet_speed(bullet_speed)
.maybe_bullet_krupp(bullet_krupp)
.maybe_bullet_cap(bullet_cap)
.maybe_bullet_cap_normalize_max_angle(bullet_cap_normalize_max_angle)
.maybe_bullet_detonator(bullet_detonator)
.maybe_bullet_detonator_threshold(bullet_detonator_threshold)
.maybe_bullet_ricochet_at(bullet_ricochet_at)
.maybe_bullet_always_ricochet_at(bullet_always_ricochet_at)
.maybe_alpha_piercing_he(alpha_piercing_he)
.maybe_alpha_piercing_cs(alpha_piercing_cs)
.maybe_alpha_damage(alpha_damage)
.maybe_burn_prob(burn_prob)
.maybe_uw_critical(uw_critical)
.maybe_time_factor(time_factor)
.maybe_bullet_air_drag(bullet_air_drag)
.maybe_speed(speed)
.maybe_damage(damage)
.maybe_visibility_factor(visibility_factor)
.maybe_distance_of_damage(distance_of_damage)
.maybe_torpedo_type(torpedo_type)
.maybe_ignore_classes(ignore_classes)
.build(),
))
}
ParamType::Building => {
let level = game_param_to_type!(param_data, "level", u32);
let hull = param_data.get(&pk("hull")).and_then(|v| v.dict_or_object_dict());
let health = hull
.as_ref()
.and_then(|h| {
let inner = h.inner();
inner
.get(&pk("health"))
.and_then(|v| v.f64_ref().map(|f| *f as f32).or_else(|| v.i64_ref().map(|i| *i as f32)))
})
.unwrap_or(0.0);
Some(ParamData::Building(super::types::Building::builder().level(level).health(health).build()))
}
_ => {
if param_data.contains_key(&pk("markerNameActive")) {
let marker_name_active = param_data
.get(&pk("markerNameActive"))
.and_then(|v| v.string_ref())
.map(|s| s.inner().to_string())
.unwrap_or_default();
let marker_name_inactive = param_data
.get(&pk("markerNameInactive"))
.and_then(|v| v.string_ref())
.map(|s| s.inner().to_string())
.unwrap_or_default();
let sorting = param_data.get(&pk("sorting")).and_then(|v| v.i64_ref()).copied().unwrap_or(0);
Some(ParamData::Drop(
super::types::BuffDrop::builder()
.marker_name_active(marker_name_active)
.marker_name_inactive(marker_name_inactive)
.sorting(sorting)
.build(),
))
} else {
None
}
}
}?;
let id_value = param_data.get(&pk(keys::PARAM_ID)).expect("param has no id field");
let id = GameParamId::from(
*id_value.i64_ref().unwrap_or_else(|| panic!("param id is not an i64, got: {id_value:?}")),
);
let index = param_data
.get(&pk(keys::PARAM_INDEX))
.expect("param has no index field")
.string_ref()
.expect("param index is not a string")
.inner()
.clone();
let name = param_data
.get(&pk(keys::PARAM_NAME))
.expect("param has no name field")
.string_ref()
.expect("param name is not a string")
.inner()
.clone();
Some(
Param::builder()
.id(id)
.index(index)
.name(name)
.maybe_species(species)
.nation(nation)
.data(parsed_param_data)
.build(),
)
}
#[cfg(feature = "vfs")]
pub fn from_params_with_vfs(params: Vec<Param>, vfs: &vfs::VfsPath) -> Result<GameMetadataProvider, GameDataError> {
let param_id_to_translation_id =
HashMap::from_iter(params.iter().map(|param| (param.id(), format!("IDS_{}", param.index()))));
let data_file_loader = DataFileWithCallback::new(|path| {
debug!("requesting file: {path}");
let mut file_data = Vec::new();
vfs.join(path)
.expect("failed to join path")
.open_file()
.expect("failed to open file")
.read_to_end(&mut file_data)
.expect("failed to read file");
Ok(Cow::Owned(file_data))
});
let specs = Arc::new(parse_scripts(&data_file_loader)?);
Ok(GameMetadataProvider {
params: params.into(),
param_id_to_translation_id,
translations: RwLock::new(None),
specs,
})
}
pub fn from_params_no_specs(params: Vec<Param>) -> Result<GameMetadataProvider, GameDataError> {
let param_id_to_translation_id =
HashMap::from_iter(params.iter().map(|param| (param.id(), format!("IDS_{}", param.index()))));
let specs = Arc::new(Vec::new());
Ok(GameMetadataProvider {
params: params.into(),
param_id_to_translation_id,
translations: RwLock::new(None),
specs,
})
}
pub fn set_translations(&self, catalog: Catalog) {
*self.translations.write().expect("translations lock poisoned") = Some(catalog);
}
fn translate(&self, key: &str) -> Option<String> {
let guard = self.translations.read().ok()?;
let catalog = guard.as_ref()?;
let result = catalog.gettext(key);
if result == key { None } else { Some(result.to_string()) }
}
pub fn param_localization_id(&self, ship_id: GameParamId) -> Option<&str> {
self.param_id_to_translation_id.get(&ship_id).map(|s| s.as_str())
}
pub fn resolve_shell_from_param_id(&self, params_id: GameParamId) -> Option<ShellInfo> {
let param = GameParamProvider::game_param_by_id(self, params_id)?;
let projectile = param.projectile()?;
let name = param.name().to_string();
Some(projectile.to_shell_info(name))
}
}
#[cfg(test)]
mod camera_tests {
use super::*;
use pickled::value::Shared;
fn fv(f: f64) -> Value {
Value::F64(f)
}
fn list(items: Vec<Value>) -> Value {
Value::List(Shared::new(items))
}
fn dict(entries: Vec<(HashableValue, Value)>) -> Value {
Value::Dict(Shared::new(entries.into_iter().collect()))
}
fn iv(i: i64) -> Value {
Value::I64(i)
}
fn typeinfo(nation: &str, species: &str, ty: &str) -> Value {
dict(vec![(pk("nation"), sv(nation)), (pk("species"), sv(species)), (pk("type"), sv(ty))])
}
#[test]
fn parse_single_param_retains_torpedo_fields() {
let proj = dict(vec![
(pk("id"), iv(4242)),
(pk("index"), sv("PAPT027")),
(pk("name"), sv("PAPT027_Mk_16_mod_1")),
(pk("typeinfo"), typeinfo("USA", "Torpedo", "Projectile")),
(pk("ammoType"), sv("torpedo")),
(pk("maxDist"), fv(350.0)),
(pk("alphaDamage"), fv(53500.0)),
(pk("damage"), fv(1200.0)),
(pk("speed"), fv(66.0)),
(pk("visibilityFactor"), fv(1.4)),
(pk("torpedoType"), iv(1)),
(pk("distanceOfDamage"), list(vec![list(vec![fv(83.33), fv(0.1)]), list(vec![fv(86.66), fv(1.0)])])),
(pk("ignoreClasses"), list(vec![sv("Cruiser"), sv("Destroyer")])),
]);
let params_dict: Shared<BTreeMap<HashableValue, Value>> = Shared::new(BTreeMap::new());
let param = GameMetadataProvider::parse_single_param(&proj, ¶ms_dict).expect("param parsed");
let projectile = param.projectile().expect("projectile data");
assert_eq!(projectile.alpha_damage(), Some(53500.0));
assert_eq!(projectile.max_dist().map(|d| d.value()), Some(350.0));
assert_eq!(projectile.speed(), Some(66.0));
assert_eq!(projectile.damage(), Some(1200.0));
assert_eq!(projectile.visibility_factor(), Some(1.4));
assert_eq!(projectile.torpedo_type(), Some(1));
assert_eq!(projectile.distance_of_damage(), Some(&[(83.33, 0.1), (86.66, 1.0)][..]));
assert_eq!(projectile.ignore_classes(), Some(&["Cruiser".to_string(), "Destroyer".to_string()][..]));
}
#[test]
fn parse_single_param_torpedo_fields_absent_are_none() {
let proj = dict(vec![
(pk("id"), iv(7)),
(pk("index"), sv("PAPT099")),
(pk("name"), sv("PAPT099_NoTorp")),
(pk("typeinfo"), typeinfo("USA", "Torpedo", "Projectile")),
(pk("ammoType"), sv("torpedo")),
]);
let params_dict: Shared<BTreeMap<HashableValue, Value>> = Shared::new(BTreeMap::new());
let param = GameMetadataProvider::parse_single_param(&proj, ¶ms_dict).expect("param parsed");
let projectile = param.projectile().expect("projectile data");
assert_eq!(projectile.speed(), None);
assert_eq!(projectile.damage(), None);
assert_eq!(projectile.visibility_factor(), None);
assert_eq!(projectile.torpedo_type(), None);
assert_eq!(projectile.distance_of_damage(), None);
assert_eq!(projectile.ignore_classes(), None);
}
#[test]
fn read_camera_trajectories_empty_dict_returns_empty() {
let ship_data: BTreeMap<HashableValue, Value> = BTreeMap::new();
let result = read_camera_trajectories(&ship_data);
assert!(result.is_empty());
}
fn sv(s: &str) -> Value {
Value::String(s.to_string().into())
}
fn build_inner_traj(extra: Vec<(HashableValue, Value)>) -> Value {
let pos_center = list(vec![list(vec![fv(0.0), fv(1.958), fv(0.0)]), list(vec![fv(0.0), fv(2.008), fv(0.0)])]);
let mut entries = vec![
(pk("posCenter"), pos_center),
(pk("semiAxisH"), list(vec![fv(6.552), fv(5.981)])),
(pk("semiAxisV"), list(vec![fv(4.123), fv(3.999)])),
];
entries.extend(extra);
dict(entries)
}
#[test]
fn read_camera_trajectories_parses_mode() {
let inner_traj = build_inner_traj(vec![]);
let mode = dict(vec![(pk("InnerTrajectory"), inner_traj)]);
let cameras = dict(vec![(pk("TestMode"), mode)]);
let ship_data: BTreeMap<HashableValue, Value> = [(pk("Cameras"), cameras)].into_iter().collect();
let result = read_camera_trajectories(&ship_data);
assert_eq!(result.len(), 1);
let (name, traj) = &result[0];
assert_eq!(name, "TestMode");
assert!((traj.pos_center[0].y - 1.958_f32).abs() < 1e-4);
assert!((traj.pos_center[1].y - 2.008_f32).abs() < 1e-4);
assert!((traj.semi_axes[0].x - 6.552_f32).abs() < 1e-4);
assert!((traj.semi_axes[1].x - 5.981_f32).abs() < 1e-4);
assert!((traj.semi_axes[0].y - 4.123_f32).abs() < 1e-4);
assert!((traj.semi_axes[1].y - 3.999_f32).abs() < 1e-4);
assert_eq!(traj.tags, "");
assert!(!traj.ignore_height_multiplier);
}
#[test]
fn read_camera_trajectories_parses_tags_and_ignore_height_bool() {
let inner_traj = build_inner_traj(vec![(pk("ignoreHeightMultiplier"), Value::Bool(true))]);
let mode = dict(vec![(pk("InnerTrajectory"), inner_traj), (pk("tags"), sv("AT"))]);
let cameras = dict(vec![(pk("TagMode"), mode)]);
let ship_data: BTreeMap<HashableValue, Value> = [(pk("Cameras"), cameras)].into_iter().collect();
let result = read_camera_trajectories(&ship_data);
assert_eq!(result.len(), 1);
let (name, traj) = &result[0];
assert_eq!(name, "TagMode");
assert_eq!(traj.tags, "AT");
assert!(traj.ignore_height_multiplier);
}
#[test]
fn read_camera_trajectories_parses_ignore_height_as_int() {
let inner_traj = build_inner_traj(vec![(pk("ignoreHeightMultiplier"), Value::I64(1))]);
let mode = dict(vec![(pk("InnerTrajectory"), inner_traj)]);
let cameras = dict(vec![(pk("IntMode"), mode)]);
let ship_data: BTreeMap<HashableValue, Value> = [(pk("Cameras"), cameras)].into_iter().collect();
let result = read_camera_trajectories(&ship_data);
assert_eq!(result.len(), 1);
assert!(result[0].1.ignore_height_multiplier);
}
fn build_outer_traj() -> Value {
let pos_center = list(vec![list(vec![fv(0.0), fv(4.199), fv(0.0)]), list(vec![fv(0.0), fv(6.023), fv(0.0)])]);
dict(vec![
(pk("posCenter"), pos_center),
(pk("semiAxisH"), list(vec![fv(18.785), fv(17.315)])),
(pk("semiAxisV"), list(vec![fv(12.5), fv(11.0)])),
])
}
#[test]
fn read_camera_trajectories_parses_outer_trajectory() {
let mode =
dict(vec![(pk("InnerTrajectory"), build_inner_traj(vec![])), (pk("OuterTrajectory"), build_outer_traj())]);
let cameras = dict(vec![(pk("PairMode"), mode)]);
let ship_data: BTreeMap<HashableValue, Value> = [(pk("Cameras"), cameras)].into_iter().collect();
let result = read_camera_trajectories(&ship_data);
assert_eq!(result.len(), 1);
let outer = result[0].1.outer.as_ref().expect("outer parsed");
assert!((outer.pos_center[0].y - 4.199_f32).abs() < 1e-4);
assert!((outer.pos_center[1].y - 6.023_f32).abs() < 1e-4);
assert!((outer.semi_axes[0].x - 18.785_f32).abs() < 1e-4);
assert!((outer.semi_axes[0].y - 12.5_f32).abs() < 1e-4);
assert!((outer.semi_axes[1].y - 11.0_f32).abs() < 1e-4);
}
#[test]
fn read_camera_trajectories_outer_absent_is_none() {
let mode = dict(vec![(pk("InnerTrajectory"), build_inner_traj(vec![]))]);
let cameras = dict(vec![(pk("NoOuter"), mode)]);
let ship_data: BTreeMap<HashableValue, Value> = [(pk("Cameras"), cameras)].into_iter().collect();
let result = read_camera_trajectories(&ship_data);
assert!(result[0].1.outer.is_none());
}
#[test]
fn read_camera_trajectories_skips_scalar_sibling_keys() {
let pos_center = list(vec![list(vec![fv(0.0), fv(1.0), fv(0.0)]), list(vec![fv(0.0), fv(1.0), fv(0.0)])]);
let inner_traj = dict(vec![
(pk("posCenter"), pos_center),
(pk("semiAxisH"), list(vec![fv(1.0), fv(1.0)])),
(pk("semiAxisV"), list(vec![fv(1.0), fv(1.0)])),
]);
let mode = dict(vec![(pk("InnerTrajectory"), inner_traj)]);
let cameras = dict(vec![(pk("RealMode"), mode), (pk("inertialRollCoef"), Value::F64(0.5))]);
let ship_data: BTreeMap<HashableValue, Value> = [(pk("Cameras"), cameras)].into_iter().collect();
let result = read_camera_trajectories(&ship_data);
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, "RealMode");
}
#[test]
fn build_ability_category_collects_effect_fields_logic_wins() {
let modifiers = dict(vec![(pk("shootShift"), fv(3.0)), (pk("workRange"), fv(5000.0))]);
let logic = dict(vec![
(pk("regenerationHPSpeed"), fv(0.005)),
(pk("reloadTime"), fv(99.0)),
(pk("workRange"), fv(7.0)),
(pk("modifiers"), modifiers),
]);
let tactical = dict(vec![(pk("workRange"), fv(1000.0)), (pk("aimRange"), fv(11.0))]);
let category_data: BTreeMap<HashableValue, Value> = vec![
(pk("reloadTime"), fv(40.0)),
(pk("workTime"), Value::I64(28)),
(pk("numConsumables"), Value::I64(3)),
(pk("consumableType"), sv("regenCrew")),
(pk("logic"), logic),
(pk("tacticalParams"), tactical),
]
.into_iter()
.collect();
let cat = build_ability_category(&category_data);
let fields = cat.effect_fields();
assert_eq!(fields.get("workTime"), Some(&28.0_f32));
assert_eq!(fields.get("numConsumables"), Some(&3.0_f32));
assert_eq!(fields.get("regenerationHPSpeed"), Some(&0.005_f32));
assert_eq!(fields.get("reloadTime"), Some(&99.0_f32));
assert_eq!(fields.get("aimRange"), Some(&11.0_f32));
assert_eq!(fields.get("workRange"), Some(&5000.0_f32));
assert_eq!(fields.get("shootShift"), Some(&3.0_f32));
assert!(!fields.contains_key("consumableType"));
}
#[test]
fn build_ship_extracts_ttx_hull_and_engine_base_stats() {
let a_hull = dict(vec![
(pk("health"), fv(19400.0)),
(pk("maxSpeed"), fv(36.0)),
(pk("speedCoef"), fv(1.0)),
(pk("turningRadius"), fv(640.0)),
(pk("rudderTime"), fv(4.25)),
(pk("visibilityFactor"), fv(7.33)),
(pk("visibilityFactorByPlane"), fv(3.41)),
(pk("visibilityCoefFire"), fv(2.0)),
(pk("visibilityCoefFireByPlane"), fv(2.0)),
(pk("visibilityCoefGK"), fv(1e-6)),
(pk("visibilityCoefGKInSmoke"), fv(2.83)),
(
pk("visibilityFactorsBySubmarine"),
dict(vec![(pk("PERISCOPE"), fv(3.41)), (pk("SURFACE"), fv(0.0)), (pk("DEEP_WATER"), fv(2.0))]),
),
(pk("model"), sv("A_Hull.model")),
(pk("floodNodes"), list(vec![list(vec![fv(0.15), fv(0.5), fv(40.0)])])),
(pk("SubmarineBattery"), dict(vec![(pk("capacity"), fv(240.0)), (pk("regenRate"), fv(1.2))])),
]);
let a_engine = dict(vec![(pk("speedCoef"), fv(0.0))]);
let hull_components =
dict(vec![(pk("hull"), list(vec![sv("A_Hull")])), (pk("artillery"), list(vec![sv("A_Artillery")]))]);
let hull_upgrade = dict(vec![(pk("ucType"), sv("_Hull")), (pk("components"), hull_components)]);
let engine_components = dict(vec![(pk("engine"), list(vec![sv("A_Engine")]))]);
let engine_upgrade = dict(vec![(pk("ucType"), sv("_Engine")), (pk("components"), engine_components)]);
let upgrade_info =
dict(vec![(pk("PAUH911_Gearing_1945"), hull_upgrade), (pk("PAUE903_D10_ENG_STOCK"), engine_upgrade)]);
let ship_data: BTreeMap<HashableValue, Value> = vec![
(pk("level"), Value::I64(10)),
(pk("group"), sv("special")),
(pk("ShipUpgradeInfo"), upgrade_info),
(pk("A_Hull"), a_hull),
(pk("A_Engine"), a_engine),
]
.into_iter()
.collect();
let vehicle = build_ship(&ship_data);
let ttx = vehicle.ttx_components().expect("ttx components extracted");
let hull = ttx.hull("PAUH911_Gearing_1945").expect("hull stats present");
assert_eq!(hull.health, Some(crate::game_params::ttx::model::Hp::from(19400.0)));
assert_eq!(hull.max_speed, Some(crate::game_params::ttx::model::Knots::from(36.0)));
assert_eq!(hull.speed_coef, Some(1.0));
assert_eq!(hull.turning_radius, Some(Meters::from(640.0)));
assert_eq!(hull.rudder_time, Some(crate::game_params::ttx::model::Seconds::from(4.25)));
assert_eq!(hull.visibility_factor, Some(Km::from(7.33)));
assert_eq!(hull.visibility_factor_by_plane, Some(Km::from(3.41)));
assert_eq!(hull.visibility_coef_fire, Some(Km::from(2.0)));
assert_eq!(hull.visibility_coef_fire_by_plane, Some(Km::from(2.0)));
assert_eq!(hull.visibility_coef_gk, Some(Km::from(1e-6)));
assert_eq!(hull.visibility_coef_gk_in_smoke, Some(Km::from(2.83)));
assert_eq!(hull.visibility_factor_by_periscope, Some(Km::from(3.41)));
let flood = hull.flood_prob.expect("flood_prob derived");
assert!((flood - (0.333 - 0.15) / 0.333).abs() < 1e-6, "got {flood}");
assert_eq!(hull.battery_capacity, Some(240.0));
assert_eq!(hull.battery_regen_rate, Some(1.2));
let engine = ttx.engine("PAUE903_D10_ENG_STOCK").expect("engine stats present");
assert_eq!(engine.speed_coef, Some(0.0));
}
#[test]
fn build_ship_extracts_torpedo_launcher_base_stats() {
let hp_agt = |barrels: f64| {
dict(vec![
(pk("shotDelay"), fv(103.0)),
(pk("rotationSpeed"), list(vec![fv(25.0), fv(25.0)])),
(pk("numBarrels"), fv(barrels)),
(pk("ammoSwitchCoeff"), fv(0.2)),
(pk("ammoList"), list(vec![sv("PAPT027_Mk_16_mod_1")])),
])
};
let a_torpedoes = dict(vec![(pk("HP_AGT_1"), hp_agt(5.0)), (pk("HP_AGT_2"), hp_agt(5.0))]);
let torp_components = dict(vec![(pk("torpedoes"), list(vec![sv("A_Torpedoes")]))]);
let torp_upgrade = dict(vec![(pk("ucType"), sv("_Torpedoes")), (pk("components"), torp_components)]);
let hull_components = dict(vec![(pk("hull"), list(vec![sv("A_Hull")]))]);
let hull_upgrade = dict(vec![(pk("ucType"), sv("_Hull")), (pk("components"), hull_components)]);
let a_hull = dict(vec![(pk("health"), fv(19400.0)), (pk("model"), sv("A_Hull.model"))]);
let upgrade_info =
dict(vec![(pk("PAUH911_Gearing_1945"), hull_upgrade), (pk("PAUT901_D10_TORP_STOCK"), torp_upgrade)]);
let ship_data: BTreeMap<HashableValue, Value> = vec![
(pk("level"), Value::I64(10)),
(pk("group"), sv("special")),
(pk("ShipUpgradeInfo"), upgrade_info),
(pk("A_Hull"), a_hull),
(pk("A_Torpedoes"), a_torpedoes),
]
.into_iter()
.collect();
let vehicle = build_ship(&ship_data);
let ttx = vehicle.ttx_components().expect("ttx components extracted");
let launchers = ttx.torpedoes("PAUT901_D10_TORP_STOCK").expect("torpedo stats present");
assert_eq!(launchers.len(), 2);
let l = &launchers[0];
assert_eq!(l.shot_delay, Some(crate::game_params::ttx::model::Seconds::from(103.0)));
assert_eq!(l.rotation_speed, Some(crate::game_params::ttx::model::DegreesPerSecond::from(25.0)));
assert_eq!(l.num_barrels, Some(5.0));
assert_eq!(l.ammo_switch_coeff, Some(0.2));
assert_eq!(l.ammo, vec!["PAPT027_Mk_16_mod_1".to_string()]);
assert!(ttx.torpedoes("PAUT901_D10_TORP_STOCK").is_some());
assert!(ttx.torpedoes("NoSuchUpgrade").is_none());
}
#[test]
fn build_ship_no_torpedoes_yields_no_torpedo_entry() {
let hull_components = dict(vec![(pk("hull"), list(vec![sv("A_Hull")]))]);
let hull_upgrade = dict(vec![(pk("ucType"), sv("_Hull")), (pk("components"), hull_components)]);
let a_hull = dict(vec![(pk("health"), fv(10000.0)), (pk("model"), sv("A_Hull.model"))]);
let upgrade_info = dict(vec![(pk("PAUH001_Hull"), hull_upgrade)]);
let ship_data: BTreeMap<HashableValue, Value> = vec![
(pk("level"), Value::I64(5)),
(pk("group"), sv("special")),
(pk("ShipUpgradeInfo"), upgrade_info),
(pk("A_Hull"), a_hull),
]
.into_iter()
.collect();
let vehicle = build_ship(&ship_data);
let ttx = vehicle.ttx_components().expect("ttx components extracted");
assert!(ttx.torpedoes.is_empty());
assert!(ttx.torpedoes("PAUH001_Hull").is_none());
}
#[test]
fn build_ship_extracts_artillery_gun_base_stats() {
let hp_agm = || {
dict(vec![
(pk("shotDelay"), fv(4.6)),
(pk("rotationSpeed"), list(vec![fv(25.0), fv(50.0)])),
(pk("numBarrels"), fv(2.0)),
(pk("barrelDiameter"), fv(0.152)),
(pk("ammoSwitchCoeff"), fv(1.0)),
(pk("minRadius"), fv(1.1)),
(pk("idealRadius"), fv(8.0)),
(pk("idealDistance"), fv(1000.0)),
(
pk("ammoList"),
list(vec![sv("PAPA051_152mm_HE_HC_Mark_39_Mod_0"), sv("PAPA050_152mm_AP_130lbs_Mk35")]),
),
])
};
let arty_default =
dict(vec![(pk("maxDist"), fv(15320.0)), (pk("HP_AGM_1"), hp_agm()), (pk("HP_AGM_2"), hp_agm())]);
let arty_components = dict(vec![(pk("artillery"), list(vec![sv("ArtilleryDefault")]))]);
let arty_upgrade = dict(vec![(pk("ucType"), sv("_Artillery")), (pk("components"), arty_components)]);
let hull_components = dict(vec![(pk("hull"), list(vec![sv("A_Hull")]))]);
let hull_upgrade = dict(vec![(pk("ucType"), sv("_Hull")), (pk("components"), hull_components)]);
let a_hull = dict(vec![(pk("health"), fv(40300.0)), (pk("model"), sv("A_Hull.model"))]);
let upgrade_info =
dict(vec![(pk("PAUH911_Worcester_1948"), hull_upgrade), (pk("PAUA901_Worcester_ART_STOCK"), arty_upgrade)]);
let ship_data: BTreeMap<HashableValue, Value> = vec![
(pk("level"), Value::I64(10)),
(pk("group"), sv("special")),
(pk("ShipUpgradeInfo"), upgrade_info),
(pk("A_Hull"), a_hull),
(pk("ArtilleryDefault"), arty_default),
]
.into_iter()
.collect();
let vehicle = build_ship(&ship_data);
let ttx = vehicle.ttx_components().expect("ttx components extracted");
let arty = ttx.artillery("PAUA901_Worcester_ART_STOCK").expect("artillery stats present");
assert_eq!(arty.max_dist, Some(Meters::from(15320.0)));
assert_eq!(arty.guns.len(), 2);
let g = &arty.guns[0];
assert_eq!(g.shot_delay, Some(crate::game_params::ttx::model::Seconds::from(4.6)));
assert_eq!(g.rotation_speed, Some(crate::game_params::ttx::model::DegreesPerSecond::from(25.0)));
assert_eq!(g.num_barrels, Some(2.0));
assert_eq!(g.barrel_diameter, Some(Meters::from(0.152)));
assert_eq!(g.ammo_switch_coeff, Some(1.0));
assert_eq!(g.min_radius, Some(1.1));
assert_eq!(g.ideal_radius, Some(8.0));
assert_eq!(g.ideal_distance, Some(1000.0));
assert_eq!(
g.ammo,
vec!["PAPA051_152mm_HE_HC_Mark_39_Mod_0".to_string(), "PAPA050_152mm_AP_130lbs_Mk35".to_string()]
);
assert!(ttx.artillery("NoSuchUpgrade").is_none());
}
#[test]
fn build_ship_no_artillery_yields_no_artillery_entry() {
let hull_components = dict(vec![(pk("hull"), list(vec![sv("A_Hull")]))]);
let hull_upgrade = dict(vec![(pk("ucType"), sv("_Hull")), (pk("components"), hull_components)]);
let a_hull = dict(vec![(pk("health"), fv(10000.0)), (pk("model"), sv("A_Hull.model"))]);
let upgrade_info = dict(vec![(pk("PAUH001_Hull"), hull_upgrade)]);
let ship_data: BTreeMap<HashableValue, Value> = vec![
(pk("level"), Value::I64(5)),
(pk("group"), sv("special")),
(pk("ShipUpgradeInfo"), upgrade_info),
(pk("A_Hull"), a_hull),
]
.into_iter()
.collect();
let vehicle = build_ship(&ship_data);
let ttx = vehicle.ttx_components().expect("ttx components extracted");
assert!(ttx.artillery.is_empty());
assert!(ttx.artillery("PAUH001_Hull").is_none());
}
#[test]
fn build_ship_extracts_fire_control_max_dist_coef() {
let ab1_fc = dict(vec![(pk("maxDistCoef"), fv(1.0)), (pk("sigmaCountCoef"), fv(1.0))]);
let ab2_fc = dict(vec![(pk("maxDistCoef"), fv(1.1)), (pk("sigmaCountCoef"), fv(1.0))]);
let suo1_components = dict(vec![(pk("fireControl"), list(vec![sv("AB1_FireControl")]))]);
let suo1_upgrade = dict(vec![(pk("ucType"), sv("_Suo")), (pk("components"), suo1_components)]);
let suo2_components = dict(vec![(pk("fireControl"), list(vec![sv("AB2_FireControl")]))]);
let suo2_upgrade = dict(vec![(pk("ucType"), sv("_Suo")), (pk("components"), suo2_components)]);
let hull_components = dict(vec![(pk("hull"), list(vec![sv("A_Hull")]))]);
let hull_upgrade = dict(vec![(pk("ucType"), sv("_Hull")), (pk("components"), hull_components)]);
let a_hull = dict(vec![(pk("health"), fv(76600.0)), (pk("model"), sv("A_Hull.model"))]);
let upgrade_info = dict(vec![
(pk("PAUH018_Iowa_1944"), hull_upgrade),
(pk("PAUS821_Suo"), suo1_upgrade),
(pk("PAUS822_Suo"), suo2_upgrade),
]);
let ship_data: BTreeMap<HashableValue, Value> = vec![
(pk("level"), Value::I64(9)),
(pk("group"), sv("special")),
(pk("ShipUpgradeInfo"), upgrade_info),
(pk("A_Hull"), a_hull),
(pk("AB1_FireControl"), ab1_fc),
(pk("AB2_FireControl"), ab2_fc),
]
.into_iter()
.collect();
let vehicle = build_ship(&ship_data);
let ttx = vehicle.ttx_components().expect("ttx components extracted");
assert_eq!(ttx.fire_control_max_dist_coef("PAUS821_Suo"), Some(1.0));
assert_eq!(ttx.fire_control_max_dist_coef("PAUS822_Suo"), Some(1.1));
assert_eq!(ttx.fire_control_max_dist_coef("NoSuchUpgrade"), None);
}
#[test]
fn build_ship_no_fire_control_yields_no_fc_entry() {
let hull_components = dict(vec![(pk("hull"), list(vec![sv("A_Hull")]))]);
let hull_upgrade = dict(vec![(pk("ucType"), sv("_Hull")), (pk("components"), hull_components)]);
let a_hull = dict(vec![(pk("health"), fv(10000.0)), (pk("model"), sv("A_Hull.model"))]);
let upgrade_info = dict(vec![(pk("PAUH001_Hull"), hull_upgrade)]);
let ship_data: BTreeMap<HashableValue, Value> = vec![
(pk("level"), Value::I64(5)),
(pk("group"), sv("special")),
(pk("ShipUpgradeInfo"), upgrade_info),
(pk("A_Hull"), a_hull),
]
.into_iter()
.collect();
let vehicle = build_ship(&ship_data);
let ttx = vehicle.ttx_components().expect("ttx components extracted");
assert!(ttx.fire_controls.is_empty());
assert!(ttx.fire_control_max_dist_coef("PAUH001_Hull").is_none());
}
}