From 928179b09a668efdc12deedd8925b1b87c28e5dc Mon Sep 17 00:00:00 2001 From: ViTeXFTW Date: Tue, 8 Sep 2026 20:57:29 +0200 Subject: [PATCH] feat: complete and highlight model-compatible animations --- crates/analysis/src/completion.rs | 32 +++++ crates/analysis/src/diagnostics.rs | 6 + crates/analysis/src/index.rs | 128 +++++++++++++++++- crates/analysis/src/model.rs | 37 +++++ crates/analysis/src/semantic.rs | 54 +++++++- crates/analysis/tests/bone_names.rs | 1 + crates/analysis/tests/spec.rs | 6 + .../tests/spec/AnimationCompletion.ini | 37 +++++ .../tests/spec/AnimationCompletion.spec.toml | 64 +++++++++ crates/server/src/backend.rs | 117 ++++++++++++++-- crates/server/src/cache.rs | 2 +- crates/server/src/cli.rs | 15 +- crates/server/src/convert.rs | 50 ++++++- crates/server/src/scan.rs | 91 ++++++++++--- crates/server/tests/e2e.py | 64 +++++++++ crates/w3d/src/lib.rs | 96 +++++++++++++ crates/w3d/src/parse.rs | 19 +++ docs/language-server.md | 12 ++ 18 files changed, 787 insertions(+), 44 deletions(-) create mode 100644 crates/analysis/tests/spec/AnimationCompletion.ini create mode 100644 crates/analysis/tests/spec/AnimationCompletion.spec.toml diff --git a/crates/analysis/src/completion.rs b/crates/analysis/src/completion.rs index 6dfa969..fa59b9c 100644 --- a/crates/analysis/src/completion.rs +++ b/crates/analysis/src/completion.rs @@ -27,6 +27,7 @@ pub enum CompletionKind { Value, Reference, W3dModel, + W3dAnimation, } /// A single completion candidate. @@ -154,6 +155,11 @@ fn classify_position( .as_ref() .and_then(|scope_node| scope_schema(analyzer, scope_node).field(&key)) .and_then(|field| { + // Animation's following tokens are optional distance and + // repeat count, despite its lenient string schema type. + if field.parse_fn == "parseAnimation" { + return None; + } field .value_type .token_index_at_input(&input, raw_value_index) @@ -412,6 +418,27 @@ fn field_value_completions( let mut base = { let scope = scope_schema(analyzer, scope_node); if let Some(f) = scope.field(key) { + if f.parse_fn == "parseAnimation" { + return if value_index == 0 { + index + .map(|index| { + crate::model::condition_state_model(scope_node) + .into_iter() + .flat_map(|model| index.model_animations(&model)) + .map(|name| Completion { + label: name.to_string(), + kind: CompletionKind::W3dAnimation, + detail: Some("W3D animation".into()), + documentation: None, + insert: None, + }) + .collect() + }) + .unwrap_or_default() + } else { + Vec::new() + }; + } if let Some(asset_completions) = model_asset_completions( analyzer, scope_node, @@ -1290,6 +1317,7 @@ mod tests { index.set_file_models( "models/Good.w3d", vec![crate::index::ModelAsset { + hierarchy: None, name: "Good".into(), members: vec!["Cargo01".into(), "Tire01".into()], }], @@ -1328,6 +1356,7 @@ End index.set_file_models( "models/Good.w3d", vec![crate::index::ModelAsset { + hierarchy: None, name: "Good".into(), members: vec!["Turret01".into()], }], @@ -1362,6 +1391,7 @@ End index.set_file_models( "a10.w3d", vec![crate::index::ModelAsset { + hierarchy: None, name: "A10".into(), members: vec!["WeaponA01".into()], }], @@ -1387,6 +1417,7 @@ End index.set_file_models( "models/Good.w3d", vec![crate::index::ModelAsset { + hierarchy: None, name: "Good".into(), members: vec![], }], @@ -1408,6 +1439,7 @@ End index.set_file_models( "models/Good.w3d", vec![crate::index::ModelAsset { + hierarchy: None, name: "Good".into(), members: vec!["Muzzle01".into(), "Muzzle02".into()], }], diff --git a/crates/analysis/src/diagnostics.rs b/crates/analysis/src/diagnostics.rs index 1566f6d..cd7f9cc 100644 --- a/crates/analysis/src/diagnostics.rs +++ b/crates/analysis/src/diagnostics.rs @@ -2756,6 +2756,7 @@ End index.set_file_models( "models/Good.w3d", vec![crate::index::ModelAsset { + hierarchy: None, name: "Good".into(), members: vec!["Tire01".into(), "Cargo01".into(), "Muzzle01".into()], }], @@ -2827,6 +2828,7 @@ End index.set_file_models( "models/Good.w3d", vec![crate::index::ModelAsset { + hierarchy: None, name: "Good".into(), members: vec!["Turret01".into()], }], @@ -2869,6 +2871,7 @@ End index.set_file_models( "models/A10.w3d", vec![crate::index::ModelAsset { + hierarchy: None, name: "A10".into(), members: vec!["WeaponA01".into(), "Missile01".into()], }], @@ -2922,6 +2925,7 @@ End index.set_file_models( "models/Good.w3d", vec![crate::index::ModelAsset { + hierarchy: None, name: "Good".into(), members: vec![], }], @@ -2941,6 +2945,7 @@ End index.set_file_models( "a.w3d", vec![crate::index::ModelAsset { + hierarchy: None, name: "A".into(), members: vec!["Bone01".into()], }], @@ -2948,6 +2953,7 @@ End index.set_file_models( "b.w3d", vec![crate::index::ModelAsset { + hierarchy: None, name: "B".into(), members: vec![], }], diff --git a/crates/analysis/src/index.rs b/crates/analysis/src/index.rs index 298fd4d..c1df3f4 100644 --- a/crates/analysis/src/index.rs +++ b/crates/analysis/src/index.rs @@ -34,6 +34,13 @@ pub struct FileAsset { pub struct ModelAsset { pub name: String, pub members: Vec, + pub hierarchy: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AnimationAsset { + pub name: String, + pub hierarchy: String, } /// A definition's location within a file. @@ -138,6 +145,8 @@ pub struct WorkspaceIndex { model_assets: HashMap, ModelAsset)>>, /// Reverse map for removing/replacing models contributed by one asset file. file_models: HashMap>, + animation_assets: HashMap, AnimationAsset)>>, + file_animations: HashMap>, asset_names: HashMap>>, texture_assets: HashMap>, file_assets: HashMap>, @@ -241,6 +250,7 @@ impl WorkspaceIndex { self.remove_entries(file); self.remove_site_entries(file); self.remove_model_entries(file); + self.set_file_animations(file, Vec::new()); self.set_file_assets(file, Vec::new()); self.remove_object_model_entries(file); self.remove_object_parent_entries(file); @@ -275,6 +285,51 @@ impl WorkspaceIndex { } } + /// Replace animation metadata contributed by one loose or archived W3D. + pub fn set_file_animations(&mut self, file: &str, animations: Vec) { + if let Some(names) = self.file_animations.remove(file) { + for name in names { + if let Some(entries) = self.animation_assets.get_mut(&name) { + entries.retain(|(source, _)| source.as_ref() != file); + if entries.is_empty() { + self.animation_assets.remove(&name); + } + } + } + } + let source: Arc = Arc::from(file); + let mut names = Vec::new(); + for animation in animations { + let name = animation.name.to_ascii_lowercase(); + self.animation_assets + .entry(name.clone()) + .or_default() + .push((source.clone(), animation)); + names.push(name); + } + if !names.is_empty() { + self.file_animations.insert(file.to_string(), names); + } + // Animation metadata only drives uncached completion requests. + } + + /// Animations compatible with the effective model's skeleton. Files may + /// contribute animations separately from the model and its hierarchy. + pub fn model_animations<'a>(&'a self, model: &str) -> impl Iterator { + let hierarchy = self + .model_assets + .get(&model.to_ascii_lowercase()) + .and_then(|entries| entries.last()) + .and_then(|(_, model)| model.hierarchy.as_deref()); + self.animation_assets + .values() + .filter_map(|entries| entries.last()) + .filter(move |(_, animation)| { + hierarchy.is_some_and(|h| h.eq_ignore_ascii_case(&animation.hierarchy)) + }) + .map(|(_, animation)| animation.name.as_str()) + } + /// Replace W3D model assets contributed by `file`. pub fn set_file_models(&mut self, file: &str, models: Vec) { let changed = match self.file_models.get(file) { @@ -865,11 +920,13 @@ fn dedup_case_insensitive(values: &mut Vec) { values.retain(|value| seen.insert(value.to_ascii_lowercase())); } -fn normalized_model_assets(models: &[ModelAsset]) -> Vec<(String, Vec)> { +fn normalized_model_assets(models: &[ModelAsset]) -> Vec<(String, Vec, Option)> { normalized_model_asset_refs(&models.iter().collect::>()) } -fn normalized_model_asset_refs(models: &[&ModelAsset]) -> Vec<(String, Vec)> { +fn normalized_model_asset_refs( + models: &[&ModelAsset], +) -> Vec<(String, Vec, Option)> { let mut out = models .iter() .map(|model| { @@ -880,7 +937,11 @@ fn normalized_model_asset_refs(models: &[&ModelAsset]) -> Vec<(String, Vec>(); members.sort(); members.dedup(); - (model.name.to_ascii_lowercase(), members) + ( + model.name.to_ascii_lowercase(), + members, + model.hierarchy.as_ref().map(|h| h.to_ascii_lowercase()), + ) }) .collect::>(); out.sort(); @@ -1368,6 +1429,7 @@ mod tests { idx.set_file_models( "base.w3d", vec![ModelAsset { + hierarchy: None, name: "Tank".into(), members: Vec::new(), }], @@ -1375,6 +1437,7 @@ mod tests { idx.set_file_models( "mod.w3d", vec![ModelAsset { + hierarchy: None, name: "TANK".into(), members: Vec::new(), }], @@ -1445,10 +1508,63 @@ mod tests { assert!(idx.is_defined(RefKind::Weapon, "New")); } + #[test] + fn animation_lookup_tracks_model_overrides_and_file_removal() { + let mut idx = WorkspaceIndex::new(); + idx.set_file_models( + "base.w3d", + vec![ModelAsset { + name: "Soldier".into(), + members: vec![], + hierarchy: Some("HumanSKL".into()), + }], + ); + let animation = AnimationAsset { + name: "HumanSKL.Run".into(), + hierarchy: "HumanSKL".into(), + }; + idx.set_file_animations("run.w3d", vec![animation.clone()]); + idx.set_file_animations("patch-run.w3d", vec![animation]); + assert_eq!( + idx.model_animations("soldier").collect::>(), + ["HumanSKL.Run"] + ); + idx.remove_file("patch-run.w3d"); + assert_eq!( + idx.model_animations("soldier").collect::>(), + ["HumanSKL.Run"] + ); + idx.set_file_models( + "patch.w3d", + vec![ModelAsset { + name: "Soldier".into(), + members: vec![], + hierarchy: Some("OtherSKL".into()), + }], + ); + assert_eq!(idx.model_animations("Soldier").count(), 0); + idx.remove_file("patch.w3d"); + assert_eq!(idx.model_animations("Soldier").count(), 1); + idx.set_file_animations( + "run.w3d", + vec![AnimationAsset { + name: "HumanSKL.Idle".into(), + hierarchy: "HumanSKL".into(), + }], + ); + assert_eq!( + idx.model_animations("Soldier").collect::>(), + ["HumanSKL.Idle"] + ); + idx.remove_file("run.w3d"); + assert_eq!(idx.model_animations("Soldier").count(), 0); + } + #[test] fn model_asset_member_changes_bump_generation() { let mut idx = WorkspaceIndex::new(); let original = vec![ModelAsset { + hierarchy: None, name: "Tank".into(), members: vec!["Tire01".into()], }]; @@ -1460,6 +1576,7 @@ mod tests { idx.set_file_models( "model.w3d", vec![ModelAsset { + hierarchy: None, name: "Tank".into(), members: vec!["Tire02".into()], }], @@ -1473,6 +1590,7 @@ mod tests { idx.insert_file_models_prepared( "base.w3d", vec![ModelAsset { + hierarchy: None, name: "Tank".into(), members: vec!["Tire01".into()], }], @@ -1480,6 +1598,7 @@ mod tests { idx.insert_file_models_prepared( "patch.w3d", vec![ModelAsset { + hierarchy: None, name: "TANK".into(), members: vec!["Cargo01".into()], }], @@ -1508,6 +1627,7 @@ mod tests { idx.set_file_models( "base.w3d", vec![ModelAsset { + hierarchy: None, name: "Tank".into(), members: vec!["Tire01".into()], }], @@ -1515,6 +1635,7 @@ mod tests { idx.set_file_models( "patch.w3d", vec![ModelAsset { + hierarchy: None, name: "TANK".into(), members: vec!["Cargo01".into()], }], @@ -1531,6 +1652,7 @@ mod tests { idx.set_file_models( "base.w3d", vec![ModelAsset { + hierarchy: None, name: "Tank".into(), members: vec!["Tire01".into()], }], diff --git a/crates/analysis/src/model.rs b/crates/analysis/src/model.rs index e928cca..1bf152a 100644 --- a/crates/analysis/src/model.rs +++ b/crates/analysis/src/model.rs @@ -297,6 +297,43 @@ pub(crate) fn models_for_source( } } +/// A state inherits only the explicit default state in the same draw module. +/// An explicit empty/None Model must not fall through to another state's model. +pub(crate) fn condition_state_model(scope: &SyntaxNode) -> Option { + fn declared_model(scope: &SyntaxNode) -> Option> { + scope + .children() + .filter(|n| n.kind() == SyntaxKind::FIELD) + .filter_map(|node| { + let field = AstField(node); + field + .key() + .filter(|key| key.text().eq_ignore_ascii_case("Model"))?; + Some( + field + .value_tokens() + .first() + .map(|value| value.text().trim_matches('"').to_string()), + ) + }) + .last() + } + let model = match declared_model(scope) { + Some(model) => model, + None => scope + .parent()? + .children() + .take_while(|node| node != scope) + .find(|node| { + Module(node.clone()) + .slot() + .is_some_and(|slot| slot.text() == "DefaultConditionState") + }) + .and_then(|default| declared_model(&default).flatten()), + }; + model.filter(|name| !name.is_empty() && !name.eq_ignore_ascii_case("None")) +} + fn collect_models(analyzer: &Analyzer, node: &SyntaxNode, out: &mut Vec) { let scope = scope_schema(analyzer, node); for child in node.children() { diff --git a/crates/analysis/src/semantic.rs b/crates/analysis/src/semantic.rs index fbf4797..90198d9 100644 --- a/crates/analysis/src/semantic.rs +++ b/crates/analysis/src/semantic.rs @@ -189,12 +189,11 @@ impl<'a> Sem<'a> { }, ); } - let ty = field - .key() - .and_then(|k| scope.field(k.text())) - .map(|f| f.value_type.clone()); + let schema_field = field.key().and_then(|k| scope.field(k.text())); + let is_animation = schema_field.is_some_and(|f| f.parse_fn == "parseAnimation"); + let ty = schema_field.map(|f| &f.value_type); let value_tokens = field.value_tokens(); - let active_ty = ty.as_ref().and_then(|ty| { + let active_ty = ty.and_then(|ty| { ty.variant_for_first_token(value_tokens.first().map(|t| t.text().trim_matches('"'))) }); let input = value_tokens @@ -206,6 +205,20 @@ impl<'a> Sem<'a> { self.set(tok, SemKind::Reference); continue; } + // parseAnimation accepts a name, optional distance, and optional + // repeat count. Its lenient string schema must not hide those + // meanings, including when the animation name is quoted. + if is_animation && i < 3 { + self.set( + tok, + if i == 0 { + SemKind::Reference + } else { + SemKind::Number + }, + ); + continue; + } if matches!(active_ty, Some(ValueType::RandomVariable { .. })) { self.set( tok, @@ -301,6 +314,37 @@ mod tests { .collect() } + #[test] + fn animations_are_references_with_numeric_arguments() { + let src = "Object Soldier\n Draw = W3DModelDraw Tag\n DefaultConditionState\n Model = SoldierSkin\n Animation = HumanSKL.Run 16\n IdleAnimation = \"HumanSKL.Idle\" 0 9\n End\n ConditionState = MOVING\n Animation = HumanSKL.Walk\n End\n TransitionState = TRANS_A TRANS_B\n Animation = HumanSKL.Stop\n End\n End\n DisplayName = PlainString\nEnd\n"; + let tokens = toks(src); + for name in [ + "HumanSKL.Run", + "\"HumanSKL.Idle\"", + "HumanSKL.Walk", + "HumanSKL.Stop", + ] { + assert!( + tokens.contains(&(SemKind::Reference, name.into())), + "expected animation reference for {name}: {tokens:?}" + ); + } + for number in ["16", "0", "9"] { + assert!( + tokens.contains(&(SemKind::Number, number.into())), + "expected numeric animation argument {number}" + ); + } + assert!(tokens.contains(&(SemKind::StringLit, "PlainString".into()))); + let analyzer = Analyzer::embedded(); + let parse = analyzer.parse(src); + let offset = src.find("HumanSKL.Walk").unwrap() as u32; + assert_eq!( + semantic_tokens_range(&analyzer, &parse, Span::new(offset, offset + 1)), + semantic_tokens(&analyzer, &parse) + ); + } + #[test] fn classifies_weapon_tokens() { let src = "Weapon AK47\n PrimaryDamage = 50.0\n DeathType = BURNED\nEnd ; done\n"; diff --git a/crates/analysis/tests/bone_names.rs b/crates/analysis/tests/bone_names.rs index aef1658..b1cf9bc 100644 --- a/crates/analysis/tests/bone_names.rs +++ b/crates/analysis/tests/bone_names.rs @@ -7,6 +7,7 @@ fn bone_completions_deduplicate_aliases_and_preserve_prefix_insertion() { index.set_file_models( "good.w3d", vec![ModelAsset { + hierarchy: None, name: "Good".into(), members: vec![ "Good.Fire01".into(), diff --git a/crates/analysis/tests/spec.rs b/crates/analysis/tests/spec.rs index 317278d..e89cf9a 100644 --- a/crates/analysis/tests/spec.rs +++ b/crates/analysis/tests/spec.rs @@ -65,12 +65,16 @@ struct Spec { texture_assets: Vec, #[serde(default)] model_assets: Vec, + #[serde(default)] + animation_assets: Vec, } #[derive(Deserialize)] struct ModelSpec { name: String, members: Vec, + #[serde(default)] + hierarchy: Option, } #[derive(Deserialize)] @@ -413,11 +417,13 @@ fn specs_hold() { spec.model_assets .iter() .map(|model| zerosyntax_analysis::index::ModelAsset { + hierarchy: model.hierarchy.clone(), name: model.name.clone(), members: model.members.clone(), }) .collect(), ); + index.set_file_animations("spec-animations", spec.animation_assets.clone()); index.set_file_assets( "spec-assets", spec.audio_assets diff --git a/crates/analysis/tests/spec/AnimationCompletion.ini b/crates/analysis/tests/spec/AnimationCompletion.ini new file mode 100644 index 0000000..af1ff1a --- /dev/null +++ b/crates/analysis/tests/spec/AnimationCompletion.ini @@ -0,0 +1,37 @@ +Object Soldier + Draw = W3DModelDraw Tag + DefaultConditionState + Model = SoldierSkin + Animation = $1 + IdleAnimation = "HumanSKL.I$2" + Animation = HumanSKL.Run $3 + IdleAnimation = HumanSKL.Idle 0 $4 + End + ConditionState = MOVING + Animation = $5 + End + ConditionState = DAMAGED + Model = DamagedSkin + Animation = $6 + End + ConditionState = REALLYDAMAGED + Model = None + Animation = $7 + End + ConditionState = DYING + Model = Missing + Animation = $8 + End + TransitionState = TRANS_A TRANS_B + Animation = $9 + End + End + Draw = W3DModelDraw OtherTag + ConditionState = NONE + Model = DamagedSkin + End + ConditionState = MOVING + Animation = $10 + End + End +End diff --git a/crates/analysis/tests/spec/AnimationCompletion.spec.toml b/crates/analysis/tests/spec/AnimationCompletion.spec.toml new file mode 100644 index 0000000..f11ad79 --- /dev/null +++ b/crates/analysis/tests/spec/AnimationCompletion.spec.toml @@ -0,0 +1,64 @@ +no_errors = true + +[[model_assets]] +name = "SoldierSkin" +hierarchy = "humanskl" +members = [] + +[[model_assets]] +name = "DamagedSkin" +hierarchy = "DamagedSKL" +members = [] + +[[animation_assets]] +name = "HumanSKL.Run" +hierarchy = "HumanSKL" + +[[animation_assets]] +name = "HumanSKL.Idle" +hierarchy = "HumanSKL" + +[[animation_assets]] +name = "DamagedSKL.Limp" +hierarchy = "DamagedSKL" + +[[complete]] +at = "$1" +equals = ["HumanSKL.Run", "HumanSKL.Idle"] + +[[complete]] +at = "$2" +equals = ["HumanSKL.Run", "HumanSKL.Idle"] + +[[complete]] +at = "$3" +equals = [] + +[[complete]] +at = "$4" +equals = [] + +[[complete]] +at = "$5" +equals = ["HumanSKL.Run", "HumanSKL.Idle"] + +[[complete]] +at = "$6" +equals = ["DamagedSKL.Limp"] + +[[complete]] +at = "$7" +equals = [] + +[[complete]] +at = "$8" +equals = [] + +[[complete]] +at = "$9" +equals = ["HumanSKL.Run", "HumanSKL.Idle"] + +# A plain ConditionState NONE is not an inheritable DefaultConditionState. +[[complete]] +at = "$10" +equals = [] diff --git a/crates/server/src/backend.rs b/crates/server/src/backend.rs index a40e042..edef9aa 100644 --- a/crates/server/src/backend.rs +++ b/crates/server/src/backend.rs @@ -33,12 +33,14 @@ use zerosyntax_syntax::{Edit, Parse, Strategy}; use crate::convert::{self, PositionEnc}; use crate::progress::ProgressReporter; +#[cfg(test)] +use crate::scan::parse_w3d_models; use crate::scan::{ clear_index_cache, index_cache_path, load_sibling_str_keys, read_asset_uri, read_lossy, scan_with_cache, ScanOutcome, ScanProgress, ScanStats, }; #[cfg(test)] -use crate::scan::{parse_w3d_models, scan_big, scan_roots}; +use crate::scan::{scan_big, scan_roots}; const CLEAR_INDEX_CACHE_COMMAND: &str = "zerosyntax.clearIndexCache"; const REBUILD_INDEX_CACHE_COMMAND: &str = "zerosyntax.rebuildIndexCache"; @@ -1243,26 +1245,28 @@ impl Backend { let base_ini_count = scanned .iter() - .filter(|(is_base, (_, _, _, _, _, _, models, assets, _))| { - *is_base && models.is_empty() && assets.is_empty() - }) + .filter( + |(is_base, (_, _, _, _, _, _, models, assets, _, animations))| { + *is_base && models.is_empty() && assets.is_empty() && animations.is_empty() + }, + ) .count(); self.base_indexed_count .store(base_ini_count, Ordering::Relaxed); self.scan_finished.store(true, Ordering::Relaxed); let ini_total = scanned .iter() - .filter(|(_, (_, _, _, _, _, _, models, assets, _))| { - models.is_empty() && assets.is_empty() + .filter(|(_, (_, _, _, _, _, _, models, assets, _, animations))| { + models.is_empty() && assets.is_empty() && animations.is_empty() }) .count(); let model_total: usize = scanned .iter() - .map(|(_, (_, _, _, _, _, _, models, _, _))| models.len()) + .map(|(_, (_, _, _, _, _, _, models, _, _, _))| models.len()) .sum(); let (audio_total, texture_total) = scanned .iter() - .flat_map(|(_, (_, _, _, _, _, _, _, assets, _))| assets) + .flat_map(|(_, (_, _, _, _, _, _, _, assets, _, _))| assets) .fold((0, 0), |(audio, texture), asset| match asset.kind { zerosyntax_analysis::index::AssetKind::Audio => (audio + 1, texture), zerosyntax_analysis::index::AssetKind::Texture => (audio, texture + 1), @@ -1280,8 +1284,21 @@ impl Backend { let mut replacement = WorkspaceIndex::new(); replacement.set_model_member_strictness(model_member_strictness); self.virtual_files.clear(); - for (_, (uri, defs, refs, tags, object_models, object_parents, models, assets, text)) in - scanned + for ( + _, + ( + uri, + defs, + refs, + tags, + object_models, + object_parents, + models, + assets, + text, + animations, + ), + ) in scanned { if let Some(text) = text { self.virtual_files.insert(uri.clone(), text); @@ -1293,6 +1310,7 @@ impl Backend { replacement.set_file_object_models(&uri, object_models); replacement.set_file_object_parents(&uri, object_parents); replacement.insert_file_models_prepared(&uri, models); + replacement.set_file_animations(&uri, animations); replacement.set_file_assets(&uri, assets); } } @@ -1942,7 +1960,17 @@ impl LanguageServer for Backend { Some(uri.as_str()), ) .into_iter() - .map(|c| convert::to_lsp_completion(c, snippets)) + .map(|c| { + let animation = c.kind == completion::CompletionKind::W3dAnimation; + let mut item = convert::to_lsp_completion(c, snippets); + if animation { + item.text_edit = Some(CompletionTextEdit::Edit(TextEdit { + range: convert::animation_completion_range(&rope, offset, self.enc()), + new_text: item.label.clone(), + })); + } + item + }) .collect(); Ok(Some(CompletionResponse::Array(items))) } @@ -3003,6 +3031,68 @@ mod tests { assert!(assets.iter().all(|asset| asset.uri.starts_with("file:"))); } + #[test] + fn w3d_scan_completes_animations_for_condition_model() { + fn chunk(kind: u32, payload: Vec) -> Vec { + [ + kind.to_le_bytes().to_vec(), + (payload.len() as u32).to_le_bytes().to_vec(), + payload, + ] + .concat() + } + fn header(name: &str, hierarchy: &str) -> Vec { + let mut bytes = vec![0; 44]; + bytes[4..4 + name.len()].copy_from_slice(name.as_bytes()); + bytes[20..20 + hierarchy.len()].copy_from_slice(hierarchy.as_bytes()); + bytes + } + let dir = + std::env::temp_dir().join(format!("zerosyntax-animations-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut hlod = vec![0; 40]; + hlod[8..15].copy_from_slice(b"Soldier"); + hlod[24..32].copy_from_slice(b"HumanSKL"); + std::fs::write(dir.join("Soldier.w3d"), chunk(0x700, chunk(0x701, hlod))).unwrap(); + std::fs::write( + dir.join("Run.w3d"), + chunk(0x200, chunk(0x201, header("Run", "HumanSKL"))), + ) + .unwrap(); + std::fs::write( + dir.join("Idle.w3d"), + chunk(0x280, chunk(0x281, header("Idle", "HumanSKL"))), + ) + .unwrap(); + std::fs::write( + dir.join("Other.w3d"), + chunk(0x200, chunk(0x201, header("Fly", "PlaneSKL"))), + ) + .unwrap(); + let analyzer = Analyzer::embedded(); + let scanned = scan_roots(&analyzer, std::slice::from_ref(&dir)); + std::fs::remove_dir_all(&dir).unwrap(); + let mut idx = WorkspaceIndex::new(); + for (uri, _, _, _, _, _, models, _, _, animations) in scanned { + idx.set_file_models(&uri, models); + idx.set_file_animations(&uri, animations); + } + let src = "Object Soldier\n Draw = W3DModelDraw Tag\n ConditionState = NONE\n Model = Soldier\n Animation = \n End\n End\nEnd\n"; + let offset = src.find("Animation = ").unwrap() + "Animation = ".len(); + let mut labels: Vec<_> = completion::complete( + &analyzer, + &analyzer.parse(src), + offset as u32, + Some(&idx), + None, + ) + .into_iter() + .map(|c| c.label) + .collect(); + labels.sort(); + assert_eq!(labels, ["HumanSKL.Idle", "HumanSKL.Run"]); + } + #[test] fn w3d_root_scan_powers_model_and_bone_completions() { // End-to-end over the `baseIniRoots` path: a directory containing a @@ -3024,13 +3114,16 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); let mut idx = WorkspaceIndex::new(); - for (uri, defs, refs, tags, object_models, object_parents, models, assets, _) in scanned { + for (uri, defs, refs, tags, object_models, object_parents, models, assets, _, animations) in + scanned + { idx.set_file(&uri, defs); idx.set_file_refs(&uri, refs); idx.set_file_tags(&uri, tags); idx.set_file_object_models(&uri, object_models); idx.set_file_object_parents(&uri, object_parents); idx.set_file_models(&uri, models); + idx.set_file_animations(&uri, animations); idx.set_file_assets(&uri, assets); } assert!(idx.is_model_asset("Good"), "model name from file stem"); diff --git a/crates/server/src/cache.rs b/crates/server/src/cache.rs index e5f35fa..d08bfb9 100644 --- a/crates/server/src/cache.rs +++ b/crates/server/src/cache.rs @@ -17,7 +17,7 @@ const STORE_FILE: &str = "index-v1.sqlite3"; const STORE_VERSION: i64 = 1; /// Bump when `CachedEntry` serialization or any extractor feeding it changes. /// SQL layout changes instead bump `STORE_VERSION` and the store filename. -const PRODUCER_ABI: &[u8] = b"zerosyntax-physical-input-v3"; +const PRODUCER_ABI: &[u8] = b"zerosyntax-physical-input-v4"; const BUSY_TIMEOUT: Duration = Duration::from_secs(2); const TOUCH_INTERVAL_SECS: i64 = 24 * 60 * 60; const MAX_AGE_SECS: i64 = 30 * 24 * 60 * 60; diff --git a/crates/server/src/cli.rs b/crates/server/src/cli.rs index f54fe98..38df1f7 100644 --- a/crates/server/src/cli.rs +++ b/crates/server/src/cli.rs @@ -272,8 +272,18 @@ fn has_extension(path: &Path, extension: &str) -> bool { } fn apply_entries(index: &mut WorkspaceIndex, entries: Vec) { - for (file, definitions, references, tags, object_models, object_parents, models, assets, _) in - entries + for ( + file, + definitions, + references, + tags, + object_models, + object_parents, + models, + assets, + _, + animations, + ) in entries { index.set_file(&file, definitions); index.set_file_refs(&file, references); @@ -281,6 +291,7 @@ fn apply_entries(index: &mut WorkspaceIndex, entries: Vec) { index.set_file_object_models(&file, object_models); index.set_file_object_parents(&file, object_parents); index.set_file_models(&file, models); + index.set_file_animations(&file, animations); index.set_file_assets(&file, assets); } } diff --git a/crates/server/src/convert.rs b/crates/server/src/convert.rs index e5ae144..fe0dc18 100644 --- a/crates/server/src/convert.rs +++ b/crates/server/src/convert.rs @@ -192,7 +192,9 @@ pub fn to_lsp_completion(c: Completion, snippets_supported: bool) -> CompletionI CompletionKind::EnumMember => CompletionItemKind::ENUM_MEMBER, CompletionKind::Value => CompletionItemKind::VALUE, CompletionKind::Reference => CompletionItemKind::REFERENCE, - CompletionKind::W3dModel => CompletionItemKind::REFERENCE, + CompletionKind::W3dModel | CompletionKind::W3dAnimation => { + CompletionItemKind::REFERENCE + } }), detail: c.detail, documentation, @@ -204,6 +206,29 @@ pub fn to_lsp_completion(c: Completion, snippets_supported: bool) -> CompletionI } } +/// Replace the entire qualified animation token, even when a client treats +/// the dot as a word boundary. Preserve surrounding quotes and numeric args. +pub fn animation_completion_range(rope: &Rope, offset: u32, enc: PositionEnc) -> Range { + let cursor = rope.byte_to_char(offset as usize); + let mut start = cursor; + let mut end = cursor; + let separator = |c: char| c.is_whitespace() || matches!(c, '=' | '"' | ';'); + while start > 0 && !separator(rope.char(start - 1)) { + start -= 1; + } + while end < rope.len_chars() && !separator(rope.char(end)) { + end += 1; + } + span_to_range( + rope, + Span { + start: rope.char_to_byte(start) as u32, + end: rope.char_to_byte(end) as u32, + }, + enc, + ) +} + /// Convert an analysis outline symbol to an LSP `DocumentSymbol` (recursive). pub fn to_lsp_document_symbol(rope: &Rope, s: &DocSymbol, enc: PositionEnc) -> DocumentSymbol { #[allow(deprecated)] // `deprecated` field is required by the struct literal @@ -354,6 +379,29 @@ mod tests { out } + #[test] + fn animation_edit_replaces_qualified_token_and_preserves_arguments() { + for enc in [PositionEnc::Utf8, PositionEnc::Utf16] { + for (input, expected) in [ + ( + "; Æ漢\n Animation = \"HumanSKL.R$un\" 0 9\n", + "; Æ漢\n Animation = \"HumanSKL.Idle\" 0 9\n", + ), + ( + " Animation = HumanSKL.$Run 16\n", + " Animation = HumanSKL.Idle 16\n", + ), + (" Animation = $\n", " Animation = HumanSKL.Idle\n"), + ] { + let offset = input.find('$').unwrap() as u32; + let mut rope = Rope::from_str(&input.replace('$', "")); + let range = animation_completion_range(&rope, offset, enc); + apply_change(&mut rope, Some(range), "HumanSKL.Idle", enc); + assert_eq!(rope.to_string(), expected); + } + } + } + #[test] fn model_completion_is_tagged_but_not_eagerly_rendered() { let item = to_lsp_completion( diff --git a/crates/server/src/scan.rs b/crates/server/src/scan.rs index c48e52b..07af245 100644 --- a/crates/server/src/scan.rs +++ b/crates/server/src/scan.rs @@ -12,8 +12,9 @@ use postcard::ser_flavors::Flavor; use serde::{Deserialize, Serialize}; use tower_lsp::lsp_types::Url; use zerosyntax_analysis::index::{ - definitions_in, module_tags_in, object_models_in, object_parents_in, references_in, AssetKind, - Definition, FileAsset, ModelAsset, ModuleTagDefinition, ReferenceSite, + definitions_in, module_tags_in, object_models_in, object_parents_in, references_in, + AnimationAsset, AssetKind, Definition, FileAsset, ModelAsset, ModuleTagDefinition, + ReferenceSite, }; use zerosyntax_analysis::Analyzer; use zerosyntax_w3d::W3dFile; @@ -30,6 +31,7 @@ pub(crate) type ScanEntry = ( Vec, Vec, Option>, + Vec, ); #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -148,6 +150,7 @@ struct CachedEntry { object_models: Vec<(String, Vec)>, object_parents: Vec<(String, String)>, models: Vec, + animations: Vec, assets: Vec, text: Option, } @@ -162,6 +165,7 @@ impl From<&ScanEntry> for CachedEntry { object_models: entry.4.clone(), object_parents: entry.5.clone(), models: entry.6.clone(), + animations: entry.9.clone(), assets: entry.7.clone(), text: entry.8.as_deref().map(str::to_owned), } @@ -180,6 +184,7 @@ impl From for ScanEntry { entry.models, entry.assets, entry.text.map(Arc::from), + entry.animations, ) } } @@ -368,21 +373,39 @@ fn raw_asset(path: &str, uri: &str) -> Option { }) } +#[cfg(test)] pub(crate) fn parse_w3d_models(bytes: &[u8], fallback_name: &str) -> Vec { + parse_w3d_assets(bytes, fallback_name).0 +} + +fn parse_w3d_assets(bytes: &[u8], fallback_name: &str) -> (Vec, Vec) { match W3dFile::parse(bytes) { - Ok(file) => file - .catalog(fallback_name) - .into_iter() - .map(|model| ModelAsset { - name: model.name, - members: model.members, - }) - .collect(), - Err(_) if !fallback_name.trim().is_empty() => vec![ModelAsset { - name: fallback_name.trim().to_string(), - members: Vec::new(), - }], - Err(_) => Vec::new(), + Ok(file) => ( + file.catalog(fallback_name) + .into_iter() + .map(|model| ModelAsset { + name: model.name, + members: model.members, + hierarchy: model.hierarchy, + }) + .collect(), + file.animations() + .iter() + .map(|animation| AnimationAsset { + name: animation.name.clone(), + hierarchy: animation.hierarchy.clone(), + }) + .collect(), + ), + Err(_) if !fallback_name.trim().is_empty() => ( + vec![ModelAsset { + name: fallback_name.trim().to_string(), + members: Vec::new(), + hierarchy: None, + }], + Vec::new(), + ), + Err(_) => (Vec::new(), Vec::new()), } } @@ -411,13 +434,14 @@ pub(crate) fn scan_big(analyzer: &Analyzer, path: &Path) -> Result Result Result Result> { Vec::new(), Vec::new(), None, + Vec::new(), )]) } else if ext.eq_ignore_ascii_case("w3d") { let bytes = @@ -805,8 +832,8 @@ fn scan_path(analyzer: &Analyzer, path: &Path) -> Result> { .file_stem() .and_then(|s| s.to_str()) .unwrap_or_default(); - let models = parse_w3d_models(&bytes, stem); - Ok((!models.is_empty()) + let (models, animations) = parse_w3d_assets(&bytes, stem); + Ok((!models.is_empty() || !animations.is_empty()) .then_some(( uri.to_string(), Vec::new(), @@ -817,6 +844,7 @@ fn scan_path(analyzer: &Analyzer, path: &Path) -> Result> { models, Vec::new(), None, + animations, )) .into_iter() .collect()) @@ -831,6 +859,7 @@ fn scan_path(analyzer: &Analyzer, path: &Path) -> Result> { Vec::new(), vec![asset], None, + Vec::new(), )]) } else { Ok(Vec::new()) @@ -1020,11 +1049,23 @@ mod tests { let cache_dir = root.join("cache"); let workspace = root.join("workspace"); std::fs::create_dir_all(&workspace).unwrap(); + let mut animation = vec![0; 44]; + animation[4..7].copy_from_slice(b"Run"); + animation[20..28].copy_from_slice(b"HumanSKL"); + let animation = [ + 0x200u32.to_le_bytes().to_vec(), + 52u32.to_le_bytes().to_vec(), + 0x201u32.to_le_bytes().to_vec(), + 44u32.to_le_bytes().to_vec(), + animation, + ] + .concat(); write_big( &workspace.join("Data.big"), &[ ("Data\\INI\\Object.ini", b"Object BigObject\nEnd\n"), ("Data\\INI\\Weapon.ini", b"Weapon BigWeapon\nEnd\n"), + ("Art\\W3D\\Run.w3d", &animation), ], ); let workspace_roots = vec![workspace]; @@ -1037,7 +1078,17 @@ mod tests { &mut |_| {}, ); assert_eq!(cold.stats.cache_misses, 1); - assert_eq!(cold.entries.len(), 2); + assert_eq!(cold.entries.len(), 3); + let animation_entry = cold + .entries + .iter() + .find(|(_, entry)| !entry.9.is_empty()) + .unwrap(); + assert!( + animation_entry.1 .6.is_empty(), + "animation-only file is not a model" + ); + assert_eq!(animation_entry.1 .9[0].name, "HumanSKL.Run"); let warm = scan_with_cache_in( &Analyzer::embedded(), diff --git a/crates/server/tests/e2e.py b/crates/server/tests/e2e.py index 2fa9a5c..33a1ff1 100644 --- a/crates/server/tests/e2e.py +++ b/crates/server/tests/e2e.py @@ -84,6 +84,13 @@ def w3d_chunk(kind, payload): w3d_chunk(0x38, w3d_chunk(0x48, w3d_chunk(0x49, struct.pack(", + pub hierarchy: Option, +} + +/// Engine animation name (`Hierarchy.Animation`) and its target skeleton. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AnimationCatalogEntry { + pub name: String, + pub hierarchy: String, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -44,6 +52,7 @@ pub struct W3dFile { pub(crate) hlods: Vec, extra_names: Vec, extra_members: Vec, + pub(crate) animations: Vec, } impl W3dFile { @@ -52,6 +61,15 @@ impl W3dFile { } pub fn catalog(&self, fallback_name: &str) -> Vec { + // Animation-only files are not renderable models. + if !self.animations.is_empty() + && self.meshes.is_empty() + && self.hierarchies.is_empty() + && self.hlods.is_empty() + && self.extra_names.is_empty() + { + return Vec::new(); + } let mut names = Vec::new(); let mut members = self.extra_members.clone(); push_name(&mut names, fallback_name); @@ -83,12 +101,32 @@ impl W3dFile { .into_iter() .filter(|name| !name.is_empty()) .map(|name| ModelCatalogEntry { + hierarchy: self + .hlods + .iter() + .find(|hlod| hlod.name.eq_ignore_ascii_case(&name)) + .or_else(|| { + (name.eq_ignore_ascii_case(fallback_name) && self.hlods.len() == 1) + .then(|| &self.hlods[0]) + }) + .map(|hlod| hlod.hierarchy.clone()) + .or_else(|| { + self.hierarchies + .iter() + .find(|h| h.name.eq_ignore_ascii_case(&name)) + .map(|h| h.name.clone()) + }) + .filter(|name| !name.is_empty()), name, members: members.clone(), }) .collect() } + pub fn animations(&self) -> &[AnimationCatalogEntry] { + &self.animations + } + pub fn render_thumbnail( &self, model: &str, @@ -137,6 +175,64 @@ mod tests { .collect() } + #[test] + fn animation_headers_keep_qualified_names_and_stay_out_of_models() { + for kind in [0x200, 0x280, 0x2c0] { + let mut header = vec![0; if kind == 0x2c0 { 48 } else { 44 }]; + header[4..7].copy_from_slice(b"Run"); + header[20..28].copy_from_slice(b"HumanSKL"); + let file = W3dFile::parse(&chunk(kind, chunk(kind + 1, header))).unwrap(); + assert!(file.catalog("Run").is_empty()); + assert_eq!( + file.animations(), + &[AnimationCatalogEntry { + name: "HumanSKL.Run".into(), + hierarchy: "HumanSKL".into(), + }] + ); + assert!(W3dFile::parse(&chunk(kind, chunk(kind + 1, vec![0; 35]))).is_err()); + } + } + + #[test] + fn catalog_associates_each_hlod_with_its_own_skeleton() { + fn hlod(name: &str, hierarchy: &str) -> Vec { + let mut header = vec![0; 40]; + header[8..8 + name.len()].copy_from_slice(name.as_bytes()); + header[24..24 + hierarchy.len()].copy_from_slice(hierarchy.as_bytes()); + chunk(0x700, chunk(0x701, header)) + } + let bytes = [hlod("Soldier", "HumanSKL"), hlod("Plane", "PlaneSKL")].concat(); + let file = W3dFile::parse(&bytes).unwrap(); + let catalog = file.catalog("Bundle"); + assert_eq!( + catalog + .iter() + .find(|m| m.name == "Soldier") + .unwrap() + .hierarchy + .as_deref(), + Some("HumanSKL") + ); + assert_eq!( + catalog + .iter() + .find(|m| m.name == "Plane") + .unwrap() + .hierarchy + .as_deref(), + Some("PlaneSKL") + ); + assert_eq!( + catalog + .iter() + .find(|m| m.name == "Bundle") + .unwrap() + .hierarchy, + None + ); + } + #[test] fn parses_and_renders_a_textured_w3d() { let mut header = vec![0; 116]; diff --git a/crates/w3d/src/parse.rs b/crates/w3d/src/parse.rs index 95ac6cd..f07a4ec 100644 --- a/crates/w3d/src/parse.rs +++ b/crates/w3d/src/parse.rs @@ -114,6 +114,25 @@ pub(crate) fn parse(bytes: &[u8]) -> Result { MESH => file.meshes.push(parse_mesh(chunk.data)?), HIERARCHY => file.hierarchies.push(parse_hierarchy(chunk.data)?), HLOD => file.hlods.push(parse_hlod(chunk.data)?), + // Raw, compressed (time-coded/adaptive delta), and morph headers + // share Version, Name[16], HierarchyName[16]. See w3d_file.h. + 0x0200 | 0x0280 | 0x02c0 => { + for header in chunks(chunk.data, 1)? { + if header.kind == chunk.kind + 1 { + if header.data.len() < 44 { + return Err(W3dError::new("truncated animation header")); + } + let name = fixed_name(&header.data[4..20]); + let hierarchy = fixed_name(&header.data[20..36]); + if !name.is_empty() && !hierarchy.is_empty() { + file.animations.push(crate::AnimationCatalogEntry { + name: format!("{hierarchy}.{name}"), + hierarchy, + }); + } + } + } + } _ => {} } collect_catalog_names( diff --git a/docs/language-server.md b/docs/language-server.md index 3af840b..c530324 100644 --- a/docs/language-server.md +++ b/docs/language-server.md @@ -212,6 +212,18 @@ ZeroSyntax supports incremental document sync, diagnostics, completion, hover, go to definition, references, rename, semantic tokens, document and workspace symbols, folding ranges, quick fixes, and optional document formatting. +`Animation` and `IdleAnimation` offer qualified W3D animation names matching the +`Model` in the current condition state. States without a `Model` inherit it from +the preceding `DefaultConditionState` in the same draw module; `TransitionState` +works the same way. Matching uses the model's skeleton and animation headers, +including animations stored in separate loose W3D files or BIG archives under +workspace roots or `baseIniRoots`. An explicit `Model = None` offers no animations. +Suggestions apply only to the first value, preserving optional distance and repeat +arguments. Semantic highlighting treats animation names (including quoted names) +as references and distance/repeat arguments as numbers. Header layouts and state inheritance follow the +[engine source](https://github.com/electronicarts/CnC_Generals_Zero_Hour/blob/main/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/Drawable/Draw/W3DModelDraw.cpp) +and [W3D format definitions](https://github.com/electronicarts/CnC_Generals_Zero_Hour/blob/main/GeneralsMD/Code/Libraries/Source/WWVegas/WW3D2/w3d_file.h). + W3D model completion items support `completionItem/resolve`. Clients that render Markdown completion documentation can show a lazy textured thumbnail for the active `Model =` suggestion. The initial completion list contains no