diff --git a/crates/rrt-runtime/src/import.rs b/crates/rrt-runtime/src/import.rs index fbc92a5..b002d8e 100644 --- a/crates/rrt-runtime/src/import.rs +++ b/crates/rrt-runtime/src/import.rs @@ -5181,6 +5181,7 @@ mod tests { market_state: Some(crate::RuntimeCompanyMarketState { outstanding_shares: 20_000, bond_count: 2, + live_bond_slots: Vec::new(), largest_live_bond_principal: Some(500_000), highest_coupon_live_bond_principal: Some(350_000), mutable_support_scalar_raw_u32: 0x3f99999a, @@ -5235,6 +5236,7 @@ mod tests { market_state: Some(crate::RuntimeCompanyMarketState { outstanding_shares: 18_000, bond_count: 1, + live_bond_slots: Vec::new(), largest_live_bond_principal: Some(300_000), highest_coupon_live_bond_principal: Some(300_000), mutable_support_scalar_raw_u32: 0x3f4ccccd, @@ -6619,6 +6621,7 @@ mod tests { crate::RuntimeCompanyMarketState { outstanding_shares: 30_000, bond_count: 3, + live_bond_slots: Vec::new(), largest_live_bond_principal: Some(750_000), highest_coupon_live_bond_principal: Some(500_000), mutable_support_scalar_raw_u32: 0x3f19999a, diff --git a/crates/rrt-runtime/src/lib.rs b/crates/rrt-runtime/src/lib.rs index e0e366f..7014787 100644 --- a/crates/rrt-runtime/src/lib.rs +++ b/crates/rrt-runtime/src/lib.rs @@ -52,21 +52,22 @@ pub use runtime::{ RuntimeCargoCatalogEntry, RuntimeCargoClass, RuntimeCargoPriceTarget, RuntimeCargoProductionTarget, RuntimeChairmanMetric, RuntimeChairmanProfile, RuntimeChairmanTarget, RuntimeCompany, RuntimeCompanyAnnualFinanceState, - RuntimeCompanyConditionTestScope, RuntimeCompanyControllerKind, RuntimeCompanyMarketMetric, - RuntimeCompanyMarketState, RuntimeCompanyMetric, RuntimeCompanyStatBandCandidate, - RuntimeCompanyStatSelector, RuntimeCompanyTarget, RuntimeCompanyTerritoryAccess, - RuntimeCompanyTerritoryTrackPieceCount, RuntimeCondition, RuntimeConditionComparator, - RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, RuntimeLocomotiveCatalogEntry, - RuntimePackedEventCollectionSummary, RuntimePackedEventCompactControlSummary, - RuntimePackedEventConditionRowSummary, RuntimePackedEventGroupedEffectRowSummary, - RuntimePackedEventNegativeSentinelScopeSummary, RuntimePackedEventRecordSummary, - RuntimePackedEventTextBandSummary, RuntimePlayer, RuntimePlayerConditionTestScope, - RuntimePlayerTarget, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState, - RuntimeTerritory, RuntimeTerritoryMetric, RuntimeTerritoryTarget, RuntimeTrackMetric, - RuntimeTrackPieceCounts, RuntimeTrain, RuntimeWorldFinanceNeighborhoodCandidate, - RuntimeWorldIssueState, RuntimeWorldRestoreState, runtime_company_annual_finance_state, - runtime_company_assigned_share_pool, runtime_company_market_value, runtime_company_prime_rate, - runtime_company_stat_value, runtime_company_stat_value_f64, + RuntimeCompanyBondSlot, RuntimeCompanyConditionTestScope, RuntimeCompanyControllerKind, + RuntimeCompanyMarketMetric, RuntimeCompanyMarketState, RuntimeCompanyMetric, + RuntimeCompanyStatBandCandidate, RuntimeCompanyStatSelector, RuntimeCompanyTarget, + RuntimeCompanyTerritoryAccess, RuntimeCompanyTerritoryTrackPieceCount, RuntimeCondition, + RuntimeConditionComparator, RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate, + RuntimeLocomotiveCatalogEntry, RuntimePackedEventCollectionSummary, + RuntimePackedEventCompactControlSummary, RuntimePackedEventConditionRowSummary, + RuntimePackedEventGroupedEffectRowSummary, RuntimePackedEventNegativeSentinelScopeSummary, + RuntimePackedEventRecordSummary, RuntimePackedEventTextBandSummary, RuntimePlayer, + RuntimePlayerConditionTestScope, RuntimePlayerTarget, RuntimeSaveProfileState, + RuntimeServiceState, RuntimeState, RuntimeTerritory, RuntimeTerritoryMetric, + RuntimeTerritoryTarget, RuntimeTrackMetric, RuntimeTrackPieceCounts, RuntimeTrain, + RuntimeWorldFinanceNeighborhoodCandidate, RuntimeWorldIssueState, RuntimeWorldRestoreState, + runtime_company_annual_finance_state, runtime_company_assigned_share_pool, + runtime_company_average_live_bond_coupon, runtime_company_market_value, + runtime_company_prime_rate, runtime_company_stat_value, runtime_company_stat_value_f64, runtime_company_unassigned_share_pool, runtime_world_issue_opinion_multiplier, runtime_world_issue_opinion_term_sum_raw, runtime_world_issue_state, runtime_world_prime_rate_baseline, diff --git a/crates/rrt-runtime/src/runtime.rs b/crates/rrt-runtime/src/runtime.rs index 0518e19..e36fa2b 100644 --- a/crates/rrt-runtime/src/runtime.rs +++ b/crates/rrt-runtime/src/runtime.rs @@ -106,6 +106,15 @@ pub struct RuntimeCompanyMarketState { pub special_stat_family_232a_qword_bits: Vec, #[serde(default)] pub issue_opinion_terms_raw_i32: Vec, + #[serde(default)] + pub live_bond_slots: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeCompanyBondSlot { + pub slot_index: u32, + pub principal: u32, + pub coupon_rate_raw_u32: u32, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -1982,6 +1991,16 @@ pub fn runtime_company_stat_value_f64( } } +fn runtime_company_current_stat_value_f64( + state: &RuntimeState, + company_id: u32, + slot_id: u32, +) -> Option { + let market_state = state.service_state.company_market_state.get(&company_id)?; + let index = slot_id.checked_mul(RUNTIME_COMPANY_YEAR_STAT_FAMILY_SPAN)? as usize; + runtime_decode_saved_f64_bits(*market_state.year_stat_family_qword_bits.get(index)?) +} + fn runtime_company_control_transfer_stat_value_f64( state: &RuntimeState, company_id: u32, @@ -1992,7 +2011,16 @@ fn runtime_company_control_transfer_stat_value_f64( .iter() .find(|company| company.company_id == company_id)?; match slot_id { - RUNTIME_COMPANY_STAT_SLOT_CURRENT_CASH => Some(company.current_cash as f64), + 0x00..=0x12 if slot_id != RUNTIME_COMPANY_STAT_SLOT_CURRENT_CASH => { + runtime_company_current_stat_value_f64(state, company_id, slot_id) + } + RUNTIME_COMPANY_STAT_SLOT_CURRENT_CASH => { + if company.current_cash != 0 { + Some(company.current_cash as f64) + } else { + runtime_company_current_stat_value_f64(state, company_id, slot_id) + } + } RUNTIME_COMPANY_STAT_SLOT_BOOK_VALUE_PER_SHARE => Some(company.book_value_per_share as f64), _ => None, } @@ -2273,6 +2301,30 @@ pub fn runtime_company_prime_rate(state: &RuntimeState, company_id: u32) -> Opti runtime_round_f64_to_i64(baseline + (raw_issue_sum as f64) * 0.01) } +pub fn runtime_company_average_live_bond_coupon( + state: &RuntimeState, + company_id: u32, +) -> Option { + let market_state = state.service_state.company_market_state.get(&company_id)?; + if market_state.live_bond_slots.is_empty() { + return Some(0.0); + } + let mut weighted_coupon_sum = 0.0f64; + let mut total_principal = 0u64; + for slot in &market_state.live_bond_slots { + let coupon_rate = f32::from_bits(slot.coupon_rate_raw_u32) as f64; + if !coupon_rate.is_finite() { + continue; + } + weighted_coupon_sum += coupon_rate * (slot.principal as f64); + total_principal = total_principal.checked_add(slot.principal as u64)?; + } + if total_principal == 0 { + return Some(0.0); + } + Some(weighted_coupon_sum / total_principal as f64) +} + pub fn runtime_world_absolute_counter(state: &RuntimeState) -> Option { state.world_restore.absolute_counter_raw_u32 } @@ -4403,6 +4455,13 @@ mod tests { #[test] fn reads_grounded_company_stat_family_slots_from_runtime_state() { + let mut year_stat_family_qword_bits = vec![ + 0u64; + (RUNTIME_COMPANY_STAT_SLOT_COUNT * RUNTIME_COMPANY_YEAR_STAT_FAMILY_SPAN) + as usize + ]; + year_stat_family_qword_bits[(0x12 * RUNTIME_COMPANY_YEAR_STAT_FAMILY_SPAN) as usize] = + 75.0f64.to_bits(); let state = RuntimeState { calendar: CalendarPoint { year: 1830, @@ -4460,7 +4519,16 @@ mod tests { territory_runtime_variables: BTreeMap::new(), world_scalar_overrides: BTreeMap::new(), special_conditions: BTreeMap::new(), - service_state: RuntimeServiceState::default(), + service_state: RuntimeServiceState { + company_market_state: BTreeMap::from([( + 7, + RuntimeCompanyMarketState { + year_stat_family_qword_bits, + ..RuntimeCompanyMarketState::default() + }, + )]), + ..RuntimeServiceState::default() + }, }; assert_eq!( @@ -4485,6 +4553,17 @@ mod tests { ), Some(2_620) ); + assert_eq!( + runtime_company_stat_value( + &state, + 7, + RuntimeCompanyStatSelector { + family_id: RUNTIME_COMPANY_STAT_FAMILY_CONTROL_TRANSFER, + slot_id: 0x12, + }, + ), + Some(75) + ); assert_eq!( runtime_company_stat_value( &state, @@ -4494,7 +4573,7 @@ mod tests { slot_id: 0x2b, }, ), - None + Some(0) ); assert_eq!( runtime_company_stat_value( @@ -5327,6 +5406,93 @@ mod tests { assert_eq!(runtime_company_prime_rate(&state, 7), Some(7)); } + #[test] + fn computes_weighted_average_live_bond_coupon_from_owned_market_slots() { + let state = RuntimeState { + calendar: CalendarPoint { + year: 1830, + month_slot: 0, + phase_slot: 0, + tick_slot: 0, + }, + world_flags: BTreeMap::new(), + save_profile: RuntimeSaveProfileState::default(), + world_restore: RuntimeWorldRestoreState::default(), + metadata: BTreeMap::new(), + companies: vec![RuntimeCompany { + company_id: 7, + current_cash: 0, + debt: 0, + credit_rating_score: None, + prime_rate: None, + active: true, + available_track_laying_capacity: None, + controller_kind: RuntimeCompanyControllerKind::Unknown, + linked_chairman_profile_id: None, + book_value_per_share: 0, + investor_confidence: 0, + management_attitude: 0, + takeover_cooldown_year: None, + merger_cooldown_year: None, + track_piece_counts: RuntimeTrackPieceCounts::default(), + }], + selected_company_id: None, + players: Vec::new(), + selected_player_id: None, + chairman_profiles: Vec::new(), + selected_chairman_profile_id: None, + trains: Vec::new(), + locomotive_catalog: Vec::new(), + cargo_catalog: Vec::new(), + territories: Vec::new(), + company_territory_track_piece_counts: Vec::new(), + company_territory_access: Vec::new(), + packed_event_collection: None, + event_runtime_records: Vec::new(), + candidate_availability: BTreeMap::new(), + named_locomotive_availability: BTreeMap::new(), + named_locomotive_cost: BTreeMap::new(), + all_cargo_price_override: None, + named_cargo_price_overrides: BTreeMap::new(), + all_cargo_production_override: None, + factory_cargo_production_override: None, + farm_mine_cargo_production_override: None, + named_cargo_production_overrides: BTreeMap::new(), + cargo_production_overrides: BTreeMap::new(), + world_runtime_variables: BTreeMap::new(), + company_runtime_variables: BTreeMap::new(), + player_runtime_variables: BTreeMap::new(), + territory_runtime_variables: BTreeMap::new(), + world_scalar_overrides: BTreeMap::new(), + special_conditions: BTreeMap::new(), + service_state: RuntimeServiceState { + company_market_state: BTreeMap::from([( + 7, + RuntimeCompanyMarketState { + live_bond_slots: vec![ + RuntimeCompanyBondSlot { + slot_index: 0, + principal: 100_000, + coupon_rate_raw_u32: 0.04f32.to_bits(), + }, + RuntimeCompanyBondSlot { + slot_index: 1, + principal: 300_000, + coupon_rate_raw_u32: 0.08f32.to_bits(), + }, + ], + ..RuntimeCompanyMarketState::default() + }, + )]), + ..RuntimeServiceState::default() + }, + }; + + let average = runtime_company_average_live_bond_coupon(&state, 7) + .expect("weighted average live bond coupon"); + assert!((average - 0.07).abs() < 1e-6); + } + #[test] fn decodes_and_packs_company_issue_calendar_tuple() { let tuple = runtime_decode_packed_calendar_tuple(0x0101_0726, 0x0001_0001); diff --git a/crates/rrt-runtime/src/smp.rs b/crates/rrt-runtime/src/smp.rs index ecb0f4f..aa8715b 100644 --- a/crates/rrt-runtime/src/smp.rs +++ b/crates/rrt-runtime/src/smp.rs @@ -2388,6 +2388,8 @@ pub struct SmpSaveCompanyRecordAnalysisEntry { pub debt: u64, pub bond_count: u8, #[serde(default)] + pub live_bond_slots: Vec, + #[serde(default)] pub largest_live_bond_principal: Option, #[serde(default)] pub highest_coupon_live_bond_principal: Option, @@ -3065,6 +3067,7 @@ pub fn inspect_save_company_and_chairman_analysis_bytes( &bytes, record_offset + SAVE_COMPANY_RECORD_BOND_COUNT_OFFSET, )?; + let live_bond_slots = parse_save_company_live_bond_slots(&bytes, record_offset)?; let largest_live_bond_principal = parse_save_company_largest_live_bond_principal(&bytes, record_offset)?; let highest_coupon_live_bond_principal = @@ -3168,6 +3171,7 @@ pub fn inspect_save_company_and_chairman_analysis_bytes( outstanding_shares, debt, bond_count, + live_bond_slots, largest_live_bond_principal, highest_coupon_live_bond_principal, available_track_laying_capacity, @@ -3697,6 +3701,7 @@ fn parse_save_company_roster_probe( )?; let debt = parse_save_company_total_debt(bytes, record_offset)?; let bond_count = read_u8_at(bytes, record_offset + SAVE_COMPANY_RECORD_BOND_COUNT_OFFSET)?; + let live_bond_slots = parse_save_company_live_bond_slots(bytes, record_offset)?; let largest_live_bond_principal = parse_save_company_largest_live_bond_principal(bytes, record_offset)?; let highest_coupon_live_bond_principal = @@ -3822,11 +3827,17 @@ fn parse_save_company_roster_probe( SAVE_COMPANY_RECORD_ISSUE_OPINION_TERMS_OFFSET, SAVE_COMPANY_RECORD_ISSUE_OPINION_TERM_COUNT, )?; + let current_cash = decode_save_company_current_year_stat_slot( + &year_stat_family_qword_bits, + crate::RUNTIME_COMPANY_STAT_SLOT_CURRENT_CASH, + ) + .and_then(round_f64_to_i64) + .unwrap_or(0); entries.push(SmpLoadedCompanyRosterEntry { company_id, active, controller_kind: RuntimeCompanyControllerKind::Unknown, - current_cash: 0, + current_cash, debt, credit_rating_score: None, prime_rate: None, @@ -3841,6 +3852,7 @@ fn parse_save_company_roster_probe( market_state: Some(RuntimeCompanyMarketState { outstanding_shares, bond_count, + live_bond_slots, largest_live_bond_principal, highest_coupon_live_bond_principal, mutable_support_scalar_raw_u32, @@ -3947,6 +3959,15 @@ fn build_save_i32_term_strip( .collect::>>() } +fn decode_save_company_current_year_stat_slot( + year_stat_family_qword_bits: &[u64], + slot_id: u32, +) -> Option { + let index = slot_id.checked_mul(crate::RUNTIME_COMPANY_YEAR_STAT_FAMILY_SPAN)? as usize; + let value = f64::from_bits(*year_stat_family_qword_bits.get(index)?); + value.is_finite().then_some(value) +} + fn detect_save_company_record_start_offset( bytes: &[u8], header_probe: &SmpSaveTaggedCollectionHeaderProbe, @@ -4054,37 +4075,13 @@ fn parse_save_company_total_debt(bytes: &[u8], record_offset: usize) -> Option Option> { +) -> Option> { let bond_count = read_u8_at(bytes, record_offset + SAVE_COMPANY_RECORD_BOND_COUNT_OFFSET)? as usize; - let mut largest_live_principal: Option = None; - for slot_index in 0..bond_count { - let slot_offset = record_offset - .checked_add(SAVE_COMPANY_RECORD_BOND_TABLE_OFFSET)? - .checked_add(slot_index.checked_mul(SAVE_COMPANY_RECORD_BOND_SLOT_STRIDE)?)?; - let principal = read_i32_at(bytes, slot_offset)?; - if principal > 0 { - let principal = principal as u32; - largest_live_principal = Some(match largest_live_principal { - Some(current) => current.max(principal), - None => principal, - }); - } - } - Some(largest_live_principal) -} - -fn parse_save_company_highest_coupon_live_bond_principal( - bytes: &[u8], - record_offset: usize, -) -> Option> { - let bond_count = - read_u8_at(bytes, record_offset + SAVE_COMPANY_RECORD_BOND_COUNT_OFFSET)? as usize; - let mut highest_coupon_principal = None; - let mut highest_coupon_rate = None; + let mut slots = Vec::new(); for slot_index in 0..bond_count { let slot_offset = record_offset .checked_add(SAVE_COMPANY_RECORD_BOND_TABLE_OFFSET)? @@ -4098,19 +4095,49 @@ fn parse_save_company_highest_coupon_live_bond_principal( if !coupon_rate.is_finite() { continue; } - let principal = principal as u32; + slots.push(crate::RuntimeCompanyBondSlot { + slot_index: slot_index as u32, + principal: principal as u32, + coupon_rate_raw_u32, + }); + } + Some(slots) +} + +fn parse_save_company_largest_live_bond_principal( + bytes: &[u8], + record_offset: usize, +) -> Option> { + let mut largest_live_principal: Option = None; + for slot in parse_save_company_live_bond_slots(bytes, record_offset)? { + largest_live_principal = Some(match largest_live_principal { + Some(current) => current.max(slot.principal), + None => slot.principal, + }); + } + Some(largest_live_principal) +} + +fn parse_save_company_highest_coupon_live_bond_principal( + bytes: &[u8], + record_offset: usize, +) -> Option> { + let mut highest_coupon_principal = None; + let mut highest_coupon_rate = None; + for slot in parse_save_company_live_bond_slots(bytes, record_offset)? { + let coupon_rate = f32::from_bits(slot.coupon_rate_raw_u32); match highest_coupon_rate { Some(current_rate) if coupon_rate < current_rate => {} Some(current_rate) if coupon_rate == current_rate => { if let Some(current_principal) = highest_coupon_principal { - if principal > current_principal { - highest_coupon_principal = Some(principal); + if slot.principal > current_principal { + highest_coupon_principal = Some(slot.principal); } } } _ => { highest_coupon_rate = Some(coupon_rate); - highest_coupon_principal = Some(principal); + highest_coupon_principal = Some(slot.principal); } } } @@ -16466,6 +16493,12 @@ mod tests { .as_ref() .expect("company market state should load"); assert_eq!(market_state.outstanding_shares, 20_000); + assert_eq!(market_state.live_bond_slots.len(), 2); + assert_eq!(market_state.live_bond_slots[0].principal, 900_000); + assert_eq!( + market_state.live_bond_slots[1].coupon_rate_raw_u32, + 0.12f32.to_bits() + ); assert_eq!(market_state.largest_live_bond_principal, Some(900_000)); assert_eq!( market_state.highest_coupon_live_bond_principal, diff --git a/crates/rrt-runtime/src/summary.rs b/crates/rrt-runtime/src/summary.rs index bf568f0..d6788e3 100644 --- a/crates/rrt-runtime/src/summary.rs +++ b/crates/rrt-runtime/src/summary.rs @@ -2039,6 +2039,7 @@ mod tests { crate::RuntimeCompanyMarketState { outstanding_shares: 20_000, bond_count: 2, + live_bond_slots: Vec::new(), highest_coupon_live_bond_principal: Some(350_000), largest_live_bond_principal: Some(500_000), mutable_support_scalar_raw_u32: 0x3f800000,