feat(runtime): load script varset defaults

This commit is contained in:
2026-07-18 21:23:28 +04:00
parent a7882545b7
commit 837b613513
4 changed files with 184 additions and 10 deletions
+2 -1
View File
@@ -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
);
}
+151 -9
View File
@@ -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]
@@ -1346,6 +1439,55 @@ mod tests {
assert_eq!(phases, vec![MissionLoadPhase::Context]);
}
#[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(
+4
View File
@@ -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.
@@ -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 + "\"");
}
}
}