diff --git a/CHANGELOG.md b/CHANGELOG.md
index 79d99d7..a591944 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## 0.1.22 — 2026-08-11
+
+### Fixed
+
+- Manifest assets that share the same id under different types are now all kept in the by-id index. Previously the first same-id entry (e.g. `W3DHierarchy:AUMCV_HOVER`) could shadow later definitions (e.g. `W3DContainer:AUMCV_HOVER`), causing `Model@Name` and other `BaseRenderAssetType` references to be reported as unresolved even though the asset existed in `Static.manifest`.
+
## 0.1.21 — 2026-08-11
### Added
diff --git a/docs/analysis-issues.md b/docs/analysis-issues.md
index f01e635..f5c3cbb 100644
--- a/docs/analysis-issues.md
+++ b/docs/analysis-issues.md
@@ -1813,3 +1813,96 @@ manifest 候选就会被劫持到 mod 文件。
> 备注:ART/AUDIO 源映射按用户意见不作为本轮目标;`buildVanillaSearchPaths`
> 已包含对应 SDK 目录,将来若有源码可直接复用。
+
+---
+
+## 二十九、问题分析(2026-08-11):manifest 同名不同类型资产被 `assetsById` 去重丢弃
+
+### 现象
+
+Corona `Data\Allied\Units\AlliedMCV.xml` 的
+`ScriptedModelDraw → ModelConditionState → Model Name="AUMCV_Hover"`
+报 unresolved-reference:
+
+```xml
+
+
+
+
+
+```
+
+提示为“没有类型为 `BaseRenderAssetType` 的定义(其他类型存在同名 id)”,
+但 `Static.manifest` 中确实存在 `W3DContainer:AUMCV_HOVER`。
+
+### 根因
+
+`src/indexer/indexer.ts` 的 `addAsset()` 在维护两个索引时用了同一套去重:
+
+- `assets`:`类型 -> id -> 定义`,按 `(file, line)` 去重;
+- `assetsById`:`id -> 所有类型定义`,也按 `(file, line)` 去重。
+
+XML 定义的行号各不相同,所以 `(file, line)` 足够;但 manifest 资产入库时
+`line` 固定为 0,于是同一个 manifest 里 id 相同、类型不同的多个资产会被
+当成同一条定义,只保留最先出现的类型。
+
+`Static.manifest` 中 `AUMCV_HOVER` 的实际顺序是:
+
+```text
+W3DHierarchy:AUMCV_HOVER
+W3DAnimation:AUMCV_HOVER
+W3DContainer:AUMCV_HOVER
+```
+
+`W3DContainer` 因此被 `W3DHierarchy` 挤掉。`Model@Name` 的 `refType` 是
+`BaseRenderAssetType`,`W3DHierarchy` 按 XSD 继承链不是渲染资产,所以
+`assetsById` 里“有同名 id”但“没有匹配类型”,正好产生上述提示。
+
+### 为什么以前没暴露 / 不是回归
+
+第三轮修复的 `AUAntiVehicleVehicleTech1_SKN` 在 static.manifest 里只有一个
+同名定义(`W3DContainer`),没有类型竞争,因此当时测不到该分支。git blame
+显示 `addAsset` 的 `(file, line)` 去重从首个提交就存在,所以这是潜在缺陷被
+新数据形态首次触发,不是近期改动造成的回归。
+
+### 影响面(真实 manifest 扫描)
+
+对 `Static / Global / Audio` 三个 manifest 模拟当前入库逻辑:
+
+| 指标 | 数值 |
+|---|---:|
+| 同名 id 跨类型的 ID | 1318 |
+| 被丢弃的类型定义 | 1470 |
+| `W3DContainer` / `W3DMesh` 被丢弃的 id | 412 |
+
+`Audio.manifest` 无此类碰撞。受影响的不止诊断和 hover:
+`resolveReferenceTargetsForType`、语义 FAR / CodeLens 引用计数、未类型化补全
+都经 `assetsById` 查找,因此 manifest 中的模型引用普遍可能误报或漏计。
+
+### 修复
+
+`assetsById` 是“按 id 汇总所有类型定义”的索引,去重身份必须包含类型:
+
+1. `src/indexer/indexer.ts` 的 `addAsset()`:`assets` 与 `assetsById` 的去重
+ 都改为 `(type, file, line)`;
+2. `src/indexer/localScope.ts` 的 `addAsset()`:同样的去重修正,避免局部
+ overlay 未来遇到同构数据时重复踩坑。
+
+`mergeLocalAndGlobalDefs`、`assetDefKey` 本来已按 `(type, id, file, line)`
+区分定义,修复后三处语义一致。
+
+### 测试(新增 1 个集成测试,全量 198 个通过)
+
+`test/indexer.test.mjs` 新增自包含用例:
+
+- 用最小 version-5 manifest 构造 `W3DHierarchy / W3DAnimation / W3DContainer`
+ 三个同 id 资产,顺序刻意让渲染类型排在最后;
+- 再构造 `Texture:ABAirfield` 在前、`W3DContainer:ABAIRFIELD` 在后的常见形态;
+- 断言 `assetsById` 保留全部类型;
+- 断言 `Model@Name` 经 `resolveReferenceTargetsForType` 命中 `W3DContainer`;
+- 断言反向引用索引把该引用记到 `W3DContainer` 名下。
+
+### 文档同步
+
+`docs/plan.md` 的 manifest 建模小节补充:`assetsById` 必须保留同 id 的不同
+类型定义,去重身份为 `(type, file, line)`。
diff --git a/docs/plan.md b/docs/plan.md
index 77e65e3..e157e2f 100644
--- a/docs/plan.md
+++ b/docs/plan.md
@@ -1,6 +1,6 @@
# 调研结论与实施计划(已按最新代码同步更新)
-> 说明:本文档随实现演进持续同步。最近一次同步(2026-08-10)对齐了实现过程中新增的模块与设计变更:BAB 精确搜索路径、manifest 类型/ID 推导、上下文感知元素类型、属性级 refType / Poid 局部引用(`id` 定义点)、精确跳转范围、嵌套 `xi:include`、注入式语法高亮、bit-flag 列表补全(空格触发 / 排除已用 / 追加模式)、simple-content 元素文本引用(补全 / hover / 跳转 / 诊断 / Find All References)、语义引用索引 / CodeLens 引用计数 / 未引用资产命令、属性补全换行判定与按 id 去重、manifest 源地址按 vanilla-only 解析(避免 mod 同名 DATA 路径遮蔽)等。
+> 说明:本文档随实现演进持续同步。最近一次同步(2026-08-11)对齐了实现过程中新增的模块与设计变更:BAB 精确搜索路径、manifest 类型/ID 推导、上下文感知元素类型、属性级 refType / Poid 局部引用(`id` 定义点)、精确跳转范围、嵌套 `xi:include`、注入式语法高亮、bit-flag 列表补全(空格触发 / 排除已用 / 追加模式)、simple-content 元素文本引用(补全 / hover / 跳转 / 诊断 / Find All References)、语义引用索引 / CodeLens 引用计数 / 未引用资产命令、属性补全换行判定与按 id 去重、manifest 源地址按 vanilla-only 解析(避免 mod 同名 DATA 路径遮蔽)、`assetsById` 保留同 id 的不同类型 manifest 定义等。
## 一、调研结论(带证据)
@@ -140,7 +140,7 @@ test/
`W3DHierarchy` / `W3DCollisionBox` 等),使 `Model@Name`、`Hierarchy`、`Mesh`
等引用可解析;大模型文件**浅扫描**(不建 DOM),结果缓存在 workspace 级、
跨重建复用(详见设计决策 14)。
-3. **manifest 资产建模**:类型优先用哈希表,未知时从名称前缀推导;可引用 ID 取最后冒号段;类型名统一走大小写规范化(`W3dContainer` ↔ `W3DContainer`),类型匹配严格遵循 XSD 继承链。
+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 类型属性是管线局部引用,全局索引无法判定,不检查、不解析。
6. **重复 ID 诊断**:与 `check_duplicate_ids.py` 一致——SageXml 不参与冲突判定,mod 覆盖原版视为正常。
diff --git a/src/indexer/indexer.ts b/src/indexer/indexer.ts
index 5e984aa..bb0434b 100644
--- a/src/indexer/indexer.ts
+++ b/src/indexer/indexer.ts
@@ -1014,14 +1014,32 @@ export class ModIndexer {
}
const arr = byId.get(idKey);
if (arr) {
- if (arr.some((a) => a.file === def.file && a.line === def.line)) return;
+ if (
+ arr.some(
+ (a) =>
+ a.type === def.type &&
+ a.file === def.file &&
+ a.line === def.line,
+ )
+ ) {
+ return;
+ }
arr.push(def);
} else {
byId.set(idKey, [def]);
}
const all = this.assetsById.get(idKey);
if (all) {
- if (all.some((a) => a.file === def.file && a.line === def.line)) return;
+ if (
+ all.some(
+ (a) =>
+ a.type === def.type &&
+ a.file === def.file &&
+ a.line === def.line,
+ )
+ ) {
+ return;
+ }
all.push(def);
} else {
this.assetsById.set(idKey, [def]);
diff --git a/src/indexer/localScope.ts b/src/indexer/localScope.ts
index 8c1c2d4..18f2049 100644
--- a/src/indexer/localScope.ts
+++ b/src/indexer/localScope.ts
@@ -244,14 +244,32 @@ class OverlayBuilder {
}
const arr = byId.get(idKey);
if (arr) {
- if (arr.some((a) => a.file === def.file && a.line === def.line)) return;
+ if (
+ arr.some(
+ (a) =>
+ a.type === def.type &&
+ a.file === def.file &&
+ a.line === def.line,
+ )
+ ) {
+ return;
+ }
arr.push(def);
} else {
byId.set(idKey, [def]);
}
const all = this.overlay.assetsById.get(idKey);
if (all) {
- if (all.some((a) => a.file === def.file && a.line === def.line)) return;
+ if (
+ all.some(
+ (a) =>
+ a.type === def.type &&
+ a.file === def.file &&
+ a.line === def.line,
+ )
+ ) {
+ return;
+ }
all.push(def);
} else {
this.overlay.assetsById.set(idKey, [def]);
diff --git a/test/indexer.test.mjs b/test/indexer.test.mjs
index 732274e..8188c48 100644
--- a/test/indexer.test.mjs
+++ b/test/indexer.test.mjs
@@ -12,6 +12,7 @@ import {
IndexRecordsCache,
} from "../out/indexer/caches.js";
import { resolveReferenceTargetsForType } from "../out/indexer/refs.js";
+import { assetDefKey } from "../out/indexer/referenceIndex.js";
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const project = join(root, "test", "fixtures", "minimod");
@@ -29,6 +30,75 @@ async function buildIndex() {
return indexer.build();
}
+function u32(value) {
+ const b = Buffer.alloc(4);
+ b.writeUInt32LE(value >>> 0);
+ return b;
+}
+
+function u16(value) {
+ const b = Buffer.alloc(2);
+ b.writeUInt16LE(value >>> 0);
+ return b;
+}
+
+/** Minimal version-5 manifest with one asset entry per supplied descriptor. */
+function minimalManifestV5(assets) {
+ const nameParts = [];
+ const sourceParts = [];
+ let nameOffset = 0;
+ let sourceOffset = 0;
+ const entries = assets.map((asset) => {
+ const name = Buffer.from(`${asset.name}\0`, "ascii");
+ const source = Buffer.from(`${asset.source ?? ""}\0`, "ascii");
+ const entry = {
+ typeId: asset.typeId,
+ nameOffset,
+ sourceFileNameOffset: sourceOffset,
+ };
+ nameParts.push(name);
+ sourceParts.push(source);
+ nameOffset += name.length;
+ sourceOffset += source.length;
+ return entry;
+ });
+ const names = Buffer.concat(nameParts);
+ const sources = Buffer.concat(sourceParts);
+
+ const parts = [
+ Buffer.from([0, 1]), // isBigEndian=false, isLinked=true
+ u16(5), // version
+ u32(0), // streamChecksum
+ u32(0), // allTypesHash
+ u32(assets.length), // assetCount
+ u32(0), // totalInstanceDataSize
+ u32(0), // maxInstanceChunkSize
+ u32(0), // maxRelocationChunkSize
+ u32(0), // maxImportsChunkSize
+ u32(0), // assetReferenceBufferSize
+ u32(0), // referencedManifestNameBufferSize
+ u32(names.length), // assetNameBufferSize
+ u32(sources.length), // sourceFileNameBufferSize
+ ];
+ for (const entry of entries) {
+ parts.push(
+ u32(entry.typeId),
+ u32(0), // instanceId
+ u32(0), // typeHash
+ u32(0), // instanceHash
+ u32(0), // assetReferenceOffset
+ u32(0), // assetReferenceCount
+ u32(entry.nameOffset),
+ u32(entry.sourceFileNameOffset),
+ u32(0), // instanceDataSize
+ u32(0), // relocationDataSize
+ u32(0), // importsDataSize
+ );
+ }
+ parts.push(names, sources);
+ return Buffer.concat(parts);
+}
+
test("indexes assets, defines, streams and include errors", async () => {
const idx = await buildIndex();
@@ -133,6 +203,102 @@ test("w3x files appear in Include source completion candidates", async () => {
);
});
+test("manifest assets sharing an id keep every type in assetsById", async () => {
+ const tmp = fs.mkdtempSync(join(os.tmpdir(), "ra3-manifest-multitype-"));
+ const projectDir = join(tmp, "project");
+ const sdkDir = join(tmp, "sdk");
+ const builtmodsDir = join(sdkDir, "builtmods");
+ fs.mkdirSync(join(projectDir, "Data"), { recursive: true });
+ fs.mkdirSync(builtmodsDir, { recursive: true });
+ fs.writeFileSync(join(sdkDir, "Static.xml"), "");
+ fs.writeFileSync(
+ join(projectDir, "Data", "Mod.xml"),
+ `
+
+
+
+
+
+
+
+
+
+
+
+
+
+`,
+ );
+ fs.writeFileSync(
+ join(builtmodsDir, "static.manifest"),
+ minimalManifestV5([
+ {
+ typeId: 0x11111111,
+ name: "W3DHierarchy:AUMCV_HOVER",
+ source: "ART:aumcv_hover.w3x",
+ },
+ {
+ typeId: 0x22222222,
+ name: "W3DAnimation:AUMCV_HOVER",
+ source: "ART:aumcv_hover.w3x",
+ },
+ {
+ typeId: 0x33333333,
+ name: "W3DContainer:AUMCV_HOVER",
+ source: "ART:aumcv_hover.w3x",
+ },
+ {
+ typeId: 0x44444444,
+ name: "Texture:ABAirfield",
+ source: "ART:abairfield.tga",
+ },
+ {
+ typeId: 0x55555555,
+ name: "W3DContainer:ABAIRFIELD",
+ source: "ART:abairfield.w3x",
+ },
+ ]),
+ );
+
+ const indexer = new ModIndexer({
+ projectDir,
+ sdkDir,
+ builtmodsDirs: [builtmodsDir],
+ indexSageXml: false,
+ additionalDataSearchPaths: [],
+ walker: new CachedDirectoryWalker(),
+ });
+ const idx = await indexer.build();
+
+ // The reported AUMCV_HOVER shape: Hierarchy/Animation precede the
+ // W3DContainer, so the by-id index must not drop the render asset.
+ const hover = idx.assetsById.get("aumcv_hover");
+ assert.ok(hover?.some((d) => d.type === "W3DContainer"), "W3DContainer retained");
+ assert.ok(hover?.some((d) => d.type === "W3DHierarchy"), "W3DHierarchy retained");
+ assert.ok(hover?.some((d) => d.type === "W3DAnimation"), "W3DAnimation retained");
+
+ const targets = resolveReferenceTargetsForType(
+ idx,
+ "ScriptedModelDrawModel",
+ "Name",
+ "AUMCV_Hover",
+ );
+ assert.equal(targets.length, 1);
+ assert.equal(targets[0].def.type, "W3DContainer");
+
+ const container = hover.find((d) => d.type === "W3DContainer");
+ const sites = idx.references.get(assetDefKey(container));
+ assert.ok(
+ sites?.some((s) => s.kind === "attr" && /Mod\.xml$/.test(s.file)),
+ "Model reference is attributed to the W3DContainer definition",
+ );
+
+ // Common Texture-first shape must also keep the render definition.
+ const airfield = idx.assetsById.get("abairfield");
+ assert.ok(airfield?.some((d) => d.type === "Texture"), "Texture retained");
+ assert.ok(airfield?.some((d) => d.type === "W3DContainer"), "W3DContainer retained");
+});
+
test("build publishes an immutable XML phase before art scanning", async () => {
let phaseA;
const indexer = new ModIndexer({