Add hook debug tooling and refine RT3 atlas
This commit is contained in:
parent
860d1aed90
commit
57bf0666e0
38 changed files with 14437 additions and 873 deletions
|
|
@ -6,4 +6,6 @@ license.workspace = true
|
|||
|
||||
[dependencies]
|
||||
rrt-model = { path = "../rrt-model" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
|
|
|
|||
|
|
@ -6,10 +6,34 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use rrt_model::{
|
||||
BINARY_SUMMARY_PATH, CANONICAL_EXE_PATH, CONTROL_LOOP_ATLAS_PATH, FUNCTION_MAP_PATH,
|
||||
REQUIRED_ATLAS_HEADINGS, REQUIRED_EXPORTS, load_binary_summary, load_function_map,
|
||||
REQUIRED_ATLAS_HEADINGS, REQUIRED_EXPORTS,
|
||||
finance::{FinanceOutcome, FinanceSnapshot},
|
||||
load_binary_summary, load_function_map,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
enum Command {
|
||||
Validate { repo_root: PathBuf },
|
||||
FinanceEval { snapshot_path: PathBuf },
|
||||
FinanceDiff { left_path: PathBuf, right_path: PathBuf },
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct FinanceDiffEntry {
|
||||
path: String,
|
||||
left: Value,
|
||||
right: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct FinanceDiffReport {
|
||||
matches: bool,
|
||||
difference_count: usize,
|
||||
differences: Vec<FinanceDiffEntry>,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if let Err(err) = real_main() {
|
||||
eprintln!("error: {err}");
|
||||
|
|
@ -18,22 +42,155 @@ fn main() {
|
|||
}
|
||||
|
||||
fn real_main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let repo_root = parse_repo_root()?;
|
||||
validate_required_files(&repo_root)?;
|
||||
validate_binary_summary(&repo_root)?;
|
||||
validate_function_map(&repo_root)?;
|
||||
validate_control_loop_atlas(&repo_root)?;
|
||||
println!("baseline validation passed");
|
||||
match parse_command()? {
|
||||
Command::Validate { repo_root } => {
|
||||
validate_required_files(&repo_root)?;
|
||||
validate_binary_summary(&repo_root)?;
|
||||
validate_function_map(&repo_root)?;
|
||||
validate_control_loop_atlas(&repo_root)?;
|
||||
println!("baseline validation passed");
|
||||
}
|
||||
Command::FinanceEval { snapshot_path } => {
|
||||
run_finance_eval(&snapshot_path)?;
|
||||
}
|
||||
Command::FinanceDiff {
|
||||
left_path,
|
||||
right_path,
|
||||
} => {
|
||||
run_finance_diff(&left_path, &right_path)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_repo_root() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
fn parse_command() -> Result<Command, Box<dyn std::error::Error>> {
|
||||
let mut args = env::args().skip(1);
|
||||
match (args.next().as_deref(), args.next(), args.next()) {
|
||||
(None, None, None) => Ok(env::current_dir()?),
|
||||
(Some("validate"), None, None) => Ok(env::current_dir()?),
|
||||
(Some("validate"), Some(path), None) => Ok(PathBuf::from(path)),
|
||||
_ => Err("usage: rrt-cli [validate [repo-root]]".into()),
|
||||
match (args.next().as_deref(), args.next(), args.next(), args.next()) {
|
||||
(None, None, None, None) => Ok(Command::Validate {
|
||||
repo_root: env::current_dir()?,
|
||||
}),
|
||||
(Some("validate"), None, None, None) => Ok(Command::Validate {
|
||||
repo_root: env::current_dir()?,
|
||||
}),
|
||||
(Some("validate"), Some(path), None, None) => Ok(Command::Validate {
|
||||
repo_root: PathBuf::from(path),
|
||||
}),
|
||||
(Some("finance"), Some(subcommand), Some(path), None) if subcommand == "eval" => {
|
||||
Ok(Command::FinanceEval {
|
||||
snapshot_path: PathBuf::from(path),
|
||||
})
|
||||
}
|
||||
(Some("finance"), Some(subcommand), Some(left), Some(right)) if subcommand == "diff" => {
|
||||
Ok(Command::FinanceDiff {
|
||||
left_path: PathBuf::from(left),
|
||||
right_path: PathBuf::from(right),
|
||||
})
|
||||
}
|
||||
_ => Err(
|
||||
"usage: rrt-cli [validate [repo-root] | finance eval <snapshot.json> | finance diff <left.json> <right.json>]"
|
||||
.into(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_finance_eval(snapshot_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let outcome = load_finance_outcome(snapshot_path)?;
|
||||
println!("{}", serde_json::to_string_pretty(&outcome)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_finance_diff(
|
||||
left_path: &Path,
|
||||
right_path: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let left = load_finance_outcome(left_path)?;
|
||||
let right = load_finance_outcome(right_path)?;
|
||||
let report = diff_finance_outcomes(&left, &right)?;
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_finance_outcome(path: &Path) -> Result<FinanceOutcome, Box<dyn std::error::Error>> {
|
||||
let text = fs::read_to_string(path)?;
|
||||
if let Ok(snapshot) = serde_json::from_str::<FinanceSnapshot>(&text) {
|
||||
return Ok(snapshot.evaluate());
|
||||
}
|
||||
if let Ok(outcome) = serde_json::from_str::<FinanceOutcome>(&text) {
|
||||
return Ok(outcome);
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"unable to parse {} as FinanceSnapshot or FinanceOutcome",
|
||||
path.display()
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
fn diff_finance_outcomes(
|
||||
left: &FinanceOutcome,
|
||||
right: &FinanceOutcome,
|
||||
) -> Result<FinanceDiffReport, Box<dyn std::error::Error>> {
|
||||
let left_value = serde_json::to_value(left)?;
|
||||
let right_value = serde_json::to_value(right)?;
|
||||
let mut differences = Vec::new();
|
||||
collect_json_differences("$", &left_value, &right_value, &mut differences);
|
||||
|
||||
Ok(FinanceDiffReport {
|
||||
matches: differences.is_empty(),
|
||||
difference_count: differences.len(),
|
||||
differences,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_json_differences(
|
||||
path: &str,
|
||||
left: &Value,
|
||||
right: &Value,
|
||||
differences: &mut Vec<FinanceDiffEntry>,
|
||||
) {
|
||||
match (left, right) {
|
||||
(Value::Object(left_map), Value::Object(right_map)) => {
|
||||
let mut keys = BTreeSet::new();
|
||||
keys.extend(left_map.keys().cloned());
|
||||
keys.extend(right_map.keys().cloned());
|
||||
|
||||
for key in keys {
|
||||
let next_path = format!("{path}.{key}");
|
||||
match (left_map.get(&key), right_map.get(&key)) {
|
||||
(Some(left_value), Some(right_value)) => {
|
||||
collect_json_differences(&next_path, left_value, right_value, differences);
|
||||
}
|
||||
(left_value, right_value) => differences.push(FinanceDiffEntry {
|
||||
path: next_path,
|
||||
left: left_value.cloned().unwrap_or(Value::Null),
|
||||
right: right_value.cloned().unwrap_or(Value::Null),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
(Value::Array(left_items), Value::Array(right_items)) => {
|
||||
let max_len = left_items.len().max(right_items.len());
|
||||
for index in 0..max_len {
|
||||
let next_path = format!("{path}[{index}]");
|
||||
match (left_items.get(index), right_items.get(index)) {
|
||||
(Some(left_value), Some(right_value)) => {
|
||||
collect_json_differences(&next_path, left_value, right_value, differences);
|
||||
}
|
||||
(left_value, right_value) => differences.push(FinanceDiffEntry {
|
||||
path: next_path,
|
||||
left: left_value.cloned().unwrap_or(Value::Null),
|
||||
right: right_value.cloned().unwrap_or(Value::Null),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ if left != right => differences.push(FinanceDiffEntry {
|
||||
path: path.to_string(),
|
||||
left: left.clone(),
|
||||
right: right.clone(),
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -143,3 +300,62 @@ fn sha256_file(path: &Path) -> Result<String, Box<dyn std::error::Error>> {
|
|||
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rrt_model::finance::{AnnualFinanceDecision, AnnualFinanceEvaluation, CompanyFinanceState, DebtRestructureSummary};
|
||||
|
||||
#[test]
|
||||
fn loads_snapshot_as_outcome() {
|
||||
let snapshot = FinanceSnapshot {
|
||||
policy: rrt_model::finance::AnnualFinancePolicy {
|
||||
dividends_allowed: false,
|
||||
..rrt_model::finance::AnnualFinancePolicy::default()
|
||||
},
|
||||
company: CompanyFinanceState::default(),
|
||||
};
|
||||
let path = write_temp_json("snapshot", &snapshot);
|
||||
|
||||
let outcome = load_finance_outcome(&path).expect("snapshot should load");
|
||||
assert_eq!(outcome.evaluation.decision, AnnualFinanceDecision::NoAction);
|
||||
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diffs_outcomes_recursively() {
|
||||
let left = FinanceOutcome {
|
||||
evaluation: AnnualFinanceEvaluation::no_action(),
|
||||
post_company: CompanyFinanceState::default(),
|
||||
};
|
||||
let mut right = left.clone();
|
||||
right.post_company.current_cash = 123;
|
||||
right.evaluation.debt_restructure = DebtRestructureSummary {
|
||||
retired_principal: 10,
|
||||
issued_principal: 20,
|
||||
};
|
||||
|
||||
let report = diff_finance_outcomes(&left, &right).expect("diff should succeed");
|
||||
assert!(!report.matches);
|
||||
assert!(report
|
||||
.differences
|
||||
.iter()
|
||||
.any(|entry| entry.path == "$.post_company.current_cash"));
|
||||
assert!(report
|
||||
.differences
|
||||
.iter()
|
||||
.any(|entry| entry.path == "$.evaluation.debt_restructure.retired_principal"));
|
||||
}
|
||||
|
||||
fn write_temp_json<T: Serialize>(stem: &str, value: &T) -> PathBuf {
|
||||
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-cli-{stem}-{nonce}.json"));
|
||||
let bytes = serde_json::to_vec_pretty(value).expect("json serialization should succeed");
|
||||
fs::write(&path, bytes).expect("temp json should be written");
|
||||
path
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,3 +7,8 @@ license.workspace = true
|
|||
[lib]
|
||||
name = "dinput8"
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
rrt-model = { path = "../rrt-model" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,10 +1,144 @@
|
|||
#![cfg_attr(not(windows), allow(dead_code))]
|
||||
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rrt_model::finance::{
|
||||
AnnualFinancePolicy, BondPosition, CompanyFinanceState, FinanceOutcome, FinanceSnapshot,
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FinanceLogPaths {
|
||||
pub snapshot_path: PathBuf,
|
||||
pub outcome_path: PathBuf,
|
||||
}
|
||||
|
||||
pub fn sample_finance_snapshot() -> FinanceSnapshot {
|
||||
FinanceSnapshot {
|
||||
policy: AnnualFinancePolicy {
|
||||
dividends_allowed: false,
|
||||
..AnnualFinancePolicy::default()
|
||||
},
|
||||
company: CompanyFinanceState {
|
||||
current_cash: 100_000,
|
||||
support_adjusted_share_price: 27.5,
|
||||
book_value_per_share: 20.0,
|
||||
outstanding_share_count: 60_000,
|
||||
recent_net_profits: [40_000, 30_000, 20_000],
|
||||
recent_revenue_totals: [250_000, 240_000, 230_000],
|
||||
bonds: vec![
|
||||
BondPosition {
|
||||
principal: 150_000,
|
||||
coupon_rate: 0.12,
|
||||
years_remaining: 12,
|
||||
},
|
||||
BondPosition {
|
||||
principal: 10_000,
|
||||
coupon_rate: 0.10,
|
||||
years_remaining: 10,
|
||||
},
|
||||
],
|
||||
..CompanyFinanceState::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_finance_snapshot_bundle(
|
||||
base_dir: &Path,
|
||||
stem: &str,
|
||||
snapshot: &FinanceSnapshot,
|
||||
) -> io::Result<FinanceLogPaths> {
|
||||
fs::create_dir_all(base_dir)?;
|
||||
|
||||
let snapshot_path = base_dir.join(format!("rrt_finance_{stem}_snapshot.json"));
|
||||
let outcome_path = base_dir.join(format!("rrt_finance_{stem}_outcome.json"));
|
||||
let outcome: FinanceOutcome = snapshot.evaluate();
|
||||
|
||||
let snapshot_json = serde_json::to_vec_pretty(snapshot)
|
||||
.map_err(|err| io::Error::other(format!("serialize snapshot: {err}")))?;
|
||||
let outcome_json = serde_json::to_vec_pretty(&outcome)
|
||||
.map_err(|err| io::Error::other(format!("serialize outcome: {err}")))?;
|
||||
|
||||
fs::write(&snapshot_path, snapshot_json)?;
|
||||
fs::write(&outcome_path, outcome_json)?;
|
||||
|
||||
Ok(FinanceLogPaths {
|
||||
snapshot_path,
|
||||
outcome_path,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write_finance_snapshot_only(
|
||||
base_dir: &Path,
|
||||
stem: &str,
|
||||
snapshot: &FinanceSnapshot,
|
||||
) -> io::Result<PathBuf> {
|
||||
fs::create_dir_all(base_dir)?;
|
||||
|
||||
let snapshot_path = base_dir.join(format!("rrt_finance_{stem}_snapshot.json"));
|
||||
let snapshot_json = serde_json::to_vec_pretty(snapshot)
|
||||
.map_err(|err| io::Error::other(format!("serialize snapshot: {err}")))?;
|
||||
fs::write(&snapshot_path, snapshot_json)?;
|
||||
|
||||
Ok(snapshot_path)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct IndexedCollectionProbeRow {
|
||||
pub entry_id: usize,
|
||||
pub live: bool,
|
||||
pub resolved_ptr: usize,
|
||||
pub active_flag: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct IndexedCollectionProbe {
|
||||
pub collection_addr: usize,
|
||||
pub flat_payload: bool,
|
||||
pub stride: u32,
|
||||
pub id_bound: i32,
|
||||
pub payload_ptr: usize,
|
||||
pub tombstone_ptr: usize,
|
||||
pub first_rows: Vec<IndexedCollectionProbeRow>,
|
||||
}
|
||||
|
||||
pub fn write_indexed_collection_probe(
|
||||
base_dir: &Path,
|
||||
stem: &str,
|
||||
probe: &IndexedCollectionProbe,
|
||||
) -> io::Result<PathBuf> {
|
||||
fs::create_dir_all(base_dir)?;
|
||||
|
||||
let path = base_dir.join(format!("rrt_finance_{stem}_collection_probe.json"));
|
||||
let json = serde_json::to_vec_pretty(probe)
|
||||
.map_err(|err| io::Error::other(format!("serialize collection probe: {err}")))?;
|
||||
fs::write(&path, json)?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows_hook {
|
||||
use super::{
|
||||
IndexedCollectionProbe, IndexedCollectionProbeRow, sample_finance_snapshot,
|
||||
write_finance_snapshot_bundle, write_finance_snapshot_only, write_indexed_collection_probe,
|
||||
};
|
||||
use core::ffi::{c_char, c_void};
|
||||
use core::mem;
|
||||
use core::ptr;
|
||||
use std::env;
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use rrt_model::finance::{
|
||||
AnnualFinancePolicy, BondPosition, CompanyFinanceState, FinanceSnapshot, GrowthSetting,
|
||||
};
|
||||
|
||||
const DLL_PROCESS_ATTACH: u32 = 1;
|
||||
const E_FAIL: i32 = 0x8000_4005_u32 as i32;
|
||||
|
|
@ -17,10 +151,92 @@ mod windows_hook {
|
|||
|
||||
static LOG_PATH: &[u8] = b"rrt_hook_attach.log\0";
|
||||
static ATTACH_MESSAGE: &[u8] = b"rrt-hook: process attach\n";
|
||||
static FINANCE_CAPTURE_STARTED_MESSAGE: &[u8] = b"rrt-hook: finance capture thread started\n";
|
||||
static FINANCE_CAPTURE_SCAN_MESSAGE: &[u8] =
|
||||
b"rrt-hook: finance capture raw collection scan\n";
|
||||
static FINANCE_CAPTURE_PROBE_DUMP_WRITTEN_MESSAGE: &[u8] =
|
||||
b"rrt-hook: finance collection probe written\n";
|
||||
static FINANCE_CAPTURE_COMPANY_RESOLVED_MESSAGE: &[u8] =
|
||||
b"rrt-hook: finance capture company resolved\n";
|
||||
static FINANCE_CAPTURE_PROBE_WRITTEN_MESSAGE: &[u8] =
|
||||
b"rrt-hook: finance probe snapshot written\n";
|
||||
static FINANCE_CAPTURE_TIMEOUT_MESSAGE: &[u8] =
|
||||
b"rrt-hook: finance capture timed out\n";
|
||||
static AUTO_LOAD_STARTED_MESSAGE: &[u8] =
|
||||
b"rrt-hook: auto load hook armed\n";
|
||||
static AUTO_LOAD_HOOK_INSTALLED_MESSAGE: &[u8] =
|
||||
b"rrt-hook: auto load shell-pump hook installed\n";
|
||||
static AUTO_LOAD_READY_MESSAGE: &[u8] =
|
||||
b"rrt-hook: auto load ready gate passed\n";
|
||||
static AUTO_LOAD_DEFERRED_MESSAGE: &[u8] =
|
||||
b"rrt-hook: auto load restore deferred to later shell-pump turn\n";
|
||||
static AUTO_LOAD_CALLING_MESSAGE: &[u8] =
|
||||
b"rrt-hook: auto load restore calling\n";
|
||||
static AUTO_LOAD_OWNER_ENTRY_MESSAGE: &[u8] =
|
||||
b"rrt-hook: auto load larger owner entering\n";
|
||||
static AUTO_LOAD_OWNER_RETURNED_MESSAGE: &[u8] =
|
||||
b"rrt-hook: auto load larger owner returned\n";
|
||||
static AUTO_LOAD_TRIGGERED_MESSAGE: &[u8] =
|
||||
b"rrt-hook: auto load restore invoked\n";
|
||||
static AUTO_LOAD_SUCCESS_MESSAGE: &[u8] =
|
||||
b"rrt-hook: auto load request reported success\n";
|
||||
static AUTO_LOAD_FAILURE_MESSAGE: &[u8] =
|
||||
b"rrt-hook: auto load request reported failure\n";
|
||||
static DEBUG_MESSAGE: &[u8] = b"rrt-hook: DllMain process attach\0";
|
||||
static DIRECT_INPUT8_CREATE_NAME: &[u8] = b"DirectInput8Create\0";
|
||||
static mut REAL_DINPUT8_CREATE: Option<DirectInput8CreateFn> = None;
|
||||
static FINANCE_TEMPLATE_EMITTED: AtomicBool = AtomicBool::new(false);
|
||||
static FINANCE_CAPTURE_STARTED: AtomicBool = AtomicBool::new(false);
|
||||
static FINANCE_COLLECTION_PROBE_WRITTEN: AtomicBool = AtomicBool::new(false);
|
||||
static AUTO_LOAD_THREAD_STARTED: AtomicBool = AtomicBool::new(false);
|
||||
static AUTO_LOAD_HOOK_INSTALLED: AtomicBool = AtomicBool::new(false);
|
||||
static AUTO_LOAD_ATTEMPTED: AtomicBool = AtomicBool::new(false);
|
||||
static AUTO_LOAD_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
|
||||
static AUTO_LOAD_DEFERRED: AtomicBool = AtomicBool::new(false);
|
||||
static AUTO_LOAD_LAST_GATE_MASK: AtomicU32 = AtomicU32::new(u32::MAX);
|
||||
static AUTO_LOAD_READY_COUNT: AtomicU32 = AtomicU32::new(0);
|
||||
static AUTO_LOAD_SAVE_STEM: OnceLock<String> = OnceLock::new();
|
||||
static mut SHELL_PUMP_TRAMPOLINE: usize = 0;
|
||||
|
||||
const COMPANY_COLLECTION_ADDR: usize = 0x0062be10;
|
||||
const SHELL_CONTROLLER_PTR_ADDR: usize = 0x006d4024;
|
||||
const SHELL_STATE_PTR_ADDR: usize = 0x006cec74;
|
||||
const ACTIVE_MODE_PTR_ADDR: usize = 0x006cec78;
|
||||
const SHELL_PUMP_ADDR: usize = 0x00483f70;
|
||||
const SHELL_STATE_ACTIVE_MODE_OFFSET: usize = 0x08;
|
||||
const SHELL_STATE_ACTIVE_MODE_OBJECT_OFFSET: usize = 0x0c;
|
||||
const RUNTIME_PROFILE_PTR_ADDR: usize = 0x006cec7c;
|
||||
const RUNTIME_PROFILE_MANUAL_LOAD_PATH_OFFSET: usize = 0x11;
|
||||
const RUNTIME_PROFILE_PENDING_LOAD_BYTE_OFFSET: usize = 0x97;
|
||||
const INDEXED_COLLECTION_FLAT_FLAG_OFFSET: usize = 0x04;
|
||||
const INDEXED_COLLECTION_STRIDE_OFFSET: usize = 0x08;
|
||||
const INDEXED_COLLECTION_ID_BOUND_OFFSET: usize = 0x14;
|
||||
const INDEXED_COLLECTION_PAYLOAD_OFFSET: usize = 0x30;
|
||||
const INDEXED_COLLECTION_TOMBSTONE_BITSET_OFFSET: usize = 0x34;
|
||||
const COMPANY_ACTIVE_OFFSET: usize = 0x3f;
|
||||
const COMPANY_OUTSTANDING_SHARES_OFFSET: usize = 0x47;
|
||||
const COMPANY_COMPANY_VALUE_OFFSET: usize = 0x57;
|
||||
const COMPANY_BOND_COUNT_OFFSET: usize = 0x5b;
|
||||
const COMPANY_BOND_TABLE_OFFSET: usize = 0x5f;
|
||||
const COMPANY_FOUNDING_YEAR_OFFSET: usize = 0x157;
|
||||
const COMPANY_LAST_BANKRUPTCY_YEAR_OFFSET: usize = 0x163;
|
||||
const COMPANY_CITY_CONNECTION_LATCH_OFFSET: usize = 0x0d18;
|
||||
const COMPANY_LINKED_TRANSIT_LATCH_OFFSET: usize = 0x0d56;
|
||||
|
||||
const SCENARIO_CURRENT_YEAR_OFFSET: usize = 0x0d;
|
||||
const SCENARIO_BUILDING_DENSITY_GROWTH_OFFSET: usize = 0x4c7c;
|
||||
const SCENARIO_BANKRUPTCY_TOGGLE_OFFSET: usize = 0x4a8f;
|
||||
const SCENARIO_BOND_TOGGLE_OFFSET: usize = 0x4a8b;
|
||||
const SCENARIO_STOCK_TOGGLE_OFFSET: usize = 0x4a87;
|
||||
const SCENARIO_DIVIDEND_TOGGLE_OFFSET: usize = 0x4a93;
|
||||
|
||||
const MAX_CAPTURE_POLL_ATTEMPTS: usize = 120;
|
||||
const CAPTURE_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||
const AUTO_LOAD_READY_POLLS: u32 = 30;
|
||||
const AUTO_LOAD_DEFER_POLLS: u32 = 5;
|
||||
const MEM_COMMIT: u32 = 0x1000;
|
||||
const MEM_RESERVE: u32 = 0x2000;
|
||||
const PAGE_EXECUTE_READWRITE: u32 = 0x40;
|
||||
unsafe extern "system" {
|
||||
fn CreateFileA(
|
||||
lp_file_name: *const c_char,
|
||||
|
|
@ -46,10 +262,28 @@ mod windows_hook {
|
|||
) -> i32;
|
||||
fn CloseHandle(handle: isize) -> i32;
|
||||
fn DisableThreadLibraryCalls(module: *mut c_void) -> i32;
|
||||
fn FlushInstructionCache(
|
||||
process: *mut c_void,
|
||||
base_address: *const c_void,
|
||||
size: usize,
|
||||
) -> i32;
|
||||
fn GetCurrentProcess() -> *mut c_void;
|
||||
fn GetSystemDirectoryA(buffer: *mut u8, size: u32) -> u32;
|
||||
fn GetProcAddress(module: isize, name: *const c_char) -> *mut c_void;
|
||||
fn LoadLibraryA(name: *const c_char) -> isize;
|
||||
fn OutputDebugStringA(output: *const c_char);
|
||||
fn VirtualAlloc(
|
||||
address: *mut c_void,
|
||||
size: usize,
|
||||
allocation_type: u32,
|
||||
protect: u32,
|
||||
) -> *mut c_void;
|
||||
fn VirtualProtect(
|
||||
address: *mut c_void,
|
||||
size: usize,
|
||||
new_protect: u32,
|
||||
old_protect: *mut u32,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
|
|
@ -67,7 +301,8 @@ mod windows_hook {
|
|||
out: *mut *mut c_void,
|
||||
outer: *mut c_void,
|
||||
) -> i32;
|
||||
|
||||
type ShellPumpFn = unsafe extern "thiscall" fn(*mut u8) -> i32;
|
||||
type LargerManualLoadOwnerFn = unsafe extern "thiscall" fn(*mut u8, u32, u32);
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "system" fn DllMain(
|
||||
module: *mut c_void,
|
||||
|
|
@ -92,6 +327,10 @@ mod windows_hook {
|
|||
out: *mut *mut c_void,
|
||||
outer: *mut c_void,
|
||||
) -> i32 {
|
||||
maybe_emit_finance_template_bundle();
|
||||
maybe_start_finance_capture_thread();
|
||||
maybe_install_auto_load_hook();
|
||||
|
||||
let direct_input8_create = unsafe { load_direct_input8_create() };
|
||||
match direct_input8_create {
|
||||
Some(callback) => unsafe { callback(instance, version, riid, out, outer) },
|
||||
|
|
@ -100,6 +339,10 @@ mod windows_hook {
|
|||
}
|
||||
|
||||
unsafe fn append_attach_log() {
|
||||
append_log_message(ATTACH_MESSAGE);
|
||||
}
|
||||
|
||||
fn append_log_message(message: &[u8]) {
|
||||
let handle = unsafe {
|
||||
CreateFileA(
|
||||
LOG_PATH.as_ptr().cast(),
|
||||
|
|
@ -120,8 +363,8 @@ mod windows_hook {
|
|||
let _ = unsafe {
|
||||
WriteFile(
|
||||
handle,
|
||||
ATTACH_MESSAGE.as_ptr().cast(),
|
||||
ATTACH_MESSAGE.len() as u32,
|
||||
message.as_ptr().cast(),
|
||||
message.len() as u32,
|
||||
&mut bytes_written,
|
||||
ptr::null_mut(),
|
||||
)
|
||||
|
|
@ -129,6 +372,613 @@ mod windows_hook {
|
|||
let _ = unsafe { CloseHandle(handle) };
|
||||
}
|
||||
|
||||
fn append_log_line(line: &str) {
|
||||
append_log_message(line.as_bytes());
|
||||
}
|
||||
|
||||
fn maybe_emit_finance_template_bundle() {
|
||||
if env::var_os("RRT_WRITE_FINANCE_TEMPLATE").is_none() {
|
||||
return;
|
||||
}
|
||||
if FINANCE_TEMPLATE_EMITTED.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
|
||||
let base_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
let _ = write_finance_snapshot_bundle(
|
||||
&base_dir,
|
||||
"attach_template",
|
||||
&sample_finance_snapshot(),
|
||||
);
|
||||
}
|
||||
|
||||
fn maybe_start_finance_capture_thread() {
|
||||
if env::var_os("RRT_WRITE_FINANCE_CAPTURE").is_none() {
|
||||
return;
|
||||
}
|
||||
if FINANCE_CAPTURE_STARTED.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
|
||||
append_log_message(FINANCE_CAPTURE_STARTED_MESSAGE);
|
||||
let base_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
let _ = thread::Builder::new()
|
||||
.name("rrt-finance-capture".to_string())
|
||||
.spawn(move || {
|
||||
for _ in 0..MAX_CAPTURE_POLL_ATTEMPTS {
|
||||
if !FINANCE_COLLECTION_PROBE_WRITTEN.load(Ordering::Acquire) {
|
||||
if let Some(probe) = unsafe { capture_company_collection_probe() } {
|
||||
if write_indexed_collection_probe(&base_dir, "attach_probe", &probe)
|
||||
.is_ok()
|
||||
{
|
||||
FINANCE_COLLECTION_PROBE_WRITTEN.store(true, Ordering::Release);
|
||||
append_log_message(FINANCE_CAPTURE_PROBE_DUMP_WRITTEN_MESSAGE);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(snapshot) = unsafe { try_capture_probe_snapshot() } {
|
||||
append_log_message(FINANCE_CAPTURE_COMPANY_RESOLVED_MESSAGE);
|
||||
if write_finance_snapshot_only(&base_dir, "attach_probe", &snapshot)
|
||||
.is_ok()
|
||||
{
|
||||
append_log_message(FINANCE_CAPTURE_PROBE_WRITTEN_MESSAGE);
|
||||
return;
|
||||
}
|
||||
}
|
||||
thread::sleep(CAPTURE_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
append_log_message(FINANCE_CAPTURE_TIMEOUT_MESSAGE);
|
||||
});
|
||||
}
|
||||
|
||||
fn maybe_install_auto_load_hook() {
|
||||
let save_stem = match env::var("RRT_AUTO_LOAD_SAVE") {
|
||||
Ok(value) if !value.trim().is_empty() => value,
|
||||
_ => return,
|
||||
};
|
||||
let _ = AUTO_LOAD_SAVE_STEM.set(save_stem);
|
||||
if AUTO_LOAD_HOOK_INSTALLED.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
|
||||
append_log_message(AUTO_LOAD_STARTED_MESSAGE);
|
||||
AUTO_LOAD_THREAD_STARTED.store(true, Ordering::Release);
|
||||
if unsafe { install_shell_pump_hook() } {
|
||||
append_log_message(AUTO_LOAD_HOOK_INSTALLED_MESSAGE);
|
||||
} else {
|
||||
append_log_message(AUTO_LOAD_FAILURE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_auto_load_worker(save_stem: &str) {
|
||||
append_log_message(AUTO_LOAD_CALLING_MESSAGE);
|
||||
let staged = unsafe { invoke_manual_load_branch(save_stem) };
|
||||
if staged {
|
||||
append_log_message(AUTO_LOAD_TRIGGERED_MESSAGE);
|
||||
append_log_message(AUTO_LOAD_SUCCESS_MESSAGE);
|
||||
} else {
|
||||
append_log_message(AUTO_LOAD_FAILURE_MESSAGE);
|
||||
}
|
||||
AUTO_LOAD_IN_PROGRESS.store(false, Ordering::Release);
|
||||
}
|
||||
|
||||
unsafe fn invoke_manual_load_branch(save_stem: &str) -> bool {
|
||||
if save_stem.is_empty() || save_stem.as_bytes().contains(&0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let shell_state = unsafe { read_ptr(SHELL_STATE_PTR_ADDR as *const u8) };
|
||||
let runtime_profile = unsafe { read_ptr(RUNTIME_PROFILE_PTR_ADDR as *const u8) };
|
||||
let active_mode = unsafe { resolve_active_mode_ptr() };
|
||||
if shell_state.is_null() || runtime_profile.is_null() || active_mode.is_null() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let path_seed = unsafe { runtime_profile.add(RUNTIME_PROFILE_MANUAL_LOAD_PATH_OFFSET) };
|
||||
if unsafe { write_c_string(path_seed, 260, save_stem.as_bytes()) }.is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
ptr::write_unaligned(
|
||||
runtime_profile
|
||||
.add(RUNTIME_PROFILE_PENDING_LOAD_BYTE_OFFSET)
|
||||
.cast::<u8>(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
|
||||
let larger_owner: LargerManualLoadOwnerFn =
|
||||
unsafe { mem::transmute(0x00438890usize) };
|
||||
|
||||
let global_active_mode = unsafe { read_ptr(ACTIVE_MODE_PTR_ADDR as *const u8) };
|
||||
|
||||
if global_active_mode.is_null() {
|
||||
unsafe {
|
||||
ptr::write_unaligned(
|
||||
(ACTIVE_MODE_PTR_ADDR as *mut u8).cast::<usize>(),
|
||||
active_mode as usize,
|
||||
)
|
||||
};
|
||||
}
|
||||
append_log_message(AUTO_LOAD_OWNER_ENTRY_MESSAGE);
|
||||
unsafe { larger_owner(active_mode, 1, 0) };
|
||||
append_log_message(AUTO_LOAD_OWNER_RETURNED_MESSAGE);
|
||||
if global_active_mode.is_null() {
|
||||
unsafe {
|
||||
ptr::write_unaligned((ACTIVE_MODE_PTR_ADDR as *mut u8).cast::<usize>(), 0)
|
||||
};
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
unsafe fn write_c_string(
|
||||
destination: *mut u8,
|
||||
capacity: usize,
|
||||
bytes: &[u8],
|
||||
) -> Option<()> {
|
||||
if bytes.len() + 1 > capacity {
|
||||
return None;
|
||||
}
|
||||
|
||||
unsafe { ptr::write_bytes(destination, 0, capacity) };
|
||||
unsafe { ptr::copy_nonoverlapping(bytes.as_ptr(), destination, bytes.len()) };
|
||||
Some(())
|
||||
}
|
||||
|
||||
unsafe fn try_capture_probe_snapshot() -> Option<FinanceSnapshot> {
|
||||
append_log_message(FINANCE_CAPTURE_SCAN_MESSAGE);
|
||||
let company = unsafe { resolve_first_active_company()? };
|
||||
Some(unsafe { capture_probe_snapshot_from_company(company) })
|
||||
}
|
||||
|
||||
unsafe fn runtime_saved_world_restore_gate_mask() -> u32 {
|
||||
let mut mask = 0_u32;
|
||||
let shell_state = unsafe { read_ptr(SHELL_STATE_PTR_ADDR as *const u8) };
|
||||
if !shell_state.is_null() {
|
||||
mask |= 0x1;
|
||||
}
|
||||
let shell_controller = unsafe { read_ptr(SHELL_CONTROLLER_PTR_ADDR as *const u8) };
|
||||
if !shell_controller.is_null() {
|
||||
mask |= 0x2;
|
||||
}
|
||||
let active_mode = unsafe { resolve_active_mode_ptr() };
|
||||
if !active_mode.is_null() {
|
||||
mask |= 0x4;
|
||||
}
|
||||
mask
|
||||
}
|
||||
|
||||
unsafe fn current_mode_id() -> u32 {
|
||||
let shell_state = unsafe { read_ptr(SHELL_STATE_PTR_ADDR as *const u8) };
|
||||
if shell_state.is_null() {
|
||||
return 0;
|
||||
}
|
||||
unsafe { read_u32(shell_state.add(SHELL_STATE_ACTIVE_MODE_OFFSET)) }
|
||||
}
|
||||
|
||||
fn auto_load_ready_polls() -> u32 {
|
||||
env::var("RRT_AUTO_LOAD_READY_POLLS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(AUTO_LOAD_READY_POLLS)
|
||||
}
|
||||
|
||||
fn auto_load_defer_polls() -> u32 {
|
||||
env::var("RRT_AUTO_LOAD_DEFER_POLLS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.unwrap_or(AUTO_LOAD_DEFER_POLLS)
|
||||
}
|
||||
|
||||
unsafe extern "fastcall" fn shell_pump_detour(this: *mut u8, _edx: usize) -> i32 {
|
||||
let trampoline: ShellPumpFn = unsafe { mem::transmute(SHELL_PUMP_TRAMPOLINE) };
|
||||
let result = unsafe { trampoline(this) };
|
||||
maybe_service_auto_load_on_main_thread();
|
||||
result
|
||||
}
|
||||
|
||||
fn maybe_service_auto_load_on_main_thread() {
|
||||
if !AUTO_LOAD_HOOK_INSTALLED.load(Ordering::Acquire)
|
||||
|| AUTO_LOAD_ATTEMPTED.load(Ordering::Acquire)
|
||||
|| AUTO_LOAD_IN_PROGRESS.load(Ordering::Acquire)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let gate_mask = unsafe { runtime_saved_world_restore_gate_mask() };
|
||||
let last_gate_mask = AUTO_LOAD_LAST_GATE_MASK.swap(gate_mask, Ordering::AcqRel);
|
||||
if gate_mask != last_gate_mask {
|
||||
log_auto_load_gate_mask(gate_mask);
|
||||
}
|
||||
|
||||
let mode_id = unsafe { current_mode_id() };
|
||||
let ready = gate_mask == 0x7 && mode_id == 2;
|
||||
let ready_count = if ready {
|
||||
AUTO_LOAD_READY_COUNT.fetch_add(1, Ordering::AcqRel) + 1
|
||||
} else {
|
||||
AUTO_LOAD_READY_COUNT.store(0, Ordering::Release);
|
||||
AUTO_LOAD_DEFERRED.store(false, Ordering::Release);
|
||||
0
|
||||
};
|
||||
|
||||
let ready_polls = auto_load_ready_polls();
|
||||
if ready_count < ready_polls {
|
||||
return;
|
||||
}
|
||||
|
||||
if !AUTO_LOAD_DEFERRED.load(Ordering::Acquire) {
|
||||
AUTO_LOAD_DEFERRED.store(true, Ordering::Release);
|
||||
append_log_message(AUTO_LOAD_READY_MESSAGE);
|
||||
append_log_message(AUTO_LOAD_DEFERRED_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
if ready_count < ready_polls.saturating_add(auto_load_defer_polls()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if AUTO_LOAD_ATTEMPTED.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(save_stem) = AUTO_LOAD_SAVE_STEM.get() else {
|
||||
append_log_message(AUTO_LOAD_FAILURE_MESSAGE);
|
||||
return;
|
||||
};
|
||||
|
||||
AUTO_LOAD_IN_PROGRESS.store(true, Ordering::Release);
|
||||
append_log_message(AUTO_LOAD_READY_MESSAGE);
|
||||
run_auto_load_worker(save_stem);
|
||||
}
|
||||
|
||||
fn log_auto_load_gate_mask(mask: u32) {
|
||||
let mut line = String::from("rrt-hook: auto load gate mask ");
|
||||
let global_active_mode = unsafe { read_ptr(ACTIVE_MODE_PTR_ADDR as *const u8) } as usize;
|
||||
let shell_state = unsafe { read_ptr(SHELL_STATE_PTR_ADDR as *const u8) };
|
||||
let mode_id = if shell_state.is_null() {
|
||||
0
|
||||
} else {
|
||||
unsafe { read_u32(shell_state.add(SHELL_STATE_ACTIVE_MODE_OFFSET)) as usize }
|
||||
};
|
||||
let field_active_mode_object = if shell_state.is_null() {
|
||||
0
|
||||
} else {
|
||||
unsafe { read_ptr(shell_state.add(SHELL_STATE_ACTIVE_MODE_OBJECT_OFFSET)) as usize }
|
||||
};
|
||||
let _ = write!(
|
||||
&mut line,
|
||||
"0x{mask:01x} shell_state={} shell_controller={} active_mode={} global_active_mode=0x{global_active_mode:08x} mode_id=0x{mode_id:08x} field_active_mode_object=0x{field_active_mode_object:08x}\n",
|
||||
(mask & 0x1) != 0,
|
||||
(mask & 0x2) != 0,
|
||||
(mask & 0x4) != 0,
|
||||
);
|
||||
append_log_line(&line);
|
||||
}
|
||||
|
||||
unsafe fn resolve_active_mode_ptr() -> *mut u8 {
|
||||
let global_active_mode = unsafe { resolve_global_active_mode_ptr() };
|
||||
if !global_active_mode.is_null() {
|
||||
return global_active_mode;
|
||||
}
|
||||
|
||||
let shell_state = unsafe { read_ptr(SHELL_STATE_PTR_ADDR as *const u8) };
|
||||
if shell_state.is_null() {
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
unsafe { read_ptr(shell_state.add(SHELL_STATE_ACTIVE_MODE_OBJECT_OFFSET)) }
|
||||
}
|
||||
|
||||
unsafe fn resolve_global_active_mode_ptr() -> *mut u8 {
|
||||
unsafe { read_ptr(ACTIVE_MODE_PTR_ADDR as *const u8) }
|
||||
}
|
||||
|
||||
unsafe fn install_shell_pump_hook() -> bool {
|
||||
const STOLEN_LEN: usize = 8;
|
||||
let target = SHELL_PUMP_ADDR as *mut u8;
|
||||
let trampoline_size = STOLEN_LEN + 5;
|
||||
let trampoline = unsafe {
|
||||
VirtualAlloc(
|
||||
ptr::null_mut(),
|
||||
trampoline_size,
|
||||
MEM_COMMIT | MEM_RESERVE,
|
||||
PAGE_EXECUTE_READWRITE,
|
||||
)
|
||||
} as *mut u8;
|
||||
if trampoline.is_null() {
|
||||
return false;
|
||||
}
|
||||
|
||||
unsafe { ptr::copy_nonoverlapping(target, trampoline, STOLEN_LEN) };
|
||||
unsafe {
|
||||
write_rel32_jump(
|
||||
trampoline.add(STOLEN_LEN),
|
||||
target.add(STOLEN_LEN) as usize,
|
||||
)
|
||||
};
|
||||
|
||||
let mut old_protect = 0_u32;
|
||||
if unsafe {
|
||||
VirtualProtect(
|
||||
target.cast(),
|
||||
STOLEN_LEN,
|
||||
PAGE_EXECUTE_READWRITE,
|
||||
&mut old_protect,
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
unsafe { write_rel32_jump(target, shell_pump_detour as *const () as usize) };
|
||||
unsafe { ptr::write(target.add(5), 0x90) };
|
||||
unsafe { ptr::write(target.add(6), 0x90) };
|
||||
unsafe { ptr::write(target.add(7), 0x90) };
|
||||
let mut restore_protect = 0_u32;
|
||||
let _ = unsafe {
|
||||
VirtualProtect(
|
||||
target.cast(),
|
||||
STOLEN_LEN,
|
||||
old_protect,
|
||||
&mut restore_protect,
|
||||
)
|
||||
};
|
||||
let _ = unsafe {
|
||||
FlushInstructionCache(
|
||||
GetCurrentProcess(),
|
||||
target.cast(),
|
||||
STOLEN_LEN,
|
||||
)
|
||||
};
|
||||
unsafe {
|
||||
SHELL_PUMP_TRAMPOLINE = trampoline as usize;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
unsafe fn write_rel32_jump(location: *mut u8, destination: usize) {
|
||||
unsafe { ptr::write(location, 0xE9) };
|
||||
let next_ip = unsafe { location.add(5) } as usize;
|
||||
let relative = (destination as isize - next_ip as isize) as i32;
|
||||
unsafe { ptr::write_unaligned(location.add(1).cast::<i32>(), relative) };
|
||||
}
|
||||
|
||||
unsafe fn resolve_first_active_company() -> Option<*mut u8> {
|
||||
let collection = COMPANY_COLLECTION_ADDR as *const u8;
|
||||
let id_bound = unsafe { read_i32(collection.add(INDEXED_COLLECTION_ID_BOUND_OFFSET)) };
|
||||
if id_bound <= 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
for entry_id in 1..=id_bound as usize {
|
||||
if unsafe { indexed_collection_entry_id_is_live(collection, entry_id) } {
|
||||
let company = unsafe { indexed_collection_resolve_live_entry_by_id(collection, entry_id) };
|
||||
if !company.is_null() && unsafe { read_u8(company.add(COMPANY_ACTIVE_OFFSET)) != 0 } {
|
||||
return Some(company);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
unsafe fn capture_company_collection_probe() -> Option<IndexedCollectionProbe> {
|
||||
let collection = COMPANY_COLLECTION_ADDR as *const u8;
|
||||
let id_bound = unsafe { read_i32(collection.add(INDEXED_COLLECTION_ID_BOUND_OFFSET)) };
|
||||
if id_bound <= 0 {
|
||||
return Some(IndexedCollectionProbe {
|
||||
collection_addr: COMPANY_COLLECTION_ADDR,
|
||||
flat_payload: unsafe {
|
||||
read_u32(collection.add(INDEXED_COLLECTION_FLAT_FLAG_OFFSET)) != 0
|
||||
},
|
||||
stride: unsafe { read_u32(collection.add(INDEXED_COLLECTION_STRIDE_OFFSET)) },
|
||||
id_bound,
|
||||
payload_ptr: unsafe {
|
||||
read_ptr(collection.add(INDEXED_COLLECTION_PAYLOAD_OFFSET)) as usize
|
||||
},
|
||||
tombstone_ptr: unsafe {
|
||||
read_ptr(collection.add(INDEXED_COLLECTION_TOMBSTONE_BITSET_OFFSET)) as usize
|
||||
},
|
||||
first_rows: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut first_rows = Vec::new();
|
||||
let sample_bound = (id_bound as usize).min(8);
|
||||
for entry_id in 1..=sample_bound {
|
||||
let live = unsafe { indexed_collection_entry_id_is_live(collection, entry_id) };
|
||||
let resolved_ptr = unsafe {
|
||||
indexed_collection_resolve_live_entry_by_id(collection, entry_id) as usize
|
||||
};
|
||||
let active_flag = if resolved_ptr == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(unsafe { read_u8((resolved_ptr as *const u8).add(COMPANY_ACTIVE_OFFSET)) })
|
||||
};
|
||||
first_rows.push(IndexedCollectionProbeRow {
|
||||
entry_id,
|
||||
live,
|
||||
resolved_ptr,
|
||||
active_flag,
|
||||
});
|
||||
}
|
||||
|
||||
Some(IndexedCollectionProbe {
|
||||
collection_addr: COMPANY_COLLECTION_ADDR,
|
||||
flat_payload: unsafe {
|
||||
read_u32(collection.add(INDEXED_COLLECTION_FLAT_FLAG_OFFSET)) != 0
|
||||
},
|
||||
stride: unsafe { read_u32(collection.add(INDEXED_COLLECTION_STRIDE_OFFSET)) },
|
||||
id_bound,
|
||||
payload_ptr: unsafe { read_ptr(collection.add(INDEXED_COLLECTION_PAYLOAD_OFFSET)) as usize },
|
||||
tombstone_ptr: unsafe {
|
||||
read_ptr(collection.add(INDEXED_COLLECTION_TOMBSTONE_BITSET_OFFSET)) as usize
|
||||
},
|
||||
first_rows,
|
||||
})
|
||||
}
|
||||
|
||||
unsafe fn capture_probe_snapshot_from_company(company: *mut u8) -> FinanceSnapshot {
|
||||
let scenario = unsafe { read_ptr(ACTIVE_MODE_PTR_ADDR as *const u8) } as *const u8;
|
||||
let current_year = unsafe { read_u16(scenario.add(SCENARIO_CURRENT_YEAR_OFFSET)) };
|
||||
let founding_year = unsafe { read_u16(company.add(COMPANY_FOUNDING_YEAR_OFFSET)) };
|
||||
let last_bankruptcy_year =
|
||||
unsafe { read_u16(company.add(COMPANY_LAST_BANKRUPTCY_YEAR_OFFSET)) };
|
||||
let outstanding_share_count =
|
||||
unsafe { read_u32(company.add(COMPANY_OUTSTANDING_SHARES_OFFSET)) };
|
||||
let bonds = unsafe { capture_bonds(company, current_year) };
|
||||
let company_value = unsafe { read_u32(company.add(COMPANY_COMPANY_VALUE_OFFSET)) as i64 };
|
||||
let growth_setting = unsafe {
|
||||
growth_setting_from_raw(read_u8(
|
||||
scenario.add(SCENARIO_BUILDING_DENSITY_GROWTH_OFFSET),
|
||||
))
|
||||
};
|
||||
|
||||
FinanceSnapshot {
|
||||
policy: AnnualFinancePolicy {
|
||||
annual_mode: 0x0c,
|
||||
bankruptcy_allowed: unsafe {
|
||||
read_u8(scenario.add(SCENARIO_BANKRUPTCY_TOGGLE_OFFSET)) == 0
|
||||
},
|
||||
bond_issuance_allowed: unsafe {
|
||||
read_u8(scenario.add(SCENARIO_BOND_TOGGLE_OFFSET)) == 0
|
||||
},
|
||||
stock_actions_allowed: unsafe {
|
||||
read_u8(scenario.add(SCENARIO_STOCK_TOGGLE_OFFSET)) == 0
|
||||
},
|
||||
dividends_allowed: unsafe {
|
||||
read_u8(scenario.add(SCENARIO_DIVIDEND_TOGGLE_OFFSET)) == 0
|
||||
},
|
||||
growth_setting,
|
||||
..AnnualFinancePolicy::default()
|
||||
},
|
||||
company: CompanyFinanceState {
|
||||
active: unsafe { read_u8(company.add(COMPANY_ACTIVE_OFFSET)) != 0 },
|
||||
years_since_founding: year_delta(current_year, founding_year),
|
||||
years_since_last_bankruptcy: year_delta(current_year, last_bankruptcy_year),
|
||||
current_company_value: company_value,
|
||||
outstanding_share_count,
|
||||
city_connection_bonus_latch: unsafe {
|
||||
read_u8(company.add(COMPANY_CITY_CONNECTION_LATCH_OFFSET)) != 0
|
||||
},
|
||||
linked_transit_service_latch: unsafe {
|
||||
read_u8(company.add(COMPANY_LINKED_TRANSIT_LATCH_OFFSET)) != 0
|
||||
},
|
||||
chairman_buyback_factor: None,
|
||||
bonds,
|
||||
..CompanyFinanceState::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn capture_bonds(company: *mut u8, current_year: u16) -> Vec<BondPosition> {
|
||||
let bond_count = unsafe { read_u8(company.add(COMPANY_BOND_COUNT_OFFSET)) as usize };
|
||||
let table = unsafe { company.add(COMPANY_BOND_TABLE_OFFSET) };
|
||||
let mut bonds = Vec::with_capacity(bond_count);
|
||||
|
||||
for index in 0..bond_count {
|
||||
let slot = unsafe { table.add(index * 12) };
|
||||
let principal = unsafe { read_i32(slot) } as i64;
|
||||
let maturity_year = unsafe { read_u32(slot.add(4)) };
|
||||
let coupon_rate = unsafe { read_f32(slot.add(8)) } as f64;
|
||||
|
||||
bonds.push(BondPosition {
|
||||
principal,
|
||||
coupon_rate,
|
||||
years_remaining: maturity_year
|
||||
.saturating_sub(current_year as u32)
|
||||
.min(u8::MAX as u32) as u8,
|
||||
});
|
||||
}
|
||||
|
||||
bonds
|
||||
}
|
||||
|
||||
fn growth_setting_from_raw(raw: u8) -> GrowthSetting {
|
||||
match raw {
|
||||
1 => GrowthSetting::ExpansionBias,
|
||||
2 => GrowthSetting::DividendSuppressed,
|
||||
_ => GrowthSetting::Neutral,
|
||||
}
|
||||
}
|
||||
|
||||
fn year_delta(current_year: u16, past_year: u16) -> u8 {
|
||||
current_year
|
||||
.saturating_sub(past_year)
|
||||
.min(u8::MAX as u16) as u8
|
||||
}
|
||||
|
||||
unsafe fn indexed_collection_entry_id_is_live(collection: *const u8, entry_id: usize) -> bool {
|
||||
let id_bound = unsafe { read_i32(collection.add(INDEXED_COLLECTION_ID_BOUND_OFFSET)) };
|
||||
if entry_id == 0 || entry_id > id_bound.max(0) as usize {
|
||||
return false;
|
||||
}
|
||||
|
||||
let tombstone_bits = unsafe {
|
||||
read_ptr(collection.add(INDEXED_COLLECTION_TOMBSTONE_BITSET_OFFSET))
|
||||
};
|
||||
if tombstone_bits.is_null() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let bit_index = entry_id as u32;
|
||||
let word = unsafe {
|
||||
ptr::read_unaligned(tombstone_bits.add((bit_index / 32) as usize).cast::<u32>())
|
||||
};
|
||||
(word & (1_u32 << (bit_index % 32))) == 0
|
||||
}
|
||||
|
||||
unsafe fn indexed_collection_resolve_live_entry_by_id(
|
||||
collection: *const u8,
|
||||
entry_id: usize,
|
||||
) -> *mut u8 {
|
||||
if !unsafe { indexed_collection_entry_id_is_live(collection, entry_id) } {
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
let payload = unsafe { read_ptr(collection.add(INDEXED_COLLECTION_PAYLOAD_OFFSET)) };
|
||||
if payload.is_null() {
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
let stride = unsafe { read_u32(collection.add(INDEXED_COLLECTION_STRIDE_OFFSET)) as usize };
|
||||
let flat = unsafe { read_u32(collection.add(INDEXED_COLLECTION_FLAT_FLAG_OFFSET)) != 0 };
|
||||
|
||||
if flat {
|
||||
unsafe { payload.add(stride * entry_id) }
|
||||
} else {
|
||||
unsafe { ptr::read_unaligned(payload.add(stride * entry_id).cast::<*mut u8>()) }
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn read_u8(address: *const u8) -> u8 {
|
||||
unsafe { ptr::read_unaligned(address) }
|
||||
}
|
||||
|
||||
unsafe fn read_u16(address: *const u8) -> u16 {
|
||||
unsafe { ptr::read_unaligned(address.cast::<u16>()) }
|
||||
}
|
||||
|
||||
unsafe fn read_u32(address: *const u8) -> u32 {
|
||||
unsafe { ptr::read_unaligned(address.cast::<u32>()) }
|
||||
}
|
||||
|
||||
unsafe fn read_i32(address: *const u8) -> i32 {
|
||||
unsafe { ptr::read_unaligned(address.cast::<i32>()) }
|
||||
}
|
||||
|
||||
unsafe fn read_f32(address: *const u8) -> f32 {
|
||||
unsafe { ptr::read_unaligned(address.cast::<f32>()) }
|
||||
}
|
||||
|
||||
unsafe fn read_ptr(address: *const u8) -> *mut u8 {
|
||||
unsafe { ptr::read_unaligned(address.cast::<*mut u8>()) }
|
||||
}
|
||||
|
||||
unsafe fn load_direct_input8_create() -> Option<DirectInput8CreateFn> {
|
||||
if let Some(callback) = unsafe { REAL_DINPUT8_CREATE } {
|
||||
return Some(callback);
|
||||
|
|
@ -168,3 +1018,30 @@ mod windows_hook {
|
|||
pub fn host_build_marker() -> &'static str {
|
||||
"rrt-hook host build"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[test]
|
||||
fn writes_snapshot_bundle_to_disk() {
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system time should be after epoch")
|
||||
.as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("rrt-hook-finance-{nonce}"));
|
||||
let paths = write_finance_snapshot_bundle(&dir, "testcase", &sample_finance_snapshot())
|
||||
.expect("bundle should be written");
|
||||
|
||||
let snapshot_json = fs::read_to_string(&paths.snapshot_path).expect("snapshot should exist");
|
||||
let outcome_json = fs::read_to_string(&paths.outcome_path).expect("outcome should exist");
|
||||
|
||||
assert!(snapshot_json.contains("\"policy\""));
|
||||
assert!(outcome_json.contains("\"evaluation\""));
|
||||
|
||||
let _ = fs::remove_file(&paths.snapshot_path);
|
||||
let _ = fs::remove_file(&paths.outcome_path);
|
||||
let _ = fs::remove_dir(&dir);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
957
crates/rrt-model/src/finance.rs
Normal file
957
crates/rrt-model/src/finance.rs
Normal file
|
|
@ -0,0 +1,957 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum GrowthSetting {
|
||||
#[default]
|
||||
Neutral,
|
||||
ExpansionBias,
|
||||
DividendSuppressed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BondPosition {
|
||||
pub principal: i64,
|
||||
pub coupon_rate: f64,
|
||||
pub years_remaining: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BankruptcyReason {
|
||||
EarlyStress,
|
||||
DeepDistress,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AnnualReportMetric {
|
||||
NetProfits,
|
||||
RevenueAggregate,
|
||||
FuelCost,
|
||||
RevenuePerShare,
|
||||
EarningsPerShare,
|
||||
DividendPerShare,
|
||||
BookValuePerShare,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DebtNewsOutcome {
|
||||
RefinanceOnly,
|
||||
RefinanceAndBorrow,
|
||||
RefinanceAndPayDown,
|
||||
DebtPayoffOnly,
|
||||
NewBorrowingOnly,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct DebtRestructureSummary {
|
||||
pub retired_principal: i64,
|
||||
pub issued_principal: i64,
|
||||
}
|
||||
|
||||
impl DebtRestructureSummary {
|
||||
pub fn classify(self) -> Option<DebtNewsOutcome> {
|
||||
match (self.retired_principal > 0, self.issued_principal > 0) {
|
||||
(false, false) => None,
|
||||
(false, true) => Some(DebtNewsOutcome::NewBorrowingOnly),
|
||||
(true, false) => Some(DebtNewsOutcome::DebtPayoffOnly),
|
||||
(true, true) if self.retired_principal == self.issued_principal => {
|
||||
Some(DebtNewsOutcome::RefinanceOnly)
|
||||
}
|
||||
(true, true) if self.issued_principal > self.retired_principal => {
|
||||
Some(DebtNewsOutcome::RefinanceAndBorrow)
|
||||
}
|
||||
(true, true) => Some(DebtNewsOutcome::RefinanceAndPayDown),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum AnnualFinanceDecision {
|
||||
NoAction,
|
||||
DeclareBankruptcy {
|
||||
reason: BankruptcyReason,
|
||||
},
|
||||
IssueBond {
|
||||
count: u32,
|
||||
principal_per_bond: i64,
|
||||
term_years: u8,
|
||||
},
|
||||
RepurchasePublicShares {
|
||||
share_count: u32,
|
||||
price_per_share: f64,
|
||||
},
|
||||
IssuePublicShares {
|
||||
share_count_per_tranche: u32,
|
||||
tranche_count: u32,
|
||||
price_per_share: f64,
|
||||
},
|
||||
AdjustDividend {
|
||||
old_rate: f64,
|
||||
new_rate: f64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AnnualFinanceEvaluation {
|
||||
pub decision: AnnualFinanceDecision,
|
||||
pub debt_restructure: DebtRestructureSummary,
|
||||
pub debt_news: Option<DebtNewsOutcome>,
|
||||
pub repurchased_share_count: u32,
|
||||
pub issued_share_count: u32,
|
||||
}
|
||||
|
||||
impl AnnualFinanceEvaluation {
|
||||
pub fn no_action() -> Self {
|
||||
Self {
|
||||
decision: AnnualFinanceDecision::NoAction,
|
||||
debt_restructure: DebtRestructureSummary::default(),
|
||||
debt_news: None,
|
||||
repurchased_share_count: 0,
|
||||
issued_share_count: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AnnualFinancePolicy {
|
||||
pub annual_mode: u8,
|
||||
pub build_103_plus: bool,
|
||||
pub bankruptcy_allowed: bool,
|
||||
pub bond_issuance_allowed: bool,
|
||||
pub stock_actions_allowed: bool,
|
||||
pub dividends_allowed: bool,
|
||||
pub growth_setting: GrowthSetting,
|
||||
pub stock_issue_cash_buffer: i64,
|
||||
}
|
||||
|
||||
impl Default for AnnualFinancePolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
annual_mode: 0x0c,
|
||||
build_103_plus: true,
|
||||
bankruptcy_allowed: true,
|
||||
bond_issuance_allowed: true,
|
||||
stock_actions_allowed: true,
|
||||
dividends_allowed: true,
|
||||
growth_setting: GrowthSetting::Neutral,
|
||||
stock_issue_cash_buffer: 30_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CompanyFinanceState {
|
||||
pub active: bool,
|
||||
pub years_since_founding: u8,
|
||||
pub years_since_last_bankruptcy: u8,
|
||||
pub current_cash: i64,
|
||||
pub current_company_value: i64,
|
||||
pub support_adjusted_share_price: f64,
|
||||
pub book_value_per_share: f64,
|
||||
pub current_fuel_cost: i64,
|
||||
pub current_dividend_per_share: f64,
|
||||
pub board_dividend_ceiling: f64,
|
||||
pub outstanding_share_count: u32,
|
||||
pub unassigned_share_count: u32,
|
||||
pub city_connection_bonus_latch: bool,
|
||||
pub linked_transit_service_latch: bool,
|
||||
pub chairman_buyback_factor: Option<f64>,
|
||||
pub recent_net_profits: [i64; 3],
|
||||
pub recent_revenue_totals: [i64; 3],
|
||||
pub recent_revenue_per_share: [f64; 3],
|
||||
pub recent_earnings_per_share: [f64; 3],
|
||||
pub recent_dividend_per_share: [f64; 3],
|
||||
pub bonds: Vec<BondPosition>,
|
||||
}
|
||||
|
||||
impl Default for CompanyFinanceState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
active: true,
|
||||
years_since_founding: 5,
|
||||
years_since_last_bankruptcy: 20,
|
||||
current_cash: 0,
|
||||
current_company_value: 1_000_000,
|
||||
support_adjusted_share_price: 25.0,
|
||||
book_value_per_share: 20.0,
|
||||
current_fuel_cost: 0,
|
||||
current_dividend_per_share: 0.0,
|
||||
board_dividend_ceiling: 2.0,
|
||||
outstanding_share_count: 20_000,
|
||||
unassigned_share_count: 10_000,
|
||||
city_connection_bonus_latch: false,
|
||||
linked_transit_service_latch: false,
|
||||
chairman_buyback_factor: None,
|
||||
recent_net_profits: [10_000, 10_000, 10_000],
|
||||
recent_revenue_totals: [200_000, 180_000, 160_000],
|
||||
recent_revenue_per_share: [1.2, 1.1, 1.0],
|
||||
recent_earnings_per_share: [1.0, 0.9, 0.8],
|
||||
recent_dividend_per_share: [0.2, 0.2, 0.1],
|
||||
bonds: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CompanyFinanceState {
|
||||
pub const BOND_PRINCIPAL: i64 = 500_000;
|
||||
pub const BOND_TERM_YEARS: u8 = 30;
|
||||
pub const SHARE_LOT: u32 = 1_000;
|
||||
|
||||
pub fn total_debt_principal(&self) -> i64 {
|
||||
self.bonds.iter().map(|bond| bond.principal.max(0)).sum()
|
||||
}
|
||||
|
||||
pub fn highest_coupon_bond(&self) -> Option<BondPosition> {
|
||||
self.bonds
|
||||
.iter()
|
||||
.copied()
|
||||
.max_by(|left, right| left.coupon_rate.total_cmp(&right.coupon_rate))
|
||||
}
|
||||
|
||||
pub fn simulate_cash_after_full_bond_repayment(&self) -> i64 {
|
||||
self.current_cash - self.total_debt_principal()
|
||||
}
|
||||
|
||||
pub fn declare_bankruptcy(&mut self) {
|
||||
for bond in &mut self.bonds {
|
||||
bond.principal /= 2;
|
||||
}
|
||||
self.current_company_value /= 2;
|
||||
self.years_since_last_bankruptcy = 0;
|
||||
}
|
||||
|
||||
pub fn issue_bond(&mut self, coupon_rate: f64, count: u32) {
|
||||
for _ in 0..count {
|
||||
self.bonds.push(BondPosition {
|
||||
principal: Self::BOND_PRINCIPAL,
|
||||
coupon_rate,
|
||||
years_remaining: Self::BOND_TERM_YEARS,
|
||||
});
|
||||
self.current_cash += Self::BOND_PRINCIPAL;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn repurchase_public_shares(&mut self, share_count: u32, price_per_share: f64) {
|
||||
let repurchased = share_count.min(self.unassigned_share_count);
|
||||
self.unassigned_share_count -= repurchased;
|
||||
self.outstanding_share_count = self.outstanding_share_count.saturating_sub(repurchased);
|
||||
self.current_cash -= (repurchased as f64 * price_per_share).round() as i64;
|
||||
}
|
||||
|
||||
pub fn issue_public_shares(&mut self, share_count: u32, price_per_share: f64) {
|
||||
self.outstanding_share_count = self.outstanding_share_count.saturating_add(share_count);
|
||||
self.unassigned_share_count = self.unassigned_share_count.saturating_add(share_count);
|
||||
self.current_cash += (share_count as f64 * price_per_share).round() as i64;
|
||||
}
|
||||
|
||||
pub fn set_dividend_rate(&mut self, new_rate: f64) {
|
||||
self.current_dividend_per_share = new_rate.clamp(0.0, self.board_dividend_ceiling);
|
||||
}
|
||||
|
||||
pub fn read_recent_metric(
|
||||
&self,
|
||||
metric: AnnualReportMetric,
|
||||
years_ago: usize,
|
||||
) -> Option<f64> {
|
||||
match metric {
|
||||
AnnualReportMetric::FuelCost if years_ago == 0 => Some(self.current_fuel_cost as f64),
|
||||
AnnualReportMetric::BookValuePerShare if years_ago == 0 => Some(self.book_value_per_share),
|
||||
AnnualReportMetric::NetProfits => self
|
||||
.recent_net_profits
|
||||
.get(years_ago)
|
||||
.copied()
|
||||
.map(|value| value as f64),
|
||||
AnnualReportMetric::RevenueAggregate => self
|
||||
.recent_revenue_totals
|
||||
.get(years_ago)
|
||||
.copied()
|
||||
.map(|value| value as f64),
|
||||
AnnualReportMetric::RevenuePerShare => {
|
||||
self.recent_revenue_per_share.get(years_ago).copied()
|
||||
}
|
||||
AnnualReportMetric::EarningsPerShare => {
|
||||
self.recent_earnings_per_share.get(years_ago).copied()
|
||||
}
|
||||
AnnualReportMetric::DividendPerShare => {
|
||||
self.recent_dividend_per_share.get(years_ago).copied()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_recent_metric_window(
|
||||
&self,
|
||||
metric: AnnualReportMetric,
|
||||
years: usize,
|
||||
) -> Vec<f64> {
|
||||
(0..years)
|
||||
.filter_map(|years_ago| self.read_recent_metric(metric, years_ago))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn weighted_recent_metric(
|
||||
&self,
|
||||
metric: AnnualReportMetric,
|
||||
weights: &[f64],
|
||||
) -> Option<f64> {
|
||||
let mut numerator = 0.0;
|
||||
let mut denominator = 0.0;
|
||||
for (years_ago, weight) in weights.iter().copied().enumerate() {
|
||||
let value = self.read_recent_metric(metric, years_ago)?;
|
||||
numerator += value * weight;
|
||||
denominator += weight;
|
||||
}
|
||||
|
||||
(denominator > 0.0).then_some(numerator / denominator)
|
||||
}
|
||||
|
||||
pub fn apply_annual_decision(&mut self, decision: &AnnualFinanceDecision) {
|
||||
match *decision {
|
||||
AnnualFinanceDecision::NoAction => {}
|
||||
AnnualFinanceDecision::DeclareBankruptcy { .. } => self.declare_bankruptcy(),
|
||||
AnnualFinanceDecision::IssueBond {
|
||||
count,
|
||||
principal_per_bond: _,
|
||||
term_years: _,
|
||||
} => {
|
||||
let coupon = self
|
||||
.highest_coupon_bond()
|
||||
.map(|bond| bond.coupon_rate)
|
||||
.unwrap_or(0.10);
|
||||
self.issue_bond(coupon, count);
|
||||
}
|
||||
AnnualFinanceDecision::RepurchasePublicShares {
|
||||
share_count,
|
||||
price_per_share,
|
||||
} => self.repurchase_public_shares(share_count, price_per_share),
|
||||
AnnualFinanceDecision::IssuePublicShares {
|
||||
share_count_per_tranche,
|
||||
tranche_count,
|
||||
price_per_share,
|
||||
} => self.issue_public_shares(
|
||||
share_count_per_tranche.saturating_mul(tranche_count),
|
||||
price_per_share,
|
||||
),
|
||||
AnnualFinanceDecision::AdjustDividend { new_rate, .. } => {
|
||||
self.set_dividend_rate(new_rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FinanceSnapshot {
|
||||
pub policy: AnnualFinancePolicy,
|
||||
pub company: CompanyFinanceState,
|
||||
}
|
||||
|
||||
impl FinanceSnapshot {
|
||||
pub fn evaluate(&self) -> FinanceOutcome {
|
||||
let evaluation = evaluate_annual_finance_policy_detailed(&self.policy, &self.company);
|
||||
let mut post_company = self.company.clone();
|
||||
post_company.apply_annual_decision(&evaluation.decision);
|
||||
|
||||
FinanceOutcome {
|
||||
evaluation,
|
||||
post_company,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FinanceOutcome {
|
||||
pub evaluation: AnnualFinanceEvaluation,
|
||||
pub post_company: CompanyFinanceState,
|
||||
}
|
||||
|
||||
pub fn evaluate_annual_finance_policy(
|
||||
policy: &AnnualFinancePolicy,
|
||||
company: &CompanyFinanceState,
|
||||
) -> AnnualFinanceDecision {
|
||||
evaluate_annual_finance_policy_detailed(policy, company).decision
|
||||
}
|
||||
|
||||
pub fn evaluate_annual_finance_policy_detailed(
|
||||
policy: &AnnualFinancePolicy,
|
||||
company: &CompanyFinanceState,
|
||||
) -> AnnualFinanceEvaluation {
|
||||
if !company.active {
|
||||
return AnnualFinanceEvaluation::no_action();
|
||||
}
|
||||
|
||||
if should_bankrupt_early(policy, company) {
|
||||
return AnnualFinanceEvaluation {
|
||||
decision: AnnualFinanceDecision::DeclareBankruptcy {
|
||||
reason: BankruptcyReason::EarlyStress,
|
||||
},
|
||||
..AnnualFinanceEvaluation::no_action()
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(evaluation) = issue_bond_evaluation(policy, company) {
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
if let Some(evaluation) = repurchase_evaluation(policy, company) {
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
if should_bankrupt_deep_distress(policy, company) {
|
||||
return AnnualFinanceEvaluation {
|
||||
decision: AnnualFinanceDecision::DeclareBankruptcy {
|
||||
reason: BankruptcyReason::DeepDistress,
|
||||
},
|
||||
..AnnualFinanceEvaluation::no_action()
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(evaluation) = issue_stock_evaluation(policy, company) {
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
if let Some(evaluation) = dividend_evaluation(policy, company) {
|
||||
return evaluation;
|
||||
}
|
||||
|
||||
AnnualFinanceEvaluation::no_action()
|
||||
}
|
||||
|
||||
fn should_bankrupt_early(policy: &AnnualFinancePolicy, company: &CompanyFinanceState) -> bool {
|
||||
if policy.annual_mode != 0x0c || !policy.bankruptcy_allowed {
|
||||
return false;
|
||||
}
|
||||
if company.years_since_last_bankruptcy < 13 || company.years_since_founding < 4 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let current_revenue = company.recent_revenue_totals[0];
|
||||
let stress_ladder: i64 = if current_revenue < 120_000 {
|
||||
-600_000
|
||||
} else if current_revenue < 230_000 {
|
||||
-1_100_000
|
||||
} else if current_revenue < 340_000 {
|
||||
-1_600_000
|
||||
} else {
|
||||
-2_000_000
|
||||
};
|
||||
|
||||
let failed_profit_years = company
|
||||
.recent_net_profits
|
||||
.iter()
|
||||
.filter(|profit| **profit <= 0)
|
||||
.count();
|
||||
let net_profit_sum: i64 = company.recent_net_profits.iter().sum();
|
||||
let share_price_floor = if failed_profit_years == 3 { 20.0 } else { 15.0 };
|
||||
let fuel_gate = (stress_ladder.abs() as f64 * 0.08).round() as i64;
|
||||
|
||||
failed_profit_years >= 2
|
||||
&& net_profit_sum <= -60_000
|
||||
&& company.support_adjusted_share_price >= share_price_floor
|
||||
&& company.current_fuel_cost >= fuel_gate
|
||||
}
|
||||
|
||||
fn should_bankrupt_deep_distress(
|
||||
policy: &AnnualFinancePolicy,
|
||||
company: &CompanyFinanceState,
|
||||
) -> bool {
|
||||
policy.bankruptcy_allowed
|
||||
&& company.current_cash < -300_000
|
||||
&& company.years_since_founding >= 3
|
||||
&& company.years_since_last_bankruptcy >= 5
|
||||
&& company.recent_net_profits.iter().all(|profit| *profit <= -20_000)
|
||||
}
|
||||
|
||||
fn issue_bond_evaluation(
|
||||
policy: &AnnualFinancePolicy,
|
||||
company: &CompanyFinanceState,
|
||||
) -> Option<AnnualFinanceEvaluation> {
|
||||
if !policy.bond_issuance_allowed {
|
||||
return None;
|
||||
}
|
||||
|
||||
let simulated_cash = company.simulate_cash_after_full_bond_repayment();
|
||||
let target_floor = if company.linked_transit_service_latch {
|
||||
-30_000
|
||||
} else {
|
||||
-250_000
|
||||
};
|
||||
if simulated_cash >= target_floor {
|
||||
return None;
|
||||
}
|
||||
|
||||
let shortfall = (target_floor - simulated_cash).max(0) as u64;
|
||||
let count = shortfall.div_ceil(CompanyFinanceState::BOND_PRINCIPAL as u64) as u32;
|
||||
let issued_principal = count.max(1) as i64 * CompanyFinanceState::BOND_PRINCIPAL;
|
||||
let debt_restructure = DebtRestructureSummary {
|
||||
retired_principal: 0,
|
||||
issued_principal,
|
||||
};
|
||||
|
||||
Some(AnnualFinanceEvaluation {
|
||||
decision: AnnualFinanceDecision::IssueBond {
|
||||
count: count.max(1),
|
||||
principal_per_bond: CompanyFinanceState::BOND_PRINCIPAL,
|
||||
term_years: CompanyFinanceState::BOND_TERM_YEARS,
|
||||
},
|
||||
debt_news: debt_restructure.classify(),
|
||||
debt_restructure,
|
||||
..AnnualFinanceEvaluation::no_action()
|
||||
})
|
||||
}
|
||||
|
||||
fn repurchase_evaluation(
|
||||
policy: &AnnualFinancePolicy,
|
||||
company: &CompanyFinanceState,
|
||||
) -> Option<AnnualFinanceEvaluation> {
|
||||
if !policy.stock_actions_allowed
|
||||
|| !company.city_connection_bonus_latch
|
||||
|| matches!(policy.growth_setting, GrowthSetting::DividendSuppressed)
|
||||
|| company.unassigned_share_count < CompanyFinanceState::SHARE_LOT
|
||||
|| company.current_company_value < 800_000
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut factor = company.chairman_buyback_factor.unwrap_or(1.0);
|
||||
if matches!(policy.growth_setting, GrowthSetting::ExpansionBias) {
|
||||
factor *= 1.6;
|
||||
}
|
||||
|
||||
let batch = CompanyFinanceState::SHARE_LOT;
|
||||
let affordability_gate = company.support_adjusted_share_price * factor * batch as f64 * 1.2;
|
||||
if company.current_cash < affordability_gate.round() as i64 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(AnnualFinanceEvaluation {
|
||||
decision: AnnualFinanceDecision::RepurchasePublicShares {
|
||||
share_count: batch,
|
||||
price_per_share: company.support_adjusted_share_price,
|
||||
},
|
||||
repurchased_share_count: batch,
|
||||
..AnnualFinanceEvaluation::no_action()
|
||||
})
|
||||
}
|
||||
|
||||
fn issue_stock_evaluation(
|
||||
policy: &AnnualFinancePolicy,
|
||||
company: &CompanyFinanceState,
|
||||
) -> Option<AnnualFinanceEvaluation> {
|
||||
if !policy.build_103_plus
|
||||
|| !policy.stock_actions_allowed
|
||||
|| !policy.bond_issuance_allowed
|
||||
|| company.bonds.len() < 2
|
||||
|| company.years_since_founding < 1
|
||||
|| company.support_adjusted_share_price < 22.0
|
||||
|| company.book_value_per_share <= 0.0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let highest_coupon = company.highest_coupon_bond()?;
|
||||
if company.current_cash >= highest_coupon.principal + policy.stock_issue_cash_buffer {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut tranche =
|
||||
((company.outstanding_share_count / 10) / CompanyFinanceState::SHARE_LOT)
|
||||
* CompanyFinanceState::SHARE_LOT;
|
||||
tranche = tranche.max(2_000);
|
||||
while tranche >= CompanyFinanceState::SHARE_LOT
|
||||
&& company.support_adjusted_share_price * tranche as f64 > 55_000.0
|
||||
{
|
||||
tranche -= CompanyFinanceState::SHARE_LOT;
|
||||
}
|
||||
if tranche < CompanyFinanceState::SHARE_LOT {
|
||||
return None;
|
||||
}
|
||||
|
||||
let price_to_book = company.support_adjusted_share_price / company.book_value_per_share;
|
||||
if price_to_book < required_price_to_book_ratio(highest_coupon.coupon_rate) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(AnnualFinanceEvaluation {
|
||||
decision: AnnualFinanceDecision::IssuePublicShares {
|
||||
share_count_per_tranche: tranche,
|
||||
tranche_count: 2,
|
||||
price_per_share: company.support_adjusted_share_price,
|
||||
},
|
||||
issued_share_count: tranche * 2,
|
||||
..AnnualFinanceEvaluation::no_action()
|
||||
})
|
||||
}
|
||||
|
||||
fn dividend_evaluation(
|
||||
policy: &AnnualFinancePolicy,
|
||||
company: &CompanyFinanceState,
|
||||
) -> Option<AnnualFinanceEvaluation> {
|
||||
if !policy.dividends_allowed || company.years_since_founding < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let weighted_target = company
|
||||
.weighted_recent_metric(AnnualReportMetric::EarningsPerShare, &[3.0, 2.0, 1.0])
|
||||
.unwrap_or(0.0);
|
||||
let mut target = weighted_target.max(0.0);
|
||||
|
||||
if company.unassigned_share_count < CompanyFinanceState::SHARE_LOT
|
||||
&& company.outstanding_share_count > 0
|
||||
&& company.current_cash > 0
|
||||
{
|
||||
target += company.current_cash as f64 / company.outstanding_share_count as f64;
|
||||
}
|
||||
|
||||
target = match policy.growth_setting {
|
||||
GrowthSetting::Neutral => target,
|
||||
GrowthSetting::ExpansionBias => target * 0.66,
|
||||
GrowthSetting::DividendSuppressed => 0.0,
|
||||
};
|
||||
target = quantize_tenths(target.clamp(0.0, company.board_dividend_ceiling));
|
||||
|
||||
if (target - company.current_dividend_per_share).abs() <= 0.1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(AnnualFinanceEvaluation {
|
||||
decision: AnnualFinanceDecision::AdjustDividend {
|
||||
old_rate: company.current_dividend_per_share,
|
||||
new_rate: target,
|
||||
},
|
||||
..AnnualFinanceEvaluation::no_action()
|
||||
})
|
||||
}
|
||||
|
||||
fn required_price_to_book_ratio(coupon_rate: f64) -> f64 {
|
||||
if coupon_rate <= 0.07 {
|
||||
1.3
|
||||
} else if coupon_rate <= 0.08 {
|
||||
1.2
|
||||
} else if coupon_rate <= 0.09 {
|
||||
1.1
|
||||
} else if coupon_rate <= 0.10 {
|
||||
0.95
|
||||
} else if coupon_rate <= 0.11 {
|
||||
0.8
|
||||
} else if coupon_rate <= 0.12 {
|
||||
0.62
|
||||
} else if coupon_rate <= 0.13 {
|
||||
0.5
|
||||
} else {
|
||||
0.35
|
||||
}
|
||||
}
|
||||
|
||||
fn quantize_tenths(value: f64) -> f64 {
|
||||
(value * 10.0).round() / 10.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn base_policy() -> AnnualFinancePolicy {
|
||||
AnnualFinancePolicy::default()
|
||||
}
|
||||
|
||||
fn base_company() -> CompanyFinanceState {
|
||||
CompanyFinanceState {
|
||||
bonds: vec![
|
||||
BondPosition {
|
||||
principal: 400_000,
|
||||
coupon_rate: 0.10,
|
||||
years_remaining: 10,
|
||||
},
|
||||
BondPosition {
|
||||
principal: 300_000,
|
||||
coupon_rate: 0.12,
|
||||
years_remaining: 12,
|
||||
},
|
||||
],
|
||||
..CompanyFinanceState::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn early_bankruptcy_precedes_other_actions() {
|
||||
let policy = base_policy();
|
||||
let company = CompanyFinanceState {
|
||||
current_fuel_cost: 90_000,
|
||||
support_adjusted_share_price: 21.0,
|
||||
recent_net_profits: [-30_000, -25_000, -20_000],
|
||||
recent_revenue_totals: [150_000, 140_000, 130_000],
|
||||
city_connection_bonus_latch: true,
|
||||
linked_transit_service_latch: true,
|
||||
..base_company()
|
||||
};
|
||||
|
||||
let decision = evaluate_annual_finance_policy(&policy, &company);
|
||||
assert_eq!(
|
||||
decision,
|
||||
AnnualFinanceDecision::DeclareBankruptcy {
|
||||
reason: BankruptcyReason::EarlyStress,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bond_issue_precedes_stock_issue_when_cash_window_fails() {
|
||||
let policy = base_policy();
|
||||
let company = CompanyFinanceState {
|
||||
current_cash: -900_000,
|
||||
support_adjusted_share_price: 30.0,
|
||||
book_value_per_share: 20.0,
|
||||
linked_transit_service_latch: true,
|
||||
recent_net_profits: [20_000, 10_000, 5_000],
|
||||
..base_company()
|
||||
};
|
||||
|
||||
let decision = evaluate_annual_finance_policy(&policy, &company);
|
||||
assert_eq!(
|
||||
decision,
|
||||
AnnualFinanceDecision::IssueBond {
|
||||
count: 4,
|
||||
principal_per_bond: CompanyFinanceState::BOND_PRINCIPAL,
|
||||
term_years: CompanyFinanceState::BOND_TERM_YEARS,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_issue_checks_liquidity_before_valuation() {
|
||||
let policy = AnnualFinancePolicy {
|
||||
bond_issuance_allowed: false,
|
||||
dividends_allowed: false,
|
||||
..base_policy()
|
||||
};
|
||||
let company = CompanyFinanceState {
|
||||
current_cash: 500_000,
|
||||
support_adjusted_share_price: 30.0,
|
||||
book_value_per_share: 20.0,
|
||||
recent_net_profits: [40_000, 30_000, 20_000],
|
||||
recent_revenue_totals: [250_000, 240_000, 230_000],
|
||||
..base_company()
|
||||
};
|
||||
|
||||
let decision = evaluate_annual_finance_policy(&policy, &company);
|
||||
assert_eq!(decision, AnnualFinanceDecision::NoAction);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_issue_emits_two_tranches_when_gates_pass() {
|
||||
let policy = AnnualFinancePolicy {
|
||||
dividends_allowed: false,
|
||||
..base_policy()
|
||||
};
|
||||
let company = CompanyFinanceState {
|
||||
current_cash: 100_000,
|
||||
support_adjusted_share_price: 27.5,
|
||||
book_value_per_share: 20.0,
|
||||
outstanding_share_count: 60_000,
|
||||
recent_net_profits: [40_000, 30_000, 20_000],
|
||||
recent_revenue_totals: [250_000, 240_000, 230_000],
|
||||
bonds: vec![
|
||||
BondPosition {
|
||||
principal: 150_000,
|
||||
coupon_rate: 0.12,
|
||||
years_remaining: 12,
|
||||
},
|
||||
BondPosition {
|
||||
principal: 10_000,
|
||||
coupon_rate: 0.10,
|
||||
years_remaining: 10,
|
||||
},
|
||||
],
|
||||
..base_company()
|
||||
};
|
||||
|
||||
let decision = evaluate_annual_finance_policy(&policy, &company);
|
||||
assert_eq!(
|
||||
decision,
|
||||
AnnualFinanceDecision::IssuePublicShares {
|
||||
share_count_per_tranche: 2_000,
|
||||
tranche_count: 2,
|
||||
price_per_share: 27.5,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_metric_reader_exposes_report_lanes() {
|
||||
let company = CompanyFinanceState {
|
||||
current_fuel_cost: 12_345,
|
||||
recent_net_profits: [11_000, 22_000, 33_000],
|
||||
recent_revenue_totals: [101_000, 102_000, 103_000],
|
||||
recent_revenue_per_share: [1.6, 1.5, 1.4],
|
||||
recent_earnings_per_share: [1.3, 1.2, 1.1],
|
||||
recent_dividend_per_share: [0.4, 0.3, 0.2],
|
||||
book_value_per_share: 19.5,
|
||||
..base_company()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
company.read_recent_metric(AnnualReportMetric::NetProfits, 1),
|
||||
Some(22_000.0)
|
||||
);
|
||||
assert_eq!(
|
||||
company.read_recent_metric(AnnualReportMetric::RevenuePerShare, 2),
|
||||
Some(1.4)
|
||||
);
|
||||
assert_eq!(
|
||||
company.read_recent_metric(AnnualReportMetric::FuelCost, 0),
|
||||
Some(12_345.0)
|
||||
);
|
||||
assert_eq!(
|
||||
company.read_recent_metric(AnnualReportMetric::BookValuePerShare, 0),
|
||||
Some(19.5)
|
||||
);
|
||||
assert_eq!(
|
||||
company.weighted_recent_metric(AnnualReportMetric::EarningsPerShare, &[3.0, 2.0, 1.0]),
|
||||
Some(1.2333333333333334)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debt_news_classifies_borrow_and_paydown_paths() {
|
||||
assert_eq!(
|
||||
DebtRestructureSummary {
|
||||
retired_principal: 500_000,
|
||||
issued_principal: 500_000,
|
||||
}
|
||||
.classify(),
|
||||
Some(DebtNewsOutcome::RefinanceOnly)
|
||||
);
|
||||
assert_eq!(
|
||||
DebtRestructureSummary {
|
||||
retired_principal: 300_000,
|
||||
issued_principal: 500_000,
|
||||
}
|
||||
.classify(),
|
||||
Some(DebtNewsOutcome::RefinanceAndBorrow)
|
||||
);
|
||||
assert_eq!(
|
||||
DebtRestructureSummary {
|
||||
retired_principal: 500_000,
|
||||
issued_principal: 300_000,
|
||||
}
|
||||
.classify(),
|
||||
Some(DebtNewsOutcome::RefinanceAndPayDown)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detailed_evaluation_carries_share_and_debt_side_effects() {
|
||||
let policy = base_policy();
|
||||
let company = CompanyFinanceState {
|
||||
current_cash: -900_000,
|
||||
support_adjusted_share_price: 30.0,
|
||||
book_value_per_share: 20.0,
|
||||
linked_transit_service_latch: true,
|
||||
recent_net_profits: [20_000, 10_000, 5_000],
|
||||
..base_company()
|
||||
};
|
||||
|
||||
let evaluation = evaluate_annual_finance_policy_detailed(&policy, &company);
|
||||
assert_eq!(
|
||||
evaluation.decision,
|
||||
AnnualFinanceDecision::IssueBond {
|
||||
count: 4,
|
||||
principal_per_bond: CompanyFinanceState::BOND_PRINCIPAL,
|
||||
term_years: CompanyFinanceState::BOND_TERM_YEARS,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
evaluation.debt_news,
|
||||
Some(DebtNewsOutcome::NewBorrowingOnly)
|
||||
);
|
||||
assert_eq!(evaluation.debt_restructure.issued_principal, 2_000_000);
|
||||
assert_eq!(evaluation.repurchased_share_count, 0);
|
||||
assert_eq!(evaluation.issued_share_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_evaluation_applies_post_state_transition() {
|
||||
let snapshot = FinanceSnapshot {
|
||||
policy: AnnualFinancePolicy {
|
||||
dividends_allowed: false,
|
||||
..base_policy()
|
||||
},
|
||||
company: CompanyFinanceState {
|
||||
current_cash: 100_000,
|
||||
support_adjusted_share_price: 27.5,
|
||||
book_value_per_share: 20.0,
|
||||
outstanding_share_count: 60_000,
|
||||
recent_net_profits: [40_000, 30_000, 20_000],
|
||||
recent_revenue_totals: [250_000, 240_000, 230_000],
|
||||
bonds: vec![
|
||||
BondPosition {
|
||||
principal: 150_000,
|
||||
coupon_rate: 0.12,
|
||||
years_remaining: 12,
|
||||
},
|
||||
BondPosition {
|
||||
principal: 10_000,
|
||||
coupon_rate: 0.10,
|
||||
years_remaining: 10,
|
||||
},
|
||||
],
|
||||
..base_company()
|
||||
},
|
||||
};
|
||||
|
||||
let outcome = snapshot.evaluate();
|
||||
assert_eq!(
|
||||
outcome.evaluation.decision,
|
||||
AnnualFinanceDecision::IssuePublicShares {
|
||||
share_count_per_tranche: 2_000,
|
||||
tranche_count: 2,
|
||||
price_per_share: 27.5,
|
||||
}
|
||||
);
|
||||
assert_eq!(outcome.evaluation.issued_share_count, 4_000);
|
||||
assert_eq!(outcome.post_company.outstanding_share_count, 64_000);
|
||||
assert_eq!(outcome.post_company.unassigned_share_count, 14_000);
|
||||
assert_eq!(outcome.post_company.current_cash, 210_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dividend_target_is_quantized_and_clamped() {
|
||||
let policy = AnnualFinancePolicy {
|
||||
bond_issuance_allowed: false,
|
||||
..base_policy()
|
||||
};
|
||||
let company = CompanyFinanceState {
|
||||
current_cash: 20_000,
|
||||
board_dividend_ceiling: 0.9,
|
||||
current_dividend_per_share: 0.2,
|
||||
unassigned_share_count: 500,
|
||||
outstanding_share_count: 10_000,
|
||||
recent_earnings_per_share: [1.4, 1.1, 0.9],
|
||||
..base_company()
|
||||
};
|
||||
|
||||
let decision = evaluate_annual_finance_policy(&policy, &company);
|
||||
assert_eq!(
|
||||
decision,
|
||||
AnnualFinanceDecision::AdjustDividend {
|
||||
old_rate: 0.2,
|
||||
new_rate: 0.9,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bankruptcy_mutator_halves_bond_principal() {
|
||||
let mut company = base_company();
|
||||
company.current_company_value = 900_000;
|
||||
company.years_since_last_bankruptcy = 25;
|
||||
|
||||
company.apply_annual_decision(&AnnualFinanceDecision::DeclareBankruptcy {
|
||||
reason: BankruptcyReason::DeepDistress,
|
||||
});
|
||||
|
||||
assert_eq!(company.bonds[0].principal, 200_000);
|
||||
assert_eq!(company.bonds[1].principal, 150_000);
|
||||
assert_eq!(company.current_company_value, 450_000);
|
||||
assert_eq!(company.years_since_last_bankruptcy, 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
pub mod finance;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::fs::File;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue