0.1.24 fix inheritFrom="type:id"

This commit is contained in:
2026-08-11 20:01:33 +02:00
parent 84a44bedfd
commit 19dbfe0f34
15 changed files with 521 additions and 12 deletions
+18 -1
View File
@@ -1,9 +1,26 @@
# Changelog # Changelog
## 0.1.23 — 2026-08-11 ## 0.1.24 — 2026-08-11
### Fixed ### Fixed
- Manifest-style qualified reference values (`Type:Id`, e.g.
`inheritFrom="AudioEvent:BaseSoundEffect"`,
`Sound="AudioEvent:JAP_Refinery_Select"`, `Side="PlayerTemplate:Allies"`)
now resolve to the plain-id definitions indexed from XML (mod or
`SageXml`). Previously the plugin only applied the “last colon segment”
rule to manifest asset names, so qualified XML references were reported as
unresolved even when the definition existed (the reported
`AudioEvent:BaseSoundEffect` case).
- The same normalization now applies to simple-content references, the
semantic reverse index (Find All References / CodeLens counts), and the
reference peek path, so hover, Ctrl+click, diagnostics, reference counts
and unreferenced reports all agree.
- Value completion keeps a `Type:` prefix the user already typed:
`inheritFrom="AudioEvent:Base…` completes to
`AudioEvent:BaseSoundEffect` instead of dropping the prefix. Plain ids
without a prefix keep the previous bare-id behavior.
- `inheritFrom` is now accepted on all `BaseAssetType`-derived assets (e.g. `FXList`, `AIMicroManagerData`, `ObjectCreationList`, `OnDemandTextureImage`, `AITargetingHeuristic`). The XSD only declares it on `BaseInheritableAsset`, but vanilla and Corona data use it more broadly. Attribute legality is now separate from the CodeLens / Find All References “reference target by design” filter, so the universal attribute does not widen the code-lens type list. - `inheritFrom` is now accepted on all `BaseAssetType`-derived assets (e.g. `FXList`, `AIMicroManagerData`, `ObjectCreationList`, `OnDemandTextureImage`, `AITargetingHeuristic`). The XSD only declares it on `BaseInheritableAsset`, but vanilla and Corona data use it more broadly. Attribute legality is now separate from the CodeLens / Find All References “reference target by design” filter, so the universal attribute does not widen the code-lens type list.
- `simpleContent` complex types (`AudioFileRefWithWeight`, `MultisoundSubsoundRef`) keep their XSD attributes (`Weight`, `Volume`, `PitchShiftLow/High`, ...) and their text content (`<Sound>AudioFile</Sound>`, `<Subsound>VoiceEvent</Subsound>`) is now handled as a typed asset reference by completion, hover, navigation, diagnostics, the semantic reference index, and Find All References. - `simpleContent` complex types (`AudioFileRefWithWeight`, `MultisoundSubsoundRef`) keep their XSD attributes (`Weight`, `Volume`, `PitchShiftLow/High`, ...) and their text content (`<Sound>AudioFile</Sound>`, `<Subsound>VoiceEvent</Subsound>`) is now handled as a typed asset reference by completion, hover, navigation, diagnostics, the semantic reference index, and Find All References.
- Fragment roots whose name also appears as a nested child type (e.g. `<EvaEvent>`, `<UpgradeTemplate>`) now resolve to the top-level `AssetDeclaration` type instead of the colliding child type. - Fragment roots whose name also appears as a nested child type (e.g. `<EvaEvent>`, `<UpgradeTemplate>`) now resolve to the top-level `AssetDeclaration` type instead of the colliding child type.
+87
View File
@@ -2128,3 +2128,90 @@ hover / 跳转 / 诊断 / FAR)在第三十一轮补齐,见下。
- `docs/plan.md`simple-content 文本引用说明补充第三十一轮扩展; - `docs/plan.md`simple-content 文本引用说明补充第三十一轮扩展;
- `docs/features-reference-counts.md`:引用语义说明补充“含 simpleContent 复杂 - `docs/features-reference-counts.md`:引用语义说明补充“含 simpleContent 复杂
类型”。 类型”。
---
## 三十二、问题分析(2026-08-11):限定引用值 `类型:ID` 未被归一化导致误报未解析
### 现象
Corona `Data\Allied\Units\AlliedFutureTankX-1\AudioEvent.xml`
```xml
<Includes>
<Include type="instance" source="DATA:SageXml/Sounds/BaseSoundEffect.xml" />
</Includes>
<AudioEvent
id="ALL_FutureTank_ArmPrimaryWeapon"
inheritFrom="AudioEvent:BaseSoundEffect"
... />
```
报 `Unresolved reference "AudioEvent:BaseSoundEffect"`,提示当前索引中未找到;
但 `SageXml\Sounds\BaseSoundEffect.xml` 里确实存在 `<AudioEvent id="BaseSoundEffect" />`
且 `instance` include 会被索引器与文档局部 overlay 正常 walk。
### 根因
插件只在 **manifest 一侧**做了“资产名 `类型:ID` → 裸 ID(取最后冒号段)”
的归一化(`manifestParser.deriveAssetId`);**XML 引用值一侧**直接用原始值查
`assetsById`。于是 `AudioEvent:BaseSoundEffect` 被当成完整 ID 精确匹配,
索引里只有 `BaseSoundEffect`,必然查不到。
实测最小复现:索引中包含 `AudioEvent@BaseSoundEffect`origin=sdk),
`assetsById.get("audioevent:basesoundeffect")` 返回 NOT FOUND
`resolveReferenceTargetsForType` 返回 0 目标。
### 影响面(真实数据统计)
这是原版数据的**普遍写法**,不是用户笔误:
| 属性 | SageXml | Corona Data | 典型值 |
|---|---|---:|---|
| `inheritFrom` | 5,483 | 3,219 | `AudioEvent:BaseSoundEffect` |
| `Sound`AudioEntry | 39 | 98 | `AudioEvent:JAP_Refinery_Select` |
| `Side` | 67 | 195 | `PlayerTemplate:Allies` |
| `ParticleTexture` | 2 | 2 | `Texture:FXLenzFlare01` |
前缀全部是**定义资产的具体类型**(manifest 全名格式),而 XSD refType 可能是
基类(如 `Sound` 的 refType 是 `BaseAudioEventInfo`,前缀是 `AudioEvent`)。
两侧数据的 `id="类型:ID"` 出现次数均为 0,说明定义侧永远是裸 ID,取最后冒号段
没有歧义。少数 `Sound="AudioEvent:MammothTankTurretMoveLoop"` 等引用在 SDK
源码与三个 manifest 中都找不到定义,是原版数据自身的死引用,归一化后仍会
(且应该)继续报未解析。
### 修复
1. `refs.ts` 新增 `normalizeReferenceId(value)`:取最后冒号段(与
`deriveAssetId` 同一规则;冒号后为空时保留原值,避免半输入误匹配),
应用到 `resolveReferenceTargetsForType` 与 `resolveContentReferenceTargets`。
2. `referenceIndex.ts` 的 `buildReferenceIndex` 与 `features/references.ts`
的 `definitionsForReference` 同样归一化,FAR / CodeLens / 引用 peek 与
诊断、hover、跳转保持一致。
3. `records.ts` 不修改:记录仍保存原始值与原始偏移,导航/悬停范围不受影响,
缓存格式与版本不变。
4. `completion.ts` 的 `assetIdItems`:当前输入段含 `:` 时按冒号后片段过滤,
补全项 label/insertText 为“已输入前缀 + 裸 ID”(如 `AudioEvent:Base…`
→ `AudioEvent:BaseSoundEffect`);未输入前缀时保持裸 ID 补全,不特判任何
类型、也不改变默认补全形态。
### 测试(219 → 226 全绿)
- `refs.test.mjs``normalizeReferenceId` 边界;qualified `inheritFrom`
解析、裸 ID 不变、错误类型前缀仍被 selfType 过滤;qualified 属性
`Sound` / `Side`)与 simple-content`AudioFile:...`)引用解析;
- `referenceIndex.test.mjs`:qualified 记录计入反向索引(FAR / CodeLens 桶);
- `indexer.test.mjs`:临时项目集成——`instance` include 进 SageXml +
`inheritFrom="AudioEvent:BaseSoundEffect"`,断言定义入库、解析命中、
反向索引落点(即用户报告的完整场景);
- `completion.test.mjs``AudioEvent:Base…` 补全为
`AudioEvent:BaseSoundEffect` 且替换范围只覆盖当前段;无前缀仍补裸 ID;
- `contentFeatures.test.mjs`qualified `inheritFrom` 不产生未解析诊断,
Ctrl+点击精确定位到裸 ID 定义。
### 文档同步
- `docs/requirements.md`:情况描述补充 `类型:ID` 引用写法与归一化规则;
- `docs/plan.md`:设计决策 5 补充限定引用值归一化,实施记录追加第 29 轮;
- `CHANGELOG.md`0.1.24。
+5
View File
@@ -36,6 +36,11 @@
- `inheritFrom`(按元素自身类型过滤); - `inheritFrom`(按元素自身类型过滤);
-`refType``isRef` 属性(按同名 ID 匹配任意声明类型)。 -`refType``isRef` 属性(按同名 ID 匹配任意声明类型)。
引用值本身支持原版/Mod 常用的 manifest 风格全名 `类型:ID`
`inheritFrom="AudioEvent:BaseSoundEffect"``Sound="AudioEvent:..."`
`Side="PlayerTemplate:Allies"`):解析与反向索引先按 `normalizeReferenceId`
取最后冒号段,再执行上述类型过滤;记录里的原始值与偏移保持不变。
`inheritFrom``BaseAssetType` 系资产是通用合法属性(XSD 只在 `inheritFrom``BaseAssetType` 系资产是通用合法属性(XSD 只在
`BaseInheritableAsset` 声明,但原版数据在 `FXList` 等类型上也使用)。这里的 `BaseInheritableAsset` 声明,但原版数据在 `FXList` 等类型上也使用)。这里的
“合法属性”判定与“设计上应显示引用计数”的 `referenceTargetTypes()` 是分开的: “合法属性”判定与“设计上应显示引用计数”的 `referenceTargetTypes()` 是分开的:
+9 -1
View File
@@ -142,7 +142,7 @@ test/
跨重建复用(详见设计决策 14)。 跨重建复用(详见设计决策 14)。
3. **manifest 资产建模**:类型优先用哈希表,未知时从名称前缀推导;可引用 ID 取最后冒号段;类型名统一走大小写规范化(`W3dContainer``W3DContainer`),类型匹配严格遵循 XSD 继承链。`assetsById` 按 id 汇总**全部类型**的定义,去重身份为 `(type, file, line)`,同一 manifest 中同名但不同类型的美术资产(如 `W3DHierarchy:AUMCV_HOVER``W3DContainer:AUMCV_HOVER`)必须全部保留,避免 `Model@Name` 这类 `BaseRenderAssetType` 引用因先到的非渲染类型而被误判为未解析。 3. **manifest 资产建模**:类型优先用哈希表,未知时从名称前缀推导;可引用 ID 取最后冒号段;类型名统一走大小写规范化(`W3dContainer``W3DContainer`),类型匹配严格遵循 XSD 继承链。`assetsById` 按 id 汇总**全部类型**的定义,去重身份为 `(type, file, line)`,同一 manifest 中同名但不同类型的美术资产(如 `W3DHierarchy:AUMCV_HOVER``W3DContainer:AUMCV_HOVER`)必须全部保留,避免 `Model@Name` 这类 `BaseRenderAssetType` 引用因先到的非渲染类型而被误判为未解析。
4. **上下文感知元素类型**:同名元素按父元素类型解析(`resolveElementType` 沿解析树逐层 `childTypeOf`,失败回退全局映射),保证 `<Weapon>` 等元素的属性/引用判定正确。 4. **上下文感知元素类型**:同名元素按父元素类型解析(`resolveElementType` 沿解析树逐层 `childTypeOf`,失败回退全局映射),保证 `<Weapon>` 等元素的属性/引用判定正确。
5. **引用判定与解析**`refType``isRef` 均视为引用;带 `refType` 时严格按类型过滤(同名 ID 不串类型);`inheritFrom` 按可继承类型过滤。**局部作用域例外**(`isLocalReferenceAttribute`):`id` 是元素自身的定义点——无 refType 或 refType 与自身类型兼容时不检查、不解析(`RoadObject@id→Road` 这类跨类型 id 引用保留检查);Poid 类型属性是管线局部引用,全局索引无法判定,不检查、不解析。 5. **引用判定与解析**`refType``isRef` 均视为引用;带 `refType` 时严格按类型过滤(同名 ID 不串类型);`inheritFrom` 按可继承类型过滤。**局部作用域例外**(`isLocalReferenceAttribute`):`id` 是元素自身的定义点——无 refType 或 refType 与自身类型兼容时不检查、不解析(`RoadObject@id→Road` 这类跨类型 id 引用保留检查);Poid 类型属性是管线局部引用,全局索引无法判定,不检查、不解析。**限定引用值**:原版/Mod 数据常用 manifest 风格全名 `类型:ID`(如 `AudioEvent:BaseSoundEffect``PlayerTemplate:Allies`);XML 定义侧 id 从不含冒号,所以查询统一走 `normalizeReferenceId`(取最后冒号段,与 manifest 的 `deriveAssetId` 同一规则)后再按 refType/selfType 过滤,records 仍保留原始值与偏移供导航/悬停使用。
6. **重复 ID 诊断**:与 `check_duplicate_ids.py` 一致——SageXml 不参与冲突判定,mod 覆盖原版视为正常。 6. **重复 ID 诊断**:与 `check_duplicate_ids.py` 一致——SageXml 不参与冲突判定,mod 覆盖原版视为正常。
7. **未解析引用诊断**:按设置严重级别报告(默认 warning);类型不匹配时给出明确文案("有同名 ID 但类型不匹配")。`definitionMode` 设置控制跳转候选:`all`(mod + 原版全部列出,mod 优先)或 `project-only` 7. **未解析引用诊断**:按设置严重级别报告(默认 warning);类型不匹配时给出明确文案("有同名 ID 但类型不匹配")。`definitionMode` 设置控制跳转候选:`all`(mod + 原版全部列出,mod 优先)或 `project-only`
8. **跳转精度**XML 定义跳转到 `id` 属性值的精确 Range;manifest 定义映射到源码文件(如 SageXml)时也在文件内精确定位;找不到再回退到记录行。 8. **跳转精度**XML 定义跳转到 `id` 属性值的精确 Range;manifest 定义映射到源码文件(如 SageXml)时也在文件内精确定位;找不到再回退到记录行。
@@ -424,6 +424,14 @@ test/
修正;SageXml 源缺失时保持 manifest-only,文件存在但 id 被删时降级 修正;SageXml 源缺失时保持 manifest-only,文件存在但 id 被删时降级
到文件顶部;测试 178 → 184;分析见 `docs/analysis-issues.md` 到文件顶部;测试 178 → 184;分析见 `docs/analysis-issues.md`
二十八。 二十八。
29. [x] 限定引用值 `类型:ID` 归一化(2026-08-11v0.1.24):新增
`refs.normalizeReferenceId`(取最后冒号段,与 manifest `deriveAssetId`
同一规则),应用到属性引用、simple-content 引用、语义反向索引与
FAR/CodeLens 的 `definitionsForReference`records 仍保存原始值与
偏移;补全在已输入 `类型:` 前缀时按冒号后片段过滤并保留前缀;
实测 SageXml 5483 / Corona 3219 处 `inheritFrom="AudioEvent:..."`
等受限引用全部修复;测试 219 → 226;分析见
`docs/analysis-issues.md` 三十二。
## 四、验证结果(实测) ## 四、验证结果(实测)
+4
View File
@@ -25,6 +25,8 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
继承机制:`inheritFrom` 让一个元素默认获得目标元素的所有内容;具体合并行为由 `xai:joinAction``uri:ea.com:eala:asset:instance` 命名空间)控制,实际项目中出现的取值为 `Replace``Remove`。XSD 只在 `BaseInheritableAsset` 上显式声明 `inheritFrom`,但原版与 Corona 数据也在 `FXList``AIMicroManagerData``BaseAssetType` 系资产上使用它,插件按“所有资产类型的通用属性”处理。 继承机制:`inheritFrom` 让一个元素默认获得目标元素的所有内容;具体合并行为由 `xai:joinAction``uri:ea.com:eala:asset:instance` 命名空间)控制,实际项目中出现的取值为 `Replace``Remove`。XSD 只在 `BaseInheritableAsset` 上显式声明 `inheritFrom`,但原版与 Corona 数据也在 `FXList``AIMicroManagerData``BaseAssetType` 系资产上使用它,插件按“所有资产类型的通用属性”处理。
引用值写法:原版与 Corona 数据中的引用既可以是裸 ID,也可以是 manifest 风格的全名 `类型:ID`(如 `inheritFrom="AudioEvent:BaseSoundEffect"``Sound="AudioEvent:JAP_Refinery_Select"``Side="PlayerTemplate:Allies"``ParticleTexture="Texture:FXLenzFlare01"`)。XML 定义侧的 `id` 从不含冒号,因此插件统一按“最后冒号段”归一化后再匹配定义,类型过滤仍按 refType / 元素自身类型执行。
全部 XML 语法由 XSD 定义:SDK 自带 `Schemas/xsd/CnC3Types.xsd`(及其 800+ 个子 XSD)。大型 Mod 项目(如 Corona)还会携带自己修改过的 XSD 副本。 全部 XML 语法由 XSD 定义:SDK 自带 `Schemas/xsd/CnC3Types.xsd`(及其 800+ 个子 XSD)。大型 Mod 项目(如 Corona)还会携带自己修改过的 XSD 副本。
## 二、需求清单 ## 二、需求清单
@@ -38,6 +40,7 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
- 属性值: - 属性值:
- 引用型属性(XSD 中带 `xas:refType`)补全已定义的资产 ID - 引用型属性(XSD 中带 `xas:refType`)补全已定义的资产 ID
- `inheritFrom` 补全可继承的资产 ID - `inheritFrom` 补全可继承的资产 ID
- 引用值支持 `类型:ID` 前缀写法:已输入 `AudioEvent:` 时按冒号后的 ID 过滤,插入时保留已输入的前缀;未输入前缀时保持裸 ID 补全;
- 枚举值(XSD `xs:enumeration`); - 枚举值(XSD `xs:enumeration`);
- `$DEFINE` 常量(如 `$CIV_HEALTH_SMALL`); - `$DEFINE` 常量(如 `$CIV_HEALTH_SMALL`);
- `<Include source>` 补全可解析的文件路径(`DATA:` / `ART:` / `AUDIO:`)。 - `<Include source>` 补全可解析的文件路径(`DATA:` / `ART:` / `AUDIO:`)。
@@ -61,6 +64,7 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
- 缺失必填 `id`(顶层资产); - 缺失必填 `id`(顶层资产);
- 重复 ID(同类型 + 同 id,mod 文件之间;覆盖原版 SageXml 不算冲突); - 重复 ID(同类型 + 同 id,mod 文件之间;覆盖原版 SageXml 不算冲突);
- 引用未解析(引用了不存在的资产 ID,可配置是否忽略原版 manifest 中的 ID); - 引用未解析(引用了不存在的资产 ID,可配置是否忽略原版 manifest 中的 ID);
- 引用值带 `类型:` 前缀时先归一化为裸 ID 再判定(如 `AudioEvent:BaseSoundEffect``BaseSoundEffect`),前缀不影响类型过滤;
- simple-content 引用元素的文本未解析(同属性引用规则,仅带 refType 的类型)。 - simple-content 引用元素的文本未解析(同属性引用规则,仅带 refType 的类型)。
- `<Include>` 目标文件找不到、Include 循环; - `<Include>` 目标文件找不到、Include 循环;
- `$DEFINE` 未定义。 - `$DEFINE` 未定义。
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ra3-mod-xml", "name": "ra3-mod-xml",
"displayName": "%ra3modxml.displayName%", "displayName": "%ra3modxml.displayName%",
"description": "%ra3modxml.description%", "description": "%ra3modxml.description%",
"version": "0.1.23", "version": "0.1.24",
"publisher": "lanyi", "publisher": "lanyi",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
"icon": "images/icon.png", "icon": "images/icon.png",
+10 -2
View File
@@ -484,6 +484,14 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem, make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> { ): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> {
const lower = prefix.toLowerCase(); const lower = prefix.toLowerCase();
// Manifest-style qualified values ("AudioEvent:BaseSoundEffect") are
// common in vanilla data. When the user already typed a "Type:" prefix,
// filter on the id part after the last colon and keep the prefix in the
// inserted label (e.g. typing "AudioEvent:Base" completes to
// "AudioEvent:BaseSoundEffect", never to a bare "BaseSoundEffect").
const colon = lower.lastIndexOf(":");
const idPrefix = colon >= 0 ? lower.slice(colon + 1) : lower;
const typePrefix = colon >= 0 ? prefix.slice(0, colon + 1) : "";
// Deduplicate by id: the same asset can be defined in several places at // Deduplicate by id: the same asset can be defined in several places at
// once (current file's local overlay + global index, project XML + // once (current file's local overlay + global index, project XML +
// compiled manifest, or an override). Showing one completion entry per // compiled manifest, or an override). Showing one completion entry per
@@ -502,7 +510,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
if (seen.has(defKey)) return; if (seen.has(defKey)) return;
seen.add(defKey); seen.add(defKey);
const idKey = def.id.toLowerCase(); const idKey = def.id.toLowerCase();
if (!idKey.startsWith(lower)) return; if (!idKey.startsWith(idPrefix)) return;
let score = 3; let score = 3;
if (refType && model.isAssignableTo(def.type, refType)) score = 1; if (refType && model.isAssignableTo(def.type, refType)) score = 1;
if (selfType && model.isAssignableTo(def.type, selfType)) score = 0; if (selfType && model.isAssignableTo(def.type, selfType)) score = 0;
@@ -569,7 +577,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
); );
} }
return make( return make(
def.id, typePrefix ? `${typePrefix}${def.id}` : def.id,
vscode.CompletionItemKind.Value, vscode.CompletionItemKind.Value,
t("{0} · {1}", def.type, origin), t("{0} · {1}", def.type, origin),
doc.value, doc.value,
+4 -2
View File
@@ -20,6 +20,7 @@ import {
isReferenceAttributeOfType, isReferenceAttributeOfType,
isReferenceContentType, isReferenceContentType,
mergeLocalAndGlobalDefs, mergeLocalAndGlobalDefs,
normalizeReferenceId,
} from "../indexer/refs"; } from "../indexer/refs";
import { import {
referenceSitesForDef, referenceSitesForDef,
@@ -92,9 +93,10 @@ export function definitionsForReference(
idx: ModIndex, idx: ModIndex,
ctx: ReferenceContext, ctx: ReferenceContext,
): AssetDef[] { ): AssetDef[] {
const lookupId = normalizeReferenceId(ctx.id);
const defs = mergeLocalAndGlobalDefs( const defs = mergeLocalAndGlobalDefs(
idx.local?.assetsById.get(ctx.id.toLowerCase()), idx.local?.assetsById.get(lookupId.toLowerCase()),
idx.assetsById.get(ctx.id.toLowerCase()), idx.assetsById.get(lookupId.toLowerCase()),
); );
return filterAndScoreDefs(defs, ctx.refType, ctx.selfType).map((t) => t.def); return filterAndScoreDefs(defs, ctx.refType, ctx.selfType).map((t) => t.def);
} }
+4 -1
View File
@@ -16,6 +16,7 @@ import { extractIndexRecords, type IndexRecords } from "./records";
import { import {
filterAndScoreDefs, filterAndScoreDefs,
isReferenceTargetType, isReferenceTargetType,
normalizeReferenceId,
type ReferenceLookup, type ReferenceLookup,
} from "./refs"; } from "./refs";
import { buildVanillaSearchPaths, resolveSource } from "./includeResolver"; import { buildVanillaSearchPaths, resolveSource } from "./includeResolver";
@@ -50,7 +51,9 @@ export function buildReferenceIndex(
const map = new Map<string, ReferenceSite[]>(); const map = new Map<string, ReferenceSite[]>();
for (const { file, records } of sources) { for (const { file, records } of sources) {
for (const ref of records.references) { for (const ref of records.references) {
const defs = lookup.assetsById.get(ref.value.toLowerCase()); const defs = lookup.assetsById.get(
normalizeReferenceId(ref.value).toLowerCase(),
);
if (!defs?.length) continue; if (!defs?.length) continue;
const targets = filterAndScoreDefs(defs, ref.refType, ref.selfType); const targets = filterAndScoreDefs(defs, ref.refType, ref.selfType);
for (const target of targets) { for (const target of targets) {
+23 -4
View File
@@ -16,6 +16,23 @@ export interface ReferenceTarget {
score: number; score: number;
} }
/**
* Normalizes a reference value that may use the manifest-style qualified
* form `Type:Id` (e.g. `inheritFrom="AudioEvent:BaseSoundEffect"`,
* `Sound="AudioEvent:JAP_Refinery_Select"` or
* `Side="PlayerTemplate:Allies"`). XML asset ids never contain ":" (the same
* InstanceId rule the manifest parser relies on), so the referenceable id is
* the last colon-separated segment, exactly like `deriveAssetId`. Plain ids
* are returned unchanged. A trailing colon with an empty remainder is left
* unchanged so a half-typed value cannot accidentally match an id.
*/
export function normalizeReferenceId(value: string): string {
const idx = value.lastIndexOf(":");
if (idx < 0) return value;
const id = value.slice(idx + 1);
return id.length > 0 ? id : value;
}
/** /**
* The subset of `ModIndex` that reference resolution needs. Kept narrow so * The subset of `ModIndex` that reference resolution needs. Kept narrow so
* the reverse reference index can resolve records against the indexer's live * the reverse reference index can resolve records against the indexer's live
@@ -116,9 +133,10 @@ export function resolveReferenceTargetsForType(
attrName: string, attrName: string,
id: string, id: string,
): ReferenceTarget[] { ): ReferenceTarget[] {
const lookupId = normalizeReferenceId(id);
const defs = mergeLocalAndGlobalDefs( const defs = mergeLocalAndGlobalDefs(
idx.local?.assetsById.get(id.toLowerCase()), idx.local?.assetsById.get(lookupId.toLowerCase()),
idx.assetsById.get(id.toLowerCase()), idx.assetsById.get(lookupId.toLowerCase()),
); );
if (!defs.length) return []; if (!defs.length) return [];
@@ -175,9 +193,10 @@ export function resolveContentReferenceTargets(
): ReferenceTarget[] { ): ReferenceTarget[] {
if (!isReferenceContentType(typeName)) return []; if (!isReferenceContentType(typeName)) return [];
if (!typeName) return []; if (!typeName) return [];
const lookupId = normalizeReferenceId(id);
const defs = mergeLocalAndGlobalDefs( const defs = mergeLocalAndGlobalDefs(
idx.local?.assetsById.get(id.toLowerCase()), idx.local?.assetsById.get(lookupId.toLowerCase()),
idx.assetsById.get(id.toLowerCase()), idx.assetsById.get(lookupId.toLowerCase()),
); );
if (!defs.length) return []; if (!defs.length) return [];
const info = contentInfoOfType(typeName); const info = contentInfoOfType(typeName);
+63
View File
@@ -777,6 +777,69 @@ test("simple-content value completion works before the closing tag is typed", as
assert.equal(item.range.end.character, pos.character); assert.equal(item.range.end.character, pos.character);
}); });
test("asset value completion keeps a typed Type: prefix the user already typed", async () => {
const def = {
type: "AudioEvent",
id: "BaseSoundEffect",
file: "Sounds.xml",
line: 1,
origin: "sdk",
};
const idx = {
assets: new Map([["AudioEvent", new Map([["basesoundeffect", [def]]])]]),
assetsById: new Map([["basesoundeffect", [def]]]),
};
// Qualified input: the typed "AudioEvent:" prefix must be kept, so the
// completed value is "AudioEvent:BaseSoundEffect" and the replacement
// range still covers only the current segment.
const qualifiedText =
`<AssetDeclaration>\n` +
` <AudioEvent id="X" inheritFrom="AudioEvent:Base\n` +
`</AssetDeclaration>`;
const qualifiedLine = qualifiedText.split("\n")[1];
const qualifiedPos = new Position(1, qualifiedLine.length);
const qualifiedItems = await makeProvider(idx).provideCompletionItems(
makeDocument(qualifiedText),
qualifiedPos,
token,
);
const qualifiedLabels = qualifiedItems.map((i) => i.label);
assert.ok(
qualifiedLabels.includes("AudioEvent:BaseSoundEffect"),
"qualified label offered for AudioEvent:Base",
);
assert.ok(
!qualifiedLabels.includes("BaseSoundEffect"),
"the bare id is not offered when a type prefix was typed",
);
const qualifiedItem = qualifiedItems.find(
(i) => i.label === "AudioEvent:BaseSoundEffect",
);
assert.equal(qualifiedItem.insertText, "AudioEvent:BaseSoundEffect");
assert.equal(
qualifiedItem.range.start.character,
qualifiedLine.lastIndexOf('"') + 1,
);
assert.equal(qualifiedItem.range.end.character, qualifiedPos.character);
// Plain input stays plain: no prefix typed -> bare id, unchanged behavior.
const plainText =
`<AssetDeclaration>\n` +
` <AudioEvent id="X" inheritFrom="Base\n` +
`</AssetDeclaration>`;
const plainLine = plainText.split("\n")[1];
const plainPos = new Position(1, plainLine.length);
const plainItems = await makeProvider(idx).provideCompletionItems(
makeDocument(plainText),
plainPos,
token,
);
const plainItem = plainItems.find((i) => i.label === "BaseSoundEffect");
assert.ok(plainItem, "bare id offered without a type prefix");
assert.equal(plainItem.insertText, "BaseSoundEffect");
});
test("simpleContent complex child inserts a value pair and triggers suggest", async () => { test("simpleContent complex child inserts a value pair and triggers suggest", async () => {
const text = const text =
`<AssetDeclaration>\n` + `<AssetDeclaration>\n` +
+57
View File
@@ -239,6 +239,63 @@ test("Ctrl+click on simple-content text jumps to the definition", async () => {
); );
}); });
test("qualified Type:Id inheritFrom is diagnosed and navigated as resolved", async () => {
const text =
`<AssetDeclaration xmlns="uri:ea.com:eala:asset">\n` +
` <AudioEvent id="BaseSoundEffect"/>\n` +
` <AudioEvent id="X" inheritFrom="AudioEvent:BaseSoundEffect"/>\n` +
`</AssetDeclaration>`;
const def = {
type: "AudioEvent",
id: "BaseSoundEffect",
file: URI,
line: 1,
origin: "project",
};
const idx = makeIdx([def]);
const scope = await makeScope(text, idx);
// Diagnostics: the manifest-style qualified value must not be reported as
// an unresolved reference (the reported FutureTank scenario).
const collection = new FakeDiagnosticCollection();
const diagnostics = new Ra3Diagnostics({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: {
diagnoseUnknownElements: false,
reportUnresolvedReferences: "warning",
},
});
diagnostics["collection"] = collection;
await diagnostics.update(makeDocument(text));
const messages = collection.last.diags.map((d) => d.message);
assert.ok(
!messages.some((m) => m.includes("AudioEvent:BaseSoundEffect")),
"qualified inheritFrom is not unresolved",
);
// Ctrl+click on the qualified value jumps to the plain-id definition.
const provider = new Ra3DefinitionProvider({
isRa3Workspace: () => true,
getScope: async () => scope,
settings: { definitionMode: "all" },
indexer: null,
});
const line = text.split("\n")[2];
const pos = new Position(2, line.indexOf("AudioEvent:BaseSoundEffect") + 8);
const locations = await provider.provideDefinition(makeDocument(text), pos, {});
assert.ok(locations && locations.length === 1, "qualified reference resolves");
const defLine = text.split("\n")[1];
const defStartChar =
defLine.indexOf('id="BaseSoundEffect"') + 'id="'.length;
assert.equal(locations[0].range.start.line, 1);
assert.equal(locations[0].range.start.character, defStartChar);
assert.equal(
locations[0].range.end.character,
defStartChar + "BaseSoundEffect".length,
);
});
test("hover on simpleContent complex content shows the referenced definition", async () => { test("hover on simpleContent complex content shows the referenced definition", async () => {
const text = const text =
`<AssetDeclaration>\n` + `<AssetDeclaration>\n` +
+75
View File
@@ -299,6 +299,81 @@ test("manifest assets sharing an id keep every type in assetsById", async () =>
assert.ok(airfield?.some((d) => d.type === "W3DContainer"), "W3DContainer retained"); assert.ok(airfield?.some((d) => d.type === "W3DContainer"), "W3DContainer retained");
}); });
test("qualified Type:Id inheritFrom resolves against an instance-included SageXml definition", async () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), "ra3-qualified-ref-"));
try {
const projectDir = join(tmp, "project");
const sdkDir = join(tmp, "sdk");
fs.mkdirSync(join(projectDir, "Data"), { recursive: true });
fs.mkdirSync(join(sdkDir, "SageXml", "Sounds"), { recursive: true });
fs.writeFileSync(
join(projectDir, "Data", "Mod.xml"),
`<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<Includes>
<Include type="all" source="Units.xml" />
</Includes>
</AssetDeclaration>`,
);
fs.writeFileSync(
join(projectDir, "Data", "Units.xml"),
`<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<Includes>
<Include type="instance" source="DATA:SageXml/Sounds/BaseSoundEffect.xml" />
</Includes>
<AudioEvent id="ALL_FutureTank_ArmPrimaryWeapon" inheritFrom="AudioEvent:BaseSoundEffect" />
</AssetDeclaration>`,
);
fs.writeFileSync(
join(sdkDir, "SageXml", "Sounds", "BaseSoundEffect.xml"),
`<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<AudioEvent id="BaseSoundEffect" />
</AssetDeclaration>`,
);
const indexer = new ModIndexer({
projectDir,
sdkDir,
builtmodsDirs: [],
indexSageXml: false,
additionalDataSearchPaths: [],
walker: new CachedDirectoryWalker(),
});
const idx = await indexer.build();
assert.ok(
!idx.diagnostics.some((d) => d.code === "include-not-found"),
"the DATA:SageXml instance include resolves",
);
const defs = idx.assetsById.get("basesoundeffect");
assert.ok(
defs?.some((d) => d.type === "AudioEvent"),
"the SageXml definition is indexed through the instance include",
);
const targets = resolveReferenceTargetsForType(
idx,
"AudioEvent",
"inheritFrom",
"AudioEvent:BaseSoundEffect",
);
assert.equal(targets.length, 1);
assert.equal(targets[0].def.id, "BaseSoundEffect");
assert.equal(targets[0].def.type, "AudioEvent");
const sageDef = defs.find((d) => d.type === "AudioEvent");
const sites = idx.references.get(assetDefKey(sageDef));
assert.ok(
sites?.some((s) => /Units\.xml$/.test(s.file)),
"the qualified inheritFrom lands in the reverse index (FAR / CodeLens)",
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test("build publishes an immutable XML phase before art scanning", async () => { test("build publishes an immutable XML phase before art scanning", async () => {
let phaseA; let phaseA;
const indexer = new ModIndexer({ const indexer = new ModIndexer({
+36
View File
@@ -157,6 +157,42 @@ test("records extracted from XML resolve through the reference index", () => {
assert.equal(csSites[0].kind, "attr"); assert.equal(csSites[0].kind, "attr");
}); });
test("qualified Type:Id reference records resolve to plain-id definitions", () => {
const def = makeDef("AudioEvent", "BaseSoundEffect", "C:/sdk/Sounds.xml", 2);
const lookup = {
assets: new Map(),
assetsById: new Map([["basesoundeffect", [def]]]),
};
const records = {
assets: [],
defines: [],
includes: [],
rootXiIncludes: [],
nestedXiIncludes: [],
references: [
{
kind: "attr",
refType: null,
selfType: "AudioEvent",
value: "AudioEvent:BaseSoundEffect",
line: 3,
start: 10,
end: 40,
},
],
};
const map = buildReferenceIndex(
[{ file: "C:/mod/AudioEvent.xml", records }],
lookup,
);
const sites = map.get(assetDefKey(def));
assert.equal(sites?.length, 1);
assert.equal(sites[0].file, "C:/mod/AudioEvent.xml");
assert.equal(sites[0].start, 10);
assert.equal(sites[0].end, 40);
});
test("referenceSitesForDefinition unions manifest-source sites onto the SageXml source file", () => { test("referenceSitesForDefinition unions manifest-source sites onto the SageXml source file", () => {
const tmp = mkdtempSync(join(tmpdir(), "ra3-refindex-")); const tmp = mkdtempSync(join(tmpdir(), "ra3-refindex-"));
try { try {
+125
View File
@@ -10,6 +10,7 @@ import {
isReferenceAttributeOfType, isReferenceAttributeOfType,
isReferenceContentType, isReferenceContentType,
isReferenceTargetType, isReferenceTargetType,
normalizeReferenceId,
resolveContentReferenceTargets, resolveContentReferenceTargets,
resolveReferenceTargets, resolveReferenceTargets,
resolveReferenceTargetsForType, resolveReferenceTargetsForType,
@@ -346,6 +347,130 @@ test("simpleContent complex types resolve as typed content references", () => {
assert.equal(subsoundTargets[0].def.type, "AudioEvent"); assert.equal(subsoundTargets[0].def.type, "AudioEvent");
}); });
test("normalizeReferenceId strips a manifest-style Type: prefix", () => {
assert.equal(normalizeReferenceId("BaseSoundEffect"), "BaseSoundEffect");
assert.equal(
normalizeReferenceId("AudioEvent:BaseSoundEffect"),
"BaseSoundEffect",
);
// Art-asset manifest names can carry a subtype segment; the referenceable
// id is still the last colon segment.
assert.equal(
normalizeReferenceId("W3dContainer:W3DContainer:ABC_SKN"),
"ABC_SKN",
);
// A trailing colon has no id yet; keep the raw value so a half-typed
// qualified value never matches anything.
assert.equal(normalizeReferenceId("AudioEvent:"), "AudioEvent:");
});
test("qualified Type:Id inheritFrom values resolve to plain-id definitions", () => {
const def = {
type: "AudioEvent",
id: "BaseSoundEffect",
file: "Sounds.xml",
line: 1,
origin: "sdk",
};
const idx = {
assetsById: new Map([["basesoundeffect", [def]]]),
assets: new Map(),
defines: new Map(),
};
// The reported scenario: <AudioEvent inheritFrom="AudioEvent:BaseSoundEffect"/>.
const qualified = resolveReferenceTargetsForType(
idx,
"AudioEvent",
"inheritFrom",
"AudioEvent:BaseSoundEffect",
);
assert.equal(qualified.length, 1);
assert.equal(qualified[0].def.id, "BaseSoundEffect");
// Plain ids keep working unchanged.
assert.equal(
resolveReferenceTargetsForType(idx, "AudioEvent", "inheritFrom", "BaseSoundEffect")
.length,
1,
);
// A wrong type prefix is still filtered by selfType: the AudioEvent def
// must never satisfy a GameObject inheritFrom.
assert.equal(
resolveReferenceTargetsForType(
idx,
"GameObject",
"inheritFrom",
"GameObject:BaseSoundEffect",
).length,
0,
);
});
test("qualified Type:Id values resolve for typed attributes and content refs", () => {
const audioEvent = {
type: "AudioEvent",
id: "JAP_Refinery_Select",
file: "SoundEffects.xml",
line: 1,
origin: "sdk",
};
const playerTemplate = {
type: "PlayerTemplate",
id: "Allies",
file: "PlayerTemplates.xml",
line: 1,
origin: "manifest",
};
const audioFile = {
type: "AudioFile",
id: "Shared",
file: "Audio.xml",
line: 1,
origin: "project",
};
const idx = {
assetsById: new Map([
["jap_refinery_select", [audioEvent]],
["allies", [playerTemplate]],
["shared", [audioFile]],
]),
assets: new Map(),
defines: new Map(),
};
// SoundOrEvaEvent@Sound refType is BaseAudioEventInfo; the concrete
// "AudioEvent:" prefix must survive normalization and the type filter.
const soundTargets = resolveReferenceTargetsForType(
idx,
"SoundOrEvaEvent",
"Sound",
"AudioEvent:JAP_Refinery_Select",
);
assert.equal(soundTargets.length, 1);
assert.equal(soundTargets[0].def.type, "AudioEvent");
// Side="PlayerTemplate:Allies" style attribute.
const sideTargets = resolveReferenceTargetsForType(
idx,
"SideSound",
"Side",
"PlayerTemplate:Allies",
);
assert.equal(sideTargets.length, 1);
assert.equal(sideTargets[0].def.type, "PlayerTemplate");
// Simple-content references use the same convention.
const contentTargets = resolveContentReferenceTargets(
idx,
"AudioFileRefWithWeight",
"AudioFile:Shared",
);
assert.equal(contentTargets.length, 1);
assert.equal(contentTargets[0].def.type, "AudioFile");
});
test("untyped and pipeline-local content is not a global reference", () => { test("untyped and pipeline-local content is not a global reference", () => {
// Generic AssetReference content is used for shader constants and model // Generic AssetReference content is used for shader constants and model
// sub-object names, not global asset ids; Poid is pipeline-local. // sub-object names, not global asset ids; Poid is pipeline-local.