3980 lines
161 KiB
Rust
3980 lines
161 KiB
Rust
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, RuntimeCompanyConditionTestScope, RuntimeCompanyControllerKind,
|
|
RuntimeCompanyTarget, RuntimeEffect, RuntimeEventRecord, RuntimeEventRecordTemplate,
|
|
RuntimePackedEventCollectionSummary, RuntimePackedEventCompactControlSummary,
|
|
RuntimePackedEventConditionRowSummary, RuntimePackedEventGroupedEffectRowSummary,
|
|
RuntimePackedEventNegativeSentinelScopeSummary, RuntimePackedEventRecordSummary,
|
|
RuntimePackedEventTextBandSummary, RuntimePlayerConditionTestScope, RuntimeSaveProfileState,
|
|
RuntimeServiceState, RuntimeState, RuntimeWorldRestoreState,
|
|
SmpLoadedPackedEventNegativeSentinelScopeSummary, SmpLoadedPackedEventRecordSummary,
|
|
SmpLoadedPackedEventTextBandSummary, SmpLoadedSaveSlice,
|
|
};
|
|
|
|
pub const STATE_DUMP_FORMAT_VERSION: u32 = 1;
|
|
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,
|
|
}
|
|
|
|
#[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,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct ImportCompanyContext {
|
|
known_company_ids: BTreeSet<u32>,
|
|
selected_company_id: Option<u32>,
|
|
has_complete_controller_context: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum CompanyTargetImportBlocker {
|
|
MissingCompanyContext,
|
|
MissingSelectionContext,
|
|
MissingCompanyRoleContext,
|
|
MissingConditionContext,
|
|
CompanyConditionScopeDisabled,
|
|
PlayerConditionScope,
|
|
TerritoryConditionScope,
|
|
}
|
|
|
|
impl ImportCompanyContext {
|
|
fn standalone() -> Self {
|
|
Self {
|
|
known_company_ids: BTreeSet::new(),
|
|
selected_company_id: None,
|
|
has_complete_controller_context: false,
|
|
}
|
|
}
|
|
|
|
fn from_runtime_state(state: &RuntimeState) -> Self {
|
|
Self {
|
|
known_company_ids: state
|
|
.companies
|
|
.iter()
|
|
.map(|company| company.company_id)
|
|
.collect(),
|
|
selected_company_id: state.selected_company_id,
|
|
has_complete_controller_context: !state.companies.is_empty()
|
|
&& state.companies.iter().all(|company| {
|
|
company.controller_kind != RuntimeCompanyControllerKind::Unknown
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
&ImportCompanyContext::standalone(),
|
|
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(),
|
|
selected_company_id: None,
|
|
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 company_context = ImportCompanyContext::from_runtime_state(base_state);
|
|
let projection = project_save_slice_components(
|
|
save_slice,
|
|
&company_context,
|
|
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(),
|
|
selected_company_id: base_state.selected_company_id,
|
|
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,
|
|
company_context: &ImportCompanyContext,
|
|
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, company_context)?;
|
|
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,
|
|
company_context: &ImportCompanyContext,
|
|
) -> 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, company_context)
|
|
{
|
|
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,
|
|
company_context,
|
|
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,
|
|
company_context: &ImportCompanyContext,
|
|
imported: bool,
|
|
) -> RuntimePackedEventRecordSummary {
|
|
let lowered_decoded_actions =
|
|
lowered_record_decoded_actions(record).unwrap_or_else(|_| record.decoded_actions.clone());
|
|
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(),
|
|
payload_family: record.payload_family.clone(),
|
|
trigger_kind: record.trigger_kind,
|
|
active: record.active,
|
|
marks_collection_dirty: record.marks_collection_dirty,
|
|
one_shot: record.one_shot,
|
|
compact_control: record
|
|
.compact_control
|
|
.as_ref()
|
|
.map(runtime_packed_event_compact_control_summary_from_smp),
|
|
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,
|
|
standalone_condition_rows: record
|
|
.standalone_condition_rows
|
|
.iter()
|
|
.map(runtime_packed_event_condition_row_summary_from_smp)
|
|
.collect(),
|
|
negative_sentinel_scope: record
|
|
.negative_sentinel_scope
|
|
.as_ref()
|
|
.map(runtime_packed_event_negative_sentinel_scope_summary_from_smp),
|
|
grouped_effect_row_counts: record.grouped_effect_row_counts.clone(),
|
|
grouped_effect_rows: record
|
|
.grouped_effect_rows
|
|
.iter()
|
|
.map(runtime_packed_event_grouped_effect_row_summary_from_smp)
|
|
.collect(),
|
|
grouped_company_targets: classify_real_grouped_company_targets(record),
|
|
decoded_actions: lowered_decoded_actions,
|
|
executable_import_ready: record.executable_import_ready,
|
|
import_outcome: Some(determine_packed_event_import_outcome(
|
|
record,
|
|
company_context,
|
|
imported,
|
|
)),
|
|
notes: record.notes.clone(),
|
|
}
|
|
}
|
|
|
|
fn runtime_packed_event_negative_sentinel_scope_summary_from_smp(
|
|
scope: &SmpLoadedPackedEventNegativeSentinelScopeSummary,
|
|
) -> RuntimePackedEventNegativeSentinelScopeSummary {
|
|
RuntimePackedEventNegativeSentinelScopeSummary {
|
|
company_test_scope: scope.company_test_scope,
|
|
player_test_scope: scope.player_test_scope,
|
|
territory_scope_selector_is_0x63: scope.territory_scope_selector_is_0x63,
|
|
source_row_indexes: scope.source_row_indexes.clone(),
|
|
}
|
|
}
|
|
|
|
fn runtime_packed_event_compact_control_summary_from_smp(
|
|
control: &crate::SmpLoadedPackedEventCompactControlSummary,
|
|
) -> RuntimePackedEventCompactControlSummary {
|
|
RuntimePackedEventCompactControlSummary {
|
|
mode_byte_0x7ef: control.mode_byte_0x7ef,
|
|
primary_selector_0x7f0: control.primary_selector_0x7f0,
|
|
grouped_mode_0x7f4: control.grouped_mode_0x7f4,
|
|
one_shot_header_0x7f5: control.one_shot_header_0x7f5,
|
|
modifier_flag_0x7f9: control.modifier_flag_0x7f9,
|
|
modifier_flag_0x7fa: control.modifier_flag_0x7fa,
|
|
grouped_target_scope_ordinals_0x7fb: control.grouped_target_scope_ordinals_0x7fb.clone(),
|
|
grouped_scope_checkboxes_0x7ff: control.grouped_scope_checkboxes_0x7ff.clone(),
|
|
summary_toggle_0x800: control.summary_toggle_0x800,
|
|
grouped_territory_selectors_0x80f: control.grouped_territory_selectors_0x80f.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 runtime_packed_event_condition_row_summary_from_smp(
|
|
row: &crate::SmpLoadedPackedEventConditionRowSummary,
|
|
) -> RuntimePackedEventConditionRowSummary {
|
|
RuntimePackedEventConditionRowSummary {
|
|
row_index: row.row_index,
|
|
raw_condition_id: row.raw_condition_id,
|
|
subtype: row.subtype,
|
|
flag_bytes: row.flag_bytes.clone(),
|
|
candidate_name: row.candidate_name.clone(),
|
|
notes: row.notes.clone(),
|
|
}
|
|
}
|
|
|
|
fn runtime_packed_event_grouped_effect_row_summary_from_smp(
|
|
row: &crate::SmpLoadedPackedEventGroupedEffectRowSummary,
|
|
) -> RuntimePackedEventGroupedEffectRowSummary {
|
|
RuntimePackedEventGroupedEffectRowSummary {
|
|
group_index: row.group_index,
|
|
row_index: row.row_index,
|
|
descriptor_id: row.descriptor_id,
|
|
descriptor_label: row.descriptor_label.clone(),
|
|
target_mask_bits: row.target_mask_bits,
|
|
parameter_family: row.parameter_family.clone(),
|
|
opcode: row.opcode,
|
|
raw_scalar_value: row.raw_scalar_value,
|
|
value_byte_0x09: row.value_byte_0x09,
|
|
value_dword_0x0d: row.value_dword_0x0d,
|
|
value_byte_0x11: row.value_byte_0x11,
|
|
value_byte_0x12: row.value_byte_0x12,
|
|
value_word_0x14: row.value_word_0x14,
|
|
value_word_0x16: row.value_word_0x16,
|
|
row_shape: row.row_shape.clone(),
|
|
semantic_family: row.semantic_family.clone(),
|
|
semantic_preview: row.semantic_preview.clone(),
|
|
locomotive_name: row.locomotive_name.clone(),
|
|
notes: row.notes.clone(),
|
|
}
|
|
}
|
|
|
|
fn smp_packed_record_to_runtime_event_record(
|
|
record: &SmpLoadedPackedEventRecordSummary,
|
|
company_context: &ImportCompanyContext,
|
|
) -> Option<Result<RuntimeEventRecord, String>> {
|
|
if record.decode_status == "unsupported_framing" {
|
|
return None;
|
|
}
|
|
if record.payload_family == "real_packed_v1" {
|
|
if record.compact_control.is_none() || !record.executable_import_ready {
|
|
return None;
|
|
}
|
|
}
|
|
|
|
let lowered_effects = match lowered_record_decoded_actions(record) {
|
|
Ok(effects) => effects,
|
|
Err(_) => return None,
|
|
};
|
|
let effects = match smp_runtime_effects_to_runtime_effects(&lowered_effects, company_context) {
|
|
Ok(effects) => effects,
|
|
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.unwrap_or(true);
|
|
let marks_collection_dirty = record.marks_collection_dirty.unwrap_or(false);
|
|
let one_shot = record.one_shot.unwrap_or(false);
|
|
Ok(RuntimeEventRecordTemplate {
|
|
record_id: record.live_entry_id,
|
|
trigger_kind,
|
|
active,
|
|
marks_collection_dirty,
|
|
one_shot,
|
|
effects,
|
|
}
|
|
.into_runtime_record())
|
|
})())
|
|
}
|
|
|
|
fn lowered_record_decoded_actions(
|
|
record: &SmpLoadedPackedEventRecordSummary,
|
|
) -> Result<Vec<RuntimeEffect>, CompanyTargetImportBlocker> {
|
|
if let Some(blocker) = packed_record_condition_scope_import_blocker(record) {
|
|
return Err(blocker);
|
|
}
|
|
|
|
let Some(lowered_target) = lowered_condition_true_company_target(record) else {
|
|
return Ok(record.decoded_actions.clone());
|
|
};
|
|
Ok(record
|
|
.decoded_actions
|
|
.iter()
|
|
.map(|effect| lower_condition_true_company_target_in_effect(effect, &lowered_target))
|
|
.collect())
|
|
}
|
|
|
|
fn packed_record_condition_scope_import_blocker(
|
|
record: &SmpLoadedPackedEventRecordSummary,
|
|
) -> Option<CompanyTargetImportBlocker> {
|
|
if record.standalone_condition_rows.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let negative_sentinel_row_count = record
|
|
.standalone_condition_rows
|
|
.iter()
|
|
.filter(|row| row.raw_condition_id == -1)
|
|
.count();
|
|
if negative_sentinel_row_count == 0 {
|
|
return Some(CompanyTargetImportBlocker::MissingConditionContext);
|
|
}
|
|
if negative_sentinel_row_count != record.standalone_condition_rows.len() {
|
|
return Some(CompanyTargetImportBlocker::MissingConditionContext);
|
|
}
|
|
|
|
let Some(scope) = record.negative_sentinel_scope.as_ref() else {
|
|
return Some(CompanyTargetImportBlocker::MissingConditionContext);
|
|
};
|
|
if scope.player_test_scope != RuntimePlayerConditionTestScope::Disabled {
|
|
return Some(CompanyTargetImportBlocker::PlayerConditionScope);
|
|
}
|
|
if scope.territory_scope_selector_is_0x63 {
|
|
return Some(CompanyTargetImportBlocker::TerritoryConditionScope);
|
|
}
|
|
if scope.company_test_scope == RuntimeCompanyConditionTestScope::Disabled {
|
|
return Some(CompanyTargetImportBlocker::CompanyConditionScopeDisabled);
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn lowered_condition_true_company_target(
|
|
record: &SmpLoadedPackedEventRecordSummary,
|
|
) -> Option<RuntimeCompanyTarget> {
|
|
let scope = record.negative_sentinel_scope.as_ref()?;
|
|
match scope.company_test_scope {
|
|
RuntimeCompanyConditionTestScope::Disabled => None,
|
|
RuntimeCompanyConditionTestScope::AllCompanies => Some(RuntimeCompanyTarget::AllActive),
|
|
RuntimeCompanyConditionTestScope::SelectedCompanyOnly => {
|
|
Some(RuntimeCompanyTarget::SelectedCompany)
|
|
}
|
|
RuntimeCompanyConditionTestScope::AiCompaniesOnly => {
|
|
Some(RuntimeCompanyTarget::AiCompanies)
|
|
}
|
|
RuntimeCompanyConditionTestScope::HumanCompaniesOnly => {
|
|
Some(RuntimeCompanyTarget::HumanCompanies)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn lower_condition_true_company_target_in_effect(
|
|
effect: &RuntimeEffect,
|
|
lowered_target: &RuntimeCompanyTarget,
|
|
) -> RuntimeEffect {
|
|
match effect {
|
|
RuntimeEffect::SetWorldFlag { key, value } => RuntimeEffect::SetWorldFlag {
|
|
key: key.clone(),
|
|
value: *value,
|
|
},
|
|
RuntimeEffect::SetCompanyCash { target, value } => RuntimeEffect::SetCompanyCash {
|
|
target: lower_condition_true_company_target_in_company_target(target, lowered_target),
|
|
value: *value,
|
|
},
|
|
RuntimeEffect::DeactivateCompany { target } => RuntimeEffect::DeactivateCompany {
|
|
target: lower_condition_true_company_target_in_company_target(target, lowered_target),
|
|
},
|
|
RuntimeEffect::SetCompanyTrackLayingCapacity { target, value } => {
|
|
RuntimeEffect::SetCompanyTrackLayingCapacity {
|
|
target: lower_condition_true_company_target_in_company_target(
|
|
target,
|
|
lowered_target,
|
|
),
|
|
value: *value,
|
|
}
|
|
}
|
|
RuntimeEffect::AdjustCompanyCash { target, delta } => RuntimeEffect::AdjustCompanyCash {
|
|
target: lower_condition_true_company_target_in_company_target(target, lowered_target),
|
|
delta: *delta,
|
|
},
|
|
RuntimeEffect::AdjustCompanyDebt { target, delta } => RuntimeEffect::AdjustCompanyDebt {
|
|
target: lower_condition_true_company_target_in_company_target(target, lowered_target),
|
|
delta: *delta,
|
|
},
|
|
RuntimeEffect::SetCandidateAvailability { name, value } => {
|
|
RuntimeEffect::SetCandidateAvailability {
|
|
name: name.clone(),
|
|
value: *value,
|
|
}
|
|
}
|
|
RuntimeEffect::SetSpecialCondition { label, value } => RuntimeEffect::SetSpecialCondition {
|
|
label: label.clone(),
|
|
value: *value,
|
|
},
|
|
RuntimeEffect::AppendEventRecord { record } => RuntimeEffect::AppendEventRecord {
|
|
record: Box::new(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: record
|
|
.effects
|
|
.iter()
|
|
.map(|nested| {
|
|
lower_condition_true_company_target_in_effect(nested, lowered_target)
|
|
})
|
|
.collect(),
|
|
}),
|
|
},
|
|
RuntimeEffect::ActivateEventRecord { record_id } => RuntimeEffect::ActivateEventRecord {
|
|
record_id: *record_id,
|
|
},
|
|
RuntimeEffect::DeactivateEventRecord { record_id } => {
|
|
RuntimeEffect::DeactivateEventRecord {
|
|
record_id: *record_id,
|
|
}
|
|
}
|
|
RuntimeEffect::RemoveEventRecord { record_id } => RuntimeEffect::RemoveEventRecord {
|
|
record_id: *record_id,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn lower_condition_true_company_target_in_company_target(
|
|
target: &RuntimeCompanyTarget,
|
|
lowered_target: &RuntimeCompanyTarget,
|
|
) -> RuntimeCompanyTarget {
|
|
match target {
|
|
RuntimeCompanyTarget::ConditionTrueCompany => lowered_target.clone(),
|
|
_ => target.clone(),
|
|
}
|
|
}
|
|
|
|
fn smp_runtime_effects_to_runtime_effects(
|
|
effects: &[RuntimeEffect],
|
|
company_context: &ImportCompanyContext,
|
|
) -> Result<Vec<RuntimeEffect>, String> {
|
|
effects
|
|
.iter()
|
|
.map(|effect| smp_runtime_effect_to_runtime_effect(effect, company_context))
|
|
.collect()
|
|
}
|
|
|
|
fn smp_runtime_effect_to_runtime_effect(
|
|
effect: &RuntimeEffect,
|
|
company_context: &ImportCompanyContext,
|
|
) -> Result<RuntimeEffect, String> {
|
|
match effect {
|
|
RuntimeEffect::SetWorldFlag { key, value } => Ok(RuntimeEffect::SetWorldFlag {
|
|
key: key.clone(),
|
|
value: *value,
|
|
}),
|
|
RuntimeEffect::SetCompanyCash { target, value } => {
|
|
if company_target_import_blocker(target, company_context).is_none() {
|
|
Ok(RuntimeEffect::SetCompanyCash {
|
|
target: target.clone(),
|
|
value: *value,
|
|
})
|
|
} else {
|
|
Err(company_target_import_error_message(target, company_context))
|
|
}
|
|
}
|
|
RuntimeEffect::DeactivateCompany { target } => {
|
|
if company_target_import_blocker(target, company_context).is_none() {
|
|
Ok(RuntimeEffect::DeactivateCompany {
|
|
target: target.clone(),
|
|
})
|
|
} else {
|
|
Err(company_target_import_error_message(target, company_context))
|
|
}
|
|
}
|
|
RuntimeEffect::SetCompanyTrackLayingCapacity { target, value } => {
|
|
if company_target_import_blocker(target, company_context).is_none() {
|
|
Ok(RuntimeEffect::SetCompanyTrackLayingCapacity {
|
|
target: target.clone(),
|
|
value: *value,
|
|
})
|
|
} else {
|
|
Err(company_target_import_error_message(target, company_context))
|
|
}
|
|
}
|
|
RuntimeEffect::AdjustCompanyCash { target, delta } => {
|
|
if company_target_import_blocker(target, company_context).is_none() {
|
|
Ok(RuntimeEffect::AdjustCompanyCash {
|
|
target: target.clone(),
|
|
delta: *delta,
|
|
})
|
|
} else {
|
|
Err(company_target_import_error_message(target, company_context))
|
|
}
|
|
}
|
|
RuntimeEffect::AdjustCompanyDebt { target, delta } => {
|
|
if company_target_import_blocker(target, company_context).is_none() {
|
|
Ok(RuntimeEffect::AdjustCompanyDebt {
|
|
target: target.clone(),
|
|
delta: *delta,
|
|
})
|
|
} else {
|
|
Err(company_target_import_error_message(target, company_context))
|
|
}
|
|
}
|
|
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,
|
|
company_context,
|
|
)?),
|
|
}),
|
|
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,
|
|
company_context: &ImportCompanyContext,
|
|
) -> 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, company_context)?,
|
|
})
|
|
}
|
|
|
|
fn company_target_import_blocker(
|
|
target: &RuntimeCompanyTarget,
|
|
company_context: &ImportCompanyContext,
|
|
) -> Option<CompanyTargetImportBlocker> {
|
|
match target {
|
|
RuntimeCompanyTarget::AllActive => None,
|
|
RuntimeCompanyTarget::Ids { ids } => {
|
|
if ids.is_empty()
|
|
|| ids
|
|
.iter()
|
|
.any(|company_id| !company_context.known_company_ids.contains(company_id))
|
|
{
|
|
Some(CompanyTargetImportBlocker::MissingCompanyContext)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
RuntimeCompanyTarget::HumanCompanies | RuntimeCompanyTarget::AiCompanies => {
|
|
if !company_context.has_complete_controller_context {
|
|
Some(CompanyTargetImportBlocker::MissingCompanyRoleContext)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
RuntimeCompanyTarget::SelectedCompany => {
|
|
if company_context.selected_company_id.is_some() {
|
|
None
|
|
} else {
|
|
Some(CompanyTargetImportBlocker::MissingSelectionContext)
|
|
}
|
|
}
|
|
RuntimeCompanyTarget::ConditionTrueCompany => {
|
|
Some(CompanyTargetImportBlocker::MissingConditionContext)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn company_target_import_error_message(
|
|
target: &RuntimeCompanyTarget,
|
|
company_context: &ImportCompanyContext,
|
|
) -> String {
|
|
match company_target_import_blocker(target, company_context) {
|
|
Some(CompanyTargetImportBlocker::MissingCompanyContext) => {
|
|
"packed company effect requires resolved company ids".to_string()
|
|
}
|
|
Some(CompanyTargetImportBlocker::MissingSelectionContext) => {
|
|
"packed company effect requires selected_company_id context".to_string()
|
|
}
|
|
Some(CompanyTargetImportBlocker::MissingCompanyRoleContext) => {
|
|
"packed company effect requires company controller role context".to_string()
|
|
}
|
|
Some(CompanyTargetImportBlocker::MissingConditionContext) => {
|
|
"packed company effect requires condition-relative context".to_string()
|
|
}
|
|
Some(CompanyTargetImportBlocker::CompanyConditionScopeDisabled) => {
|
|
"packed company effect disables company-side negative-sentinel condition scope"
|
|
.to_string()
|
|
}
|
|
Some(CompanyTargetImportBlocker::PlayerConditionScope) => {
|
|
"packed company effect requires player runtime ownership for negative-sentinel scope"
|
|
.to_string()
|
|
}
|
|
Some(CompanyTargetImportBlocker::TerritoryConditionScope) => {
|
|
"packed company effect requires territory runtime ownership for negative-sentinel scope"
|
|
.to_string()
|
|
}
|
|
None => "packed company effect is importable".to_string(),
|
|
}
|
|
}
|
|
|
|
fn determine_packed_event_import_outcome(
|
|
record: &SmpLoadedPackedEventRecordSummary,
|
|
company_context: &ImportCompanyContext,
|
|
imported: bool,
|
|
) -> String {
|
|
if imported {
|
|
return "imported".to_string();
|
|
}
|
|
if record.decode_status == "unsupported_framing" {
|
|
return "blocked_unsupported_decode".to_string();
|
|
}
|
|
if record.payload_family == "real_packed_v1" {
|
|
if record.compact_control.is_none() {
|
|
return "blocked_missing_compact_control".to_string();
|
|
}
|
|
if !record.executable_import_ready {
|
|
return "blocked_unmapped_real_descriptor".to_string();
|
|
}
|
|
if let Some(blocker) = packed_record_condition_scope_import_blocker(record) {
|
|
return company_target_import_outcome(blocker).to_string();
|
|
}
|
|
if let Some(blocker) = packed_record_company_target_import_blocker(record, company_context)
|
|
{
|
|
return company_target_import_outcome(blocker).to_string();
|
|
}
|
|
return "blocked_unsupported_decode".to_string();
|
|
}
|
|
if let Some(blocker) = packed_record_company_target_import_blocker(record, company_context) {
|
|
return company_target_import_outcome(blocker).to_string();
|
|
}
|
|
"blocked_unsupported_decode".to_string()
|
|
}
|
|
|
|
fn packed_record_company_target_import_blocker(
|
|
record: &SmpLoadedPackedEventRecordSummary,
|
|
company_context: &ImportCompanyContext,
|
|
) -> Option<CompanyTargetImportBlocker> {
|
|
let lowered_effects = match lowered_record_decoded_actions(record) {
|
|
Ok(effects) => effects,
|
|
Err(blocker) => return Some(blocker),
|
|
};
|
|
lowered_effects
|
|
.iter()
|
|
.find_map(|effect| runtime_effect_company_target_import_blocker(effect, company_context))
|
|
}
|
|
|
|
fn runtime_effect_company_target_import_blocker(
|
|
effect: &RuntimeEffect,
|
|
company_context: &ImportCompanyContext,
|
|
) -> Option<CompanyTargetImportBlocker> {
|
|
match effect {
|
|
RuntimeEffect::SetCompanyCash { target, .. }
|
|
| RuntimeEffect::DeactivateCompany { target }
|
|
| RuntimeEffect::SetCompanyTrackLayingCapacity { target, .. }
|
|
| RuntimeEffect::AdjustCompanyCash { target, .. }
|
|
| RuntimeEffect::AdjustCompanyDebt { target, .. } => {
|
|
company_target_import_blocker(target, company_context)
|
|
}
|
|
RuntimeEffect::AppendEventRecord { record } => record.effects.iter().find_map(|nested| {
|
|
runtime_effect_company_target_import_blocker(nested, company_context)
|
|
}),
|
|
RuntimeEffect::SetWorldFlag { .. }
|
|
| RuntimeEffect::SetCandidateAvailability { .. }
|
|
| RuntimeEffect::SetSpecialCondition { .. }
|
|
| RuntimeEffect::ActivateEventRecord { .. }
|
|
| RuntimeEffect::DeactivateEventRecord { .. }
|
|
| RuntimeEffect::RemoveEventRecord { .. } => None,
|
|
}
|
|
}
|
|
|
|
fn classify_real_grouped_company_targets(
|
|
record: &SmpLoadedPackedEventRecordSummary,
|
|
) -> Vec<Option<RuntimeCompanyTarget>> {
|
|
let Some(control) = &record.compact_control else {
|
|
return Vec::new();
|
|
};
|
|
|
|
control
|
|
.grouped_target_scope_ordinals_0x7fb
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(group_index, ordinal)| {
|
|
if !record
|
|
.grouped_effect_rows
|
|
.iter()
|
|
.any(|row| row.group_index == group_index)
|
|
{
|
|
return None;
|
|
}
|
|
classify_real_grouped_company_target(*ordinal)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn classify_real_grouped_company_target(ordinal: u8) -> Option<RuntimeCompanyTarget> {
|
|
match ordinal {
|
|
0 => Some(RuntimeCompanyTarget::ConditionTrueCompany),
|
|
1 => Some(RuntimeCompanyTarget::SelectedCompany),
|
|
2 => Some(RuntimeCompanyTarget::HumanCompanies),
|
|
3 => Some(RuntimeCompanyTarget::AiCompanies),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn company_target_import_outcome(blocker: CompanyTargetImportBlocker) -> &'static str {
|
|
match blocker {
|
|
CompanyTargetImportBlocker::MissingCompanyContext => "blocked_missing_company_context",
|
|
CompanyTargetImportBlocker::MissingSelectionContext => "blocked_missing_selection_context",
|
|
CompanyTargetImportBlocker::MissingCompanyRoleContext => {
|
|
"blocked_missing_company_role_context"
|
|
}
|
|
CompanyTargetImportBlocker::MissingConditionContext => "blocked_missing_condition_context",
|
|
CompanyTargetImportBlocker::CompanyConditionScopeDisabled => {
|
|
"blocked_company_condition_scope_disabled"
|
|
}
|
|
CompanyTargetImportBlocker::PlayerConditionScope => "blocked_player_condition_scope",
|
|
CompanyTargetImportBlocker::TerritoryConditionScope => "blocked_territory_condition_scope",
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
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(())
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
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(),
|
|
selected_company_id: None,
|
|
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(),
|
|
},
|
|
]
|
|
}
|
|
|
|
fn real_condition_rows() -> Vec<crate::SmpLoadedPackedEventConditionRowSummary> {
|
|
vec![crate::SmpLoadedPackedEventConditionRowSummary {
|
|
row_index: 0,
|
|
raw_condition_id: -1,
|
|
subtype: 4,
|
|
flag_bytes: vec![0x30; 25],
|
|
candidate_name: Some("AutoPlant".to_string()),
|
|
notes: vec!["negative sentinel-style condition row id".to_string()],
|
|
}]
|
|
}
|
|
|
|
fn synthetic_packed_record(
|
|
record_index: usize,
|
|
live_entry_id: u32,
|
|
effect: RuntimeEffect,
|
|
) -> crate::SmpLoadedPackedEventRecordSummary {
|
|
crate::SmpLoadedPackedEventRecordSummary {
|
|
record_index,
|
|
live_entry_id,
|
|
payload_offset: Some(0x7200 + (live_entry_id as usize * 0x20)),
|
|
payload_len: Some(64),
|
|
decode_status: "parity_only".to_string(),
|
|
payload_family: "synthetic_harness".to_string(),
|
|
trigger_kind: Some(7),
|
|
active: Some(true),
|
|
marks_collection_dirty: Some(false),
|
|
one_shot: Some(false),
|
|
compact_control: None,
|
|
text_bands: packed_text_bands(),
|
|
standalone_condition_row_count: 0,
|
|
standalone_condition_rows: vec![],
|
|
negative_sentinel_scope: None,
|
|
grouped_effect_row_counts: vec![0, 0, 0, 0],
|
|
grouped_effect_rows: vec![],
|
|
decoded_actions: vec![effect],
|
|
executable_import_ready: false,
|
|
notes: vec!["synthetic test record".to_string()],
|
|
}
|
|
}
|
|
|
|
fn company_negative_sentinel_scope(
|
|
company_test_scope: RuntimeCompanyConditionTestScope,
|
|
) -> crate::SmpLoadedPackedEventNegativeSentinelScopeSummary {
|
|
crate::SmpLoadedPackedEventNegativeSentinelScopeSummary {
|
|
company_test_scope,
|
|
player_test_scope: RuntimePlayerConditionTestScope::Disabled,
|
|
territory_scope_selector_is_0x63: false,
|
|
source_row_indexes: vec![0],
|
|
}
|
|
}
|
|
|
|
fn territory_negative_sentinel_scope() -> crate::SmpLoadedPackedEventNegativeSentinelScopeSummary
|
|
{
|
|
crate::SmpLoadedPackedEventNegativeSentinelScopeSummary {
|
|
company_test_scope: RuntimeCompanyConditionTestScope::AllCompanies,
|
|
player_test_scope: RuntimePlayerConditionTestScope::Disabled,
|
|
territory_scope_selector_is_0x63: true,
|
|
source_row_indexes: vec![0],
|
|
}
|
|
}
|
|
|
|
fn player_negative_sentinel_scope() -> crate::SmpLoadedPackedEventNegativeSentinelScopeSummary {
|
|
crate::SmpLoadedPackedEventNegativeSentinelScopeSummary {
|
|
company_test_scope: RuntimeCompanyConditionTestScope::AllCompanies,
|
|
player_test_scope: RuntimePlayerConditionTestScope::AllPlayers,
|
|
territory_scope_selector_is_0x63: false,
|
|
source_row_indexes: vec![0],
|
|
}
|
|
}
|
|
|
|
fn real_grouped_rows() -> Vec<crate::SmpLoadedPackedEventGroupedEffectRowSummary> {
|
|
vec![crate::SmpLoadedPackedEventGroupedEffectRowSummary {
|
|
group_index: 0,
|
|
row_index: 0,
|
|
descriptor_id: 2,
|
|
descriptor_label: Some("Company Cash".to_string()),
|
|
target_mask_bits: Some(0x01),
|
|
parameter_family: Some("company_finance_scalar".to_string()),
|
|
opcode: 8,
|
|
raw_scalar_value: 7,
|
|
value_byte_0x09: 1,
|
|
value_dword_0x0d: 12,
|
|
value_byte_0x11: 2,
|
|
value_byte_0x12: 3,
|
|
value_word_0x14: 24,
|
|
value_word_0x16: 36,
|
|
row_shape: "multivalue_scalar".to_string(),
|
|
semantic_family: Some("multivalue_scalar".to_string()),
|
|
semantic_preview: Some("Set Company Cash to 7 with aux [2, 3, 24, 36]".to_string()),
|
|
locomotive_name: Some("Mikado".to_string()),
|
|
notes: vec!["grouped effect row carries locomotive-name side string".to_string()],
|
|
}]
|
|
}
|
|
|
|
fn real_deactivate_company_row(
|
|
enabled: bool,
|
|
) -> crate::SmpLoadedPackedEventGroupedEffectRowSummary {
|
|
crate::SmpLoadedPackedEventGroupedEffectRowSummary {
|
|
group_index: 0,
|
|
row_index: 0,
|
|
descriptor_id: 13,
|
|
descriptor_label: Some("Deactivate Company".to_string()),
|
|
target_mask_bits: Some(0x01),
|
|
parameter_family: Some("company_lifecycle_toggle".to_string()),
|
|
opcode: 1,
|
|
raw_scalar_value: if enabled { 1 } else { 0 },
|
|
value_byte_0x09: 0,
|
|
value_dword_0x0d: 0,
|
|
value_byte_0x11: 0,
|
|
value_byte_0x12: 0,
|
|
value_word_0x14: 0,
|
|
value_word_0x16: 0,
|
|
row_shape: "bool_toggle".to_string(),
|
|
semantic_family: Some("bool_toggle".to_string()),
|
|
semantic_preview: Some(format!(
|
|
"Set Deactivate Company to {}",
|
|
if enabled { "TRUE" } else { "FALSE" }
|
|
)),
|
|
locomotive_name: None,
|
|
notes: vec![],
|
|
}
|
|
}
|
|
|
|
fn real_track_capacity_row(value: i32) -> crate::SmpLoadedPackedEventGroupedEffectRowSummary {
|
|
crate::SmpLoadedPackedEventGroupedEffectRowSummary {
|
|
group_index: 0,
|
|
row_index: 0,
|
|
descriptor_id: 16,
|
|
descriptor_label: Some("Company Track Pieces Buildable".to_string()),
|
|
target_mask_bits: Some(0x01),
|
|
parameter_family: Some("company_build_limit_scalar".to_string()),
|
|
opcode: 3,
|
|
raw_scalar_value: value,
|
|
value_byte_0x09: 0,
|
|
value_dword_0x0d: 0,
|
|
value_byte_0x11: 0,
|
|
value_byte_0x12: 0,
|
|
value_word_0x14: 0,
|
|
value_word_0x16: 0,
|
|
row_shape: "scalar_assignment".to_string(),
|
|
semantic_family: Some("scalar_assignment".to_string()),
|
|
semantic_preview: Some(format!("Set Company Track Pieces Buildable to {value}")),
|
|
locomotive_name: None,
|
|
notes: vec![],
|
|
}
|
|
}
|
|
|
|
fn unsupported_real_grouped_row() -> crate::SmpLoadedPackedEventGroupedEffectRowSummary {
|
|
crate::SmpLoadedPackedEventGroupedEffectRowSummary {
|
|
group_index: 1,
|
|
row_index: 0,
|
|
descriptor_id: 8,
|
|
descriptor_label: Some("Economic Status".to_string()),
|
|
target_mask_bits: Some(0x08),
|
|
parameter_family: Some("whole_game_state_enum".to_string()),
|
|
opcode: 3,
|
|
raw_scalar_value: 2,
|
|
value_byte_0x09: 0,
|
|
value_dword_0x0d: 0,
|
|
value_byte_0x11: 0,
|
|
value_byte_0x12: 0,
|
|
value_word_0x14: 0,
|
|
value_word_0x16: 0,
|
|
row_shape: "scalar_assignment".to_string(),
|
|
semantic_family: Some("scalar_assignment".to_string()),
|
|
semantic_preview: Some("Set Economic Status to 2".to_string()),
|
|
locomotive_name: None,
|
|
notes: vec![],
|
|
}
|
|
}
|
|
|
|
fn real_compact_control() -> crate::SmpLoadedPackedEventCompactControlSummary {
|
|
crate::SmpLoadedPackedEventCompactControlSummary {
|
|
mode_byte_0x7ef: 6,
|
|
primary_selector_0x7f0: 0x63,
|
|
grouped_mode_0x7f4: 2,
|
|
one_shot_header_0x7f5: 1,
|
|
modifier_flag_0x7f9: 1,
|
|
modifier_flag_0x7fa: 0,
|
|
grouped_target_scope_ordinals_0x7fb: vec![0, 1, 2, 3],
|
|
grouped_scope_checkboxes_0x7ff: vec![1, 0, 1, 0],
|
|
summary_toggle_0x800: 1,
|
|
grouped_territory_selectors_0x80f: vec![-1, 10, -1, 22],
|
|
}
|
|
}
|
|
|
|
fn real_compact_control_without_symbolic_company_scope()
|
|
-> crate::SmpLoadedPackedEventCompactControlSummary {
|
|
crate::SmpLoadedPackedEventCompactControlSummary {
|
|
mode_byte_0x7ef: 6,
|
|
primary_selector_0x7f0: 0x63,
|
|
grouped_mode_0x7f4: 2,
|
|
one_shot_header_0x7f5: 1,
|
|
modifier_flag_0x7f9: 1,
|
|
modifier_flag_0x7fa: 0,
|
|
grouped_target_scope_ordinals_0x7fb: vec![8, 9, 10, 11],
|
|
grouped_scope_checkboxes_0x7ff: vec![1, 0, 1, 0],
|
|
summary_toggle_0x800: 1,
|
|
grouped_territory_selectors_0x80f: vec![-1, 10, -1, 22],
|
|
}
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
|
|
#[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(),
|
|
payload_family: "unsupported_framing".to_string(),
|
|
trigger_kind: None,
|
|
active: None,
|
|
marks_collection_dirty: None,
|
|
one_shot: None,
|
|
compact_control: None,
|
|
text_bands: Vec::new(),
|
|
standalone_condition_row_count: 0,
|
|
standalone_condition_rows: Vec::new(),
|
|
negative_sentinel_scope: None,
|
|
grouped_effect_row_counts: vec![0, 0, 0, 0],
|
|
grouped_effect_rows: Vec::new(),
|
|
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(),
|
|
payload_family: "unsupported_framing".to_string(),
|
|
trigger_kind: None,
|
|
active: None,
|
|
marks_collection_dirty: None,
|
|
one_shot: None,
|
|
compact_control: None,
|
|
text_bands: Vec::new(),
|
|
standalone_condition_row_count: 0,
|
|
standalone_condition_rows: Vec::new(),
|
|
negative_sentinel_scope: None,
|
|
grouped_effect_row_counts: vec![0, 0, 0, 0],
|
|
grouped_effect_rows: Vec::new(),
|
|
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(),
|
|
payload_family: "unsupported_framing".to_string(),
|
|
trigger_kind: None,
|
|
active: None,
|
|
marks_collection_dirty: None,
|
|
one_shot: None,
|
|
compact_control: None,
|
|
text_bands: Vec::new(),
|
|
standalone_condition_row_count: 0,
|
|
standalone_condition_rows: Vec::new(),
|
|
negative_sentinel_scope: None,
|
|
grouped_effect_row_counts: vec![0, 0, 0, 0],
|
|
grouped_effect_rows: Vec::new(),
|
|
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(),
|
|
payload_family: "synthetic_harness".to_string(),
|
|
trigger_kind: Some(7),
|
|
active: Some(true),
|
|
marks_collection_dirty: Some(true),
|
|
one_shot: Some(false),
|
|
compact_control: None,
|
|
text_bands: packed_text_bands(),
|
|
standalone_condition_row_count: 1,
|
|
standalone_condition_rows: vec![],
|
|
negative_sentinel_scope: None,
|
|
grouped_effect_row_counts: vec![0, 1, 0, 0],
|
|
grouped_effect_rows: vec![],
|
|
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(),
|
|
payload_family: "synthetic_harness".to_string(),
|
|
trigger_kind: Some(7),
|
|
active: Some(true),
|
|
marks_collection_dirty: Some(false),
|
|
one_shot: Some(false),
|
|
compact_control: None,
|
|
text_bands: packed_text_bands(),
|
|
standalone_condition_row_count: 0,
|
|
standalone_condition_rows: vec![],
|
|
negative_sentinel_scope: None,
|
|
grouped_effect_row_counts: vec![0, 0, 0, 0],
|
|
grouped_effect_rows: vec![],
|
|
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 classifies_symbolic_company_target_blockers_for_standalone_import() {
|
|
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: 12,
|
|
live_record_count: 3,
|
|
live_entry_ids: vec![10, 11, 12],
|
|
decoded_record_count: 3,
|
|
imported_runtime_record_count: 0,
|
|
records: vec![
|
|
synthetic_packed_record(
|
|
0,
|
|
10,
|
|
RuntimeEffect::AdjustCompanyCash {
|
|
target: RuntimeCompanyTarget::SelectedCompany,
|
|
delta: 1,
|
|
},
|
|
),
|
|
synthetic_packed_record(
|
|
1,
|
|
11,
|
|
RuntimeEffect::AdjustCompanyDebt {
|
|
target: RuntimeCompanyTarget::HumanCompanies,
|
|
delta: 2,
|
|
},
|
|
),
|
|
synthetic_packed_record(
|
|
2,
|
|
12,
|
|
RuntimeEffect::AdjustCompanyDebt {
|
|
target: RuntimeCompanyTarget::ConditionTrueCompany,
|
|
delta: 3,
|
|
},
|
|
),
|
|
],
|
|
}),
|
|
notes: vec![],
|
|
};
|
|
|
|
let import =
|
|
project_save_slice_to_runtime_state_import(&save_slice, "symbolic-blockers", None)
|
|
.expect("standalone projection should succeed");
|
|
|
|
assert!(import.state.event_runtime_records.is_empty());
|
|
let outcomes = import
|
|
.state
|
|
.packed_event_collection
|
|
.as_ref()
|
|
.expect("packed event collection should be present")
|
|
.records
|
|
.iter()
|
|
.map(|record| record.import_outcome.clone())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
outcomes,
|
|
vec![
|
|
Some("blocked_missing_selection_context".to_string()),
|
|
Some("blocked_missing_company_role_context".to_string()),
|
|
Some("blocked_missing_condition_context".to_string()),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn overlays_symbolic_company_targets_into_executable_runtime_records() {
|
|
let base_state = RuntimeState {
|
|
companies: vec![
|
|
crate::RuntimeCompany {
|
|
company_id: 1,
|
|
controller_kind: RuntimeCompanyControllerKind::Human,
|
|
current_cash: 100,
|
|
debt: 10,
|
|
active: true,
|
|
available_track_laying_capacity: None,
|
|
},
|
|
crate::RuntimeCompany {
|
|
company_id: 2,
|
|
controller_kind: RuntimeCompanyControllerKind::Ai,
|
|
current_cash: 50,
|
|
debt: 20,
|
|
active: true,
|
|
available_track_laying_capacity: None,
|
|
},
|
|
],
|
|
selected_company_id: Some(1),
|
|
..state()
|
|
};
|
|
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: 22,
|
|
live_record_count: 2,
|
|
live_entry_ids: vec![21, 22],
|
|
decoded_record_count: 2,
|
|
imported_runtime_record_count: 0,
|
|
records: vec![
|
|
synthetic_packed_record(
|
|
0,
|
|
21,
|
|
RuntimeEffect::AdjustCompanyCash {
|
|
target: RuntimeCompanyTarget::SelectedCompany,
|
|
delta: 15,
|
|
},
|
|
),
|
|
synthetic_packed_record(
|
|
1,
|
|
22,
|
|
RuntimeEffect::AdjustCompanyDebt {
|
|
target: RuntimeCompanyTarget::AiCompanies,
|
|
delta: 4,
|
|
},
|
|
),
|
|
],
|
|
}),
|
|
notes: vec![],
|
|
};
|
|
|
|
let mut import = project_save_slice_overlay_to_runtime_state_import(
|
|
&base_state,
|
|
&save_slice,
|
|
"symbolic-overlay",
|
|
None,
|
|
)
|
|
.expect("overlay projection should succeed");
|
|
|
|
assert_eq!(import.state.event_runtime_records.len(), 2);
|
|
let outcomes = import
|
|
.state
|
|
.packed_event_collection
|
|
.as_ref()
|
|
.expect("packed event collection should be present")
|
|
.records
|
|
.iter()
|
|
.map(|record| record.import_outcome.clone())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
outcomes,
|
|
vec![Some("imported".to_string()), Some("imported".to_string())]
|
|
);
|
|
|
|
execute_step_command(
|
|
&mut import.state,
|
|
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
|
|
)
|
|
.expect("symbolic overlay dispatch should succeed");
|
|
|
|
assert_eq!(import.state.companies[0].current_cash, 115);
|
|
assert_eq!(import.state.companies[1].debt, 24);
|
|
}
|
|
|
|
#[test]
|
|
fn leaves_real_records_without_compact_control_blocked_missing_compact_control() {
|
|
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(96),
|
|
decode_status: "parity_only".to_string(),
|
|
payload_family: "real_packed_v1".to_string(),
|
|
trigger_kind: None,
|
|
active: None,
|
|
marks_collection_dirty: None,
|
|
one_shot: None,
|
|
compact_control: None,
|
|
text_bands: packed_text_bands(),
|
|
standalone_condition_row_count: 1,
|
|
standalone_condition_rows: real_condition_rows(),
|
|
negative_sentinel_scope: Some(company_negative_sentinel_scope(
|
|
RuntimeCompanyConditionTestScope::AllCompanies,
|
|
)),
|
|
grouped_effect_row_counts: vec![1, 0, 0, 0],
|
|
grouped_effect_rows: real_grouped_rows(),
|
|
decoded_actions: vec![RuntimeEffect::SetCompanyCash {
|
|
target: RuntimeCompanyTarget::ConditionTrueCompany,
|
|
value: 7,
|
|
}],
|
|
executable_import_ready: true,
|
|
notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()],
|
|
}],
|
|
}),
|
|
notes: vec![],
|
|
};
|
|
|
|
let import = project_save_slice_to_runtime_state_import(
|
|
&save_slice,
|
|
"packed-events-structural-only",
|
|
None,
|
|
)
|
|
.expect("save slice should project");
|
|
|
|
assert!(import.state.event_runtime_records.is_empty());
|
|
assert_eq!(
|
|
import
|
|
.state
|
|
.packed_event_collection
|
|
.as_ref()
|
|
.and_then(|summary| summary.records[0].import_outcome.as_deref()),
|
|
Some("blocked_missing_compact_control")
|
|
);
|
|
assert_eq!(
|
|
import
|
|
.state
|
|
.packed_event_collection
|
|
.as_ref()
|
|
.map(|summary| summary.records[0].standalone_condition_rows.len()),
|
|
Some(1)
|
|
);
|
|
assert_eq!(
|
|
import
|
|
.state
|
|
.packed_event_collection
|
|
.as_ref()
|
|
.map(|summary| summary.records[0].grouped_effect_rows.len()),
|
|
Some(1)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn lowers_negative_sentinel_company_scopes_into_runtime_company_targets() {
|
|
let base_state = RuntimeState {
|
|
companies: vec![
|
|
crate::RuntimeCompany {
|
|
company_id: 1,
|
|
controller_kind: RuntimeCompanyControllerKind::Human,
|
|
current_cash: 100,
|
|
debt: 10,
|
|
active: true,
|
|
available_track_laying_capacity: None,
|
|
},
|
|
crate::RuntimeCompany {
|
|
company_id: 2,
|
|
controller_kind: RuntimeCompanyControllerKind::Ai,
|
|
current_cash: 50,
|
|
debt: 20,
|
|
active: true,
|
|
available_track_laying_capacity: None,
|
|
},
|
|
crate::RuntimeCompany {
|
|
company_id: 3,
|
|
controller_kind: RuntimeCompanyControllerKind::Human,
|
|
current_cash: 70,
|
|
debt: 30,
|
|
active: true,
|
|
available_track_laying_capacity: None,
|
|
},
|
|
],
|
|
selected_company_id: Some(3),
|
|
..state()
|
|
};
|
|
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: 11,
|
|
live_record_count: 5,
|
|
live_entry_ids: vec![7, 8, 9, 10, 11],
|
|
decoded_record_count: 5,
|
|
imported_runtime_record_count: 0,
|
|
records: vec![
|
|
crate::SmpLoadedPackedEventRecordSummary {
|
|
record_index: 0,
|
|
live_entry_id: 7,
|
|
payload_offset: Some(0x7202),
|
|
payload_len: Some(133),
|
|
decode_status: "parity_only".to_string(),
|
|
payload_family: "real_packed_v1".to_string(),
|
|
trigger_kind: Some(6),
|
|
active: None,
|
|
marks_collection_dirty: None,
|
|
one_shot: Some(true),
|
|
compact_control: Some(real_compact_control()),
|
|
text_bands: packed_text_bands(),
|
|
standalone_condition_row_count: 1,
|
|
standalone_condition_rows: real_condition_rows(),
|
|
negative_sentinel_scope: Some(company_negative_sentinel_scope(
|
|
RuntimeCompanyConditionTestScope::AllCompanies,
|
|
)),
|
|
grouped_effect_row_counts: vec![1, 0, 0, 0],
|
|
grouped_effect_rows: real_grouped_rows(),
|
|
decoded_actions: vec![RuntimeEffect::SetCompanyCash {
|
|
target: RuntimeCompanyTarget::ConditionTrueCompany,
|
|
value: 7,
|
|
}],
|
|
executable_import_ready: true,
|
|
notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()],
|
|
},
|
|
crate::SmpLoadedPackedEventRecordSummary {
|
|
record_index: 1,
|
|
live_entry_id: 8,
|
|
payload_offset: Some(0x7282),
|
|
payload_len: Some(133),
|
|
decode_status: "parity_only".to_string(),
|
|
payload_family: "real_packed_v1".to_string(),
|
|
trigger_kind: Some(6),
|
|
active: None,
|
|
marks_collection_dirty: None,
|
|
one_shot: Some(true),
|
|
compact_control: Some(real_compact_control()),
|
|
text_bands: packed_text_bands(),
|
|
standalone_condition_row_count: 1,
|
|
standalone_condition_rows: real_condition_rows(),
|
|
negative_sentinel_scope: Some(company_negative_sentinel_scope(
|
|
RuntimeCompanyConditionTestScope::SelectedCompanyOnly,
|
|
)),
|
|
grouped_effect_row_counts: vec![1, 0, 0, 0],
|
|
grouped_effect_rows: real_grouped_rows(),
|
|
decoded_actions: vec![RuntimeEffect::SetCompanyCash {
|
|
target: RuntimeCompanyTarget::ConditionTrueCompany,
|
|
value: 8,
|
|
}],
|
|
executable_import_ready: true,
|
|
notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()],
|
|
},
|
|
crate::SmpLoadedPackedEventRecordSummary {
|
|
record_index: 2,
|
|
live_entry_id: 9,
|
|
payload_offset: Some(0x7302),
|
|
payload_len: Some(133),
|
|
decode_status: "parity_only".to_string(),
|
|
payload_family: "real_packed_v1".to_string(),
|
|
trigger_kind: Some(6),
|
|
active: None,
|
|
marks_collection_dirty: None,
|
|
one_shot: Some(true),
|
|
compact_control: Some(real_compact_control()),
|
|
text_bands: packed_text_bands(),
|
|
standalone_condition_row_count: 1,
|
|
standalone_condition_rows: real_condition_rows(),
|
|
negative_sentinel_scope: Some(company_negative_sentinel_scope(
|
|
RuntimeCompanyConditionTestScope::AiCompaniesOnly,
|
|
)),
|
|
grouped_effect_row_counts: vec![1, 0, 0, 0],
|
|
grouped_effect_rows: real_grouped_rows(),
|
|
decoded_actions: vec![RuntimeEffect::SetCompanyCash {
|
|
target: RuntimeCompanyTarget::ConditionTrueCompany,
|
|
value: 9,
|
|
}],
|
|
executable_import_ready: true,
|
|
notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()],
|
|
},
|
|
crate::SmpLoadedPackedEventRecordSummary {
|
|
record_index: 3,
|
|
live_entry_id: 10,
|
|
payload_offset: Some(0x7382),
|
|
payload_len: Some(133),
|
|
decode_status: "parity_only".to_string(),
|
|
payload_family: "real_packed_v1".to_string(),
|
|
trigger_kind: Some(6),
|
|
active: None,
|
|
marks_collection_dirty: None,
|
|
one_shot: Some(true),
|
|
compact_control: Some(real_compact_control()),
|
|
text_bands: packed_text_bands(),
|
|
standalone_condition_row_count: 1,
|
|
standalone_condition_rows: real_condition_rows(),
|
|
negative_sentinel_scope: Some(company_negative_sentinel_scope(
|
|
RuntimeCompanyConditionTestScope::HumanCompaniesOnly,
|
|
)),
|
|
grouped_effect_row_counts: vec![1, 0, 0, 0],
|
|
grouped_effect_rows: real_grouped_rows(),
|
|
decoded_actions: vec![RuntimeEffect::SetCompanyCash {
|
|
target: RuntimeCompanyTarget::ConditionTrueCompany,
|
|
value: 10,
|
|
}],
|
|
executable_import_ready: true,
|
|
notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()],
|
|
},
|
|
crate::SmpLoadedPackedEventRecordSummary {
|
|
record_index: 4,
|
|
live_entry_id: 11,
|
|
payload_offset: Some(0x7402),
|
|
payload_len: Some(133),
|
|
decode_status: "parity_only".to_string(),
|
|
payload_family: "real_packed_v1".to_string(),
|
|
trigger_kind: Some(6),
|
|
active: None,
|
|
marks_collection_dirty: None,
|
|
one_shot: Some(true),
|
|
compact_control: Some(real_compact_control()),
|
|
text_bands: packed_text_bands(),
|
|
standalone_condition_row_count: 1,
|
|
standalone_condition_rows: real_condition_rows(),
|
|
negative_sentinel_scope: Some(company_negative_sentinel_scope(
|
|
RuntimeCompanyConditionTestScope::Disabled,
|
|
)),
|
|
grouped_effect_row_counts: vec![1, 0, 0, 0],
|
|
grouped_effect_rows: real_grouped_rows(),
|
|
decoded_actions: vec![RuntimeEffect::SetCompanyCash {
|
|
target: RuntimeCompanyTarget::ConditionTrueCompany,
|
|
value: 11,
|
|
}],
|
|
executable_import_ready: true,
|
|
notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()],
|
|
},
|
|
],
|
|
}),
|
|
notes: vec![],
|
|
};
|
|
|
|
let import = project_save_slice_overlay_to_runtime_state_import(
|
|
&base_state,
|
|
&save_slice,
|
|
"packed-events-real-descriptor-frontier",
|
|
None,
|
|
)
|
|
.expect("save slice should project");
|
|
|
|
assert_eq!(import.state.event_runtime_records.len(), 4);
|
|
assert_eq!(
|
|
import
|
|
.state
|
|
.packed_event_collection
|
|
.as_ref()
|
|
.and_then(|summary| summary.records[0].compact_control.as_ref())
|
|
.map(|control| control.mode_byte_0x7ef),
|
|
Some(6)
|
|
);
|
|
let effects = import
|
|
.state
|
|
.event_runtime_records
|
|
.iter()
|
|
.map(|record| record.effects[0].clone())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
effects,
|
|
vec![
|
|
RuntimeEffect::SetCompanyCash {
|
|
target: RuntimeCompanyTarget::AllActive,
|
|
value: 7,
|
|
},
|
|
RuntimeEffect::SetCompanyCash {
|
|
target: RuntimeCompanyTarget::SelectedCompany,
|
|
value: 8,
|
|
},
|
|
RuntimeEffect::SetCompanyCash {
|
|
target: RuntimeCompanyTarget::AiCompanies,
|
|
value: 9,
|
|
},
|
|
RuntimeEffect::SetCompanyCash {
|
|
target: RuntimeCompanyTarget::HumanCompanies,
|
|
value: 10,
|
|
},
|
|
]
|
|
);
|
|
assert_eq!(
|
|
import
|
|
.state
|
|
.packed_event_collection
|
|
.as_ref()
|
|
.map(|summary| {
|
|
summary
|
|
.records
|
|
.iter()
|
|
.map(|record| record.import_outcome.clone())
|
|
.collect::<Vec<_>>()
|
|
}),
|
|
Some(vec![
|
|
Some("imported".to_string()),
|
|
Some("imported".to_string()),
|
|
Some("imported".to_string()),
|
|
Some("imported".to_string()),
|
|
Some("blocked_company_condition_scope_disabled".to_string()),
|
|
])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn blocks_negative_sentinel_player_scope_until_player_runtime_exists() {
|
|
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(133),
|
|
decode_status: "parity_only".to_string(),
|
|
payload_family: "real_packed_v1".to_string(),
|
|
trigger_kind: Some(6),
|
|
active: None,
|
|
marks_collection_dirty: None,
|
|
one_shot: Some(true),
|
|
compact_control: Some(real_compact_control()),
|
|
text_bands: packed_text_bands(),
|
|
standalone_condition_row_count: 1,
|
|
standalone_condition_rows: real_condition_rows(),
|
|
negative_sentinel_scope: Some(player_negative_sentinel_scope()),
|
|
grouped_effect_row_counts: vec![1, 0, 0, 0],
|
|
grouped_effect_rows: real_grouped_rows(),
|
|
decoded_actions: vec![RuntimeEffect::SetCompanyCash {
|
|
target: RuntimeCompanyTarget::ConditionTrueCompany,
|
|
value: 7,
|
|
}],
|
|
executable_import_ready: true,
|
|
notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()],
|
|
}],
|
|
}),
|
|
notes: vec![],
|
|
};
|
|
|
|
let import = project_save_slice_to_runtime_state_import(
|
|
&save_slice,
|
|
"negative-sentinel-player-scope",
|
|
None,
|
|
)
|
|
.expect("save slice should project");
|
|
|
|
assert!(import.state.event_runtime_records.is_empty());
|
|
assert_eq!(
|
|
import
|
|
.state
|
|
.packed_event_collection
|
|
.as_ref()
|
|
.and_then(|summary| summary.records[0].import_outcome.as_deref()),
|
|
Some("blocked_player_condition_scope")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn blocks_negative_sentinel_territory_scope_until_territory_runtime_exists() {
|
|
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(133),
|
|
decode_status: "parity_only".to_string(),
|
|
payload_family: "real_packed_v1".to_string(),
|
|
trigger_kind: Some(6),
|
|
active: None,
|
|
marks_collection_dirty: None,
|
|
one_shot: Some(true),
|
|
compact_control: Some(real_compact_control()),
|
|
text_bands: packed_text_bands(),
|
|
standalone_condition_row_count: 1,
|
|
standalone_condition_rows: real_condition_rows(),
|
|
negative_sentinel_scope: Some(territory_negative_sentinel_scope()),
|
|
grouped_effect_row_counts: vec![1, 0, 0, 0],
|
|
grouped_effect_rows: real_grouped_rows(),
|
|
decoded_actions: vec![RuntimeEffect::SetCompanyCash {
|
|
target: RuntimeCompanyTarget::ConditionTrueCompany,
|
|
value: 7,
|
|
}],
|
|
executable_import_ready: true,
|
|
notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()],
|
|
}],
|
|
}),
|
|
notes: vec![],
|
|
};
|
|
|
|
let import = project_save_slice_to_runtime_state_import(
|
|
&save_slice,
|
|
"negative-sentinel-territory-scope",
|
|
None,
|
|
)
|
|
.expect("save slice should project");
|
|
|
|
assert!(import.state.event_runtime_records.is_empty());
|
|
assert_eq!(
|
|
import
|
|
.state
|
|
.packed_event_collection
|
|
.as_ref()
|
|
.and_then(|summary| summary.records[0].import_outcome.as_deref()),
|
|
Some("blocked_territory_condition_scope")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn leaves_real_records_with_unclassified_scope_blocked_unmapped_real_descriptor() {
|
|
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(133),
|
|
decode_status: "parity_only".to_string(),
|
|
payload_family: "real_packed_v1".to_string(),
|
|
trigger_kind: Some(6),
|
|
active: None,
|
|
marks_collection_dirty: None,
|
|
one_shot: Some(true),
|
|
compact_control: Some(real_compact_control_without_symbolic_company_scope()),
|
|
text_bands: packed_text_bands(),
|
|
standalone_condition_row_count: 1,
|
|
standalone_condition_rows: real_condition_rows(),
|
|
negative_sentinel_scope: None,
|
|
grouped_effect_row_counts: vec![1, 0, 0, 0],
|
|
grouped_effect_rows: real_grouped_rows(),
|
|
decoded_actions: vec![],
|
|
executable_import_ready: false,
|
|
notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()],
|
|
}],
|
|
}),
|
|
notes: vec![],
|
|
};
|
|
|
|
let import = project_save_slice_to_runtime_state_import(
|
|
&save_slice,
|
|
"packed-events-real-descriptor-frontier",
|
|
None,
|
|
)
|
|
.expect("save slice should project");
|
|
|
|
assert!(import.state.event_runtime_records.is_empty());
|
|
assert_eq!(
|
|
import
|
|
.state
|
|
.packed_event_collection
|
|
.as_ref()
|
|
.and_then(|summary| summary.records[0].import_outcome.as_deref()),
|
|
Some("blocked_unmapped_real_descriptor")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn overlays_real_company_cash_descriptor_into_executable_runtime_record() {
|
|
let base_state = RuntimeState {
|
|
calendar: CalendarPoint {
|
|
year: 1845,
|
|
month_slot: 2,
|
|
phase_slot: 1,
|
|
tick_slot: 3,
|
|
},
|
|
world_flags: BTreeMap::new(),
|
|
save_profile: RuntimeSaveProfileState::default(),
|
|
world_restore: RuntimeWorldRestoreState::default(),
|
|
metadata: BTreeMap::new(),
|
|
companies: vec![crate::RuntimeCompany {
|
|
company_id: 42,
|
|
controller_kind: RuntimeCompanyControllerKind::Human,
|
|
current_cash: 500,
|
|
debt: 20,
|
|
active: true,
|
|
available_track_laying_capacity: None,
|
|
}],
|
|
selected_company_id: Some(42),
|
|
packed_event_collection: None,
|
|
event_runtime_records: Vec::new(),
|
|
candidate_availability: BTreeMap::new(),
|
|
special_conditions: BTreeMap::new(),
|
|
service_state: RuntimeServiceState::default(),
|
|
};
|
|
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: 9,
|
|
live_record_count: 1,
|
|
live_entry_ids: vec![9],
|
|
decoded_record_count: 1,
|
|
imported_runtime_record_count: 0,
|
|
records: vec![crate::SmpLoadedPackedEventRecordSummary {
|
|
record_index: 0,
|
|
live_entry_id: 9,
|
|
payload_offset: Some(0x7202),
|
|
payload_len: Some(133),
|
|
decode_status: "parity_only".to_string(),
|
|
payload_family: "real_packed_v1".to_string(),
|
|
trigger_kind: Some(7),
|
|
active: None,
|
|
marks_collection_dirty: None,
|
|
one_shot: Some(false),
|
|
compact_control: Some(crate::SmpLoadedPackedEventCompactControlSummary {
|
|
mode_byte_0x7ef: 7,
|
|
primary_selector_0x7f0: 0x63,
|
|
grouped_mode_0x7f4: 2,
|
|
one_shot_header_0x7f5: 0,
|
|
modifier_flag_0x7f9: 1,
|
|
modifier_flag_0x7fa: 0,
|
|
grouped_target_scope_ordinals_0x7fb: vec![1, 1, 1, 1],
|
|
grouped_scope_checkboxes_0x7ff: vec![1, 0, 0, 0],
|
|
summary_toggle_0x800: 1,
|
|
grouped_territory_selectors_0x80f: vec![-1, -1, -1, -1],
|
|
}),
|
|
text_bands: packed_text_bands(),
|
|
standalone_condition_row_count: 0,
|
|
standalone_condition_rows: vec![],
|
|
negative_sentinel_scope: None,
|
|
grouped_effect_row_counts: vec![1, 0, 0, 0],
|
|
grouped_effect_rows: vec![crate::SmpLoadedPackedEventGroupedEffectRowSummary {
|
|
group_index: 0,
|
|
row_index: 0,
|
|
descriptor_id: 2,
|
|
descriptor_label: Some("Company Cash".to_string()),
|
|
target_mask_bits: Some(0x01),
|
|
parameter_family: Some("company_finance_scalar".to_string()),
|
|
opcode: 8,
|
|
raw_scalar_value: 250,
|
|
value_byte_0x09: 1,
|
|
value_dword_0x0d: 12,
|
|
value_byte_0x11: 2,
|
|
value_byte_0x12: 3,
|
|
value_word_0x14: 24,
|
|
value_word_0x16: 36,
|
|
row_shape: "multivalue_scalar".to_string(),
|
|
semantic_family: Some("multivalue_scalar".to_string()),
|
|
semantic_preview: Some(
|
|
"Set Company Cash to 250 with aux [2, 3, 24, 36]".to_string(),
|
|
),
|
|
locomotive_name: Some("Mikado".to_string()),
|
|
notes: vec![
|
|
"grouped effect row carries locomotive-name side string".to_string(),
|
|
],
|
|
}],
|
|
decoded_actions: vec![RuntimeEffect::SetCompanyCash {
|
|
target: RuntimeCompanyTarget::SelectedCompany,
|
|
value: 250,
|
|
}],
|
|
executable_import_ready: true,
|
|
notes: vec![
|
|
"decoded from grounded real 0x4e9a row framing".to_string(),
|
|
"grouped descriptor labels and target masks come from the checked-in effect table recovered around 0x006103a0".to_string(),
|
|
],
|
|
}],
|
|
}),
|
|
notes: vec![],
|
|
};
|
|
|
|
let mut import = project_save_slice_overlay_to_runtime_state_import(
|
|
&base_state,
|
|
&save_slice,
|
|
"real-company-cash-overlay",
|
|
None,
|
|
)
|
|
.expect("overlay import should project");
|
|
|
|
assert_eq!(import.state.event_runtime_records.len(), 1);
|
|
assert_eq!(
|
|
import
|
|
.state
|
|
.packed_event_collection
|
|
.as_ref()
|
|
.and_then(|summary| summary.records[0].import_outcome.as_deref()),
|
|
Some("imported")
|
|
);
|
|
|
|
execute_step_command(
|
|
&mut import.state,
|
|
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
|
|
)
|
|
.expect("real company-cash descriptor should execute through the normal trigger path");
|
|
|
|
assert_eq!(import.state.companies[0].current_cash, 250);
|
|
}
|
|
|
|
#[test]
|
|
fn overlays_real_deactivate_company_descriptor_into_executable_runtime_record() {
|
|
let base_state = RuntimeState {
|
|
companies: vec![crate::RuntimeCompany {
|
|
company_id: 42,
|
|
controller_kind: RuntimeCompanyControllerKind::Human,
|
|
current_cash: 500,
|
|
debt: 20,
|
|
active: true,
|
|
available_track_laying_capacity: None,
|
|
}],
|
|
selected_company_id: Some(42),
|
|
..state()
|
|
};
|
|
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: 13,
|
|
live_record_count: 1,
|
|
live_entry_ids: vec![13],
|
|
decoded_record_count: 1,
|
|
imported_runtime_record_count: 0,
|
|
records: vec![crate::SmpLoadedPackedEventRecordSummary {
|
|
record_index: 0,
|
|
live_entry_id: 13,
|
|
payload_offset: Some(0x7202),
|
|
payload_len: Some(120),
|
|
decode_status: "parity_only".to_string(),
|
|
payload_family: "real_packed_v1".to_string(),
|
|
trigger_kind: Some(7),
|
|
active: None,
|
|
marks_collection_dirty: None,
|
|
one_shot: Some(false),
|
|
compact_control: Some(crate::SmpLoadedPackedEventCompactControlSummary {
|
|
mode_byte_0x7ef: 7,
|
|
primary_selector_0x7f0: 0x63,
|
|
grouped_mode_0x7f4: 2,
|
|
one_shot_header_0x7f5: 0,
|
|
modifier_flag_0x7f9: 1,
|
|
modifier_flag_0x7fa: 0,
|
|
grouped_target_scope_ordinals_0x7fb: vec![1, 1, 1, 1],
|
|
grouped_scope_checkboxes_0x7ff: vec![1, 0, 0, 0],
|
|
summary_toggle_0x800: 1,
|
|
grouped_territory_selectors_0x80f: vec![-1, -1, -1, -1],
|
|
}),
|
|
text_bands: packed_text_bands(),
|
|
standalone_condition_row_count: 0,
|
|
standalone_condition_rows: vec![],
|
|
negative_sentinel_scope: None,
|
|
grouped_effect_row_counts: vec![1, 0, 0, 0],
|
|
grouped_effect_rows: vec![real_deactivate_company_row(true)],
|
|
decoded_actions: vec![RuntimeEffect::DeactivateCompany {
|
|
target: RuntimeCompanyTarget::SelectedCompany,
|
|
}],
|
|
executable_import_ready: true,
|
|
notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()],
|
|
}],
|
|
}),
|
|
notes: vec![],
|
|
};
|
|
|
|
let mut import = project_save_slice_overlay_to_runtime_state_import(
|
|
&base_state,
|
|
&save_slice,
|
|
"real-deactivate-company-overlay",
|
|
None,
|
|
)
|
|
.expect("overlay import should project");
|
|
|
|
assert_eq!(import.state.event_runtime_records.len(), 1);
|
|
execute_step_command(
|
|
&mut import.state,
|
|
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
|
|
)
|
|
.expect("real deactivate-company descriptor should execute");
|
|
|
|
assert!(!import.state.companies[0].active);
|
|
assert_eq!(import.state.selected_company_id, None);
|
|
}
|
|
|
|
#[test]
|
|
fn keeps_real_deactivate_company_false_row_parity_only() {
|
|
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: 14,
|
|
live_record_count: 1,
|
|
live_entry_ids: vec![14],
|
|
decoded_record_count: 1,
|
|
imported_runtime_record_count: 0,
|
|
records: vec![crate::SmpLoadedPackedEventRecordSummary {
|
|
record_index: 0,
|
|
live_entry_id: 14,
|
|
payload_offset: Some(0x7202),
|
|
payload_len: Some(120),
|
|
decode_status: "parity_only".to_string(),
|
|
payload_family: "real_packed_v1".to_string(),
|
|
trigger_kind: Some(7),
|
|
active: None,
|
|
marks_collection_dirty: None,
|
|
one_shot: Some(false),
|
|
compact_control: Some(crate::SmpLoadedPackedEventCompactControlSummary {
|
|
mode_byte_0x7ef: 7,
|
|
primary_selector_0x7f0: 0x63,
|
|
grouped_mode_0x7f4: 2,
|
|
one_shot_header_0x7f5: 0,
|
|
modifier_flag_0x7f9: 1,
|
|
modifier_flag_0x7fa: 0,
|
|
grouped_target_scope_ordinals_0x7fb: vec![1, 1, 1, 1],
|
|
grouped_scope_checkboxes_0x7ff: vec![1, 0, 0, 0],
|
|
summary_toggle_0x800: 1,
|
|
grouped_territory_selectors_0x80f: vec![-1, -1, -1, -1],
|
|
}),
|
|
text_bands: packed_text_bands(),
|
|
standalone_condition_row_count: 0,
|
|
standalone_condition_rows: vec![],
|
|
negative_sentinel_scope: None,
|
|
grouped_effect_row_counts: vec![1, 0, 0, 0],
|
|
grouped_effect_rows: vec![real_deactivate_company_row(false)],
|
|
decoded_actions: vec![],
|
|
executable_import_ready: false,
|
|
notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()],
|
|
}],
|
|
}),
|
|
notes: vec![],
|
|
};
|
|
|
|
let import = project_save_slice_to_runtime_state_import(
|
|
&save_slice,
|
|
"real-deactivate-company-false",
|
|
None,
|
|
)
|
|
.expect("save slice should project");
|
|
|
|
assert!(import.state.event_runtime_records.is_empty());
|
|
assert_eq!(
|
|
import
|
|
.state
|
|
.packed_event_collection
|
|
.as_ref()
|
|
.and_then(|summary| summary.records[0].import_outcome.as_deref()),
|
|
Some("blocked_unmapped_real_descriptor")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn overlays_real_track_capacity_descriptor_into_executable_runtime_record() {
|
|
let base_state = RuntimeState {
|
|
companies: vec![crate::RuntimeCompany {
|
|
company_id: 42,
|
|
controller_kind: RuntimeCompanyControllerKind::Human,
|
|
current_cash: 500,
|
|
debt: 20,
|
|
active: true,
|
|
available_track_laying_capacity: None,
|
|
}],
|
|
selected_company_id: Some(42),
|
|
..state()
|
|
};
|
|
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: 16,
|
|
live_record_count: 1,
|
|
live_entry_ids: vec![16],
|
|
decoded_record_count: 1,
|
|
imported_runtime_record_count: 0,
|
|
records: vec![crate::SmpLoadedPackedEventRecordSummary {
|
|
record_index: 0,
|
|
live_entry_id: 16,
|
|
payload_offset: Some(0x7202),
|
|
payload_len: Some(120),
|
|
decode_status: "parity_only".to_string(),
|
|
payload_family: "real_packed_v1".to_string(),
|
|
trigger_kind: Some(7),
|
|
active: None,
|
|
marks_collection_dirty: None,
|
|
one_shot: Some(false),
|
|
compact_control: Some(crate::SmpLoadedPackedEventCompactControlSummary {
|
|
mode_byte_0x7ef: 7,
|
|
primary_selector_0x7f0: 0x63,
|
|
grouped_mode_0x7f4: 2,
|
|
one_shot_header_0x7f5: 0,
|
|
modifier_flag_0x7f9: 1,
|
|
modifier_flag_0x7fa: 0,
|
|
grouped_target_scope_ordinals_0x7fb: vec![1, 1, 1, 1],
|
|
grouped_scope_checkboxes_0x7ff: vec![1, 0, 0, 0],
|
|
summary_toggle_0x800: 1,
|
|
grouped_territory_selectors_0x80f: vec![-1, -1, -1, -1],
|
|
}),
|
|
text_bands: packed_text_bands(),
|
|
standalone_condition_row_count: 0,
|
|
standalone_condition_rows: vec![],
|
|
negative_sentinel_scope: None,
|
|
grouped_effect_row_counts: vec![1, 0, 0, 0],
|
|
grouped_effect_rows: vec![real_track_capacity_row(18)],
|
|
decoded_actions: vec![RuntimeEffect::SetCompanyTrackLayingCapacity {
|
|
target: RuntimeCompanyTarget::SelectedCompany,
|
|
value: Some(18),
|
|
}],
|
|
executable_import_ready: true,
|
|
notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()],
|
|
}],
|
|
}),
|
|
notes: vec![],
|
|
};
|
|
|
|
let mut import = project_save_slice_overlay_to_runtime_state_import(
|
|
&base_state,
|
|
&save_slice,
|
|
"real-track-capacity-overlay",
|
|
None,
|
|
)
|
|
.expect("overlay import should project");
|
|
|
|
execute_step_command(
|
|
&mut import.state,
|
|
&StepCommand::ServiceTriggerKind { trigger_kind: 7 },
|
|
)
|
|
.expect("real track-capacity descriptor should execute");
|
|
|
|
assert_eq!(
|
|
import.state.companies[0].available_track_laying_capacity,
|
|
Some(18)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn keeps_mixed_real_records_out_of_event_runtime_records() {
|
|
let base_state = RuntimeState {
|
|
companies: vec![crate::RuntimeCompany {
|
|
company_id: 42,
|
|
controller_kind: RuntimeCompanyControllerKind::Human,
|
|
current_cash: 500,
|
|
debt: 20,
|
|
active: true,
|
|
available_track_laying_capacity: None,
|
|
}],
|
|
selected_company_id: Some(42),
|
|
..state()
|
|
};
|
|
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: 17,
|
|
live_record_count: 1,
|
|
live_entry_ids: vec![17],
|
|
decoded_record_count: 1,
|
|
imported_runtime_record_count: 0,
|
|
records: vec![crate::SmpLoadedPackedEventRecordSummary {
|
|
record_index: 0,
|
|
live_entry_id: 17,
|
|
payload_offset: Some(0x7202),
|
|
payload_len: Some(160),
|
|
decode_status: "parity_only".to_string(),
|
|
payload_family: "real_packed_v1".to_string(),
|
|
trigger_kind: Some(7),
|
|
active: None,
|
|
marks_collection_dirty: None,
|
|
one_shot: Some(false),
|
|
compact_control: Some(crate::SmpLoadedPackedEventCompactControlSummary {
|
|
mode_byte_0x7ef: 7,
|
|
primary_selector_0x7f0: 0x63,
|
|
grouped_mode_0x7f4: 2,
|
|
one_shot_header_0x7f5: 0,
|
|
modifier_flag_0x7f9: 1,
|
|
modifier_flag_0x7fa: 0,
|
|
grouped_target_scope_ordinals_0x7fb: vec![1, 1, 1, 1],
|
|
grouped_scope_checkboxes_0x7ff: vec![1, 1, 0, 0],
|
|
summary_toggle_0x800: 1,
|
|
grouped_territory_selectors_0x80f: vec![-1, -1, -1, -1],
|
|
}),
|
|
text_bands: packed_text_bands(),
|
|
standalone_condition_row_count: 0,
|
|
standalone_condition_rows: vec![],
|
|
negative_sentinel_scope: None,
|
|
grouped_effect_row_counts: vec![1, 1, 0, 0],
|
|
grouped_effect_rows: vec![
|
|
real_track_capacity_row(18),
|
|
unsupported_real_grouped_row(),
|
|
],
|
|
decoded_actions: vec![RuntimeEffect::SetCompanyTrackLayingCapacity {
|
|
target: RuntimeCompanyTarget::SelectedCompany,
|
|
value: Some(18),
|
|
}],
|
|
executable_import_ready: false,
|
|
notes: vec!["decoded from grounded real 0x4e9a row framing".to_string()],
|
|
}],
|
|
}),
|
|
notes: vec![],
|
|
};
|
|
|
|
let import = project_save_slice_overlay_to_runtime_state_import(
|
|
&base_state,
|
|
&save_slice,
|
|
"mixed-real-record-overlay",
|
|
None,
|
|
)
|
|
.expect("overlay import should project");
|
|
|
|
assert!(import.state.event_runtime_records.is_empty());
|
|
assert_eq!(
|
|
import
|
|
.state
|
|
.packed_event_collection
|
|
.as_ref()
|
|
.and_then(|summary| summary.records[0].import_outcome.as_deref()),
|
|
Some("blocked_unmapped_real_descriptor")
|
|
);
|
|
}
|
|
|
|
#[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,
|
|
controller_kind: RuntimeCompanyControllerKind::Human,
|
|
current_cash: 500,
|
|
debt: 20,
|
|
active: true,
|
|
available_track_laying_capacity: None,
|
|
}],
|
|
selected_company_id: Some(42),
|
|
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(),
|
|
payload_family: "synthetic_harness".to_string(),
|
|
trigger_kind: Some(7),
|
|
active: Some(true),
|
|
marks_collection_dirty: Some(false),
|
|
one_shot: Some(false),
|
|
compact_control: None,
|
|
text_bands: packed_text_bands(),
|
|
standalone_condition_row_count: 0,
|
|
standalone_condition_rows: vec![],
|
|
negative_sentinel_scope: None,
|
|
grouped_effect_row_counts: vec![0, 0, 0, 0],
|
|
grouped_effect_rows: vec![],
|
|
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,
|
|
controller_kind: RuntimeCompanyControllerKind::Human,
|
|
current_cash: 100,
|
|
debt: 0,
|
|
active: true,
|
|
available_track_laying_capacity: None,
|
|
}],
|
|
selected_company_id: Some(42),
|
|
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(),
|
|
payload_family: "synthetic_harness".to_string(),
|
|
trigger_kind: Some(7),
|
|
active: Some(true),
|
|
marks_collection_dirty: Some(false),
|
|
one_shot: Some(false),
|
|
compact_control: None,
|
|
text_bands: packed_text_bands(),
|
|
standalone_condition_row_count: 0,
|
|
standalone_condition_rows: vec![],
|
|
negative_sentinel_scope: None,
|
|
grouped_effect_row_counts: vec![0, 0, 0, 0],
|
|
grouped_effect_rows: vec![],
|
|
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);
|
|
}
|
|
}
|