rrt/crates/rrt-runtime/src/import.rs

2011 lines
76 KiB
Rust
Raw Normal View History

use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::persistence::{load_runtime_snapshot_document, validate_runtime_snapshot_document};
use crate::{
CalendarPoint, RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate,
RuntimePackedEventCollectionSummary, RuntimePackedEventRecordSummary,
RuntimePackedEventTextBandSummary, RuntimeSaveProfileState, RuntimeServiceState, RuntimeState,
RuntimeWorldRestoreState, SmpLoadedPackedEventRecordSummary,
SmpLoadedPackedEventTextBandSummary, SmpLoadedSaveSlice,
};
pub const STATE_DUMP_FORMAT_VERSION: u32 = 1;
2026-04-14 20:51:27 -07:00
pub const SAVE_SLICE_DOCUMENT_FORMAT_VERSION: u32 = 1;
pub const OVERLAY_IMPORT_DOCUMENT_FORMAT_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RuntimeStateDumpSource {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub source_binary: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeStateDumpDocument {
pub format_version: u32,
pub dump_id: String,
#[serde(default)]
pub source: RuntimeStateDumpSource,
pub state: RuntimeState,
}
2026-04-14 20:51:27 -07:00
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RuntimeSaveSliceDocumentSource {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub original_save_filename: Option<String>,
#[serde(default)]
pub original_save_sha256: Option<String>,
#[serde(default)]
pub notes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeSaveSliceDocument {
pub format_version: u32,
pub save_slice_id: String,
#[serde(default)]
pub source: RuntimeSaveSliceDocumentSource,
pub save_slice: SmpLoadedSaveSlice,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RuntimeOverlayImportDocumentSource {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub notes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeOverlayImportDocument {
pub format_version: u32,
pub import_id: String,
#[serde(default)]
pub source: RuntimeOverlayImportDocumentSource,
pub base_snapshot_path: String,
pub save_slice_path: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeStateImport {
pub import_id: String,
pub description: Option<String>,
pub state: RuntimeState,
}
#[derive(Debug)]
struct SaveSliceProjection {
world_flags: BTreeMap<String, bool>,
save_profile: RuntimeSaveProfileState,
world_restore: RuntimeWorldRestoreState,
metadata: BTreeMap<String, String>,
packed_event_collection: Option<RuntimePackedEventCollectionSummary>,
event_runtime_records: Vec<RuntimeEventRecord>,
candidate_availability: BTreeMap<String, u32>,
special_conditions: BTreeMap<String, u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SaveSliceProjectionMode {
Standalone,
Overlay,
}
pub fn project_save_slice_to_runtime_state_import(
save_slice: &SmpLoadedSaveSlice,
import_id: &str,
description: Option<String>,
) -> Result<RuntimeStateImport, String> {
if import_id.trim().is_empty() {
return Err("import_id must not be empty".to_string());
}
let projection = project_save_slice_components(
save_slice,
&BTreeSet::new(),
SaveSliceProjectionMode::Standalone,
)?;
let state = RuntimeState {
calendar: CalendarPoint {
year: 1830,
month_slot: 0,
phase_slot: 0,
tick_slot: 0,
},
world_flags: projection.world_flags,
save_profile: projection.save_profile,
world_restore: projection.world_restore,
metadata: projection.metadata,
companies: Vec::new(),
packed_event_collection: projection.packed_event_collection,
event_runtime_records: projection.event_runtime_records,
candidate_availability: projection.candidate_availability,
special_conditions: projection.special_conditions,
service_state: RuntimeServiceState::default(),
};
state.validate()?;
Ok(RuntimeStateImport {
import_id: import_id.to_string(),
description,
state,
})
}
pub fn project_save_slice_overlay_to_runtime_state_import(
base_state: &RuntimeState,
save_slice: &SmpLoadedSaveSlice,
import_id: &str,
description: Option<String>,
) -> Result<RuntimeStateImport, String> {
if import_id.trim().is_empty() {
return Err("import_id must not be empty".to_string());
}
base_state.validate()?;
let known_company_ids = base_state
.companies
.iter()
.map(|company| company.company_id)
.collect::<BTreeSet<_>>();
let projection = project_save_slice_components(
save_slice,
&known_company_ids,
SaveSliceProjectionMode::Overlay,
)?;
let mut world_flags = base_state.world_flags.clone();
world_flags.retain(|key, _| !key.starts_with("save_slice."));
world_flags.extend(projection.world_flags);
let mut metadata = base_state.metadata.clone();
metadata.retain(|key, _| !key.starts_with("save_slice."));
metadata.extend(projection.metadata);
let state = RuntimeState {
calendar: base_state.calendar,
world_flags,
save_profile: projection.save_profile,
world_restore: projection.world_restore,
metadata,
companies: base_state.companies.clone(),
packed_event_collection: projection.packed_event_collection,
event_runtime_records: projection.event_runtime_records,
candidate_availability: projection.candidate_availability,
special_conditions: projection.special_conditions,
service_state: base_state.service_state.clone(),
};
state.validate()?;
Ok(RuntimeStateImport {
import_id: import_id.to_string(),
description,
state,
})
}
fn project_save_slice_components(
save_slice: &SmpLoadedSaveSlice,
known_company_ids: &BTreeSet<u32>,
mode: SaveSliceProjectionMode,
) -> Result<SaveSliceProjection, String> {
let mut world_flags = BTreeMap::new();
world_flags.insert(
"save_slice.profile_present".to_string(),
save_slice.profile.is_some(),
);
world_flags.insert(
"save_slice.candidate_availability_present".to_string(),
save_slice.candidate_availability_table.is_some(),
);
world_flags.insert(
"save_slice.special_conditions_present".to_string(),
save_slice.special_conditions_table.is_some(),
);
world_flags.insert(
"save_slice.event_runtime_collection_present".to_string(),
save_slice.event_runtime_collection.is_some(),
);
world_flags.insert(
"save_slice.mechanism_confidence_grounded".to_string(),
save_slice.mechanism_confidence == "grounded",
);
if let Some(profile) = &save_slice.profile {
world_flags.insert(
"save_slice.profile_byte_0x82_nonzero".to_string(),
profile.profile_byte_0x82 != 0,
);
world_flags.insert(
"save_slice.profile_byte_0x97_nonzero".to_string(),
profile.profile_byte_0x97 != 0,
);
world_flags.insert(
"save_slice.profile_byte_0xc5_nonzero".to_string(),
profile.profile_byte_0xc5 != 0,
);
}
let mut metadata = BTreeMap::new();
metadata.insert(
"save_slice.import_projection".to_string(),
match mode {
SaveSliceProjectionMode::Standalone => "partial-runtime-restore-v1",
SaveSliceProjectionMode::Overlay => "overlay-runtime-restore-v1",
}
.to_string(),
);
metadata.insert(
"save_slice.calendar_source".to_string(),
match mode {
SaveSliceProjectionMode::Standalone => "default-1830-placeholder",
SaveSliceProjectionMode::Overlay => "base-snapshot-preserved",
}
.to_string(),
);
metadata.insert(
"save_slice.selected_year_seed_tuple_source".to_string(),
"raw-lane-via-0x51d3f0".to_string(),
);
metadata.insert(
"save_slice.selected_year_absolute_counter_source".to_string(),
"mode-adjusted-lane-via-0x51d390-0x409e80".to_string(),
);
metadata.insert(
"save_slice.selected_year_absolute_counter_reconstructible_from_save".to_string(),
"false".to_string(),
);
metadata.insert(
"save_slice.disable_cargo_economy_special_condition_slot".to_string(),
"30".to_string(),
);
metadata.insert(
"save_slice.disable_cargo_economy_special_condition_reconstructible_from_save".to_string(),
"true".to_string(),
);
metadata.insert(
"save_slice.disable_cargo_economy_special_condition_write_side_grounded".to_string(),
"true".to_string(),
);
metadata.insert(
"save_slice.selected_year_absolute_counter_adjustment_context".to_string(),
"editor-map-mode,shell-selected-year-adjust-policy-0x9d26-0x9d28,save-special-condition-disable-cargo-economy-slot-30"
.to_string(),
);
metadata.insert(
"save_slice.mechanism_family".to_string(),
save_slice.mechanism_family.clone(),
);
metadata.insert(
"save_slice.mechanism_confidence".to_string(),
save_slice.mechanism_confidence.clone(),
);
if let Some(family) = &save_slice.container_profile_family {
metadata.insert(
"save_slice.container_profile_family".to_string(),
family.clone(),
);
}
if let Some(family) = &save_slice.trailer_family {
metadata.insert("save_slice.trailer_family".to_string(), family.clone());
}
if let Some(family) = &save_slice.bridge_family {
metadata.insert("save_slice.bridge_family".to_string(), family.clone());
}
let (packed_event_collection, event_runtime_records) =
project_packed_event_collection(save_slice, known_company_ids)?;
if let Some(summary) = &save_slice.event_runtime_collection {
metadata.insert(
"save_slice.event_runtime_collection_source_kind".to_string(),
summary.source_kind.clone(),
);
metadata.insert(
"save_slice.event_runtime_collection_version_hex".to_string(),
summary.packed_state_version_hex.clone(),
);
metadata.insert(
"save_slice.event_runtime_collection_record_count".to_string(),
summary.live_record_count.to_string(),
);
metadata.insert(
"save_slice.event_runtime_collection_decoded_record_count".to_string(),
summary.decoded_record_count.to_string(),
);
metadata.insert(
"save_slice.event_runtime_collection_imported_runtime_record_count".to_string(),
event_runtime_records.len().to_string(),
);
}
let save_profile = if let Some(profile) = &save_slice.profile {
metadata.insert(
"save_slice.profile_kind".to_string(),
profile.profile_kind.clone(),
);
metadata.insert(
"save_slice.profile_family".to_string(),
profile.profile_family.clone(),
);
metadata.insert(
"save_slice.packed_profile_offset".to_string(),
profile.packed_profile_offset.to_string(),
);
metadata.insert(
"save_slice.packed_profile_len".to_string(),
profile.packed_profile_len.to_string(),
);
metadata.insert(
"save_slice.leading_word_0_hex".to_string(),
profile.leading_word_0_hex.clone(),
);
metadata.insert(
"save_slice.profile_byte_0x77_hex".to_string(),
profile.profile_byte_0x77_hex.clone(),
);
metadata.insert(
"save_slice.profile_byte_0x82_hex".to_string(),
profile.profile_byte_0x82_hex.clone(),
);
metadata.insert(
"save_slice.profile_byte_0x97_hex".to_string(),
profile.profile_byte_0x97_hex.clone(),
);
metadata.insert(
"save_slice.profile_byte_0xc5_hex".to_string(),
profile.profile_byte_0xc5_hex.clone(),
);
if let Some(header_flag_word_3_hex) = &profile.header_flag_word_3_hex {
metadata.insert(
"save_slice.header_flag_word_3_hex".to_string(),
header_flag_word_3_hex.clone(),
);
}
if let Some(map_path) = &profile.map_path {
metadata.insert("save_slice.map_path".to_string(), map_path.clone());
}
if let Some(display_name) = &profile.display_name {
metadata.insert("save_slice.display_name".to_string(), display_name.clone());
}
RuntimeSaveProfileState {
profile_kind: Some(profile.profile_kind.clone()),
profile_family: Some(profile.profile_family.clone()),
map_path: profile.map_path.clone(),
display_name: profile.display_name.clone(),
selected_year_profile_lane: Some(profile.profile_byte_0x77),
sandbox_enabled: Some(profile.profile_byte_0x82 != 0),
campaign_scenario_enabled: Some(profile.profile_byte_0xc5 != 0),
staged_profile_copy_on_restore: Some(profile.profile_byte_0x97 != 0),
}
} else {
RuntimeSaveProfileState::default()
};
let special_condition_enabled = |slot_index: u8| {
save_slice.special_conditions_table.as_ref().map(|table| {
table
.entries
.iter()
.find(|entry| entry.slot_index == slot_index)
.map(|entry| entry.value != 0)
.unwrap_or(false)
})
};
let world_restore = if let Some(profile) = &save_slice.profile {
let disable_cargo_economy_special_condition_enabled = special_condition_enabled(30);
RuntimeWorldRestoreState {
selected_year_profile_lane: Some(profile.profile_byte_0x77),
campaign_scenario_enabled: Some(profile.profile_byte_0xc5 != 0),
sandbox_enabled: Some(profile.profile_byte_0x82 != 0),
seed_tuple_written_from_raw_lane: Some(true),
absolute_counter_requires_shell_context: Some(true),
absolute_counter_reconstructible_from_save: Some(false),
disable_cargo_economy_special_condition_slot: Some(30),
disable_cargo_economy_special_condition_reconstructible_from_save: Some(true),
disable_cargo_economy_special_condition_write_side_grounded: Some(true),
disable_cargo_economy_special_condition_enabled,
use_bio_accelerator_cars_enabled: special_condition_enabled(29),
use_wartime_cargos_enabled: special_condition_enabled(31),
disable_train_crashes_enabled: special_condition_enabled(32),
disable_train_crashes_and_breakdowns_enabled: special_condition_enabled(33),
ai_ignore_territories_at_startup_enabled: special_condition_enabled(34),
absolute_counter_restore_kind: Some(
"mode-adjusted-selected-year-lane".to_string(),
),
absolute_counter_adjustment_context: Some(
"editor-map-mode,shell-selected-year-adjust-policy-0x9d26-0x9d28,save-special-condition-disable-cargo-economy-slot-30"
.to_string(),
),
}
} else {
RuntimeWorldRestoreState::default()
};
let mut candidate_availability = BTreeMap::new();
if let Some(table) = &save_slice.candidate_availability_table {
metadata.insert(
"save_slice.candidate_table_source_kind".to_string(),
table.source_kind.clone(),
);
metadata.insert(
"save_slice.candidate_table_semantic_family".to_string(),
table.semantic_family.clone(),
);
metadata.insert(
"save_slice.candidate_table_entry_count".to_string(),
table.observed_entry_count.to_string(),
);
metadata.insert(
"save_slice.candidate_table_zero_count".to_string(),
table.zero_availability_count.to_string(),
);
for entry in &table.entries {
candidate_availability.insert(entry.text.clone(), entry.availability_dword);
}
}
let mut special_conditions = BTreeMap::new();
if let Some(table) = &save_slice.special_conditions_table {
metadata.insert(
"save_slice.special_conditions_source_kind".to_string(),
table.source_kind.clone(),
);
metadata.insert(
"save_slice.special_conditions_table_offset".to_string(),
table.table_offset.to_string(),
);
metadata.insert(
"save_slice.special_conditions_enabled_visible_count".to_string(),
table.enabled_visible_count.to_string(),
);
for entry in &table.entries {
if !entry.hidden {
special_conditions.insert(entry.label.clone(), entry.value);
}
}
}
for (index, note) in save_slice.notes.iter().enumerate() {
metadata.insert(format!("save_slice.note.{index}"), note.clone());
}
Ok(SaveSliceProjection {
world_flags,
save_profile,
world_restore,
metadata,
packed_event_collection,
event_runtime_records,
candidate_availability,
special_conditions,
})
}
fn project_packed_event_collection(
save_slice: &SmpLoadedSaveSlice,
known_company_ids: &BTreeSet<u32>,
) -> Result<
(
Option<RuntimePackedEventCollectionSummary>,
Vec<RuntimeEventRecord>,
),
String,
> {
let Some(summary) = save_slice.event_runtime_collection.as_ref() else {
return Ok((None, Vec::new()));
};
let mut imported_runtime_records = Vec::new();
let mut imported_record_ids = BTreeSet::new();
for record in &summary.records {
if let Some(import_result) =
smp_packed_record_to_runtime_event_record(record, known_company_ids)
{
let runtime_record = import_result?;
imported_record_ids.insert(record.live_entry_id);
imported_runtime_records.push(runtime_record);
}
}
let records = summary
.records
.iter()
.map(|record| {
runtime_packed_event_record_summary_from_smp(
record,
known_company_ids,
imported_record_ids.contains(&record.live_entry_id),
)
})
.collect::<Vec<_>>();
Ok((
Some(RuntimePackedEventCollectionSummary {
source_kind: summary.source_kind.clone(),
mechanism_family: summary.mechanism_family.clone(),
mechanism_confidence: summary.mechanism_confidence.clone(),
container_profile_family: summary.container_profile_family.clone(),
packed_state_version: summary.packed_state_version,
packed_state_version_hex: summary.packed_state_version_hex.clone(),
live_id_bound: summary.live_id_bound,
live_record_count: summary.live_record_count,
live_entry_ids: summary.live_entry_ids.clone(),
decoded_record_count: records
.iter()
.filter(|record| record.decode_status != "unsupported_framing")
.count(),
imported_runtime_record_count: imported_runtime_records.len(),
records,
}),
imported_runtime_records,
))
}
fn runtime_packed_event_record_summary_from_smp(
record: &SmpLoadedPackedEventRecordSummary,
known_company_ids: &BTreeSet<u32>,
imported: bool,
) -> RuntimePackedEventRecordSummary {
RuntimePackedEventRecordSummary {
record_index: record.record_index,
live_entry_id: record.live_entry_id,
payload_offset: record.payload_offset,
payload_len: record.payload_len,
decode_status: record.decode_status.clone(),
trigger_kind: record.trigger_kind,
active: record.active,
marks_collection_dirty: record.marks_collection_dirty,
one_shot: record.one_shot,
text_bands: record
.text_bands
.iter()
.map(runtime_packed_event_text_band_summary_from_smp)
.collect(),
standalone_condition_row_count: record.standalone_condition_row_count,
grouped_effect_row_counts: record.grouped_effect_row_counts.clone(),
decoded_actions: record.decoded_actions.clone(),
executable_import_ready: record.executable_import_ready,
import_outcome: Some(determine_packed_event_import_outcome(
record,
known_company_ids,
imported,
)),
notes: record.notes.clone(),
}
}
fn runtime_packed_event_text_band_summary_from_smp(
band: &SmpLoadedPackedEventTextBandSummary,
) -> RuntimePackedEventTextBandSummary {
RuntimePackedEventTextBandSummary {
label: band.label.clone(),
packed_len: band.packed_len,
present: band.present,
preview: band.preview.clone(),
}
}
fn smp_packed_record_to_runtime_event_record(
record: &SmpLoadedPackedEventRecordSummary,
known_company_ids: &BTreeSet<u32>,
) -> Option<Result<RuntimeEventRecord, String>> {
if record.decode_status == "unsupported_framing" {
return None;
}
let effects =
match smp_runtime_effects_to_runtime_effects(&record.decoded_actions, known_company_ids) {
Ok(effects) => effects,
Err(err) if err.contains("unresolved company ids") => return None,
Err(_) => return None,
};
Some((|| {
let trigger_kind = record.trigger_kind.ok_or_else(|| {
format!(
"packed event record {} is missing trigger_kind",
record.live_entry_id
)
})?;
let active = record.active.ok_or_else(|| {
format!(
"packed event record {} is missing active flag",
record.live_entry_id
)
})?;
let marks_collection_dirty = record.marks_collection_dirty.ok_or_else(|| {
format!(
"packed event record {} is missing dirty flag",
record.live_entry_id
)
})?;
let one_shot = record.one_shot.ok_or_else(|| {
format!(
"packed event record {} is missing one_shot flag",
record.live_entry_id
)
})?;
Ok(RuntimeEventRecordTemplate {
record_id: record.live_entry_id,
trigger_kind,
active,
marks_collection_dirty,
one_shot,
effects,
}
.into_runtime_record())
})())
}
fn smp_runtime_effects_to_runtime_effects(
effects: &[RuntimeEffect],
known_company_ids: &BTreeSet<u32>,
) -> Result<Vec<RuntimeEffect>, String> {
effects
.iter()
.map(|effect| smp_runtime_effect_to_runtime_effect(effect, known_company_ids))
.collect()
}
fn smp_runtime_effect_to_runtime_effect(
effect: &RuntimeEffect,
known_company_ids: &BTreeSet<u32>,
) -> Result<RuntimeEffect, String> {
match effect {
RuntimeEffect::SetWorldFlag { key, value } => Ok(RuntimeEffect::SetWorldFlag {
key: key.clone(),
value: *value,
}),
RuntimeEffect::AdjustCompanyCash { target, delta } => {
if company_target_supported_for_import(target, known_company_ids) {
Ok(RuntimeEffect::AdjustCompanyCash {
target: target.clone(),
delta: *delta,
})
} else {
Err("packed company-cash effect requires unresolved company ids".to_string())
}
}
RuntimeEffect::AdjustCompanyDebt { target, delta } => {
if company_target_supported_for_import(target, known_company_ids) {
Ok(RuntimeEffect::AdjustCompanyDebt {
target: target.clone(),
delta: *delta,
})
} else {
Err("packed company-debt effect requires unresolved company ids".to_string())
}
}
RuntimeEffect::SetCandidateAvailability { name, value } => {
Ok(RuntimeEffect::SetCandidateAvailability {
name: name.clone(),
value: *value,
})
}
RuntimeEffect::SetSpecialCondition { label, value } => {
Ok(RuntimeEffect::SetSpecialCondition {
label: label.clone(),
value: *value,
})
}
RuntimeEffect::AppendEventRecord { record } => Ok(RuntimeEffect::AppendEventRecord {
record: Box::new(smp_runtime_record_template_to_runtime(
record,
known_company_ids,
)?),
}),
RuntimeEffect::ActivateEventRecord { record_id } => {
Ok(RuntimeEffect::ActivateEventRecord {
record_id: *record_id,
})
}
RuntimeEffect::DeactivateEventRecord { record_id } => {
Ok(RuntimeEffect::DeactivateEventRecord {
record_id: *record_id,
})
}
RuntimeEffect::RemoveEventRecord { record_id } => Ok(RuntimeEffect::RemoveEventRecord {
record_id: *record_id,
}),
}
}
fn smp_runtime_record_template_to_runtime(
record: &RuntimeEventRecordTemplate,
known_company_ids: &BTreeSet<u32>,
) -> Result<RuntimeEventRecordTemplate, String> {
Ok(RuntimeEventRecordTemplate {
record_id: record.record_id,
trigger_kind: record.trigger_kind,
active: record.active,
marks_collection_dirty: record.marks_collection_dirty,
one_shot: record.one_shot,
effects: smp_runtime_effects_to_runtime_effects(&record.effects, known_company_ids)?,
})
}
fn company_target_supported_for_import(
target: &crate::RuntimeCompanyTarget,
known_company_ids: &BTreeSet<u32>,
) -> bool {
match target {
crate::RuntimeCompanyTarget::AllActive => true,
crate::RuntimeCompanyTarget::Ids { ids } => {
!ids.is_empty()
&& ids
.iter()
.all(|company_id| known_company_ids.contains(company_id))
}
}
}
fn determine_packed_event_import_outcome(
record: &SmpLoadedPackedEventRecordSummary,
known_company_ids: &BTreeSet<u32>,
imported: bool,
) -> String {
if imported {
return "imported".to_string();
}
if record.decode_status == "unsupported_framing" {
return "blocked_unsupported_decode".to_string();
}
if packed_record_requires_missing_company_context(record, known_company_ids) {
return "blocked_missing_company_context".to_string();
}
"blocked_unsupported_decode".to_string()
}
fn packed_record_requires_missing_company_context(
record: &SmpLoadedPackedEventRecordSummary,
known_company_ids: &BTreeSet<u32>,
) -> bool {
record
.decoded_actions
.iter()
.any(|effect| runtime_effect_requires_missing_company_context(effect, known_company_ids))
}
fn runtime_effect_requires_missing_company_context(
effect: &RuntimeEffect,
known_company_ids: &BTreeSet<u32>,
) -> bool {
match effect {
RuntimeEffect::AdjustCompanyCash { target, .. }
| RuntimeEffect::AdjustCompanyDebt { target, .. } => {
!company_target_supported_for_import(target, known_company_ids)
}
RuntimeEffect::AppendEventRecord { record } => record.effects.iter().any(|nested| {
runtime_effect_requires_missing_company_context(nested, known_company_ids)
}),
RuntimeEffect::SetWorldFlag { .. }
| RuntimeEffect::SetCandidateAvailability { .. }
| RuntimeEffect::SetSpecialCondition { .. }
| RuntimeEffect::ActivateEventRecord { .. }
| RuntimeEffect::DeactivateEventRecord { .. }
| RuntimeEffect::RemoveEventRecord { .. } => false,
}
}
pub fn validate_runtime_state_dump_document(
document: &RuntimeStateDumpDocument,
) -> Result<(), String> {
if document.format_version != STATE_DUMP_FORMAT_VERSION {
return Err(format!(
"unsupported state dump format_version {} (expected {})",
document.format_version, STATE_DUMP_FORMAT_VERSION
));
}
if document.dump_id.trim().is_empty() {
return Err("dump_id must not be empty".to_string());
}
document.state.validate()
}
2026-04-14 20:51:27 -07:00
pub fn validate_runtime_save_slice_document(
document: &RuntimeSaveSliceDocument,
) -> Result<(), String> {
if document.format_version != SAVE_SLICE_DOCUMENT_FORMAT_VERSION {
return Err(format!(
"unsupported save slice document format_version {} (expected {})",
document.format_version, SAVE_SLICE_DOCUMENT_FORMAT_VERSION
));
}
if document.save_slice_id.trim().is_empty() {
return Err("save_slice_id must not be empty".to_string());
}
if document
.source
.description
.as_deref()
.is_some_and(|text| text.trim().is_empty())
{
return Err("save slice source.description must not be empty".to_string());
}
if document
.source
.original_save_filename
.as_deref()
.is_some_and(|text| text.trim().is_empty())
{
return Err("save slice source.original_save_filename must not be empty".to_string());
}
if document
.source
.original_save_sha256
.as_deref()
.is_some_and(|text| text.trim().is_empty())
{
return Err("save slice source.original_save_sha256 must not be empty".to_string());
}
for (index, note) in document.source.notes.iter().enumerate() {
if note.trim().is_empty() {
return Err(format!(
"save slice source.notes[{index}] must not be empty"
));
}
}
if document.save_slice.mechanism_family.trim().is_empty() {
return Err("save_slice.mechanism_family must not be empty".to_string());
}
if document.save_slice.mechanism_confidence.trim().is_empty() {
return Err("save_slice.mechanism_confidence must not be empty".to_string());
}
Ok(())
}
pub fn validate_runtime_overlay_import_document(
document: &RuntimeOverlayImportDocument,
) -> Result<(), String> {
if document.format_version != OVERLAY_IMPORT_DOCUMENT_FORMAT_VERSION {
return Err(format!(
"unsupported overlay import document format_version {} (expected {})",
document.format_version, OVERLAY_IMPORT_DOCUMENT_FORMAT_VERSION
));
}
if document.import_id.trim().is_empty() {
return Err("import_id must not be empty".to_string());
}
if document
.source
.description
.as_deref()
.is_some_and(|text| text.trim().is_empty())
{
return Err("overlay import source.description must not be empty".to_string());
}
for (index, note) in document.source.notes.iter().enumerate() {
if note.trim().is_empty() {
return Err(format!(
"overlay import source.notes[{index}] must not be empty"
));
}
}
if document.base_snapshot_path.trim().is_empty() {
return Err("base_snapshot_path must not be empty".to_string());
}
if document.save_slice_path.trim().is_empty() {
return Err("save_slice_path must not be empty".to_string());
}
Ok(())
}
2026-04-14 20:51:27 -07:00
pub fn load_runtime_save_slice_document(
path: &Path,
) -> Result<RuntimeSaveSliceDocument, Box<dyn std::error::Error>> {
let text = std::fs::read_to_string(path)?;
let document: RuntimeSaveSliceDocument = serde_json::from_str(&text)?;
Ok(document)
}
pub fn load_runtime_overlay_import_document(
path: &Path,
) -> Result<RuntimeOverlayImportDocument, Box<dyn std::error::Error>> {
let text = std::fs::read_to_string(path)?;
let document: RuntimeOverlayImportDocument = serde_json::from_str(&text)?;
Ok(document)
}
2026-04-14 20:51:27 -07:00
pub fn save_runtime_save_slice_document(
path: &Path,
document: &RuntimeSaveSliceDocument,
) -> Result<(), Box<dyn std::error::Error>> {
validate_runtime_save_slice_document(document)
.map_err(|err| format!("invalid runtime save slice document: {err}"))?;
let bytes = serde_json::to_vec_pretty(document)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, bytes)?;
Ok(())
}
pub fn save_runtime_overlay_import_document(
path: &Path,
document: &RuntimeOverlayImportDocument,
) -> Result<(), Box<dyn std::error::Error>> {
validate_runtime_overlay_import_document(document)
.map_err(|err| format!("invalid runtime overlay import document: {err}"))?;
let bytes = serde_json::to_vec_pretty(document)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, bytes)?;
Ok(())
}
pub fn load_runtime_state_import(
path: &Path,
) -> Result<RuntimeStateImport, Box<dyn std::error::Error>> {
let text = std::fs::read_to_string(path)?;
load_runtime_state_import_from_str_with_base(
&text,
path.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("runtime-state"),
path.parent().unwrap_or_else(|| Path::new(".")),
)
}
pub fn load_runtime_state_import_from_str(
text: &str,
fallback_id: &str,
) -> Result<RuntimeStateImport, Box<dyn std::error::Error>> {
load_runtime_state_import_from_str_with_base(text, fallback_id, Path::new("."))
}
fn load_runtime_state_import_from_str_with_base(
text: &str,
fallback_id: &str,
base_dir: &Path,
) -> Result<RuntimeStateImport, Box<dyn std::error::Error>> {
if let Ok(document) = serde_json::from_str::<RuntimeStateDumpDocument>(text) {
validate_runtime_state_dump_document(&document)
.map_err(|err| format!("invalid runtime state dump document: {err}"))?;
return Ok(RuntimeStateImport {
import_id: document.dump_id,
description: document.source.description,
state: document.state,
});
}
2026-04-14 20:51:27 -07:00
if let Ok(document) = serde_json::from_str::<RuntimeSaveSliceDocument>(text) {
validate_runtime_save_slice_document(&document)
.map_err(|err| format!("invalid runtime save slice document: {err}"))?;
let mut description_parts = Vec::new();
if let Some(description) = document.source.description {
description_parts.push(description);
}
if let Some(filename) = document.source.original_save_filename {
description_parts.push(format!("source save {filename}"));
}
let import = project_save_slice_to_runtime_state_import(
&document.save_slice,
&document.save_slice_id,
if description_parts.is_empty() {
None
} else {
Some(description_parts.join(" | "))
},
)?;
return Ok(import);
}
if let Ok(document) = serde_json::from_str::<RuntimeOverlayImportDocument>(text) {
validate_runtime_overlay_import_document(&document)
.map_err(|err| format!("invalid runtime overlay import document: {err}"))?;
let base_snapshot_path = resolve_document_path(base_dir, &document.base_snapshot_path);
let save_slice_path = resolve_document_path(base_dir, &document.save_slice_path);
let snapshot = load_runtime_snapshot_document(&base_snapshot_path)?;
validate_runtime_snapshot_document(&snapshot).map_err(|err| {
format!(
"invalid runtime snapshot {}: {err}",
base_snapshot_path.display()
)
})?;
let save_slice_document = load_runtime_save_slice_document(&save_slice_path)?;
validate_runtime_save_slice_document(&save_slice_document).map_err(|err| {
format!(
"invalid runtime save slice document {}: {err}",
save_slice_path.display()
)
})?;
let mut description_parts = Vec::new();
if let Some(description) = document.source.description {
description_parts.push(description);
}
if let Some(description) = snapshot.source.description {
description_parts.push(format!("base snapshot {description}"));
}
if let Some(description) = save_slice_document.source.description {
description_parts.push(format!("save slice {description}"));
}
return project_save_slice_overlay_to_runtime_state_import(
&snapshot.state,
&save_slice_document.save_slice,
&document.import_id,
if description_parts.is_empty() {
None
} else {
Some(description_parts.join(" | "))
},
)
.map_err(Into::into);
}
let state: RuntimeState = serde_json::from_str(text)?;
state
.validate()
.map_err(|err| format!("invalid runtime state: {err}"))?;
Ok(RuntimeStateImport {
import_id: fallback_id.to_string(),
description: None,
state,
})
}
fn resolve_document_path(base_dir: &Path, path: &str) -> PathBuf {
let candidate = PathBuf::from(path);
if candidate.is_absolute() {
candidate
} else {
base_dir.join(candidate)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{StepCommand, execute_step_command};
fn state() -> RuntimeState {
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::new(),
packed_event_collection: None,
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
}
}
fn packed_text_bands() -> Vec<crate::SmpLoadedPackedEventTextBandSummary> {
vec![
crate::SmpLoadedPackedEventTextBandSummary {
label: "primary_text_band".to_string(),
packed_len: 5,
present: true,
preview: "Alpha".to_string(),
},
crate::SmpLoadedPackedEventTextBandSummary {
label: "secondary_text_band_0".to_string(),
packed_len: 0,
present: false,
preview: "".to_string(),
},
crate::SmpLoadedPackedEventTextBandSummary {
label: "secondary_text_band_1".to_string(),
packed_len: 0,
present: false,
preview: "".to_string(),
},
crate::SmpLoadedPackedEventTextBandSummary {
label: "secondary_text_band_2".to_string(),
packed_len: 0,
present: false,
preview: "".to_string(),
},
crate::SmpLoadedPackedEventTextBandSummary {
label: "secondary_text_band_3".to_string(),
packed_len: 0,
present: false,
preview: "".to_string(),
},
crate::SmpLoadedPackedEventTextBandSummary {
label: "secondary_text_band_4".to_string(),
packed_len: 0,
present: false,
preview: "".to_string(),
},
]
}
#[test]
fn loads_dump_document() {
let text = serde_json::to_string(&RuntimeStateDumpDocument {
format_version: STATE_DUMP_FORMAT_VERSION,
dump_id: "dump-smoke".to_string(),
source: RuntimeStateDumpSource {
description: Some("test dump".to_string()),
source_binary: None,
},
state: state(),
})
.expect("dump should serialize");
let import =
load_runtime_state_import_from_str(&text, "fallback").expect("dump should load");
assert_eq!(import.import_id, "dump-smoke");
assert_eq!(import.description.as_deref(), Some("test dump"));
}
#[test]
fn loads_bare_runtime_state() {
let text = serde_json::to_string(&state()).expect("state should serialize");
let import =
load_runtime_state_import_from_str(&text, "fallback").expect("state should load");
assert_eq!(import.import_id, "fallback");
assert!(import.description.is_none());
}
2026-04-14 20:51:27 -07:00
#[test]
fn validates_and_roundtrips_save_slice_document() {
let document = RuntimeSaveSliceDocument {
format_version: SAVE_SLICE_DOCUMENT_FORMAT_VERSION,
save_slice_id: "save-slice-smoke".to_string(),
source: RuntimeSaveSliceDocumentSource {
description: Some("test save slice".to_string()),
original_save_filename: Some("smoke.gms".to_string()),
original_save_sha256: Some("deadbeef".to_string()),
notes: vec!["captured fixture".to_string()],
},
save_slice: crate::SmpLoadedSaveSlice {
file_extension_hint: Some("gms".to_string()),
container_profile_family: Some("rt3-classic-save-container-v1".to_string()),
mechanism_family: "classic-save-rehydrate-v1".to_string(),
mechanism_confidence: "grounded".to_string(),
trailer_family: None,
bridge_family: None,
profile: None,
candidate_availability_table: None,
special_conditions_table: None,
event_runtime_collection: None,
notes: vec![],
},
};
assert!(validate_runtime_save_slice_document(&document).is_ok());
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time should be after epoch")
.as_nanos();
let path = std::env::temp_dir().join(format!("rrt-save-slice-doc-{nonce}.json"));
save_runtime_save_slice_document(&path, &document).expect("save slice doc should save");
let loaded = load_runtime_save_slice_document(&path).expect("save slice doc should load");
assert_eq!(document, loaded);
let _ = std::fs::remove_file(path);
}
#[test]
fn loads_save_slice_document_as_runtime_state_import() {
let text = serde_json::to_string(&RuntimeSaveSliceDocument {
format_version: SAVE_SLICE_DOCUMENT_FORMAT_VERSION,
save_slice_id: "save-slice-import".to_string(),
source: RuntimeSaveSliceDocumentSource {
description: Some("test save slice import".to_string()),
original_save_filename: Some("import.gms".to_string()),
original_save_sha256: None,
notes: vec![],
},
save_slice: crate::SmpLoadedSaveSlice {
file_extension_hint: Some("gms".to_string()),
container_profile_family: Some("rt3-classic-save-container-v1".to_string()),
mechanism_family: "classic-save-rehydrate-v1".to_string(),
mechanism_confidence: "grounded".to_string(),
trailer_family: None,
bridge_family: None,
profile: None,
candidate_availability_table: None,
special_conditions_table: None,
event_runtime_collection: None,
notes: vec![],
},
})
.expect("save slice doc should serialize");
let import = load_runtime_state_import_from_str(&text, "fallback")
.expect("save slice document should load as runtime import");
assert_eq!(import.import_id, "save-slice-import");
assert_eq!(
import
.state
.metadata
.get("save_slice.import_projection")
.map(String::as_str),
Some("partial-runtime-restore-v1")
);
}
#[test]
fn projects_save_slice_into_runtime_state_import() {
let save_slice = SmpLoadedSaveSlice {
file_extension_hint: Some("gms".to_string()),
container_profile_family: Some("rt3-105-save-container-v1".to_string()),
mechanism_family: "rt3-105-save-post-span-bridge-v1".to_string(),
mechanism_confidence: "mixed".to_string(),
trailer_family: Some("rt3-105-save-trailer-v1".to_string()),
bridge_family: Some("rt3-105-save-post-span-bridge-v1".to_string()),
profile: Some(crate::SmpLoadedProfile {
profile_kind: "rt3-105-packed-profile".to_string(),
profile_family: "rt3-105-save-container-v1".to_string(),
packed_profile_offset: 0x73c0,
packed_profile_len: 0x108,
packed_profile_len_hex: "0x108".to_string(),
leading_word_0: 3,
leading_word_0_hex: "0x00000003".to_string(),
header_flag_word_3: Some(0x01000000),
header_flag_word_3_hex: Some("0x01000000".to_string()),
map_path: Some("Alternate USA.gmp".to_string()),
display_name: Some("Alternate USA".to_string()),
profile_byte_0x77: 0x07,
profile_byte_0x77_hex: "0x07".to_string(),
profile_byte_0x82: 0x4d,
profile_byte_0x82_hex: "0x4d".to_string(),
profile_byte_0x97: 0x00,
profile_byte_0x97_hex: "0x00".to_string(),
profile_byte_0xc5: 0x00,
profile_byte_0xc5_hex: "0x00".to_string(),
}),
candidate_availability_table: Some(crate::SmpLoadedCandidateAvailabilityTable {
source_kind: "save-bridge-secondary-block".to_string(),
semantic_family: "scenario-named-candidate-availability-table".to_string(),
header_offset: 0x6a70,
entries_offset: 0x6ad1,
entries_end_offset: 0x73b7,
observed_entry_count: 2,
zero_availability_count: 1,
zero_availability_names: vec!["Uranium Mine".to_string()],
footer_progress_hex_words: vec!["0x000032dc".to_string(), "0x00003714".to_string()],
entries: vec![
crate::SmpRt3105SaveNameTableEntry {
index: 0,
offset: 0x6ad1,
text: "AutoPlant".to_string(),
availability_dword: 1,
availability_dword_hex: "0x00000001".to_string(),
trailer_word: 1,
trailer_word_hex: "0x00000001".to_string(),
},
crate::SmpRt3105SaveNameTableEntry {
index: 1,
offset: 0x6af3,
text: "Uranium Mine".to_string(),
availability_dword: 0,
availability_dword_hex: "0x00000000".to_string(),
trailer_word: 0,
trailer_word_hex: "0x00000000".to_string(),
},
],
}),
special_conditions_table: Some(crate::SmpLoadedSpecialConditionsTable {
source_kind: "save-fixed-special-conditions-range".to_string(),
table_offset: 0x0d64,
table_len: 36 * 4,
enabled_visible_count: 0,
enabled_visible_labels: vec![],
entries: vec![
crate::SmpSpecialConditionEntry {
slot_index: 30,
hidden: false,
label_id: 3722,
help_id: 3723,
label: "Disable Cargo Economy".to_string(),
value: 0,
value_hex: "0x00000000".to_string(),
},
crate::SmpSpecialConditionEntry {
slot_index: 35,
hidden: true,
label_id: 3,
help_id: 3,
label: "Hidden sentinel".to_string(),
value: 1,
value_hex: "0x00000001".to_string(),
},
],
}),
event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary {
source_kind: "packed-event-runtime-collection".to_string(),
mechanism_family: "rt3-105-save-post-span-bridge-v1".to_string(),
mechanism_confidence: "mixed".to_string(),
container_profile_family: Some("rt3-105-save-container-v1".to_string()),
metadata_tag_offset: 0x7100,
records_tag_offset: 0x7200,
close_tag_offset: 0x7600,
packed_state_version: 0x3e9,
packed_state_version_hex: "0x000003e9".to_string(),
live_id_bound: 5,
live_record_count: 3,
live_entry_ids: vec![1, 3, 5],
decoded_record_count: 0,
imported_runtime_record_count: 0,
records: vec![
crate::SmpLoadedPackedEventRecordSummary {
record_index: 0,
live_entry_id: 1,
payload_offset: None,
payload_len: None,
decode_status: "unsupported_framing".to_string(),
trigger_kind: None,
active: None,
marks_collection_dirty: None,
one_shot: None,
text_bands: Vec::new(),
standalone_condition_row_count: 0,
grouped_effect_row_counts: vec![0, 0, 0, 0],
decoded_actions: Vec::new(),
executable_import_ready: false,
notes: vec!["test".to_string()],
},
crate::SmpLoadedPackedEventRecordSummary {
record_index: 1,
live_entry_id: 3,
payload_offset: None,
payload_len: None,
decode_status: "unsupported_framing".to_string(),
trigger_kind: None,
active: None,
marks_collection_dirty: None,
one_shot: None,
text_bands: Vec::new(),
standalone_condition_row_count: 0,
grouped_effect_row_counts: vec![0, 0, 0, 0],
decoded_actions: Vec::new(),
executable_import_ready: false,
notes: vec!["test".to_string()],
},
crate::SmpLoadedPackedEventRecordSummary {
record_index: 2,
live_entry_id: 5,
payload_offset: None,
payload_len: None,
decode_status: "unsupported_framing".to_string(),
trigger_kind: None,
active: None,
marks_collection_dirty: None,
one_shot: None,
text_bands: Vec::new(),
standalone_condition_row_count: 0,
grouped_effect_row_counts: vec![0, 0, 0, 0],
decoded_actions: Vec::new(),
executable_import_ready: false,
notes: vec!["test".to_string()],
},
],
}),
notes: vec!["packed profile recovered".to_string()],
};
let import = project_save_slice_to_runtime_state_import(
&save_slice,
"save-import-smoke",
Some("test save import".to_string()),
)
.expect("save slice should project");
assert_eq!(import.import_id, "save-import-smoke");
assert_eq!(
import
.state
.metadata
.get("save_slice.map_path")
.map(String::as_str),
Some("Alternate USA.gmp")
);
assert_eq!(
import.state.save_profile.selected_year_profile_lane,
Some(0x07)
);
assert_eq!(import.state.save_profile.sandbox_enabled, Some(true));
assert_eq!(
import.state.world_restore.selected_year_profile_lane,
Some(0x07)
);
assert_eq!(import.state.world_restore.sandbox_enabled, Some(true));
assert_eq!(
import.state.world_restore.campaign_scenario_enabled,
Some(false)
);
assert_eq!(
import.state.world_restore.seed_tuple_written_from_raw_lane,
Some(true)
);
assert_eq!(
import
.state
.world_restore
.absolute_counter_requires_shell_context,
Some(true)
);
assert_eq!(
import
.state
.world_restore
.absolute_counter_reconstructible_from_save,
Some(false)
);
assert_eq!(
import
.state
.world_restore
.disable_cargo_economy_special_condition_slot,
Some(30)
);
assert_eq!(
import
.state
.world_restore
.disable_cargo_economy_special_condition_reconstructible_from_save,
Some(true)
);
assert_eq!(
import
.state
.world_restore
.disable_cargo_economy_special_condition_write_side_grounded,
Some(true)
);
assert_eq!(
import
.state
.world_restore
.disable_cargo_economy_special_condition_enabled,
Some(false)
);
assert_eq!(
import.state.world_restore.use_bio_accelerator_cars_enabled,
Some(false)
);
assert_eq!(
import.state.world_restore.use_wartime_cargos_enabled,
Some(false)
);
assert_eq!(
import.state.world_restore.disable_train_crashes_enabled,
Some(false)
);
assert_eq!(
import
.state
.world_restore
.disable_train_crashes_and_breakdowns_enabled,
Some(false)
);
assert_eq!(
import
.state
.world_restore
.ai_ignore_territories_at_startup_enabled,
Some(false)
);
assert_eq!(
import
.state
.world_restore
.absolute_counter_restore_kind
.as_deref(),
Some("mode-adjusted-selected-year-lane")
);
assert_eq!(
import
.state
.world_restore
.absolute_counter_adjustment_context
.as_deref(),
Some(
"editor-map-mode,shell-selected-year-adjust-policy-0x9d26-0x9d28,save-special-condition-disable-cargo-economy-slot-30"
)
);
assert_eq!(
import.state.save_profile.map_path.as_deref(),
Some("Alternate USA.gmp")
);
assert_eq!(
import.state.candidate_availability.get("Uranium Mine"),
Some(&0)
);
assert_eq!(
import.state.special_conditions.get("Disable Cargo Economy"),
Some(&0)
);
assert_eq!(
import
.state
.world_flags
.get("save_slice.profile_byte_0x82_nonzero"),
Some(&true)
);
assert_eq!(
import
.state
.packed_event_collection
.as_ref()
.map(|summary| summary.live_record_count),
Some(3)
);
assert_eq!(
import
.state
.packed_event_collection
.as_ref()
.map(|summary| summary.live_entry_ids.clone()),
Some(vec![1, 3, 5])
);
assert!(import.state.event_runtime_records.is_empty());
}
#[test]
fn projects_executable_packed_records_into_runtime_and_services_follow_on() {
let save_slice = SmpLoadedSaveSlice {
file_extension_hint: Some("gms".to_string()),
container_profile_family: Some("rt3-classic-save-container-v1".to_string()),
mechanism_family: "classic-save-rehydrate-v1".to_string(),
mechanism_confidence: "grounded".to_string(),
trailer_family: None,
bridge_family: None,
profile: None,
candidate_availability_table: None,
special_conditions_table: None,
event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary {
source_kind: "packed-event-runtime-collection".to_string(),
mechanism_family: "classic-save-rehydrate-v1".to_string(),
mechanism_confidence: "grounded".to_string(),
container_profile_family: Some("rt3-classic-save-container-v1".to_string()),
metadata_tag_offset: 0x7100,
records_tag_offset: 0x7200,
close_tag_offset: 0x7600,
packed_state_version: 0x3e9,
packed_state_version_hex: "0x000003e9".to_string(),
live_id_bound: 7,
live_record_count: 1,
live_entry_ids: vec![7],
decoded_record_count: 1,
imported_runtime_record_count: 1,
records: vec![crate::SmpLoadedPackedEventRecordSummary {
record_index: 0,
live_entry_id: 7,
payload_offset: Some(0x7202),
payload_len: Some(64),
decode_status: "executable".to_string(),
trigger_kind: Some(7),
active: Some(true),
marks_collection_dirty: Some(true),
one_shot: Some(false),
text_bands: packed_text_bands(),
standalone_condition_row_count: 1,
grouped_effect_row_counts: vec![0, 1, 0, 0],
decoded_actions: vec![
RuntimeEffect::SetWorldFlag {
key: "from_packed_root".to_string(),
value: true,
},
RuntimeEffect::AppendEventRecord {
record: Box::new(RuntimeEventRecordTemplate {
record_id: 99,
trigger_kind: 0x0a,
active: true,
marks_collection_dirty: false,
one_shot: false,
effects: vec![RuntimeEffect::SetSpecialCondition {
label: "Imported Follow-On".to_string(),
value: 1,
}],
}),
},
],
executable_import_ready: true,
notes: vec!["decoded test record".to_string()],
}],
}),
notes: vec![],
};
let mut import = project_save_slice_to_runtime_state_import(
&save_slice,
"packed-events-exec",
Some("test packed event import".to_string()),
)
.expect("save slice should project");
assert_eq!(import.state.event_runtime_records.len(), 1);
assert_eq!(
import
.state
.packed_event_collection
.as_ref()
.map(|summary| summary.imported_runtime_record_count),
Some(1)
);
let result = execute_step_command(
&mut import.state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("trigger service should succeed");
assert_eq!(result.final_summary.event_runtime_record_count, 2);
assert_eq!(result.final_summary.total_event_record_service_count, 2);
assert_eq!(result.final_summary.total_trigger_dispatch_count, 2);
assert_eq!(result.final_summary.dirty_rerun_count, 1);
assert_eq!(
import.state.world_flags.get("from_packed_root"),
Some(&true)
);
assert_eq!(
import.state.special_conditions.get("Imported Follow-On"),
Some(&1)
);
assert_eq!(import.state.event_runtime_records[0].service_count, 1);
assert_eq!(import.state.event_runtime_records[1].record_id, 99);
assert_eq!(import.state.event_runtime_records[1].service_count, 1);
}
#[test]
fn leaves_parity_only_packed_records_out_of_runtime_event_records() {
let save_slice = SmpLoadedSaveSlice {
file_extension_hint: Some("gms".to_string()),
container_profile_family: Some("rt3-classic-save-container-v1".to_string()),
mechanism_family: "classic-save-rehydrate-v1".to_string(),
mechanism_confidence: "grounded".to_string(),
trailer_family: None,
bridge_family: None,
profile: None,
candidate_availability_table: None,
special_conditions_table: None,
event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary {
source_kind: "packed-event-runtime-collection".to_string(),
mechanism_family: "classic-save-rehydrate-v1".to_string(),
mechanism_confidence: "grounded".to_string(),
container_profile_family: Some("rt3-classic-save-container-v1".to_string()),
metadata_tag_offset: 0x7100,
records_tag_offset: 0x7200,
close_tag_offset: 0x7600,
packed_state_version: 0x3e9,
packed_state_version_hex: "0x000003e9".to_string(),
live_id_bound: 7,
live_record_count: 1,
live_entry_ids: vec![7],
decoded_record_count: 1,
imported_runtime_record_count: 0,
records: vec![crate::SmpLoadedPackedEventRecordSummary {
record_index: 0,
live_entry_id: 7,
payload_offset: Some(0x7202),
payload_len: Some(48),
decode_status: "parity_only".to_string(),
trigger_kind: Some(7),
active: Some(true),
marks_collection_dirty: Some(false),
one_shot: Some(false),
text_bands: packed_text_bands(),
standalone_condition_row_count: 0,
grouped_effect_row_counts: vec![0, 0, 0, 0],
decoded_actions: vec![RuntimeEffect::AdjustCompanyCash {
target: crate::RuntimeCompanyTarget::Ids { ids: vec![42] },
delta: 50,
}],
executable_import_ready: false,
notes: vec!["decoded but not importable".to_string()],
}],
}),
notes: vec![],
};
let import = project_save_slice_to_runtime_state_import(
&save_slice,
"packed-events-parity-only",
None,
)
.expect("save slice should project");
assert!(import.state.event_runtime_records.is_empty());
assert_eq!(
import
.state
.packed_event_collection
.as_ref()
.map(|summary| summary.decoded_record_count),
Some(1)
);
assert_eq!(
import
.state
.packed_event_collection
.as_ref()
.map(|summary| summary.imported_runtime_record_count),
Some(0)
);
assert_eq!(
import
.state
.packed_event_collection
.as_ref()
.and_then(|summary| summary.records[0].import_outcome.as_deref()),
Some("blocked_missing_company_context")
);
}
#[test]
fn overlays_save_slice_events_onto_base_company_context() {
let base_state = RuntimeState {
calendar: CalendarPoint {
year: 1845,
month_slot: 2,
phase_slot: 1,
tick_slot: 3,
},
world_flags: BTreeMap::from([("base.only".to_string(), true)]),
save_profile: RuntimeSaveProfileState::default(),
world_restore: RuntimeWorldRestoreState::default(),
metadata: BTreeMap::from([("base.note".to_string(), "kept".to_string())]),
companies: vec![crate::RuntimeCompany {
company_id: 42,
current_cash: 500,
debt: 20,
}],
packed_event_collection: None,
event_runtime_records: vec![RuntimeEventRecord {
record_id: 1,
trigger_kind: 1,
active: true,
service_count: 0,
marks_collection_dirty: false,
one_shot: false,
has_fired: false,
effects: vec![],
}],
candidate_availability: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState {
periodic_boundary_calls: 9,
trigger_dispatch_counts: BTreeMap::new(),
total_event_record_services: 4,
dirty_rerun_count: 2,
},
};
let save_slice = SmpLoadedSaveSlice {
file_extension_hint: Some("gms".to_string()),
container_profile_family: Some("rt3-classic-save-container-v1".to_string()),
mechanism_family: "classic-save-rehydrate-v1".to_string(),
mechanism_confidence: "grounded".to_string(),
trailer_family: None,
bridge_family: None,
profile: None,
candidate_availability_table: None,
special_conditions_table: None,
event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary {
source_kind: "packed-event-runtime-collection".to_string(),
mechanism_family: "classic-save-rehydrate-v1".to_string(),
mechanism_confidence: "grounded".to_string(),
container_profile_family: Some("rt3-classic-save-container-v1".to_string()),
metadata_tag_offset: 0x7100,
records_tag_offset: 0x7200,
close_tag_offset: 0x7600,
packed_state_version: 0x3e9,
packed_state_version_hex: "0x000003e9".to_string(),
live_id_bound: 42,
live_record_count: 1,
live_entry_ids: vec![7],
decoded_record_count: 1,
imported_runtime_record_count: 0,
records: vec![crate::SmpLoadedPackedEventRecordSummary {
record_index: 0,
live_entry_id: 7,
payload_offset: Some(0x7202),
payload_len: Some(48),
decode_status: "parity_only".to_string(),
trigger_kind: Some(7),
active: Some(true),
marks_collection_dirty: Some(false),
one_shot: Some(false),
text_bands: packed_text_bands(),
standalone_condition_row_count: 0,
grouped_effect_row_counts: vec![0, 0, 0, 0],
decoded_actions: vec![RuntimeEffect::AdjustCompanyCash {
target: crate::RuntimeCompanyTarget::Ids { ids: vec![42] },
delta: 50,
}],
executable_import_ready: false,
notes: vec!["needs company context".to_string()],
}],
}),
notes: vec![],
};
let mut import = project_save_slice_overlay_to_runtime_state_import(
&base_state,
&save_slice,
"overlay-smoke",
Some("overlay test".to_string()),
)
.expect("overlay import should project");
assert_eq!(import.state.calendar, base_state.calendar);
assert_eq!(import.state.companies, base_state.companies);
assert_eq!(import.state.service_state, base_state.service_state);
assert_eq!(import.state.event_runtime_records.len(), 1);
assert_eq!(
import
.state
.packed_event_collection
.as_ref()
.map(|summary| summary.imported_runtime_record_count),
Some(1)
);
assert_eq!(
import
.state
.packed_event_collection
.as_ref()
.and_then(|summary| summary.records[0].import_outcome.as_deref()),
Some("imported")
);
assert_eq!(
import
.state
.metadata
.get("save_slice.import_projection")
.map(String::as_str),
Some("overlay-runtime-restore-v1")
);
assert_eq!(
import.state.metadata.get("base.note").map(String::as_str),
Some("kept")
);
assert_eq!(import.state.world_flags.get("base.only"), Some(&true));
execute_step_command(
&mut import.state,
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
)
.expect("overlay-imported company-targeted record should run");
assert_eq!(import.state.companies[0].current_cash, 550);
}
#[test]
fn loads_overlay_import_document_with_relative_paths() {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time should be after epoch")
.as_nanos();
let fixture_dir = std::env::temp_dir().join(format!("rrt-overlay-import-{nonce}"));
std::fs::create_dir_all(&fixture_dir).expect("fixture dir should be created");
let snapshot_path = fixture_dir.join("base.json");
let save_slice_path = fixture_dir.join("slice.json");
let overlay_path = fixture_dir.join("overlay.json");
let snapshot = crate::RuntimeSnapshotDocument {
format_version: crate::SNAPSHOT_FORMAT_VERSION,
snapshot_id: "base".to_string(),
source: crate::RuntimeSnapshotSource {
source_fixture_id: None,
description: Some("base snapshot".to_string()),
},
state: RuntimeState {
calendar: CalendarPoint {
year: 1835,
month_slot: 1,
phase_slot: 2,
tick_slot: 4,
},
world_flags: BTreeMap::new(),
save_profile: RuntimeSaveProfileState::default(),
world_restore: RuntimeWorldRestoreState::default(),
metadata: BTreeMap::new(),
companies: vec![crate::RuntimeCompany {
company_id: 42,
current_cash: 100,
debt: 0,
}],
packed_event_collection: None,
event_runtime_records: Vec::new(),
candidate_availability: BTreeMap::new(),
special_conditions: BTreeMap::new(),
service_state: RuntimeServiceState::default(),
},
};
crate::save_runtime_snapshot_document(&snapshot_path, &snapshot)
.expect("snapshot should save");
let save_slice_document = RuntimeSaveSliceDocument {
format_version: SAVE_SLICE_DOCUMENT_FORMAT_VERSION,
save_slice_id: "slice".to_string(),
source: RuntimeSaveSliceDocumentSource::default(),
save_slice: SmpLoadedSaveSlice {
file_extension_hint: Some("gms".to_string()),
container_profile_family: Some("rt3-classic-save-container-v1".to_string()),
mechanism_family: "classic-save-rehydrate-v1".to_string(),
mechanism_confidence: "grounded".to_string(),
trailer_family: None,
bridge_family: None,
profile: None,
candidate_availability_table: None,
special_conditions_table: None,
event_runtime_collection: Some(crate::SmpLoadedEventRuntimeCollectionSummary {
source_kind: "packed-event-runtime-collection".to_string(),
mechanism_family: "classic-save-rehydrate-v1".to_string(),
mechanism_confidence: "grounded".to_string(),
container_profile_family: Some("rt3-classic-save-container-v1".to_string()),
metadata_tag_offset: 0x7100,
records_tag_offset: 0x7200,
close_tag_offset: 0x7600,
packed_state_version: 0x3e9,
packed_state_version_hex: "0x000003e9".to_string(),
live_id_bound: 7,
live_record_count: 1,
live_entry_ids: vec![7],
decoded_record_count: 1,
imported_runtime_record_count: 0,
records: vec![crate::SmpLoadedPackedEventRecordSummary {
record_index: 0,
live_entry_id: 7,
payload_offset: Some(0x7202),
payload_len: Some(48),
decode_status: "parity_only".to_string(),
trigger_kind: Some(7),
active: Some(true),
marks_collection_dirty: Some(false),
one_shot: Some(false),
text_bands: packed_text_bands(),
standalone_condition_row_count: 0,
grouped_effect_row_counts: vec![0, 0, 0, 0],
decoded_actions: vec![RuntimeEffect::AdjustCompanyCash {
target: crate::RuntimeCompanyTarget::Ids { ids: vec![42] },
delta: 50,
}],
executable_import_ready: false,
notes: vec!["needs company context".to_string()],
}],
}),
notes: vec![],
},
};
save_runtime_save_slice_document(&save_slice_path, &save_slice_document)
.expect("save slice should save");
let overlay = RuntimeOverlayImportDocument {
format_version: OVERLAY_IMPORT_DOCUMENT_FORMAT_VERSION,
import_id: "overlay-relative".to_string(),
source: RuntimeOverlayImportDocumentSource {
description: Some("relative overlay".to_string()),
notes: vec![],
},
base_snapshot_path: "base.json".to_string(),
save_slice_path: "slice.json".to_string(),
};
save_runtime_overlay_import_document(&overlay_path, &overlay)
.expect("overlay document should save");
let import =
load_runtime_state_import(&overlay_path).expect("overlay runtime import should load");
assert_eq!(import.import_id, "overlay-relative");
assert_eq!(import.state.event_runtime_records.len(), 1);
assert_eq!(import.state.companies[0].company_id, 42);
let _ = std::fs::remove_file(snapshot_path);
let _ = std::fs::remove_file(save_slice_path);
let _ = std::fs::remove_file(overlay_path);
let _ = std::fs::remove_dir(fixture_dir);
}
}