diff --git a/CHANGELOG.md b/CHANGELOG.md
index f921eca..a0584db 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,26 @@
# Changelog
-## 0.1.23 — 2026-08-11
+## 0.1.24 — 2026-08-11
### 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.
- `simpleContent` complex types (`AudioFileRefWithWeight`, `MultisoundSubsoundRef`) keep their XSD attributes (`Weight`, `Volume`, `PitchShiftLow/High`, ...) and their text content (`AudioFile`, `VoiceEvent`) 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. ``, ``) now resolve to the top-level `AssetDeclaration` type instead of the colliding child type.
diff --git a/docs/analysis-issues.md b/docs/analysis-issues.md
index 573093c..13da7b3 100644
--- a/docs/analysis-issues.md
+++ b/docs/analysis-issues.md
@@ -2128,3 +2128,90 @@ hover / 跳转 / 诊断 / FAR)在第三十一轮补齐,见下。
- `docs/plan.md`:simple-content 文本引用说明补充第三十一轮扩展;
- `docs/features-reference-counts.md`:引用语义说明补充“含 simpleContent 复杂
类型”。
+
+---
+
+## 三十二、问题分析(2026-08-11):限定引用值 `类型:ID` 未被归一化导致误报未解析
+
+### 现象
+
+Corona `Data\Allied\Units\AlliedFutureTankX-1\AudioEvent.xml`:
+
+```xml
+
+
+
+
+
+```
+
+报 `Unresolved reference "AudioEvent:BaseSoundEffect"`,提示当前索引中未找到;
+但 `SageXml\Sounds\BaseSoundEffect.xml` 里确实存在 ``,
+且 `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。
diff --git a/docs/features-reference-counts.md b/docs/features-reference-counts.md
index b792551..84aa5ea 100644
--- a/docs/features-reference-counts.md
+++ b/docs/features-reference-counts.md
@@ -36,6 +36,11 @@
- `inheritFrom`(按元素自身类型过滤);
- 无 `refType` 的 `isRef` 属性(按同名 ID 匹配任意声明类型)。
+引用值本身支持原版/Mod 常用的 manifest 风格全名 `类型:ID`
+(`inheritFrom="AudioEvent:BaseSoundEffect"`、`Sound="AudioEvent:..."`、
+`Side="PlayerTemplate:Allies"`):解析与反向索引先按 `normalizeReferenceId`
+取最后冒号段,再执行上述类型过滤;记录里的原始值与偏移保持不变。
+
`inheritFrom` 对 `BaseAssetType` 系资产是通用合法属性(XSD 只在
`BaseInheritableAsset` 声明,但原版数据在 `FXList` 等类型上也使用)。这里的
“合法属性”判定与“设计上应显示引用计数”的 `referenceTargetTypes()` 是分开的:
diff --git a/docs/plan.md b/docs/plan.md
index 1afd487..d00c39a 100644
--- a/docs/plan.md
+++ b/docs/plan.md
@@ -142,7 +142,7 @@ test/
跨重建复用(详见设计决策 14)。
3. **manifest 资产建模**:类型优先用哈希表,未知时从名称前缀推导;可引用 ID 取最后冒号段;类型名统一走大小写规范化(`W3dContainer` ↔ `W3DContainer`),类型匹配严格遵循 XSD 继承链。`assetsById` 按 id 汇总**全部类型**的定义,去重身份为 `(type, file, line)`,同一 manifest 中同名但不同类型的美术资产(如 `W3DHierarchy:AUMCV_HOVER` 与 `W3DContainer:AUMCV_HOVER`)必须全部保留,避免 `Model@Name` 这类 `BaseRenderAssetType` 引用因先到的非渲染类型而被误判为未解析。
4. **上下文感知元素类型**:同名元素按父元素类型解析(`resolveElementType` 沿解析树逐层 `childTypeOf`,失败回退全局映射),保证 `` 等元素的属性/引用判定正确。
-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 覆盖原版视为正常。
7. **未解析引用诊断**:按设置严重级别报告(默认 warning);类型不匹配时给出明确文案("有同名 ID 但类型不匹配")。`definitionMode` 设置控制跳转候选:`all`(mod + 原版全部列出,mod 优先)或 `project-only`。
8. **跳转精度**:XML 定义跳转到 `id` 属性值的精确 Range;manifest 定义映射到源码文件(如 SageXml)时也在文件内精确定位;找不到再回退到记录行。
@@ -424,6 +424,14 @@ test/
修正;SageXml 源缺失时保持 manifest-only,文件存在但 id 被删时降级
到文件顶部;测试 178 → 184;分析见 `docs/analysis-issues.md`
二十八。
+29. [x] 限定引用值 `类型:ID` 归一化(2026-08-11,v0.1.24):新增
+ `refs.normalizeReferenceId`(取最后冒号段,与 manifest `deriveAssetId`
+ 同一规则),应用到属性引用、simple-content 引用、语义反向索引与
+ FAR/CodeLens 的 `definitionsForReference`;records 仍保存原始值与
+ 偏移;补全在已输入 `类型:` 前缀时按冒号后片段过滤并保留前缀;
+ 实测 SageXml 5483 / Corona 3219 处 `inheritFrom="AudioEvent:..."`
+ 等受限引用全部修复;测试 219 → 226;分析见
+ `docs/analysis-issues.md` 三十二。
## 四、验证结果(实测)
diff --git a/docs/requirements.md b/docs/requirements.md
index 3368d74..3e2aed0 100644
--- a/docs/requirements.md
+++ b/docs/requirements.md
@@ -25,6 +25,8 @@ XML 之间的组织靠 `` 标签,共有三种语义:
继承机制:`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 副本。
## 二、需求清单
@@ -38,6 +40,7 @@ XML 之间的组织靠 `` 标签,共有三种语义:
- 属性值:
- 引用型属性(XSD 中带 `xas:refType`)补全已定义的资产 ID;
- `inheritFrom` 补全可继承的资产 ID;
+ - 引用值支持 `类型:ID` 前缀写法:已输入 `AudioEvent:` 时按冒号后的 ID 过滤,插入时保留已输入的前缀;未输入前缀时保持裸 ID 补全;
- 枚举值(XSD `xs:enumeration`);
- `$DEFINE` 常量(如 `$CIV_HEALTH_SMALL`);
- `` 补全可解析的文件路径(`DATA:` / `ART:` / `AUDIO:`)。
@@ -61,6 +64,7 @@ XML 之间的组织靠 `` 标签,共有三种语义:
- 缺失必填 `id`(顶层资产);
- 重复 ID(同类型 + 同 id,mod 文件之间;覆盖原版 SageXml 不算冲突);
- 引用未解析(引用了不存在的资产 ID,可配置是否忽略原版 manifest 中的 ID);
+ - 引用值带 `类型:` 前缀时先归一化为裸 ID 再判定(如 `AudioEvent:BaseSoundEffect` → `BaseSoundEffect`),前缀不影响类型过滤;
- simple-content 引用元素的文本未解析(同属性引用规则,仅带 refType 的类型)。
- `` 目标文件找不到、Include 循环;
- `$DEFINE` 未定义。
diff --git a/package.json b/package.json
index fab0b82..2f89552 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "ra3-mod-xml",
"displayName": "%ra3modxml.displayName%",
"description": "%ra3modxml.description%",
- "version": "0.1.23",
+ "version": "0.1.24",
"publisher": "lanyi",
"license": "SEE LICENSE IN LICENSE",
"icon": "images/icon.png",
diff --git a/src/features/completion.ts b/src/features/completion.ts
index d41e58b..3acefe3 100644
--- a/src/features/completion.ts
+++ b/src/features/completion.ts
@@ -484,6 +484,14 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
): vscode.CompletionItem[] | vscode.CompletionList {
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
// once (current file's local overlay + global index, project XML +
// 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;
seen.add(defKey);
const idKey = def.id.toLowerCase();
- if (!idKey.startsWith(lower)) return;
+ if (!idKey.startsWith(idPrefix)) return;
let score = 3;
if (refType && model.isAssignableTo(def.type, refType)) score = 1;
if (selfType && model.isAssignableTo(def.type, selfType)) score = 0;
@@ -569,7 +577,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
);
}
return make(
- def.id,
+ typePrefix ? `${typePrefix}${def.id}` : def.id,
vscode.CompletionItemKind.Value,
t("{0} · {1}", def.type, origin),
doc.value,
diff --git a/src/features/references.ts b/src/features/references.ts
index 1b5bda9..87407fb 100644
--- a/src/features/references.ts
+++ b/src/features/references.ts
@@ -20,6 +20,7 @@ import {
isReferenceAttributeOfType,
isReferenceContentType,
mergeLocalAndGlobalDefs,
+ normalizeReferenceId,
} from "../indexer/refs";
import {
referenceSitesForDef,
@@ -92,9 +93,10 @@ export function definitionsForReference(
idx: ModIndex,
ctx: ReferenceContext,
): AssetDef[] {
+ const lookupId = normalizeReferenceId(ctx.id);
const defs = mergeLocalAndGlobalDefs(
- idx.local?.assetsById.get(ctx.id.toLowerCase()),
- idx.assetsById.get(ctx.id.toLowerCase()),
+ idx.local?.assetsById.get(lookupId.toLowerCase()),
+ idx.assetsById.get(lookupId.toLowerCase()),
);
return filterAndScoreDefs(defs, ctx.refType, ctx.selfType).map((t) => t.def);
}
diff --git a/src/indexer/referenceIndex.ts b/src/indexer/referenceIndex.ts
index 09d9721..7d512aa 100644
--- a/src/indexer/referenceIndex.ts
+++ b/src/indexer/referenceIndex.ts
@@ -16,6 +16,7 @@ import { extractIndexRecords, type IndexRecords } from "./records";
import {
filterAndScoreDefs,
isReferenceTargetType,
+ normalizeReferenceId,
type ReferenceLookup,
} from "./refs";
import { buildVanillaSearchPaths, resolveSource } from "./includeResolver";
@@ -50,7 +51,9 @@ export function buildReferenceIndex(
const map = new Map();
for (const { file, records } of sources) {
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;
const targets = filterAndScoreDefs(defs, ref.refType, ref.selfType);
for (const target of targets) {
diff --git a/src/indexer/refs.ts b/src/indexer/refs.ts
index 1ffe27c..14d9554 100644
--- a/src/indexer/refs.ts
+++ b/src/indexer/refs.ts
@@ -16,6 +16,23 @@ export interface ReferenceTarget {
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 reverse reference index can resolve records against the indexer's live
@@ -116,9 +133,10 @@ export function resolveReferenceTargetsForType(
attrName: string,
id: string,
): ReferenceTarget[] {
+ const lookupId = normalizeReferenceId(id);
const defs = mergeLocalAndGlobalDefs(
- idx.local?.assetsById.get(id.toLowerCase()),
- idx.assetsById.get(id.toLowerCase()),
+ idx.local?.assetsById.get(lookupId.toLowerCase()),
+ idx.assetsById.get(lookupId.toLowerCase()),
);
if (!defs.length) return [];
@@ -175,9 +193,10 @@ export function resolveContentReferenceTargets(
): ReferenceTarget[] {
if (!isReferenceContentType(typeName)) return [];
if (!typeName) return [];
+ const lookupId = normalizeReferenceId(id);
const defs = mergeLocalAndGlobalDefs(
- idx.local?.assetsById.get(id.toLowerCase()),
- idx.assetsById.get(id.toLowerCase()),
+ idx.local?.assetsById.get(lookupId.toLowerCase()),
+ idx.assetsById.get(lookupId.toLowerCase()),
);
if (!defs.length) return [];
const info = contentInfoOfType(typeName);
diff --git a/test/completion.test.mjs b/test/completion.test.mjs
index d0f9276..dd148ab 100644
--- a/test/completion.test.mjs
+++ b/test/completion.test.mjs
@@ -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);
});
+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 =
+ `\n` +
+ ` 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 =
+ `\n` +
+ ` 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 () => {
const text =
`\n` +
diff --git a/test/contentFeatures.test.mjs b/test/contentFeatures.test.mjs
index 38d9633..6b975de 100644
--- a/test/contentFeatures.test.mjs
+++ b/test/contentFeatures.test.mjs
@@ -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 =
+ `\n` +
+ ` \n` +
+ ` \n` +
+ ``;
+ 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 () => {
const text =
`\n` +
diff --git a/test/indexer.test.mjs b/test/indexer.test.mjs
index 8188c48..3f6d2c0 100644
--- a/test/indexer.test.mjs
+++ b/test/indexer.test.mjs
@@ -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");
});
+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"),
+ `
+
+
+
+
+`,
+ );
+ fs.writeFileSync(
+ join(projectDir, "Data", "Units.xml"),
+ `
+
+
+
+
+
+`,
+ );
+ fs.writeFileSync(
+ join(sdkDir, "SageXml", "Sounds", "BaseSoundEffect.xml"),
+ `
+
+
+`,
+ );
+
+ 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 () => {
let phaseA;
const indexer = new ModIndexer({
diff --git a/test/referenceIndex.test.mjs b/test/referenceIndex.test.mjs
index a7dafac..a695a68 100644
--- a/test/referenceIndex.test.mjs
+++ b/test/referenceIndex.test.mjs
@@ -157,6 +157,42 @@ test("records extracted from XML resolve through the reference index", () => {
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", () => {
const tmp = mkdtempSync(join(tmpdir(), "ra3-refindex-"));
try {
diff --git a/test/refs.test.mjs b/test/refs.test.mjs
index ad256ba..f948b17 100644
--- a/test/refs.test.mjs
+++ b/test/refs.test.mjs
@@ -10,6 +10,7 @@ import {
isReferenceAttributeOfType,
isReferenceContentType,
isReferenceTargetType,
+ normalizeReferenceId,
resolveContentReferenceTargets,
resolveReferenceTargets,
resolveReferenceTargetsForType,
@@ -346,6 +347,130 @@ test("simpleContent complex types resolve as typed content references", () => {
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: .
+ 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", () => {
// Generic AssetReference content is used for shader constants and model
// sub-object names, not global asset ids; Poid is pipeline-local.