aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--apps/fparkan-headless/src/main.rs3
-rw-r--r--crates/fparkan-runtime/src/lib.rs160
-rw-r--r--docs/appendices/script-vm.md4
-rw-r--r--tools/ghidra/ExportAiScriptLoaderStrings.java27
4 files changed, 184 insertions, 10 deletions
diff --git a/apps/fparkan-headless/src/main.rs b/apps/fparkan-headless/src/main.rs
index e7a1c14..013c2da 100644
--- a/apps/fparkan-headless/src/main.rs
+++ b/apps/fparkan-headless/src/main.rs
@@ -56,7 +56,7 @@ fn run() -> Result<(), String> {
let loaded = load_mission(&mut engine, MissionRequest { key: mission })
.map_err(|err| format!("{err}"))?;
println!(
- "mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} scripts={} script_events={} graph_failures={}",
+ "mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} scripts={} script_events={} script_varset_declarations={} graph_failures={}",
loaded.object_count,
loaded.areal_count,
loaded.surface_count,
@@ -68,6 +68,7 @@ fn run() -> Result<(), String> {
loaded.graph_lightmap_resolved_count,
loaded.script_bundle_count,
loaded.script_event_count,
+ loaded.script_varset_declaration_count,
loaded.graph_failure_count
);
}
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs
index 5ab7dfd..13a880f 100644
--- a/crates/fparkan-runtime/src/lib.rs
+++ b/crates/fparkan-runtime/src/lib.rs
@@ -34,7 +34,7 @@ use fparkan_prototype::{
UnitComponentRecord,
};
use fparkan_resource::{resource_name, CachedResourceRepository, ResourceRepository};
-use fparkan_script::ScriptPackage;
+use fparkan_script::{ScriptPackage, VarSet};
use fparkan_terrain::SurfaceQuery;
use fparkan_vfs::{Vfs, VfsError};
use fparkan_world::{
@@ -48,6 +48,7 @@ use std::sync::Arc;
pub use fparkan_assets::MissionAssets;
const MISSION_DECODED_PAYLOAD_CACHE_ENTRIES: usize = 256;
+const FALLBACK_SCRIPT_VARSET_PATH: &str = "MISSIONS/SCRIPTS/varset.var";
/// Engine mode.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -251,12 +252,33 @@ pub struct MissionScriptBundle {
pub package: ScriptPackage,
}
+/// Numeric script defaults selected for the mission's first script bundle.
+///
+/// GOG `ai.dll` first tries `<bundle-base>.var`, then falls back to the shared
+/// `MISSIONS/SCRIPTS/varset.var`. This stores the successfully selected source
+/// without claiming that the not-yet-recovered VM executes its declarations.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MissionScriptVarSet {
+ /// Resolved `.var` path selected by the original fallback order.
+ pub path: String,
+ /// Whether the shared `MISSIONS/SCRIPTS/varset.var` fallback was used.
+ pub used_fallback: bool,
+ /// Typed numeric declaration defaults in source order.
+ pub declarations: VarSet,
+}
+
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct MissionLoadOptions {
fail_after_registered_objects: Option<usize>,
asset_scope: MissionAssetScope,
}
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+struct LoadedMissionScripts {
+ bundles: Vec<MissionScriptBundle>,
+ varset: Option<MissionScriptVarSet>,
+}
+
/// Selects how much of the resolved mission asset graph to prepare.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
enum MissionAssetScope {
@@ -342,6 +364,8 @@ pub struct LoadedMission {
pub script_bundle_count: usize,
/// Total named events in loaded script packages.
pub script_event_count: usize,
+ /// Numeric script defaults loaded through the original bundle/fallback rule.
+ pub script_varset_declaration_count: usize,
}
/// Frame result.
@@ -379,6 +403,7 @@ struct LoadedMissionState {
asset_plan: MissionAssetPlan,
object_drafts: Vec<MissionObjectDraft>,
script_bundles: Vec<MissionScriptBundle>,
+ script_varset: Option<MissionScriptVarSet>,
}
/// Engine error.
@@ -409,6 +434,13 @@ pub enum EngineError {
/// Decode diagnostic.
message: String,
},
+ /// Script defaults could not be decoded.
+ VarSet {
+ /// Script-default path.
+ path: String,
+ /// Parse diagnostic.
+ message: String,
+ },
/// `NRes` decode error.
Nres {
/// Resource path.
@@ -544,6 +576,7 @@ impl std::fmt::Display for EngineError {
}
Self::Vfs { path, source } => write!(f, "{path}: {source}"),
Self::Script { path, message } => write!(f, "{path}: script decode failed: {message}"),
+ Self::VarSet { path, message } => write!(f, "{path}: varset decode failed: {message}"),
Self::Nres { path, source } => write!(f, "{path}: {source}"),
Self::Mission { path, source } => write!(f, "{path}: {source}"),
Self::TerrainFormat { path, source } => write!(f, "{path}: {source}"),
@@ -585,6 +618,7 @@ impl std::error::Error for EngineError {
Self::AssetPreparation { source, .. } => Some(source),
Self::MissingVfs
| Self::Script { .. }
+ | Self::VarSet { .. }
| Self::Movement(_)
| Self::PrototypeGraph { .. }
| Self::SchedulerPhaseOrder { .. }
@@ -803,7 +837,7 @@ fn load_mission_with_options_and_progress(
source,
}
})?;
- let script_bundles = load_mission_script_bundles(&vfs, &mission)?;
+ let loaded_scripts = load_mission_scripts(&vfs, &mission)?;
trace.transforms = mission
.objects
.iter()
@@ -1020,11 +1054,16 @@ fn load_mission_with_options_and_progress(
asset_material_count: mission_asset_plan.material_count,
asset_texture_count: mission_asset_plan.texture_count,
asset_lightmap_count: mission_asset_plan.lightmap_count,
- script_bundle_count: script_bundles.len(),
- script_event_count: script_bundles
+ script_bundle_count: loaded_scripts.bundles.len(),
+ script_event_count: loaded_scripts
+ .bundles
.iter()
.map(|bundle| bundle.package.events.len())
.sum(),
+ script_varset_declaration_count: loaded_scripts
+ .varset
+ .as_ref()
+ .map_or(0, |varset| varset.declarations.declarations.len()),
};
engine.world = new_runtime_world;
@@ -1038,7 +1077,8 @@ fn load_mission_with_options_and_progress(
mission_assets,
asset_plan: mission_asset_plan,
object_drafts,
- script_bundles,
+ script_bundles: loaded_scripts.bundles,
+ script_varset: loaded_scripts.varset,
});
Ok((summary, trace))
}
@@ -1128,6 +1168,15 @@ pub fn loaded_mission_script_bundles(engine: &Engine) -> Option<&[MissionScriptB
.map(|state| state.script_bundles.as_slice())
}
+/// Returns the numeric script defaults selected by the original `.var` fallback rule.
+#[must_use]
+pub fn loaded_mission_script_varset(engine: &Engine) -> Option<&MissionScriptVarSet> {
+ engine
+ .loaded
+ .as_ref()
+ .and_then(|state| state.script_varset.as_ref())
+}
+
/// Returns terrain runtime data for the loaded mission.
#[must_use]
pub fn loaded_terrain(engine: &Engine) -> Option<&TerrainWorld> {
@@ -1258,12 +1307,13 @@ fn normalize_engine_path(role: &'static str, value: &str) -> Result<NormalizedPa
})
}
-fn load_mission_script_bundles(
+fn load_mission_scripts(
vfs: &Arc<dyn Vfs>,
mission: &MissionDocument,
-) -> Result<Vec<MissionScriptBundle>, EngineError> {
+) -> Result<LoadedMissionScripts, EngineError> {
let script_bases = mission_script_bundle_bases(mission);
let mut bundles = Vec::with_capacity(script_bases.len());
+ let mut first_base_path = None;
for script_base in script_bases {
let base_path = String::from_utf8_lossy(&script_base.path_raw).into_owned();
let script_path = if base_path.to_ascii_lowercase().ends_with(".scr") {
@@ -1277,6 +1327,9 @@ fn load_mission_script_bundles(
path: normalized.as_str().to_string(),
message: source.to_string(),
})?;
+ if first_base_path.is_none() {
+ first_base_path = Some(base_path.clone());
+ }
bundles.push(MissionScriptBundle {
clan_index: script_base.clan_index,
base_path,
@@ -1284,7 +1337,47 @@ fn load_mission_script_bundles(
package,
});
}
- Ok(bundles)
+ let varset = first_base_path
+ .as_deref()
+ .map(|base_path| load_script_varset(vfs, base_path))
+ .transpose()?;
+ Ok(LoadedMissionScripts { bundles, varset })
+}
+
+fn load_script_varset(
+ vfs: &Arc<dyn Vfs>,
+ base_path: &str,
+) -> Result<MissionScriptVarSet, EngineError> {
+ let base_path = base_path
+ .strip_suffix(".scr")
+ .or_else(|| base_path.strip_suffix(".SCR"))
+ .unwrap_or(base_path);
+ let primary_path = normalize_engine_path("mission script varset", &format!("{base_path}.var"))?;
+ let (path, bytes, used_fallback) = match vfs.read(&primary_path) {
+ Ok(bytes) => (primary_path, bytes, false),
+ Err(VfsError::NotFound(_)) => {
+ let fallback_path =
+ normalize_engine_path("fallback script varset", FALLBACK_SCRIPT_VARSET_PATH)?;
+ let bytes = read_vfs(vfs, &fallback_path)?;
+ (fallback_path, bytes, true)
+ }
+ Err(source) => {
+ return Err(EngineError::Vfs {
+ path: primary_path.as_str().to_string(),
+ source,
+ });
+ }
+ };
+ let declarations =
+ fparkan_script::parse_varset(&bytes).map_err(|source| EngineError::VarSet {
+ path: path.as_str().to_string(),
+ message: source.to_string(),
+ })?;
+ Ok(MissionScriptVarSet {
+ path: path.as_str().to_string(),
+ used_fallback,
+ declarations,
+ })
}
fn read_vfs(vfs: &Arc<dyn Vfs>, path: &NormalizedPath) -> Result<Arc<[u8]>, EngineError> {
@@ -1297,7 +1390,7 @@ fn read_vfs(vfs: &Arc<dyn Vfs>, path: &NormalizedPath) -> Result<Arc<[u8]>, Engi
#[cfg(test)]
mod tests {
use super::*;
- use fparkan_vfs::{DirectoryVfs, VfsEntry, VfsMetadata};
+ use fparkan_vfs::{DirectoryVfs, MemoryVfs, VfsEntry, VfsMetadata};
use std::path::{Path, PathBuf};
#[test]
@@ -1347,6 +1440,55 @@ mod tests {
}
#[test]
+ fn script_varset_prefers_bundle_adjacent_file_then_uses_shared_fallback() {
+ let mut personal_files = MemoryVfs::default();
+ personal_files.insert(
+ normalize_engine_path("test", "MISSIONS/SCRIPTS/custom.var").expect("path"),
+ Arc::from(&b"VAR( DWORD, personal, 7)\n"[..]),
+ );
+ personal_files.insert(
+ normalize_engine_path("test", FALLBACK_SCRIPT_VARSET_PATH).expect("path"),
+ Arc::from(&b"VAR( DWORD, fallback, 8)\n"[..]),
+ );
+ let personal_vfs: Arc<dyn Vfs> = Arc::new(personal_files);
+ let personal =
+ load_script_varset(&personal_vfs, "MISSIONS/SCRIPTS/custom").expect("personal varset");
+ assert_eq!(personal.path, "MISSIONS/SCRIPTS/custom.var");
+ assert!(!personal.used_fallback);
+ assert_eq!(personal.declarations.declarations[0].name, "personal");
+
+ let mut fallback_files = MemoryVfs::default();
+ fallback_files.insert(
+ normalize_engine_path("test", FALLBACK_SCRIPT_VARSET_PATH).expect("path"),
+ Arc::from(&b"VAR( float, fallback, 0.5)\n"[..]),
+ );
+ let fallback_vfs: Arc<dyn Vfs> = Arc::new(fallback_files);
+ let fallback =
+ load_script_varset(&fallback_vfs, "MISSIONS/SCRIPTS/custom").expect("fallback varset");
+ assert_eq!(fallback.path, FALLBACK_SCRIPT_VARSET_PATH);
+ assert!(fallback.used_fallback);
+ assert_eq!(fallback.declarations.declarations[0].name, "fallback");
+ }
+
+ #[test]
+ fn malformed_personal_script_varset_does_not_silently_use_fallback() {
+ let mut files = MemoryVfs::default();
+ files.insert(
+ normalize_engine_path("test", "MISSIONS/SCRIPTS/custom.var").expect("path"),
+ Arc::from(&b"VAR( DWORD, broken, nope)\n"[..]),
+ );
+ files.insert(
+ normalize_engine_path("test", FALLBACK_SCRIPT_VARSET_PATH).expect("path"),
+ Arc::from(&b"VAR( DWORD, fallback, 8)\n"[..]),
+ );
+ let vfs: Arc<dyn Vfs> = Arc::new(files);
+ assert!(matches!(
+ load_script_varset(&vfs, "MISSIONS/SCRIPTS/custom"),
+ Err(EngineError::VarSet { path, .. }) if path == "MISSIONS/SCRIPTS/custom.var"
+ ));
+ }
+
+ #[test]
fn headless_scheduler_trace_skips_presentation_phases() {
let mut engine = create(
EngineConfig {
diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md
index e387846..8955834 100644
--- a/docs/appendices/script-vm.md
+++ b/docs/appendices/script-vm.md
@@ -24,6 +24,10 @@ opaque, поэтому legacy non-UTF-8 text не ломает загрузку)
`FUNCTION(...)` пока сохранены за границей этого numeric contract;
GOG `MISSIONS/SCRIPTS/varset.var` даёт через него ровно 231 declaration:
31 `float` и 200 `DWORD` (от `f0` до `fY`);
+loader `ai.dll!0x10001000` сначала открывает `<bundle-base>.var` и только при
+`not found` откатывается к этому shared file. Runtime повторяет данный порядок
+транзакционно и публикует selected `MissionScriptVarSet` с путём/provenance, но
+ещё не исполняет declarations как VM state;
`.trf` — NRes tables, чей framing подтверждён, а field semantics местами лишь
consumer-inferred.
diff --git a/tools/ghidra/ExportAiScriptLoaderStrings.java b/tools/ghidra/ExportAiScriptLoaderStrings.java
new file mode 100644
index 0000000..e1443c7
--- /dev/null
+++ b/tools/ghidra/ExportAiScriptLoaderStrings.java
@@ -0,0 +1,27 @@
+// Emits path literals used by the GOG AI script loader at 0x10001000.
+// Run headless; the original PE remains read only.
+import ghidra.app.script.GhidraScript;
+import ghidra.program.model.address.Address;
+
+public class ExportAiScriptLoaderStrings extends GhidraScript {
+ private static final long[] ADDRESSES = {
+ 0x10038a88L, 0x10038a9cL, 0x10038aa4L, 0x10038aacL,
+ 0x10038ab4L, 0x10038abcL, 0x10038ad0L, 0x10038ad8L,
+ 0x10038ae0L
+ };
+
+ @Override
+ public void run() throws Exception {
+ for (long value : ADDRESSES) {
+ Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
+ .getAddress(value);
+ byte[] bytes = new byte[256];
+ int count = currentProgram.getMemory().getBytes(address, bytes);
+ StringBuilder text = new StringBuilder();
+ for (int index = 0; index < count && bytes[index] != 0; index++) {
+ text.append((char) (bytes[index] & 0xff));
+ }
+ println(address + " = \"" + text + "\"");
+ }
+ }
+}