From 21c2275c0004a84ce537740dbb9208ef388c12d0 Mon Sep 17 00:00:00 2001 From: lanyizi Date: Tue, 11 Aug 2026 15:17:30 +0200 Subject: [PATCH] fixed manifest references with same id --- CHANGELOG.md | 6 ++ docs/analysis-issues.md | 93 +++++++++++++++++++++ docs/plan.md | 4 +- src/indexer/indexer.ts | 22 ++++- src/indexer/localScope.ts | 22 ++++- test/indexer.test.mjs | 166 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 307 insertions(+), 6 deletions(-) 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({