2 Commits
Author SHA1 Message Date
lanyi 44b2eaf273 0.1.1 2026-08-02 19:11:54 +02:00
lanyi 4cc07b9abc 支持 W3X 2026-08-02 14:32:18 +02:00
27 changed files with 1949 additions and 219 deletions
+1
View File
@@ -3,3 +3,4 @@ out/
dist/ dist/
*.vsix *.vsix
OpenSAGE/ OpenSAGE/
debug.log
+13 -1
View File
@@ -18,6 +18,14 @@
- **引用导航**:从引用值(`CommandSet="..."``Weapon="..."``inheritFrom`)跳转到定义(严格按引用类型过滤,候选由 `ra3modxml.definitionMode` 控制:`all` 列出 mod + 原版、`project-only` 优先项目内定义);`Ctrl+点击` Include / `xi:include href` 打开目标文件;Find All References 搜索整个工作区;文档大纲列出顶层资产与 `$DEFINE` - **引用导航**:从引用值(`CommandSet="..."``Weapon="..."``inheritFrom`)跳转到定义(严格按引用类型过滤,候选由 `ra3modxml.definitionMode` 控制:`all` 列出 mod + 原版、`project-only` 优先项目内定义);`Ctrl+点击` Include / `xi:include href` 打开目标文件;Find All References 搜索整个工作区;文档大纲列出顶层资产与 `$DEFINE`
- **错误检查**:XML 格式错误、未知元素/属性(`xi:` 等外来命名空间不误报)、顶层资产缺 `id`、重复 ID、未解析引用(含类型不匹配)、Include / 嵌套 `xi:include` 目标找不到、`$DEFINE` 未定义。 - **错误检查**:XML 格式错误、未知元素/属性(`xi:` 等外来命名空间不误报)、顶层资产缺 `id`、重复 ID、未解析引用(含类型不匹配)、Include / 嵌套 `xi:include` 目标找不到、`$DEFINE` 未定义。
- **manifest 支持**`<Include type="reference">` 指向的 `static/global/audio.manifest`SDK `builtmods`)会被解析,manifest 中的原版资产 ID 可用于补全/悬停/导航/诊断。 - **manifest 支持**`<Include type="reference">` 指向的 `static/global/audio.manifest`SDK `builtmods`)会被解析,manifest 中的原版资产 ID 可用于补全/悬停/导航/诊断。
- **美术资产(`.w3x`**`W3X.xml` / `ART:` include 链中的 `.w3x` 模型文件会被
索引(`W3DContainer` / `W3DMesh` / `W3DHierarchy` 等顶层资产),因此
`Model@Name``Hierarchy``Mesh` 等引用可以解析、悬停与跳转。超大模型
(几十 MB 的顶点/三角形数据)采用浅扫描——只提取顶层资产记录、不建 DOM 树,
结果在 workspace 级缓存并跨重建复用,保存文件触发的重建不会重读未变化的模型文件。
- **大项目性能**:索引记录(资产 / Define / Include / 行号)与 include 解析结果
跨重建缓存,保存触发的重建零 stat、零重读(Corona 实测约 2 秒);DOM 树只按需
保留并设元素预算,避免内存膨胀。
## 使用 ## 使用
@@ -72,7 +80,11 @@ src/
manifestParser.ts .manifest 二进制解析(移植 OpenSAGE ManifestFile.cs manifestParser.ts .manifest 二进制解析(移植 OpenSAGE ManifestFile.cs
fileScanner.ts 目录扫描与 Include source 候选 fileScanner.ts 目录扫描与 Include source 候选
refs.ts 引用目标解析(按引用类型过滤) refs.ts 引用目标解析(按引用类型过滤)
indexer.ts 工作区索引器(后台、缓存、增量重建 shallowScan.ts .w3x 等大体积美术资产顶层浅扫描(纯 TS,不建 DOM
records.ts 每文件紧凑索引记录(资产/Define/Include/xi + 行号)
caches.ts 跨重建持久缓存(DocumentCache / IndexRecordsCache /
IncludeResolveCache
indexer.ts 工作区索引器(后台、缓存、记录驱动重建)
features/ completion / hover / navigation / diagnostics / semanticTokens features/ completion / hover / navigation / diagnostics / semanticTokens
syntaxes/ TextMate 注入语法 syntaxes/ TextMate 注入语法
tools/ XSD → 模型、AssetType 枚举提取 tools/ XSD → 模型、AssetType 枚举提取
-1
View File
@@ -1 +0,0 @@
[0801/032923.150:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: 拒绝访问。 (0x5)
+172
View File
@@ -590,3 +590,175 @@ token:文档出现解析错误时,由插件用自己的容错解析树继续
配色,可能与 TextMate XML 配色略有差异——只在打字过程中出现,可接受; 配色,可能与 TextMate XML 配色略有差异——只在打字过程中出现,可接受;
- 未实现 `provideDocumentSemanticTokensEdits`(delta 版本),每次全量计算;单文件 - 未实现 `provideDocumentSemanticTokensEdits`(delta 版本),每次全量计算;单文件
解析在 KB 级,开销可忽略。 解析在 KB 级,开销可忽略。
---
## 十三、问题分析(第八轮,2026-08-02):`.w3x` 美术资产未被索引(AUGunship_SKN
### 问题
AttachTest `Harbinger Gunship\GameObject.xml` 中 `<Model Name="AUGunship_SKN"/>`
报两条错误:
1. Problems 面板诊断:`Unresolved reference "AUGunship_SKN" (not found in the current index)`
`unresolved-reference`,来自 `features/diagnostics.ts`);
2. 悬停提示:`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` / `.manifestxml`。
### 关键事实(实测)
1. **`.w3x` 是文本 XML**:文件头即 `<?xml ...?>` + `<AssetDeclaration>`,内容为
`<W3DContainer id="AUGUNSHIP_SKN" Hierarchy="AUGUNSHIP_SKL">` + `<SubObject>` 子树。
附带 UTF-8 BOM(抽样 292 个 Corona w3x0 个 UTF-1640 个带 BOM)。
2. **w3x 通过 `<Include type="all">` 链进索引**`Mod.xml → … → W3X.xml → W3X/*.w3x`
也常用 `ART:xxx.w3x`SDK 根、项目 `Art` 等搜索路径)。索引器此前只把 w3x 登记为
文件(`stream.files`),从不解析,因此其顶层资产不在 `assetsById` 中。
3. **类型匹配本来是对的**`Model@Name` 的 refType 是 `BaseRenderAssetType`
`W3DContainer → BaseRenderAssetType` 可赋值(`isAssignableTo` = true)。缺的只是定义。
4. **manifest / SageXml 支持早已存在**(第二轮/第三轮),但该资产是 mod 自己的 w3x,
不在 `builtmods/*.manifest` 也不在 SageXml——hover 的 "may exist in a compiled manifest
or vanilla data" 只是通用兜底文案。
### 规模调查(CoronaD: 盘已连接)
- **3788 个 w3x,共 2.64 GB**;最大 22.8 MB163 个超过原 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 MB55%)。
- 全量建 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,保留内存近似为零。
- **读整个文件不可避免,但建树不是**:浅扫描仍然是线性扫描全文(要知道顶层元素边界
就必须扫完),只是不分配子节点对象——所以"事后优化"完全有意义,且是必要项。
### 修复
1. **新增浅扫描模块** `src/indexer/shallowScan.ts`(纯 TS):
单次线性扫描,只提取顶层元素 `name + id`(含精确 offset)、顶层 `<Includes>`
的 `Include@type/source`、任意层级 `<xi:include>` 的 `href/xpointer`、`<Defines>`
常量;注释 / CDATA / DOCTYPE / PI 整体跳过;属性解析兼容引号内 `>` 与 `/`。
2. **索引模式三分**`.xml`/`.manifestxml` 全量解析(4 MB 上限不变);
`.w3x` 一律浅扫描;未知扩展名先嗅探文件头(512 字节,BOM/空白后以 `<` 开头且无
NUL 字节 → 按 XML 浅扫描,否则按二进制仅登记)。w3x 自身的 `<Includes>` 与
嵌套 `xi:include` 会继续被 walk(BAB 语义)。
3. **持久缓存**`DocumentCache` / `ShallowScanCache` 移到 `src/indexer/caches.ts`
由 `ModWorkspace` 持有并传入每次新建的 `ModIndexer`;读取时按 `mtimeMs + size`
校验,未变化不重读。这是 w3x 方案在 Corona 上可用的前提(否则每次保存都重读 2.6 GB)。
4. **BOM 剥离**`xmlParser` 新增 `stripBom()`,全量解析与浅扫描前统一剥离,
保证第一行偏移与编辑器一致。
5. **Include source 补全候选**:DATA 目录与项目相对路径候选加入 `.w3x`
`fileScanner`),`W3X/xxx.w3x`、`ART/xxx.w3x` 可补全。
6. 索引报告/状态栏新增 `shallowScannedFiles` / `shallowCacheHits` 统计。
### 验证
- 单元测试 **53 → 63 全绿**:新增 `test/shallowScan.test.mjs`(顶层资产 / Includes /
xi:include / Defines / 引号内 `>` / CDATA / 未闭合标签 / 数值载荷不产生记录);
indexer 新增 w3x 链、`.w3d` 嗅探、二进制 `.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 次缓存命中。
- Corona 实机(D: 盘):
- 首次构建 241 s8,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
- 全部索引 Mapmanifests + 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` 保留,浅扫描结果以"带行号的紧凑记录"形式缓存。
### 其他改动
1. **`IndexRecordsCache` 取代 DOM 依赖的重建**:新增 `src/indexer/records.ts`
每个文件解析时提取紧凑索引记录(顶层资产 / Define / Include / xi:include +
1-based 行号),跨重建缓存;信任重建完全不接触 DOM。
2. **`DocumentCache` 双重淘汰**:条数(LRU)+ 元素预算(超预算先淘汰最大树),
把 DOM 常驻内存封顶;DOM 只服务于按需特性(跳转精确范围等)。
3. **候选目录扫描并行化**`collectSourceCandidates` 各目录 `Promise.all`)。
4. **阶段计时**`stats.candidatesMs` / `stats.walkMs` / `stats.resolveCalls` /
`stats.resolveCacheHits` 进入索引报告,便于后续定位耗时。
5. 版本 0.1.0 → **0.1.1**。
### 实测(CoronaD: 机械盘)
| 场景 | 优化前 | 优化后 |
|---|---|---|
| 首次全量建索引 | ~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
可靠性可改为保留。
+50 -8
View File
@@ -85,7 +85,11 @@ src/
manifestParser.ts .manifest 二进制解析 + 类型/ID 推导(纯 TS) manifestParser.ts .manifest 二进制解析 + 类型/ID 推导(纯 TS)
fileScanner.ts 目录遍历缓存 + Include source 候选收集 fileScanner.ts 目录遍历缓存 + Include source 候选收集
refs.ts 引用目标解析(按 refType / isRef / inheritFrom 过滤,纯 TS refs.ts 引用目标解析(按 refType / isRef / inheritFrom 过滤,纯 TS
indexer.ts 工作区索引器(资产/Define/流/manifest 合并,LRU 解析缓存 shallowScan.ts 大体积美术资产(.w3x 等)顶层浅扫描(纯 TS,不建 DOM
records.ts 每文件紧凑索引记录(资产/Define/Include/xi + 行号)
caches.ts 跨重建持久缓存(DocumentCache / IndexRecordsCache /
IncludeResolveCache
indexer.ts 工作区索引器(资产/Define/流/manifest/w3x 合并)
types.ts 共享类型 types.ts 共享类型
features/ features/
completion.ts 补全 provider(元素/属性/值,上下文感知;xs:list 多值按当前段过滤) completion.ts 补全 provider(元素/属性/值,上下文感知;xs:list 多值按当前段过滤)
@@ -109,6 +113,11 @@ test/
1. **语言激活范围**:不劫持 `*.xml`。通过 `workspaceContains:**/Data/Mod.xml``**/*.babproj` 激活;语法高亮为**纯注入** grammar(不声明 `language`,避免覆盖内置 XML 语法)。 1. **语言激活范围**:不劫持 `*.xml`。通过 `workspaceContains:**/Data/Mod.xml``**/*.babproj` 激活;语法高亮为**纯注入** grammar(不声明 `language`,避免覆盖内置 XML 语法)。
2. **索引范围与默认值**:索引“项目 Data + additionalmaps + 沿 include 可达的 SageXml 原版源码”;SDK 路径默认 `C:\Apps\RA3-MODSDK-X`(可配置)。`reference` include 解析为 `builtmods` 下对应 manifest(惰性解析、按文件缓存),manifest 缺失/无效时回退到占位 XML。 2. **索引范围与默认值**:索引“项目 Data + additionalmaps + 沿 include 可达的 SageXml 原版源码”;SDK 路径默认 `C:\Apps\RA3-MODSDK-X`(可配置)。`reference` include 解析为 `builtmods` 下对应 manifest(惰性解析、按文件缓存),manifest 缺失/无效时回退到占位 XML。
**美术资产(.w3x**`<Include type="all">` / `ART:` 指向的 `.w3x`(及内容嗅探为
XML 的未知扩展名文件)按其顶层资产入库(`W3DContainer` / `W3DMesh` /
`W3DHierarchy` / `W3DCollisionBox` 等),使 `Model@Name``Hierarchy``Mesh`
等引用可解析;大模型文件**浅扫描**(不建 DOM),结果缓存在 workspace 级、
跨重建复用(详见设计决策 14)。
3. **manifest 资产建模**:类型优先用哈希表,未知时从名称前缀推导;可引用 ID 取最后冒号段;类型名统一走大小写规范化(`W3dContainer``W3DContainer`),类型匹配严格遵循 XSD 继承链。 3. **manifest 资产建模**:类型优先用哈希表,未知时从名称前缀推导;可引用 ID 取最后冒号段;类型名统一走大小写规范化(`W3dContainer``W3DContainer`),类型匹配严格遵循 XSD 继承链。
4. **上下文感知元素类型**:同名元素按父元素类型解析(`resolveElementType` 沿解析树逐层 `childTypeOf`,失败回退全局映射),保证 `<Weapon>` 等元素的属性/引用判定正确。 4. **上下文感知元素类型**:同名元素按父元素类型解析(`resolveElementType` 沿解析树逐层 `childTypeOf`,失败回退全局映射),保证 `<Weapon>` 等元素的属性/引用判定正确。
5. **引用判定与解析**`refType``isRef` 均视为引用;带 `refType` 时严格按类型过滤(同名 ID 不串类型);`inheritFrom` 按可继承类型过滤。**局部作用域例外**(`isLocalReferenceAttribute`):`id` 是元素自身的定义点——无 refType 或 refType 与自身类型兼容时不检查、不解析(`RoadObject@id→Road` 这类跨类型 id 引用保留检查);Poid 类型属性是管线局部引用,全局索引无法判定,不检查、不解析。 5. **引用判定与解析**`refType``isRef` 均视为引用;带 `refType` 时严格按类型过滤(同名 ID 不串类型);`inheritFrom` 按可继承类型过滤。**局部作用域例外**(`isLocalReferenceAttribute`):`id` 是元素自身的定义点——无 refType 或 refType 与自身类型兼容时不检查、不解析(`RoadObject@id→Road` 这类跨类型 id 引用保留检查);Poid 类型属性是管线局部引用,全局索引无法判定,不检查、不解析。
@@ -128,6 +137,33 @@ test/
XML 编辑器皆然);扩展注册 `DocumentSemanticTokensProvider`,仅当解析报错时用 XML 编辑器皆然);扩展注册 `DocumentSemanticTokensProvider`,仅当解析报错时用
语义 token 覆盖标签名 / 属性名 / 属性值(标准 token 类型 `type` / `property` / 语义 token 覆盖标签名 / 属性名 / 属性值(标准 token 类型 `type` / `property` /
`string`,主题自带配色)。合法文件返回空,观感与纯 TextMate 完全一致。 `string`,主题自带配色)。合法文件返回空,观感与纯 TextMate 完全一致。
14. **大体积美术资产浅扫描 + 跨重建持久缓存**(第八轮,2026-08-02):
- 实测 Corona3788 个 w3x / 2.64 GB163 个超过 4 MB,最大 22.8 MB;大文件是
`W3DHierarchy` + 若干 `W3DMesh``Vertices/V``Triangles/T` 等 unbounded 数值
载荷),顶层记录通常只有几个到二十几个。
- 全量解析内存放大约 17 倍(6.3 MB 文本 → +109 MB DOM),不可接受;`scanXmlShallow`
单次线性扫描只提取顶层 `name+id``<Includes>``<xi:include>``<Defines>`
22 MB 文件 ~600 ms、保留内存≈0。
- 索引按扩展名三分:`.xml`/`.manifestxml` 全量解析(4 MB 上限不变);`.w3x` 浅扫描;
未知扩展名嗅探文件头(`<` 开头、无 NUL)决定按 XML 浅扫描或二进制登记。
- `DocumentCache` / `ShallowScanCache``ModWorkspace` 持有,每次重建传入新的
`ModIndexer`;按 `mtimeMs + size` 校验,未变化不重读。Corona 第二次构建
w3x 重扫数为 0(4,829 次缓存命中)。
- 读取整个文件不可避免(顶层边界需要全量扫描),但建 DOM 不是;优化的是
"不分配子节点对象"与"跨重建不重读",两者叠加后方案可行。
15. **重建零 stat + 记录驱动索引**(第九轮,2026-08-02v0.1.1):
- 插桩发现每次重建(含信任重建)都有约 11 万次同步 `statSync`
`resolveSource` 的 include 存在性检查),机械盘上占 10-20s;
`IncludeResolveCache` 按(目录 + source)缓存解析结果,内容编辑不清、
创建/删除文件与强制 reindex 才清。信任重建 statSync 降到 0。
- `IndexRecordsCache` 缓存每文件紧凑索引记录(顶层资产 / Define / Include /
xi:include + 1-based 行号),信任重建完全不接触 DOM;`DocumentCache`
双重淘汰(条数 LRU + 元素预算,超预算先淘汰最大树)把 DOM 常驻内存封顶。
- w3x 缓存不再保留 LineMapCorona 全量约 700MB),浅扫描直接产出带行号的记录。
- 实测:Corona 信任二次构建 21s → **2.0s**statSync 0);首建后 2.5GB
堆保留确认为构建期可回收垃圾,常驻 ~100MB;强制 reindex ~5-25s。
- 候选目录扫描并行化;`stats` 新增 `candidatesMs` / `walkMs` / `resolveCalls` /
`resolveCacheHits` 供索引报告定位耗时。
## 三、实施步骤 ## 三、实施步骤
@@ -136,22 +172,28 @@ test/
3. [x] 生成模型:`schema-model.json`XSD295 顶层元素 / 1851 类型)与 `asset-types.json`79 个 AssetType 哈希)。 3. [x] 生成模型:`schema-model.json`XSD295 顶层元素 / 1851 类型)与 `asset-types.json`79 个 AssetType 哈希)。
4. [x] 纯 TS 核心:include 解析、manifest 解析、XML 解析封装、索引器、引用解析。 4. [x] 纯 TS 核心:include 解析、manifest 解析、XML 解析封装、索引器、引用解析。
5. [x] 功能层:补全、hover、导航、诊断、大纲、高亮 grammar。 5. [x] 功能层:补全、hover、导航、诊断、大纲、高亮 grammar。
6. [x] 单测(fixture Mod53 个用例全绿:含 xs:list 枚举、未闭合引号恢复、list 多值 6. [x] 单测(fixture Mod73 个用例全绿:含 xs:list 枚举、未闭合引号恢复、list 多值
分段、带 vscode stub 的补全集成、语义 token 兜底)+ 编译 + `vsce package` 打包 分段、带 vscode stub 的补全集成、语义 token 兜底)+ 编译 + `vsce package` 打包
ra3-mod-xml-0.1.0.vsix,约 495KB)。 ra3-mod-xml-0.1.1.vsix,约 499KB)。
7. [x] 在 AttachTest / GenEvoTest / Corona 上做冒烟验证,并按真实项目反馈修复问题(详见 `docs/analysis-issues.md` 轮分析)。 7. [x] 在 AttachTest / GenEvoTest / Corona 上做冒烟验证,并按真实项目反馈修复问题(详见 `docs/analysis-issues.md` 轮分析)。
8. [x] w3x 美术资产索引(第八轮):新增 `shallowScan.ts` / `caches.ts`w3x 与内容嗅探
XML 走浅扫描,Include source 补全候选加入 w3x,BOM 剥离,缓存跨重建持久化;
AttachTest 报错场景(`AUGunship_SKN`)修复,Corona 首次/二次构建实测验证。
9. [x] Corona 性能与内存优化(第九轮,v0.1.1):`records.ts` 记录驱动索引、
`IncludeResolveCache` 零 stat 重建、DOM 元素预算淘汰、w3x LineMap 移除、
候选并行扫描、阶段计时;Corona 信任重建 2.0s;确认首建 2.5GB 为可回收垃圾。
## 四、验证结果(实测) ## 四、验证结果(实测)
| 项目 | 规模 | 索引耗时 | 资产数 | 说明 | | 项目 | 规模 | 索引耗时 | 资产数 | 说明 |
|---|---|---|---|---| |---|---|---|---|---|
| AttachTest | 71 文件 | ~0.6s | 35,546manifest 35,322 | 2 个流(static + mapmetadata | | AttachTest | 88 文件 | ~1.2s | 35,607manifest 35,322w3x 浅扫 62 | 2 个流(static + mapmetadata;二次构建 354ms / 62 缓存命中 |
| GenEvoTest | 24 文件 | ~1.8s | 35,392manifest 35,322 | 项目 IDalliedmcv 等)正确收录 | | GenEvoTest | 66 文件 | 首次 ~2.6s / 二次 ~0.4s | 35,502manifest 35,322w3x 浅扫 38 | 2 个流、73 个 Define;二次构建 0 重扫 / 38 缓存命中 |
| Corona | 3,448 文件(+ 非 XML 资产路径) | ~54.5s | 55,305manifest 35,322 | 3 个流、183 个 Define、0 诊断 | | Corona | 8,976 文件 | 首次 ~250s / 信任二次 ~2s / 强制 ~5-25s | 64,868manifest 35,322w3x 浅扫 4,829 | 3 个流、183 个 Define、0 诊断;二次构建 statSync 0、resolveHits 15,333 |
单元测试覆盖:XML 解析(自闭合/容错/偏移/未闭合引号行尾恢复)、补全上下文(未闭合引号仍为 attribute-value、list 多值分段)、补全集成(vscode stub 下 `LocomotorTemplate@Surfaces` 未闭合引号枚举补全、空格后第二段过滤与替换范围)、语义 token(标签/属性/值范围、合法文档返回空、malformed 返回兜底 token)、include 解析(BAB 顺序、SDK 根优先于 SageXml)、manifest 二进制解析(合成 v5 样本、类型/ID 推导)、索引器(资产/Define/流/缺失 include/嵌套 xi:include)、XSD 模型(上下文类型、`childTypeOf`、大小写规范化、属性级 refType、外来命名空间判定、`xs:list` 枚举继承与 `isList` 标记)、引用过滤(`Weapon="X"` 只跳 `WeaponTemplate`、模块 `id` 定义点、Poid 局部引用、`xi:include` 不校验、`Side="Allies"` 命中 manifest 的 `PlayerTemplate`)。 单元测试覆盖:XML 解析(自闭合/容错/偏移/未闭合引号行尾恢复)、补全上下文(未闭合引号仍为 attribute-value、list 多值分段)、补全集成(vscode stub 下 `LocomotorTemplate@Surfaces` 未闭合引号枚举补全、空格后第二段过滤与替换范围)、语义 token(标签/属性/值范围、合法文档返回空、malformed 返回兜底 token)、include 解析(BAB 顺序、SDK 根优先于 SageXml)、manifest 二进制解析(合成 v5 样本、类型/ID 推导)、索引器(资产/Define/流/缺失 include/嵌套 xi:include)、XSD 模型(上下文类型、`childTypeOf`、大小写规范化、属性级 refType、外来命名空间判定、`xs:list` 枚举继承与 `isList` 标记)、引用过滤(`Weapon="X"` 只跳 `WeaponTemplate`、模块 `id` 定义点、Poid 局部引用、`xi:include` 不校验、`Side="Allies"` 命中 manifest 的 `PlayerTemplate`)。
> 注:`D:\Mods\CoronaMod` 位于移动硬盘,当前未连接;GenEvoTest / Corona 的回归需在 D: 盘可用时补跑(用户会另行通知) > 注:D: 盘移动硬盘已恢复连接;Corona 已在第八 / 九轮按上述新数据回归
## 五、假设与开放问题 ## 五、假设与开放问题
+9
View File
@@ -70,6 +70,15 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
- `TypeId` 哈希 → 类型名的映射来自 OpenSAGE 的 `AssetType` 枚举(本工作区可提取); - `TypeId` 哈希 → 类型名的映射来自 OpenSAGE 的 `AssetType` 枚举(本工作区可提取);
- 资产名与源文件名各自存放在独立的空字符结尾字符串缓冲区中。 - 资产名与源文件名各自存放在独立的空字符结尾字符串缓冲区中。
**补充(美术资产 `.w3x` 索引)**
- `W3X.xml` / `ART:` include 链中的 `.w3x` 是文本 XML(建模工具导出,BAB 同样按
XML 编译),其顶层资产(`W3DContainer` / `W3DMesh` / `W3DHierarchy` 等)应参与
补全、悬停、导航与诊断——`Model@Name``Hierarchy``Mesh` 等引用依赖这些定义;
- 大模型文件(实测 Corona 最大 22.8 MB,顶点/三角形数据占大头)采用**顶层浅扫描**
(不建 DOM 树),结果在 workspace 级缓存并跨重建复用,避免每次保存都重读整个
美术资产目录(Corona 约 2.6 GB);索引记录与 include 解析结果同样跨重建缓存,
保存触发的重建零 stat、零重读(Corona 实测约 2 秒)。
### P1:非近期目标(本期不做,但预留扩展点) ### P1:非近期目标(本期不做,但预留扩展点)
6. **高效搜索**Mod 项目巨大(Corona 约 7500 个 XML、38MB)时直接全文搜索很慢,需要一个高效的 XML 内容索引机制。 6. **高效搜索**Mod 项目巨大(Corona 约 7500 个 XML、38MB)时直接全文搜索很慢,需要一个高效的 XML 内容索引机制。
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ra3-mod-xml", "name": "ra3-mod-xml",
"version": "0.1.0", "version": "0.1.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ra3-mod-xml", "name": "ra3-mod-xml",
"version": "0.1.0", "version": "0.1.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-xml-parser": "^4.5.0" "fast-xml-parser": "^4.5.0"
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ra3-mod-xml", "name": "ra3-mod-xml",
"displayName": "RA3 Mod XML", "displayName": "RA3 Mod XML",
"description": "Red Alert 3 Mod XML tooling: syntax highlighting, completions, reference navigation and diagnostics for SAGE/BinaryAssetBuilder XML.", "description": "Red Alert 3 Mod XML tooling: syntax highlighting, completions, reference navigation and diagnostics for SAGE/BinaryAssetBuilder XML.",
"version": "0.1.0", "version": "0.1.1",
"publisher": "ra3-mod-xml", "publisher": "ra3-mod-xml",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
+9 -3
View File
@@ -109,18 +109,24 @@ export function activate(context: vscode.ExtensionContext): void {
context.subscriptions.push( context.subscriptions.push(
vscode.workspace.onDidSaveTextDocument((doc) => { vscode.workspace.onDidSaveTextDocument((doc) => {
if (doc.languageId !== "xml") return; if (doc.languageId !== "xml") return;
ws.invalidate(doc.uri.fsPath);
ws.scheduleRebuild(); ws.scheduleRebuild();
void diagnostics.update(doc); void diagnostics.update(doc);
}), }),
); );
context.subscriptions.push( context.subscriptions.push(
vscode.workspace.onDidChangeConfiguration((e) => { vscode.workspace.onDidChangeConfiguration((e) => {
if (e.affectsConfiguration("ra3modxml")) ws.scheduleRebuild(); if (e.affectsConfiguration("ra3modxml")) {
// Search paths / builtmods locations may have changed: cached include
// resolutions and manifest lookups are no longer valid.
ws.invalidateExistence();
ws.scheduleRebuild();
}
}), }),
); );
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.reindex", () => ws.rebuild()), vscode.commands.registerCommand("ra3modxml.reindex", () => ws.rebuild(true)),
); );
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand("ra3modxml.openIndexReport", () => { vscode.commands.registerCommand("ra3modxml.openIndexReport", () => {
@@ -135,7 +141,7 @@ export function activate(context: vscode.ExtensionContext): void {
void vscode.window.showInformationMessage( void vscode.window.showInformationMessage(
`RA3 Mod XML index\n` + `RA3 Mod XML index\n` +
`Project: ${s.projectDir}\n` + `Project: ${s.projectDir}\n` +
`Files: ${s.indexedFiles} (${s.parsedFiles} parsed)\n` + `Files: ${s.indexedFiles} (${s.parsedFiles} parsed, ${s.shallowScannedFiles} shallow-scanned, ${s.shallowCacheHits + s.recordsCacheHits} cache hits)\n` +
`Assets: ${s.assetCount} (${s.manifestAssetCount} from ${s.manifestFiles} manifests)\n` + `Assets: ${s.assetCount} (${s.manifestAssetCount} from ${s.manifestFiles} manifests)\n` +
`Defines: ${s.defineCount} · Streams: ${s.streams} · Candidates: ${s.sourceCandidates}\n` + `Defines: ${s.defineCount} · Streams: ${s.streams} · Candidates: ${s.sourceCandidates}\n` +
`Indexed in ${(s.elapsedMs / 1000).toFixed(1)}s`, `Indexed in ${(s.elapsedMs / 1000).toFixed(1)}s`,
+226
View File
@@ -0,0 +1,226 @@
/**
* Caches shared by the indexer.
*
* The workspace owns one instance of each cache and passes them into every
* ModIndexer, so a rebuild (which creates a fresh indexer) does not re-read
* files whose stat (mtime/size) is unchanged. This is what makes indexing
* large art-asset corpora (Corona: ~3800 .w3x files, 2.6 GB) practical:
* after the first build, save-triggered rebuilds only re-scan files that
* actually changed.
*/
import { resolve } from "node:path";
import type { IndexedFile, ParsedFile } from "./types";
import type { IndexRecords } from "./records";
import type { ResolveResult } from "./includeResolver";
/** Case-insensitive absolute path key. */
export function normKey(path: string): string {
return resolve(path).toLowerCase();
}
/**
* LRU cache for fully parsed XML documents.
*
* Parse trees of huge mods can be memory-heavy (~17x the source text), so
* retention is bounded twice: by entry count (least-recently-used eviction)
* and by a total element budget (largest trees are evicted first). Evicted
* entries are re-read from disk on demand.
*/
export class DocumentCache {
private map = new Map<string, ParsedFile>();
private totalElements = 0;
constructor(
private capacity = 64,
private elementBudget = 2_000_000,
) {}
get(path: string): ParsedFile | undefined {
const key = normKey(path);
const hit = this.map.get(key);
if (!hit) return undefined;
this.map.delete(key);
this.map.set(key, hit);
return hit;
}
/** Number of cached documents (for diagnostics). */
get size(): number {
return this.map.size;
}
/** Total elements held by cached parse trees (for diagnostics). */
get elements(): number {
return this.totalElements;
}
set(parsed: ParsedFile): void {
const key = normKey(parsed.file.path);
const prev = this.map.get(key);
if (prev) this.totalElements -= elementCount(prev);
this.map.delete(key);
this.map.set(key, parsed);
this.totalElements += elementCount(parsed);
this.evict();
}
invalidate(path: string): void {
const key = normKey(path);
const prev = this.map.get(key);
if (prev) this.totalElements -= elementCount(prev);
this.map.delete(key);
}
clear(): void {
this.map.clear();
this.totalElements = 0;
}
private evict(): void {
while (this.map.size > this.capacity || this.totalElements > this.elementBudget) {
if (this.map.size === 0) break;
if (this.map.size > this.capacity) {
// Over capacity: drop the least recently used entry.
const oldest = this.map.keys().next().value;
if (oldest === undefined) break;
this.remove(oldest);
} else {
// Over the element budget: drop the largest tree, which frees the
// most memory per eviction.
let largestKey: string | undefined;
let largest = -1;
for (const [key, value] of this.map) {
const n = elementCount(value);
if (n > largest) {
largest = n;
largestKey = key;
}
}
if (largestKey === undefined || largest <= 0) break;
this.remove(largestKey);
}
}
}
private remove(key: string): void {
const prev = this.map.get(key);
if (prev) this.totalElements -= elementCount(prev);
this.map.delete(key);
}
}
function elementCount(parsed: ParsedFile): number {
return parsed.parse?.elements.length ?? 0;
}
export interface IndexRecordsCacheEntry {
stat: IndexedFile["stat"];
records: IndexRecords;
/** "shallow" for art-asset scans (.w3x), "full" for parsed XML. */
kind: "shallow" | "full";
}
/**
* Cache for per-file index records (top-level assets, defines, includes,
* xi:include targets). Records are tiny compared to DOM trees or line maps of
* multi-megabyte model files, so the capacity comfortably covers a whole mod
* (Corona: ~9k files) and rebuilds never re-read unchanged files.
*/
export class IndexRecordsCache {
private map = new Map<string, IndexRecordsCacheEntry>();
constructor(private capacity = 16384) {}
get(path: string): IndexRecordsCacheEntry | undefined {
const key = normKey(path);
const hit = this.map.get(key);
if (!hit) return undefined;
this.map.delete(key);
this.map.set(key, hit);
return hit;
}
/** Number of cached record sets (for diagnostics). */
get size(): number {
return this.map.size;
}
set(path: string, entry: IndexRecordsCacheEntry): void {
const key = normKey(path);
this.map.delete(key);
this.map.set(key, entry);
if (this.map.size > this.capacity) {
const oldest = this.map.keys().next().value;
if (oldest !== undefined) this.map.delete(oldest);
}
}
invalidate(path: string): void {
this.map.delete(normKey(path));
}
clear(): void {
this.map.clear();
}
}
/**
* Cache for Include/@source (and xi:include/@href) resolution results.
*
* Resolving a source performs synchronous statSync existence checks against
* every search base; a Corona build issues ~110k of them (tens of seconds on
* a mechanical drive). Content edits never change *existence*, so this cache
* survives content rebuilds and is only cleared when files are created or
* deleted (or on a forced reindex).
*/
export class IncludeResolveCache {
private map = new Map<string, ResolveResult>();
private manifestMap = new Map<string, string | null>();
constructor(private capacity = 262144) {}
get(key: string): ResolveResult | undefined {
const hit = this.map.get(key);
if (!hit) return undefined;
this.map.delete(key);
this.map.set(key, hit);
return hit;
}
set(key: string, result: ResolveResult): void {
this.map.delete(key);
this.map.set(key, result);
if (this.map.size > this.capacity) {
const oldest = this.map.keys().next().value;
if (oldest !== undefined) this.map.delete(oldest);
}
}
getManifest(key: string): string | null | undefined {
const hit = this.manifestMap.get(key);
if (hit === undefined) return undefined;
this.manifestMap.delete(key);
this.manifestMap.set(key, hit);
return hit;
}
setManifest(key: string, path: string | null): void {
this.manifestMap.delete(key);
this.manifestMap.set(key, path);
if (this.manifestMap.size > this.capacity) {
const oldest = this.manifestMap.keys().next().value;
if (oldest !== undefined) this.manifestMap.delete(oldest);
}
}
clear(): void {
this.map.clear();
this.manifestMap.clear();
}
/** Number of cached resolutions (for diagnostics). */
get size(): number {
return this.map.size;
}
}
+27 -13
View File
@@ -1,7 +1,18 @@
import { readdir, stat } from "node:fs/promises"; import { readdir, stat } from "node:fs/promises";
import { join, relative, resolve } from "node:path"; import { extname, join, relative, resolve } from "node:path";
import type { FileWalker, SourceCandidate } from "./types"; import type { FileWalker, SourceCandidate } from "./types";
/**
* Extensions offered as Include/@source candidates in DATA directories:
* regular XML plus art-asset XML (.w3x) that mods include via W3X.xml hubs.
* ART/AUDIO directories already list every file.
*/
const DATA_CANDIDATE_EXTENSIONS = new Set([".xml", ".w3x"]);
function isDataCandidate(path: string): boolean {
return DATA_CANDIDATE_EXTENSIONS.has(extname(path).toLowerCase());
}
/** /**
* Recursive file list walker with a simple directory-mtime cache. Used to * Recursive file list walker with a simple directory-mtime cache. Used to
* enumerate candidate files for Include/@source completion. Directory scans * enumerate candidate files for Include/@source completion. Directory scans
@@ -76,29 +87,32 @@ export async function collectSourceCandidates(
out.push({ source, path: resolve(path), prefix, baseDir: resolve(baseDir) }); out.push({ source, path: resolve(path), prefix, baseDir: resolve(baseDir) });
}; };
for (const dir of dataDirs) { // List directories in parallel; the walker caches each directory by its
const files = await walker.listFiles(dir); // mtime, so rebuilds after the first are still cheap.
const [dataLists, artLists, audioLists] = await Promise.all([
Promise.all(dataDirs.map(async (dir) => ({ dir, files: await walker.listFiles(dir) }))),
Promise.all(artDirs.map(async (dir) => ({ dir, files: await walker.listFiles(dir) }))),
Promise.all(audioDirs.map(async (dir) => ({ dir, files: await walker.listFiles(dir) }))),
]);
for (const { dir, files } of dataLists) {
for (const f of files) { for (const f of files) {
if (!f.toLowerCase().endsWith(".xml")) continue; if (!isDataCandidate(f)) continue;
add(f, dir, "DATA"); add(f, dir, "DATA");
} }
if (samePath(dir, projectDataDir)) { if (samePath(dir, projectDataDir)) {
// Also offer project-relative paths (what Mod.xml itself uses). // Also offer project-relative paths (what Mod.xml itself uses).
for (const f of files) { for (const f of files) {
if (f.toLowerCase().endsWith(".xml")) add(f, projectDataDir, null); if (isDataCandidate(f)) add(f, projectDataDir, null);
} }
} }
} }
for (const dir of artDirs) { for (const { dir, files } of artLists) {
for (const f of await walker.listFiles(dir)) { for (const f of files) add(f, dir, "ART");
add(f, dir, "ART");
}
} }
for (const dir of audioDirs) { for (const { dir, files } of audioLists) {
for (const f of await walker.listFiles(dir)) { for (const f of files) add(f, dir, "AUDIO");
add(f, dir, "AUDIO");
}
} }
return out; return out;
+391 -187
View File
@@ -11,13 +11,21 @@
* outside the extension. * outside the extension.
*/ */
import { readFile, readdir, stat } from "node:fs/promises"; import { open, readFile, readdir, stat } from "node:fs/promises";
import type { Stats } from "node:fs";
import { basename, dirname, extname, join, resolve } from "node:path"; import { basename, dirname, extname, join, resolve } from "node:path";
import { LineMap, parseXml, type XmlDocument, type XmlElement } from "../language/xmlParser"; import {
LineMap,
parseXml,
stripBom,
type XmlDocument,
type XmlElement,
} from "../language/xmlParser";
import { import {
buildSearchPaths, buildSearchPaths,
manifestPathForReference, manifestPathForReference,
resolveSource, resolveSource,
type ResolveResult,
type SearchPaths, type SearchPaths,
} from "./includeResolver"; } from "./includeResolver";
import { import {
@@ -28,6 +36,14 @@ import {
} from "./manifestParser"; } from "./manifestParser";
import { canonicalTypeName } from "../model/schemaModel"; import { canonicalTypeName } from "../model/schemaModel";
import { collectSourceCandidates } from "./fileScanner"; import { collectSourceCandidates } from "./fileScanner";
import { DocumentCache, IncludeResolveCache, IndexRecordsCache, normKey } from "./caches";
import type { IndexRecordsCacheEntry } from "./caches";
import { scanXmlShallow } from "./shallowScan";
import {
extractIndexRecords,
recordsFromShallow,
type IndexRecordXi,
} from "./records";
import type { import type {
AssetDef, AssetDef,
DefineDef, DefineDef,
@@ -42,54 +58,35 @@ import type {
const MAX_DEPTH = 300; const MAX_DEPTH = 300;
/** Files above this size are never parsed (safety against binary blobs). */ /** Files above this size are never parsed (safety against binary blobs). */
const MAX_PARSE_BYTES = 4 * 1024 * 1024; const MAX_PARSE_BYTES = 4 * 1024 * 1024;
/** Only these extensions are treated as XML documents. */
const XML_EXTENSIONS = new Set([".xml", ".manifestxml"]);
function normKey(path: string): string {
return resolve(path).toLowerCase();
}
/** /**
* LRU cache for parsed documents. The parse trees of huge mods can be * Fully parsed XML documents. `.xml` / `.manifestxml` files are small enough
* memory-heavy, so only a bounded number of recent documents is retained; * that a full DOM is affordable.
* evicted entries are re-read from disk on demand.
*/ */
export class DocumentCache { const FULL_XML_EXTENSIONS = new Set([".xml", ".manifestxml"]);
private map = new Map<string, ParsedFile>(); /**
* XML documents whose top-level structure is all the index needs (art-asset
* files exported by modeling tools, e.g. .w3x). They are shallow-scanned so
* multi-megabyte vertex/triangle payloads never become a DOM.
*/
const SHALLOW_XML_EXTENSIONS = new Set([".w3x"]);
/** Bytes peeked when deciding whether an unknown extension is XML text. */
const SNIFF_BYTES = 512;
constructor(private capacity = 64) {} type XmlMode = "full" | "shallow" | "binary";
get(path: string): ParsedFile | undefined {
const key = normKey(path);
const hit = this.map.get(key);
if (!hit) return undefined;
this.map.delete(key);
this.map.set(key, hit);
return hit;
}
set(parsed: ParsedFile): void {
const key = normKey(parsed.file.path);
this.map.delete(key);
this.map.set(key, parsed);
if (this.map.size > this.capacity) {
const oldest = this.map.keys().next().value;
if (oldest !== undefined) this.map.delete(oldest);
}
}
invalidate(path: string): void {
this.map.delete(normKey(path));
}
clear(): void {
this.map.clear();
}
}
export class ModIndexer { export class ModIndexer {
private searchPaths: SearchPaths; private searchPaths: SearchPaths;
private docs = new DocumentCache(); private docs: DocumentCache;
private recordsCache: IndexRecordsCache;
private resolveCache: IncludeResolveCache;
private scanCounters = {
shallowScannedFiles: 0,
shallowCacheHits: 0,
recordsCacheHits: 0,
resolveCacheHits: 0,
resolveCalls: 0,
};
private phase = { candidatesMs: 0, walkMs: 0 };
private assets = new Map<string, Map<string, AssetDef[]>>(); private assets = new Map<string, Map<string, AssetDef[]>>();
private assetsById = new Map<string, AssetDef[]>(); private assetsById = new Map<string, AssetDef[]>();
private defines = new Map<string, DefineDef[]>(); private defines = new Map<string, DefineDef[]>();
@@ -106,42 +103,221 @@ export class ModIndexer {
this.searchPaths = buildSearchPaths(opts.sdkDir, opts.projectDir, { this.searchPaths = buildSearchPaths(opts.sdkDir, opts.projectDir, {
DATA: opts.additionalDataSearchPaths, DATA: opts.additionalDataSearchPaths,
}); });
// Caches may be owned by the workspace so they survive rebuilds.
this.docs = opts.documentCache ?? new DocumentCache();
this.recordsCache = opts.recordsCache ?? new IndexRecordsCache();
this.resolveCache = opts.resolveCache ?? new IncludeResolveCache();
} }
/** Re-reads and caches a document; null when unreadable. */ /**
* Returns a document for indexing/navigation:
* - `.xml` / `.manifestxml` files are fully parsed (bounded by
* MAX_PARSE_BYTES);
* - `.w3x` (and unknown-extension files whose content looks like XML) are
* shallow-scanned, so huge model files never become a DOM;
* - everything else is registered as a file but never parsed.
*
* Cached entries are reused when the file stat is unchanged, which lets a
* workspace-owned cache survive rebuilds.
*/
async readDocument(path: string): Promise<ParsedFile | null> { async readDocument(path: string): Promise<ParsedFile | null> {
const hit = this.docs.get(path); const key = normKey(path);
if (hit) return hit; const trust =
this.opts.trustUnchanged === true && !this.opts.changedFiles?.has(key);
// Trusted fast path: a file that the watcher has not reported as changed
// is reused without any stat / content sniff / read at all. This is what
// makes save-triggered rebuilds cheap on huge corpora (Corona: ~9k files,
// ~2.6 GB of art assets on a mechanical drive).
if (trust) {
const rec = this.recordsCache.get(key);
if (rec) return this.recordsParsed(path, rec);
const cached = this.docs.get(key);
if (cached) {
this.files.set(key, cached.file);
return cached;
}
}
// Untrusted / cache miss: verify the stat against caches, then read.
try { try {
const [st, text] = await Promise.all([stat(path), readFile(path, "utf8")]); const st = await stat(path);
if (st.size > MAX_PARSE_BYTES) { const rec = this.recordsCache.get(key);
if (rec?.stat && rec.stat.mtimeMs === st.mtimeMs && rec.stat.size === st.size) {
return this.recordsParsed(path, rec);
}
const hit = this.docs.get(key);
if (
hit?.file.stat &&
hit.file.stat.mtimeMs === st.mtimeMs &&
hit.file.stat.size === st.size
) {
this.files.set(key, hit.file);
return hit;
}
const mode = await this.detectXmlMode(path);
if (mode === "shallow") return this.scanShallow(path, st);
if (mode === "binary") {
const file: IndexedFile = { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } }; const file: IndexedFile = { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } };
const parsed: ParsedFile = { file, parse: null, lineMap: null }; const parsed: ParsedFile = { file, parse: null, records: null, lineMap: null };
this.docs.set(parsed); this.docs.set(parsed);
this.files.set(normKey(parsed.file.path), file); this.files.set(key, file);
return parsed; return parsed;
} }
if (st.size > MAX_PARSE_BYTES) {
const file: IndexedFile = { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } };
const parsed: ParsedFile = { file, parse: null, records: null, lineMap: null };
this.docs.set(parsed);
this.files.set(key, file);
return parsed;
}
const text = stripBom(await readFile(path, "utf8"));
const lineMap = new LineMap(text);
const parse = parseXml(text); const parse = parseXml(text);
const records = extractIndexRecords(parse, lineMap);
const parsed: ParsedFile = { const parsed: ParsedFile = {
file: { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } }, file: { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } },
parse, parse,
lineMap: new LineMap(text), records,
lineMap,
}; };
this.docs.set(parsed); this.docs.set(parsed);
this.files.set(normKey(parsed.file.path), parsed.file); this.recordsCache.set(key, { stat: parsed.file.stat, records, kind: "full" });
this.files.set(key, parsed.file);
return parsed; return parsed;
} catch { } catch {
const parsed: ParsedFile = { const parsed: ParsedFile = {
file: { path: resolve(path), stat: null }, file: { path: resolve(path), stat: null },
parse: null, parse: null,
records: null,
lineMap: null, lineMap: null,
}; };
this.docs.set(parsed); this.docs.set(parsed);
this.files.set(normKey(parsed.file.path), parsed.file); this.files.set(key, parsed.file);
return parsed; return parsed;
} }
} }
/**
* Shallow-scans a large art-asset XML document (no DOM built) and caches
* its compact index records. The transient LineMap used to compute record
* lines is discarded, so the cache never retains megabytes of line offsets
* for model files (Corona w3x alone would otherwise keep ~700 MB).
*/
private async scanShallow(path: string, st: Stats): Promise<ParsedFile | null> {
const key = normKey(path);
try {
const text = stripBom(await readFile(path, "utf8"));
const lineMap = new LineMap(text);
const records = recordsFromShallow(scanXmlShallow(text), lineMap);
const parsed: ParsedFile = {
file: { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } },
parse: null,
records,
lineMap: null,
};
this.recordsCache.set(key, { stat: parsed.file.stat, records, kind: "shallow" });
this.files.set(key, parsed.file);
this.scanCounters.shallowScannedFiles++;
return parsed;
} catch {
return null;
}
}
/** Builds a lean ParsedFile from cached index records (no DOM / line map). */
private recordsParsed(path: string, entry: IndexRecordsCacheEntry): ParsedFile {
if (entry.kind === "shallow") this.scanCounters.shallowCacheHits++;
else this.scanCounters.recordsCacheHits++;
const file: IndexedFile = { path: resolve(path), stat: entry.stat };
this.files.set(normKey(path), file);
return { file, parse: null, records: entry.records, lineMap: null };
}
/**
* Reads a document and guarantees a DOM parse tree. Used only for
* root-level <xi:include> xpointer selection (rare), where the target's
* container children are needed.
*/
private async readDom(path: string): Promise<ParsedFile | null> {
const key = normKey(path);
const cached = this.docs.get(key);
if (cached?.parse?.root) {
this.files.set(key, cached.file);
return cached;
}
try {
const st = await stat(path);
const hit = this.docs.get(key);
if (
hit?.parse?.root &&
hit.file.stat &&
hit.file.stat.mtimeMs === st.mtimeMs &&
hit.file.stat.size === st.size
) {
this.files.set(key, hit.file);
return hit;
}
if (st.size > MAX_PARSE_BYTES) return null;
const text = stripBom(await readFile(path, "utf8"));
const lineMap = new LineMap(text);
const parse = parseXml(text);
const records = extractIndexRecords(parse, lineMap);
const parsed: ParsedFile = {
file: { path: resolve(path), stat: { mtimeMs: st.mtimeMs, size: st.size } },
parse,
records,
lineMap,
};
this.docs.set(parsed);
this.recordsCache.set(key, { stat: parsed.file.stat, records, kind: "full" });
this.files.set(key, parsed.file);
return parsed;
} catch {
return null;
}
}
/** Decides how a resolved include target should be consumed. */
private async detectXmlMode(path: string): Promise<XmlMode> {
const ext = extname(path).toLowerCase();
if (FULL_XML_EXTENSIONS.has(ext)) return "full";
if (SHALLOW_XML_EXTENSIONS.has(ext)) return "shallow";
return (await looksLikeXml(path)) ? "shallow" : "binary";
}
/**
* Resolves an include source through the cross-rebuild cache. Existence
* does not change on content edits, so trusted rebuilds pay zero statSync
* for include resolution (Corona does ~110k checks per build otherwise).
*/
private resolveCached(source: string, currentDir: string | null): ResolveResult {
const key = `${normKey(currentDir ?? "")}\u0000${source}`;
const hit = this.resolveCache.get(key);
if (hit) {
this.scanCounters.resolveCacheHits++;
return hit;
}
this.scanCounters.resolveCalls++;
const result = resolveSource(source, currentDir, this.searchPaths);
this.resolveCache.set(key, result);
return result;
}
/** Cached manifest lookup for `reference` includes. */
private manifestPathCached(source: string): string | null {
const key = source.toLowerCase();
const hit = this.resolveCache.getManifest(key);
if (hit !== undefined) {
this.scanCounters.resolveCacheHits++;
return hit;
}
this.scanCounters.resolveCalls++;
const path = manifestPathForReference(source, this.opts.builtmodsDirs);
this.resolveCache.setManifest(key, path);
return path;
}
/** Returns the cached parse if present (does not read from disk). */ /** Returns the cached parse if present (does not read from disk). */
cachedDocument(path: string): ParsedFile | undefined { cachedDocument(path: string): ParsedFile | undefined {
return this.docs.get(path); return this.docs.get(path);
@@ -155,6 +331,7 @@ export class ModIndexer {
: null; : null;
// ── Streams ── // ── Streams ──
const walkStart = Date.now();
const staticEntry = projectData ? join(projectData, "Mod.xml") : null; const staticEntry = projectData ? join(projectData, "Mod.xml") : null;
if (staticEntry) { if (staticEntry) {
const stream: StreamInfo = { name: "static", entry: staticEntry, files: new Set() }; const stream: StreamInfo = { name: "static", entry: staticEntry, files: new Set() };
@@ -183,8 +360,10 @@ export class ModIndexer {
await this.walk(entry, "all", stream, 0); await this.walk(entry, "all", stream, 0);
} }
} }
this.phase.walkMs = Date.now() - walkStart;
// ── Source completion candidates ── // ── Source completion candidates ──
const candidatesStart = Date.now();
const dataDirs = [ const dataDirs = [
projectData ?? join(this.opts.projectDir, "Data"), projectData ?? join(this.opts.projectDir, "Data"),
join(this.opts.sdkDir, "SageXml"), join(this.opts.sdkDir, "SageXml"),
@@ -229,6 +408,7 @@ export class ModIndexer {
...sdkRootCandidates, ...sdkRootCandidates,
...this.sourceCandidates, ...this.sourceCandidates,
]); ]);
this.phase.candidatesMs = Date.now() - candidatesStart;
const manifestAssetCount = [...this.manifests.values()].reduce( const manifestAssetCount = [...this.manifests.values()].reduce(
(sum, m) => sum + m.assets.length, (sum, m) => sum + m.assets.length,
@@ -253,6 +433,13 @@ export class ModIndexer {
parsedFiles: [...this.files.values()].filter( parsedFiles: [...this.files.values()].filter(
(f) => f.stat != null && f.stat.size <= MAX_PARSE_BYTES, (f) => f.stat != null && f.stat.size <= MAX_PARSE_BYTES,
).length, ).length,
shallowScannedFiles: this.scanCounters.shallowScannedFiles,
shallowCacheHits: this.scanCounters.shallowCacheHits,
recordsCacheHits: this.scanCounters.recordsCacheHits,
resolveCacheHits: this.scanCounters.resolveCacheHits,
resolveCalls: this.scanCounters.resolveCalls,
candidatesMs: this.phase.candidatesMs,
walkMs: this.phase.walkMs,
assetCount: [...this.assets.values()].reduce((sum, byId) => sum + byId.size, 0), assetCount: [...this.assets.values()].reduce((sum, byId) => sum + byId.size, 0),
defineCount: this.defines.size, defineCount: this.defines.size,
manifestFiles: this.manifests.size, manifestFiles: this.manifests.size,
@@ -294,166 +481,160 @@ export class ModIndexer {
stream.files.add(key); stream.files.add(key);
// Binary assets (w3x/dds/...) are referenced but never parsed. // readDocument returns compact index records for every indexable XML
if (!isXmlPath(path)) return; // document (full parse or shallow scan), or a bare file registration
// for binary / unparseable targets.
const parsed = await this.readDocument(path); const parsed = await this.readDocument(path);
if (!parsed?.parse?.root) return; if (!parsed) return;
const root = parsed.parse.root; if (parsed.records) {
await this.applyRecords(parsed, stream, depth, mode === "instance");
return;
}
}
for (const child of root.children) { /**
const local = localName(child.name); * Applies a document's compact index records: top-level assets, defines,
if (local === "Tags" || local === "Includes" || local === "Defines") continue; * <Includes>, nested <xi:include> and root-level <xi:include> targets.
if (local === "include") { * Works identically for fully parsed XML and shallow-scanned art assets.
await this.handleXiInclude(child, parsed, stream, depth); */
continue; private async applyRecords(
} parsed: ParsedFile,
const idAttr = child.attrs.find((a) => a.name === "id"); stream: StreamInfo,
if (idAttr) { depth: number,
this.addAsset({ viaInstance: boolean,
type: local, ): Promise<void> {
id: idAttr.value, const records = parsed.records;
file: parsed.file.path, if (!records) return;
line: lineOf(parsed, idAttr.valueStart), const file = parsed.file.path;
origin: this.originOf(parsed.file.path), const origin = this.originOf(file);
stream: stream.name,
viaInstance: mode === "instance", for (const asset of records.assets) {
}); this.addAsset({
} type: asset.type,
id: asset.id,
file,
line: asset.line,
origin,
stream: stream.name,
viaInstance,
});
} }
for (const child of root.children) { for (const define of records.defines) {
if (localName(child.name) !== "Defines") continue; const entry: DefineDef = {
for (const define of child.children) { name: define.name,
if (localName(define.name) !== "Define") continue; value: define.value,
const name = define.attrs.find((a) => a.name === "name")?.value; file,
const value = define.attrs.find((a) => a.name === "value")?.value; line: define.line,
if (!name) continue; origin,
const entry: DefineDef = { };
name, const arr = this.defines.get(define.name.toLowerCase());
value: value ?? "", if (arr) arr.push(entry);
file: parsed.file.path, else this.defines.set(define.name.toLowerCase(), [entry]);
line: lineOf(parsed, define.start),
origin: this.originOf(parsed.file.path),
};
const arr = this.defines.get(name.toLowerCase());
if (arr) arr.push(entry);
else this.defines.set(name.toLowerCase(), [entry]);
}
} }
const includesElem = root.children.find((c) => localName(c.name) === "Includes"); for (const inc of records.includes) {
if (includesElem) { const resolved = this.resolveCached(inc.source, dirname(file));
for (const inc of includesElem.children) {
if (localName(inc.name) !== "Include") continue;
const type = inc.attrs.find((a) => a.name === "type")?.value;
const source = inc.attrs.find((a) => a.name === "source")?.value;
if (!source) continue;
const resolved = resolveSource(source, dirname(parsed.file.path), this.searchPaths);
if (!resolved.path) {
this.diagnostics.push({
file: parsed.file.path,
line: lineOf(parsed, inc.start),
message: `Include target not found: ${source}`,
severity: "warning",
code: "include-not-found",
});
continue;
}
if (type === "all" || type === "instance") {
await this.walk(resolved.path, type === "all" ? "all" : "instance", stream, depth + 1);
} else if (type === "reference") {
const manifestPath = manifestPathForReference(source, this.opts.builtmodsDirs);
if (manifestPath) {
const loaded = await this.loadManifest(manifestPath, stream.name);
if (!loaded && isXmlPath(resolved.path)) {
// The manifest could not be parsed (missing/invalid): fall back
// to the placeholder XML so its content is still available.
await this.walk(resolved.path, "instance", stream, depth + 1);
}
} else if (isXmlPath(resolved.path)) {
// reference to a real XML file: treat its assets as available
await this.walk(resolved.path, "instance", stream, depth + 1);
}
}
}
}
// Nested <xi:include> anywhere in the tree (not just under the root):
// the target content is inlined into the parent element. We make the
// target file available and surface missing targets instead of ignoring
// them silently.
for (const el of parsed.parse.elements) {
if (localName(el.name) !== "include") continue;
if (el.parent === root) continue; // already handled in the loop above
const href = el.attrs.find((a) => a.name === "href")?.value;
if (!href) continue;
const resolved = resolveSource(href, dirname(parsed.file.path), this.searchPaths);
if (!resolved.path) { if (!resolved.path) {
this.diagnostics.push({ this.diagnostics.push({
file: parsed.file.path, file,
line: lineOf(parsed, el.start), line: inc.line,
message: `xi:include target not found: ${href}`, message: `Include target not found: ${inc.source}`,
severity: "warning",
code: "include-not-found",
});
continue;
}
if (inc.type === "all" || inc.type === "instance") {
await this.walk(
resolved.path,
inc.type === "all" ? "all" : "instance",
stream,
depth + 1,
);
} else if (inc.type === "reference") {
const manifestPath = this.manifestPathCached(inc.source);
if (manifestPath) {
const loaded = await this.loadManifest(manifestPath, stream.name);
if (!loaded) await this.walk(resolved.path, "instance", stream, depth + 1);
} else {
await this.walk(resolved.path, "instance", stream, depth + 1);
}
}
}
for (const xi of records.nestedXiIncludes) {
const resolved = this.resolveCached(xi.href, dirname(file));
if (!resolved.path) {
this.diagnostics.push({
file,
line: xi.line,
message: `xi:include target not found: ${xi.href}`,
severity: "warning", severity: "warning",
code: "include-not-found", code: "include-not-found",
}); });
continue; continue;
} }
stream.files.add(normKey(resolved.path)); stream.files.add(normKey(resolved.path));
if (isXmlPath(resolved.path)) { await this.walk(resolved.path, "all", stream, depth + 1);
await this.walk(resolved.path, "all", stream, depth + 1); }
}
for (const xi of records.rootXiIncludes) {
await this.handleRootXiInclude(xi, file, stream, depth);
} }
} }
private async handleXiInclude( /**
xi: XmlElement, * Root-level <xi:include> with an xpointer selects a named container's
parent: ParsedFile, * children in the target document, so the target's DOM is needed. These
* targets are rare, so they are parsed on demand (the tree is also cached).
*/
private async handleRootXiInclude(
xi: IndexRecordXi,
parentFile: string,
stream: StreamInfo, stream: StreamInfo,
depth: number, depth: number,
): Promise<void> { ): Promise<void> {
const href = xi.attrs.find((a) => a.name === "href")?.value; const resolved = this.resolveCached(xi.href, dirname(parentFile));
if (!href) return;
const resolved = resolveSource(href, dirname(parent.file.path), this.searchPaths);
if (!resolved.path) { if (!resolved.path) {
this.diagnostics.push({ this.diagnostics.push({
file: parent.file.path, file: parentFile,
line: lineOf(parent, xi.start), line: xi.line,
message: `xi:include target not found: ${href}`, message: `xi:include target not found: ${xi.href}`,
severity: "warning", severity: "warning",
code: "include-not-found", code: "include-not-found",
}); });
return; return;
} }
if (!isXmlPath(resolved.path)) return;
const target = await this.readDocument(resolved.path);
if (!target?.parse?.root) return;
const xpointer = xi.attrs.find((a) => a.name === "xpointer")?.value ?? ""; const target = await this.readDom(resolved.path);
let candidates: XmlElement[]; if (target?.parse?.root) {
if (xpointer) { const xpointer = xi.xpointer ?? "";
const container = findXPointerContainer(target.parse, xpointer); let candidates: XmlElement[];
candidates = container ? container.children : []; if (xpointer) {
} else { const container = findXPointerContainer(target.parse, xpointer);
candidates = target.parse.root.children; candidates = container ? container.children : [];
} } else {
for (const el of candidates) { candidates = target.parse.root.children;
const local = localName(el.name); }
if (local === "Tags" || local === "Includes" || local === "Defines") continue; for (const el of candidates) {
const idAttr = el.attrs.find((a) => a.name === "id"); const local = localName(el.name);
if (idAttr) { if (local === "Tags" || local === "Includes" || local === "Defines") continue;
this.addAsset({ const idAttr = el.attrs.find((a) => a.name === "id");
type: local, if (idAttr) {
id: idAttr.value, this.addAsset({
file: target.file.path, type: local,
line: lineOf(target, idAttr.valueStart), id: idAttr.value,
origin: this.originOf(target.file.path), file: target.file.path,
stream: stream.name, line: lineOf(target, idAttr.valueStart),
}); origin: this.originOf(target.file.path),
stream: stream.name,
});
}
} }
} }
stream.files.add(normKey(target.file.path)); stream.files.add(normKey(resolved.path));
await this.walk(target.file.path, "all", stream, depth + 1); await this.walk(resolved.path, "all", stream, depth + 1);
} }
// ── Manifest loading ────────────────────────────────────────────── // ── Manifest loading ──────────────────────────────────────────────
@@ -551,9 +732,32 @@ function lineOf(parsed: ParsedFile, offset: number): number {
return parsed.lineMap.positionAt(offset).line + 1; return parsed.lineMap.positionAt(offset).line + 1;
} }
function isXmlPath(path: string): boolean { /**
const ext = extname(path).toLowerCase(); * Content sniffing for include targets with unknown extensions (e.g. art
return XML_EXTENSIONS.has(ext); * formats beyond .w3x): a small header that starts with "<" after an
* optional UTF-8 BOM / whitespace and contains no NUL bytes is treated as
* XML text; anything else is a binary asset (registered, never parsed).
*/
async function looksLikeXml(path: string): Promise<boolean> {
try {
const fh = await open(path, "r");
try {
const buf = Buffer.alloc(SNIFF_BYTES);
const { bytesRead } = await fh.read(buf, 0, SNIFF_BYTES, 0);
const head = buf.subarray(0, bytesRead);
if (head.includes(0)) return false;
let i = 0;
if (head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf) i = 3;
while (i < head.length && (head[i] === 0x20 || head[i] === 0x09 || head[i] === 0x0a || head[i] === 0x0d)) {
i++;
}
return i < head.length && head[i] === 0x3c; // "<"
} finally {
await fh.close();
}
} catch {
return false;
}
} }
async function findCaseInsensitiveDir(dir: string): Promise<string | null> { async function findCaseInsensitiveDir(dir: string): Promise<string | null> {
+177
View File
@@ -0,0 +1,177 @@
/**
* Compact per-file index records.
*
* A rebuild only needs each file's top-level assets, defines, includes and
* xi:include targets — not the DOM. Extracting these records at parse time
* and caching them across rebuilds lets Corona-scale rebuilds skip both the
* DOM and the file I/O for unchanged files, while keeping the DOM cache small
* for on-demand features (hover / navigation / outline).
*
* Pure TypeScript: no vscode dependency.
*/
import type { LineMap, XmlDocument } from "../language/xmlParser";
import type { ShallowDocument } from "./shallowScan";
export interface IndexRecordAsset {
/** Top-level element name, e.g. "W3DContainer". */
type: string;
id: string;
/** 1-based line of the id attribute value. */
line: number;
}
export interface IndexRecordDefine {
name: string;
value: string;
/** 1-based line of the <Define> element. */
line: number;
}
export interface IndexRecordInclude {
type: "all" | "instance" | "reference" | null;
source: string;
/** 1-based line of the <Include> element. */
line: number;
}
export interface IndexRecordXi {
href: string;
xpointer: string | null;
/** 1-based line of the <xi:include> element. */
line: number;
}
export interface IndexRecords {
assets: IndexRecordAsset[];
defines: IndexRecordDefine[];
includes: IndexRecordInclude[];
/** <xi:include> elements that are direct children of the root. */
rootXiIncludes: IndexRecordXi[];
/** <xi:include> elements nested anywhere else in the document. */
nestedXiIncludes: IndexRecordXi[];
}
const INCLUDE_TYPES = new Set(["all", "instance", "reference"]);
function lineOf(lineMap: LineMap, offset: number): number {
return lineMap.positionAt(offset).line + 1;
}
function localName(tag: string): string {
const idx = tag.lastIndexOf(":");
return idx >= 0 ? tag.slice(idx + 1) : tag;
}
/**
* Extracts the index records of a fully parsed document. Mirrors the walk
* semantics of the indexer exactly: top-level assets (excluding
* Tags/Includes/Defines), $DEFINE constants, the top-level <Includes> block
* and root/nested <xi:include> elements.
*/
export function extractIndexRecords(parse: XmlDocument, lineMap: LineMap): IndexRecords {
const assets: IndexRecordAsset[] = [];
const defines: IndexRecordDefine[] = [];
const includes: IndexRecordInclude[] = [];
const rootXiIncludes: IndexRecordXi[] = [];
const nestedXiIncludes: IndexRecordXi[] = [];
const root = parse.root;
if (!root) return { assets, defines, includes, rootXiIncludes, nestedXiIncludes };
for (const child of root.children) {
const local = localName(child.name);
if (local === "Tags" || local === "Includes" || local === "Defines") continue;
if (local === "include") {
const href = child.attrs.find((a) => a.name === "href")?.value;
if (href) {
rootXiIncludes.push({
href,
xpointer: child.attrs.find((a) => a.name === "xpointer")?.value ?? null,
line: lineOf(lineMap, child.start),
});
}
continue;
}
const idAttr = child.attrs.find((a) => a.name === "id");
if (idAttr) {
assets.push({ type: local, id: idAttr.value, line: lineOf(lineMap, idAttr.valueStart) });
}
}
for (const child of root.children) {
if (localName(child.name) !== "Defines") continue;
for (const define of child.children) {
if (localName(define.name) !== "Define") continue;
const name = define.attrs.find((a) => a.name === "name")?.value;
if (!name) continue;
defines.push({
name,
value: define.attrs.find((a) => a.name === "value")?.value ?? "",
line: lineOf(lineMap, define.start),
});
}
}
const includesElem = root.children.find((c) => localName(c.name) === "Includes");
if (includesElem) {
for (const inc of includesElem.children) {
if (localName(inc.name) !== "Include") continue;
const source = inc.attrs.find((a) => a.name === "source")?.value;
if (!source) continue;
const type = inc.attrs.find((a) => a.name === "type")?.value;
includes.push({
type:
type && INCLUDE_TYPES.has(type)
? (type as "all" | "instance" | "reference")
: null,
source,
line: lineOf(lineMap, inc.start),
});
}
}
for (const el of parse.elements) {
if (localName(el.name) !== "include") continue;
if (el.parent === root) continue; // already handled above
const href = el.attrs.find((a) => a.name === "href")?.value;
if (!href) continue;
nestedXiIncludes.push({
href,
xpointer: el.attrs.find((a) => a.name === "xpointer")?.value ?? null,
line: lineOf(lineMap, el.start),
});
}
return { assets, defines, includes, rootXiIncludes, nestedXiIncludes };
}
/** Converts a shallow scan (offsets) into index records (1-based lines). */
export function recordsFromShallow(scan: ShallowDocument, lineMap: LineMap): IndexRecords {
return {
assets: scan.assets.map((a) => ({
type: a.name,
id: a.id,
line: lineOf(lineMap, a.idValueStart),
})),
defines: scan.defines.map((d) => ({
name: d.name,
value: d.value,
line: lineOf(lineMap, d.start),
})),
includes: scan.includes.map((i) => ({
type: i.type,
source: i.source,
line: lineOf(lineMap, i.start),
})),
rootXiIncludes: scan.rootXiIncludes.map((x) => ({
href: x.href,
xpointer: x.xpointer,
line: lineOf(lineMap, x.start),
})),
nestedXiIncludes: scan.nestedXiIncludes.map((x) => ({
href: x.href,
xpointer: x.xpointer,
line: lineOf(lineMap, x.start),
})),
};
}
+274
View File
@@ -0,0 +1,274 @@
/**
* Shallow XML scanner for large art-asset documents (e.g. .w3x).
*
* Full XML parsing builds an element tree whose memory footprint is roughly
* 17x the source text (measured on Corona model files). Model exports like
* <W3DMesh> contain hundreds of thousands of tiny numeric elements
* (Vertices/V, Triangles/T, ...) that the extension never needs: the index
* only consumes top-level asset definitions (name + id), top-level
* <Includes>, nested <xi:include> targets and <Defines> constants.
*
* This scanner performs a single linear pass without building child nodes,
* so multi-megabyte model files can be indexed in linear time with ~zero
* retained memory.
*
* Pure TypeScript: no vscode dependency, reusable outside the extension.
*/
export interface ShallowAssetRecord {
/** Top-level element name, e.g. "W3DContainer". */
name: string;
/** Value of the id attribute. */
id: string;
/** Offset of the first id value character. */
idValueStart: number;
/** Offset one past the last id value character. */
idValueEnd: number;
/** Offset of the element's "<". */
start: number;
/** Offset one past the ">" of the start tag. */
startTagEnd: number;
}
export interface ShallowIncludeRecord {
/** "all" | "instance" | "reference", or null when absent/unknown. */
type: "all" | "instance" | "reference" | null;
source: string;
/** Offset of the <Include> element. */
start: number;
}
export interface ShallowXiIncludeRecord {
href: string;
xpointer: string | null;
/** Offset of the <xi:include> element. */
start: number;
}
export interface ShallowDefineRecord {
name: string;
value: string;
/** Offset of the <Define> element. */
start: number;
}
export interface ShallowScanError {
message: string;
offset: number;
}
export interface ShallowDocument {
assets: ShallowAssetRecord[];
includes: ShallowIncludeRecord[];
/** <xi:include> elements that are direct children of the root. */
rootXiIncludes: ShallowXiIncludeRecord[];
/** <xi:include> elements nested anywhere else in the document. */
nestedXiIncludes: ShallowXiIncludeRecord[];
defines: ShallowDefineRecord[];
errors: ShallowScanError[];
}
interface AttrHit {
value: string;
valueStart: number;
valueEnd: number;
}
/**
* Matches a complete XML tag while respecting quoted attribute values, so a
* ">" or "/" inside a value never terminates the tag early.
*/
const TAG_RE = /<(?:"[^"]*"|'[^']*'|[^'"<>])*>/g;
export function scanXmlShallow(text: string): ShallowDocument {
const errors: ShallowScanError[] = [];
const assets: ShallowAssetRecord[] = [];
const includes: ShallowIncludeRecord[] = [];
const rootXiIncludes: ShallowXiIncludeRecord[] = [];
const nestedXiIncludes: ShallowXiIncludeRecord[] = [];
const defines: ShallowDefineRecord[] = [];
// Depth of the currently open element stack. The document root opens at
// depth 0 -> 1, so its direct children open when depth === 1.
let depth = 0;
let inIncludes = false;
let inDefines = false;
let i = 0;
while (i < text.length) {
const lt = text.indexOf("<", i);
if (lt < 0) break;
// Non-element constructs: comments, CDATA, DOCTYPE and processing
// instructions are skipped whole so their content never looks like tags.
if (text.startsWith("<!--", lt)) {
const close = text.indexOf("-->", lt + 4);
if (close < 0) {
errors.push({ message: "Unterminated comment", offset: lt });
break;
}
i = close + 3;
continue;
}
if (text.startsWith("<![CDATA[", lt)) {
const close = text.indexOf("]]>", lt + 9);
if (close < 0) {
errors.push({ message: "Unterminated CDATA section", offset: lt });
break;
}
i = close + 3;
continue;
}
if (text.startsWith("<!", lt)) {
const close = text.indexOf(">", lt + 2);
if (close < 0) {
errors.push({ message: "Unterminated DOCTYPE", offset: lt });
break;
}
i = close + 1;
continue;
}
if (text.startsWith("<?", lt)) {
const close = text.indexOf("?>", lt + 2);
if (close < 0) {
errors.push({ message: "Unterminated processing instruction", offset: lt });
break;
}
i = close + 2;
continue;
}
TAG_RE.lastIndex = lt;
const m = TAG_RE.exec(text);
if (!m) {
errors.push({ message: "Unterminated tag", offset: lt });
break;
}
const tag = m[0];
const gt = m.index + tag.length - 1;
const inner = tag.slice(1, -1);
const closing = inner.startsWith("/");
const selfClosing = !closing && /\/\s*$/.test(inner);
const body = closing ? inner.slice(1) : inner;
let nameEnd = 0;
while (nameEnd < body.length && !/[\s/>]/.test(body[nameEnd])) nameEnd++;
const name = body.slice(0, nameEnd);
const base = lt + 1;
if (!closing) {
// Top-level elements (direct children of the root).
if (depth === 1) {
inIncludes = name === "Includes";
inDefines = name === "Defines";
const idAttr = findAttr(inner, base, "id");
if (idAttr && idAttr.value) {
assets.push({
name,
id: idAttr.value,
idValueStart: idAttr.valueStart,
idValueEnd: idAttr.valueEnd,
start: lt,
startTagEnd: gt + 1,
});
}
}
// <Include> entries inside the top-level <Includes> block.
if (inIncludes && depth === 2 && name === "Include") {
const type = findAttr(inner, base, "type");
const source = findAttr(inner, base, "source");
if (source?.value) {
includes.push({
type:
type?.value === "all" || type?.value === "instance" || type?.value === "reference"
? type.value
: null,
source: source.value,
start: lt,
});
}
}
// <Define> entries inside the top-level <Defines> block.
if (inDefines && depth === 2 && name === "Define") {
const nameAttr = findAttr(inner, base, "name");
const valueAttr = findAttr(inner, base, "value");
if (nameAttr?.value) {
defines.push({ name: nameAttr.value, value: valueAttr?.value ?? "", start: lt });
}
}
// Nested <xi:include> (or any *:include) anywhere in the document.
if (localName(name) === "include") {
const href = findAttr(inner, base, "href");
const xpointer = findAttr(inner, base, "xpointer");
if (href?.value) {
const rec: ShallowXiIncludeRecord = {
href: href.value,
xpointer: xpointer?.value ?? null,
start: lt,
};
if (depth === 1) rootXiIncludes.push(rec);
else nestedXiIncludes.push(rec);
}
}
if (!selfClosing) depth++;
} else {
depth--;
}
i = gt + 1;
}
return { assets, includes, rootXiIncludes, nestedXiIncludes, defines, errors };
}
/**
* Scans the attributes of a tag (its inner text, without "<" and ">") for
* the first occurrence of `want` and returns its value with absolute offsets
* (base = offset one past the "<").
*/
function findAttr(inner: string, base: number, want: string): AttrHit | null {
let i = 0;
while (i < inner.length) {
while (i < inner.length && /\s/.test(inner[i])) i++;
// Self-closing marker or end of tag: no more attributes.
if (i >= inner.length || inner[i] === "/" || inner[i] === ">") break;
const nameStart = i;
while (i < inner.length && !/[\s=/>]/.test(inner[i])) i++;
const name = inner.slice(nameStart, i);
while (i < inner.length && /\s/.test(inner[i])) i++;
if (inner[i] === "=") {
i++;
while (i < inner.length && /\s/.test(inner[i])) i++;
const q = inner[i];
if (q === '"' || q === "'") {
const vStart = i + 1;
const vEnd = inner.indexOf(q, vStart);
if (vEnd < 0) {
return name === want ? { value: "", valueStart: -1, valueEnd: -1 } : null;
}
if (name === want) {
return {
value: inner.slice(vStart, vEnd),
valueStart: base + vStart,
valueEnd: base + vEnd,
};
}
i = vEnd + 1;
continue;
}
// Unquoted value (tolerated).
const vs = i;
while (i < inner.length && !/[\s>]/.test(inner[i])) i++;
if (name === want) {
return { value: inner.slice(vs, i), valueStart: base + vs, valueEnd: base + i };
}
continue;
}
// Attribute without a value (rare but tolerated).
if (name === want) return { value: "", valueStart: -1, valueEnd: -1 };
}
return null;
}
function localName(name: string): string {
const idx = name.lastIndexOf(":");
return idx >= 0 ? name.slice(idx + 1) : name;
}
+38
View File
@@ -1,6 +1,8 @@
import type { XmlDocument } from "../language/xmlParser"; import type { XmlDocument } from "../language/xmlParser";
import type { ManifestInfo } from "./manifestParser"; import type { ManifestInfo } from "./manifestParser";
import type { LineMap } from "../language/xmlParser"; import type { LineMap } from "../language/xmlParser";
import type { IndexRecords } from "./records";
import type { DocumentCache, IncludeResolveCache, IndexRecordsCache } from "./caches";
export type AssetOrigin = "project" | "sdk" | "manifest"; export type AssetOrigin = "project" | "sdk" | "manifest";
@@ -65,6 +67,20 @@ export interface IndexStats {
sdkDir: string; sdkDir: string;
indexedFiles: number; indexedFiles: number;
parsedFiles: number; parsedFiles: number;
/** Art-asset documents indexed via shallow scan (no DOM tree). */
shallowScannedFiles: number;
/** Shallow scans served from the persistent cache (unchanged files). */
shallowCacheHits: number;
/** Parsed XML files served from the persistent records cache. */
recordsCacheHits: number;
/** Include/xi:include resolutions served from the resolve cache. */
resolveCacheHits: number;
/** Include/xi:include resolutions performed during this build. */
resolveCalls: number;
/** Time spent enumerating Include source candidates (ms). */
candidatesMs: number;
/** Time spent walking the include graph (ms). */
walkMs: number;
assetCount: number; assetCount: number;
defineCount: number; defineCount: number;
manifestFiles: number; manifestFiles: number;
@@ -102,6 +118,23 @@ export interface IndexOptions {
additionalDataSearchPaths: string[]; additionalDataSearchPaths: string[];
/** Directory walker used to enumerate files for source completion. */ /** Directory walker used to enumerate files for source completion. */
walker: FileWalker; walker: FileWalker;
/** Optional parse-tree cache shared across rebuilds (owned by the workspace). */
documentCache?: DocumentCache;
/** Optional index-records cache shared across rebuilds (owned by the workspace). */
recordsCache?: IndexRecordsCache;
/** Optional include-resolution cache shared across rebuilds (owned by the workspace). */
resolveCache?: IncludeResolveCache;
/**
* When true, cached documents whose path is not in `changedFiles` are used
* without a per-file stat. The workspace enables this while a file watcher
* invalidates caches for changed paths; a forced reindex passes false.
*/
trustUnchanged?: boolean;
/**
* Normalized paths (see `normKey`) known to have changed since the caches
* were populated. Only consulted when `trustUnchanged` is true.
*/
changedFiles?: ReadonlySet<string>;
} }
export interface FileWalker { export interface FileWalker {
@@ -121,5 +154,10 @@ export interface ParseCache {
export interface ParsedFile { export interface ParsedFile {
file: IndexedFile; file: IndexedFile;
parse: XmlDocument | null; parse: XmlDocument | null;
/**
* Compact index records (assets/defines/includes/xi:include with lines).
* Present for every indexable XML document; null for binary files.
*/
records: IndexRecords | null;
lineMap: LineMap | null; lineMap: LineMap | null;
} }
+8
View File
@@ -66,6 +66,14 @@ export interface Position {
character: number; character: number;
} }
/**
* Removes a leading UTF-8 byte-order mark (U+FEFF) so source offsets match
* the text as editors expose it (VS Code strips the BOM from document text).
*/
export function stripBom(text: string): string {
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
}
/** Precomputes line start offsets for offset <-> position conversion. */ /** Precomputes line start offsets for offset <-> position conversion. */
export class LineMap { export class LineMap {
private lineStarts: number[] = [0]; private lineStarts: number[] = [0];
+78 -2
View File
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
import { join, dirname } from "node:path"; import { join, dirname } from "node:path";
import { CachedDirectoryWalker } from "./indexer/fileScanner"; import { CachedDirectoryWalker } from "./indexer/fileScanner";
import { ModIndexer } from "./indexer/indexer"; import { ModIndexer } from "./indexer/indexer";
import { DocumentCache, IncludeResolveCache, IndexRecordsCache } from "./indexer/caches";
import type { ModIndex } from "./indexer/types"; import type { ModIndex } from "./indexer/types";
import { readSettings, type ExtensionSettings } from "./settings"; import { readSettings, type ExtensionSettings } from "./settings";
@@ -15,12 +16,21 @@ export class ModWorkspace {
settings: ExtensionSettings; settings: ExtensionSettings;
private walker = new CachedDirectoryWalker(); private walker = new CachedDirectoryWalker();
// Caches owned here survive rebuilds: a fresh ModIndexer reuses them and
// only re-reads files whose stat changed (crucial for the ~2.6 GB of .w3x
// art assets in a project like Corona).
private documentCache = new DocumentCache();
private recordsCache = new IndexRecordsCache();
private resolveCache = new IncludeResolveCache();
private context: vscode.ExtensionContext;
private watchers: vscode.FileSystemWatcher[] = [];
private statusBar: vscode.StatusBarItem; private statusBar: vscode.StatusBarItem;
private rebuildTimer: ReturnType<typeof setTimeout> | null = null; private rebuildTimer: ReturnType<typeof setTimeout> | null = null;
private building = false; private building = false;
private dirty = false; private dirty = false;
constructor(context: vscode.ExtensionContext) { constructor(context: vscode.ExtensionContext) {
this.context = context;
this.settings = readSettings(); this.settings = readSettings();
this.statusBar = vscode.window.createStatusBarItem( this.statusBar = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left, vscode.StatusBarAlignment.Left,
@@ -51,11 +61,69 @@ export class ModWorkspace {
this.statusBar.hide(); this.statusBar.hide();
return; return;
} }
this.startWatching();
this.statusBar.text = "$(sync~spin) RA3 XML: indexing…"; this.statusBar.text = "$(sync~spin) RA3 XML: indexing…";
this.statusBar.show(); this.statusBar.show();
await this.rebuild(); await this.rebuild();
} }
/**
* Invalidates cached documents for a path (called by the file watcher and
* on document save), so the next rebuild re-reads it instead of trusting
* the cached copy.
*/
invalidate(path: string): void {
if (!path) return;
this.documentCache.invalidate(path);
this.recordsCache.invalidate(path);
}
/**
* Called when files are created or deleted: include-resolution results
* (which encode file existence) are no longer trustworthy.
*/
invalidateExistence(): void {
this.resolveCache.clear();
}
/**
* Watches the project, SDK and extra DATA roots for file changes and
* invalidates the corresponding cache entries. With caches invalidated
* precisely, rebuilds can trust every other cached file and skip per-file
* stats (huge win on mechanical drives; Corona rebuild dropped from ~38s
* to a few seconds).
*/
private startWatching(): void {
if (!this.projectRoot) return;
const roots = new Set([
this.projectRoot,
this.settings.sdkPath,
...this.settings.additionalDataSearchPaths,
]);
for (const root of roots) {
if (!existsSync(root)) continue;
try {
const watcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(root, "**/*"),
);
watcher.onDidCreate((uri) => {
this.invalidate(uri.fsPath);
this.invalidateExistence();
});
watcher.onDidChange((uri) => this.invalidate(uri.fsPath));
watcher.onDidDelete((uri) => {
this.invalidate(uri.fsPath);
this.invalidateExistence();
});
this.watchers.push(watcher);
this.context.subscriptions.push(watcher);
} catch {
// The root may be temporarily unavailable (e.g. removable drive);
// indexing still works, just without watcher-based invalidation.
}
}
}
scheduleRebuild(): void { scheduleRebuild(): void {
if (!this.projectRoot) return; if (!this.projectRoot) return;
if (this.rebuildTimer) clearTimeout(this.rebuildTimer); if (this.rebuildTimer) clearTimeout(this.rebuildTimer);
@@ -64,12 +132,13 @@ export class ModWorkspace {
}, REBUILD_DEBOUNCE_MS); }, REBUILD_DEBOUNCE_MS);
} }
async rebuild(): Promise<void> { async rebuild(force = false): Promise<void> {
if (!this.projectRoot) return; if (!this.projectRoot) return;
if (this.building) { if (this.building) {
this.dirty = true; this.dirty = true;
return; return;
} }
if (force) this.resolveCache.clear();
this.building = true; this.building = true;
this.settings = readSettings(); this.settings = readSettings();
try { try {
@@ -81,6 +150,12 @@ export class ModWorkspace {
indexSageXml: this.settings.indexSageXml, indexSageXml: this.settings.indexSageXml,
additionalDataSearchPaths: this.settings.additionalDataSearchPaths, additionalDataSearchPaths: this.settings.additionalDataSearchPaths,
walker: this.walker, walker: this.walker,
documentCache: this.documentCache,
recordsCache: this.recordsCache,
resolveCache: this.resolveCache,
// Trust cache entries unless the user explicitly asked for a full
// verification (ra3modxml.reindex).
trustUnchanged: !force,
}); });
const started = Date.now(); const started = Date.now();
this.index = await indexer.build(); this.index = await indexer.build();
@@ -90,7 +165,7 @@ export class ModWorkspace {
this.statusBar.text = `$(symbol-misc) RA3 XML: ${formatCount(s.assetCount)} assets`; this.statusBar.text = `$(symbol-misc) RA3 XML: ${formatCount(s.assetCount)} assets`;
this.statusBar.tooltip = this.statusBar.tooltip =
`${s.projectDir}\n` + `${s.projectDir}\n` +
`${s.indexedFiles} files indexed (${secs}s)\n` + `${s.indexedFiles} files indexed (${s.parsedFiles} parsed, ${s.shallowScannedFiles} art assets shallow-scanned, ${secs}s)\n` +
`${s.assetCount} assets (${s.manifestAssetCount} from ${s.manifestFiles} manifests)\n` + `${s.assetCount} assets (${s.manifestAssetCount} from ${s.manifestFiles} manifests)\n` +
`${s.defineCount} defines, ${s.streams} streams, ${s.sourceCandidates} include candidates`; `${s.defineCount} defines, ${s.streams} streams, ${s.sourceCandidates} include candidates`;
} catch (err) { } catch (err) {
@@ -113,6 +188,7 @@ export class ModWorkspace {
return { return {
file: { path, stat: null }, file: { path, stat: null },
parse, parse,
records: null,
lineMap: new LineMap(text), lineMap: new LineMap(text),
}; };
} }
+73
View File
@@ -0,0 +1,73 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
DocumentCache,
IncludeResolveCache,
IndexRecordsCache,
} from "../out/indexer/caches.js";
function parsed(path, elements) {
return {
file: { path, stat: { mtimeMs: 1, size: 1 } },
parse: { root: { name: "r" }, elements: new Array(elements), errors: [] },
records: null,
lineMap: null,
};
}
test("DocumentCache evicts the largest tree when over the element budget", () => {
const cache = new DocumentCache(64, 100);
cache.set(parsed("a.xml", 60));
cache.set(parsed("b.xml", 60));
assert.equal(cache.get("a.xml"), undefined, "largest tree evicted first");
assert.ok(cache.get("b.xml"));
});
test("DocumentCache evicts least recently used when over capacity", () => {
const cache = new DocumentCache(2, 1_000_000);
cache.set(parsed("a.xml", 10));
cache.set(parsed("b.xml", 10));
cache.set(parsed("c.xml", 10));
assert.equal(cache.get("a.xml"), undefined);
assert.ok(cache.get("b.xml"));
assert.ok(cache.get("c.xml"));
});
test("DocumentCache invalidate frees budget", () => {
const cache = new DocumentCache(64, 100);
cache.set(parsed("a.xml", 60));
cache.invalidate("a.xml");
cache.set(parsed("b.xml", 60));
assert.ok(cache.get("b.xml"), "invalidating a freed its budget share");
cache.set(parsed("c.xml", 60));
assert.equal(cache.get("a.xml"), undefined);
assert.ok(cache.get("c.xml"));
});
test("IndexRecordsCache stores and invalidates entries", () => {
const cache = new IndexRecordsCache();
const entry = {
stat: { mtimeMs: 1, size: 1 },
records: { assets: [], defines: [], includes: [], rootXiIncludes: [], nestedXiIncludes: [] },
kind: "full",
};
cache.set("a.xml", entry);
assert.equal(cache.get("a.xml"), entry);
cache.invalidate("a.xml");
assert.equal(cache.get("a.xml"), undefined);
});
test("IncludeResolveCache stores sources and manifest lookups", () => {
const cache = new IncludeResolveCache();
const key = "dir|DATA:static.xml";
const result = { path: "C:/sdk/static.xml", prefix: "DATA", raw: "DATA:static.xml" };
assert.equal(cache.get(key), undefined);
cache.set(key, result);
assert.equal(cache.get(key), result);
assert.equal(cache.getManifest("static.xml"), undefined);
cache.setManifest("static.xml", "C:/sdk/builtmods/static.manifest");
assert.equal(cache.getManifest("static.xml"), "C:/sdk/builtmods/static.manifest");
cache.clear();
assert.equal(cache.get(key), undefined);
assert.equal(cache.getManifest("static.xml"), undefined);
});
@@ -0,0 +1,2 @@
This is a fake DDS payload used to verify that binary assets included from
an XML hub are registered as files but never parsed as XML.
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<W3DMesh id="Tank_FP" />
</AssetDeclaration>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<W3DContainer id="Tank_SKN" Hierarchy="Tank_SKL" />
<W3DHierarchy id="Tank_SKL" />
</AssetDeclaration>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<Includes>
<Include type="all" source="Models/Tank_SKN.w3x" />
<Include type="all" source="Models/Tank_FP.w3d" />
<Include type="all" source="Models/Tank_Damaged.dds" />
</Includes>
</AssetDeclaration>
+1
View File
@@ -9,5 +9,6 @@
<Include type="all" source="Includes/Weapons.xml" /> <Include type="all" source="Includes/Weapons.xml" />
<Include type="all" source="Includes/Shared.xml" /> <Include type="all" source="Includes/Shared.xml" />
<Include type="all" source="Includes/Refs.xml" /> <Include type="all" source="Includes/Refs.xml" />
<Include type="all" source="Includes/VehicleArt.xml" />
</Includes> </Includes>
</AssetDeclaration> </AssetDeclaration>
+187
View File
@@ -2,8 +2,16 @@ import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import fs from "node:fs";
import os from "node:os";
import { ModIndexer } from "../out/indexer/indexer.js"; import { ModIndexer } from "../out/indexer/indexer.js";
import { CachedDirectoryWalker } from "../out/indexer/fileScanner.js"; import { CachedDirectoryWalker } from "../out/indexer/fileScanner.js";
import {
DocumentCache,
IncludeResolveCache,
IndexRecordsCache,
} from "../out/indexer/caches.js";
import { resolveReferenceTargetsForType } from "../out/indexer/refs.js";
const root = dirname(dirname(fileURLToPath(import.meta.url))); const root = dirname(dirname(fileURLToPath(import.meta.url)));
const project = join(root, "test", "fixtures", "minimod"); const project = join(root, "test", "fixtures", "minimod");
@@ -70,3 +78,182 @@ test("provides include source candidates", async () => {
assert.ok(xml.some((c) => c.source === "Includes/Units.xml")); assert.ok(xml.some((c) => c.source === "Includes/Units.xml"));
assert.ok(xml.some((c) => c.source === "DATA:static.xml")); assert.ok(xml.some((c) => c.source === "DATA:static.xml"));
}); });
test("indexes art-asset XML (.w3x / sniffed .w3d) via shallow scan and skips binary", async () => {
const idx = await buildIndex();
// The .w3x hub chain: Mod.xml -> VehicleArt.xml -> Models/Tank_SKN.w3x.
const skn = idx.assetsById.get("tank_skn");
assert.ok(skn?.some((d) => d.type === "W3DContainer"), "W3DContainer from .w3x indexed");
assert.ok(
idx.assetsById.get("tank_skl")?.some((d) => d.type === "W3DHierarchy"),
"W3DHierarchy from .w3x indexed",
);
const container = skn.find((d) => d.type === "W3DContainer");
assert.match(container.file, /Tank_SKN\.w3x$/);
assert.ok(container.line > 0, "definition line recorded from shallow scan");
// Unknown extension with XML content is sniffed and indexed.
assert.ok(
idx.assetsById.get("tank_fp")?.some((d) => d.type === "W3DMesh"),
"unknown-extension XML (.w3d) sniffed and indexed",
);
// Binary content is registered as a file but never parsed.
assert.equal(idx.assetsById.get("tank_damaged"), undefined, "binary asset never indexed");
assert.ok(
idx.files.has(
join(project, "Data", "Includes", "Models", "Tank_Damaged.dds").toLowerCase(),
),
"binary include target registered as a file",
);
// The reported Harbinger scenario: <Model Name="Tank_SKN"/> (refType
// BaseRenderAssetType) must resolve to the W3DContainer defined in the w3x.
const targets = resolveReferenceTargetsForType(
idx,
"ScriptedModelDrawModel",
"Name",
"Tank_SKN",
);
assert.equal(targets.length, 1);
assert.equal(targets[0].def.type, "W3DContainer");
assert.match(targets[0].def.file, /Tank_SKN\.w3x$/);
});
test("w3x files appear in Include source completion candidates", async () => {
const idx = await buildIndex();
assert.ok(
idx.sourceCandidates.some((c) => c.source === "Includes/Models/Tank_SKN.w3x"),
"project-relative w3x candidate",
);
assert.ok(
idx.sourceCandidates.some((c) => c.source === "DATA:Includes/Models/Tank_SKN.w3x"),
"DATA: w3x candidate",
);
});
test("shallow scans and full parses are cached across rebuilds", async () => {
const documentCache = new DocumentCache();
const recordsCache = new IndexRecordsCache();
const resolveCache = new IncludeResolveCache();
const makeIndexer = () =>
new ModIndexer({
projectDir: project,
sdkDir: sdk,
builtmodsDirs: [join(sdk, "builtmods")],
indexSageXml: true,
additionalDataSearchPaths: [],
walker: new CachedDirectoryWalker(),
documentCache,
recordsCache,
resolveCache,
});
const first = await makeIndexer().build();
const second = await makeIndexer().build();
assert.equal(first.stats.shallowScannedFiles, 2, "w3x + sniffed w3d scanned on first build");
assert.equal(second.stats.shallowScannedFiles, 0, "unchanged art assets are not re-scanned");
assert.equal(second.stats.shallowCacheHits, 2);
assert.ok(second.stats.recordsCacheHits > 0, "XML records served from cache");
assert.ok(second.stats.resolveCacheHits > 0, "include resolutions served from cache");
assert.equal(second.stats.resolveCalls, 0, "no include re-resolved on a trusted rebuild");
assert.equal(second.stats.assetCount, first.stats.assetCount);
assert.equal(second.stats.indexedFiles, first.stats.indexedFiles);
});
test("trusted rebuilds skip unchanged files; invalidation forces re-reads", async () => {
const documentCache = new DocumentCache();
const recordsCache = new IndexRecordsCache();
const resolveCache = new IncludeResolveCache();
const opts = () => ({
projectDir: project,
sdkDir: sdk,
builtmodsDirs: [join(sdk, "builtmods")],
indexSageXml: true,
additionalDataSearchPaths: [],
walker: new CachedDirectoryWalker(),
documentCache,
recordsCache,
resolveCache,
});
// Trusted rebuild: cached files are reused without any per-file stat/read.
const first = await new ModIndexer({ ...opts(), trustUnchanged: true }).build();
assert.equal(first.stats.shallowScannedFiles, 2);
const second = await new ModIndexer({ ...opts(), trustUnchanged: true }).build();
assert.equal(second.stats.shallowScannedFiles, 0);
assert.equal(second.stats.shallowCacheHits, 2);
assert.ok(second.stats.recordsCacheHits > 0);
// Simulate the file watcher / save handler: invalidate one art asset.
// The next trusted rebuild must re-scan exactly that file.
recordsCache.invalidate(join(project, "Data", "Includes", "Models", "Tank_SKN.w3x"));
const third = await new ModIndexer({ ...opts(), trustUnchanged: true }).build();
assert.equal(third.stats.shallowScannedFiles, 1);
assert.equal(third.stats.shallowCacheHits, 1);
assert.ok(
third.assetsById.get("tank_skn")?.some((d) => d.type === "W3DContainer"),
"re-scanned w3x asset present",
);
// Invalidate one XML file: exactly its records are re-extracted.
recordsCache.invalidate(join(project, "Data", "Includes", "Units.xml"));
const fourth = await new ModIndexer({ ...opts(), trustUnchanged: true }).build();
assert.equal(fourth.stats.recordsCacheHits, third.stats.recordsCacheHits - 1);
assert.ok(
fourth.assetsById.get("testtank")?.some((d) => d.type === "GameObject"),
"re-parsed XML asset present",
);
// A forced rebuild (ra3modxml.reindex) verifies stats but still reuses
// content caches for unchanged files.
const forced = await new ModIndexer({ ...opts(), trustUnchanged: false }).build();
assert.equal(forced.stats.shallowScannedFiles, 0);
assert.equal(forced.stats.shallowCacheHits, 2);
});
test("index stats include candidate/walk phase timings", async () => {
const idx = await buildIndex();
assert.equal(typeof idx.stats.candidatesMs, "number");
assert.equal(typeof idx.stats.walkMs, "number");
});
test("w3x with a UTF-8 BOM is indexed with correct offsets", async (t) => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), "ra3modxml-bom-"));
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
const projectDir = join(tmp, "project");
fs.mkdirSync(join(projectDir, "Data", "Models"), { recursive: true });
fs.writeFileSync(
join(projectDir, "Data", "Mod.xml"),
`<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration>
<Includes>
<Include type="all" source="Models/Tank_BOM.w3x"/>
</Includes>
</AssetDeclaration>`,
"utf8",
);
fs.writeFileSync(
join(projectDir, "Data", "Models", "Tank_BOM.w3x"),
"\uFEFF<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
"<AssetDeclaration>\n" +
" <W3DContainer id=\"Tank_BOM\" />\n" +
"</AssetDeclaration>\n",
"utf8",
);
const idx = await new ModIndexer({
projectDir,
sdkDir: sdk,
builtmodsDirs: [join(sdk, "builtmods")],
indexSageXml: false,
additionalDataSearchPaths: [],
walker: new CachedDirectoryWalker(),
}).build();
const def = idx.assetsById.get("tank_bom")?.find((d) => d.type === "W3DContainer");
assert.ok(def, "BOM-prefixed w3x asset indexed");
assert.equal(def.line, 3, "id line is correct despite the BOM");
});
+65
View File
@@ -0,0 +1,65 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseXml, LineMap } from "../out/language/xmlParser.js";
import { extractIndexRecords, recordsFromShallow } from "../out/indexer/records.js";
import { scanXmlShallow } from "../out/indexer/shallowScan.js";
test("extractIndexRecords mirrors the walk semantics", () => {
const text = `<?xml version="1.0"?>
<AssetDeclaration>
<Defines>
<Define name="HP" value="100"/>
</Defines>
<Includes>
<Include type="all" source="Units.xml"/>
<Include type="instance" source="Base.xml"/>
</Includes>
<GameObject id="Tank" CommandSet="TankCommandSet"/>
<WeaponTemplate id="TankGun"/>
<xi:include href="DATA:Extra.xml" xpointer="xmlns(n=uri:ea.com:eala:asset) xpointer(/n:Extra/child::*)"/>
<GameObject id="Tank2">
<Draws>
<xi:include href="DATA:Nested.xml"/>
</Draws>
</GameObject>
</AssetDeclaration>`;
const lineMap = new LineMap(text);
const records = extractIndexRecords(parseXml(text), lineMap);
assert.deepEqual(
records.assets.map((a) => [a.type, a.id, a.line]),
[
["GameObject", "Tank", 10],
["WeaponTemplate", "TankGun", 11],
["GameObject", "Tank2", 13],
],
);
assert.deepEqual(
records.defines.map((d) => [d.name, d.value]),
[["HP", "100"]],
);
assert.deepEqual(
records.includes.map((i) => [i.type, i.source]),
[
["all", "Units.xml"],
["instance", "Base.xml"],
],
);
assert.deepEqual(
records.rootXiIncludes.map((x) => [x.href, x.line]),
[["DATA:Extra.xml", 12]],
);
assert.deepEqual(
records.nestedXiIncludes.map((x) => [x.href, x.line]),
[["DATA:Nested.xml", 15]],
);
});
test("recordsFromShallow converts offsets to 1-based lines", () => {
const text = `<AssetDeclaration>\n <W3DContainer id="A"/>\n</AssetDeclaration>`;
const lineMap = new LineMap(text);
const records = recordsFromShallow(scanXmlShallow(text), lineMap);
assert.equal(records.assets.length, 1);
assert.equal(records.assets[0].type, "W3DContainer");
assert.equal(records.assets[0].id, "A");
assert.equal(records.assets[0].line, 2);
});
+122
View File
@@ -0,0 +1,122 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { scanXmlShallow } from "../out/indexer/shallowScan.js";
test("extracts top-level assets, includes, defines and xi:include", () => {
const text = `<?xml version="1.0"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<Defines>
<Define name="MODEL_SCALE" value="1.0"/>
</Defines>
<Includes>
<Include type="all" source="Art/Model_SKN.w3x"/>
<Include type="reference" source="DATA:static.xml"/>
</Includes>
<W3DContainer id="Model_SKN" Hierarchy="Model_SKL">
<SubObject SubObjectID="GUN">
<RenderObject><Mesh>Model_SKN.GUN</Mesh></RenderObject>
</SubObject>
</W3DContainer>
<W3DMesh id="Model_SKN.GUN"/>
<xi:include href="DATA:Includes/Extra.xml" xpointer="xmlns(n=uri:ea.com:eala:asset) xpointer(/n:Extra/child::*)"/>
</AssetDeclaration>`;
const doc = scanXmlShallow(text);
assert.equal(doc.errors.length, 0);
assert.deepEqual(
doc.assets.map((a) => `${a.name}:${a.id}`),
["W3DContainer:Model_SKN", "W3DMesh:Model_SKN.GUN"],
);
assert.equal(doc.includes.length, 2);
assert.equal(doc.includes[0].type, "all");
assert.equal(doc.includes[0].source, "Art/Model_SKN.w3x");
assert.equal(doc.includes[1].type, "reference");
assert.equal(doc.defines.length, 1);
assert.equal(doc.defines[0].name, "MODEL_SCALE");
assert.equal(doc.defines[0].value, "1.0");
assert.equal(doc.rootXiIncludes.length, 1);
assert.equal(doc.rootXiIncludes[0].href, "DATA:Includes/Extra.xml");
assert.match(doc.rootXiIncludes[0].xpointer, /xpointer\(/);
assert.equal(doc.nestedXiIncludes.length, 0);
// Recorded offsets must slice the original text back out.
const container = doc.assets[0];
assert.equal(text.slice(container.idValueStart, container.idValueEnd), "Model_SKN");
assert.equal(text.slice(container.start, container.startTagEnd).startsWith("<W3DContainer"), true);
const mesh = doc.assets[1];
assert.equal(text.slice(mesh.idValueStart, mesh.idValueEnd), "Model_SKN.GUN");
});
test("handles > and / inside quoted values, self-closing tags and comments", () => {
const text = `<?xml version="1.0"?>
<!-- a > comment with < inside -->
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<W3DContainer id="Weird" Description="a > b < c" />
<W3DMesh id="X" VertexData="a / b"/>
</AssetDeclaration>`;
const doc = scanXmlShallow(text);
assert.equal(doc.errors.length, 0);
assert.deepEqual(
doc.assets.map((a) => a.id),
["Weird", "X"],
);
});
test("skips CDATA and processing instructions", () => {
const text = `<AssetDeclaration>
<!-- <W3DMesh id="FAKE"/> -->
<![CDATA[ <W3DMesh id="FAKE2"/> ]]>
<?xml-stylesheet href="x"?>
<W3DContainer id="REAL"/>
</AssetDeclaration>`;
const doc = scanXmlShallow(text);
assert.equal(doc.errors.length, 0);
assert.deepEqual(
doc.assets.map((a) => a.id),
["REAL"],
);
});
test("reports unterminated tags and keeps partial results", () => {
const doc = scanXmlShallow('<AssetDeclaration><W3DMesh id="A"/><W3DMesh id="B"');
assert.ok(doc.errors.length >= 1);
assert.deepEqual(
doc.assets.map((a) => a.id),
["A"],
);
});
test("nested module payload does not create asset records", () => {
// Only top-level elements with an id are assets; the hundreds of thousands
// of numeric V/T elements inside a mesh are not.
const doc = scanXmlShallow(`<AssetDeclaration>
<W3DMesh id="MESH">
<Vertices><V X="1" Y="2" Z="3"/><V X="4" Y="5" Z="6"/></Vertices>
<Triangles><T A="0" B="1" C="2"/></Triangles>
</W3DMesh>
</AssetDeclaration>`);
assert.deepEqual(
doc.assets.map((a) => a.id),
["MESH"],
);
});
test("distinguishes root-level and nested xi:include", () => {
const doc = scanXmlShallow(`<AssetDeclaration>
<xi:include href="DATA:Top.xml"/>
<W3DMesh id="MESH">
<SubObject>
<RenderObject>
<xi:include href="DATA:Nested.xml" xpointer="xmlns(n=uri:ea.com:eala:asset) xpointer(/n:X/child::*)"/>
</RenderObject>
</SubObject>
</W3DMesh>
</AssetDeclaration>`);
assert.deepEqual(
doc.rootXiIncludes.map((x) => x.href),
["DATA:Top.xml"],
);
assert.deepEqual(
doc.nestedXiIncludes.map((x) => x.href),
["DATA:Nested.xml"],
);
});
+6 -1
View File
@@ -1,6 +1,11 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { parseXml, findElementAt } from "../out/language/xmlParser.js"; import { parseXml, findElementAt, stripBom } from "../out/language/xmlParser.js";
test("stripBom removes a leading UTF-8 byte-order mark", () => {
assert.equal(stripBom("\uFEFF<A/>"), "<A/>");
assert.equal(stripBom("<A/>"), "<A/>");
});
test("parses elements, attributes and positions", () => { test("parses elements, attributes and positions", () => {
const text = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">\n\t<GameObject id="X" KindOf="A B"/>\n</AssetDeclaration>`; const text = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">\n\t<GameObject id="X" KindOf="A B"/>\n</AssetDeclaration>`;