72 KiB
问题分析:AttachTest 高亮异常与误报诊断
日期:2026-08-01。基于在 AttachTest
Data/Mod.xml上复现的问题。 本文件只做分析,代码尚未修改,修复方案见文末,待审阅后实施。
一、现象
- 语法高亮异常:
<?xml version="1.0" encoding="UTF-8"?>显示为纯文本颜色;<Includes之后的>无高亮;<Include type="reference" ...>中Include之后的内容全部无高亮;- 整体表现为"只有少量标签名被着色,其余按纯文本处理"。
- 误报诊断:
<Include type="reference">的type值报Unresolved reference "reference";<Include source="DATA:Static.xml">的source值报Unresolved reference;- 同一处 hover 显示解析结果为
C:\Apps\RA3-MODSDK-X\SageXml\Static.xml,但实际应按编译参数解析到 SDK 根目录C:\Apps\RA3-MODSDK-X\Static.xml。
二、实测证据
用当前构建代码对 AttachTest 索引后验证:
[resolve] DATA:Static.xml -> C:\Apps\RA3-MODSDK-X\Static.xml ← 解析器结果正确
[candidate] 精确匹配 -> C:\Apps\RA3-MODSDK-X\SageXml\Static.xml ← hover 用的是这条
[model] Include@type refType: null enum: [reference, instance, all]
[model] Include@source refType: null type: anyURI
[diag] Include@type='reference' targets: 0
三个问题均稳定复现。
三、根因分析
1. 高亮异常:grammar 注册方式覆盖了内置 XML 语法
package.json 中的 grammar 贡献写法:
{
"language": "xml",
"scopeName": "source.ra3modxml",
"injectTo": ["source.xml"]
}
同时填写 language 与 injectTo 时,VS Code 会把该 grammar 注册为 xml 语言的主 grammar(替换内置 XML 语法),而不仅是注入。结果是:
- 内置
source.xml的完整 token 化规则不再生效; - 我的语法只有几个领域关键词模式,没有 XML 声明、标签括号、属性名/值的规则;
- 于是
<?xml ...?>、>、属性区全部退化为纯文本,只有$DEFINE、inheritFrom、结构标签名等被我的模式着色——与现象完全吻合。
正确的做法是注入式 grammar 不声明 language(VS Code 官方注入语法示例即省略该字段)。
2. 误报 Unresolved reference:诊断把所有属性值都当成"引用"检查
诊断代码在 checkValueReferences 中先调用 resolveReferenceTargets(...),并以其返回空数组作为"未解析"依据。而 resolveReferenceTargets 对非引用属性(如 Include@type 枚举、Include@source 的 anyURI)也返回空数组——两者无法区分。
这是早期实现回归:最初代码有 isRef(检查 refType 或 inheritFrom)守卫,重构为共享函数后丢失了该守卫。
同理,hover 对非引用属性值也会走到"未找到匹配定义"分支,显示误导性文案。
3. DATA:Static.xml hover 路径错误:候选表优先于解析器,且候选表大小写不统一
- 解析器
resolveSource按搜索路径正确解析到 SDK 根Static.xml; - 但 hover/定义跳转/文档链接都是先查
idx.sourceCandidates精确匹配; - 候选表里同一 source 字符串
DATA:Static.xml出现两条:一条来自SageXml\Static.xml(扫描更早、排在前面),一条来自 SDK 根目录浅扫描; find精确匹配返回第一条 → 显示 SageXml 路径,而按 BAB 顺序应先命中 SDK 根目录。
修复方向相应调整为:候选表只服务补全;hover/跳转/链接一律先走 resolveSource(按 BAB 顺序校验文件存在)。候选表本身也按 source 去重并让 SDK 根目录条目排前。
另外,当前搜索路径来自 check_duplicate_ids.py 的简版([SDK根, 项目Data, Mods, SDK/SageXml]),与 BAB 编译参数不完全一致,缺少 modGranParent 与 SDK\Mods 两个条目。defaultscript.cs 的 getIncludePaths() 实际参数为:
/data: ".;{modGranParent};{0}\Data;.\Mods;{1};.\SageXml"
/art: ".;{modGranParent};{0}\Art1;{0}\Art;.\Mods;{1};.\Art"
/audio: ".;{modGranParent};{0}\Audio1;{0}\Audio;.\Mods;{1};.\Audio"
其中 {0}=ModPath, {1}=modParentPath, {2}=modGranParent, "." = SDK 根目录(编译时 cd 到 SDK)
展开后(以 AttachTest 为例):
| 前缀 | 搜索路径(BAB 顺序) |
|---|---|
| DATA | SDK根 → modGranParent → Mod\Data → SDK\Mods → modParentPath → SDK\SageXml |
| ART | SDK根 → modGranParent → Mod\Art1 → Mod\Art → SDK\Mods → modParentPath → SDK\Art |
| AUDIO | SDK根 → modGranParent → Mod\Audio1 → Mod\Audio → SDK\Mods → modParentPath → SDK\Audio |
DATA:Static.xml 在 BAB 顺序下首先命中 SDK 根的占位文件(与用户预期一致)。
四、修复方案
4.1 语法高亮(P0)
package.json的grammars条目移除"language": "xml",只保留scopeName、path、injectTo,使其成为纯粹的注入语法,叠加在内置 XML 语法之上。- 精简
syntaxes/ra3modxml.tmLanguage.json:保留不冲突的模式($DEFINE常量、inheritFrom、xai:joinAction、TODO 注释、结构标签名);删除对Replace/Remove的裸词匹配(避免误染普通内容)。 - 验证方式:扩展开发宿主中打开
Data/Mod.xml,确认 XML 声明、标签、属性恢复完整高亮且领域关键词仍有专属颜色。
4.2 诊断与 hover 的引用判断(P0)
- 恢复"是否引用属性"守卫:仅当属性为
inheritFrom或模型中有refType时才进入未解析引用检查;Include@type、Include@source等不再误报。 - hover 同步加守卫:非引用属性值不再显示"未找到匹配定义"文案;
Include@source走专门的"Include 源文件"分支。
4.3 Include 源解析(P0)
- hover / 定义跳转 / 文档链接统一改为优先调用
resolveSource(按 BAB 顺序、校验文件存在),候选表只作为兜底与补全来源。 buildSearchPaths对齐 BAB 编译参数:DATA/ART/AUDIO 各补上modGranParent与SDK\Mods两个搜索条目,顺序与defaultscript.cs一致。- 候选表去重改为大小写不敏感(按
source.toLowerCase()去重),避免同一文件因大小写不同出现两条候选。
4.4 测试补充
- 新增/更新单测:
- 非引用属性(
Include@type、Include@source)不会产生 unresolved-reference 目标; DATA:Static.xml按 BAB 顺序解析到 SDK 根目录而非 SageXml;buildSearchPaths展开结果与 BAB 参数一致(构造 SDK 内/外两种项目布局断言顺序)。
- 非引用属性(
- 回归验证:AttachTest / GenEvoTest / Corona 各索引一次,确认 0 误报、补全/跳转正常。
4.5 不在本次范围
- P1 搜索与索引复用设计;
- manifest 深度解析(资产类型不匹配提示的进一步细化)。
五、风险与影响
- grammar 改动只影响高亮层,不影响索引与诊断逻辑;
- 搜索路径顺序变化可能影响个别 include 的解析结果(更接近 BAB 真实行为),需在三个真实项目上回归;
- 候选表去重策略变化理论上减少补全条目重复,不影响条目正确性。
六、实施结果(2026-08-01)
已按上述方案完成修复并验证:
- grammar 改为纯注入:
package.json的grammars条目移除"language": "xml",内置 XML 语法恢复为主语法,领域关键词以注入方式叠加。 - 引用属性守卫恢复:新增
isReferenceAttribute()纯函数,诊断与 hover 仅对inheritFrom/ 带refType的属性做未解析引用检查;Include@type、Include@source不再误报。 - Include 源解析优先
resolveSource:hover / 定义跳转 / 文档链接统一先按 BAB 顺序解析;候选表仅作补全来源并按 source 去重、SDK 根目录条目排前。 - 搜索路径对齐 BAB 参数:
buildSearchPaths的 DATA/ART/AUDIO 各补入modGranParent与SDK\Mods,顺序与defaultscript.cs的/data /art /audio一致。
实测(AttachTest,C: 盘):
DATA:Static.xml resolves to: C:\Apps\RA3-MODSDK-X\Static.xml (OK,不再指向 SageXml)
DATA:static.xml candidates: 1 -> C:\Apps\RA3-MODSDK-X\Static.xml (去重生效)
isReferenceAttribute(Include,type) = false
isReferenceAttribute(Include,source) = false
CommandSet 引用仍能解析到 LogicCommandSet 定义(无回归)
单元测试 24/24 通过(新增:BAB 搜索路径顺序、SDK 根优先于 SageXml、isReferenceAttribute 判定)。
注:本环境当前无法访问
D:\Mods\CoronaMod(D: 盘不可见),GenEvoTest / Corona 的实机回归待 D: 盘可用后补跑;相关逻辑已被 AttachTest 与单元测试覆盖。
七、问题分析(第二轮,2026-08-01)
用户在 AttachTest Guardian Tank\GameObject.xml 上报了三个新问题,均已修复并验证。
问题 A:Side="Allies" 误报"未被定义"
现象:Side="Allies" 报 unresolved reference,但 global.manifest 中存在 PlayerTemplate:Allies。
根因:Side 属性确实是引用(PlayerTemplateWeakRef → PlayerTemplate),但 manifest 解析器依赖 OpenSAGE AssetType.cs 的 TypeId 哈希表,而该表不完整(实测 Global.manifest 11268 个资产中有 2707 个哈希未知,PlayerTemplate 即缺失)。未知类型被记成 #哈希,无法与 refType=PlayerTemplate 匹配。
修复:manifest 资产名本身是 类型名:ID 格式(如 PlayerTemplate:Allies),新增 deriveAssetType()——哈希已知时用哈希;未知时从名称前缀推导类型(资产 ID 不允许含冒号,切分无歧义)。修复后 Side="Allies" 正确解析到 PlayerTemplate@Global.manifest。
问题 B:<Weapon> 报没有 Ordering 属性
现象:WeaponSetUpdate → WeaponSlotTurret → Weapon 链中 <Weapon Ordering=...> 报未知属性;XSD 里 WeaponSlot_WeaponData 确实有 Ordering。
根因:模型用"元素名→类型"的全局单映射,同名元素(如 <Weapon> 出现在武器槽、慢速死亡、引用等许多上下文)以"先到先得"注册。Weapon 被注册成 WeaponRef,因此按全局映射查不到 Ordering。这是典型的上下文相关类型问题。
修复:新增上下文感知类型解析:
childTypeOf(父类型, 子元素名)/elementTypeIn(父元素, 子元素名)/attributesOfType(类型);resolveElementType(元素)沿解析树向上逐层用父类型的子元素声明解析真实类型,失败时回退全局映射。
修复后 Weapon(在 WeaponSlotTurret 下)解析为 WeaponSlot_WeaponData,Ordering 合法;且 Ordering 本身不是引用,不再被误判。
问题 C:嵌套 xi:include(第 248 行)未处理
现象:<xi:include href="DATA:Includes/HeadlightDraw2.xml" xpointer="..."> 位于元素内部(非 AssetDeclaration 直接子级),原实现只处理根级 xi:include,嵌套的一律静默忽略。
根因:索引器 walk() 只遍历根的直接子元素处理 xi:include。
修复:解析后遍历文档全部元素,对任意层级的 xi:include:解析 href(按 BAB 搜索路径);目标缺失时产生 include-not-found 诊断(不再静默);目标存在时记录并 walk 目标文件,使其内容/资产进入索引。href 的文档链接(Ctrl+点击)此前已可用。
举一反三的测试
test/typeContext.test.mjs:用真实结构(GameObject → BehaviorModules → WeaponSetUpdate → WeaponSlotTurret → Weapon)验证上下文类型解析;childTypeOf原语断言。test/manifestTypes.test.mjs:deriveAssetType前缀推导(含无冒号/前导冒号边界);模拟PlayerTemplate:Allies后Side引用解析成功;isReferenceAttributeOfType判定。test/indexer.test.mjs:新增嵌套xi:include断言——目标文件被索引、目标资产可用、缺失目标产生诊断。- AttachTest 实机验证:
Side="Allies"→PlayerTemplate@Global.manifest;Weapon类型 →WeaponSlot_WeaponData(含Ordering);HeadlightDraw2.xml进入索引、无相关诊断。
单元测试 28/28 通过,.vsix 已重新打包。
八、问题分析(第三轮,2026-08-01)
用户在 AttachTest Guardian Tank\GameObject.xml 上报了三个问题,均已修复并验证。
问题 A:Locomotor="..." 误报 "has no definition of type true"
现象:LocomotorSet/@Locomotor 引用报错,且类型显示为 true。
根因:模型生成器把简单类型的 xas:isRef="true" 当成了 refType 值。Locomotor 的类型是 AssetReference(只有 xas:isRef="true"、没有 refType),于是 refType 被写成字符串 "true",导致任何类型匹配都失败。这是生成器 refType 取值逻辑的 bug。
修复:
- 生成器只从
xas:refType取 refType,xas:isRef只作为布尔标记isRef(属性条目新增该字段); isReferenceAttributeOfType对refType或isRef都视为引用;resolveReferenceTargetsForType对无类型引用(isRef 且无 refType)不做类型过滤,直接匹配同名 id——正是Locomotor这类"引用任意已声明资产"的语义。
修复后 Locomotor="AlliedAntiVehicleVehicleTech1Locomotor" 正确解析到 mod 的 LocomotorTemplate(以及 static.manifest 中的同名原版资产)。
问题 B:Name="AUAntiVehicleVehicleTech1_SKN" 误报 BaseRenderAssetType 未定义
现象:W3D 模型名引用报"no definition of type BaseRenderAssetType",但资产确实存在于 static.manifest。
根因:两个叠加原因:
- manifest 名格式是
类型:子类型:文件名(如W3dContainer:W3DContainer:AUANTIVEHICLEVEHICLETECH1_SKN),旧的 id 提取只切掉第一个前缀,得到W3DContainer:AUANTIVEHICLEVEHICLETECH1_SKN,无法匹配 XML 里的AUAntiVehicleVehicleTech1_SKN; - manifest 哈希表里的类型名是
W3dContainer(小写 d),而 XSD 类型名是W3DContainer,大小写不一致导致继承链匹配失败。
修复:
deriveAssetId改为取最后一个冒号段(资产 ID 不允许含冒号),兼容PlayerTemplate:Allies与W3dContainer:W3DContainer:...两种格式;canonicalTypeName大小写不敏感规范化,typeChain/isAssignableTo/ 属性查询统一走规范化;manifest 资产入库时类型名也规范化。
类型匹配严格遵循 XSD 继承链:W3DContainer / W3DMesh → BaseRenderAssetType ✓;W3DHierarchy → BaseAssetType(不是 BaseRenderAssetType)✗。实测 _SKN 只解析出 W3DContainer@Static.manifest 一个候选,碰撞盒(id 带 .OBBOX 后缀)与层级自然排除。
问题 C:跳转候选"只能打开文件、不能精确定位"
现象:Template="AlliedAntiVehicleVehicleTech1Cannon" 有两个候选——mod 定义和 SageXml\globaldata\weapon.xml(由 manifest 的 manifestSource 映射而来)。后者只打开文件、不定位。
修复:
- 所有 XML 定义的跳转改为精确范围:用
id属性值的起止偏移构造 Range(找不到时回退到元素起始标签),mod 与 SageXml 一致; - manifest 定义映射到源码文件时,同样在源码文件内查找同名 id 并精确定位(实测定位到
weapon.xml:2357); - 新增配置
ra3modxml.definitionMode:all(默认,mod + 原版全部列出,mod 优先)或project-only(项目内已定义时只跳 mod 定义)。
举一反三的测试(31/31 通过)
test/manifestTypes.test.mjs:deriveAssetId最后一段提取(含 W3D/纹理双前缀、无冒号边界);大小写规范化后的isAssignableTo(W3dContainer→BaseRenderAssetType为真、W3dHierarchy为假)。test/refs.test.mjs:无类型引用(LocomotorSet/@Locomotor)解析到任意声明类型的同名 id;非引用属性仍返回空。- AttachTest 实机:三个原始场景全部按预期(Locomotor 命中 LocomotorTemplate、_SKN 命中 W3DContainer、cannon 双候选均可精确定位)。
.vsix 已重新打包(13:33)。D 盘恢复后照旧可补跑 GenEvoTest / Corona 回归。
九、问题分析(第四轮,2026-08-01):模块 id 被误报为未解析引用
问题:id="ModuleTag_Draw" 误报 Unresolved reference
现象:AttachTest Allied Vehicle\Guardian Tank\GameObject.xml 第 45 行
<TruckDraw
id="ModuleTag_Draw"
...>
报 Unresolved reference "ModuleTag_Draw" (not found in the current index),
hover 同时显示 No matching definition of the expected declared type...。
但该 id 是 TruckDraw(GameObject 模块)自身的标识,只在所属 <GameObject /> 内部有效,
此处就是定义处,全局资产索引中不存在(也不应存在)它的定义。
根因(两层叠加):
-
模型生成器丢失属性级
xas:refType:ModuleData@id在 XSD 中声明为<xs:complexType name="ModuleData" xas:isPolymorphic="true"> <xs:attribute name="id" type="Poid" xas:refType="ModuleData" /> </xs:complexType>refType 写在
<xs:attribute>节点上,而tools/xsd-to-model.mjs只从 simple type 描述符读取 refType,属性级声明被丢弃。Poid本身带xas:isWeakRef="true"(“管线对象 ID”),于是该 id 在模型里变成{ type: "Poid", refType: null, isRef: true }——一个 “无类型引用”,导致所有继承自 ModuleData 的模块类型(以及 ObjectFilter、MapObject、 GameScript、AIStateTactic 等共 430 个类型)的id都被当作全局引用检查。 -
id的语义是“定义点”而非“引用”:即便 refType 正确,嵌套元素(模块、nugget、 地图对象)的id也只是其局部标识,检查全局 unresolved 必然误报。反过来,XSD 里确有一类 真正引用其他资产类型的id(如RoadObject@id为AssetReference+xas:refType="Road"), 这类检查必须保留。
验证:
- 用未修改的生成器对当前 SDK XSD 重新生成模型,与仓库内模型逐字段一致(0 差异), 证明修复后重新生成的 diff 只落在属性级 refType 上,无无关噪音。
- 修复后
W3DTruckDrawModuleData@id→refType: ModuleData;isReferenceAttributeOfType(...) === false;空索引下TruckDraw@id不再产生诊断; 全文件扫描 0 个id误报,61 处真实引用属性(inheritFrom、CommandSet、Side、Locomotor、TrackMarks等)行为不变。
修复:
- 生成器(
tools/xsd-to-model.mjs):collectAttributes改为attr["@_refType"] ?? desc?.refType(属性级优先、simple type 兜底),重新生成schema-model.json——共恢复 444 处 refType(含继承传播),isRef与其他字段零变化。 附带收益:Locomotor → LocomotorTemplate、Armor → ArmorTemplate、ThingTemplate → GameObject等此前被当作“无类型引用”的属性恢复真实类型 (第三轮中 Locomotor“无 refType”的结论实为该生成器 bug 的误判)。 - 局部引用规则(
src/indexer/refs.ts新增isLocalReferenceAttribute):id:无 refType,或 refType 与元素自身类型兼容(isAssignableTo,如W3DTruckDrawModuleData → ModuleData)→ 定义点,不做全局引用检查; refType 指向不同类型(RoadObject@id → Road)→ 保留真实引用检查;- 非
id且类型为Poid的属性(ModuleId、AutoResolveBody、SoundRef、AttachModuleId…)→ 管线局部引用,全局索引无法判定,不检查。isReferenceAttributeOfType与resolveReferenceTargetsForType同步使用该守卫, 诊断 / hover / 跳转 / 补全行为一致。
- 补全(
src/features/completion.ts):id与 Poid 属性不再按 refType 提供 全局资产补全(模块 id 是局部的,全局资产列表是错误建议)。
测试(31 → 37,全部通过):
refs.test.mjs:TruckDraw 实景结构(GameObject → Draws → TruckDraw)下id不再是 引用且不解析;RoadObject@id仍是引用并能解析到 Road;AttachModuleId等 Poid 属性 不误报;Locomotor 改为严格类型引用(同名 GameObject 不再匹配);schemaModel.test.mjs:ModuleData@id/MapObject@id/ThingTemplate/RoadObject@id/AttachModuleId的属性级 refType 断言;- 回归:原有 31 个用例全部保持通过。
后续可做:GameObject 内模块 id 的“局部作用域”解析——
AttachModuleId、ModuleId等模块引用指向同一 GameObject 内的兄弟模块,但部分引用(如武器上的AttachModuleId)目标 GameObject 跨文件无法静态确定,本轮先统一不检查;待局部 作用域建模落地后再启用这些引用的解析与诊断。
十、问题分析(第五轮,2026-08-01):xi:include 的 href/xpointer 误报未知属性
问题
AttachTest Allied Vehicle\Guardian Tank\GameObject.xml 第 249 行附近:
<xi:include
href="DATA:Includes/HeadlightDraw2.xml"
xpointer="xmlns(n=uri:ea.com:eala:asset) xpointer(/n:HeadlightDraw2/child::*)"/>
报两条 Unknown attribute "href" / "xpointer" for <include>(unknown-attribute),
hover 同时显示 Unknown attribute for this element.。
这个元素属于 W3C XInclude 命名空间(xmlns:xi="http://www.w3.org/2001/XInclude"),
并不是 EA uri:ea.com:eala:asset XSD 的一部分。同一行在第二轮“问题 C”处理过
(嵌套 xi:include 的索引与导航),但那轮没有覆盖 unknown-attribute 诊断,属于遗留缺口。
根因
诊断的属性校验没有像元素校验那样排除外来命名空间:
- 元素校验已有
!el.name.startsWith("xi:")守卫(所以<include>本身不报 unknown element); - 属性校验只跳过
xmlns*/xai:/xi:前缀的属性名,而href、xpointer是不带 前缀的普通属性名; <xi:include>解析类型为 null(XSD 模型不含该元素),knownAttrs 为空 → 任何属性 都被判为 unknown。
修复
schemaModel新增两个纯函数:isXsdElementName:xi:前缀元素不属于 EA XSD 模型;isXsdAttributeName:EA XSD 属性不带命名空间前缀,带前缀(xai:、xi:、xlink:、xml:、xsi:、xmlns:*)的都是命名空间机制,不做 schema 校验。
diagnostics:xi:前缀元素整体跳过 schema 校验(元素与属性都不再误报); 前缀属性名统一跳过。hover:xi:include元素/属性给出 XInclude 说明;href值悬停像<Include source>一样解析目标文件(Ctrl+点击跳转此前已可用)。
验证
- 真实文件全量扫描:0 未知元素、0 未知属性(修复前
href/xpointer两条必现); - 新增测试:
isXsdElementName/isXsdAttributeName断言;xi:include解析类型为 null 且不参与校验;全量 39/39 通过。
后续(架构方向,待确认)
用户提出“先展开 xi:include(类比 C++ 宏展开),再处理 mod XML 解析”。该方向与第二轮
遗留的“虚拟合并”开放项一致,设计要点:
- 构建逻辑树而非文本拼接:把目标文件选中内容(
xpointer子集)作为子节点拼入父 元素,节点保留源文件与原始偏移,避免文本级拼接导致的偏移断裂; - 展开范围:
xi:include与 EA<Include type="all">(内容合并);instance/reference是可见性 / 编译产物语义,不拼树;inheritFrom+joinAction是属性级 继承合并,不是宏展开; - 收益:跨 include 的上下文类型解析、包含内容的结构校验、以及后续“GameObject 内模块 id 局部作用域”(HeadlightDraw2 的模块也是该 GameObject 的模块);
- 风险:include 环 / 深度限制、大文件性能、
xpointer仅支持现有子集形式 (/n:Name/child::*)。
十、问题分析(第五轮,2026-08-01):xi:include 的 href / xpointer 被误报为未知属性
问题
同一 GameObject.xml 第 246–249 行:
<!-- include Headlight draw module. -->
<xi:include
href="DATA:Includes/HeadlightDraw2.xml"
xpointer="xmlns(n=uri:ea.com:eala:asset) xpointer(/n:HeadlightDraw2/child::*)"/>
报两条 Unknown attribute "href" / "xpointer" for <include>(unknown-attribute),
hover 显示 Unknown attribute for this element.。
与第二轮的关系:第二轮“问题 C”处理的正是同一行的嵌套 xi:include——但那一轮修的是
索引器(嵌套 include 不再被静默忽略、缺失目标产生 include-not-found、目标内容进索引),
本轮这处 unknown-attribute 诊断是当时未覆盖的遗留问题。
根因
xi:include 属于 W3C XInclude 命名空间(http://www.w3.org/2001/XInclude),
不是 RA3 XSD(uri:ea.com:eala:asset)定义的元素:
- 未知元素检查已通过
el.name.startsWith("xi:")跳过,所以没有 unknown-element 误报; - 但属性检查没有同类守卫:
xi:include解析类型为 null →knownAttrs为空 →href、xpointer两个非xi:前缀的属性名全部落入 unknown-attribute 分支。
复现证据(真实文件):
xi:include found: true | parent: Draws
resolved element type: null
known attribute names: (none)
attr href: known=false -> would flag unknown-attribute: true
修复
- 模型层新增命名空间守卫(
schemaModel.ts):isXsdElementName(name):xi:前缀(XInclude)等外来命名空间元素不属于 XSD 模型;isXsdAttributeName(name):EA XSD 属性一律无前缀,带前缀的属性 (xai:、xi:、xlink:、xml:、xsi:、xmlns:*)都是命名空间机制,不做未知属性校验。
- 诊断(
diagnostics.ts):外来命名空间元素的未知元素/未知属性检查整体跳过 (href、xpointer不再误报);属性名带前缀的一律跳过校验(比原先只跳过xmlns/xai:/xi:更完整)。 - hover(
hover.ts):xi:include的元素/属性悬停显示 XInclude 说明;xi:include@href与Include@source一样显示解析后的目标文件(与第二轮已可用的 Ctrl+点击跳转对齐)。
测试(37 → 39,全部通过):
schemaModel.test.mjs:isXsdElementName/isXsdAttributeName判定 (xi:include非 XSD 元素;href/xpointer是合法属性名形态;xai:joinAction、xlink:href、xmlns:xi等带前缀属性不校验);refs.test.mjs:GameObject → Draws →xi:include实景结构解析类型为 null、 元素被判定为外来命名空间,href/xpointer不会进入未知属性分支;- 实机复验:整份 GameObject.xml 0 个 unknown-attribute 残留。
架构讨论(用户提议):把 XML 先“宏展开”成不含
xi:include的版本再解析。 这与 BAB 编译时的实际行为一致(defaultscript.cs把整个 Mod 合并成一份大 XML), 也是实现“GameObject 内模块 id 局部作用域解析”(第四轮遗留)的正确地基——展开后一个 GameObject 连同 include 进来的兄弟模块都在同一棵树里,AttachModuleId等模块引用才能 静态判定。设计备忘(现状 / 逻辑树方案 / 展开范围 / 落地点 / 检查清单)已整理在docs/plan.md第六节,等待确认后作为下一阶段实现。
十一、问题分析(第六轮,2026-08-01):Surfaces=" 未闭合引号导致枚举补全失效
现象
在 <Locomotor ...> 的起始标签里输入 Surfaces="(引号尚未闭合)时:
- 光标处不出
LocomotorSurfaceBitFlags的枚举补全(GROUND、WATER 等); - 整个文件高亮退化(XML 变成“不合法”的观感),直到补上第二个引号才恢复。
根因(两个独立缺陷叠加)
A. 上下文分析不认未闭合的引号(src/language/context.ts)
复现证据(编译产物直接执行):
闭合引号: <Locomotor id="x" Surfaces="GROUND">…
ctx.kind = attribute-value, attr = Surfaces, valuePrefix = "GROUND"
未闭合引号: <Locomotor id="x" Surfaces="GROUND>…
Surfaces: quoteStart=27, quoteEnd=-1 ← 解析器吞掉整个文件
ctx.kind = attribute-name ← 补全走错分支
analyzeStartTag 判断属性值上下文的条件是 offset >= quoteStart && offset <= quoteEnd,
未闭合时 quoteEnd = -1 永远不成立,于是回退成 attribute-name。
B. 模型生成器不支持 xs:list(tools/xsd-to-model.mjs)
XSD 中 LocomotorSurfaceBitFlags 是:
<xs:simpleType name="LocomotorSurfaceBitFlags">
<xs:list itemType="Surface"></xs:list>
</xs:simpleType>
而 Surface 才是真正带 11 个枚举值(GROUND、WATER、CLIFF、AIR…)的类型。生成器
只读取 restriction.enumeration,list 层把枚举全部丢掉——所以即使引号闭合,模型里
该属性也没有任何候选值。影响面:SDK XSD 共 79 个 xs:list 简单类型、317 处属性
声明使用它们(KindOfBitFlags、ObjectStatusBitFlags、WeaponFlagsBitFlags、
ModelConditionBitFlags、BuildPlacementTypeBitFlags 等),展开到继承后的模型条目
共 890 个属性受影响。
关于高亮丢失
这是 TextMate XML 语法对“未闭合字符串”的正常行为:后续内容被当作字符串吞掉,直到
遇到下一个引号或 EOF。与插件注入语法无关(注入部分只有 $DEFINE、inheritFrom 等
少量规则),即使改成 LSP 也不会自动消失。真正的解法是语义 token
(DocumentSemanticTokensProvider),本次未实施,列为可选后续。
修复(第 1–4 项)
- 解析器行尾恢复(
src/language/xmlParser.ts):起始标签扫描到 EOF 且引号仍未 闭合时,把标签在第一个换行处截断并继续解析主循环。未闭合引号只影响当前行,后面 的元素照常进入解析树,补全 / hover / 诊断不中断;解析错误仍照常上报 (Unterminated start tag)。 - 未闭合引号上下文(
src/language/context.ts):quoteEnd < 0时,offset >= quoteStart即视为 attribute-value 上下文,valuePrefix照常取引号后 到光标处文本。 - 模型支持
xs:list(tools/xsd-to-model.mjs):resolveTypeDescriptor解析xs:list的itemType(属性形式或内联 simpleType),继承其枚举值 / refType / isRef / allowsDefine,新增isList标记;重新生成schema-model.json(LocomotorSurfaceBitFlags恢复 11 个枚举值,ModelConditionBitFlags457 个 值与 XSD 一致)。AttributeInfo/SimpleTypeInfo接口同步新增isList。 - 多值补全按“最后一段”过滤(
src/features/completion.ts+context.splitListValuePrefix):list 属性只取当前空格段做前缀过滤,替换范围只 覆盖该段——Surfaces="GROUND之后输入W也能提示 WATER / WALL_RAILING,而不是 用整段前缀匹配失败。
验证
- 未闭合引号复现场景:仅报 1 条
Unterminated start tag,<Other/>等后续元素仍被 解析;ctx.kind = attribute-value、attr = Surfaces、valuePrefix = "GROUND"。 - 模拟补全过滤:前缀
G→GROUND;前缀W→WATER, WALL_RAILING;GROUND后输入W→WATER, WALL_RAILING。 GameObject@KindOf:isList=true、284 个枚举值。- 单元测试 39 → 50 全部通过(新增
test/context.test.mjs与带 vscode stub 的test/completion.test.mjs集成用例;xmlParser 新增未闭合引号恢复 / EOF 用例; schemaModel 新增 list 枚举与isList用例)。 tsc/esbuild构建通过。
补充:真实文件中该场景的元素名是
<LocomotorTemplate ...>(AttachTestLocomotor.xml实测,Surfaces="GROUND CRUSHABLE_OBSTACLE"正是多值 list); SDK XSD 中没有名为Locomotor的元素,补全集成测试按真实写法夹具。
后续可做(未列入本次)
AssetIdList等“任意资产 ID 列表”的引用语义建模(list 补全框架已就绪,但这类 属性在 XSD 里没有 refType,需要另行定义过滤规则)。
语义 token 兜底高亮已在第七轮实现(见下节)。
十二、问题分析(第七轮,2026-08-01):语义 token 兜底高亮
目标
未闭合引号期间 TextMate 把后续内容当字符串吞掉、整个文件高亮退化,这是 XML 语法 固有的行为(任何 XML 编辑器皆然),也无法靠注入 grammar 修复。本轮的解法是语义 token:文档出现解析错误时,由插件用自己的容错解析树继续给标签 / 属性 / 值着色。
设计
- 纯 TS 核心(
src/language/semanticTokens.ts):buildSemanticTokenRanges(doc, text)把解析树转换成按位置排序的{ line, startChar, length, tokenType };token 类型只用 标准type/property/string,所有主题自带配色,无需额外贡献样式。- 元素名:起始标签与闭合标签各一个
typetoken; - 属性名:
propertytoken; - 属性值:
stringtoken,闭合时含两端引号,未闭合时从开引号到行尾恢复点 (如"GROUND>)。
- 元素名:起始标签与闭合标签各一个
- provider(
src/features/semanticTokens.ts):parseXml后若doc.errors.length === 0直接返回空——合法文件观感与纯 TextMate 完全一致; 有解析错误时才用SemanticTokensBuilder编码输出。 - 注册:
extension.ts对xml语言注册DocumentSemanticTokensProvider, legend 与 provider 共用同一实例。
验证
- malformed(
Surfaces="GROUND>未闭合):AssetDeclaration/LocomotorTemplate/<Other/>标签名、id/Surfaces属性名、"x"与"GROUND>值均有 token, 且按位置升序排列; - 合法文档:返回空 token 数组;
- 单元测试 50 → 53 全部通过(新增
test/semanticTokens.test.mjs,纯函数 + vscode stub 的 provider 集成);tsc通过。
边界与取舍
- 语义 token 只在解析报错时启用,且使用主题对
type/property/string的默认 配色,可能与 TextMate XML 配色略有差异——只在打字过程中出现,可接受; - 未实现
provideDocumentSemanticTokensEdits(delta 版本),每次全量计算;单文件 解析在 KB 级,开销可忽略。
十三、问题分析(第八轮,2026-08-02):.w3x 美术资产未被索引(AUGunship_SKN)
问题
AttachTest Harbinger Gunship\GameObject.xml 中 <Model Name="AUGunship_SKN"/>
报两条错误:
- Problems 面板诊断:
Unresolved reference "AUGunship_SKN" (not found in the current index)(unresolved-reference,来自features/diagnostics.ts); - 悬停提示:
No matching definition of type BaseRenderAssetType in the current index (may exist in a compiled manifest or vanilla data).(来自features/hover.ts)。
两条是同一缺失定义的两种呈现(诊断 + hover),不是两个独立 bug。
W3DContainer:AUGunship_SKN 确实由 mod 定义——但定义在
Harbinger Gunship\W3X\AUGUNSHIP_SKN.w3x 里,而索引器只解析 .xml
(manifest 是 *.manifest 二进制,不存在 .manifestxml 源码格式)。
关键事实(实测)
.w3x是文本 XML:文件头即<?xml ...?>+<AssetDeclaration>,内容为<W3DContainer id="AUGUNSHIP_SKN" Hierarchy="AUGUNSHIP_SKL">+<SubObject>子树。 附带 UTF-8 BOM(抽样 292 个 Corona w3x:0 个 UTF-16,40 个带 BOM)。- w3x 通过
<Include type="all">链进索引:Mod.xml → … → W3X.xml → W3X/*.w3x, 也常用ART:xxx.w3x(SDK 根、项目Art等搜索路径)。索引器此前只把 w3x 登记为 文件(stream.files),从不解析,因此其顶层资产不在assetsById中。 - 类型匹配本来是对的:
Model@Name的 refType 是BaseRenderAssetType,W3DContainer → BaseRenderAssetType可赋值(isAssignableTo= true)。缺的只是定义。 - manifest / SageXml 支持早已存在(第二轮/第三轮),但该资产是 mod 自己的 w3x,
不在
builtmods/*.manifest也不在 SageXml——hover 的 "may exist in a compiled manifest or vanilla data" 只是通用兜底文案。
规模调查(Corona,D: 盘已连接)
- 3788 个 w3x,共 2.64 GB;最大 22.8 MB;163 个超过原 4 MB 解析上限,11 个超 10 MB。
- 大文件结构符合"建模软件导出"模式:顶层是少量固定 W3D 资产
(
W3DHierarchy+ 若干W3DMesh/W3DContainer/W3DCollisionBox, 有的还带<Includes>引用ART:*.xml);体积大头是W3DMesh内的Vertices/V、Normals/N、TexCoords/T、Triangles/T等maxOccurs="unbounded"数值元素。例如 21.7 MB 的CBRefinery_BLD.W3X只有 22 个顶层记录, Vertices+Triangles 块占约 12 MB(55%)。 - 全量建 DOM 的代价(实测 6.3 MB Aegis 文件):407 ms、193,651 个元素、 堆内存 +109 MB(约 17 倍文本体积);22 MB 文件外推约 1.5 s + ~380 MB/份。 浅扫描(只取顶层记录):6.3 MB ~200 ms、22 MB ~600 ms,保留内存近似为零。
- 读整个文件不可避免,但建树不是:浅扫描仍然是线性扫描全文(要知道顶层元素边界 就必须扫完),只是不分配子节点对象——所以"事后优化"完全有意义,且是必要项。
修复
- 新增浅扫描模块
src/indexer/shallowScan.ts(纯 TS): 单次线性扫描,只提取顶层元素name + id(含精确 offset)、顶层<Includes>的Include@type/source、任意层级<xi:include>的href/xpointer、<Defines>常量;注释 / CDATA / DOCTYPE / PI 整体跳过;属性解析兼容引号内>与/。 - 索引模式三分:
.xml全量解析(4 MB 上限不变);.w3x一律浅扫描;未知扩展名先嗅探文件头(512 字节,BOM/空白后以<开头且无 NUL 字节 → 按 XML 浅扫描,否则按二进制仅登记)。w3x 自身的<Includes>与 嵌套xi:include会继续被 walk(BAB 语义)。 - 持久缓存:
DocumentCache/ShallowScanCache移到src/indexer/caches.ts, 由ModWorkspace持有并传入每次新建的ModIndexer;读取时按mtimeMs + size校验,未变化不重读。这是 w3x 方案在 Corona 上可用的前提(否则每次保存都重读 2.6 GB)。 - BOM 剥离:
xmlParser新增stripBom(),全量解析与浅扫描前统一剥离, 保证第一行偏移与编辑器一致。 - Include source 补全候选:DATA 目录与项目相对路径候选加入
.w3x(fileScanner),W3X/xxx.w3x、ART/xxx.w3x可补全。 - 索引报告/状态栏新增
shallowScannedFiles/shallowCacheHits统计。
验证
- 单元测试 53 → 63 全绿:新增
test/shallowScan.test.mjs(顶层资产 / Includes / xi:include / Defines / 引号内>/ CDATA / 未闭合标签 / 数值载荷不产生记录); indexer 新增 w3x 链、未知扩展名 XML 嗅探(fixture 用.dat)、二进制.dds跳过、跨重建缓存命中、 BOM 偏移断言;xmlParser 新增stripBom用例。 - AttachTest 实机:
- 首次构建 1.2 s,浅扫 62 个文件;
Model@Name=AUGunship_SKN→W3DContainer @ …\W3X\AUGUNSHIP_SKN.w3x:3;Hierarchy=AUGunship_SKL、AUGunship_FP同样解析;资产数 35,546 → 35,607。 - 第二次构建 354 ms,0 次重扫,62 次缓存命中。
- 首次构建 1.2 s,浅扫 62 个文件;
- Corona 实机(D: 盘):
- 首次构建 241 s:8,976 个文件、浅扫 4,829 个、资产 64,868 (manifest 35,322)、3 个流、183 个 Define。
- 第二次构建 38 s:0 次重扫、4,829 次缓存命中、资产数一致。
CBREFINERY_BLD正确解析到W3DHierarchy @ cb/CBRefinery_BLD.w3x:16与W3DContainer @ …:776412。
边界与后续
- 首次建索引较慢(Corona ~4 分钟,机械盘):读 2.6 GB 是下限,浅扫描本身 ~30-90 s; 后续可做并行扫描或把 w3x 顶层记录做成独立小缓存文件。
- 第二次构建仍有 ~38 s(主要是对 ~9k 文件逐文件 stat + 重建 Map,机械盘随机读); 后续可做"文件清单 + stat 快照"级缓存。
- 浅扫描对 w3x 不做 XSD 校验(编辑器特性不注册 w3x 语言);若用户手动把
*.w3x关联为 xml,完整解析仍会发生在打开的文档上(VS Code 自身行为)。 - UTF-16 XML 的 w3x 会被嗅探判定为二进制(文件头含 NUL);实测生态中不存在, 如遇可再扩展解码。
十四、问题分析(第九轮,2026-08-02):Corona 重建性能与内存
目标与基线
第八轮后 Corona 的实测基线:
| 场景 | 耗时 |
|---|---|
| 首次全量建索引 | ~180-290s(机械盘波动) |
| 信任二次构建(保存触发) | 8-21s |
| 强制 reindex | 26-38s |
| 首建后保留堆 | ~2.5GB(原因不明) |
调查 1:二次构建为何仍有 8-21s
插桩 fs.statSync 后发现:每次构建(含信任重建)都会执行约 11 万次同步 statSync,
全部来自 resolveSource 的 include 目标存在性检查(BAB 搜索路径逐 base statSync)。
机械盘上这就是 10-20s 的来源——即使文件内容全部命中缓存,include 解析仍在逐路径 stat。
修复:新增 IncludeResolveCache(workspace 级、跨重建复用):
- 键 = 当前文件目录 + source 字符串;命中直接返回,不 stat;
- 内容编辑不影响"文件是否存在",因此保存触发的重建不清除解析缓存;
- 文件创建/删除(watcher
onDidCreate/onDidDelete)与ra3modxml.reindex(强制校验)清除缓存; reference的 manifest 查找(builtmods/*.manifest存在性)同样缓存;- 配置变更(搜索路径 / builtmods 目录)时清除。
调查 2:首建后保留堆 2.5GB 是什么
逐级清空测量(构建 → 清 DOM 缓存 → 清 records → 清 walker → 清索引 Map):
DocumentCache(元素预算 1M、64 条):实际只保留 64 条 / 1788 个元素 ≈ 1MB;IndexRecordsCache(8976 个文件的紧凑记录):约 11MB;- 目录 walker:约 4MB;
- 全部索引 Map(manifests + assets + assetsById + defines + files + streams + candidates + diagnostics):约 75MB;
- 全部清空并强制 GC 后,堆回到基线(0MB 保留)。
结论:2.5GB 是构建期可回收垃圾(60MB XML 的 DOM 瞬态 + 2.6GB w3x 扫描文本/行映射
的分配压力),不是常驻泄漏;VSCode 正常 GC 压力下会回收。扩展常驻内存约为
基线 + ~100MB。顺带修复了一个潜在常驻风险:w3x 的 LineMap(Corona 全量约 700MB)
不再随 ShallowScanCache 保留,浅扫描结果以"带行号的紧凑记录"形式缓存。
其他改动
IndexRecordsCache取代 DOM 依赖的重建:新增src/indexer/records.ts, 每个文件解析时提取紧凑索引记录(顶层资产 / Define / Include / xi:include + 1-based 行号),跨重建缓存;信任重建完全不接触 DOM。DocumentCache双重淘汰:条数(LRU)+ 元素预算(超预算先淘汰最大树), 把 DOM 常驻内存封顶;DOM 只服务于按需特性(跳转精确范围等)。- 候选目录扫描并行化(
collectSourceCandidates各目录Promise.all)。 - 阶段计时:
stats.candidatesMs/stats.walkMs/stats.resolveCalls/stats.resolveCacheHits进入索引报告,便于后续定位耗时。 - 版本 0.1.0 → 0.1.1。
实测(Corona,D: 机械盘)
| 场景 | 优化前 | 优化后 |
|---|---|---|
| 首次全量建索引 | ~180-290s | ~250s(含 2.6GB w3x 读取+浅扫描、~7.6 万次冷 statSync) |
| 信任二次构建 | 8-21s | 2.0s(statSync 0 次,resolveHits 15,333) |
| 强制 reindex | 26-38s | ~5s(保留解析缓存时);显式命令会清解析缓存,约 15-25s |
| 首建后保留堆 | ~2.5GB(疑为泄漏) | 确认是可回收垃圾;常驻 ~100MB |
单元测试 63 → 73 全绿:新增 records.test.mjs(DOM→记录提取、浅扫描→记录)、
caches.test.mjs(元素预算淘汰、LRU、records/resolve 缓存),indexer 测试补充
resolve 缓存命中与"信任重建 0 次重解析"断言。
遗留
- 首次全量建索引仍受限于机械盘读 2.6GB + 冷 statSync(~4 分钟);后续可考虑 并行浅扫描(worker)或把 w3x 顶层记录持久化到磁盘缓存。
ra3modxml.reindex出于正确性会清空解析缓存(可能 ~15-25s);如接受 watcher 可靠性可改为保留。
十五、问题分析(第十轮,2026-08-03):索引未完成时的部分可用性与分阶段索引
目标
第九轮遗留:Corona 首次全量建索引约 4 分钟(2.6GB w3x 读取 + 冷 statSync),
期间插件所有功能被整体关闭(ws.index == null)。本轮让插件在索引完成前
分层可用,并把最耗时的 w3x 扫描放到后台阶段。
现状证据
diagnostics.update无索引时直接清空诊断:连 XML 语法错误、未知元素、缺 id 这类不依赖索引的检查也被关闭;- 补全的
attribute-value/content分支无索引直接返回空,但枚举 / 布尔 / Include type / 子元素补全并不需要索引(contentItems甚至没用 idx); - hover / 文档链接的 Include 解析只依赖搜索路径(settings),不需要索引;
- Find All References 完全不使用索引。
设计
- 索引状态模型:
ModIndex增加complete/phase("xml" | "art")/stale;stats增加phase/complete/deferredArtFiles/artScanMs。 - 分阶段索引:
- 阶段 A(xml):walk include 链时只登记 w3x / 嗅探 XML 文件
(
readDocument的deferArt模式),不读内容;manifest、项目 XML、 mapmetadata 全部正常索引;结束后发布不可变快照; - 阶段 B(art):按队列浅扫描 w3x、应用资产记录,并继续走 w3x 内的 Include / xi:include(此时新遇到的美术文件立即扫描,不再入队);
- 快照不可变性的关键:
addAsset在阶段 B 仍会向数组 push,所以阶段 A 发布时必须复制assets/assetsById/defines/streams.files/diagnostics(snapshotIndex深拷贝嵌套 Map/数组)。
- 阶段 A(xml):walk include 链时只登记 w3x / 嗅探 XML 文件
(
- 部分可用性(T0 解耦):
- 诊断:语法错误、未知元素/属性、缺 id、同文件重复 ID、Include 目标存在性
不再依赖索引;引用 /
$DEFINE/ 跨文件重复在索引未完成或 stale 时 “显示但标注”:code 变为unresolved-reference-indexing/undefined-define-indexing,消息追加(index incomplete — may be a false positive);跨文件重复追加(based on a partial index); - 补全:枚举 / 布尔 / Include type /
xai:joinAction/ 子元素(content) 在无索引时可用;资产 ID / define / include source 仍需索引; - hover / 文档链接:Include source /
xi:include href用 settings 搜索路径 即可解析;无索引时引用值 hover 提示 “Index is still building”。
- 诊断:语法错误、未知元素/属性、缺 id、同文件重复 ID、Include 目标存在性
不再依赖索引;引用 /
- 构建中文件被修改:
- watcher 的 change / create / delete 现在都会
scheduleRebuild()(此前 只invalidate,外部修改后没有任何东西触发重建); - 新增
InvalidationsEpoch:invalidate/invalidateExistence递增; 构建开始时记录 epoch,发布每个快照(含最终)时若 epoch 变化则标记stale(状态栏显示(stale));dirty 机制保证构建结束后立即再重建收敛; - 构建失败时保留上一个可用快照并标记 stale,不再清空索引。
- watcher 的 change / create / delete 现在都会
- stat 结构扩展:
IndexedFile.stat从{ mtimeMs, size }变为{ mtimeMs, size, birthtimeMs, ctimeMs },为磁盘持久化缓存铺路 (FAT32 的 mtime 只有 2 秒粒度,多信号可捕捉“保留 mtime 的整体替换写入”)。
验证
- 单元测试 73 → 79 全绿:
- indexer:phase-A 快照发布(XML/manifest 资产可用、美术资产缺席)、快照
不可变性、
deferredArtFiles统计、artScanMs、mtime 变更强制重读; - caches:
InvalidationsEpoch递增 / 快照语义; - completion:无索引时枚举 / 元素名 / 属性名 / 子元素补全仍工作。
- indexer:phase-A 快照发布(XML/manifest 资产可用、美术资产缺席)、快照
不可变性、
tsc+ esbuild 构建通过。
实机复现与补充修复(2026-08-03)
在 Corona 上实测新版索引器:
- 阶段 A(xml)27.0s 发布:54,283 资产(含 manifest 35,322)、8,399 文件、 4,797 个 w3x 待扫;最终 118.0s:64,868 资产、8,976 文件、4,829 浅扫 (artScanMs ≈ 91s,walkMs ≈ 26s)。
OnSeaUnitCrate.xml中的引用在阶段 A 即可解析(Locomotor=JapanEggLocomotor、CommandSet=EmptyCommandSet均有候选);LocomotorSet@Condition是 19 个枚举值的模型属性,无索引时即可补全。
由此确认两个真实体验问题并修复:
- 状态栏初始不显示 indexing:
workspaceContains激活事件在大目录上扫描 较慢,打开工作区后扩展尚未激活,看起来“没有任何功能”。activationEvents增加onLanguage:xml,打开任意 XML 文件即激活;同时各 provider 增加isRa3Workspace()守卫,避免在非 RA3 工作区误补全/误诊断。 - 索引构建中执行 “Show index report” 提示 “no index available” 有误导 →
新增
ws.isBuilding,构建中改为提示 “index is still building”。
版本 0.1.1 → 0.1.2(重新打包 ra3-mod-xml-0.1.2.vsix)。
十六、问题分析(第十一轮,2026-08-03):冷启动磁盘缓存与首建 statSync 消除
目标
第十轮后 Corona 首次建索引仍需 ~2 分钟,且每次新会话(重启 VS Code)都要 重来一遍。本轮做两件事:
- 文件集快照替代 statSync:
resolveSource的逐 basestatSync存在性 检查(Corona 首建约 11 万次)改为目录枚举建立的Set查询; - 磁盘持久化缓存:records 缓存(含 w3x 浅扫记录)跨会话落盘,冷启动 只做 stat 校验,不再重读 2.6GB 美术资产。
设计
- ExistenceSnapshot(
src/indexer/existence.ts):- 惰性按目录 readdir:只在实际查询某个候选路径时读取它的父目录
(
readdirSync+withFileTypes,不 stat 单个文件),结果按目录缓存; 不做首建前的全量递归枚举(该方案曾让 XML 阶段从 27s 涨到 45s); - 覆盖根判定:候选父目录落在根内 → 目录条目 Set 查询(
hits);落在 根外 →statSync回退(fallbacks); - 盘符根(
C:\等)与不存在的根不枚举,避免遍历整个磁盘; - 子根被更宽的根覆盖时跳过(如
sdkDir覆盖sdkDir/SageXml)。
- 惰性按目录 readdir:只在实际查询某个候选路径时读取它的父目录
(
- DiskRecordsCache(
src/indexer/diskCache.ts):- gzip JSON,原子写(tmp + rename),版本号 + identity key (项目/SDK/设置哈希,配置变化自动忽略旧缓存);
- 每条记录保存多信号 stamp
{ size, mtimeMs, birthtimeMs, ctimeMs }; - 加载时并发(32)stat 校验,不匹配/缺失丢弃,构建时重读;
- 缺失/损坏/身份不符 → 空结果,不报错。
- workspace 集成:
- 构建前若内存 records 缓存为空,从磁盘加载并校验(状态栏显示 “validating cache…”);构建成功后异步回写(entries 先快照,避免与 下一次构建竞争);
- 新命令
ra3modxml.clearCache(清内存 + 删磁盘文件 + 强制重建)与ra3modxml.showCacheReport(缓存路径/大小/加载校验统计/命中数)。
实测(Corona)
| 场景 | 结果 |
|---|---|
| 首次构建 | 118.7s(phaseA 24.0s);snapshotHits 75,926、fallbacks 0、resolveCalls 11,061 |
| 保存缓存 | 0.1s;gzip 后 651 KB |
| 加载 + stat 校验 | 0.2s(8,976 条全部校验通过) |
| 新会话二次构建 | 10.9s(w3x 重扫 0、shallowCacheHits 4,829、recordsCacheHits 4,166) |
冷启动(加载 + 校验 + 构建)约 11s,对比之前每次 ~2.5 分钟。
测试与验证
- 单元测试 79 → 90 全绿:
existence.test.mjs:覆盖/未覆盖判定、hits/fallbacks、盘符根识别、 搜索 base 枚举、resolveSource快照命中与 statSync 回退;diskCache.test.mjs:roundtrip、原子写无残留 tmp、stat 变更丢弃、 identity 不符忽略、损坏文件空结果、clear、key 稳定性;caches.test.mjs增加entries();indexer 统计断言 snapshotHits。
tsc+ esbuild +vsce package通过。
遗留
- 首次构建 phase A 现在包含快照目录枚举成本(Corona 实测约 +10-17s,后续 构建由 walker 缓存吸收);如 SDK 根目录特别大可再优化根覆盖策略。
- w3x 并行/流水线浅扫描仍未做(HDD 收益存疑,SSD 再做)。
补充(用户实测反馈,2026-08-03)
用户在 0.1.3 上删除 workspace storage 缓存后实测:
- XML 阶段约 45s(比之前 27s 多)→ 根因是磁盘缓存轮实现的全量目录枚举 快照在首建前递归枚举 SDK 根等搜索根。已改为惰性按目录 readdir,复测 phase A 24.0s,statSync 消除效果不变(75,926 次快照命中、0 回退)。
- “show index report 显示 Indexed in 1.1s、0 浅扫、8995 缓存命中”与观察
不一致 → 首建完成后又发生了一次信任重建(follow-up)。为定位触发源,
新增重建插桩:
buildCount/lastBuildTrigger(initial、save、 watcher-create/change/delete、config、reindex-command、clear-cache、 dirty-followup)+ “RA3 Mod XML” 输出通道(每次构建记录 trigger、phase A 发布时间、完成耗时);索引报告与缓存报告均显示构建序号与触发原因。
补充 2(0.1.4 日志复现,2026-08-03)
0.1.4 输出通道日志:
- build #1:phase A 31.4s、done 120.1s、stale=true;
- build #2:
dirty-followup (initial),0.6s(报告计时不一致的直接来源); - build #3/#4/#5:每 ~35s 一次
watcher-change,每次 0.5s 重建。
build #1 的 stale=true 与周期性 watcher-change 均不符合预期:说明有后台进程
在周期性触碰被监视目录(最可疑是 .git 内部文件,如后台 fetch/maintenance)。
处理:
- watcher 事件现在把触发 URI 写入输出通道(
[watcher-change] <path>), 可直接定位是哪个文件/目录在变化; - 新增
isWatcherNoisePath:路径含.git段的事件直接忽略(不 invalidate、 不标记 stale、不触发重建);单元测试 90 → 91。
版本 0.1.4 → 0.1.5。
补充 3(0.1.5 日志复现,2026-08-03)
0.1.5 日志中周期性重建已消失,但首建期间仍有一次:
[watcher-change] d:\...\corona\Data\Neutral\Crate\UnitCrate.xml.git
该文件并不存在(疑似其他 VSCode 扩展产生的瞬时临时文件),且它是
*.xml.git 文件名,不是 .git 目录段,绕过了上一轮过滤;它也导致 build #1
stale=true 并触发 follow-up。处理(按用户建议的扩展名白名单思路):
isWatcherNoisePath增加临时文件命名模式:.git/.tmp/.lock/~/.swp/.bak/.orig后缀,以及.#/.~前缀;onDidChange只响应内容相关文件:扩展名白名单(.xml/.w3x; 领域修正:RA3 合理文本格式为 xml/w3x/lua,lua 暂未索引,manifest 为*.manifest二进制,不存在.manifestxml)或已在当前索引中的文件 (ModIndexer.isIndexedFile,覆盖被嗅探为 XML 的未知扩展名);纹理等 二进制内容变更不触发重建;- 创建/删除仍对所有真实文件响应(影响 include 存在性),临时文件模式除外。
测试 91 → 92;版本 0.1.5 → 0.1.6。
遗留(此处的磁盘缓存与文件集快照已在第十一轮完成,T1 已在第十二轮完成)
- w3x 文件名启发式定向扫描(按约定后续再做)。
AssetIdList等“任意资产 ID 列表”的引用语义建模。- Find All References 目前仍是全文搜索,未走索引(对应需求 P1 高效搜索)。
十七、问题分析(第十二轮,2026-08-03):当前文档局部链与 include 逻辑树展开(T1)
目标
上一轮遗留的 T1:让不在任何全局流里的文件也能解析自身引用,并为 GameObject
内模块 id 的局部作用域(AttachModuleId / ModuleId / AutoResolveBody 等)
铺路。两件事一起做:
- T1a 文档局部 overlay:当前打开的文档(含未保存文本)自身资产 /
$DEFINE及其 include 链进入一个轻量局部索引,与全局索引叠加使用; - T1b 逻辑树展开:
xi:include按现有xpointer子集拼入当前文档的逻辑树, 使 include 进来的内容获得正确的父上下文,并让 Poid 引用能在同一 GameObject 子树内解析。
现状证据
- 全局索引只从
Data/Mod.xml与additionalmaps/mapmetadata_*.xml出发;Data/Standalone.xml这类未进流的文件,其自身GameObject/$DEFINE不会 出现在ws.index,inheritFrom、CommandSet全部无法解析; xi:include此前只做到“目标文件可索引 / 缺失可诊断”,没有拼入当前文档树; include 进来的TruckDraw等模块拿不到Draws子元素的上下文类型;isLocalReferenceAttribute对所有 Poid 属性一律“不检查、不解析”,导致 同一 GameObject 内完全可静态判断的模块引用也没有 hover / 跳转 / 补全。
设计
- 共享
xpointer.ts:localName/findXPointerContainer从 indexer 迁出, 全局索引与局部展开共用同一份 xpointer 子集实现。 logicalTree.ts:- 按解析器扁平元素表预建逻辑节点壳(保留原始
sourceFile与偏移),再按 真实根 + 容错孤儿根遍历,重建 parent/child 链; xi:include解析 href →readDom目标 → 按xpointer选择容器子节点 → 拼入逻辑父元素;目标缺失 / 环 / 超深时跳过(环与深度用 visited + 64);- 保留 xi 节点本身在
elements中,hover 仍可解释 XInclude。
- 按解析器扁平元素表预建逻辑节点壳(保留原始
localScope.ts:buildDocumentScope返回原始 parse、逻辑树、per-source LineMap、局部 overlay 与 overlay-aware merged index;- overlay 沿
<Include type="all/instance">与xi:include递归收集资产 / Define;reference指向 manifest,资产由全局索引提供,不重复解析; withLocalOverlay不复制全局 Map(Corona ~65k 资产),只把 overlay 挂到ModIndex.local,查询函数“局部优先、全局兜底”。
- workspace 集成:
getScope(document)按 URI + 文档 version + 全局索引 代次缓存;全局索引发布 / 文件关闭时失效;首个 indexer 创建前的兜底读取走fallbackRead(小 XML 直接解析)。 - features 接入:
- diagnostics / hover / navigation / completion 改从
getScope取逻辑树与 merged index;诊断只上报sourceFile === 当前文件的节点; - 引用解析 / 补全 / Define 查询支持
idx.local优先; - Poid 属性:
AttachModuleId/ModuleId/UpdateModuleId等可在最近 GameObject 子树内补全、hover、跳转;未命中不新增诊断(保守,避免 “武器引用另一 GameObject 模块”这类跨文件语义误报); - 导航对当前未保存文件优先用编辑器文本定位,再回退磁盘 DOM。
- diagnostics / hover / navigation / completion 改从
验证
- 单元测试 92 → 98 全绿:
localScope.test.mjs:未进流文件 overlay(自身资产 / Define / instance include 链)、inheritFrom 局部解析、xi:include 展开后模块上下文类型、 Poid 引用找到 include 进来的兄弟模块、局部定义优先于全局同名定义、 xi:include 环终止;completion.test.mjs:Poid 属性只补全所在 GameObject 子树内的 id。
tsc/esbuild构建通过(打包验证见版本发布步骤)。
边界与后续
- 顶层
<Include type="all">仍不并入逻辑树:它通常是独立整文件或大体积 w3x,展开收益与风险不成比例;如需要“当前文档视角的全量合并诊断”再单独做。 AttachModuleId若出现在独立WeaponTemplate(不在 GameObject 子树内), 本轮仍不解析;等真实样本确认跨文件语义后再扩展。- 未命中 Poid 不报诊断是刻意保守,后续可加配置项开启。
版本 0.1.7 → 0.1.8。
补充:构建期局部作用域闸门(2026-08-04)
用户清空缓存后在 Corona 上测得 phase A 101.2s / 完成 254.6s,明显高于
第十一轮冷建基线(phase A 约 24s / 总约 118s)。代码审查确认 T1 没有改动 indexer
的构建路径(localScope / logicalTree 不参与 build()),但自动诊断在构建中
会触发 getScope(),可能和 indexer 抢磁盘 / CPU。
修复:getScope() 在 building === true 时返回 parse-only 轻量 scope
(只解析当前文件,不沿 include 链读盘、不展开逻辑树);构建完全结束后再刷新
全量局部 scope。这样首建期间诊断 / 补全仍可用,但不会拖慢索引。索引报告与
输出通道同步增加 walk / candidates / art 耗时分解,便于下次直接定位慢在哪一段。
版本 0.1.8 → 0.1.9。
十八、问题分析(第十三轮,2026-08-04):bit-flag 列表补全的三层问题
现象
对 xs:list 枚举(bit flag,如 CreateObject@Disposition、
LocomotorTemplate@Surfaces):
- 刚打开引号时(
Disposition=")能补全; - 输入第一项后再输入空格,不触发补全;
- 已经闭合的
Disposition="RANDOM_FORCE RELATIVE_ANGLE"想在中间插入或末尾 追加 flag,不触发补全。
根因(三个独立问题)
1. 空格没有注册为补全触发字符
extension.ts 注册 provider 时只传了 < " = : . /。VS Code 只在输入 word
字符或已注册触发字符时自动弹出补全;空格两者都不是,所以“打 flag → 空格”
永远不会自动弹出。刚打开引号能补全正是因为 " 已注册。
2. 多行未闭合引号下,解析恢复丢失后续行属性
解析器对未闭合开始标签的恢复策略是“截到第一个换行”(xmlParser.ts),因此
像用户示例这样属性逐行书写的标签,<CreateObject 之后各行的 Options /
Disposition 全部丢失,startTagEnd 停在 <CreateObject 行尾。光标在后续
行时 analyzeContext 走 content 分支——实测 kind: content, attrs: []。
也就是说,按用户贴出的原文状态,当前代码其实并不会补全 Disposition;
观察到“能补全”的编辑状态里引号/> 多半已闭合。另外恢复分支没有把恢复出的
元素挂到父元素下(parent = null),即使补上上下文分析,类型解析也会退回
全局映射(CreateObject → GameObjectWeakRef)而找不到 Disposition。
3. 闭合引号内“插入/追加 flag”的体验与范围问题
- 光标在完整值末尾(
...RELATIVE_ANGLE|")时,当前 token 恰好等于完整枚举 值,startsWith过滤只剩它自己 → 看起来“没有补全”; - 替换范围 bug:引号闭合时
endOffset固定取整个值的末尾而不是光标位置。 实测光标在RANDOM_FORCE | RELATIVE_ANGLE中间时 range 为(28..42), 选中任意 flag 会删掉RELATIVE_ANGLE及之后的内容; - 光标恰好贴在闭合引号后面(
"|)时,offset <= quoteEnd把引号算进 prefix(RELATIVE_ANGLE"),返回 0 项。
修复
- 触发字符:
registerCompletionItemProvider增加" "(空格)。副作用: 属性之间按空格会弹属性名补全(加分项),文本内容按空格会弹子元素补全。 - 多行未闭合标签的补全:
- 解析器给恢复出的元素打
recoveredStartTag标记,并补挂父链 (与正常分支一致,parent.children.push+el.parent = parent); analyzeContext在光标越过startTagEnd且元素带标记时,把text.slice(tagStart, cursor)作为部分标签重新parseTag一次, 再走同一套 start-tag 分类。全局解析恢复策略不变,后续文档解析/诊断 不受影响。- 引号判定从
offset <= quoteEnd改为offset < quoteEnd:光标在闭合 引号之后进入 attribute-name 上下文。
- 解析器给恢复出的元素打
- list 补全范围与过滤(
completion.ts):- list 值替换范围改为
min(cursor, valueEnd),只覆盖当前段;非 list 保持整值替换; - 排除列表中已出现的 flag(空格后只推荐剩余项);
- 追加模式:当前段已是完整枚举值且没有其它枚举以它为前缀时,给出零宽
range、
insertText = " FLAG",可直接在闭合值末尾/列表中间追加; 前缀保护是必要的——实测 820 个 list 枚举里有 10569 对严格前缀关系 (如CAN_ATTACK→CAN_ATTACK_WALLS)。
- list 值替换范围改为
举一反三的测试(98 → 107 全绿)
xmlParser.test.mjs:恢复元素带recoveredStartTag标记、正常元素不带;context.test.mjs:用户示例的多行未闭合引号(Disposition=")仍为 attribute-value 且 prefix 正确;输入 flag + 空格后 prefix 含完整值;闭合 引号之后为 attribute-name;completion.test.mjs:- 空格后只推荐未使用的 flag(10 项,不含 GROUND),range 零宽在光标处;
- 列表中间插入不会删掉尾部 flag(range 止于光标);
- 闭合值末尾完整 flag → 追加模式(
insertText: " WATER"); CAN_ATTACK有更长变体时保持前缀过滤,不进入追加模式;- 多行未闭合
Disposition="RANDOM_FORCE经完整 provider 链路返回剩余 flag(不含 RANDOM_FORCE)。
版本 0.1.9 → 0.1.10。
十九、问题分析(第十四轮,2026-08-04):属性补全的插入体验
现象
写完一个属性值并关闭引号后,会立刻触发下一个属性的补全菜单(由 " 触发字符
带来,方便)。但按 Enter 接受补全时不会补空格,结果属性与上一个属性的闭合
引号贴在一起:
Disposition="RANDOM_FORCE RELATIVE_ANGLE"Count="$1"
连续接受会变成 ...RELATIVE_ANGLE"Count="$1"CreateFX="$1"DestinationPlayer="$1"。
另外提出两个功能请求:
- 新属性自动参考临近属性的缩进(很多 XML 的属性统一换行缩进);
- 补全的
$1占位符在允许时变成更有意义的值(数字 → 数字、角度 → 角度、 时间 → 时间),顺带提示值的格式。
修复
1. 插入布局(completion.ts 新增 attributeInsertLayout)
- 光标紧贴上一个属性的闭合引号时,插入文本前补一个空格(inline 布局);
- 临近属性是“一行一个”布局(相邻属性之间的原文含换行)时,插入
\n + 上一个属性的缩进; - 用户已经回车换行时,用临近缩进替换当前行已有的空白(对齐);
- inline 风格的文件里用户手动换行,则保留用户自己打的缩进,不强改;
xai:joinAction/xmlns:xai辅助项同样享受前缀与触发。
2. 类型化默认值(completion.ts 新增 attributeValuePlaceholder)
- 引用 / 枚举 / list / 布尔 /
inheritFrom/Include@source/id保留$1占位并自动触发值补全(这些值靠候选选择,不能瞎猜); - 标量属性优先用 XSD
default(如Count="1"),没有默认值时按类型给示例:Angle → 0d、Time → 0s、Percentage → 100%、Velocity → 0.0、SageReal/float → 0.0、SageInt/unsigned → 0; - 填了具体默认值后不再弹空的 suggest 窗口;
allowsDefine的数值属性现在也 直接给数值示例($DEFINE仍可在值内手动触发补全)。
举一反三的测试(107 → 111 全绿)
- 闭合引号后接受属性 →
Count="1"(空格),range 零宽在光标处; - 一行一个属性 →
\n Count="1"(换行 + 缩进); - 已在新行 → range 覆盖当前行空白,插入
Count="1"对齐; - 标量默认值:
Count="1"(XSD 默认)、FadeTime="0s"、DispositionAngle="0d"; - 建议类属性:
CreateFX="$1"、Options="$1"、DisabledWhileBusy="$1"均带 trigger 命令;具体默认值不带。
版本 0.1.10 → 0.1.11。
二十、问题分析(第十五轮,2026-08-04):属性补全的缩进叠加与 $1 占位符
现象
连续接受属性补全时,缩进不是稳定对齐,而是逐行递增。用户分步实测(0.1.12):
- 关闭引号 → 属性候选菜单 → 直接 Enter:第一次补全
Count="1"就落在 6 个 Tab(Disposition是 3 个 Tab); - 按空格再次触发 → Enter:
CreateFX="$1"落在 9 个 Tab; - 再 Enter(CreateFX 带触发命令,菜单自动重开):
DestinationPlayer="$1"落在 12 个 Tab。
<CreateObject
Options="IGNORE_ALL_OBJECTS"
Disposition="RANDOM_FORCE RELATIVE_ANGLE ABSOLUTE_ANGLE"
Count="1"
CreateFX="$1"
DestinationPlayer="$1"
根因
VS Code 在插入含换行的补全文本时,会给新行套用当前行的基础缩进,并与 补全文本里嵌入的缩进相加(而不是替换):
我们插入 \n + 3 Tab → 落盘 = 当前行 3 Tab + 我们 3 Tab = 6 Tab
下一行:当前行 6 Tab + 我们 3 Tab = 9 Tab
再下一行:9 + 3 = 12 Tab
与实测的 6 / 9 / 12 完全一致。关键证据是第一次补全就已多缩进:0.1.12
第一次只插入 \n + 3 个 Tab(锚点是首个独占一行的 Options),落盘却是
6 个——问题不在我们读取了谁的缩进,而在于补全文本自带的缩进被编辑器叠加。
排查过程中还发现一个放大因素并已修复:attributeInsertLayout 原先以
“最后一个被解析出的属性”为锚点,用户在自动缩进的新行上输入的半截属性名
(C、D…,hasValue = false)也会被当成锚点,把编辑器自动缩进抄进补全
行;即使叠加根因修掉,这个因素也会让缩进更容易跑偏。
修复(0.1.13)
- 换行时只插入
\n,不再嵌入缩进:编辑器自动补当前行的基础缩进 (3 Tab),叠加量为 0,后续行稳定在 3 Tab;已在新行时仍显式替换为规范 缩进(该路径不插入换行,不受叠加影响)。 - 锚点只用完整属性(
hasValue为真),并优先取第一个独占一行的完整 属性作为规范缩进;半截属性名不参与缩进计算,整行内联时才回退到最后一个 完整属性。 $1改为真正的 snippet 占位符:属性名补全统一用SnippetString, 文档中不再出现字面$1;接受补全后光标落在引号内的占位处,弹出的也是值 补全菜单。Count="1"这类具体默认值同样用 snippet(无占位符,光标落在 闭合引号后)。- 尾随空格:插入换行时,若上一个属性与光标之间只有空白(例如为触发补全 按的空格),把这段空白一并纳入替换范围,不再残留尾随空格。
- 调试日志:
ModWorkspace.log()输出到 “RA3 Mod XML” 输出通道; attribute-name 补全每次记录existing / range / prefix(JSON 转义),用于 对比“我们插入的内容”与“落盘的内容”,定位编辑器侧改写。
测试环境说明
当前单测(node + vscode stub)不能复现 VS Code 的 suggest 弹窗、snippet
缩进与 auto-indent 行为,这类问题只能靠实机 + 输出通道日志确认。后续若要
自动化,需要引入 @vscode/test-electron 做扩展宿主集成测试(本期未做,记录
为候选)。
测试(111 → 113 全绿)
- 新行上输入半截属性名(行缩进 20 个空格)→ 补全仍用规范缩进,range 覆盖 整段自动缩进与已输入字符;
- 为触发补全按的空格被新行替换吞掉,不再残留尾随空格;
- 属性名补全断言改为
insertText.value(SnippetString),换行插入断言为\nCount="1"(缩进由编辑器提供)。
版本 0.1.11 → 0.1.13(0.1.12 为中间版本,仅含锚点与尾随空格修复, 未解决叠加;0.1.13 为最终修复)。