Compare commits

..
44 Commits
Author SHA1 Message Date
Valentin Popov 254b443d35 feat(runtime): recompute loaded Control owner profiles
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m5s
2026-10-30 00:13:56 +04:00
Valentin Popov b1134c5526 docs(runtime): clarify Control load callback order
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
2026-10-29 22:52:57 +04:00
Valentin Popov aa6d4b8537 feat(runtime): project load-time Control profile values
Docs Deploy / Build and Deploy MkDocs (push) Successful in 41s
2026-10-29 22:51:25 +04:00
Valentin Popov cc5c6414ce feat(runtime): project native Control object classes
Docs Deploy / Build and Deploy MkDocs (push) Successful in 50s
2026-10-29 21:20:45 +04:00
Valentin Popov 6c079d9457 feat(runtime): retain Control owner profiles during loading
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
2026-10-29 20:55:48 +04:00
Valentin Popov ba7cb7f0f9 feat(runtime): support tag-26 Control resource budgets
Docs Deploy / Build and Deploy MkDocs (push) Successful in 41s
2026-10-29 20:26:15 +04:00
Valentin Popov 1d20509b05 feat(runtime): support class-9 and default Control budgets
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
2026-10-29 19:43:09 +04:00
Valentin Popov d3a0f8c7ff feat(runtime): run tag-15 and tag-19 Control budgets
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
2026-10-29 19:05:13 +04:00
Valentin Popov d8e86e4f33 feat(runtime): distribute Control provider amounts
Docs Deploy / Build and Deploy MkDocs (push) Successful in 39s
2026-10-29 18:24:43 +04:00
Valentin Popov be4031cb54 feat(runtime): propagate Control node stage changes
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
2026-10-29 17:55:55 +04:00
Valentin Popov 0c31c34b57 feat(runtime): update Control node stages
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
2026-10-29 17:30:41 +04:00
Valentin Popov 6eb1dcaf70 feat(runtime): publish provider values into model node state
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
2026-10-29 17:08:08 +04:00
Valentin Popov acdf635ffb feat(runtime): update live Control provider amounts
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s
2026-10-29 16:55:09 +04:00
Valentin Popov f35c9ec1f0 test(runtime): verify randomized Control resource cadence
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
2026-10-29 16:35:50 +04:00
Valentin Popov 38ff0ca682 fix(runtime): load robot Control device tables
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
2026-10-29 16:26:54 +04:00
Valentin Popov 888a331a6d test(runtime): verify native generator budget consumption
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
2026-10-29 16:05:38 +04:00
Valentin Popov e29e3c6bb9 feat(runtime): run resource schedules during world steps
Docs Deploy / Build and Deploy MkDocs (push) Successful in 34s
2026-10-29 15:58:33 +04:00
Valentin Popov 5600ebb388 test(runtime): verify native Control activation clocks
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
2026-10-29 15:49:41 +04:00
Valentin Popov e20b902986 test(runtime): verify active Control resource clock
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
2026-10-29 15:41:08 +04:00
Valentin Popov 35b9d86637 test(runtime): verify native Control budget updates
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
2026-10-29 15:36:40 +04:00
Valentin Popov e6408fe912 docs(runtime): trace original Control clock delivery
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
2026-10-29 15:26:02 +04:00
Valentin Popov 3cec599688 test(runtime): verify native Control node stage transitions
Docs Deploy / Build and Deploy MkDocs (push) Successful in 32s
2026-10-23 14:24:00 +04:00
Valentin Popov d9fe0fcc66 feat(runtime): retain initial Control node amounts
Docs Deploy / Build and Deploy MkDocs (push) Successful in 34s
2026-10-23 13:45:30 +04:00
Valentin Popov 4e16902661 feat(runtime): project initial Control mesh metadata
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
2026-10-23 13:20:59 +04:00
Valentin Popov ce8e65c8ae test(runtime): verify native Control provider updates
Docs Deploy / Build and Deploy MkDocs (push) Successful in 33s
2026-10-23 12:32:49 +04:00
Valentin Popov 7c909ea67b feat(runtime): advance loaded mission resources
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
2026-10-23 12:02:27 +04:00
Valentin Popov 83db371742 feat(runtime): load Behavior profiles for resource queries
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m14s
2026-10-22 15:30:41 +04:00
Valentin Popov 29b81b8723 feat(runtime): load Control node providers for resource queries
Docs Deploy / Build and Deploy MkDocs (push) Failing after 3h0m1s
2026-10-22 14:50:54 +04:00
Valentin Popov b9a4d1b478 feat(runtime): query and update loaded Control resources
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m17s
2026-10-22 14:19:25 +04:00
Valentin Popov 40e4392138 feat(runtime): project merged Control device tables
Docs Deploy / Build and Deploy MkDocs (push) Successful in 56s
2026-10-22 13:55:31 +04:00
Valentin Popov 5c400e7e40 docs(control): explain component device overrides
Docs Deploy / Build and Deploy MkDocs (push) Successful in 2m4s
2026-10-22 12:49:24 +04:00
Valentin Popov ccb5af0e37 feat(assets): sample native resource device coefficient
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m12s
2026-10-22 12:20:42 +04:00
Valentin Popov dcc6459891 feat(runtime): initialize mission ore properties
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m9s
2026-10-22 11:45:23 +04:00
Valentin Popov 374db10daf fix(runtime): model distributor share normalization
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
2026-10-20 09:06:17 +04:00
Valentin Popov caef8f8e6c feat(runtime): register mission objects with clan distributors
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
2026-10-20 08:44:10 +04:00
Valentin Popov ecc46ed177 fix(runtime): preserve native mission AI selection word
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
2026-10-20 08:11:45 +04:00
Valentin Popov e4645b1d62 test(runtime): verify native resource distributor tick
Docs Deploy / Build and Deploy MkDocs (push) Successful in 39s
2026-10-20 06:35:38 +04:00
Valentin Popov 89a0d1da61 fix(runtime): warm behavior before mission Init
Docs Deploy / Build and Deploy MkDocs (push) Successful in 40s
2026-10-20 05:15:11 +04:00
Valentin Popov 31fd9d2ea9 fix(runtime): execute the first supported Init event
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
2026-10-20 04:11:35 +04:00
Valentin Popov dc8be18458 fix(runtime): preserve native mission outcome guards
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s
2026-10-20 03:20:20 +04:00
Valentin Popov 65f1298d93 docs(script): clarify mission outcome compatibility limits
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
2026-10-20 02:00:27 +04:00
Valentin Popov 37db376dd3 docs(script): describe native condition evaluation
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
2026-10-20 01:45:58 +04:00
Valentin Popov bcedda6e93 chore(deps): merge upstream dependency updates
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s
2026-10-20 01:34:22 +04:00
Valentin Popov a970974eb0 fix(script): preserve scheduler counter increment 2026-10-20 01:31:26 +04:00
21 changed files with 22423 additions and 297 deletions

No files matched your search

Generated
+2
View File
@@ -469,6 +469,7 @@ version = "0.1.0"
name = "fparkan-assets" name = "fparkan-assets"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"fparkan-binary",
"fparkan-material", "fparkan-material",
"fparkan-mission-format", "fparkan-mission-format",
"fparkan-msh", "fparkan-msh",
@@ -629,6 +630,7 @@ name = "fparkan-runtime"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"fparkan-assets", "fparkan-assets",
"fparkan-msh",
"fparkan-path", "fparkan-path",
"fparkan-platform", "fparkan-platform",
"fparkan-prototype", "fparkan-prototype",
+4 -1
View File
@@ -53,7 +53,7 @@ use fparkan_render_vulkan::{
}; };
use fparkan_runtime::{ use fparkan_runtime::{
create, load_mission_static_preview, load_mission_static_preview_roots, loaded_mission_assets, create, load_mission_static_preview, load_mission_static_preview_roots, loaded_mission_assets,
loaded_mission_object_drafts, loaded_terrain, EngineServices, MissionAssets, loaded_mission_object_drafts, loaded_terrain, EngineServices, MissionAssets, MissionMode,
MissionObjectDraft, MissionRequest, MissionObjectDraft, MissionRequest,
}; };
use fparkan_terrain::{ use fparkan_terrain::{
@@ -173,6 +173,7 @@ fn load_requested_mission(
) -> Result<fparkan_runtime::LoadedMission, String> { ) -> Result<fparkan_runtime::LoadedMission, String> {
let request = MissionRequest { let request = MissionRequest {
key: args.mission.clone(), key: args.mission.clone(),
mode: MissionMode::SingleMission,
}; };
if args.preview_roots.get() == 1 { if args.preview_roots.get() == 1 {
load_mission_static_preview(engine, request) load_mission_static_preview(engine, request)
@@ -7409,6 +7410,7 @@ mod tests {
type_id: None, type_id: None,
}, },
model_node_count: 1, model_node_count: 1,
native_profile_words: [0; 27],
bindings: vec![], bindings: vec![],
timeline_rows: vec![fparkan_assets::PreparedControlTimelineRow { timeline_rows: vec![fparkan_assets::PreparedControlTimelineRow {
row_index: 0, row_index: 0,
@@ -8090,6 +8092,7 @@ mod tests {
&mut engine, &mut engine,
MissionRequest { MissionRequest {
key: mission.clone(), key: mission.clone(),
mode: MissionMode::SingleMission,
}, },
NonZeroUsize::MAX, NonZeroUsize::MAX,
) )
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-binary = { path = "../fparkan-binary" }
fparkan-material = { path = "../fparkan-material" } fparkan-material = { path = "../fparkan-material" }
fparkan-msh = { path = "../fparkan-msh" } fparkan-msh = { path = "../fparkan-msh" }
fparkan-nres = { path = "../fparkan-nres" } fparkan-nres = { path = "../fparkan-nres" }
+684 -1
View File
@@ -1,6 +1,7 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
//! Asset manager ports and transactional preparation models. //! Asset manager ports and transactional preparation models.
use fparkan_binary::{checked_count_bytes, read_lp_bytes, Cursor, DecodeError};
use fparkan_material::{ use fparkan_material::{
decode_mat0, decode_wear, resolve_material, MaterialFallback, ResolvedMaterial, WearTable, decode_mat0, decode_wear, resolve_material, MaterialFallback, ResolvedMaterial, WearTable,
MAT0_KIND, WEAR_KIND, MAT0_KIND, WEAR_KIND,
@@ -62,6 +63,150 @@ pub struct PreparedMaterialPhase {
pub page_uv_transform: [f32; 4], pub page_uv_transform: [f32; 4],
} }
/// The two Behavior profile values currently used by recovered resource queries.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BehaviorProfileRates {
/// Second raw value blob for `Transfer_Ore_OffBoard`.
pub transfer_ore_off_board_bits: u32,
/// Second raw value blob for `Transfer_Power_Out`.
pub transfer_power_out_bits: u32,
}
/// Error while reading a bounded binary Behavior `.var` profile.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum BehaviorProfileVarError {
/// The binary record framing is malformed.
Decode(DecodeError),
/// A bound for one of the two fields used here is not a 32-bit float.
InvalidFloatField {
name: &'static str,
size: u32,
type_index: u32,
},
/// A registered runtime field was absent from the profile.
MissingField { name: &'static str },
}
impl fmt::Display for BehaviorProfileVarError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Decode(source) => write!(f, "{source}"),
Self::InvalidFloatField {
name,
size,
type_index,
} => write!(
f,
"field {name} has size {size} and type index {type_index}, expected a 4-byte float"
),
Self::MissingField { name } => write!(f, "required field {name} is missing"),
}
}
}
impl std::error::Error for BehaviorProfileVarError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Decode(source) => Some(source),
Self::InvalidFloatField { .. } | Self::MissingField { .. } => None,
}
}
}
impl From<DecodeError> for BehaviorProfileVarError {
fn from(value: DecodeError) -> Self {
Self::Decode(value)
}
}
/// Reads the two Behavior rate fields from a native binary `.var` profile.
///
/// Native records contain two equally sized value blobs. After binding a
/// registered destination, the original applies the second blob. Field names
/// are matched case-sensitively as C strings (up to the first NUL), and the
/// first occurrence wins. Other fields are consumed but their meanings remain
/// outside this decoder.
///
/// # Errors
///
/// Returns [`BehaviorProfileVarError`] when record framing is truncated,
/// contains trailing bytes, omits a registered field, or declares a selected
/// field that is not a float row.
pub fn decode_behavior_profile_rates(
bytes: &[u8],
) -> Result<BehaviorProfileRates, BehaviorProfileVarError> {
let mut cursor = Cursor::new(bytes);
let count = cursor.read_u32_le()?;
checked_count_bytes(u64::from(count), 28, cursor.remaining() as u64)?;
let mut transfer_ore_off_board_bits = None;
let mut transfer_power_out_bits = None;
for _ in 0..count {
let size = cursor.read_u32_le()?;
let type_index = cursor.read_u32_le()?;
let _flags = cursor.read_u32_le()?;
let field_name = read_lp_bytes(&mut cursor, 64 * 1024)?;
let c_string_name = field_name
.split(|byte| *byte == 0)
.next()
.unwrap_or_default();
let target_name = match c_string_name {
b"Transfer_Ore_OffBoard" => Some("Transfer_Ore_OffBoard"),
b"Transfer_Power_Out" => Some("Transfer_Power_Out"),
_ => None,
};
let _type_name = read_lp_bytes(&mut cursor, 64 * 1024)?;
let value_size = usize::try_from(size).map_err(|_| DecodeError::IntegerOverflow)?;
cursor.read_exact(value_size)?;
let capture_target = target_name.is_some_and(|name| {
if name == "Transfer_Ore_OffBoard" {
transfer_ore_off_board_bits.is_none()
} else {
transfer_power_out_bits.is_none()
}
});
let second_value_bits = if capture_target {
let name = target_name.expect("capture requires a target field");
if size != 4 || type_index != 3 {
return Err(BehaviorProfileVarError::InvalidFloatField {
name,
size,
type_index,
});
}
let value = cursor.read_exact(value_size)?;
Some(u32::from_le_bytes(
value.try_into().expect("validated four-byte float row"),
))
} else {
cursor.read_exact(value_size)?;
None
};
cursor.read_exact(8)?;
if let (Some(name), Some(value_bits)) = (target_name, second_value_bits) {
if name == "Transfer_Ore_OffBoard" {
transfer_ore_off_board_bits = Some(value_bits);
} else {
transfer_power_out_bits = Some(value_bits);
}
}
}
cursor.require_eof()?;
Ok(BehaviorProfileRates {
transfer_ore_off_board_bits: transfer_ore_off_board_bits.ok_or(
BehaviorProfileVarError::MissingField {
name: "Transfer_Ore_OffBoard",
},
)?,
transfer_power_out_bits: transfer_power_out_bits.ok_or(
BehaviorProfileVarError::MissingField {
name: "Transfer_Power_Out",
},
)?,
})
}
type WearValidationCache = type WearValidationCache =
HashMap<(NormalizedPath, Vec<u8>), Result<fparkan_material::WearTable, String>>; HashMap<(NormalizedPath, Vec<u8>), Result<fparkan_material::WearTable, String>>;
@@ -936,6 +1081,8 @@ pub struct PreparedVisual {
pub struct PreparedComponentControl { pub struct PreparedComponentControl {
/// CTLD resource that supplied these bindings. /// CTLD resource that supplied these bindings.
pub source: ResourceKey, pub source: ResourceKey,
/// The 27 raw native owner-profile words at CTLD bytes 20..128.
pub native_profile_words: [u32; 27],
/// Number of nodes in the component model this control targets. /// Number of nodes in the component model this control targets.
pub model_node_count: usize, pub model_node_count: usize,
/// Enabled source rows in their original order, including duplicate nodes. /// Enabled source rows in their original order, including duplicate nodes.
@@ -1180,6 +1327,19 @@ impl Default for ControlTag21InstanceState {
/// Input or metadata prevented a safe native control sample. /// Input or metadata prevented a safe native control sample.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ControlSamplingError { pub enum ControlSamplingError {
/// This resource-device helper only implements typed-record tag 26.
UnsupportedResourceDeviceTag { actual: u32 },
/// The record's raw provider key has no resolved NDP table.
MissingResourceDeviceNodeData { key: u32 },
/// The record's raw provider key is not a row in its NDP table.
ResourceDeviceNodeDataKeyOutOfRange { key: u32, row_count: usize },
/// The caller did not supply the provider's current type-1 value.
MissingResourceDeviceProviderValue { key: u32 },
/// A coefficient is outside the helper's finite normal-or-zero contract.
InvalidResourceDeviceOperand { operand: &'static str },
/// The native x87 intermediate is outside the range represented by normal
/// f32 values, so the Rust result could differ from the original.
UnsupportedResourceDeviceFloatRange { stage: &'static str },
/// A state object was reused with a different model node count. /// A state object was reused with a different model node count.
StateNodeCountMismatch { expected: usize, actual: usize }, StateNodeCountMismatch { expected: usize, actual: usize },
/// A state object was reused with a different number of tag-21 records. /// A state object was reused with a different number of tag-21 records.
@@ -1216,6 +1376,74 @@ pub enum ControlSamplingError {
NonFiniteSample { record_index: usize }, NonFiniteSample { record_index: usize },
} }
/// Samples Control's tag-26 typed-record query `0x300` for one current state.
///
/// The key in fixed word 1 must select an existing NDP row. The caller supplies
/// that provider's current type-1 value and the object's current multiplier;
/// this function does not invent their runtime state. Fixed word 11 is the
/// coefficient loaded by selector `0x300`. Native code multiplies in the order
/// `provider_now * coefficient * multiplier_now`.
///
/// The source x87 code returns a value with an extended exponent range even
/// though it uses 24-bit precision. This f32 helper therefore accepts only
/// finite normal-or-zero operands and normal-or-exact-zero intermediate and
/// final products. It rejects subnormal or out-of-f32-range products rather
/// than silently underflowing or overflowing differently from x87.
pub fn sample_control_resource_device_selector_300(
record: &PreparedControlTypedRecord,
node_data: Option<&PreparedControlNodeDataTable>,
provider_now: Option<f32>,
multiplier_now: f32,
) -> Result<f32, ControlSamplingError> {
if record.tag != 26 {
return Err(ControlSamplingError::UnsupportedResourceDeviceTag { actual: record.tag });
}
let key = record.fixed_words[1];
let Some(node_data) = node_data else {
return Err(ControlSamplingError::MissingResourceDeviceNodeData { key });
};
if !usize::try_from(key).is_ok_and(|key| key < node_data.rows.len()) {
return Err(ControlSamplingError::ResourceDeviceNodeDataKeyOutOfRange {
key,
row_count: node_data.rows.len(),
});
}
let provider_now =
provider_now.ok_or(ControlSamplingError::MissingResourceDeviceProviderValue { key })?;
let base = f32::from_bits(record.fixed_words[11]);
for (operand, value) in [
("provider", provider_now),
("base coefficient", base),
("multiplier", multiplier_now),
] {
if !value.is_finite() || (value != 0.0 && !value.is_normal()) {
return Err(ControlSamplingError::InvalidResourceDeviceOperand { operand });
}
}
let provider_times_base =
native_range_compatible_product(provider_now, base, "provider times base")?;
native_range_compatible_product(provider_times_base, multiplier_now, "then multiplier")
}
fn native_range_compatible_product(
lhs: f32,
rhs: f32,
stage: &'static str,
) -> Result<f32, ControlSamplingError> {
let exact = f64::from(lhs) * f64::from(rhs);
let absolute = exact.abs();
let minimum_normal = f64::from(f32::MIN_POSITIVE);
let maximum = f64::from(f32::MAX);
if exact != 0.0 && (absolute < minimum_normal || absolute > maximum) {
return Err(ControlSamplingError::UnsupportedResourceDeviceFloatRange { stage });
}
Ok(lhs * rhs)
}
/// Samples per-node animation times using the decoded Control timeline. /// Samples per-node animation times using the decoded Control timeline.
/// ///
/// `native_clock_ms` is the absolute u32 millisecond clock passed to native /// `native_clock_ms` is the absolute u32 millisecond clock passed to native
@@ -3637,7 +3865,8 @@ fn control_i32_at(payload: &[u8], offset: usize) -> Option<i32> {
)) ))
} }
const CONTROL_TYPED_COMMON_PARSER_TAGS: &[u32] = &[1, 2, 5, 8, 9, 12, 13, 15, 19, 21, 25, 26]; const CONTROL_TYPED_COMMON_PARSER_TAGS: &[u32] =
&[1, 2, 3, 4, 5, 8, 9, 10, 12, 13, 15, 19, 21, 25, 26, 27];
const CONTROL_TYPED_RECORD_FIXED_BYTES: usize = 0xB0; const CONTROL_TYPED_RECORD_FIXED_BYTES: usize = 0xB0;
const CONTROL_TYPED_RECORD_COUNT_OFFSET: usize = 0xAC; const CONTROL_TYPED_RECORD_COUNT_OFFSET: usize = 0xAC;
const CONTROL_TYPED_RECORD_LIMIT: usize = 4096; const CONTROL_TYPED_RECORD_LIMIT: usize = 4096;
@@ -3964,6 +4193,19 @@ fn decode_component_control_payload(
return Err(invalid("complete binding table extends past the payload")); return Err(invalid("complete binding table extends past the payload"));
} }
let profile_bytes = payload
.get(HEADER_BYTES..HEADER_BYTES + FIXED_SECTION_BYTES)
.ok_or_else(|| invalid("fixed owner profile extends past the payload"))?;
let mut native_profile_words = [0_u32; FIXED_SECTION_BYTES / 4];
for (index, word) in native_profile_words.iter_mut().enumerate() {
let offset = index * 4;
*word = u32::from_le_bytes(
profile_bytes[offset..offset + 4]
.try_into()
.expect("profile word is exactly four bytes"),
);
}
let mut bindings = Vec::with_capacity(row_count); let mut bindings = Vec::with_capacity(row_count);
let mut timeline_rows = Vec::with_capacity(row_count); let mut timeline_rows = Vec::with_capacity(row_count);
for row_index in 0..row_count { for row_index in 0..row_count {
@@ -4026,6 +4268,7 @@ fn decode_component_control_payload(
Ok(PreparedComponentControl { Ok(PreparedComponentControl {
source, source,
native_profile_words,
model_node_count, model_node_count,
bindings, bindings,
timeline_rows, timeline_rows,
@@ -4141,6 +4384,297 @@ mod tests {
// Preserve the authored CTLD fixture bits; this is not the `f32` value of tau. // Preserve the authored CTLD fixture bits; this is not the `f32` value of tau.
const AUTHORED_TAG21_RAW_PARAMETER_3: f32 = f32::from_bits(0x40C8_F5C3); const AUTHORED_TAG21_RAW_PARAMETER_3: f32 = f32::from_bits(0x40C8_F5C3);
fn behavior_var_row(name: &[u8], type_index: u32, first: u32, second: u32) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(&4_u32.to_le_bytes());
bytes.extend_from_slice(&type_index.to_le_bytes());
bytes.extend_from_slice(&0_u32.to_le_bytes());
bytes.extend_from_slice(&(name.len() as u32).to_le_bytes());
bytes.extend_from_slice(name);
bytes.extend_from_slice(&0_u32.to_le_bytes()); // Native transfer rows use an empty type-name.
bytes.extend_from_slice(&first.to_le_bytes());
bytes.extend_from_slice(&second.to_le_bytes());
bytes.extend_from_slice(&0_u32.to_le_bytes());
bytes.extend_from_slice(&0_u32.to_le_bytes());
bytes
}
fn behavior_var(rows: &[Vec<u8>]) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(&(rows.len() as u32).to_le_bytes());
for row in rows {
bytes.extend_from_slice(row);
}
bytes
}
#[test]
fn behavior_profile_decoder_uses_first_exact_rows_second_blob_and_float_kind() {
let profile = behavior_var(&[
behavior_var_row(b"transfer_ore_off_board", 3, 0x1111_1111, 0x2222_2222),
behavior_var_row(
b"Transfer_Ore_OffBoard\0legacy-suffix",
3,
0x3333_3333,
0x3f80_0000,
),
behavior_var_row(b"Transfer_Ore_OffBoard", 3, 0x4444_4444, 0x4000_0000),
behavior_var_row(b"Transfer_Power_Out", 3, 0x5555_5555, 0x4120_0000),
]);
assert_eq!(
decode_behavior_profile_rates(&profile),
Ok(BehaviorProfileRates {
transfer_ore_off_board_bits: 0x3f80_0000,
transfer_power_out_bits: 0x4120_0000,
})
);
}
#[test]
fn behavior_profile_decoder_rejects_every_truncation_and_trailing_bytes() {
let valid = behavior_var(&[
behavior_var_row(b"Transfer_Ore_OffBoard", 3, 0, 0x3f80_0000),
behavior_var_row(b"Transfer_Power_Out", 3, 0, 0x4120_0000),
]);
for end in 0..valid.len() {
assert!(
decode_behavior_profile_rates(&valid[..end]).is_err(),
"prefix ending at byte {end} was accepted"
);
}
let mut with_trailing = valid.clone();
with_trailing.push(0);
assert!(matches!(
decode_behavior_profile_rates(&with_trailing),
Err(BehaviorProfileVarError::Decode(
fparkan_binary::DecodeError::TrailingBytes { .. }
))
));
}
#[test]
fn behavior_profile_decoder_requires_both_registered_float_fields() {
let profile = behavior_var(&[behavior_var_row(b"Transfer_Power_Out", 3, 0, 0x4120_0000)]);
assert_eq!(
decode_behavior_profile_rates(&profile),
Err(BehaviorProfileVarError::MissingField {
name: "Transfer_Ore_OffBoard"
})
);
let profile = behavior_var(&[
behavior_var_row(b"Transfer_Ore_OffBoard", 3, 0, 0x3f80_0000),
behavior_var_row(b"Transfer_Power_Out", 1, 0, 0x4120_0000),
]);
assert!(matches!(
decode_behavior_profile_rates(&profile),
Err(BehaviorProfileVarError::InvalidFloatField {
name: "Transfer_Power_Out",
size: 4,
type_index: 1,
})
));
}
fn resource_device_record(tag: u32, key: u32, base_bits: u32) -> PreparedControlTypedRecord {
let mut fixed_words = [0; 44];
fixed_words[1] = key;
fixed_words[11] = base_bits;
PreparedControlTypedRecord {
tag,
fixed_words,
values: Vec::new(),
string_bytes: Vec::new(),
gate_provider: PreparedControlGateProvider::NotApplicable,
}
}
fn resource_device_node_data_table(row_count: usize) -> PreparedControlNodeDataTable {
PreparedControlNodeDataTable {
source: ResourceKey {
archive: parse_path("parts.rlb").expect("NDP archive"),
name: resource_name(b"resource-device.ndp"),
type_id: None,
},
rows: vec![
PreparedControlNodeDataRow {
raw_flags: 0,
max_volume: 0.0,
mobility_per_volume: 0.0,
effect: PreparedControlResourcePair {
archive: [0; 32],
name: [0; 32],
},
};
row_count
],
}
}
#[test]
fn tag26_selector_300_multiplies_provider_then_fixed_base_then_multiplier() {
let record = resource_device_record(26, 1, 0x3D25_AE4A);
let node_data = resource_device_node_data_table(2);
let provider_now = f32::from_bits(0x3FE6_982F);
let multiplier_now = f32::from_bits(0x4071_5539);
let sampled = sample_control_resource_device_selector_300(
&record,
Some(&node_data),
Some(provider_now),
multiplier_now,
)
.expect("valid tag-26 resource-device sample");
assert_eq!(sampled.to_bits(), 0x3E8C_B02E);
assert_ne!(
sampled.to_bits(),
((provider_now * multiplier_now) * f32::from_bits(record.fixed_words[11])).to_bits(),
"the original selector multiplies the provider by the record base first"
);
}
#[test]
fn tag26_selector_300_requires_its_ndp_row_and_explicit_provider() {
let record = resource_device_record(26, 1, 2.0_f32.to_bits());
let one_row = resource_device_node_data_table(1);
assert_eq!(
sample_control_resource_device_selector_300(&record, Some(&one_row), Some(3.0), 4.0,),
Err(ControlSamplingError::ResourceDeviceNodeDataKeyOutOfRange {
key: 1,
row_count: 1,
})
);
let two_rows = resource_device_node_data_table(2);
assert_eq!(
sample_control_resource_device_selector_300(&record, Some(&two_rows), None, 4.0),
Err(ControlSamplingError::MissingResourceDeviceProviderValue { key: 1 })
);
assert_eq!(
sample_control_resource_device_selector_300(&record, None, Some(3.0), 4.0),
Err(ControlSamplingError::MissingResourceDeviceNodeData { key: 1 })
);
}
#[test]
fn tag26_selector_300_rejects_other_tags_and_extended_x87_ranges() {
let node_data = resource_device_node_data_table(1);
let tag21 = resource_device_record(21, 0, 1.0_f32.to_bits());
assert_eq!(
sample_control_resource_device_selector_300(&tag21, Some(&node_data), Some(1.0), 1.0),
Err(ControlSamplingError::UnsupportedResourceDeviceTag { actual: 21 })
);
let tag26 = resource_device_record(26, 0, f32::MIN_POSITIVE.to_bits());
assert_eq!(
sample_control_resource_device_selector_300(
&tag26,
Some(&node_data),
Some(f32::MIN_POSITIVE),
1.0,
),
Err(ControlSamplingError::UnsupportedResourceDeviceFloatRange {
stage: "provider times base",
})
);
}
#[test]
fn tag26_selector_300_preserves_negative_and_signed_zero_values() {
let node_data = resource_device_node_data_table(1);
let record = resource_device_record(26, 0, 0.5_f32.to_bits());
let negative =
sample_control_resource_device_selector_300(&record, Some(&node_data), Some(-2.0), 4.0)
.expect("negative factors are not clamped");
assert_eq!(negative, -4.0);
let negative_zero = sample_control_resource_device_selector_300(
&resource_device_record(26, 0, 2.0_f32.to_bits()),
Some(&node_data),
Some(-0.0),
3.0,
)
.expect("signed zero is an exact native input");
assert_eq!(negative_zero.to_bits(), (-0.0_f32).to_bits());
let positive_zero = sample_control_resource_device_selector_300(
&resource_device_record(26, 0, 2.0_f32.to_bits()),
Some(&node_data),
Some(0.0),
3.0,
)
.expect("positive zero is an exact native input");
assert_eq!(positive_zero.to_bits(), 0.0_f32.to_bits());
}
#[test]
fn tag26_selector_300_rejects_nonfinite_and_subnormal_operands() {
let node_data = resource_device_node_data_table(1);
for (operand, provider_now, multiplier_now, base_bits) in [
("provider", f32::NAN, 1.0, 1.0_f32.to_bits()),
("multiplier", 1.0, f32::INFINITY, 1.0_f32.to_bits()),
("base coefficient", 1.0, 1.0, 1),
] {
let record = resource_device_record(26, 0, base_bits);
assert_eq!(
sample_control_resource_device_selector_300(
&record,
Some(&node_data),
Some(provider_now),
multiplier_now,
),
Err(ControlSamplingError::InvalidResourceDeviceOperand { operand })
);
}
}
#[test]
fn tag26_selector_300_rejects_extended_intermediate_and_final_ranges() {
let node_data = resource_device_node_data_table(1);
let provider = f32::MAX;
let overflowing_intermediate = resource_device_record(26, 0, 2.0_f32.to_bits());
assert_eq!(
sample_control_resource_device_selector_300(
&overflowing_intermediate,
Some(&node_data),
Some(provider),
0.25,
),
Err(ControlSamplingError::UnsupportedResourceDeviceFloatRange {
stage: "provider times base",
})
);
let underflowing_intermediate = resource_device_record(26, 0, f32::MIN_POSITIVE.to_bits());
assert_eq!(
sample_control_resource_device_selector_300(
&underflowing_intermediate,
Some(&node_data),
Some(f32::MIN_POSITIVE),
f32::MAX,
),
Err(ControlSamplingError::UnsupportedResourceDeviceFloatRange {
stage: "provider times base",
})
);
let subnormal_final = resource_device_record(26, 0, 1.0_f32.to_bits());
assert_eq!(
sample_control_resource_device_selector_300(
&subnormal_final,
Some(&node_data),
Some(f32::MIN_POSITIVE),
0.5,
),
Err(ControlSamplingError::UnsupportedResourceDeviceFloatRange {
stage: "then multiplier",
})
);
}
fn tag21_fullcycle_control() -> PreparedComponentControl { fn tag21_fullcycle_control() -> PreparedComponentControl {
let source = ResourceKey { let source = ResourceKey {
archive: parse_path("parts.rlb").expect("archive"), archive: parse_path("parts.rlb").expect("archive"),
@@ -4322,6 +4856,66 @@ mod tests {
assert_eq!(control.bindings[1].flags, 1); assert_eq!(control.bindings[1].flags, 1);
} }
#[test]
fn component_control_preserves_raw_profile_between_header_and_sections() {
const PROFILE_START: usize = 20;
const PROFILE_WORDS: usize = 27;
const SECTION_BYTES: usize = 156 + 16;
const MATRIX_BYTES: usize = 4;
const ROW_START: usize = PROFILE_START + PROFILE_WORDS * 4 + SECTION_BYTES + MATRIX_BYTES;
const ROW_BYTES: usize = 36;
let source = ResourceKey {
archive: parse_path("controls.rlb").expect("archive"),
name: resource_name(b"profile.ctl"),
type_id: None,
};
let mut payload = vec![0; ROW_START + ROW_BYTES];
payload[0..4].copy_from_slice(&1_u32.to_le_bytes()); // one section
payload[4..8].copy_from_slice(&1_u32.to_le_bytes()); // one node per section
payload[8..12].copy_from_slice(&1_u32.to_le_bytes()); // one binding row
let profile = std::array::from_fn::<_, PROFILE_WORDS, _>(|index| match index {
0 => 0xDEAD_BEEF,
1 => 0x7FC0_1234,
index => 0xA500_0000 | u32::try_from(index).expect("index fits u32"),
});
for (index, word) in profile.iter().enumerate() {
let start = PROFILE_START + index * 4;
payload[start..start + 4].copy_from_slice(&word.to_le_bytes());
}
payload[ROW_START..ROW_START + 4].copy_from_slice(&1_u32.to_le_bytes());
payload[ROW_START + 4..ROW_START + 8].copy_from_slice(&1.25_f32.to_bits().to_le_bytes());
payload[ROW_START + 8..ROW_START + 12].copy_from_slice(&2.5_f32.to_bits().to_le_bytes());
payload[ROW_START + 12..ROW_START + 16].copy_from_slice(&0.75_f32.to_bits().to_le_bytes());
payload[ROW_START + 0x20..ROW_START + 0x24].copy_from_slice(&1_u32.to_le_bytes());
let control =
decode_component_control_payload(&payload, source, 2, 0).expect("profile CTLD");
assert_eq!(control.native_profile_words, profile);
assert_eq!(control.timeline_rows.len(), 1);
assert_eq!(control.timeline_rows[0].node_index, 1);
assert_eq!(
control.timeline_rows[0].frame_a.to_bits(),
1.25_f32.to_bits()
);
assert_eq!(
control.timeline_rows[0].frame_b.to_bits(),
2.5_f32.to_bits()
);
}
#[test]
fn component_control_rejects_truncated_native_profile() {
let source = ResourceKey {
archive: parse_path("controls.rlb").expect("archive"),
name: resource_name(b"truncated-profile.ctl"),
type_id: None,
};
assert!(decode_component_control_payload(&vec![0; 127], source, 0, 0).is_err());
}
#[test] #[test]
fn component_control_rejects_bad_bounds_but_keeps_sampler_inputs_raw() { fn component_control_rejects_bad_bounds_but_keeps_sampler_inputs_raw() {
let source = ResourceKey { let source = ResourceKey {
@@ -4433,6 +5027,41 @@ mod tests {
); );
} }
#[test]
fn component_control_extended_common_tags_preserve_variable_tails_and_next_record() {
let source = ResourceKey {
archive: parse_path("controls.rlb").unwrap(),
name: resource_name(b"parts.ctl"),
type_id: None,
};
let mut payload = component_control_payload(&[(1, 0.0, 1.0, 0.25, 0)]);
payload[12..16].copy_from_slice(&4_u32.to_le_bytes());
for tag in [3_u32, 4, 10, 27] {
let start = payload.len();
append_typed_record(&mut payload, tag, 2);
payload[start + 0xac..start + 0xb0].copy_from_slice(&2_u32.to_le_bytes());
let values = [tag.to_le_bytes(), (tag + 1).to_le_bytes()].concat();
payload.splice(start + 0xb0..start + 0xb0, values);
payload.extend_from_slice(b"fx\0");
}
let control = decode_component_control_payload(&payload, source, 2, 0).unwrap();
assert_eq!(control.typed_records.len(), 4);
for (record, tag) in control.typed_records.iter().zip([3, 4, 10, 27]) {
assert_eq!(record.tag, tag);
assert_eq!(record.values, [tag, tag + 1]);
assert_eq!(record.string_bytes, b"fx");
}
assert_eq!(
control.effect_section,
PreparedControlEffectSection::Parsed(Vec::new())
);
payload.pop();
assert!(
decode_component_control_payload(&payload, control.source, 2, 0).is_err(),
"recognized final record must validate its string terminator"
);
}
#[test] #[test]
fn component_control_malformed_recognized_typed_tail_is_rejected() { fn component_control_malformed_recognized_typed_tail_is_rejected() {
let source = ResourceKey { let source = ResourceKey {
@@ -6164,6 +6793,60 @@ mod tests {
assert_eq!(plan.lightmap_count, 0); assert_eq!(plan.lightmap_count, 0);
} }
#[test]
#[ignore = "requires licensed corpus"]
fn tag26_selector_300_uses_a_real_control_record_and_ndp_key() {
let root = fixture_root("IS");
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
let repository = CachedResourceRepository::new(Arc::clone(&vfs));
let roots = [resource_name(b"UNITS/BUILDS/MINE/min_l_n1.dat")];
let (_graph, prototypes) =
build_prototype_graph(&repository, vfs.as_ref(), &roots).expect("prototype graph");
let assets = prepare_mission_assets_with_repository(
&repository,
&[0..prototypes.len()],
&prototypes,
)
.expect("prepared licensed unit controls");
let mut sampled_records = 0;
for control in assets.object_component_controls.iter().flatten().flatten() {
for record in control
.typed_records
.iter()
.filter(|record| record.tag == 26)
{
let node_data = control
.node_data
.as_ref()
.expect("tag-26 control has its explicit NDP table");
assert_eq!(control.source.name.0, b"fr_l_mine.ctl");
assert_eq!(node_data.source.name.0, b"fr_l_mine.ndp");
assert_eq!(node_data.rows.len(), 22);
assert_eq!(record.fixed_words[1], 2);
assert_eq!(record.fixed_words[11], 0x3F80_0000);
let sample = sample_control_resource_device_selector_300(
record,
Some(node_data),
Some(1.0),
1.0,
)
.expect("real tag-26 key selects an NDP row");
assert_eq!(sample.to_bits(), record.fixed_words[11]);
println!(
"control={}: ndp={}: key={} rows={} coefficient=0x{:08x}",
String::from_utf8_lossy(&control.source.name.0),
String::from_utf8_lossy(&node_data.source.name.0),
record.fixed_words[1],
node_data.rows.len(),
record.fixed_words[11],
);
sampled_records += 1;
}
}
assert_eq!(sampled_records, 1);
}
#[test] #[test]
#[ignore = "requires licensed corpus"] #[ignore = "requires licensed corpus"]
fn repository_plan_deduplicates_duplicate_visuals_but_graph_preserves_requests() { fn repository_plan_deduplicates_duplicate_visuals_but_graph_preserves_requests() {
+9 -3
View File
@@ -140,7 +140,10 @@ pub struct Node {
/// Raw fields of the standard 38-byte node layout that precede slot selection. /// Raw fields of the standard 38-byte node layout that precede slot selection.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Node38Metadata { pub struct Node38Metadata {
/// Unassigned source link value at byte offset two. /// Raw Node38 header/flag word at byte offset zero.
pub header_flags: u16,
/// Source-local parent word at byte offset two; `0xFFFF` is the native
/// sentinel before component rebasing.
pub parent_or_link_raw: u16, pub parent_or_link_raw: u16,
/// Offset into the type-19 frame map, or `0xFFFF` for no map. /// Offset into the type-19 frame map, or `0xFFFF` for no map.
pub anim_map_start: u16, pub anim_map_start: u16,
@@ -431,6 +434,7 @@ pub fn node38_metadata(model: &ModelAsset, node: NodeId) -> Option<Node38Metadat
} }
let offset = node_index.checked_mul(model.node_stride)?; let offset = node_index.checked_mul(model.node_stride)?;
Some(Node38Metadata { Some(Node38Metadata {
header_flags: read_u16(&model.nodes_raw, offset)?,
parent_or_link_raw: read_u16(&model.nodes_raw, offset + 2)?, parent_or_link_raw: read_u16(&model.nodes_raw, offset + 2)?,
anim_map_start: read_u16(&model.nodes_raw, offset + 4)?, anim_map_start: read_u16(&model.nodes_raw, offset + 4)?,
fallback_key: read_u16(&model.nodes_raw, offset + 6)?, fallback_key: read_u16(&model.nodes_raw, offset + 6)?,
@@ -441,8 +445,8 @@ pub fn node38_metadata(model: &ModelAsset, node: NodeId) -> Option<Node38Metadat
/// ///
/// The legacy sampler uses this key when a node has no frame map or its current /// The legacy sampler uses this key when a node has no frame map or its current
/// frame falls outside that map. It is a static-pose input only; callers must /// frame falls outside that map. It is a static-pose input only; callers must
/// still recover animation state and the meaning of `parent_or_link_raw` before /// assemble the runtime hierarchy separately and apply the component-specific
/// assembling a runtime hierarchy. Its quaternion is converted to match native /// mapping of `parent_or_link_raw`. Its quaternion is converted to match native
/// AniMesh's quaternion-to-matrix convention. /// AniMesh's quaternion-to-matrix convention.
#[must_use] #[must_use]
pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> { pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> {
@@ -1412,6 +1416,7 @@ mod tests {
#[test] #[test]
fn standard_node_exposes_fallback_pose_and_unassigned_link() { fn standard_node_exposes_fallback_pose_and_unassigned_link() {
let mut node = node38([u16::MAX; 15]); let mut node = node38([u16::MAX; 15]);
node[0..2].copy_from_slice(&0x8201_u16.to_le_bytes());
node[2..4].copy_from_slice(&7_u16.to_le_bytes()); node[2..4].copy_from_slice(&7_u16.to_le_bytes());
node[4..6].copy_from_slice(&u16::MAX.to_le_bytes()); node[4..6].copy_from_slice(&u16::MAX.to_le_bytes());
node[6..8].copy_from_slice(&0_u16.to_le_bytes()); node[6..8].copy_from_slice(&0_u16.to_le_bytes());
@@ -1440,6 +1445,7 @@ mod tests {
assert_eq!( assert_eq!(
node38_metadata(&model, NodeId(0)), node38_metadata(&model, NodeId(0)),
Some(Node38Metadata { Some(Node38Metadata {
header_flags: 0x8201,
parent_or_link_raw: 7, parent_or_link_raw: 7,
anim_map_start: u16::MAX, anim_map_start: u16::MAX,
fallback_key: 0, fallback_key: 0,
+1
View File
@@ -7,6 +7,7 @@ repository.workspace = true
[dependencies] [dependencies]
fparkan-assets = { path = "../fparkan-assets" } fparkan-assets = { path = "../fparkan-assets" }
fparkan-msh = { path = "../fparkan-msh" }
fparkan-path = { path = "../fparkan-path" } fparkan-path = { path = "../fparkan-path" }
fparkan-platform = { path = "../fparkan-platform" } fparkan-platform = { path = "../fparkan-platform" }
fparkan-prototype = { path = "../fparkan-prototype" } fparkan-prototype = { path = "../fparkan-prototype" }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,164 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
internal static class BehaviorWarmStartOracle {
private const uint LoadWithAlteredSearchPath = 0x00000008;
private const string ExpectedBehaviorSha256 = "ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99";
private const int WarmStartRva = 0x1adb0;
private const int GetQueueSlotRva = 0x59110;
private const int RandomStateRva = 0x63c1c;
private static uint currentQueueTime;
private static IntPtr queue;
private static bool queuePointerMatched;
private static QueueGetter queueGetter = GetCurrentQueue;
private static QueueTimeGetter queueTimeGetter = ReadQueueTime;
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr LoadLibraryEx(string fileName, IntPtr file, uint flags);
[DllImport("kernel32.dll", SetLastError = true)] private static extern bool FreeLibrary(IntPtr module);
[DllImport("kernel32.dll", SetLastError = true)] private static extern bool VirtualProtect(IntPtr address, UIntPtr size, uint protect, out uint oldProtect);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate IntPtr QueueGetter();
[UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate uint QueueTimeGetter(IntPtr self);
[UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate void WarmStart(IntPtr self);
private static IntPtr GetCurrentQueue() { return queue; }
private static uint ReadQueueTime(IntPtr self) { queuePointerMatched &= self == queue; return currentQueueTime; }
private static int Main() {
if (IntPtr.Size != 4) { Console.Error.WriteLine("Build with /platform:x86."); return 2; }
string path = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_BEHAVIOR_DLL");
if (String.IsNullOrEmpty(path)) path = @"C:\GOG Games\Parkan - Iron Strategy\Behavior.dll";
if (!File.Exists(path)) { Console.Error.WriteLine("Set FPARKAN_ORIGINAL_BEHAVIOR_DLL to the installed Behavior.dll."); return 3; }
string hash = Hash(path);
if (hash != ExpectedBehaviorSha256) { Console.Error.WriteLine("Unexpected Behavior.dll SHA-256: " + hash); return 4; }
IntPtr module = LoadLibraryEx(path, IntPtr.Zero, LoadWithAlteredSearchPath);
if (module == IntPtr.Zero) { Console.Error.WriteLine("LoadLibraryEx(Behavior.dll) failed: " + Marshal.GetLastWin32Error()); return 5; }
IntPtr slot = IntPtr.Add(module, GetQueueSlotRva);
IntPtr oldQueueGetter = Marshal.ReadIntPtr(slot);
int oldRandomValue = Marshal.ReadInt32(IntPtr.Add(module, RandomStateRva));
IntPtr queueVtable = Marshal.AllocHGlobal(0x40);
queue = Marshal.AllocHGlobal(0x80);
IntPtr record = Marshal.AllocHGlobal(0x44);
IntPtr secondRecord = Marshal.AllocHGlobal(0x44);
IntPtr zeroClockRecord = Marshal.AllocHGlobal(0x44);
IntPtr wrappingClockRecord = Marshal.AllocHGlobal(0x44);
uint oldSlotProtection = 0;
bool slotProtectionChanged = false;
try {
Zero(queueVtable, 0x40); Zero(queue, 0x80);
Zero(record, 0x44); Zero(secondRecord, 0x44); Zero(zeroClockRecord, 0x44); Zero(wrappingClockRecord, 0x44);
Marshal.WriteIntPtr(queue, 0, queueVtable);
Marshal.WriteIntPtr(queueVtable, 0x38, Marshal.GetFunctionPointerForDelegate(queueTimeGetter));
// The synthetic queue controls only GetQueue and the +0x38 clock slot.
if (!VirtualProtect(slot, new UIntPtr((uint)IntPtr.Size), 0x40, out oldSlotProtection))
throw new InvalidOperationException("VirtualProtect(GetQueue IAT) failed: " + Marshal.GetLastWin32Error());
slotProtectionChanged = true;
Marshal.WriteIntPtr(slot, Marshal.GetFunctionPointerForDelegate(queueGetter));
WarmStart prepare = (WarmStart)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, WarmStartRva), typeof(WarmStart));
uint rngState = 1;
Expect(unchecked((uint)oldRandomValue) == rngState, "fresh DLL RNG state starts at the statically observed seed");
InitializeRecord(record);
currentQueueTime = 100000;
queuePointerMatched = true;
prepare(record);
Expect(queuePointerMatched, "native clock vslot receives the synthetic queue object");
uint previousRandomState = rngState;
rngState = NextRandomState(rngState);
VerifyFirstPreparation(record, currentQueueTime, previousRandomState, rngState, module);
uint savedDeadline = ReadWord(record, 0x14);
for (int offset = 0x30; offset <= 0x40; offset += 4) Marshal.WriteInt32(record, offset, 0x5a5a5a5a);
currentQueueTime = ReadWord(record, 0x10);
queuePointerMatched = true;
prepare(record);
Expect(queuePointerMatched, "repeat native clock vslot receives the synthetic queue object");
Expect(ReadWord(record, 0x14) == savedDeadline, "repeat before deadline preserves the deadline");
Expect(ReadWord(IntPtr.Add(module, RandomStateRva), 0) == rngState, "repeat before deadline consumes no RNG draw");
for (int offset = 0x30; offset <= 0x40; offset += 4)
Expect(ReadWord(record, offset) == 0x5a5a5a5a, "nonzero first-time field skips the warm-preparation clear pass");
InitializeRecord(secondRecord);
currentQueueTime = 100500;
queuePointerMatched = true;
prepare(secondRecord);
Expect(queuePointerMatched, "second record uses the same synthetic queue");
previousRandomState = rngState;
rngState = NextRandomState(rngState);
VerifyFirstPreparation(secondRecord, currentQueueTime, previousRandomState, rngState, module);
InitializeRecord(zeroClockRecord);
currentQueueTime = 0;
queuePointerMatched = true;
prepare(zeroClockRecord);
Expect(queuePointerMatched, "zero-clock record uses the synthetic queue");
previousRandomState = rngState;
rngState = NextRandomState(rngState);
VerifyFirstPreparation(zeroClockRecord, 0, previousRandomState, rngState, module);
for (int offset = 0x30; offset <= 0x40; offset += 4) Marshal.WriteInt32(zeroClockRecord, offset, 0x6b6b6b6b);
queuePointerMatched = true;
prepare(zeroClockRecord);
Expect(queuePointerMatched, "zero-clock record uses the synthetic queue on repeat");
previousRandomState = rngState;
rngState = NextRandomState(rngState);
VerifyFirstPreparation(zeroClockRecord, 0, previousRandomState, rngState, module);
InitializeRecord(wrappingClockRecord);
currentQueueTime = 0xffffff80;
queuePointerMatched = true;
prepare(wrappingClockRecord);
Expect(queuePointerMatched, "wrapping-clock record uses the synthetic queue");
previousRandomState = rngState;
rngState = NextRandomState(rngState);
VerifyFirstPreparation(wrappingClockRecord, currentQueueTime, previousRandomState, rngState, module);
Expect(ReadWord(wrappingClockRecord, 0x14) < currentQueueTime, "deadline addition wraps as a DWORD near UINT32_MAX");
Console.WriteLine("original Behavior.dll warm-start oracle passed (RVA 0x1adb0; shared RNG, zero-clock repeat, and DWORD wrap).");
return 0;
}
finally {
if (slotProtectionChanged) {
Marshal.WriteIntPtr(slot, oldQueueGetter);
uint ignored;
VirtualProtect(slot, new UIntPtr((uint)IntPtr.Size), oldSlotProtection, out ignored);
}
Marshal.WriteInt32(IntPtr.Add(module, RandomStateRva), oldRandomValue);
Marshal.FreeHGlobal(record); Marshal.FreeHGlobal(secondRecord); Marshal.FreeHGlobal(zeroClockRecord); Marshal.FreeHGlobal(wrappingClockRecord);
Marshal.FreeHGlobal(queue); Marshal.FreeHGlobal(queueVtable);
GC.KeepAlive(queueGetter); GC.KeepAlive(queueTimeGetter);
FreeLibrary(module);
}
}
private static uint ReadWord(IntPtr address, int offset) { return unchecked((uint)Marshal.ReadInt32(address, offset)); }
private static uint NextRandomState(uint state) { return unchecked(state * 0x343fd + 0x269ec3); }
private static uint ExpectedDeadline(uint queueTime, uint previousRandomState) {
uint random = (NextRandomState(previousRandomState) >> 16) & 0x7fff;
uint jitter = ((random & 0xff) * 64) >> 8;
return unchecked(queueTime + 3 * 64 + jitter);
}
private static void InitializeRecord(IntPtr record) {
Zero(record, 0x44);
Marshal.WriteInt16(record, 0x18, 3); // Three 64-ms base units.
Marshal.WriteInt16(record, 0x1a, 1); // One 64-ms jitter range.
for (int offset = 0x30; offset <= 0x40; offset += 4)
Marshal.WriteInt32(record, offset, unchecked((int)(0xa0a00000u + (uint)offset)));
}
private static void VerifyFirstPreparation(IntPtr record, uint queueTime, uint previousRandomState, uint randomState, IntPtr module) {
Expect(ReadWord(record, 0x10) == queueTime, "warm preparation stores the queue clock in distributor+0x10");
Expect(ReadWord(record, 0x14) == ExpectedDeadline(queueTime, previousRandomState), "deadline matches base, configured interval, and native RNG jitter");
Expect(ReadWord(IntPtr.Add(module, RandomStateRva), 0) == randomState, "shared RNG advances by exactly one LCG draw");
for (int offset = 0x30; offset <= 0x40; offset += 4)
Expect(ReadWord(record, offset) == 0, "warm preparation clears distributor field +0x" + offset.ToString("x"));
}
private static string Hash(string path) {
using (SHA256 sha = SHA256.Create()) using (FileStream input = File.OpenRead(path)) {
byte[] digest = sha.ComputeHash(input); var hex = new StringBuilder();
foreach (byte value in digest) hex.Append(value.ToString("x2")); return hex.ToString();
}
}
private static void Expect(bool value, string message) { if (!value) throw new InvalidOperationException("original Behavior oracle failed: " + message); }
private static void Zero(IntPtr buffer, int length) { for (int offset = 0; offset < length; offset++) Marshal.WriteByte(buffer, offset, 0); }
}
@@ -0,0 +1,158 @@
using System;
using System.IO;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
internal static class Iron3dAnchorOracle {
private const uint LoadWithAlteredSearchPath = 0x00000008;
private const uint PageExecuteReadWrite = 0x40;
private const uint MemCommitReserve = 0x3000;
private const uint MemRelease = 0x8000;
private const int CallerRva = 0x392e0;
private const int GameGlobalRva = 0x10a5f8;
private const int CreateSuperAiIatRva = 0xe4010;
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr LoadLibraryEx(string path, IntPtr file, uint flags);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool FreeLibrary(IntPtr module);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr VirtualAlloc(IntPtr address, UIntPtr size, uint allocationType, uint protect);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool VirtualFree(IntPtr address, UIntPtr size, uint freeType);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool VirtualProtect(IntPtr address, UIntPtr size, uint protect, out uint oldProtect);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)]
private delegate void CreateAiDelegate(IntPtr record, int clanIndex, uint networkPlayer, int networkFlag);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate IntPtr CreateSuperAiDelegate(uint p0, uint p1, uint p2, uint clanIndex, uint x, uint y, uint p6, uint p7, uint p8, uint callback);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
private delegate void FpuWordOperation(ref ushort value);
private static FpuWordOperation ReadControlWord;
private static uint CapturedX;
private static uint CapturedY;
private static ushort CapturedControlWord;
private static CreateSuperAiDelegate CaptureDelegate;
private static IntPtr CaptureCreateSuperAi(uint p0, uint p1, uint p2, uint clanIndex, uint x, uint y, uint p6, uint p7, uint p8, uint callback) {
CapturedX = x;
CapturedY = y;
ReadControlWord(ref CapturedControlWord);
return IntPtr.Zero;
}
private static void Require(bool value, string message) {
if (!value) throw new InvalidOperationException(message);
}
private static void Expect(CreateAiDelegate caller, IntPtr record, FpuWordOperation setControlWord, ushort mode, float x, float y, uint expectedX, uint expectedY, string label) {
ushort requested = mode;
setControlWord(ref requested);
Marshal.WriteInt32(record, 4, BitConverter.ToInt32(BitConverter.GetBytes(x), 0));
Marshal.WriteInt32(record, 8, BitConverter.ToInt32(BitConverter.GetBytes(y), 0));
CapturedX = 0xdeadbeef;
CapturedY = 0xdeadbeef;
CapturedControlWord = 0;
caller(record, 0, 0, 0);
string actual = String.Format(CultureInfo.InvariantCulture, "{0}: cw=0x{1:x4} rc={2}, ({3:R}, {4:R}) -> (0x{5:x8}, 0x{6:x8})", label, CapturedControlWord, (CapturedControlWord >> 10) & 3, x, y, CapturedX, CapturedY);
Console.WriteLine(actual);
Require(CapturedX == expectedX && CapturedY == expectedY, label + " expected (0x" + expectedX.ToString("x8") + ",0x" + expectedY.ToString("x8") + ")");
Require(((CapturedControlWord >> 10) & 3) == ((requested >> 10) & 3), label + " x87 RC changed before the native callback");
}
private static IntPtr BuildInstruction(byte[] bytes) {
IntPtr memory = VirtualAlloc(IntPtr.Zero, new UIntPtr(4096), MemCommitReserve, PageExecuteReadWrite);
if (memory == IntPtr.Zero) throw new InvalidOperationException("VirtualAlloc failed: " + Marshal.GetLastWin32Error());
Marshal.Copy(bytes, 0, memory, bytes.Length);
return memory;
}
private static void ProtectWrite(IntPtr address, int value, out uint oldProtect) {
if (!VirtualProtect(address, new UIntPtr(4), PageExecuteReadWrite, out oldProtect)) throw new InvalidOperationException("VirtualProtect failed: " + Marshal.GetLastWin32Error());
Marshal.WriteInt32(address, value);
}
private static void RestoreProtection(IntPtr address, uint oldProtect) {
uint ignored;
if (!VirtualProtect(address, new UIntPtr(4), oldProtect, out ignored)) throw new InvalidOperationException("VirtualProtect restore failed: " + Marshal.GetLastWin32Error());
}
private static void Main() {
Require(IntPtr.Size == 4, "This oracle must run as an x86 process");
string dll = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_IRON3D_DLL");
Require(!String.IsNullOrEmpty(dll) && File.Exists(dll), "Set FPARKAN_ORIGINAL_IRON3D_DLL to the installed iron3d.dll");
string hash;
using (SHA256 sha = SHA256.Create()) using (FileStream stream = File.OpenRead(dll)) hash = BitConverter.ToString(sha.ComputeHash(stream)).Replace("-", "").ToLowerInvariant();
Require(hash == "d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa", "unexpected iron3d.dll hash: " + hash);
IntPtr module = LoadLibraryEx(dll, IntPtr.Zero, LoadWithAlteredSearchPath);
if (module == IntPtr.Zero) throw new InvalidOperationException("LoadLibraryEx failed: " + Marshal.GetLastWin32Error());
IntPtr readCode = IntPtr.Zero;
IntPtr writeCode = IntPtr.Zero;
IntPtr game = Marshal.AllocHGlobal(0x40);
IntPtr manager = Marshal.AllocHGlobal(0x40);
IntPtr record = Marshal.AllocHGlobal(0x68);
uint oldIatProtection = 0;
int oldGlobal = Marshal.ReadInt32(IntPtr.Add(module, GameGlobalRva));
int oldIat = Marshal.ReadInt32(IntPtr.Add(module, CreateSuperAiIatRva));
bool iatChanged = false;
bool controlWordCaptured = false;
ushort originalControlWord = 0;
FpuWordOperation setControlWord = null;
try {
byte[] readBytes = { 0x8b, 0x44, 0x24, 0x04, 0xd9, 0x38, 0xc2, 0x04, 0x00 }; // mov eax,[esp+4]; fnstcw [eax]; ret 4
byte[] writeBytes = { 0x8b, 0x44, 0x24, 0x04, 0xd9, 0x28, 0xc2, 0x04, 0x00 }; // mov eax,[esp+4]; fldcw [eax]; ret 4
readCode = BuildInstruction(readBytes);
writeCode = BuildInstruction(writeBytes);
ReadControlWord = (FpuWordOperation)Marshal.GetDelegateForFunctionPointer(readCode, typeof(FpuWordOperation));
setControlWord = (FpuWordOperation)Marshal.GetDelegateForFunctionPointer(writeCode, typeof(FpuWordOperation));
ReadControlWord(ref originalControlWord);
controlWordCaptured = true;
Console.WriteLine("iron3d.dll sha256=" + hash + " loadedBase=0x" + module.ToInt32().ToString("x8") + " defaultCW=0x" + originalControlWord.ToString("x4") + " defaultRC=" + ((originalControlWord >> 10) & 3));
for (int offset = 0; offset < 0x40; offset += 4) { Marshal.WriteInt32(game, offset, 0); Marshal.WriteInt32(manager, offset, 0); }
for (int offset = 0; offset < 0x68; offset += 4) Marshal.WriteInt32(record, offset, 0);
Marshal.WriteInt32(game, 0x1c, manager.ToInt32());
Marshal.WriteInt32(IntPtr.Add(module, GameGlobalRva), game.ToInt32());
CaptureDelegate = CaptureCreateSuperAi;
IntPtr callbackPointer = Marshal.GetFunctionPointerForDelegate(CaptureDelegate);
IntPtr iat = IntPtr.Add(module, CreateSuperAiIatRva);
ProtectWrite(iat, callbackPointer.ToInt32(), out oldIatProtection);
iatChanged = true;
CreateAiDelegate caller = (CreateAiDelegate)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, CallerRva), typeof(CreateAiDelegate));
ushort nearest = (ushort)(originalControlWord & ~0x0c00);
ushort down = (ushort)((originalControlWord & ~0x0c00) | 0x0400);
ushort up = (ushort)((originalControlWord & ~0x0c00) | 0x0800);
ushort truncate = (ushort)((originalControlWord & ~0x0c00) | 0x0c00);
Expect(caller, record, setControlWord, nearest, 500.2857f, 751.8217f, 500, 752, "Autodemo anchors / nearest-even");
Expect(caller, record, setControlWord, nearest, 500.5f, 501.5f, 500, 502, "nearest tie pair");
Expect(caller, record, setControlWord, down, 500.5f, 501.5f, 500, 501, "down tie pair");
Expect(caller, record, setControlWord, up, 500.5f, 501.5f, 501, 502, "up tie pair");
Expect(caller, record, setControlWord, truncate, 500.5f, 501.5f, 500, 501, "truncate tie pair");
Expect(caller, record, setControlWord, nearest, -0.4f, 10000.4f, 0, 10000, "round-in lower/upper boundary");
Expect(caller, record, setControlWord, nearest, -0.6f, 10000.6f, 0xffffffff, 10001, "round-out lower/upper boundary");
Expect(caller, record, setControlWord, nearest, 4294967296.0f, 4294967808.0f, 0, 512, "low-DWORD wrap boundary");
}
finally {
if (iatChanged) {
IntPtr iat = IntPtr.Add(module, CreateSuperAiIatRva);
Marshal.WriteInt32(iat, oldIat);
RestoreProtection(iat, oldIatProtection);
}
Marshal.WriteInt32(IntPtr.Add(module, GameGlobalRva), oldGlobal);
if (controlWordCaptured) setControlWord(ref originalControlWord);
Marshal.FreeHGlobal(record);
Marshal.FreeHGlobal(manager);
Marshal.FreeHGlobal(game);
if (readCode != IntPtr.Zero) VirtualFree(readCode, UIntPtr.Zero, MemRelease);
if (writeCode != IntPtr.Zero) VirtualFree(writeCode, UIntPtr.Zero, MemRelease);
FreeLibrary(module);
}
}
}
@@ -0,0 +1,429 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
internal static class Handler19DispatchOracle {
private const uint LoadWithAlteredSearchPath = 0x00000008;
private const string ExpectedAiSha256 = "c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc";
private const string ExpectedIron3dSha256 = "d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa";
private const string ExpectedScriptSha256 = "db1c6fde57aff43181619f19e19e1cacf7301ebb859e8cb883db6b4b7181eced";
private const int EventLookupRva = 0x11f40;
private const int EventDispatchRva = 0x11f10;
private const int Handler19Rva = 0x0aa38;
private const int AiWarmStartRva = 0x01ae0;
private const int Iron3dClanRefreshRva = 0x39440;
private const int EventStride = 0x14;
private const int InstructionStride = 0x20;
private const int VarSetStride = 0x30;
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr LoadLibraryEx(string fileName, IntPtr file, uint flags);
[DllImport("kernel32.dll", SetLastError = true)] private static extern bool FreeLibrary(IntPtr module);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate int FindEvent(IntPtr vm, IntPtr name);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate uint DispatchEvent(IntPtr vm, int eventIndex);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate uint ReadVariable(IntPtr record);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void SetDwordPosition(IntPtr record, uint x, uint y);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void RefreshClan(IntPtr record, uint requestedMode);
[UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate void HostInterfaceRefresh(IntPtr host);
private sealed class WarmStartObservation {
public int Calls;
public IntPtr Host;
public IntPtr Record;
public IntPtr Ai;
public IntPtr Cells;
public int EntryMode;
public int AiMode;
public byte Latch;
public uint XCellBeforeDispatch;
}
private static WarmStartObservation CurrentWarmStartObservation;
private static HostInterfaceRefresh WarmStartRecorder = RecordWarmStart;
private static void RecordWarmStart(IntPtr host) {
WarmStartObservation observation = CurrentWarmStartObservation;
observation.Calls++;
observation.Host = host;
observation.EntryMode = Marshal.ReadInt32(observation.Record, 0x18);
observation.AiMode = Marshal.ReadInt32(observation.Ai, 0x3fc);
observation.Latch = Marshal.ReadByte(observation.Ai, 0x8a8);
observation.XCellBeforeDispatch = ReadWord(observation.Cells, 0);
}
private sealed class Instruction {
public readonly uint[] Words;
public readonly uint[] References;
public Instruction(uint[] words, uint[] references) { Words = words; References = references; }
}
private sealed class Event {
public readonly byte[] Name;
public readonly uint EventWord;
public readonly Instruction[] Instructions;
public Event(byte[] name, uint eventWord, Instruction[] instructions) { Name = name; EventWord = eventWord; Instructions = instructions; }
}
private sealed class Package {
public readonly uint HandlerCount;
public readonly Event[] Events;
public Package(uint handlerCount, Event[] events) { HandlerCount = handlerCount; Events = events; }
}
private sealed class Instance : IDisposable {
private readonly List<IntPtr> allocations = new List<IntPtr>();
public IntPtr Ai;
public IntPtr EventTable;
public IntPtr HandlerTable;
public IntPtr VarRecords;
public IntPtr Cells;
public void Dispose() { for (int i = allocations.Count - 1; i >= 0; --i) Marshal.FreeHGlobal(allocations[i]); }
public IntPtr Allocate(int length) {
IntPtr p = Marshal.AllocHGlobal(length);
if (p == IntPtr.Zero) throw new OutOfMemoryException();
Zero(p, length); allocations.Add(p); return p;
}
}
private static int Main() {
if (IntPtr.Size != 4) { Console.Error.WriteLine("Build with /platform:x86."); return 2; }
string aiPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_AI_DLL");
string iron3dPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_IRON3D_DLL");
string scriptPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_DEFAULT_SCR");
if (String.IsNullOrEmpty(aiPath) || !File.Exists(aiPath) || String.IsNullOrEmpty(iron3dPath) || !File.Exists(iron3dPath) || String.IsNullOrEmpty(scriptPath) || !File.Exists(scriptPath)) {
Console.Error.WriteLine("Set FPARKAN_ORIGINAL_AI_DLL, FPARKAN_ORIGINAL_IRON3D_DLL, and FPARKAN_ORIGINAL_DEFAULT_SCR to the installed GOG files."); return 3;
}
if (Hash(aiPath) != ExpectedAiSha256) { Console.Error.WriteLine("Unexpected ai.dll SHA-256: " + Hash(aiPath)); return 4; }
if (Hash(iron3dPath) != ExpectedIron3dSha256) { Console.Error.WriteLine("Unexpected iron3d.dll SHA-256: " + Hash(iron3dPath)); return 4; }
if (Hash(scriptPath) != ExpectedScriptSha256) { Console.Error.WriteLine("Unexpected default.scr SHA-256: " + Hash(scriptPath)); return 5; }
Package package = ReadPackage(scriptPath);
if (package.HandlerCount != 73 || package.Events.Length != 9) throw new InvalidDataException("Unexpected default.scr header.");
if (package.Events[0].Name.Length != 5 || Encoding.ASCII.GetString(package.Events[0].Name) != "Init\0" || package.Events[0].Instructions.Length != 1)
throw new InvalidDataException("Expected a single-instruction Init event at index 0.");
Instruction init = package.Events[0].Instructions[0];
if (init.Words[0] != 19 || init.Words[1] != 0xffffffff || init.Words[2] != 0xffffffff || init.References.Length != 3 || init.References[0] != 224 || init.References[1] != 225 || init.References[2] != 226)
throw new InvalidDataException("Init instruction no longer matches the recovered Handler(19) shape.");
IntPtr module = LoadLibraryEx(aiPath, IntPtr.Zero, LoadWithAlteredSearchPath);
if (module == IntPtr.Zero) { Console.Error.WriteLine("LoadLibraryEx(ai.dll) failed: " + Marshal.GetLastWin32Error()); return 6; }
try {
FindEvent find = (FindEvent)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, EventLookupRva), typeof(FindEvent));
DispatchEvent dispatch = (DispatchEvent)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, EventDispatchRva), typeof(DispatchEvent));
ReadVariable readVariable = (ReadVariable)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, 0x13570), typeof(ReadVariable));
SetDwordPosition setDwordPosition = (SetDwordPosition)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, 0x5c70), typeof(SetDwordPosition));
VerifyEventLookupVectors(package, find, dispatch, module);
RunInstance(package, find, dispatch, readVariable, module, 500.2857f, 752.0f, 0, 500, 752);
RunInstance(package, find, dispatch, readVariable, module, 728.0f, 449.0f, 1, 728, 449);
RunDwordToHandlerRoundTrip(package, find, dispatch, readVariable, setDwordPosition, module);
RunOriginalClanRefreshPath(package, module, iron3dPath);
Console.WriteLine("original ai.dll dispatcher passed for default.scr Init instances and DWORD round-trip; original iron3d clan-refresh to AI-vtable Init path also passed; code bytes were not modified");
return 0;
}
finally { FreeLibrary(module); }
}
private static void RunOriginalClanRefreshPath(Package package, IntPtr aiModule, string iron3dPath) {
IntPtr iron3d = LoadLibraryEx(iron3dPath, IntPtr.Zero, LoadWithAlteredSearchPath);
if (iron3d == IntPtr.Zero) throw new InvalidOperationException("LoadLibraryEx(iron3d.dll) failed: " + Marshal.GetLastWin32Error());
try {
RefreshClan refresh = (RefreshClan)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(iron3d, Iron3dClanRefreshRva), typeof(RefreshClan));
IntPtr method = IntPtr.Add(aiModule, AiWarmStartRva);
RunClanRefreshCase(package, aiModule, refresh, method, 1, 2, 2, "forward requested mode");
RunClanRefreshCase(package, aiModule, refresh, method, 0, 2, 1, "zero flag forces mode one");
RunClanRefreshCase(package, aiModule, refresh, method, 1, 1, 1, "forward mode one");
RunClanRefreshMissingCase(package, aiModule, refresh, method);
RunClanRefreshBusyCase(package, aiModule, refresh, method);
RunClanRefreshNullAiCase(refresh);
}
finally { FreeLibrary(iron3d); }
}
private static void RunClanRefreshCase(Package package, IntPtr aiModule, RefreshClan refresh, IntPtr method, int forceFlag, uint requestedMode, uint expectedMode, string label) {
using (Instance instance = BuildInstance(package, 500.0f, 752.0f, 0, IntPtr.Add(aiModule, Handler19Rva))) {
IntPtr host = ConfigureClanRefresh(instance, method);
IntPtr record = PrepareClanRecord(instance, forceFlag);
CurrentWarmStartObservation = new WarmStartObservation { Record = record, Ai = instance.Ai, Cells = instance.Cells };
refresh(record, requestedMode);
WarmStartObservation observed = CurrentWarmStartObservation;
Expect(Marshal.ReadInt32(record, 0x18) == requestedMode, label + ": Iron3D stores the requested per-clan mode");
Expect(Marshal.ReadInt32(instance.Ai, 0x3fc) == expectedMode, label + ": AI callback receives the forced/forwarded mode");
Expect(observed.Calls == 1 && observed.Host == host, label + ": original AI slot called its host interface first");
Expect(observed.EntryMode == requestedMode && observed.AiMode == expectedMode && observed.Latch == 0 && observed.XCellBeforeDispatch == 0,
label + ": record and AI mode are stored before host callback, and Init dispatch follows it");
Expect(Marshal.ReadByte(instance.Ai, 0x78) == 0, label + ": normal Init dispatch clears the recursion guard");
Expect(Marshal.ReadByte(instance.Ai, 0x8a8) == 1, label + ": successful Init lookup latches after dispatch");
Expect(ReadWord(instance.Cells, 0) == 500 && ReadWord(instance.Cells, 1) == 752 && ReadWord(instance.Cells, 2) == 0,
label + ": original Init handler writes X/Y/clan after host callback");
Console.WriteLine("iron3d 39440 -> ai 01ae0 ({0}): entry_mode={1}, ai_mode={2}, latch={3}", label, requestedMode, expectedMode, Marshal.ReadByte(instance.Ai, 0x8a8));
Marshal.WriteInt32(instance.Cells, 0, unchecked((int)0xfeedface));
refresh(record, requestedMode);
Expect(observed.Calls == 2 && Marshal.ReadInt32(instance.Ai, 0x3fc) == expectedMode, label + ": repeat still reaches interface before latch check");
Expect(ReadWord(instance.Cells, 0) == 0xfeedface, label + ": Init latch suppresses a second event dispatch");
}
}
private static void RunClanRefreshMissingCase(Package package, IntPtr aiModule, RefreshClan refresh, IntPtr method) {
Event[] events = (Event[])package.Events.Clone();
bool replaced = false;
for (int i = 0; i < events.Length; i++) {
if (Encoding.ASCII.GetString(events[i].Name) == "Init\0") {
events[i] = new Event(Encoding.ASCII.GetBytes("Missing\0"), events[i].EventWord, events[i].Instructions);
replaced = true; break;
}
}
Expect(replaced, "missing-path package contains Init to remove");
using (Instance instance = BuildInstance(new Package(package.HandlerCount, events), 500.0f, 752.0f, 0, IntPtr.Add(aiModule, Handler19Rva))) {
ConfigureClanRefresh(instance, method);
IntPtr record = PrepareClanRecord(instance, 1);
CurrentWarmStartObservation = new WarmStartObservation { Record = record, Ai = instance.Ai, Cells = instance.Cells };
refresh(record, 2);
Expect(Marshal.ReadByte(instance.Ai, 0x8a8) == 0 && CurrentWarmStartObservation.Calls == 1, "missing Init leaves the native latch clear after the host callback");
Expect(ReadWord(instance.Cells, 0) == 0 && ReadWord(instance.Cells, 1) == 0 && ReadWord(instance.Cells, 2) == 0, "missing Init has no Handler(19) writes");
}
}
private static void RunClanRefreshBusyCase(Package package, IntPtr aiModule, RefreshClan refresh, IntPtr method) {
using (Instance instance = BuildInstance(package, 500.0f, 752.0f, 0, IntPtr.Add(aiModule, Handler19Rva))) {
ConfigureClanRefresh(instance, method);
Marshal.WriteByte(instance.Ai, 0x78, 1);
IntPtr record = PrepareClanRecord(instance, 1);
CurrentWarmStartObservation = new WarmStartObservation { Record = record, Ai = instance.Ai, Cells = instance.Cells };
refresh(record, 2);
Expect(CurrentWarmStartObservation.Calls == 1 && ReadWord(instance.Cells, 0) == 0, "busy recursion guard suppresses Init dispatch");
Expect(Marshal.ReadByte(instance.Ai, 0x78) == 1 && Marshal.ReadByte(instance.Ai, 0x8a8) == 1, "found Init latches even when the preexisting busy guard suppresses dispatch");
Marshal.WriteByte(instance.Ai, 0x78, 0);
Marshal.WriteInt32(instance.Cells, 0, unchecked((int)0xfeedface));
refresh(record, 2);
Expect(CurrentWarmStartObservation.Calls == 2 && Marshal.ReadByte(instance.Ai, 0x78) == 0 && Marshal.ReadByte(instance.Ai, 0x8a8) == 1,
"clearing the busy guard cannot bypass the already-set Init latch");
Expect(ReadWord(instance.Cells, 0) == 0xfeedface, "busy-path latch suppresses a later Init dispatch after the guard clears");
}
}
private static void RunClanRefreshNullAiCase(RefreshClan refresh) {
IntPtr record = Marshal.AllocHGlobal(0x68);
try {
Zero(record, 0x68);
Marshal.WriteInt32(record, 0x0c, 1);
Marshal.WriteInt32(record, 0x18, -1);
refresh(record, 2);
Expect(Marshal.ReadInt32(record, 0x18) == 2, "null AI still stores the requested record mode");
}
finally { Marshal.FreeHGlobal(record); }
}
private static IntPtr ConfigureClanRefresh(Instance instance, IntPtr method) {
IntPtr vtable = instance.Allocate(0x18);
IntPtr host = instance.Allocate(4);
IntPtr hostVtable = instance.Allocate(0x30);
Marshal.WriteInt32(vtable, 0x14, method.ToInt32());
Marshal.WriteInt32(instance.Ai, 0, vtable.ToInt32());
Marshal.WriteInt32(host, 0, hostVtable.ToInt32());
Marshal.WriteInt32(hostVtable, 0x2c, Marshal.GetFunctionPointerForDelegate(WarmStartRecorder).ToInt32());
Marshal.WriteInt32(instance.Ai, 0x438, host.ToInt32());
Marshal.WriteByte(instance.Ai, 0x78, 0);
Marshal.WriteByte(instance.Ai, 0x8a8, 0);
return host;
}
private static IntPtr PrepareClanRecord(Instance instance, int forceFlag) {
IntPtr record = instance.Allocate(0x68);
Marshal.WriteInt32(record, 0x0c, forceFlag);
Marshal.WriteInt32(record, 0x50, instance.Ai.ToInt32());
return record;
}
private static void VerifyEventLookupVectors(Package package, FindEvent find, DispatchEvent dispatch, IntPtr module) {
var duplicateEvents = new Event[package.Events.Length + 1];
Array.Copy(package.Events, duplicateEvents, package.Events.Length);
duplicateEvents[duplicateEvents.Length - 1] = new Event(
Encoding.ASCII.GetBytes("Init\0"), 0xabcdef01,
new Instruction[] { new Instruction(new uint[] { 19, 0xffffffff, 0xffffffff, 0, 0, 3, 0 }, new uint[] { 224, 225, 226 }) });
var duplicatePackage = new Package(package.HandlerCount, duplicateEvents);
using (Instance instance = BuildInstance(duplicatePackage, 500.2857f, 752.0f, 0, IntPtr.Add(module, Handler19Rva))) {
Expect(LookupEvent(find, IntPtr.Add(instance.Ai, 8), "Init") == 0, "native lookup chooses first exact duplicate event");
}
var embeddedNulEvents = (Event[])package.Events.Clone();
for (int i = 0; i < embeddedNulEvents.Length; i++) {
if (Encoding.ASCII.GetString(embeddedNulEvents[i].Name) == "Init\0") {
embeddedNulEvents[i] = new Event(Encoding.ASCII.GetBytes("Init\0suffix\0"), embeddedNulEvents[i].EventWord, embeddedNulEvents[i].Instructions);
break;
}
}
var embeddedNulPackage = new Package(package.HandlerCount, embeddedNulEvents);
using (Instance instance = BuildInstance(embeddedNulPackage, 500.2857f, 752.0f, 0, IntPtr.Add(module, Handler19Rva))) {
Expect(LookupEvent(find, IntPtr.Add(instance.Ai, 8), "Init") == 0, "native lookup stops at an embedded NUL and ignores trailing name bytes");
}
var missingEvents = (Event[])package.Events.Clone();
for (int i = 0; i < missingEvents.Length; i++) {
if (Encoding.ASCII.GetString(missingEvents[i].Name) == "Init\0") {
missingEvents[i] = new Event(Encoding.ASCII.GetBytes("NoInit\0"), missingEvents[i].EventWord, missingEvents[i].Instructions);
}
}
var missingPackage = new Package(package.HandlerCount, missingEvents);
using (Instance instance = BuildInstance(missingPackage, 500.2857f, 752.0f, 0, IntPtr.Add(module, Handler19Rva))) {
IntPtr vm = IntPtr.Add(instance.Ai, 8);
int eventIndex = LookupEvent(find, vm, "Init");
Expect(eventIndex == -1, "native lookup reports missing exact event");
Expect((dispatch(vm, eventIndex) & 0xff) == 0, "dispatching missing event performs no callback");
Expect(Marshal.ReadInt32(instance.Cells, 0) == 0 && Marshal.ReadInt32(instance.Cells, 4) == 0 && Marshal.ReadInt32(instance.Cells, 8) == 0,
"missing event makes no varset writes");
}
Console.WriteLine("original event lookup: first duplicate wins; Init\\0suffix also matches; missing Init returns -1 without writes");
}
private static int LookupEvent(FindEvent find, IntPtr vm, string nameText) {
byte[] nameBytes = Encoding.ASCII.GetBytes(nameText + "\0");
IntPtr name = Marshal.AllocHGlobal(nameBytes.Length);
try {
Marshal.Copy(nameBytes, 0, name, nameBytes.Length);
return find(vm, name);
}
finally { Marshal.FreeHGlobal(name); }
}
private static void RunDwordToHandlerRoundTrip(Package package, FindEvent find, DispatchEvent dispatch, ReadVariable readVariable, SetDwordPosition setDwordPosition, IntPtr module) {
IntPtr position = Marshal.AllocHGlobal(12);
try {
Zero(position, 12);
setDwordPosition(position, UInt32.MaxValue, 10001);
float x = BitConverter.ToSingle(BitConverter.GetBytes(ReadWord(position, 1)), 0);
float y = BitConverter.ToSingle(BitConverter.GetBytes(ReadWord(position, 2)), 0);
Expect(x == 4294967296.0f && y == 10001.0f, "FUN_10005c70 converts DWORD coordinates to the expected single-precision fields");
Console.WriteLine("FUN_10005c70 input=({0:X8}, {1}) fields=({2:R}, {3:R})", UInt32.MaxValue, 10001, x, y);
RunInstance(package, find, dispatch, readVariable, module, x, y, 2, 0, 10001);
}
finally { Marshal.FreeHGlobal(position); }
}
private static void RunInstance(Package package, FindEvent find, DispatchEvent dispatch, ReadVariable readVariable, IntPtr module, float x, float y, uint clanId, uint expectedX, uint expectedY) {
using (Instance instance = BuildInstance(package, x, y, clanId, IntPtr.Add(module, Handler19Rva))) {
IntPtr vm = IntPtr.Add(instance.Ai, 8);
IntPtr name = Marshal.AllocHGlobal(5);
try {
Marshal.Copy(Encoding.ASCII.GetBytes("Init\0"), 0, name, 5);
int eventIndex = find(vm, name);
Expect(eventIndex == 0, "original name lookup returns Init index 0");
uint result = dispatch(vm, eventIndex);
uint first = ReadWord(instance.Cells, 0);
uint second = ReadWord(instance.Cells, 1);
uint third = ReadWord(instance.Cells, 2);
uint getterX = readVariable(IntPtr.Add(instance.VarRecords, 224 * VarSetStride));
uint getterY = readVariable(IntPtr.Add(instance.VarRecords, 225 * VarSetStride));
uint getterClan = readVariable(IntPtr.Add(instance.VarRecords, 226 * VarSetStride));
Console.WriteLine("probe clan={0}: cell_words=({1:X8}, {2:X8}, {3:X8}), native_getter=({4:X8}, {5:X8}, {6:X8})", clanId, first, second, third, getterX, getterY, getterClan);
Expect((result & 0xff) == 1, "dispatcher reports completion");
Expect(Marshal.ReadInt32(vm, 0x40) == 0, "current event field +0x48 is Init index");
Expect(Marshal.ReadInt32(vm, 0x44) == 1, "current instruction field +0x4c advanced once");
Expect(Marshal.ReadInt32(vm, 0x6c) == 0, "condition stack depth reset to zero");
Expect(Marshal.ReadInt32(instance.Ai, 0x50) == 0, "handler result slot +0x50 cleared");
Expect(first == expectedX && second == expectedY && third == clanId, "Handler(19) writes the three referenced DWORD cells in order");
Expect(getterX == first && getterY == second && getterClan == third, "native DWORD getter reads the values written by Handler(19)");
Console.WriteLine("clan={0}: Init={1}, instruction={2}, result={3}, stack={4}, result_slot={5}, cell_words=({6:X8}, {7:X8}, {8:X8}), typed_getters=({9:X8}, {10:X8}, {11:X8})",
clanId, eventIndex, Marshal.ReadInt32(vm, 0x44), result, Marshal.ReadInt32(vm, 0x6c), Marshal.ReadInt32(instance.Ai, 0x50), first, second, third, getterX, getterY, getterClan);
}
finally { Marshal.FreeHGlobal(name); }
}
}
private static Instance BuildInstance(Package package, float x, float y, uint clanId, IntPtr handler19) {
var instance = new Instance();
try {
instance.Ai = instance.Allocate(0x8b0);
instance.EventTable = instance.Allocate(package.Events.Length * EventStride);
instance.HandlerTable = instance.Allocate((int)package.HandlerCount * 4);
instance.VarRecords = instance.Allocate(227 * VarSetStride);
instance.Cells = instance.Allocate(3 * 4);
IntPtr vm = IntPtr.Add(instance.Ai, 8);
Marshal.WriteInt32(vm, 0, package.Events.Length);
Marshal.WriteInt32(vm, 4, instance.EventTable.ToInt32());
Marshal.WriteInt32(vm, 8, (int)package.HandlerCount);
Marshal.WriteInt32(vm, 0x0c, instance.HandlerTable.ToInt32());
Marshal.WriteInt32(vm, 0x3c, instance.Ai.ToInt32());
Marshal.WriteInt32(instance.HandlerTable, 19 * 4, handler19.ToInt32());
for (int eventIndex = 0; eventIndex < package.Events.Length; eventIndex++) {
Event sourceEvent = package.Events[eventIndex];
IntPtr name = instance.Allocate(sourceEvent.Name.Length);
Marshal.Copy(sourceEvent.Name, 0, name, sourceEvent.Name.Length);
IntPtr records = sourceEvent.Instructions.Length == 0 ? IntPtr.Zero : instance.Allocate(sourceEvent.Instructions.Length * InstructionStride);
int eventOffset = eventIndex * EventStride;
Marshal.WriteInt32(instance.EventTable, eventOffset, name.ToInt32());
Marshal.WriteInt32(instance.EventTable, eventOffset + 4, sourceEvent.Name.Length - 1);
Marshal.WriteInt32(instance.EventTable, eventOffset + 8, unchecked((int)sourceEvent.EventWord));
Marshal.WriteInt32(instance.EventTable, eventOffset + 0x0c, sourceEvent.Instructions.Length);
Marshal.WriteInt32(instance.EventTable, eventOffset + 0x10, records.ToInt32());
for (int instructionIndex = 0; instructionIndex < sourceEvent.Instructions.Length; instructionIndex++) {
Instruction sourceInstruction = sourceEvent.Instructions[instructionIndex];
IntPtr refs = sourceInstruction.References.Length == 0 ? IntPtr.Zero : instance.Allocate(sourceInstruction.References.Length * 4);
for (int referenceIndex = 0; referenceIndex < sourceInstruction.References.Length; referenceIndex++)
Marshal.WriteInt32(refs, referenceIndex * 4, unchecked((int)sourceInstruction.References[referenceIndex]));
IntPtr instruction = IntPtr.Add(records, instructionIndex * InstructionStride);
for (int word = 0; word < 5; word++) Marshal.WriteInt32(instruction, word * 4, unchecked((int)sourceInstruction.Words[word]));
Marshal.WriteInt32(instruction, 0x14, refs.ToInt32());
Marshal.WriteInt32(instruction, 0x18, sourceInstruction.References.Length);
Marshal.WriteInt32(instruction, 0x1c, unchecked((int)sourceInstruction.Words[6]));
}
}
Marshal.WriteInt32(instance.Ai, 0x7c, unchecked((int)clanId));
Marshal.WriteInt32(instance.Ai, 0x80, BitConverter.ToInt32(BitConverter.GetBytes(x), 0));
Marshal.WriteInt32(instance.Ai, 0x84, BitConverter.ToInt32(BitConverter.GetBytes(y), 0));
// AI+0x18 is the original varset vector object: +4=count and +0xc=record array.
Marshal.WriteInt32(instance.Ai, 0x1c, 227);
Marshal.WriteInt32(instance.Ai, 0x24, instance.VarRecords.ToInt32());
uint[] targets = { 224, 225, 226 };
for (int i = 0; i < targets.Length; i++) {
IntPtr record = IntPtr.Add(instance.VarRecords, checked((int)targets[i] * VarSetStride));
IntPtr cell = IntPtr.Add(instance.Cells, i * 4);
Marshal.WriteInt32(record, 8, 5); // original parser's DWORD type tag
Marshal.WriteInt32(record, 0x20, cell.ToInt32());
}
return instance;
}
catch { instance.Dispose(); throw; }
}
private static Package ReadPackage(string path) {
using (BinaryReader reader = new BinaryReader(File.OpenRead(path), Encoding.ASCII)) {
uint handlerCount = reader.ReadUInt32();
uint eventCount = reader.ReadUInt32();
if (eventCount > 4096 || handlerCount > 4096) throw new InvalidDataException("Unreasonable package header.");
var events = new Event[eventCount];
for (int e = 0; e < events.Length; e++) {
uint nameLength = reader.ReadUInt32();
if (nameLength > 1024) throw new InvalidDataException("Unreasonable event name length.");
byte[] name = reader.ReadBytes(checked((int)nameLength + 1));
if (name.Length != nameLength + 1 || name[name.Length - 1] != 0) throw new InvalidDataException("Bad event name.");
uint eventWord = reader.ReadUInt32();
uint instructionCount = reader.ReadUInt32();
if (instructionCount > 65536) throw new InvalidDataException("Unreasonable event instruction count.");
var instructions = new Instruction[instructionCount];
for (int i = 0; i < instructions.Length; i++) {
uint[] words = new uint[6];
for (int w = 0; w < words.Length; w++) words[w] = reader.ReadUInt32();
if (words[5] > 4096) throw new InvalidDataException("Unreasonable reference count.");
uint[] refs = new uint[words[5]];
for (int r = 0; r < refs.Length; r++) refs[r] = reader.ReadUInt32();
uint trailing = reader.ReadUInt32();
instructions[i] = new Instruction(new uint[] { words[0], words[1], words[2], words[3], words[4], words[5], trailing }, refs);
}
events[e] = new Event(name, eventWord, instructions);
}
if (reader.BaseStream.Position != reader.BaseStream.Length) throw new InvalidDataException("Trailing bytes in default.scr.");
return new Package(handlerCount, events);
}
}
private static uint ReadWord(IntPtr cells, int index) { return unchecked((uint)Marshal.ReadInt32(cells, index * 4)); }
private static string Hash(string path) {
using (SHA256 sha = SHA256.Create()) using (FileStream input = File.OpenRead(path)) {
byte[] digest = sha.ComputeHash(input); var hex = new StringBuilder();
foreach (byte value in digest) hex.Append(value.ToString("x2")); return hex.ToString();
}
}
private static void Expect(bool value, string message) { if (!value) throw new InvalidOperationException("original dispatcher oracle failed: " + message); }
private static void Zero(IntPtr buffer, int length) { for (int offset = 0; offset < length; offset++) Marshal.WriteByte(buffer, offset, 0); }
}
@@ -0,0 +1,185 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
internal static class HostCallbackOracle {
private const uint LoadWithAlteredSearchPath = 0x00000008;
private const uint MemCommitReserve = 0x00003000;
private const uint PageExecuteReadWrite = 0x40;
private const int CallbackRva = 0x611d0;
private const int GameGlobalRva = 0x10a5f8;
private const int SoundServerIatRva = 0xe42cc;
private const string ExpectedSha256 = "d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa";
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr LoadLibraryEx(string fileName, IntPtr file, uint flags);
[DllImport("kernel32.dll", SetLastError = true)] private static extern bool FreeLibrary(IntPtr module);
[DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr VirtualAlloc(IntPtr address, UIntPtr size, uint allocationType, uint protect);
[DllImport("kernel32.dll", SetLastError = true)] private static extern bool VirtualFree(IntPtr address, UIntPtr size, uint freeType);
[DllImport("kernel32.dll", SetLastError = true)] private static extern bool VirtualProtect(IntPtr address, UIntPtr size, uint newProtect, out uint oldProtect);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int HostCallback(int gameMode, int command, int payload);
private static int Main() {
if (IntPtr.Size != 4) { Console.Error.WriteLine("Build with /platform:x86."); return 2; }
string dllPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_IRON3D_DLL");
if (String.IsNullOrEmpty(dllPath) || !File.Exists(dllPath)) {
Console.Error.WriteLine("Set FPARKAN_ORIGINAL_IRON3D_DLL to the original GOG iron3d.dll."); return 3;
}
string hash;
using (SHA256 sha = SHA256.Create()) using (FileStream input = File.OpenRead(dllPath)) {
byte[] digest = sha.ComputeHash(input); StringBuilder hex = new StringBuilder();
foreach (byte value in digest) hex.Append(value.ToString("x2")); hash = hex.ToString();
}
if (hash != ExpectedSha256) { Console.Error.WriteLine("Unexpected iron3d.dll SHA-256: " + hash); return 4; }
IntPtr module = LoadLibraryEx(dllPath, IntPtr.Zero, LoadWithAlteredSearchPath);
if (module == IntPtr.Zero) { Console.Error.WriteLine("LoadLibraryEx failed: " + Marshal.GetLastWin32Error()); return 5; }
IntPtr game = IntPtr.Zero, app = IntPtr.Zero, manager = IntPtr.Zero, sound = IntPtr.Zero, vtable = IntPtr.Zero;
IntPtr callStub = IntPtr.Zero, returnStub = IntPtr.Zero;
IntPtr gameGlobal = IntPtr.Zero; int originalGameGlobal = 0; bool gameGlobalPatched = false;
uint oldProtect = 0; bool iatPatched = false; int originalSoundPointer = 0;
try {
game = Marshal.AllocHGlobal(0x200); app = Marshal.AllocHGlobal(0x200); manager = Marshal.AllocHGlobal(0x40);
sound = Marshal.AllocHGlobal(4); vtable = Marshal.AllocHGlobal(0x14);
IntPtr observations = Marshal.AllocHGlobal(56);
try {
Zero(game, 0x200); Zero(app, 0x200); Zero(manager, 0x40); Zero(sound, 4); Zero(vtable, 0x14); Zero(observations, 56);
Marshal.WriteInt32(game, 4, app.ToInt32()); Marshal.WriteInt32(game, 0x2c, manager.ToInt32());
Marshal.WriteInt32(manager, 0x10, IntPtr.Zero.ToInt32());
Marshal.WriteInt32(sound, vtable.ToInt32());
IntPtr codePage = VirtualAlloc(IntPtr.Zero, new UIntPtr(0x1000), MemCommitReserve, PageExecuteReadWrite);
if (codePage == IntPtr.Zero) throw new InvalidOperationException("VirtualAlloc failed: " + Marshal.GetLastWin32Error());
callStub = codePage;
byte[] soundMethod = BuildSoundMethod(observations, game, app);
Marshal.Copy(soundMethod, 0, callStub, soundMethod.Length);
Marshal.WriteInt32(vtable, 0x10, callStub.ToInt32());
returnStub = IntPtr.Add(codePage, 0x100);
byte[] returnCode = { 0xb8, 0, 0, 0, 0, 0xc3 };
WriteImm32(returnCode, 1, sound.ToInt32()); Marshal.Copy(returnCode, 0, returnStub, returnCode.Length);
IntPtr iat = IntPtr.Add(module, SoundServerIatRva);
originalSoundPointer = Marshal.ReadInt32(iat);
if (!VirtualProtect(iat, new UIntPtr(4), PageExecuteReadWrite, out oldProtect))
throw new InvalidOperationException("VirtualProtect(IAT) failed: " + Marshal.GetLastWin32Error());
Marshal.WriteInt32(iat, returnStub.ToInt32());
iatPatched = true;
uint ignored;
if (!VirtualProtect(iat, new UIntPtr(4), oldProtect, out ignored))
throw new InvalidOperationException("VirtualProtect(IAT restore protection) failed: " + Marshal.GetLastWin32Error());
gameGlobal = IntPtr.Add(module, GameGlobalRva);
originalGameGlobal = Marshal.ReadInt32(gameGlobal);
Marshal.WriteInt32(gameGlobal, game.ToInt32());
gameGlobalPatched = true;
var callback = (HostCallback)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, CallbackRva), typeof(HostCallback));
RunAllowedFailure(callback, game, app, observations);
RunFailureRepeat(callback, game, app, observations);
RunOppositeCompletion(callback, game, app, observations);
RunCompletionRepeat(callback, game, app, observations);
RunFailureAfterCompletion(callback, game, app, observations);
RunAutoDemoGate(callback, game, app, observations);
RunNonZeroGameMode(callback, game, app, observations);
RunUnknownPayload(callback, game, app, observations);
Console.WriteLine("native callback oracle passed; audio endpoint is an isolated no-op recorder");
return 0;
}
finally { Marshal.FreeHGlobal(observations); }
}
finally {
if (gameGlobalPatched) Marshal.WriteInt32(gameGlobal, originalGameGlobal);
if (iatPatched) {
IntPtr iat = IntPtr.Add(module, SoundServerIatRva); uint restore;
if (!VirtualProtect(iat, new UIntPtr(4), PageExecuteReadWrite, out restore))
throw new InvalidOperationException("VirtualProtect(IAT cleanup) failed: " + Marshal.GetLastWin32Error());
Marshal.WriteInt32(iat, originalSoundPointer);
uint ignored;
if (!VirtualProtect(iat, new UIntPtr(4), restore, out ignored))
throw new InvalidOperationException("VirtualProtect(IAT cleanup restore protection) failed: " + Marshal.GetLastWin32Error());
}
if (callStub != IntPtr.Zero) VirtualFree(callStub, UIntPtr.Zero, 0x8000);
if (vtable != IntPtr.Zero) Marshal.FreeHGlobal(vtable);
if (sound != IntPtr.Zero) Marshal.FreeHGlobal(sound);
if (manager != IntPtr.Zero) Marshal.FreeHGlobal(manager);
if (app != IntPtr.Zero) Marshal.FreeHGlobal(app);
FreeLibrary(module);
if (game != IntPtr.Zero) Marshal.FreeHGlobal(game);
}
}
private static byte[] BuildSoundMethod(IntPtr observations, IntPtr game, IntPtr app) {
var code = new List<byte>();
code.AddRange(new byte[] { 0x50, 0x52, 0x51, 0x56, 0x57 });
code.Add(0xa1); AddImm32(code, observations.ToInt32()); code.Add(0x40); code.Add(0xa3); AddImm32(code, observations.ToInt32());
code.AddRange(new byte[] { 0x8b, 0x74, 0x24, 0x18, 0x8b, 0x06, 0x8b, 0x56, 0x04, 0x2b, 0xd0, 0x89, 0x15 }); AddImm32(code, observations.ToInt32() + 4);
code.AddRange(new byte[] { 0x83, 0xfa, 0x17, 0x76, 0x09, 0xc6, 0x05 }); AddImm32(code, observations.ToInt32() + 48);
code.AddRange(new byte[] { 0x01, 0xeb, 0x0f, 0xbf }); AddImm32(code, observations.ToInt32() + 12);
code.AddRange(new byte[] { 0x8b, 0xca, 0x8b, 0xf0, 0xfc, 0xf3, 0xa4, 0xc6, 0x07, 0x00 });
code.AddRange(new byte[] { 0x8b, 0x44, 0x24, 0x1c, 0xa3 }); AddImm32(code, observations.ToInt32() + 8);
code.Add(0xb8); AddImm32(code, game.ToInt32());
code.AddRange(new byte[] { 0x8b, 0x40, 0x08, 0xa3 }); AddImm32(code, observations.ToInt32() + 40);
code.Add(0xb8); AddImm32(code, app.ToInt32());
code.AddRange(new byte[] { 0x0f, 0xb6, 0x80, 0x57, 0x01, 0x00, 0x00, 0xa3 }); AddImm32(code, observations.ToInt32() + 44);
code.AddRange(new byte[] { 0x5f, 0x5e, 0x59, 0x5a, 0x58, 0xc2, 0x08, 0x00 });
return code.ToArray();
}
private static void RunAllowedFailure(HostCallback callback, IntPtr game, IntPtr app, IntPtr obs) {
Setup(game, app, obs, 0, 5, 0x7f); int result = callback(0, 0, 0);
Expect(result == 1 && Marshal.ReadInt32(game, 8) == 1 && Marshal.ReadByte(app, 0x157) == 0, "failure state writes");
Expect(Marshal.ReadInt32(obs, 0) == 1 && Marshal.ReadInt32(obs, 40) == 5 && Marshal.ReadInt32(obs, 44) == 0x7f, "failure sound before writes");
Console.WriteLine("fail: status=1 mission_flag=0 sound={0}", SoundId(obs));
Expect(SoundId(obs) == "VOICE_MISSION_FAIL" && Marshal.ReadInt32(obs, 8) == 0, "failure voice id and argument");
}
private static void RunFailureRepeat(HostCallback callback, IntPtr game, IntPtr app, IntPtr obs) {
Marshal.WriteByte(app, 0x157, 0x7f);
int result = callback(0, 0, 0);
Expect(result == 1 && Marshal.ReadInt32(game, 8) == 1 && Marshal.ReadByte(app, 0x157) == 0x7f && Marshal.ReadInt32(obs, 0) == 1, "repeat failure preserves mismatched mission flag");
}
private static void RunOppositeCompletion(HostCallback callback, IntPtr game, IntPtr app, IntPtr obs) {
int result = callback(0, 0, 1); Expect(result == 1 && Marshal.ReadInt32(game, 8) == 0 && Marshal.ReadByte(app, 0x157) == 1 && Marshal.ReadInt32(obs, 0) == 2, "completion after failure");
Expect(Marshal.ReadInt32(obs, 40) == 1 && Marshal.ReadInt32(obs, 44) == 0x7f && SoundId(obs) == "VOICE_MISSION_COMPLETE" && Marshal.ReadInt32(obs, 8) == 0, "completion sound before writes");
}
private static void RunCompletionRepeat(HostCallback callback, IntPtr game, IntPtr app, IntPtr obs) {
Marshal.WriteByte(app, 0x157, 0x7f);
int result = callback(0, 0, 1);
Expect(result == 1 && Marshal.ReadInt32(game, 8) == 0 && Marshal.ReadByte(app, 0x157) == 0x7f && Marshal.ReadInt32(obs, 0) == 2, "repeat completion preserves mismatched mission flag");
}
private static void RunFailureAfterCompletion(HostCallback callback, IntPtr game, IntPtr app, IntPtr obs) {
int result = callback(0, 0, 0);
Expect(result == 1 && Marshal.ReadInt32(game, 8) == 1 && Marshal.ReadByte(app, 0x157) == 0 && Marshal.ReadInt32(obs, 0) == 3, "failure after completion");
Expect(Marshal.ReadInt32(obs, 40) == 0 && Marshal.ReadInt32(obs, 44) == 0x7f && SoundId(obs) == "VOICE_MISSION_FAIL" && Marshal.ReadInt32(obs, 8) == 0, "reverse transition sound before writes");
Console.WriteLine("fail-after-complete: status=1 mission_flag=0 sound={0}", SoundId(obs));
}
private static void RunAutoDemoGate(HostCallback callback, IntPtr game, IntPtr app, IntPtr obs) {
Setup(game, app, obs, 1, 5, 0x7f); callback(0, 0, 0);
Expect(Marshal.ReadInt32(game, 8) == 5 && Marshal.ReadByte(app, 0x157) == 0x7f && Marshal.ReadInt32(obs, 0) == 0, "AutoDemo gate suppresses callback");
}
private static void RunNonZeroGameMode(HostCallback callback, IntPtr game, IntPtr app, IntPtr obs) {
Setup(game, app, obs, 0, 5, 0x7f); callback(1, 0, 0);
Expect(Marshal.ReadInt32(game, 8) == 5 && Marshal.ReadByte(app, 0x157) == 0x7f && Marshal.ReadInt32(obs, 0) == 0, "nonzero callback mode suppressed");
}
private static void RunUnknownPayload(HostCallback callback, IntPtr game, IntPtr app, IntPtr obs) {
Setup(game, app, obs, 0, 5, 0x7f); callback(0, 0, 2);
Expect(Marshal.ReadInt32(game, 8) == 5 && Marshal.ReadByte(app, 0x157) == 0x7f && Marshal.ReadInt32(obs, 0) == 0, "unknown payload suppressed");
}
private static void Setup(IntPtr game, IntPtr app, IntPtr obs, byte gate, int status, byte missionFlag) {
Marshal.WriteByte(game, 0xe5, gate); Marshal.WriteInt32(game, 8, status); Marshal.WriteByte(app, 0x157, missionFlag);
Zero(obs, 56);
}
private static void Expect(bool value, string description) { if (!value) throw new InvalidOperationException("native oracle assertion failed: " + description); }
private static string SoundId(IntPtr observations) {
int length = Marshal.ReadInt32(observations, 4);
if (Marshal.ReadByte(observations, 48) != 0) throw new InvalidOperationException("native sound identifier exceeded recorder capacity");
if (length < 0 || length > 23) throw new InvalidOperationException("unexpected sound identifier length: " + length);
byte[] bytes = new byte[length]; Marshal.Copy(IntPtr.Add(observations, 12), bytes, 0, length);
return Encoding.ASCII.GetString(bytes);
}
private static void Zero(IntPtr buffer, int length) { for (int offset = 0; offset < length; offset++) Marshal.WriteByte(buffer, offset, 0); }
private static void AddImm32(List<byte> code, int value) { byte[] bytes = BitConverter.GetBytes(value); code.AddRange(bytes); }
private static void WriteImm32(byte[] bytes, int offset, int value) { byte[] immediate = BitConverter.GetBytes(value); Buffer.BlockCopy(immediate, 0, bytes, offset, 4); }
}
+426 -12
View File
@@ -2,8 +2,8 @@
//! Lossless, bounded reader for compiled AI `.scr` packages. //! Lossless, bounded reader for compiled AI `.scr` packages.
//! //!
//! This module preserves the layout proven by the GOG `ai.dll` reader. It //! This module preserves the layout proven by the GOG `ai.dll` reader. It
//! deliberately does not assign semantics to instruction words or execute //! deliberately does not implement a general bytecode VM. It contains a
//! bytecode: that requires handler-specific evidence. //! narrow, handler-specific resolver for the confirmed `Init` path.
use fparkan_binary::{checked_allocation_len, Cursor, DecodeError, Limits}; use fparkan_binary::{checked_allocation_len, Cursor, DecodeError, Limits};
use std::sync::Arc; use std::sync::Arc;
@@ -151,6 +151,35 @@ pub struct Handler19DwordWrite {
pub value: u32, pub value: u32,
} }
/// Result of resolving the first event whose case-sensitive C-string name is
/// `Init` (bytes through the first NUL) with the supported
/// `Handler(19)` action subset.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Handler19InitResolution {
/// No event has the case-sensitive C-string name `Init`; the native lookup
/// makes no call.
Missing,
/// The first `Init\0` event was walked in source order.
Executed {
/// Zero-based event table index selected by the native name lookup.
event_index: usize,
/// Opaque event word retained from the selected record.
event_word: u32,
/// Ordered writes emitted by every supported instruction in the event.
writes: Vec<Handler19DwordWrite>,
},
/// The first unsupported instruction stopped this restricted resolver.
/// Any earlier local writes are discarded because the event did not finish.
Unsupported {
/// Zero-based event table index selected by the native name lookup.
event_index: usize,
/// Zero-based instruction index within that event.
instruction_index: usize,
/// Unsupported action shape.
error: Handler19ResolveError,
},
}
/// Inputs already expressed at the original `Handler(19)` setter ABI boundary. /// Inputs already expressed at the original `Handler(19)` setter ABI boundary.
/// ///
/// The first two words are results of original x87 `__ftol` calls. Keeping /// The first two words are results of original x87 `__ftol` calls. Keeping
@@ -169,6 +198,21 @@ pub struct Handler19InitInput {
/// Error resolving the three-target `Handler(19)` Init contract. /// Error resolving the three-target `Handler(19)` Init contract.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub enum Handler19ResolveError { pub enum Handler19ResolveError {
/// The selected action is not the recovered `Handler(19)` slot.
UnsupportedSelector {
/// Recovered selector shape.
found: ScriptDispatchSelector,
},
/// Only action instructions with control word `0xffff_ffff` are supported.
UnsupportedControlWord {
/// Opaque control word from instruction header word one.
found: u32,
},
/// Only instructions with no result-save reference are supported.
UnsupportedResultSave {
/// Opaque variable reference from instruction header word two.
found: u32,
},
/// One of the three required references was absent. /// One of the three required references was absent.
MissingReference { MissingReference {
/// Zero-based required target position. /// Zero-based required target position.
@@ -193,6 +237,17 @@ pub enum Handler19ResolveError {
impl std::fmt::Display for Handler19ResolveError { impl std::fmt::Display for Handler19ResolveError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::UnsupportedSelector { found } => {
write!(formatter, "unsupported script selector {found:?} in Init")
}
Self::UnsupportedControlWord { found } => write!(
formatter,
"unsupported Init control word 0x{found:08x}; expected 0xffffffff"
),
Self::UnsupportedResultSave { found } => write!(
formatter,
"unsupported Init result-save reference 0x{found:08x}; expected 0xffffffff"
),
Self::MissingReference { position } => { Self::MissingReference { position } => {
write!(formatter, "Handler(19) is missing reference {position}") write!(formatter, "Handler(19) is missing reference {position}")
} }
@@ -528,6 +583,73 @@ impl VarSet {
]) ])
} }
/// Resolves only the first event whose case-sensitive C-string name is
/// `Init` (bytes through the first NUL), and its supported
/// `Handler(19)` action instructions in source order.
///
/// A missing event is a normal no-write result, matching the native event
/// lookup. Unsupported selector/control/result-save forms stop at their
/// first instruction and return no partial writes. Malformed handler
/// references and declaration types remain errors.
pub fn resolve_handler19_init(
&self,
package: &ScriptPackage,
input: Handler19InitInput,
) -> Result<Handler19InitResolution, Handler19ResolveError> {
let Some((event_index, event)) = package.events.iter().enumerate().find(|(_, event)| {
event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..])
}) else {
return Ok(Handler19InitResolution::Missing);
};
let mut writes = Vec::new();
for (instruction_index, instruction) in event.instructions.iter().enumerate() {
let instruction_writes =
match self.resolve_handler19_init_instruction(instruction, input) {
Ok(writes) => writes,
Err(error @ Handler19ResolveError::UnsupportedSelector { .. })
| Err(error @ Handler19ResolveError::UnsupportedControlWord { .. })
| Err(error @ Handler19ResolveError::UnsupportedResultSave { .. }) => {
return Ok(Handler19InitResolution::Unsupported {
event_index,
instruction_index,
error,
});
}
Err(error) => return Err(error),
};
writes.extend(instruction_writes);
}
Ok(Handler19InitResolution::Executed {
event_index,
event_word: event.event_word,
writes,
})
}
fn resolve_handler19_init_instruction(
&self,
instruction: &ScriptInstruction,
input: Handler19InitInput,
) -> Result<[Handler19DwordWrite; 3], Handler19ResolveError> {
let selector = instruction.dispatch_selector();
if selector != ScriptDispatchSelector::Handler(19) {
return Err(Handler19ResolveError::UnsupportedSelector { found: selector });
}
if instruction.header_words[1] != u32::MAX {
return Err(Handler19ResolveError::UnsupportedControlWord {
found: instruction.header_words[1],
});
}
if instruction.header_words[2] != u32::MAX {
return Err(Handler19ResolveError::UnsupportedResultSave {
found: instruction.header_words[2],
});
}
self.resolve_handler19(instruction, input)
}
fn resolve_handler19_target( fn resolve_handler19_target(
&self, &self,
instruction: &ScriptInstruction, instruction: &ScriptInstruction,
@@ -856,8 +978,10 @@ pub struct Handler2Record {
pub key: Handler2RecordKey, pub key: Handler2RecordKey,
/// Resolved slot 1 scalar. /// Resolved slot 1 scalar.
pub scalar_1: f32, pub scalar_1: f32,
/// Initial and per-refresh counter word from resolved slot 2. /// Current counter word stored at original record offset `+0x24`.
pub counter: u32, pub counter: u32,
/// Original record offset `+0x28`: slot 2 captured when the record was created.
increment: u32,
/// Resolved slot 3 word. /// Resolved slot 3 word.
pub word_3: u32, pub word_3: u32,
/// Resolved slot 6 scalar. /// Resolved slot 6 scalar.
@@ -892,8 +1016,8 @@ impl Handler2RecordScheduler {
/// ///
/// The original compares the three identity words bit-for-bit. On an /// The original compares the three identity words bit-for-bit. On an
/// existing key, it refreshes only when `scalar_1` compares unequal; that /// existing key, it refreshes only when `scalar_1` compares unequal; that
/// update replaces `scalar_1` and `scalar_6`, then adds the record's own /// update replaces `scalar_1` and `scalar_6`, then adds the slot-2 word
/// slot-2 counter word with x86 wrapping arithmetic. /// captured when that record was created, with x86 wrapping arithmetic.
pub fn submit(&mut self, input: Handler2RecordInput) -> Handler2RecordUpdate { pub fn submit(&mut self, input: Handler2RecordInput) -> Handler2RecordUpdate {
let key = Handler2RecordKey::from(input); let key = Handler2RecordKey::from(input);
if let Some((index, record)) = self if let Some((index, record)) = self
@@ -905,7 +1029,7 @@ impl Handler2RecordScheduler {
if !handler_two_scalar_equal(record.scalar_1, input.scalar_1) { if !handler_two_scalar_equal(record.scalar_1, input.scalar_1) {
record.scalar_1 = input.scalar_1; record.scalar_1 = input.scalar_1;
record.scalar_6 = input.scalar_6; record.scalar_6 = input.scalar_6;
record.counter = record.counter.wrapping_add(input.word_2); record.counter = record.counter.wrapping_add(record.increment);
return Handler2RecordUpdate { return Handler2RecordUpdate {
index, index,
created: false, created: false,
@@ -923,6 +1047,7 @@ impl Handler2RecordScheduler {
key, key,
scalar_1: input.scalar_1, scalar_1: input.scalar_1,
counter: input.word_2, counter: input.word_2,
increment: input.word_2,
word_3: input.word_3, word_3: input.word_3,
scalar_6: input.scalar_6, scalar_6: input.scalar_6,
}); });
@@ -1193,10 +1318,11 @@ fn read_instruction(
mod tests { mod tests {
use super::{ use super::{
decode, decode_with_limits, parse_varset, Handler15ResolveError, Handler15TargetPayload, decode, decode_with_limits, parse_varset, Handler15ResolveError, Handler15TargetPayload,
Handler19DwordWrite, Handler19InitInput, Handler19ResolveError, Handler2RecordInput, Handler19DwordWrite, Handler19InitInput, Handler19InitResolution, Handler19ResolveError,
Handler2RecordScheduler, Handler30ResolveError, Handler8Reset, Handler8ResolveError, Handler2RecordInput, Handler2RecordScheduler, Handler30ResolveError, Handler8Reset,
ScriptDispatchSelector, ScriptInstruction, VarSetDefault, VarSetError, VarSetType, Handler8ResolveError, ScriptDispatchSelector, ScriptEvent, ScriptInstruction,
VmHostCallbackCommand, GOG_HANDLER_COUNT, INSTRUCTION_WORDS, ScriptPackage, VarSetDefault, VarSetError, VarSetType, VmHostCallbackCommand,
GOG_HANDLER_COUNT, INSTRUCTION_WORDS,
}; };
use fparkan_binary::{DecodeError, Limits}; use fparkan_binary::{DecodeError, Limits};
@@ -1254,12 +1380,20 @@ mod tests {
refreshed: true, refreshed: true,
} }
); );
assert_eq!(scheduler.records()[0].counter, 103); assert_eq!(scheduler.records()[0].counter, 8);
assert_eq!( assert_eq!(
scheduler.records()[0].scalar_6.to_bits(), scheduler.records()[0].scalar_6.to_bits(),
12.0_f32.to_bits() 12.0_f32.to_bits()
); );
let refreshed_again = handler_two_input(3.5, -0.0, 3.0, 13.0, 7);
assert!(scheduler.submit(refreshed_again).refreshed);
assert_eq!(scheduler.records()[0].counter, 12);
assert_eq!(
scheduler.records()[0].scalar_6.to_bits(),
13.0_f32.to_bits()
);
let positive_zero_key = handler_two_input(2.5, 0.0, 3.0, 12.0, 1); let positive_zero_key = handler_two_input(2.5, 0.0, 3.0, 12.0, 1);
assert_eq!(scheduler.submit(positive_zero_key).index, 1); assert_eq!(scheduler.submit(positive_zero_key).index, 1);
assert_eq!(scheduler.records().len(), 2); assert_eq!(scheduler.records().len(), 2);
@@ -1272,8 +1406,13 @@ mod tests {
let update = scheduler.submit(handler_two_input(f32::NAN, 1.0, 2.0, 4.0, 2)); let update = scheduler.submit(handler_two_input(f32::NAN, 1.0, 2.0, 4.0, 2));
assert_eq!(update.index, 0); assert_eq!(update.index, 0);
assert!(update.refreshed); assert!(update.refreshed);
assert_eq!(scheduler.records()[0].counter, 1); assert_eq!(scheduler.records()[0].counter, u32::MAX - 1);
assert_eq!(scheduler.records()[0].scalar_6.to_bits(), 4.0_f32.to_bits()); assert_eq!(scheduler.records()[0].scalar_6.to_bits(), 4.0_f32.to_bits());
let update = scheduler.submit(handler_two_input(f32::NAN, 1.0, 2.0, 5.0, 17));
assert!(update.refreshed);
assert_eq!(scheduler.records()[0].counter, u32::MAX - 2);
assert_eq!(scheduler.records()[0].scalar_6.to_bits(), 5.0_f32.to_bits());
} }
#[test] #[test]
@@ -1544,6 +1683,281 @@ STRING( 8, ignored, ignored, ignored)\r\n";
); );
} }
#[test]
fn handler_nineteen_init_uses_first_exact_event_and_keeps_instruction_order() {
let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n")
.expect("DWORD targets");
let action = |references: Vec<u32>| ScriptInstruction {
header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
references,
};
let package = ScriptPackage {
opcode_handler_count: 73,
events: vec![
ScriptEvent {
name_len: 11,
name_raw: b"Init\0suffix\0".to_vec(),
event_word: 0x1234,
instructions: vec![action(vec![0, 1, 2]), action(vec![1, 0, 2])],
},
ScriptEvent {
name_len: 4,
name_raw: b"Init\0".to_vec(),
event_word: 0x5678,
instructions: vec![ScriptInstruction {
header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0],
references: Vec::new(),
}],
},
],
trailing_bytes: Vec::new(),
raw: std::sync::Arc::from(&b""[..]),
};
let result = varset
.resolve_handler19_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
)
.expect("valid first Init event");
assert_eq!(
result,
Handler19InitResolution::Executed {
event_index: 0,
event_word: 0x1234,
writes: vec![
Handler19DwordWrite { index: 0, value: 7 },
Handler19DwordWrite { index: 1, value: 8 },
Handler19DwordWrite { index: 2, value: 9 },
Handler19DwordWrite { index: 1, value: 7 },
Handler19DwordWrite { index: 0, value: 8 },
Handler19DwordWrite { index: 2, value: 9 },
],
}
);
}
#[test]
fn handler_nineteen_init_missing_exact_name_has_no_writes() {
let varset = parse_varset(b"").expect("empty varset");
let package = ScriptPackage {
opcode_handler_count: 73,
events: vec![
ScriptEvent {
name_len: 4,
name_raw: b"init\0".to_vec(),
event_word: 1,
instructions: Vec::new(),
},
ScriptEvent {
name_len: 4,
name_raw: b"INIT\0".to_vec(),
event_word: 2,
instructions: Vec::new(),
},
],
trailing_bytes: Vec::new(),
raw: std::sync::Arc::from(&b""[..]),
};
assert_eq!(
varset.resolve_handler19_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
),
Ok(Handler19InitResolution::Missing)
);
}
#[test]
fn handler_nineteen_init_accepts_empty_first_event_as_executed_with_no_writes() {
let varset = parse_varset(b"").expect("empty varset");
let package = ScriptPackage {
opcode_handler_count: 73,
events: vec![
ScriptEvent {
name_len: 4,
name_raw: b"Init\0".to_vec(),
event_word: 0x1234,
instructions: Vec::new(),
},
ScriptEvent {
name_len: 4,
name_raw: b"Init\0".to_vec(),
event_word: 0x5678,
instructions: vec![ScriptInstruction {
header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0],
references: Vec::new(),
}],
},
],
trailing_bytes: Vec::new(),
raw: std::sync::Arc::from(&b""[..]),
};
assert_eq!(
varset.resolve_handler19_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
),
Ok(Handler19InitResolution::Executed {
event_index: 0,
event_word: 0x1234,
writes: Vec::new(),
})
);
}
#[test]
fn handler_nineteen_init_reports_unsupported_selector_and_discards_prior_writes() {
let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n")
.expect("DWORD targets");
let package = ScriptPackage {
opcode_handler_count: 73,
events: vec![ScriptEvent {
name_len: 4,
name_raw: b"Init\0".to_vec(),
event_word: 0,
instructions: vec![
ScriptInstruction {
header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
references: vec![0, 1, 2],
},
ScriptInstruction {
header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0],
references: Vec::new(),
},
ScriptInstruction {
header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
references: vec![0, 1, 2],
},
],
}],
trailing_bytes: Vec::new(),
raw: std::sync::Arc::from(&b""[..]),
};
assert_eq!(
varset.resolve_handler19_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
),
Ok(Handler19InitResolution::Unsupported {
event_index: 0,
instruction_index: 1,
error: Handler19ResolveError::UnsupportedSelector {
found: super::ScriptDispatchSelector::Handler(18),
},
})
);
}
#[test]
fn handler_nineteen_init_stops_at_unsupported_selector_before_any_write() {
let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n")
.expect("DWORD targets");
let package = ScriptPackage {
opcode_handler_count: 73,
events: vec![ScriptEvent {
name_len: 4,
name_raw: b"Init\0".to_vec(),
event_word: 0,
instructions: vec![
ScriptInstruction {
header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0],
references: Vec::new(),
},
ScriptInstruction {
header_words: [19, u32::MAX, u32::MAX, 0, 0, 3, 0],
references: vec![0, 1, 2],
},
],
}],
trailing_bytes: Vec::new(),
raw: std::sync::Arc::from(&b""[..]),
};
assert_eq!(
varset.resolve_handler19_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
),
Ok(Handler19InitResolution::Unsupported {
event_index: 0,
instruction_index: 0,
error: Handler19ResolveError::UnsupportedSelector {
found: super::ScriptDispatchSelector::Handler(18),
},
})
);
}
#[test]
fn handler_nineteen_init_types_unsupported_control_and_result_save() {
let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n")
.expect("DWORD targets");
let input = Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
};
let instruction = |control_word, result_word| ScriptInstruction {
header_words: [19, control_word, result_word, 0, 0, 3, 0],
references: vec![0, 1, 2],
};
let resolve = |instruction| {
let package = ScriptPackage {
opcode_handler_count: 73,
events: vec![ScriptEvent {
name_len: 4,
name_raw: b"Init\0".to_vec(),
event_word: 0,
instructions: vec![instruction],
}],
trailing_bytes: Vec::new(),
raw: std::sync::Arc::from(&b""[..]),
};
varset.resolve_handler19_init(&package, input)
};
assert_eq!(
resolve(instruction(0, u32::MAX)),
Ok(Handler19InitResolution::Unsupported {
event_index: 0,
instruction_index: 0,
error: Handler19ResolveError::UnsupportedControlWord { found: 0 },
})
);
assert_eq!(
resolve(instruction(u32::MAX, 2)),
Ok(Handler19InitResolution::Unsupported {
event_index: 0,
instruction_index: 0,
error: Handler19ResolveError::UnsupportedResultSave { found: 2 },
})
);
}
#[test] #[test]
fn handler_fifteen_resolves_exact_typed_target_by_place_operands() { fn handler_fifteen_resolves_exact_typed_target_by_place_operands() {
let varset = parse_varset( let varset = parse_varset(
@@ -0,0 +1,87 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
internal static class Handler2NativeOracle {
private const uint LoadWithAlteredSearchPath = 0x00000008;
private const int RefreshRecordRva = 0x5070;
private const string ExpectedSha256 = "c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc";
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr LoadLibraryEx(string fileName, IntPtr file, uint flags);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool FreeLibrary(IntPtr module);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)]
private delegate void RefreshRecord(IntPtr record);
private static int Main() {
if (IntPtr.Size != 4) {
Console.Error.WriteLine("Build this oracle with an x86 target; ai.dll is 32-bit.");
return 2;
}
string dllPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_AI_DLL");
if (String.IsNullOrEmpty(dllPath) || !File.Exists(dllPath)) {
Console.Error.WriteLine("Set FPARKAN_ORIGINAL_AI_DLL to the original GOG ai.dll.");
return 3;
}
string actualSha256;
using (SHA256 sha256 = SHA256.Create())
using (FileStream input = File.OpenRead(dllPath)) {
byte[] digest = sha256.ComputeHash(input);
StringBuilder hex = new StringBuilder(digest.Length * 2);
foreach (byte value in digest) hex.Append(value.ToString("x2"));
actualSha256 = hex.ToString();
}
if (actualSha256 != ExpectedSha256) {
Console.Error.WriteLine("Unexpected ai.dll SHA-256: " + actualSha256);
return 4;
}
IntPtr module = LoadLibraryEx(dllPath, IntPtr.Zero, LoadWithAlteredSearchPath);
if (module == IntPtr.Zero) {
Console.Error.WriteLine("LoadLibraryEx failed: " + Marshal.GetLastWin32Error());
return 5;
}
try {
var refresh = (RefreshRecord)Marshal.GetDelegateForFunctionPointer(
IntPtr.Add(module, RefreshRecordRva), typeof(RefreshRecord));
IntPtr record = Marshal.AllocHGlobal(100);
try {
for (int offset = 0; offset < 100; offset += 4) Marshal.WriteInt32(record, offset, 0);
Marshal.WriteInt32(record, 0x24, 4);
Marshal.WriteInt32(record, 0x28, 4);
AssertRefresh(refresh, record, 8);
AssertRefresh(refresh, record, 12);
Marshal.WriteInt32(record, 0x24, unchecked((int)UInt32.MaxValue));
Marshal.WriteInt32(record, 0x28, unchecked((int)UInt32.MaxValue));
AssertRefresh(refresh, record, UInt32.MaxValue - 1);
AssertRefresh(refresh, record, UInt32.MaxValue - 2);
}
finally { Marshal.FreeHGlobal(record); }
}
finally { FreeLibrary(module); }
return 0;
}
private static void AssertRefresh(RefreshRecord refresh, IntPtr record, uint expectedCounter) {
uint savedIncrement = unchecked((uint)Marshal.ReadInt32(record, 0x28));
refresh(record);
uint actualCounter = unchecked((uint)Marshal.ReadInt32(record, 0x24));
uint actualIncrement = unchecked((uint)Marshal.ReadInt32(record, 0x28));
Console.WriteLine("record[+0x24]=0x{0:X8} record[+0x28]=0x{1:X8}",
actualCounter, actualIncrement);
if (actualCounter != expectedCounter || actualIncrement != savedIncrement) {
throw new InvalidOperationException(String.Format(
"Expected +0x24=0x{0:X8}, +0x28 unchanged at 0x{1:X8}.",
expectedCounter, savedIncrement));
}
}
}
+566 -4
View File
@@ -31,9 +31,206 @@ primitives, sound events и reads в `Effect.dll`.
checks. Полная instruction grammar `.scr`, semantics opcodes и serialization checks. Полная instruction grammar `.scr`, semantics opcodes и serialization
state ещё не восстановлены. state ещё не восстановлены.
Для GOG `ai.dll` статически восстановлены пять ветвей evaluator-а 16-byte
записей условий по RVA `0x5180`, включая проверки типов, поиск объектов,
короткие выходы из обхода списков и запись байта завершения. Однако не найден
код-производитель этого массива и контракт его владельца с runtime. Reader
`FUN_10011b20` хранит описатели событий и вложенных записей `.scr` во внутренних
контейнерах с шагом `0x14` и `0x20`; сами дисковые структуры имеют переменную
длину из-за length-prefixed имён и списков ссылок. Связь этих контейнеров с
массивом evaluator-а не доказана. Виртуальные методы,
через которые проходят проверки, остаются внешней границей, а сам evaluator
не сравнивался с работающим оригиналом. Описание этих ветвей — статический
вывод по одному известному `ai.dll`, а не утверждение о совпадении во время
исполнения.
Для начального сценарного события закрыт более узкий путь. В проверенной GOG
сборке `ai.dll` SHA-256
`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` функции
`FUN_10011f40` (RVA `0x11f40`) и `FUN_10011f10` (RVA `0x11f10`) выбирают первое
имя-событие C-string `Init` и запускают его инструкции по порядку. Для координат
из TMA найден и проверен ещё один участок: `iron3d.dll` SHA-256
`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`,
`FUN_100392e0` (RVA `0x392e0`) выполняет x87 `FISTP qword` и передаёт младшие
DWORD координат пятым и шестым аргументами `CreateSuperAI`. Затем
`ai.dll!FUN_10005c70` (RVA `0x5c70`) преобразует DWORD в `float`, а `Handler(19)`
возвращает его к DWORD усечением.
Две x86-проверки вызвали эти оригинальные DLL-функции на изолированной
синтетической памяти. Caller oracle подтвердил AutoDemo-координаты
`(500.2857, 751.8217) -> (500, 752)`, ties-to-even в nearest-режиме и переход
`-0.6 -> 0xffffffff`. AI oracle проверил оба набора AutoDemo anchor-ов,
дублирующий `Init`, имя `Init\\0suffix\\0`, отсутствие события, а также
нативную связку `FUN_10005c70` → `Handler(19)`: DWORD `(0xffffffff, 10001)` стал
полями `(2^32, 10001.0)` и varset-записями `(0, 10001, 2)`. Эти проверки
подтверждают только изолированные участки ABI.
В одном read-only snapshot запущенной GOG AutoDemo были связаны записи кланов
Iron3D и указатели `GetSuperAI` из `ai.dll` (таблица RVA `0x55398`). Клан 0 имел
исходные float-координаты `(500.2857, 751.8217)` и поля AI
`(+0x80, +0x84, +0x7c) = (500.0, 752.0, 0)`; клан 1 имел `(728.0, 449.0)` и
`(728.0, 449.0, 1)`. Первый набор связывает те же значения в одном процессе и
выбирает ближайшее округление среди направлений округления x87. Синтетические
tie-векторы проверяют, как сам `FISTP` реагирует на каждый RC, но не измеряют
control word процесса.
Отдельно проверены загрузка лицензированного AutoDemo в Rust и preview
лицензированной кампании. AutoDemo даёт raw TMA anchor-ы
`[(500.2857, 751.8217), (728.0, 449.0)]` и записи `[(500, 752, 0),
(728, 449, 1)]`; preview кампании сохраняет package и статус `Unsupported`,
не применяя частичные записи. Это подтверждает загрузчик и узкий Init resolver
Rust.
Нативный конструктор показывает предупреждение при координате выше `10000`, но
продолжает работу. Rust моделирует числовую запись, а не это UI-предупреждение;
на первом `FISTP qword` он явно завершает загрузку ошибкой для NaN, infinity и
значений вне signed-qword диапазона. В первом событии `Init` Rust поддерживает
только action-форму `Handler(19)` (`control word` и `result reference` равны
`0xffffffff`). Неизвестная инструкция даёт `Unsupported` в preview, не применяя
накопленные частичные записи; при полной загрузке это ошибка. Это ограничение
Rust исполнителя, не правило оригинального dispatcher-а. Ошибочные ссылки и
типы остаются ошибками в обоих режимах.
Для GOG-сборки также найден статический host call path в `Init`. В
`iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) одна ветвь сначала проходит записи
кланов и вызывает для них `FUN_100392e0`, затем вызывает `FUN_10039440` (RVA
`0x39440`). Эта функция передаёт AI callback-у пару явных аргументов через
vtable slot `+0x14`. В `ai.dll!FUN_10001ae0` (RVA `0x1ae0`) аргумент callback-а
записывается в `AI+0x3fc`; затем объект `AI+0x438` получает вызов Behavior
vslot `+0x2c`. В проверенной `Behavior.dll` (SHA-256
`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`)
`FUN_1001adb0` (RVA `0x1adb0`) выполняет начальную подготовку расписания.
Метод ищет первый `Init` только при нулевом latch
`AI+0x8a8`, а при dispatch временно ставит и снимает recursion guard
`AI+0x78`. В этой ветви поэтому известны callback ingress и порядок
warm-start → `Init`, но смысл аргумента callback-а здесь не сопоставлен с Rust
`MissionMode`.
Изолированный x86 oracle вызывает исходные `iron3d.dll!FUN_10039440`,
`ai.dll!FUN_10001ae0` и script dispatcher на синтетических записях и проверяет
режим, guard, latch, Handler(19) writes и повторный вызов. Отдельный oracle
исполняет `Behavior.dll!FUN_1001adb0` с искусственно заданной очередью, сверяя
срок, шаг LCG и очистку полей. Это подтверждает ABI и локальные переходы исходных
функций, но не доказывает, что полная штатная загрузка выбранной миссии в
оригинальном процессе доходит до этой ветви в том же порядке. Метод
`FUN_10001ae0` оставляет latch `+0x8a8` сброшенным, когда `Init` не найдено;
если оно найдено при уже занятом guard `+0x78`, dispatch пропускается, но latch
всё равно устанавливается. Rust пока запускает обработанный поднабор при каждой
полной загрузке и не моделирует аргумент callback-а `AI+0x3fc`, guard `+0x78` или
latch `+0x8a8`.
Rust `load_mission` принимает явный `queue_time_ms`, а не читает оригинальное
время очереди. Новый `Engine` начинает локальный Behavior RNG с seed `1` и
учитывает шаги генератора этой стартовой последовательности; не восстановлены
другие потребители исходного генератора, поэтому равенство полной случайной
последовательности не утверждается. Начальная фаза выполняется после регистрации
объектов в создаваемом мире Rust;
это интеграционный выбор runtime, а не подтверждённая эквивалентность всех
host construction phases. Не проверена другая лицензированная миссия с
непустым `Init`. Вызов `Behavior.dll!FUN_10019e80` (RVA `0x19e80`, SHA-256
`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`) исполнялся
на синтетических интерфейсах. Путь членства в реальных distributor-списках
связан с полями TMA и Unit DAT: `World3D!CreateObject` передаёт selector
и class word через дескриптор в Behavior (RVA `0x7e55`, `0x7e62`), а
`Behavior.dll!FUN_10005e60` (RVA `0x5e60`) проверяет высокий бит registration
key, AI и distributor. Selector из первого DWORD после имени ресурса выбирает
слот `GetSuperAI`; таблица этой функции находится в `ai.dll` RVA `0x55398`.
Хеши `MisLoad.dll`, `iron3d.dll`, `ArealMap.dll`, `World3D.dll` и
`Behavior.dll`, использованных для цепочки, перечислены в
[справочнике TMA](../reference/tma.md).
Отдельный режимный бит Behavior нельзя выводить из `MissionMode`. Конструктор
Behavior (`Behavior.dll`, RVA `0x3600`) вызывает `World3D!stdGetGameMode` (RVA
`0x14750`), который читает host-global по RVA `0x795130` в `World3D.dll` (SHA-256
`17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e`). В
обычном пути `stdInitGame` (RVA `0x13e90`, запись режима около `0x14040`)
формируется отдельное host-значение; в наблюдавшемся запуске AutoDemo getter
вернул `4`, и бит `0x2` был снят. Это объясняет соответствующий gate при
создании Behavior, но не устанавливает отображение между host-режимом и Rust
`MissionMode`.
Наблюдение обычного запуска лицензированной GOG AutoDemo подтвердило четыре
добавления с ключами `0x80000003..0x80000006` в два клановых списка. Полная
загрузка Rust сохраняет соответствующие `ObjectHandle` в точном доступном слоте
`GetSuperAI` до стартового `Init`; preview не создаёт членств. Лицензированный
тест связывает эти записи с TMA ID 4–7, selector и Unit DAT class. Это
подтверждает только состав списков, не точную эквивалентность всей загрузки или
регулярного шага Behavior.
Нативный `Behavior.dll!Add` (RVA `0x1aac0`) и `normalize` (RVA `0x1ac90`)
проверены прямыми x86-вызовами на синтетических записях при x87 control word
`0x007f`, измеренном также у игры на входах обоих методов. Rust повторяет
начальные доли и нормализацию отдельными операциями `f32`, а повторную запись
сопоставляет по `ObjectHandle`, меняя только долю существующего участника.
Пять DWORD после доли хранятся сырыми; ненулевое первое слово закрепляет вес,
смысл четырёх следующих не установлен. Лицензированный AutoDemo-тест проверяет
список TMA-объектов и получившиеся начальные доли по кланам, но это не сравнение
живых весов миссии с полями исходных runtime-записей. Не восстановлено соединение
с реальными интерфейсами ArealMap, нужными `FUN_10019e80`; полный Behavior tick
flow и общий dispatcher событий также остаются открытыми.
Начальные свойства `CurrentOre`/`MaximumOre` восстановлены отдельно от
регулярного обмена. В проверенных `Behavior.dll` (SHA-256
`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`) RVA
`0x8790` выбирает начальные значения по class word, `0xb110` регистрирует
дескрипторы кодов 4/5, `0xb470` пишет только код 5, а `0xb5b0` читает код 4
как дополнительное слово и код 5 как основное. `MisLoad.dll` (SHA-256
`c607762bb67db14ca530927c8e1347339b62b0a163ac90c708ee83142292e2bf`) RVA
`0x40b0` выбирает первое свойство TMA с точным именем `CurrentOre` и передаёт
его `raw_value[1]`; setter сохраняет биты напрямую. В AutoDemo snapshot на этих
входах было восемь объектов Unit DAT, у всех raw `CurrentOre` равен нулю;
четыре объекта класса `0x01008000` не вошли в distributor, но остались среди
инициализированных Rust-состояний. Поэтому путь нулевого значения наблюдался
наживую, но ненулевое TMA-значение в живой миссии пока не проверено.
Синтетический x86 oracle вызвал `Behavior.dll!FUN_10008790` напрямую для классов
`0x80000004`, `0x80000008`, `0x01002000`, `0x01004000` и `0x01008000`; перед
вызовом в записи были значения `CurrentOre=123` и `MaximumOre=-1`, так что
проверено их перезаписывание. Тест выставляет `a64=1`, чтобы отключить отложенный
side effect обёртки setter-а. Это подтверждает ветви initializer-а, но не весь
путь его запуска и связи с остальными фазами объекта. Другой изолированный x86
oracle проверяет ненулевое raw-слово `CurrentOre`, только чтение `MaximumOre` и
перестановку строк дескрипторов на синтетических записях свойств.
Не проверен селектор 4 для внешнего config-like ресурса: в наблюдавшемся
AutoDemo его вызова нет, а установленный `Behavior.ini` не содержит
`Mine_MaxOre`, `Storage_MaxOre` или `Transport_MaxOre`. Поэтому Rust пока
воспроизводит доказанные class defaults (500, 4000 и 2000) и не заявляет
поддержку конфигурационного переопределения. Отдельно не доказано, что каждый
Unit DAT создаёт тот же Behavior-компонент во всех host factory paths. Rust
ограничивает новую запись полными TMA-объектами с Unit DAT; preview, прямые
prototype roots без Unit DAT и отложенные spawn после исходной загрузки вне
этой модели. Путь в `ArealMap.dll` (SHA-256
`a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea`) к
`CreateObject` по RVA `0x14fd0`/`0x15290` показывает выбор World3D factory
selector `3` или `4` по старшему биту. Для ветки GOG `AniMesh.dll` (SHA-256
`65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`) RVA
`0x2ff0` показывает одну ветку создания: после QueryInterface для IID `0x204`
выполняется отдельный импорт `GetQueue()`, а на возвращённом объекте вызывается
метод через vtable slot `+0x30`. `CreateBehaviour` вызывается только при
ненулевом результате этого метода и host class `3` или `4`; class `4` получает
адреса полей prototype `+0x140` и `+0x160`. Семантика метода slot `+0x30` не
установлена. Другие factory paths, нулевой результат и spawn после исходной
загрузки остаются за границами подтверждённой ветки.
Для `Handler(30)` отдельно подтверждены обратный вызов `iron3d.dll` RVA
`0x611d0` (SHA-256 `d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`),
начальные поля IGame и проверки режима/состояния. Обработчик выбора миссии
выставляет режим 1 (RVA `0x29142`), а обработчик таймера — режим 3 (RVA `0x70f3`).
Небольшая тестовая x86-программа сравнивает этот обратный вызов с исходной DLL
на синтетических структурах. Пока не восстановлен сквозной путь от выбора и
инициализации миссии к первому событию Handler(30): Rust загружает миссию, но
ещё не выполняет штатный `stdInitGame`, переход состояния `5 -> 4`, запуск и
игровой цикл с полной диспетчеризацией событий. Поэтому
совпадение изолированного обратного вызова не доказывает совместимость живого
сценария.
Закрывающий эксперимент: найти dispatcher loop в `ai.dll`, сопоставить jump Закрывающий эксперимент: найти dispatcher loop в `ai.dll`, сопоставить jump
table с instruction sizes, построить disassembler и сравнить выполнение table с instruction sizes, построить disassembler и сравнить выполнение
коротких scripts с оригиналом. коротких scripts с оригиналом. Для evaluator-а условий отдельно проследить
создание записей `this + 0x34`, выполнить те же входные данные в оригинале и
сверить байт завершения, порядок поиска, ранние выходы и граничный случай
`payload == count`.
## Saves and campaign state ## Saves and campaign state
@@ -50,9 +247,374 @@ CTLD и связанные resources структурно читаются, coun
известны. Не названы все секции, shape types, coefficients и точный contact известны. Не названы все секции, shape types, coefficients и точный contact
solver. То же относится к редким MSH auxiliary streams и части CTPT/NDPR flags. solver. То же относится к редким MSH auxiliary streams и части CTPT/NDPR flags.
Закрывающий эксперимент: трассировать `LoadControlSystem`, ### Класс Control при загрузке Unit DAT
`LoadPhysicalModel`, `CreateCollManager` и создание collision objects; связать
каждый изменяемый field с созданным shape, contact или реакцией на движение. Для двух подтверждённых полных Unit-DAT путей Rust теперь сохраняет класс,
который передаётся конструктору Control, вместе с ветвью-источником. Лицензированный
`min_l_n1.dat` проходит high-bit route и даёт класс `3`; synthetic-тесты покрывают
оба маршрута, известные FourCC и отсутствующие root metadata. Отдельное
исполнение проверило только чистый getter `Terrain.dll+0x33ae0` (SHA-256 и точный
RVA приведены в технической главе). Остальная цепочка через ArealMap, World3D,
Terrain/AniMesh и создание controller восстановлена по статическим инструкциям,
таблицам и конфигурации. Она не означает исполнения оригинального полного
loader-а, Control activation, движения или периодического tick.
Не моделируются произвольный ненулевой host с неудачным QueryInterface, preview
без полного Unit DAT, отложенный spawn и другие host/factory routes. В частности,
generic failed-query путь native оставляет class `0` и не читает FourCC; это не
эквивалентно canonical no-host lookup корневой registry entry. Остаточные вопросы
об активации и игровом поведении описаны в технической главе.
Одна цепочка Unit DAT → AniMesh descriptor → Control теперь восстановлена
статически в [главе о Behavior и Control](../tomes/06-behavior.md): raw
`parent_or_link` копируется в descriptor. В non-`EXTO` registry path parent ID
разрешается в индекс устройства и сдвигает локальный индекс только когда parent
ID не равен `-1` и IID `0x202` доступен; иначе используется raw локальный индекс.
В этом потоке нулевой raw kind пропускает узел вместе с потомками, поэтому общий
assigned ID нельзя получать из preorder-позиции записи. Полная загрузка миссии
в FParkan строит упорядоченную базовую проекцию таблицы
Control для объектов с Unit DAT: корневые записи добавляются, `EXTO` добавляет
строки с ключами глобальной MSH-иерархии, а `INTO` обновляет первую строку
выбранного индекса либо добавляет список при sentinel `-1`. Проверка полной
загрузки на оригинальном `min_l_n1.dat` получает 11 строк: 10 корневых записей,
четыре замены `INTO` и одну добавленную строку дочернего `EXTO`. Ветви C4/C8 и
добавление `EXTO` подтверждены машинными инструкциями оригинальных модулей, но
полный результат FParkan не сравнивался с таблицей из запущенной игры. Тест на
искусственном дереве также проверяет ID после пропущенного поддерева с raw kind
`0`. Проекция хранит общие поля и происхождение записей; она не исполняет все
обработчики Control.
Control различает добавление устройства и применение одной записи к уже
выбранному устройству. Исполнение оригинальной 32-битной DLL на искусственных
данных подтверждает общие поля переопределения для получателей tags `2`, `3`,
`4`, `5`, `8`, `9`, `10`, `15`, `19`, `21`, `25` и `27`: остаются прежними слот,
ключ, ID компонента, множитель и класс, а коэффициенты копируются из новой
записи. Обёртка tag `2` по RVA `0x29650` дополнительно пересчитывает два кэша
и обнуляет массив состояния map-строк; это выполнено на native DLL, но пока
не моделируется в базовой Rust-проекции. Её callback `0x29940` проверен только
с неизменной пустой парой ресурсов; стрельба не проверена.
Искусственный tag `9`-объект создан
без FX-хоста (`+0x3c == 0`), а запись не содержит строк таблицы map; поэтому
работа FX-хоста здесь не проверялась. В Rust parser-е
`Unsupported { typed_record_index: Some(_) }` означает, что разбор остановился
внутри списка typed records; без индекса остановки unsupported может относиться
к effect-tail после уже разобранного списка. Эти случаи нужно различать.
Ветка default native-фабрики вызывает конструктор по RVA `0x20800`, поэтому
неизвестный Rust parser-у tag не доказывает отказ оригинального loader-а. Подготовка assets
сейчас пропускает Control refs у non-geometric prototype, поэтому отсутствие
подготовленного CTL должно оставаться явным unavailable result, а не
трактоваться как пустой список.
Global Control getters `1/2` и setter `1` проверены в native x86 oracle для
искусственных factory/descriptor pairs `9`, `15` и `19`, а в Rust реализованы
для текущего descriptor tag `19` с factory tags `9`, `15`, `19` и `25`. Для tag
`25` общий getter slot `+0x10` подтверждён чтением vtable, но агрегатный вызов
этого класса native oracle не запускал. Rust API обращается к строкам проекции
конкретного живого `ObjectHandle`; это запрос к уже загруженной таблице, а не
исполнение Control или Behavior tick. Setter `17`, остальные factory tags и
внешние эффекты их методов не включены. Для tag `9` selector `0` использует
отдельный scale `+0x11c`; остальные поддержанные классы используют коэффициент
напрямую. Пустой набор и нулевой знаменатель остаются native no-result, а
неподдержанный factory tag и расчётный диапазон произведения/отношения за
нормальным диапазоном `f32` становятся явной ошибкой Rust.
Проекция полного mission load недоступна для неполного списка typed records,
Control resource у non-geometric prototype и некоторых неразрешённых EXTO node
mappings; preview и объекты без Unit DAT root её не строят. Её соответствие
полной загрузке игры для всех типов компонентов и factory paths ещё не
установлено. Полный Rust load AutoDemo проверяет доступность всех восьми таблиц
и числа строк `14, 12, 15, 13, 12, 12, 11, 11`; это не сравнение с живой
таблицей оригинала. Setter `1` меняет multiplier `+0x4c` выбранных строк, но расписание
и lifetime этих объектов после конструктора и в отложенной загрузке остаются
непроверенными.
Для `Control.dll` (SHA-256
`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`) tag-26
getter selector `0x300` восстановлен локально: RVA `0x21d00` читает provider по
ключу `fixed_words[1]`, затем умножает provider, CTLD coefficient `fixed_words[11]`
и поле объекта `+0x4c` именно в таком порядке. Provider getter RVA `0xdc40`
читает type-1 поле `+0x14` строки, которую `FUN_10008b10` (RVA `0x8b10`)
добавляет из NDP в таблицу с шагом `0x2c`; `FUN_1000b240` (RVA `0xb240`)
инициализирует это поле как `1.0`. Начальный Rust snapshot повторяет порядок
NDP строк корневого ресурса и примонтированных `EXTO` (без первой строки
последнего), сохраняя исходные таблицу и строку как provenance. Ключ устройства
сначала меняется по подтверждённому MSH mapping для `EXTO`, затем это готовое
значение применяется напрямую как индекс provider sequence. Длина этой
sequence определяется NDP: она не дополняется по числу узлов модели и ключ не
пересчитывается ещё раз по числу provider rows. `INTO` provider строк не
добавляет. Для примонтированного `EXTO` с пустым NDP невозможно безопасно
применить native skip-first путь, поэтому Rust явно сообщает unavailable;
таблица из одной строки допустима и даёт нулевой вклад. Если обязательный NDP
sidecar недоступен, запрос `0xe00` сообщает это отдельно, оставляя независимые
агрегатные selectors `1/2` доступными.
Поля `parent_index`, Control flags и начальный stage count для NDP-строк
проецируются из AniMesh по глобальному ordinal; точные правила, native ABI
векторы и лицензированная root/`EXTO` fixture описаны в
[техническом подразделе](script-vm.md#начальная-msh-часть-строки-provider).
MSH-часть проверки подтверждает начальный снимок: сборка runtime parent-ов
остаётся статическим выводом по RVA `AniMesh.dll+0xa460`, а последующие
изменения в полном Control/animation tick-е не проверялись.
Для provider-строки Rust хранит amount `+0x08`, накопитель `+0x10`, обработанное
количество `+0x0c`, долю `+0x14`, стадию `+0x18` и flags `+0x28` раздельно.
Инициализатор `Control.dll+0xf940` кладёт результат selector `3` в текущее и
обработанное количество; при подтверждённых единичных множителях это raw
`max_volume` NDP. Таблица также хранит capacity aggregate `+0x58c`, текущий
aggregate `+0x590`, dirty `+0x598` и начальный gate `+0x618`.
Прямой API `apply_loaded_mission_control_node_delta` сохраняет native четвёртый
аргумент `0`: меняет amount, долю, флаг истощения, aggregate и dirty, оставляя
`+0x10` нетронутым. Публикация `0x200` отражается в CPU-проекции соответствующего
узла модели по правилу исходного обработчика; стадия и соседние узлы не меняются.
Обработанное количество при этой прямой операции остаётся прежним.
API `distribute_loaded_mission_control_provider_amount` переносит
`Control+0x10ba0`: proportional mode делит добавку по свободной ёмкости, а
вычитание распределяет по текущим количествам; sequential заполняет строки
вперёд, снимает с конца и использует исходный запрос даже если общая ёмкость
округлилась вниз. Row updater вызывается с четвёртым аргументом `1`, поэтому
`+0x10` получает фактическую разницу amount. После распределения обязателен
`0x11130(root, true)`.
Три API работают только с CPU-проекцией и не подключены к GPU/window path или
игровому Control clock. Отдельный
`advance_loaded_mission_control_budget` переносит `0x2d340` для фабричных
callbacks `9`, `15`, `19`, `26` и общей default-пары; category lookup использует
текущий descriptor tag из подтверждённого префикса `0..=34`. Ставки сохраняются
в `+0x5dc/+0x5e0`. Шаг выполняется транзакционно, а неподдержанный
специализированный callback, provider или диапазон числа возвращает ошибку без частичного
изменения. Тест с нулевым знаменателем генератора подтверждает откат после уже
выполненного изменения потребителем. Пустая таблица всё равно записывает в оба поля отрицательный ноль;
неактивный callback не требует provider lookup. Class-9 constructor начинает
шесть значений `+0x98..+0xac` с `1.0` и шесть дельт `+0xb0..+0xc4` с нуля;
`INTO` сохраняет эти runtime-массивы. Gate `+0x618` изначально
выключен; событие `7` задаёт его по правилу `gate = (arg == 2)` и вызывает
`0x2dc20`, только если gate изменился. События Control в Rust пока не
исполняются, поэтому ресурсный шаг вызывается явно и не подключён к часам.
Capacity aggregate выведен при подтверждённых единичных множителях Unit DAT;
изменение масштабов `0x9ee0`/`0x9f90` не реализовано. Из-за округления `+0x590`
может стать больше сохранённого `+0x58c`; распределение сохраняет этот signed
capacity по native формуле, не вводя отдельного запрета. Если итоговый stage walk
нуждается в неизвестном состоянии, depletion lifecycle, FX или host callback,
распределение сообщает ошибку и не публикует ни одной частичной записи.
Отдельный
`update_loaded_mission_control_node_stage` реализует наследование parent ratio
и stage, ceiling-арифметику, CPU visible stage и копию processed amount.
Отдельная `propagate_loaded_mission_control_node_stage` переносит `0x11130`:
после обновления строки она сканирует более поздние provider-строки и обходит
найденные поддеревья в native depth-first порядке. Для родителей
`[-1, 0, 0, 1, 3]` порядок — `1, 3, 4, 2`. Переносит унаследованный Control
ratio/stage, флаги `0x80/0x100` и drain количества; маска `0x140` пропускает
только перезапись flags, не drain и рекурсию. Ухудшение определяется ростом
стадии либо флагом истощения `0x10` уже обновлённого родителя. Отдельный
`update_loaded_mission_control_node_stage` сохраняет одиночную семантику
`0x11220`.
Пропагация клонирует Control provider-таблицу и публикует её только после
успешного обхода. Неизвестная parent-связь в просматриваемом суффиксе,
lifecycle/visibility/FX callback или неподдержанное численное значение дают
ошибку без частичных изменений. `update_effects = true` отклоняется, если
стадия любого обработанного узла меняется и требуются неисполненные FX/host
callbacks. Licensed двухстадийный узел шахты подтверждает переход `0 → 1` через
новый API при доле `0.5`, без FX и window path. Гибель root/critical узла и
неизвестные flags/aggregate также остаются явными ошибками.
Rust query обращается к загруженному объекту и поддерживает `0xe00` только для
текущего descriptor tag `26` с factory tag `26`; другие активные typed tags и
factory paths остаются явными границами. Запрос читает текущее type-1 значение.
Licensed `min_l_n1.dat` подтверждает изменение `0xe00` и Behavior `0x1002` с
`1.0` до `0.5` после явного уменьшения количества вдвое; чтение получает
текущее значение provider, который также меняет явный бюджетный шаг.
Native oracle проверил изолированные пути распределения, обновления класса `4`,
стадийную арифметику `0x11220`, обход дочерних provider-строк `0x11130` на
interleaved parents `[-1, 0, 0, 1, 3]` с callback-порядком `1, 3, 4, 2` и
visibility transition `0x11920` с исходными AniMesh callbacks. Отдельный
бюджетный вектор исполняет `0x2d340` → `0x2dca0` → tag 15 `0x22bb0` →
`0x10ba0`: подтверждены коэффициенты `0.5` и `1`, точные amount/ratio и
приоритет категории перед порядком устройств. Положительный вклад генератора
в этом векторе подставлен mock-ом. Отдельный вектор с исходным генератором
tag `19` (`0x22980`, `0x229a0`, `0x22a60`) проверяет полную и ограниченную
rate, изменение provider и собственного множителя генератора; отрицательные
параметры и нулевой знаменатель не проверены. Новый оригинальный вектор
`0x2d340` смешивает tag `19` и tag `26`: supply `0.125` покрывает половину
demand `0.25`, итоговые множители равны `0.6875` и `0.5`, а выходы —
`0x80000000` и `0x3e800000`. Тот же oracle подтверждает tag-26 selector `0x500`,
общий reader `0x21d50` и сохранение поля `+0xa4` при `INTO`. Rust-тест
повторяет бюджетную пару и разрешает tag `26` в projection INTO guard, оставляя
теги `1`, `17` и `30` явными границами. Native-векторы бюджетного шага
подтверждают текущие ставки `+0x5dc/+0x5e0`, включая значения `0.2`/`0.4` у
потребителя tag 15, пустой результат `-0.0` и невырожденную пару исходных
устройств tag 19/tag 15 при `dt = 0.375`; Rust-тест повторяет поля этой пары
на синтетической таблице. Rust переносит class-9 rate/update и шестизначную
adjustment `0x25a90`, tag-26 rate/update и setter selector `0x500`, общую
default callback-пару `0x21860/0x21890`, а также category map только для current
descriptor tags `0..=34`. Остальные ещё не описанные специализированные
callbacks остаются явными ошибками.
Лицензированный `min_l_n1.dat` содержит исходные factory/current tags
`[19, 19, 9, 15, 12, 12, 13, 13, 26, 25, 21]`. Публичный API оставляет всю
таблицу без изменений при не конечном интервале. Для конечного интервала
ignored Rust-тест проходит budget по всем 11 строкам без фильтра. При `dt = 0.5`
обе output rates равны `0x3c23d70a`, первые два множителя tag `19` равны
`0x3f7fd70a`, остальные множители остаются `1.0`, а добавка tag `26` остаётся
нулевой. Эта проверка подтверждает только CPU-проекцию реальной таблицы; она
не запускает native полный mission tick и не подключает Control clock.
Успешные бюджетные native-векторы используют синтетические
NDP/Node38-строки и подтверждают локальные переходы и порядок вызовов, а не
полный Control tick на миссии. Прямые oracle-вызовы `0x11220` для одиночной
строки используют `updateEffects = false`, что исключает ветвь Control FX и
`0x11af0`; в отдельном сценарии класса `9` `0xfca0` возвращается сразу после
смены видимости, поэтому связанное с ним движение не проверено. Хотя у строки
Control выставлен flag `0x102`, слово флагов ANI `+0x14` и производные байты
`+0x111..+0x113` изначально равны нулю. Вызовы AniMesh `0x93c0` и `0xa2a0` обновляют производную геометрию и
видимость, это отдельная непроверенная граница, а не FX callbacks. Распределение
и бюджетный шаг вызывают обязательный `0x11130(root, true)`, но их проверенные
векторы имеют одну строку без смены стадии; они не доказывают исполнение FX или
`0x11af0`. Бюджетная
часть активного `0xbcf0` исполнена на синтетическом хосте класса `10`:
при периоде `500` и нулевом RNG проверены пропуск до deadline и на равенстве
`437.5`, затем ресурсный `dt = 0.5` при времени `500`. На том же ограниченном
хосте без устройств seed `0x12345678` подтверждает новые RNG draw при временах
`500`, `501`, `502`, `503` и ресурсные clocks `0`, `0`, `502`, `502`: deadline
пересчитывается при каждом принятом активном вызове. Проверены также точное
равенство с ненулевым RNG, расход RNG при принудительном `+0xd4` и сохранение
RNG/счётчика в неактивной ветви. Движение и очереди исключены условиями этих
векторов; активация `+0xe0` задана явно. Отдельный вектор
класса `4` исполняет событие `4` через `0x31ae0` → `0x314f0`, проверяя
активность, начальные часы `time - 100`, округление DWORD в `f32` и одну
начальную команду. Геометрические callbacks подставлены явно, таблицы
устройств и shapes пусты; полный загрузочный путь не проверен. Источник времени
и доставка прослежены статически:
World3D RVA `0x6bf0` вычисляет часы из `timeGetTime()` с игровым смещением и
поправкой на паузу, затем выполняет первый обход `6/1`, шаг мировой физики и
второй обход `6/0x1c`. AniMesh RVA `0x1370` передаёт часы Control; общий
обработчик Control RVA `0x7830` численно преобразует unsigned DWORD в `f32`
перед primary slot `+0x58`. Фабрика выбирает RVA `0xcbb0` для класса `9` и
RVA `0xbcf0` для остальных. Это подтверждает статическую цепочку вызовов, но не
её исполнение на миссии: связывание интерфейсов, активация устройств и shapes,
остальные ветви `0xbcf0` и
автоматическое изменение provider через clock и бюджет Rust остаются открытыми. Источник контактного коэффициента прослежен статически,
но не исполнен. Подробные векторы и статические границы приведены в
[техническом подразделе о provider-состоянии Control](script-vm.md#изменение-provider-состояния-control).
Отдельный API `advance_loaded_mission_resources` принимает явное время очереди
и обновляет представленные поля `CurrentOre`, строк распределителя и Control
selector `1`. Тот же расчёт вызывают `step_headless` и `frame` с явным
clock очереди; подготовка ресурсов и мировой шаг завершаются атомарно.
Оконное приложение пока использует static preview. Этот путь не обновляет
Control amount, профили или special object-state gate. Если обязательный
запрос позднего объекта не поддержан, транзакция возвращает ошибку без применения
подготовленных изменений. AutoDemo-тест подтверждает связь настоящих профилей с
этим шагом и проверяет откат при ошибке ресурсов или мировой очереди,
но не полный паритет миссии; отдельный aggregate getter Behavior RVA
`0xa490` в oracle не исполнен.
Behavior selectors `0x1002` и `0x1005` используют значения выбранного
профиля из `behpsp.res`, разобранного до создания нового мира. `0x1002` складывает
только текущие descriptor tag `26` запросы `0xe00`, затем применяет
`Transfer_Ore_OffBoard`; `0x1005` возвращает `Transfer_Power_Out`. Это начальный
снимок профиля и provider-строк. Полная загрузка сохраняет результат у
`ObjectHandle` и возвращает
ошибку, если известный код класса не имеет корректной записи; класс,
отсутствующий в таблице выбора, сохраняет начальные значения конструктора.
Preview и объекты без полного Unit DAT не получают профильное состояние.
Native oracle подтвердил соответствия кода класса выбранной записи, разбор
`.var` и применение второй области данных;
оригинальный aggregate getter Behavior RVA `0xa490` остаётся статически
исследованным, но не исполнен в oracle.
В x86 oracle при x87 control word `0x007f` проверены порядок, signed zero,
отрицательные входы, расширенный промежуточный диапазон и subnormal return.
Rust запрос ограничивает себя normal-or-zero значениями, поэтому явно отклоняет
промежуточные случаи, где результат выходит за поддерживаемый диапазон f32,
даже если позднее умножение вернуло бы его в диапазон. Это ограничение
поддержки Rust snapshot, а не неверный native-результат.
В Unit DAT fixture `UNITS/BUILDS/MINE/min_l_n1.dat` проверена реальная связка
`fr_l_mine.ctl` + `fr_l_mine.ndp`: tag `26`, ключ `2` в локальной NDP таблице
из `22` строк, коэффициент `1.0`. Игнорируемый runtime-тест выполняет полный
загрузочный путь, подтверждает root/`EXTO` размеры `22` и `4` и итоговые `25`
provider rows после пропуска первого `EXTO` row. Он вызывает Rust query по
восстановленному native правилу адресации и получает начальное значение `1.0`;
getter оригинальной DLL здесь не исполняется. Это не запись живого
resource-device значения во время игры.
### Профиль владельца Control из CTLD
Общий проход оригинального `Control.dll!FUN_1000fac0` (RVA `0x0fac0`, SHA-256
`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`) проверен
прямым x86-вызовом под control word `0x007f` на synthetic backing. Оригинальный
`AniMesh.dll` query `0x51f0` и методы диапазона component ID `0`
`0x5780/0x57c0` участвуют в векторе; Rust воспроизводит его raw output bits и
отдельные cache, order, clamp и signed-zero случаи.
Для обычной начальной загрузки scale-input тоже закрыт. AniMesh constructor
оставляет `+0x158/+0x15c/+0x160` равными `1.0`; перед Control event `4` World3D
проходит Agent event `0`, AniMesh RVA `0x1a80` вызывает базовый return `0x17df0`
и передаёт event `4/null` модели, FX, CollManager и CollObject. Их проверенные
ветви `0x75b0/0x6fc0`, Effect RVA `0x3d30`, Control RVA `0x1ce00` и `0x1fe00`
не вызывают AniMesh setter `0x14770` и не меняют outer scale. Поэтому FAC0 в
стандартном `LoadAgent` получает `[1, 1, 1]`. Другой маршрут
`AddNewObjectToGame`/mirror к setter и последующие placement/tick остаются
отдельными границами. В synthetic x86-векторе по-прежнему явно заданы `[2,3,5]`;
он не является источником scale для полного load.
Смысл `Slot68 +0x30/+0x34`, tag-27 cache DWORD, owner accumulators `+0x530`,
`+0x534`, `+0x538` и результата mutable word `26` не имеет доказанных единиц
измерения. Неизвестные слова остаются raw; их нельзя подписывать как массу или
площадь. Synthetic x86 vector напрямую записывает scales `[2, 3, 5]` в
`AniMesh +0x158/+0x15c/+0x160`; это не значения обычной миссии. Проверка
стандартных `ArealMap`/`World3D` и AniMesh callback-ветвей до FAC0 подтвердила,
что setter не вызывается, поэтому полный начальный loader использует defaults
конструктора `[1, 1, 1]`. Конкретный отдельный путь World3D `AddNewObjectToGame`
и mirror-варианты передают свой transform-вектор setter-у; это отдельная
граница, как и более поздние placement/tick.
Полный Rust loader теперь выполняет FAC0, затем FCA0 при построении
`build_control_device_projection_with_class`. `MissionControlOwnerProfile::load_projection`
сохраняет отдельный результат FAC0, а `fca0_projection` — статус следующего
прохода. FCA0 проверяет класс **владельца** `owner +0x50`: класс `9` сразу
возвращает неизменённую копию; это не условие по device factory tag `9`. Любой
известный класс, отличный от `9`, включая `0`, проходит общий расчёт. В Rust
`MissingNativeControlClass` означает, что проекция не восстановила метаданные
класса, а не то, что оригинальная DLL не содержит класс. `Fac0Unavailable`
применяется только к не-классу-9 ветви, потому что класс `9` выходит до проверки
FAC0.
FCA0 суммирует selector `0xe00` в обратном порядке по устройствам с текущим
descriptor tag `5`. Изолированный x86-вектор исполняет оригинальный reader
`0x21d00` и FCA0. Статическая проверка двух factory vtables подтверждает общий
reader `0x21d00` и setter `0x21d50` для tags `5`/`26`; provider getter `0x0dc40`
использует `owner +4`, а canonical `QueryInterface(0x204)` задаёт
`owner +0x38 -> owner +0x0c`.
В каноническом constructor/event-4 пути scalar `+0x1a8` и means `+0x354/+0x358`
равны `1.0`. На исходном Control.dll с SHA-256
`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e` FCA0
подтверждена по RVA `0x0fca0` при x87 control word `0x007f`. Слова
`[0,1,2,6,7,8,9,10,11]` в canonical-default векторе имеют биты
`40200000 c0a00000 40c00000 3f800000 40000000 41200000 41e70000 c22d4000 40e70000`.
Лицензированный тест `full_load_builds_effective_control_device_table_for_mine_unit_dat`
настоящего `min_l_n1.dat` теперь проверяет class `3`, `fca0_projection = Ok(())`,
11 устройств и все 25 provider rows. Его девять target words
`[0,1,2,6,7,8,9,10,11]` равны
`40000000 40000000 40000000 00000000 00000000 00000000 00000000 00000000 00000000`;
word `26` после FAC0 остаётся `0x7f7fffff`, а оригинальный блок, FAC0 агрегаты
`0x470833fc/0x4897e712/0x4d9452cb` и 25 значений строк сохраняются. Это Rust
full-load результат на оригинальных ресурсах, а не сравнение полного native
Unit DAT loader-а или gameplay. Rust helper поддерживает только конечные входы
и промежуточные значения домена `normal-or-zero`; прочие битовые случаи остаются
вне заявленного диапазона.
Полный динамический жизненный цикл после начальной загрузки не подтверждён:
поздние изменения owner scalar/means, activation, игровые часы, Control tick и
placement не сведены в одну проверенную трассу и не включены в Rust-проекцию.
Отдельные constructor, setter и loader-пути уже прослежены; физические единицы
слов профиля и накопителей остаются неизвестны. Для закрытия границы нужны
динамическая трассировка и сравнение полного Unit DAT load с оригинальным
loader-ом при одинаковых ресурсах и условиях.
Подробные RVA, QI/provider layout, арифметика и пределы векторов приведены в
[описании изолированного прохода FAC0](script-vm.md#изолированный-проход-fac0)
и [разборе FCA0](script-vm.md#изолированный-проход-fca0).
## DirectPlay wire ## DirectPlay wire
+1610 -105
View File
File diff suppressed because it is too large. Load diff
+42
View File
@@ -60,5 +60,47 @@ u32 property_count
Property properties[property_count] Property properties[property_count]
``` ```
Эти два слова после `resource_name` -- разные входные значения. В оригинале
`MisLoad.dll!FUN_10003900` (RVA `0x3900`) читает первое в смещение `0x54`
нативной записи, если версия секции объектов выше 2; следующее слово попадает
в смещение `0x5c` только при версии выше 3. Getter
`MisLoad.dll!FUN_10001440` (RVA `0x1440`) копирует первое слово в смещение
`0x50` строки, передаваемой хосту. `iron3d.dll!FUN_100a1f30` (RVA `0xA1F30`)
загружает миссию и вызывает `FUN_100a3e40` (RVA `0xA3E40`); тот запрашивает
строку через vtable slot `+0x28` (callsite RVA `0xA3F1A`). Хост передаёт это
значение в создание схемы объекта в ArealMap, откуда оно становится первым
DWORD дескриптора создания World3D. `World3D.dll!CreateObject` (RVA `0x7CB0`)
читает DWORD по RVA `0x7E58`, а callsite RVA `0x7E62` передаёт его через vtable
slot `+0x40` в путь выбора AI в Behavior. Поэтому Rust сохраняет первое слово
как `raw_after_resource: u32`: оригинал использует его как индекс выбора AI,
но соответствие значения типу `OwnerId` в `fparkan-world` не доказано.
Соседнее `identity_or_clan_raw` остаётся отдельным raw-полем; его имя не
доказывает, кому объект принадлежит.
Цепочка проверена на этих сборках (SHA-256): `MisLoad.dll`
`c607762bb67db14ca530927c8e1347339b62b0a163ac90c708ee83142292e2bf`,
`iron3d.dll`
`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`,
`ArealMap.dll`
`a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea`,
`World3D.dll`
`17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e` и
`Behavior.dll`
`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`.
Таблица `GetSuperAI` в `ai.dll` находится по RVA `0x55398`; SHA-256 файла —
`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`.
`Property` состоит из четырёх raw `u32` и имени. Typed views разрешены только `Property` состоит из четырёх raw `u32` и имени. Typed views разрешены только
для доказанных property names и consumers. для доказанных property names и consumers.
Для свойств объекта `MisLoad.dll!FUN_10003ab0` (RVA `0x3ab0`) переставляет
дисковые слова в runtime-запись, а `FUN_100040b0` (RVA `0x40b0`) проходит её
таблицу в порядке runtime-дескрипторов. Поиск имени учитывает длину и байты с
точным регистром и использует первое совпадение. В подтверждённом пути
дисковое `raw_value[1]` становится первым DWORD аргумента setter-а, поэтому
значение передаётся как биты, без промежуточного float-преобразования.
Например, Behavior регистрирует `CurrentOre` под кодом `5`; его setter кладёт
полученные биты в основное слово свойства `0x02000100`. `MaximumOre` с кодом
`4` читает дополнительное слово той же записи, а setter этого дескриптора
ничего не меняет. Подтверждение классов и native getter/setter находится в
[главе о Behavior](../tomes/06-behavior.md#начальные-свойства-запасов-руды).
+28
View File
@@ -346,6 +346,34 @@ end-of-render callbacks. Эти callbacks позволяют объектам б
изменит движение, камеру и сценарные таймеры. Подробности render item и рисков изменит движение, камеру и сценарные таймеры. Подробности render item и рисков
кадровой совместимости вынесены в справочник [Render frame](../reference/render-frame.md). кадровой совместимости вынесены в справочник [Render frame](../reference/render-frame.md).
У исходного Control источник времени прослежен статически до World3D.
World3D берёт миллисекунды `timeGetTime()`, учитывает игровое смещение и паузу,
затем передаёт младшие 32 бита результата всем объектам в событии `6/1`.
После этого следуют шаг мировой физики и второй обход объектов с событием
`6/0x1c`; они получают то же значение часов. Поэтому порядок обновлений имеет
значение даже тогда, когда все участники видят одинаковое время.
Объект AniMesh передаёт первое событие последовательно в Behavior, Wizard,
CollObject, Control и PhysicalModel, пропуская отсутствующие интерфейсы, а затем
ещё раз в CollObject с подсобытием `0x1c`. Control численно преобразует
беззнаковые миллисекунды в `f32` и выбирает обработчик по классу объекта.
Это абсолютные часы: длительность шага обработчик вычисляет из сохранённых
значений сам. Таблицы, адреса и различие путей классов приведены в
[техническом разборе](../appendices/script-vm.md#доставка-игровых-часов-в-control).
Статическая цепочка ещё не доказывает исполнение активного Control update на
миссии. Rust сохраняет исходные и текущие provider-поля отдельно. Прямой delta
API меняет строку и значение соответствующего CPU-узла модели; отдельный
стадийный API обновляет его видимую стадию и обработанное количество в
подтверждённой области. Описанный clock/budget-путь и вывод этого состояния
в окно пока не подключены.
В текущем runtime `step_headless` и `frame` получают время очереди явным
аргументом `u32`. По нему запускается представленный ресурсный планировщик
Behavior, после чего обрабатываются очередь мира и отложенные удаления.
Ресурсные изменения публикуются после успешного мирового шага, поэтому ошибка
не оставляет мир и ресурсы на разных шагах. Это начало расчётного цикла;
отдельное обновление Control, общая VM и движение ещё требуют подключения.
## World3D ## World3D
World3D связывает игровые объекты, события, время, ввод, камеру, сетевые World3D связывает игровые объекты, события, время, ввод, камеру, сетевые
+24 -12
View File
@@ -448,21 +448,28 @@ struct UnitDatRecord112 {
char archive_name[32]; // +0x00 char archive_name[32]; // +0x00
char resource_name[32]; // +0x20 char resource_name[32]; // +0x20
uint32_t kind; // +0x40, в корпусе всегда 1 uint32_t kind; // +0x40, в корпусе всегда 1
int32_t parent_or_link; // +0x44 int32_t parent_or_link; // +0x44, context-dependent local index
char description[32]; // +0x48 char description[32]; // +0x48
uint32_t tail0; // +0x68, opaque uint32_t tail0; // +0x68, opaque
uint32_t tail1; // +0x6C, opaque uint32_t tail1; // +0x6C, immediate_child_count
}; };
#pragma pack(pop) #pragma pack(pop)
``` ```
Во всех проверенных records `archive_name == "objects.rlb"` и `kind == 1`. Во всех проверенных records `archive_name == "objects.rlb"` и `kind == 1`.
Поле `parent_or_link` встречается как `-1`, `0`, `1` и другие небольшие индексы `tail1` задаёт `immediate_child_count` — число непосредственных дочерних
и связывает компоненты составного unit; точная предметная классификация ссылки записей при построении дерева Unit DAT; оно не opaque. Поле `parent_or_link`
ещё не закрыта. `description` -- человекочитаемое описание компонента. В Части 2 встречается как `-1`, `0`, `1` и другие небольшие индексы. В подтверждённой
есть поля `description[32]`, полностью заполненные без NUL; это валидная bounded цепочке Unit DAT → AniMesh → Control оно становится локальным индексом. Для
string длиной 32 байта. Требование обязательного terminator применяется только `INTO` индекс задаёт устройство Control относительно первого устройства
к полям, где оно доказано форматом. `tail0` и `tail1` нельзя нормализовать. родительского компонента, а `EXTO` обрабатывается отдельной ветвью. Поэтому
поле нельзя трактовать как универсальный parent ID или одинаковую ссылку для
всех registry types. Детали передачи описаны в [главе о Behavior и Control](06-behavior.md).
`tail0` сохраняется как raw DWORD без установленной семантики. `description` --
человекочитаемое описание компонента. В Части 2 есть поля `description[32]`,
полностью заполненные без NUL; это валидная bounded string длиной 32 байта.
Требование обязательного terminator применяется только к полям, где оно доказано
форматом.
Проверено 425 файлов / 5 219 records Части 1 и 676 файлов / 8 145 records Проверено 425 файлов / 5 219 records Части 1 и 676 файлов / 8 145 records
Части 2. Все соответствуют формуле размера, `kind == 1` и Части 2. Все соответствуют формуле размера, `kind == 1` и
@@ -470,10 +477,15 @@ string длиной 32 байта. Требование обязательног
При построении mission prototype graph FParkan сохраняет для каждого root При построении mission prototype graph FParkan сохраняет для каждого root
упорядоченный список этих raw records. Список передаётся в упорядоченный список этих raw records. Список передаётся в
`MissionObjectDraft` без преобразования `kind`, `parent_or_link`, description `MissionObjectDraft` с исходными `kind`, `parent_or_link`, description и tails.
или tails в предполагаемые роли. Так runtime уже располагает исходными данными Во время полного mission load runtime использует дерево и подготовленные CTL
составного unit для будущих Control/physics/AniMesh consumers, но не выдаёт resources, чтобы построить для такого объекта упорядоченную базовую таблицу
структурное сходство за доказанную семантику. Control: корневые строки добавляются, дочерний `INTO` может обновить выбранную
строку или добавить записи при sentinel `-1`, а `EXTO` добавляет строки с
переведёнными ключами глобальной MSH-иерархии. Результат хранит исходный factory
tag и provenance; неизвестные tag-specific callbacks он не выполняет. Preview
режим эту таблицу не строит, а неполная typed-record list оставляет состояние
явно unavailable.
Каждый graph edge от `UnitDatRoot` к effective component и далее к его MSH Каждый graph edge от `UnitDatRoot` к effective component и далее к его MSH
dependency также несёт `unit_component_index`. Это индекс исходной записи в dependency также несёт `unit_component_index`. Это индекс исходной записи в
+40
View File
@@ -195,6 +195,46 @@ Property properties[property_count]
большинстве записей равен `(1,1,1)`. `instance_name` может быть пустым у большинстве записей равен `(1,1,1)`. `instance_name` может быть пустым у
unit-ссылки или содержать stem размещённого прототипа. unit-ссылки или содержать stem размещённого прототипа.
Сразу за строкой ресурса лежат два DWORD, и их нельзя склеивать в одно
«владение» или менять местами. Первый, `raw_after_resource`, оригинал использует
как индекс выбора AI. Второй сохраняется отдельным полем
`identity_or_clan_raw`, но одно его имя ещё не говорит, кому объект принадлежит.
В runtime draft оба значения остаются раздельными:
```rust
MissionObjectDraft {
raw_after_resource: object.raw_after_resource,
identity_or_clan_raw: object.identity_or_clan_raw,
..
}
```
Мы знаем это по пути создания: загрузчик копирует первое слово из TMA-записи в
строку схемы объекта; затем хост передаёт его ArealMap и World3D, а World3D
направляет его в ветку выбора AI. Соответствие этого индекса типу `OwnerId` в
`fparkan-world` не подтверждено. Смещения, условия версий и контрольные суммы исследованных
сборок приведены в [справочнике TMA](../reference/tma.md).
Это слово влияет не только на сценарий. Успешно загруженный пакет клана
публикует его `SuperAI` в таблице `GetSuperAI`; World3D передаёт первый DWORD
дескриптора создания через Behavior, где он выбирает тот же номер таблицы.
Поэтому при создании полного мира можно отнести объект к конкретному клановому
диспетчеру, если его номер лежит в диапазоне `0..63` и сценарий этого клана
загружен. Rust хранит такую связь отдельно от владельца объекта:
```text
TMA raw_after_resource = 1
-> GetSuperAI[1] доступен после загрузки script bundle клана 1
-> запись объекта относится к distributor клана 1
```
Для объектов с Unit DAT используется старший бит DWORD по смещению `+4` от
начала DAT как общий признак, по которому оригинальный World3D создаёт
registration key для Behavior. Это второе 32-битное слово заголовка. Из него
нельзя вывести сторону или тип владельца. В AutoDemo это связывает поля TMA,
class word из Unit DAT и выбранный клановый AI, не меняя `OwnerId` и хеш
игрового состояния.
Свойства хранятся как ordered property bag: Свойства хранятся как ordered property bag:
```text ```text
+958 -26
View File
File diff suppressed because it is too large. Load diff