feat(plugins): migrate automap-reveal (#714), lighting-presets (#715), extended-viewports (#716) and remove packet-inspector (#717)

This commit is contained in:
troytt 2026-10-03 12:46:14 +00:00
parent 290f433104
commit fdde06b9cb
28 changed files with 1521 additions and 990 deletions

View File

@ -19,8 +19,8 @@
| :--- | :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- | :--- |
| [`src/common`](./src/common) | `D2Common.dll` `Storm.dll` `D2Lang.dll` | 纯 TypeScript (`ES2022`),零 DOM / 零 Node.js 依赖 | MPQ 与原生二进制格式解析器 (DC6/DCC/DT1/DS1/COF/PL2/TBL)、`D2DataRegistry` 数据表中心、1.13c DRLG 随机地图生成算法、64 位 `D2Rng` 与烘焙校验契约 (`pack-contract`) | [src/common/README.md](./src/common/README.md) | | [`src/common`](./src/common) | `D2Common.dll` `Storm.dll` `D2Lang.dll` | 纯 TypeScript (`ES2022`),零 DOM / 零 Node.js 依赖 | MPQ 与原生二进制格式解析器 (DC6/DCC/DT1/DS1/COF/PL2/TBL)、`D2DataRegistry` 数据表中心、1.13c DRLG 随机地图生成算法、64 位 `D2Rng` 与烘焙校验契约 (`pack-contract`) | [src/common/README.md](./src/common/README.md) |
| [`src/netproto`](./src/netproto) | `D2Net.dll` `Bnclient.dll` `D2MCPClient.dll` `Fog.dll` | 纯协议栈模块,零外部模块依赖,唯一入口 `src/netproto/index.ts` | BNCS (`6112`) / MCP (`6113`) / D2GS (`4000`) 二进制协议编解码、XSHA-1 与 CheckRevision 认证、Fog.dll Huffman 解压、167 个 S2C 操作码解析、物品比特流与 WebSocket/TCP 传输层抽象 | [src/netproto/README.md](./src/netproto/README.md) | | [`src/netproto`](./src/netproto) | `D2Net.dll` `Bnclient.dll` `D2MCPClient.dll` `Fog.dll` | 纯协议栈模块,零外部模块依赖,唯一入口 `src/netproto/index.ts` | BNCS (`6112`) / MCP (`6113`) / D2GS (`4000`) 二进制协议编解码、XSHA-1 与 CheckRevision 认证、Fog.dll Huffman 解压、167 个 S2C 操作码解析、物品比特流与 WebSocket/TCP 传输层抽象 | [src/netproto/README.md](./src/netproto/README.md) |
| [`src/client`](./src/client) | `D2Client.dll` `D2Gfx.dll` `D2Win.dll` `D2Launch.dll` | 纯浏览器客户端 (`DOM` + `WebGL2`),零 Node / 零 `server` / 零 `baker` / 零 `plugins` 依赖 | 游戏主入口 `play.html`、战网登录/选人/大厅前端界面、`OnlineSession` 与 `ClientWorld` 状态镜像、非侵入式插件宿主 (`plugin-host/`)、种子驱动的实时 DRLG 地图重建、移动预测与内插、WebGL2 渲染器、全套经典 HUD 面板与抓包检查器 | [src/client/README.md](./src/client/README.md) | | [`src/client`](./src/client) | `D2Client.dll` `D2Gfx.dll` `D2Win.dll` `D2Launch.dll` | 纯浏览器客户端 (`DOM` + `WebGL2`),零 Node / 零 `server` / 零 `baker` / 零 `plugins` 依赖 | 游戏主入口 `play.html`、战网登录/选人/大厅前端界面、`OnlineSession` 与 `ClientWorld` 状态镜像、非侵入式插件宿主 (`plugin-host/`)、种子驱动的实时 DRLG 地图重建、移动预测与内插、WebGL2 渲染器与全套经典 HUD 面板 | [src/client/README.md](./src/client/README.md) |
| [`plugins/`](./plugins) | 客户端外挂扩展生态 | 浏览器扩展层 (`DOM`),仅依赖 `src/common`、`src/netproto/index.ts` 与 `src/client/plugin-host/api.ts`,插件间严格物理隔离 | 独立于核心客户端的可拔插辅助扩展(如 `plugins/kill-stats/` 实时怪物击杀统计与独立页面 `plugins/kill-stats/index.html`),支持通过顶部工具栏 **Plugins** 菜单动态启停与快捷按钮配置 | [plugins/README.md](./plugins/README.md) | | [`plugins/`](./plugins) | 客户端外挂扩展生态 | 浏览器扩展层 (`DOM`),仅依赖 `src/common`、`src/netproto/index.ts` 与 `src/client/plugin-host/api.ts`,插件间严格物理隔离 | 独立于核心客户端的可拔插非 1.13c 辅助扩展(`kill-stats` 怪物击杀统计、`automap-reveal` 小地图全图揭示、`lighting-presets` 环境光照预设、`extended-viewports` 扩展高分宽屏视口),支持通过顶部工具栏 **Plugins** 菜单热插拔启停与 1.13c 原版回退 | [plugins/README.md](./plugins/README.md) |
| [`src/server`](./src/server) | `D2Game.dll` | 纯确定性模拟 (`ES2022` + `WebWorker`),禁 DOM / 禁 Node / 禁非确定性随机与时钟 | 25fps (40ms/tick) 权威游戏循环、1.13c 战斗伤害与抗性管线、7 职业 221 个技能执行器、投射物与光环引擎、怪物 AI 与房间流式激活、TC 掉落管线与 `.d2s` 存档读写 | [src/server/README.md](./src/server/README.md) | | [`src/server`](./src/server) | `D2Game.dll` | 纯确定性模拟 (`ES2022` + `WebWorker`),禁 DOM / 禁 Node / 禁非确定性随机与时钟 | 25fps (40ms/tick) 权威游戏循环、1.13c 战斗伤害与抗性管线、7 职业 221 个技能执行器、投射物与光环引擎、怪物 AI 与房间流式激活、TC 掉落管线与 `.d2s` 存档读写 | [src/server/README.md](./src/server/README.md) |
| [`src/baker`](./src/baker) | 离线资源构建管线 | 纯 Node.js CLI 环境 (`types: ["node"]`),禁 DOM / 禁 `client` / 禁 `server` | 从原版 1.13c MPQ 归档中提取并烘焙瓦片图集 (`tiles`)、角色/怪物/NPC 实体 (`entities`)、投射物 (`missiles`)、覆盖层 (`overlays`)、HUD/前端 UI、动画表 (`animdata`) 与 SHA-256 资产清单 (`asset-manifest.json`) | [src/baker/README.md](./src/baker/README.md) | | [`src/baker`](./src/baker) | 离线资源构建管线 | 纯 Node.js CLI 环境 (`types: ["node"]`),禁 DOM / 禁 `client` / 禁 `server` | 从原版 1.13c MPQ 归档中提取并烘焙瓦片图集 (`tiles`)、角色/怪物/NPC 实体 (`entities`)、投射物 (`missiles`)、覆盖层 (`overlays`)、HUD/前端 UI、动画表 (`animdata`) 与 SHA-256 资产清单 (`asset-manifest.json`) | [src/baker/README.md](./src/baker/README.md) |
| [`tools/`](./tools) | 开发者与 QA 工具集 | Node.js / Playwright CLI | 无头协议测试机器人 (`d2-bot.ts`)、`.d2cap` 抓包离线重放器 (`netproto-replay.ts`)、`.d2s` 存档全解锁工具 (`d2s-unlock.ts`)、全谱系 136 关卡浏览器审计 (`audit-levels-browser.ts`) 与 DRLG 差分对比工具 | [tools/README.md](./tools/README.md) | | [`tools/`](./tools) | 开发者与 QA 工具集 | Node.js / Playwright CLI | 无头协议测试机器人 (`d2-bot.ts`)、`.d2cap` 抓包离线重放器 (`netproto-replay.ts`)、`.d2s` 存档全解锁工具 (`d2s-unlock.ts`)、全谱系 136 关卡浏览器审计 (`audit-levels-browser.ts`) 与 DRLG 差分对比工具 | [tools/README.md](./tools/README.md) |

View File

@ -419,15 +419,6 @@
font-size: 11px; font-size: 11px;
flex: 0 0 auto; flex: 0 0 auto;
} }
#d2-inspector-container {
flex: 0 0 auto;
max-height: 100%;
overflow-y: auto;
z-index: 40;
background: #0e0c0a;
border-left: 1px solid #2b2319;
}
</style> </style>
</head> </head>
<body> <body>
@ -464,7 +455,6 @@
<div id="play-disconnect-banner" hidden></div> <div id="play-disconnect-banner" hidden></div>
</div> </div>
</div> </div>
<div id="d2-inspector-container"></div>
</div> </div>
<script type="module"> <script type="module">
import { startPlayApp } from '/src/client/main.ts' import { startPlayApp } from '/src/client/main.ts'

View File

@ -35,19 +35,25 @@
```text ```text
plugins/ plugins/
├── README.md # 插件系统架构说明、API 契约与后续迁移路线图 ├── README.md # 插件系统架构说明、API 契约与内置插件一览
├── index.ts # BUILTIN_PLUGINS 静态注册表(由组合根 play.html 导入并注入 startPlayApp) ├── index.ts # BUILTIN_PLUGINS 静态注册表(由组合根 play.html 导入并注入 startPlayApp)
└── kill-stats/ # 内置插件:怪物击杀统计 (id: 'kill-stats') ├── kill-stats/ # 内置插件:怪物击杀统计 (id: 'kill-stats')
├── index.ts # killStatsPlugin 定义、激活/卸载生命周期、状态汇报与工具栏快捷入口管理 │ ├── index.ts # killStatsPlugin 定义、激活/卸载生命周期、状态汇报与工具栏快捷入口管理
├── index.html # 独立统计页面入口 (/plugins/kill-stats/index.html) │ ├── index.html # 独立统计页面入口 (/plugins/kill-stats/index.html)
├── page-main.ts # 独立页面启动引导(调用 bootPluginPage(killStatsPlugin) 并捕获启动异常) │ ├── page-main.ts # 独立页面启动引导(调用 bootPluginPage(killStatsPlugin) 并捕获启动异常)
├── stats-page.ts # 独立统计页面 DOM 控制器(IndexedDB 读取、BroadcastChannel 同步、导入/导出/删除) │ ├── stats-page.ts # 独立统计页面 DOM 控制器(IndexedDB 读取、BroadcastChannel 同步、导入/导出/删除)
├── kill-tracker.ts # 实时击杀追踪器(监听 D2GS ServerEvent、严格校验角色身份并批量落盘) │ ├── kill-tracker.ts # 实时击杀追踪器(监听 D2GS ServerEvent、严格校验角色身份并批量落盘)
├── kill-stats-model.ts # G-counter CRDT 模型、键值规范化与严格导入校验 (KillStatsImportError) │ ├── kill-stats-model.ts # G-counter CRDT 模型、键值规范化与严格导入校验 (KillStatsImportError)
├── kill-stats-store.ts # IndexedDB ('d2web-kill-stats' v1) 存储仓库实现 │ ├── kill-stats-store.ts # IndexedDB ('d2web-kill-stats' v1) 存储仓库实现
├── kill-stats-view.ts # 86 行首领/超级精英看板与怪物分类表纯视图模型构建器 │ ├── kill-stats-view.ts # 86 行首领/超级精英看板与怪物分类表纯视图模型构建器
├── monster-kill-classes.ts# 基于 MonStats.txt (Align / killable / neverCount) 的可计数怪物分类表 │ ├── monster-kill-classes.ts# 基于 MonStats.txt (Align / killable / neverCount) 的可计数怪物分类表
└── stats-i18n.ts # 统计页面中英双语词典 │ └── stats-i18n.ts # 统计页面中英双语词典
├── automap-reveal/ # 内置插件:小地图全图揭示 (id: 'automap-reveal', Issue #714)
│ └── index.ts # 管理 automapReveal ('off' | 'level' | 'act') 与工具栏下拉快捷框 (#d2-toolbar-automap-select),禁用时强制回退 1.13c 'off'
├── lighting-presets/ # 内置插件:环境光照预设 (id: 'lighting-presets', Issue #715)
│ └── index.ts # 管理 lightingPreset (7 档光照预设) 与工具栏下拉快捷框 (#d2-toolbar-lighting-select),禁用时强制回退 1.13c 'auto'
└── extended-viewports/ # 内置插件:扩展分辨率视口 (id: 'extended-viewports', Issue #716)
└── index.ts # 解锁 1024x768 / 1068x600 / 1280x720 扩展视口,禁用时仅开放 1.13c 原版 640x480 / 800x600 并自动收敛至 800x600
``` ```
--- ---
@ -57,7 +63,12 @@ plugins/
`src/client/plugin-host/api.ts` 是 `plugins/**` 唯一允许导入的 `src/client/` 模块,导出以下核心类型与函数: `src/client/plugin-host/api.ts` 是 `plugins/**` 唯一允许导入的 `src/client/` 模块,导出以下核心类型与函数:
```ts ```ts
export type { GameLang } export type {
AutomapRevealMode,
GameLang,
LightingPresetId,
ViewportResolutionId,
}
export interface LocalizedText { export interface LocalizedText {
readonly zh: string readonly zh: string
@ -121,16 +132,39 @@ export type PluginSessionView = Pick<
'phase' | 'world' | 'loginHost' | 'getSnapshot' | 'onServerEvent' | 'onStateChange' 'phase' | 'world' | 'loginHost' | 'getSnapshot' | 'onServerEvent' | 'onStateChange'
> >
export interface PluginClientCapabilities {
getAutomapReveal(): AutomapRevealMode
setAutomapReveal(mode: AutomapRevealMode): void
subscribeAutomapReveal(fn: (mode: AutomapRevealMode) => void): () => void
getLightingPreset(): LightingPresetId
setLightingPreset(preset: LightingPresetId): void
subscribeLightingPreset(fn: (preset: LightingPresetId) => void): () => void
getViewport(): ViewportResolutionId
setViewport(viewport: ViewportResolutionId): void
getAllowedViewports(): readonly ViewportResolutionId[]
setAllowedViewports(viewports: readonly ViewportResolutionId[]): void
}
export interface PluginContext { export interface PluginContext {
readonly pluginId: string readonly pluginId: string
readonly session: PluginSessionView readonly session: PluginSessionView
readonly client: PluginClientCapabilities
readonly settings: { readonly settings: {
get(key: string): boolean | string get(key: string): boolean | string
set(key: string, value: boolean | string): void
subscribe(fn: (key: string) => void): () => void subscribe(fn: (key: string) => void): () => void
} }
readonly toolbar: { readonly toolbar: {
addLink(spec: { id: string; label: LocalizedText; href: string }): () => void addLink(spec: { id: string; label: LocalizedText; href: string }): () => void
addButton(spec: { id: string; label: LocalizedText; onClick(): void }): () => void addButton(spec: { id: string; label: LocalizedText; onClick(): void }): () => void
addSelect(spec: {
id: string
domId?: string
ariaLabel: LocalizedText
options: readonly { value: string; label: LocalizedText }[]
getValue(): string
onChange(value: string): void
}): () => void
} }
readonly lang: { readonly lang: {
get(): GameLang get(): GameLang
@ -160,7 +194,7 @@ export function bootPluginPage(plugin: D2WebPlugin): PluginPageContext
### 宿主生命周期与故障隔离保证(`PluginHost`) ### 宿主生命周期与故障隔离保证(`PluginHost`)
- **启动期快速失败校验(`validatePluginRegistry`)**:校验每个插件的 kebab-case `id` 唯一性、非空双语 `name`/`description`、唯一设置项 `key`、`select` 默认值合法性以及相对路径 `link.href`(拒绝 `http://`、`https://`、`//`、`javascript:` 或 `/` 开头路径)。 - **启动期快速失败校验(`validatePluginRegistry`)**:校验每个插件的 kebab-case `id` 唯一性、非空双语 `name`/`description`、唯一设置项 `key`、`select` 默认值合法性以及相对路径 `link.href`(拒绝 `http://`、`https://`、`//`、`javascript:` 或 `/` 开头路径)。
- **串行化热插拔(`setEnabled(id, enabled)`)**:同一插件的启停切换按序串行收敛;`deactivate` 时等待 `await instance.deactivate()`(例如 `kill-stats` 会先执行 `await tracker.flushNow()` 落盘待写入击杀增量),随后宿主自动清理该插件注册的全部工具栏按钮/链接(`#d2-toolbar-plugin-slot`)及设置/语言订阅。 - **串行化热插拔与 1.13c 原版回退(`setEnabled(id, enabled)`)**:同一插件的启停切换按序串行收敛;`deactivate` 时等待 `await instance.deactivate()`(例如 `kill-stats` 会先执行 `await tracker.flushNow()` 落盘待写入击杀增量),自动清理该插件注册的全部工具栏控件(`#d2-toolbar-plugin-slot`)及订阅,并自动将失去插件声明支撑的客户端能力回退至 1.13c 原版状态(`automapReveal -> 'off'`、`lightingPreset -> 'auto'`、`allowedViewports -> ['640x480', '800x600']`)。
- **插件级异常隔离**:`activate`、`deactivate`、动作回调或订阅回调抛错/reject 时,宿主通过 `console.error` 记录堆栈、将该插件状态标记为 `level: 'error'` 并在工具栏 `#d2-toolbar-plugins-btn` 显示 `⚠`,绝不中断主游戏循环或其他插件。 - **插件级异常隔离**:`activate`、`deactivate`、动作回调或订阅回调抛错/reject 时,宿主通过 `console.error` 记录堆栈、将该插件状态标记为 `level: 'error'` 并在工具栏 `#d2-toolbar-plugins-btn` 显示 `⚠`,绝不中断主游戏循环或其他插件。
- **独立页面引导(`bootPluginPage(plugin)`)**:供 `plugins/<id>/page-main.ts` 调用,无需直接导入 `src/client/i18n/lang.ts` 或 `src/client/settings/settings-store.ts`,自动解析语言(URL `?lang=` > `localStorage['d2web.settings.v1']` > `<html lang>`)并通过 `storage` 事件实时同步语言与 `localStorage['d2web.plugins.v1']` 中的插件启停/设置状态。 - **独立页面引导(`bootPluginPage(plugin)`)**:供 `plugins/<id>/page-main.ts` 调用,无需直接导入 `src/client/i18n/lang.ts` 或 `src/client/settings/settings-store.ts`,自动解析语言(URL `?lang=` > `localStorage['d2web.settings.v1']` > `<html lang>`)并通过 `storage` 事件实时同步语言与 `localStorage['d2web.plugins.v1']` 中的插件启停/设置状态。
@ -185,12 +219,12 @@ export function bootPluginPage(plugin: D2WebPlugin): PluginPageContext
--- ---
## 6. 已迁移插件与后续插件迁移 Gitea Issues(#714–#717) ## 6. 内置插件与 Gitea Issues(#714–#717)
| 插件目录 | 状态 | 追踪链接 | 说明 | | 插件目录 / 事项 | 状态 | 追踪链接 | 说明 |
| :--- | :---: | :--- | :--- | | :--- | :---: | :--- | :--- |
| [`plugins/kill-stats/`](./kill-stats) | ✅ 已迁移 | 本轮已落地 | 怪物击杀统计插件与独立统计页面(`/plugins/kill-stats/index.html`),按 `(host, realm, account, charName, hardcore)` 追踪普通/噩梦/地狱难度下的怪物、首领与超级精英击杀数。 | | [`plugins/kill-stats/`](./kill-stats) | ✅ 已落地 | 已上线 | 怪物击杀统计插件与独立统计页面(`/plugins/kill-stats/index.html`),按 `(host, realm, account, charName, hardcore)` 追踪普通/噩梦/地狱难度下的怪物、首领与超级精英击杀数。 |
| `plugins/automap-reveal` | 📋 待迁移 | [Issue #714](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/714) (`https://git.projectdiablo2.cn/troytt/diablo2-web/issues/714`) | 将小地图全图揭示增强功能(Automap Reveal)从核心客户端解耦迁移至 `plugins/automap-reveal`。 | | [`plugins/automap-reveal/`](./automap-reveal) | ✅ 已落地 | [Issue #714](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/714) | 小地图全图揭示增强插件(`off / level / act`),可选在顶部工具栏显示 `#d2-toolbar-automap-select` 快捷下拉框;禁用时强制恢复 1.13c 原版战争迷雾(`off`)。 |
| `plugins/lighting-presets` | 📋 待迁移 | [Issue #715](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/715) (`https://git.projectdiablo2.cn/troytt/diablo2-web/issues/715`) | 将分幕环境光照预设增强功能(Lighting Presets)从核心客户端解耦迁移至 `plugins/lighting-presets`。 | | [`plugins/lighting-presets/`](./lighting-presets) | ✅ 已落地 | [Issue #715](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/715) | 环境光照预设增强插件(`auto / noon / dusk / night / torch / cold / fullbright`),可选在顶部工具栏显示 `#d2-toolbar-lighting-select` 快捷下拉框;禁用时强制恢复 1.13c 原版关卡环境光(`auto`)。 |
| `plugins/extended-viewports` | 📋 待迁移 | [Issue #716](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/716) (`https://git.projectdiablo2.cn/troytt/diablo2-web/issues/716`) | 将非原版扩展分辨率视口预设(Extended Viewports,如宽屏/高分辨率档位)解耦迁移至 `plugins/extended-viewports`。 | | [`plugins/extended-viewports/`](./extended-viewports) | ✅ 已落地 | [Issue #716](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/716) | 扩展分辨率视口插件,启用时在工具栏分辨率下拉框中解锁 `1024x768`、`1068x600`、`1280x720`;禁用时仅保留 1.13c 原版 `640x480` 与 `800x600`,并将扩展分辨率自动收敛回 `800x600`。 |
| `plugins/packet-inspector` | 📋 待迁移 | [Issue #717](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/717) (`https://git.projectdiablo2.cn/troytt/diablo2-web/issues/717`) | 将实时网络封包检查器与录制回放面板(Packet Inspector)解耦迁移至 `plugins/packet-inspector`。 | | 移除封包检查器 (`PacketInspector`) | ✅ 已移除 | [Issue #717](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/717) | 按精简要求彻底移除 `src/client/inspector/packet-inspector.ts` 与工具栏检查器按钮(保留 `OnlineSession` 的 `.d2cap` 离线录制重放能力)。 |

View File

@ -0,0 +1,178 @@
/**
* Automap Reveal Plugin (`plugins/automap-reveal/index.ts` — Issue #714).
*
* Isolates the non-1.13c full-level (`level`) and full-act (`act`) automap reveal
* enhancement outside core `src/`. When disabled, `PluginHost` releases the capability
* claim and strictly enforces 1.13c fog-of-war (`automapReveal = 'off'`).
*/
import {
VALID_AUTOMAP_REVEAL_MODES,
type AutomapRevealMode,
type D2WebPlugin,
type LocalizedText,
type PluginContext,
type PluginInstance,
type PluginStatus,
type PluginToolbarSelectOption,
} from '../../src/client/plugin-host/api.ts'
const AUTOMAP_MODE_LABELS: Readonly<Record<AutomapRevealMode, LocalizedText>> = {
off: {
zh: '关闭',
en: 'Off',
},
level: {
zh: '本层全开',
en: 'Current Level',
},
act: {
zh: '整幕全开',
en: 'Full Act',
},
}
const AUTOMAP_SELECT_OPTIONS: readonly PluginToolbarSelectOption[] =
VALID_AUTOMAP_REVEAL_MODES.map((mode) => ({
value: mode,
label: AUTOMAP_MODE_LABELS[mode],
}))
function isAutomapRevealMode(value: unknown): value is AutomapRevealMode {
return (
typeof value === 'string' &&
(VALID_AUTOMAP_REVEAL_MODES as readonly string[]).includes(value)
)
}
export interface AutomapRevealPluginInstance extends PluginInstance {
getMode(): AutomapRevealMode
setMode(mode: AutomapRevealMode): void
}
export function createAutomapRevealPlugin(): D2WebPlugin {
return {
id: 'automap-reveal',
name: {
zh: '小地图全图透视',
en: 'Automap Reveal',
},
description: {
zh: '非 1.13c 增强:支持一键揭开本层或整幕全部小地图房间与传送点标记。关闭插件后严格恢复 1.13c 原版战争迷雾(仅显示服务端已探索区域)。',
en: 'Non-1.13c enhancement: reveals all rooms and warp markers for the current level or entire Act. When disabled, strictly enforces 1.13c server-driven fog of war.',
},
defaultEnabled: true,
settings: [
{
kind: 'select',
key: 'mode',
label: {
zh: '透视范围',
en: 'Reveal Scope',
},
default: 'off',
options: AUTOMAP_SELECT_OPTIONS,
},
{
kind: 'boolean',
key: 'showToolbarShortcut',
label: {
zh: '在工具栏显示快捷下拉框',
en: 'Show selector in toolbar',
},
default: true,
},
],
activate(ctx: PluginContext): AutomapRevealPluginInstance {
const storedSetting = ctx.settings.get('mode')
const initialMode: AutomapRevealMode = isAutomapRevealMode(storedSetting)
? storedSetting
: 'off'
ctx.client.setAutomapReveal(initialMode)
const unsubClient = ctx.client.subscribeAutomapReveal((clientMode) => {
if (ctx.settings.get('mode') !== clientMode) {
ctx.settings.set('mode', clientMode)
}
ctx.notifyStatusChanged()
})
let removeToolbarSelect: (() => void) | null = null
const syncToolbarShortcut = (): void => {
const show = ctx.settings.get('showToolbarShortcut') === true
if (show && !removeToolbarSelect) {
removeToolbarSelect = ctx.toolbar.addSelect({
id: 'mode',
selectDomId: 'd2-toolbar-automap-select',
label: {
zh: '小地图',
en: 'Automap',
},
options: AUTOMAP_SELECT_OPTIONS,
getValue: () => {
const raw = ctx.settings.get('mode')
return isAutomapRevealMode(raw) ? raw : 'off'
},
onChange: (value: string) => {
if (!isAutomapRevealMode(value)) {
throw new Error(`Invalid automap reveal mode: ${value}`)
}
ctx.settings.set('mode', value)
},
})
} else if (!show && removeToolbarSelect) {
removeToolbarSelect()
removeToolbarSelect = null
}
}
syncToolbarShortcut()
const unsubSettings = ctx.settings.subscribe((key) => {
if (key === 'mode') {
const next = ctx.settings.get('mode')
if (isAutomapRevealMode(next)) {
ctx.client.setAutomapReveal(next)
ctx.notifyStatusChanged()
}
} else if (key === 'showToolbarShortcut') {
syncToolbarShortcut()
}
})
const getStatus = (): PluginStatus => {
const mode = ctx.client.getAutomapReveal()
const label = AUTOMAP_MODE_LABELS[mode]
return {
level: 'ok',
text: {
zh: `当前:${label.zh}`,
en: `Mode: ${label.en}`,
},
}
}
return {
getMode: () => ctx.client.getAutomapReveal(),
setMode: (mode: AutomapRevealMode) => {
if (!isAutomapRevealMode(mode)) {
throw new Error(`Invalid automap reveal mode: ${String(mode)}`)
}
ctx.settings.set('mode', mode)
},
getStatus,
deactivate(): void {
unsubClient()
unsubSettings()
if (removeToolbarSelect) {
removeToolbarSelect()
removeToolbarSelect = null
}
},
}
},
}
}
export const automapRevealPlugin: D2WebPlugin = createAutomapRevealPlugin()

View File

@ -0,0 +1,127 @@
/**
* Extended Viewports Plugin (`plugins/extended-viewports/index.ts` — Issue #716).
*
* Isolates the non-1.13c extended resolutions (`1024x768`, `1068x600`, `1280x720`)
* outside core `src/`. When enabled, unlocks all 5 resolutions in the toolbar viewport
* selector; when disabled, `PluginHost` restricts allowed viewports to native 1.13c
* `640x480` and `800x600` and clamps any extended resolution back to `800x600`.
*/
import {
VALID_VIEWPORT_RESOLUTIONS,
formatViewportLabel,
type D2WebPlugin,
type PluginContext,
type PluginInstance,
type PluginStatus,
type PluginToolbarSelectOption,
type ViewportResolution,
} from '../../src/client/plugin-host/api.ts'
const VIEWPORT_SELECT_OPTIONS: readonly PluginToolbarSelectOption[] =
VALID_VIEWPORT_RESOLUTIONS.map((vp) => {
const label = formatViewportLabel(vp)
return {
value: vp,
label: {
zh: label,
en: label,
},
}
})
function isViewportResolution(value: unknown): value is ViewportResolution {
return (
typeof value === 'string' &&
(VALID_VIEWPORT_RESOLUTIONS as readonly string[]).includes(value)
)
}
export interface ExtendedViewportsPluginInstance extends PluginInstance {
getViewport(): ViewportResolution
setViewport(viewport: ViewportResolution): void
}
export function createExtendedViewportsPlugin(): D2WebPlugin {
return {
id: 'extended-viewports',
name: {
zh: '扩展高分与宽屏视口',
en: 'Extended Viewports',
},
description: {
zh: '非 1.13c 增强:解锁 1024×768、1068×600 (16:9) 与 1280×720 (HD) 扩展视口。关闭插件后仅开放 1.13c 原版 640×480 与 800×600 分辨率,并自动回退至 800×600。',
en: 'Non-1.13c enhancement: unlocks 1024×768, 1068×600 (16:9), and 1280×720 (HD) viewports. When disabled, restricts the client to native 1.13c 640×480 and 800×600 resolutions.',
},
defaultEnabled: true,
settings: [
{
kind: 'select',
key: 'viewport',
label: {
zh: '当前视口分辨率',
en: 'Active Viewport',
},
default: '800x600',
options: VIEWPORT_SELECT_OPTIONS,
},
],
activate(ctx: PluginContext): ExtendedViewportsPluginInstance {
// Unlock all 5 viewport profiles (native 640x480, 800x600 + extended 1024x768, 1068x600, 1280x720)
ctx.client.setAllowedViewports(VALID_VIEWPORT_RESOLUTIONS)
const storedSetting = ctx.settings.get('viewport')
const initialViewport: ViewportResolution = isViewportResolution(storedSetting)
? storedSetting
: '800x600'
ctx.client.setViewport(initialViewport)
const unsubClient = ctx.client.subscribeViewport((clientVp) => {
if (ctx.settings.get('viewport') !== clientVp) {
ctx.settings.set('viewport', clientVp)
}
ctx.notifyStatusChanged()
})
const unsubSettings = ctx.settings.subscribe((key) => {
if (key === 'viewport') {
const next = ctx.settings.get('viewport')
if (isViewportResolution(next)) {
ctx.client.setViewport(next)
ctx.notifyStatusChanged()
}
}
})
const getStatus = (): PluginStatus => {
const vp = ctx.client.getViewport()
const label = formatViewportLabel(vp)
return {
level: 'ok',
text: {
zh: `当前:${label}`,
en: `Viewport: ${label}`,
},
}
}
return {
getViewport: () => ctx.client.getViewport(),
setViewport: (viewport: ViewportResolution) => {
if (!isViewportResolution(viewport)) {
throw new Error(`Invalid viewport resolution: ${String(viewport)}`)
}
ctx.settings.set('viewport', viewport)
},
getStatus,
deactivate(): void {
unsubClient()
unsubSettings()
},
}
},
}
}
export const extendedViewportsPlugin: D2WebPlugin = createExtendedViewportsPlugin()

View File

@ -3,6 +3,14 @@
*/ */
import type { D2WebPlugin } from '../src/client/plugin-host/api.ts' import type { D2WebPlugin } from '../src/client/plugin-host/api.ts'
import { automapRevealPlugin } from './automap-reveal/index.ts'
import { extendedViewportsPlugin } from './extended-viewports/index.ts'
import { killStatsPlugin } from './kill-stats/index.ts' import { killStatsPlugin } from './kill-stats/index.ts'
import { lightingPresetsPlugin } from './lighting-presets/index.ts'
export const BUILTIN_PLUGINS: readonly D2WebPlugin[] = [killStatsPlugin] export const BUILTIN_PLUGINS: readonly D2WebPlugin[] = [
killStatsPlugin,
automapRevealPlugin,
lightingPresetsPlugin,
extendedViewportsPlugin,
]

View File

@ -0,0 +1,170 @@
/**
* Lighting Presets Plugin (`plugins/lighting-presets/index.ts` — Issue #715).
*
* Isolates the non-1.13c manual lighting preset selector (`noon`, `dusk`, `night`,
* `torch`, `cold`, `fullbright`) outside core `src/`. When disabled, `PluginHost`
* releases the capability claim and strictly enforces 1.13c native level lighting (`auto`).
*/
import {
CLIENT_LIGHTING_PRESET_ORDER,
LIGHTING_PRESETS,
VALID_LIGHTING_PRESETS,
type ClientLightingPreset,
type D2WebPlugin,
type PluginContext,
type PluginInstance,
type PluginStatus,
type PluginToolbarSelectOption,
} from '../../src/client/plugin-host/api.ts'
const LIGHTING_SELECT_OPTIONS: readonly PluginToolbarSelectOption[] =
CLIENT_LIGHTING_PRESET_ORDER.map((presetId) => {
const cfg = LIGHTING_PRESETS[presetId]
return {
value: presetId,
label: {
zh: cfg.labelZh,
en: cfg.labelEn,
},
}
})
function isLightingPreset(value: unknown): value is ClientLightingPreset {
return (
typeof value === 'string' &&
(VALID_LIGHTING_PRESETS as readonly string[]).includes(value)
)
}
export interface LightingPresetsPluginInstance extends PluginInstance {
getPreset(): ClientLightingPreset
setPreset(preset: ClientLightingPreset): void
}
export function createLightingPresetsPlugin(): D2WebPlugin {
return {
id: 'lighting-presets',
name: {
zh: '光照预设切换',
en: 'Lighting Presets',
},
description: {
zh: '非 1.13c 增强:允许手动切换全局环境光与火把照明预设(正午/黄昏/黑夜/火把/寒光/全亮)。关闭插件后严格恢复 1.13c 原版关卡环境光(自动)。',
en: 'Non-1.13c enhancement: manually override ambient RGB and player torch radius (Noon, Dusk, Night, Torch, Cold, Fullbright). When disabled, strictly enforces 1.13c level lighting (Auto).',
},
defaultEnabled: true,
settings: [
{
kind: 'select',
key: 'preset',
label: {
zh: '光照模式',
en: 'Lighting Mode',
},
default: 'auto',
options: LIGHTING_SELECT_OPTIONS,
},
{
kind: 'boolean',
key: 'showToolbarShortcut',
label: {
zh: '在工具栏显示快捷下拉框',
en: 'Show selector in toolbar',
},
default: true,
},
],
activate(ctx: PluginContext): LightingPresetsPluginInstance {
const storedSetting = ctx.settings.get('preset')
const initialPreset: ClientLightingPreset = isLightingPreset(storedSetting)
? storedSetting
: 'auto'
ctx.client.setLightingPreset(initialPreset)
const unsubClient = ctx.client.subscribeLightingPreset((clientPreset) => {
if (ctx.settings.get('preset') !== clientPreset) {
ctx.settings.set('preset', clientPreset)
}
ctx.notifyStatusChanged()
})
let removeToolbarSelect: (() => void) | null = null
const syncToolbarShortcut = (): void => {
const show = ctx.settings.get('showToolbarShortcut') === true
if (show && !removeToolbarSelect) {
removeToolbarSelect = ctx.toolbar.addSelect({
id: 'preset',
selectDomId: 'd2-toolbar-lighting-select',
label: {
zh: '光照',
en: 'Lighting',
},
options: LIGHTING_SELECT_OPTIONS,
getValue: () => {
const raw = ctx.settings.get('preset')
return isLightingPreset(raw) ? raw : 'auto'
},
onChange: (value: string) => {
if (!isLightingPreset(value)) {
throw new Error(`Invalid lighting preset: ${value}`)
}
ctx.settings.set('preset', value)
},
})
} else if (!show && removeToolbarSelect) {
removeToolbarSelect()
removeToolbarSelect = null
}
}
syncToolbarShortcut()
const unsubSettings = ctx.settings.subscribe((key) => {
if (key === 'preset') {
const next = ctx.settings.get('preset')
if (isLightingPreset(next)) {
ctx.client.setLightingPreset(next)
ctx.notifyStatusChanged()
}
} else if (key === 'showToolbarShortcut') {
syncToolbarShortcut()
}
})
const getStatus = (): PluginStatus => {
const preset = ctx.client.getLightingPreset()
const cfg = LIGHTING_PRESETS[preset]
return {
level: 'ok',
text: {
zh: `当前:${cfg.labelZh}`,
en: `Preset: ${cfg.labelEn}`,
},
}
}
return {
getPreset: () => ctx.client.getLightingPreset(),
setPreset: (preset: ClientLightingPreset) => {
if (!isLightingPreset(preset)) {
throw new Error(`Invalid lighting preset: ${String(preset)}`)
}
ctx.settings.set('preset', preset)
},
getStatus,
deactivate(): void {
unsubClient()
unsubSettings()
if (removeToolbarSelect) {
removeToolbarSelect()
removeToolbarSelect = null
}
},
}
},
}
}
export const lightingPresetsPlugin: D2WebPlugin = createLightingPresetsPlugin()

View File

@ -8,8 +8,8 @@
- **主页面入口与非侵入式插件宿主**: - **主页面入口与非侵入式插件宿主**:
- [`play.html`](../../play.html) 作为顶层组合根(Composition Root),通过内联 `<script type="module">` 导入 [`src/client/main.ts`](./main.ts) 的 `startPlayApp` 与 [`plugins/index.ts`](../../plugins/index.ts) 的 `BUILTIN_PLUGINS` 并启动客户端。 - [`play.html`](../../play.html) 作为顶层组合根(Composition Root),通过内联 `<script type="module">` 导入 [`src/client/main.ts`](./main.ts) 的 `startPlayApp` 与 [`plugins/index.ts`](../../plugins/index.ts) 的 `BUILTIN_PLUGINS` 并启动客户端。
- 可选客户端扩展插件统一解耦至顶层 [`plugins/`](../../plugins) 目录(例如怪物击杀统计插件 [`plugins/kill-stats/`](../../plugins/kill-stats) 及其独立页面 [`plugins/kill-stats/index.html`](../../plugins/kill-stats/index.html))。 - 可选客户端扩展插件统一解耦至顶层 [`plugins/`](../../plugins) 目录(含 `kill-stats`、`automap-reveal`、`lighting-presets` 与 `extended-viewports` 四个内置插件,以及独立统计页 [`plugins/kill-stats/index.html`](../../plugins/kill-stats/index.html))。
- `src/client` 通过 [`src/client/plugin-host/`](./plugin-host) 向插件提供只读会话视图(`PluginSessionView`)、生命周期与语言切换钩子以及顶部工具栏插件配置菜单(`PluginMenu`,挂载 `data-d2-modal-overlay="plugins"` 并配合 `ui/dom-overlay.ts` 的 `isModalDomOverlayOpen()` 实现键盘输入隔离)。组合根位于 [`play.html`](../../play.html),**`src/client/**` 具有零个来自 `plugins/**` 的导入且严禁包含任何具体插件 ID 引用**(由 `tests/arch/boundaries.test.ts` Rule 2 与 Rule 7–10 强制守卫)。 - `src/client` 通过 [`src/client/plugin-host/`](./plugin-host) 向插件提供只读会话视图(`PluginSessionView`)、受控客户端增强能力门面(`PluginClientCapabilities`)、生命周期与语言切换钩子以及顶部工具栏插件配置菜单(`PluginMenu`,挂载 `data-d2-modal-overlay="plugins"` 并配合 `ui/dom-overlay.ts` 的 `isModalDomOverlayOpen()` 实现键盘输入隔离)。当插件被禁用时,宿主自动回退至 1.13c 原版默认行为(迷雾探索 `automapReveal: 'off'`、原版关卡光照 `lightingPreset: 'auto'`、原版 `640x480` / `800x600` 分辨率)。组合根位于 [`play.html`](../../play.html),**`src/client/**` 具有零个来自 `plugins/**` 的导入且严禁包含任何具体插件 ID 引用**(由 `tests/arch/boundaries.test.ts` Rule 2 与 Rule 7–10 强制守卫)。
- **TypeScript 工程约束(`tsconfig.client.json`)**: - **TypeScript 工程约束(`tsconfig.client.json`)**:
- `lib: ["ES2022", "DOM", "DOM.Iterable"]`, `types: []`。 - `lib: ["ES2022", "DOM", "DOM.Iterable"]`, `types: []`。
- **严禁依赖 `src/server` 或 `src/baker`**,严禁导入任何 Node.js 内置模块(`fs`, `path`, `net`, `Buffer` 等)。 - **严禁依赖 `src/server` 或 `src/baker`**,严禁导入任何 Node.js 内置模块(`fs`, `path`, `net`, `Buffer` 等)。
@ -22,7 +22,7 @@
```text ```text
src/client/ src/client/
├── main.ts # 客户端主入口:组装 WebGL2 画布、工具栏、前端战网状态机、OnlineSession、PluginHost 与 60fps 渲染循环 ├── main.ts # 客户端主入口:组装 WebGL2 画布、工具栏、前端战网状态机、OnlineSession、PluginHost 与 60fps 渲染循环
├── session/ # OnlineSession 会话协调器与 C2S 发包速率限制器 (OutboundRateLimiter) ├── session/ # OnlineSession 会话协调器、.d2cap 离线重放器与 C2S 发包速率限制器 (OutboundRateLimiter)
├── world/ # ClientWorld:由 ServerEvent 驱动的客户端权威状态镜像 (玩家/怪物/NPC/地面物品/投射物/传送门) ├── world/ # ClientWorld:由 ServerEvent 驱动的客户端权威状态镜像 (玩家/怪物/NPC/地面物品/投射物/传送门)
├── map/ # 种子驱动的客户端 DRLG 地图服务 (MapService, LevelView, TileAtlas GPU 瓦片加载) ├── map/ # 种子驱动的客户端 DRLG 地图服务 (MapService, LevelView, TileAtlas GPU 瓦片加载)
├── motion/ # 本地玩家移动预测 (LocalMovementPredictor)、远端实体插值器与 A* 寻路 (pathfind) ├── motion/ # 本地玩家移动预测 (LocalMovementPredictor)、远端实体插值器与 A* 寻路 (pathfind)
@ -31,10 +31,9 @@ src/client/
├── ui/ # 游戏内 HUD 渲染与交互 (HudManager, 底部状态栏, 小地图 Automap, 7 大面板, D2 点阵字体渲染, DOM 浮层焦点守卫) ├── ui/ # 游戏内 HUD 渲染与交互 (HudManager, 底部状态栏, 小地图 Automap, 7 大面板, D2 点阵字体渲染, DOM 浮层焦点守卫)
├── ui-model/ # UI 纯视图状态模型 (HudModel, 属性加点/技能树/背包网格/商店/赫拉迪克方块交互逻辑) ├── ui-model/ # UI 纯视图状态模型 (HudModel, 属性加点/技能树/背包网格/商店/赫拉迪克方块交互逻辑)
├── frontend/ # 战网前置界面 (FrontendRenderer:Gateway 选择、账号登录/注册、8 角色选择、建房/加房大厅) ├── frontend/ # 战网前置界面 (FrontendRenderer:Gateway 选择、账号登录/注册、8 角色选择、建房/加房大厅)
├── toolbar/ # 顶部 32px 窄条工具栏 (FPS/Ping、网络抓包开关、插件快捷按钮插槽、Plugins 配置菜单入口、设置弹窗、全屏与音量控制) ├── toolbar/ # 顶部 32px 窄条工具栏 (连接状态与 RTT、服务器地址、分辨率切换、音量/语言控制、Plugins 菜单入口与动态插件工具栏插槽)
├── plugin-host/ # 非侵入式插件宿主与配置中心 (PluginHost, PluginConfigStore, PluginMenu [data-d2-modal-overlay="plugins"] 与只读契约 api.ts) ├── plugin-host/ # 非侵入式插件宿主与配置中心 (PluginHost, PluginConfigStore, PluginMenu [data-d2-modal-overlay="plugins"] 与公共契约 api.ts)
├── settings/ # 客户端持久化设置存储 (SettingsStore / client-settings.ts,管理 WS Bridge 地址与显示偏好) ├── settings/ # 客户端持久化设置存储 (SettingsStore / client-settings.ts,管理服务器地址与显示偏好)
├── inspector/ # 实时网络封包检查器 (PacketInspector) 与 .d2cap 录制/回放面板
├── sw/ # Service Worker (sw.ts) + CacheUpdater:基于 SHA-256 清单的离线资产增量缓存 ├── sw/ # Service Worker (sw.ts) + CacheUpdater:基于 SHA-256 清单的离线资产增量缓存
├── audio/ # Web Audio API 音效与背景音乐服务 (SoundService) ├── audio/ # Web Audio API 音效与背景音乐服务 (SoundService)
└── assets/ # OPFS (Origin Private File System) 本地 MPQ 导入与缓存支持 └── assets/ # OPFS (Origin Private File System) 本地 MPQ 导入与缓存支持

View File

@ -281,7 +281,6 @@ export type ToolbarI18nKey =
| 'toolbar.automap.level' | 'toolbar.automap.level'
| 'toolbar.automap.act' | 'toolbar.automap.act'
| 'toolbar.viewport.label' | 'toolbar.viewport.label'
| 'toolbar.inspector.toggle'
| 'toolbar.plugins.open' | 'toolbar.plugins.open'
| 'toolbar.cache.clear' | 'toolbar.cache.clear'
| 'toolbar.cache.cleared' | 'toolbar.cache.cleared'
@ -310,7 +309,6 @@ export const TOOLBAR_I18N: Readonly<Record<ToolbarI18nKey, { readonly zh: string
'toolbar.automap.level': { zh: '本层', en: 'Level' }, 'toolbar.automap.level': { zh: '本层', en: 'Level' },
'toolbar.automap.act': { zh: '全幕', en: 'Act' }, 'toolbar.automap.act': { zh: '全幕', en: 'Act' },
'toolbar.viewport.label': { zh: '分辨率', en: 'Viewport' }, 'toolbar.viewport.label': { zh: '分辨率', en: 'Viewport' },
'toolbar.inspector.toggle': { zh: '抓包面板', en: 'Packets' },
'toolbar.plugins.open': { zh: '插件', en: 'Plugins' }, 'toolbar.plugins.open': { zh: '插件', en: 'Plugins' },
'toolbar.cache.clear': { zh: '清除缓存', en: 'Clear Cache' }, 'toolbar.cache.clear': { zh: '清除缓存', en: 'Clear Cache' },
'toolbar.cache.cleared': { zh: '已清缓存', en: 'Cleared' }, 'toolbar.cache.cleared': { zh: '已清缓存', en: 'Cleared' },

View File

@ -1,556 +0,0 @@
import {
bytesToHex,
formatD2CapEntry,
hexToBytes,
parseD2CapJsonl,
type ClientCommand,
type D2CapHeader,
type D2CapRecord,
type PacketConn,
type PacketDirection,
type PacketTap,
type PacketTapEntry,
type ServerEvent,
} from '../../netproto/index.ts'
import { getGlobalSettingsStore, type SettingsStore } from '../settings/settings-store.ts'
export type PacketPhase = 'bncs' | 'mcp' | 'd2gs' | 'ts'
export function connToPhase(conn: PacketConn | PacketPhase): PacketPhase {
if (conn === 'bnet' || conn === 'bncs') return 'bncs'
if (conn === 'realm' || conn === 'mcp') return 'mcp'
if (conn === 'game' || conn === 'd2gs') return 'd2gs'
return 'ts'
}
export function phaseToConn(phaseOrConn: PacketPhase | PacketConn): PacketConn {
if (phaseOrConn === 'bncs' || phaseOrConn === 'bnet') return 'bnet'
if (phaseOrConn === 'mcp' || phaseOrConn === 'realm') return 'realm'
if (phaseOrConn === 'd2gs' || phaseOrConn === 'game') return 'game'
return 'ts'
}
export function resolvePacketOpcode(
conn: PacketConn,
raw: Uint8Array,
explicitPacketId?: number,
): number {
if (explicitPacketId !== undefined) {
return explicitPacketId & 0xff
}
if (raw.byteLength === 0) return 0
if (conn === 'bnet' && raw[0] === 0xff && raw.byteLength >= 2) {
return raw[1]! & 0xff
}
return raw[0]! & 0xff
}
export interface InspectedPacketRow {
readonly seq: number
readonly timestampMs: number
readonly conn: PacketConn
readonly phase: PacketPhase
readonly direction: PacketDirection
readonly opcode: number
readonly packetId: number
readonly packetName?: string | undefined
readonly opcodeHex: string
readonly byteLength: number
readonly bytes: Uint8Array
readonly hexDump: string
readonly decodedSummary?: string | undefined
readonly decodedPayload?: ServerEvent | ClientCommand | Record<string, unknown> | unknown
readonly decoded?: ServerEvent | ClientCommand | Record<string, unknown> | unknown
}
export interface PacketInspectorFilter {
readonly phase?: PacketPhase | PacketConn | 'all' | undefined
readonly conn?: PacketConn | PacketPhase | 'all' | undefined
readonly direction?: PacketDirection | 'all' | undefined
readonly opcode?: number | string | null | undefined
readonly search?: string | undefined
}
/**
* Format a `Uint8Array` into a canonical 16-byte-per-line hex + ASCII dump.
*/
export function formatHexDump(bytes: Uint8Array, maxBytes = 256): string {
const len = Math.min(bytes.byteLength, maxBytes)
if (len === 0) return ''
const lines: string[] = []
for (let offset = 0; offset < len; offset += 16) {
const rowEnd = Math.min(len, offset + 16)
const hexParts: string[] = []
let ascii = ''
for (let i = offset; i < offset + 16; i++) {
if (i < rowEnd) {
const b = bytes[i]!
hexParts.push(b.toString(16).padStart(2, '0'))
ascii += b >= 0x20 && b <= 0x7e ? String.fromCharCode(b) : '.'
} else {
hexParts.push(' ')
}
}
const addr = offset.toString(16).padStart(4, '0')
lines.push(`${addr} ${hexParts.join(' ')} |${ascii}|`)
}
if (bytes.byteLength > maxBytes) {
lines.push(`... (${bytes.byteLength - maxBytes} more bytes)`)
}
return lines.join('\n')
}
export function formatOpcodeHex(opcode: number): string {
return `0x${(opcode & 0xff).toString(16).padStart(2, '0').toUpperCase()}`
}
function parseOpcodeFilter(raw: number | string | null | undefined): number | null {
if (raw === null || raw === undefined) return null
if (typeof raw === 'number') {
return Number.isInteger(raw) && raw >= 0 && raw <= 0xff ? raw : null
}
const clean = raw.trim().toLowerCase()
if (clean === '' || clean === 'all') return null
const num = clean.startsWith('0x')
? Number.parseInt(clean.slice(2), 16)
: Number.parseInt(clean, 16)
return Number.isInteger(num) && num >= 0 && num <= 0xff ? num : null
}
export interface ReplayedD2CapFrame {
readonly record: D2CapRecord
readonly opcode: number
readonly packetId: number
readonly conn: PacketConn
readonly phase: PacketPhase
readonly dir: PacketDirection
readonly bytes: Uint8Array
readonly index: number
}
export interface PacketInspectorOptions {
readonly store?: SettingsStore | undefined
readonly tap?: PacketTap | undefined
readonly maxEntries?: number | undefined
readonly maxRows?: number | undefined
readonly container?: HTMLElement | null | undefined
readonly onReplayRecord?:
| ((frame: ReplayedD2CapFrame, rawBytes: Uint8Array, index: number) => void)
| undefined
readonly decodeReplayEntry?:
| ((conn: PacketConn, dir: PacketDirection, bytes: Uint8Array, record: D2CapRecord) => ServerEvent | ClientCommand | Record<string, unknown> | undefined)
| undefined
}
/**
* Dockable Packet Inspector & `.d2cap` deterministic capture/replay controller (F20).
* Implements `PacketTap` directly and can also wrap an existing `PacketTap` / `InMemoryPacketTap`.
*/
export class PacketInspector implements PacketTap {
private readonly store: SettingsStore
private readonly maxEntries: number
private readonly onReplayRecord:
| ((frame: ReplayedD2CapFrame, rawBytes: Uint8Array, index: number) => void)
| undefined
private readonly decodeReplayEntry:
| ((conn: PacketConn, dir: PacketDirection, bytes: Uint8Array, record: D2CapRecord) => ServerEvent | ClientCommand | Record<string, unknown> | undefined)
| undefined
private readonly rows: InspectedPacketRow[] = []
private readonly rawTapEntries: PacketTapEntry[] = []
private nextSeq = 1
private paused = false
private filter: PacketInspectorFilter = {
phase: 'all',
conn: 'all',
direction: 'all',
opcode: null,
search: '',
}
private selectedSeq: number | null = null
private replayHeader: D2CapHeader | null = null
private replayRecords: D2CapRecord[] = []
private replayCursor = 0
private detachTapHook: (() => void) | null = null
private unsubscribeStore: (() => void) | null = null
private rootEl: HTMLElement | null = null
private listEl: HTMLElement | null = null
private detailEl: HTMLElement | null = null
constructor(options?: PacketInspectorOptions) {
this.store = options?.store ?? getGlobalSettingsStore()
this.maxEntries = Math.max(1, options?.maxRows ?? options?.maxEntries ?? 500)
this.onReplayRecord = options?.onReplayRecord
this.decodeReplayEntry = options?.decodeReplayEntry
if (options?.tap) {
this.attachTap(options.tap)
}
if (typeof document !== 'undefined' && typeof document.createElement === 'function') {
this.mountDom(options?.container ?? null)
}
this.unsubscribeStore = this.store.subscribe((next, prev) => {
if (next.inspectorOpen !== prev.inspectorOpen) {
this.syncDom()
}
})
}
isOpen(): boolean {
return this.store.get().inspectorOpen
}
setOpen(open: boolean): void {
this.store.set({ inspectorOpen: open })
this.syncDom()
}
isPaused(): boolean {
return this.paused
}
setPaused(paused: boolean): void {
this.paused = paused
}
/**
* Implement `PacketTap.record` so `PacketInspector` can be passed directly as a `PacketTap`.
*/
record(entry: PacketTapEntry): void {
this.recordTapEntry(entry)
}
/**
* Intercept `tap.record(...)` calls on an existing `PacketTap` (such as `InMemoryPacketTap`).
*/
attachTap(tap: PacketTap): void {
this.detachTapHook?.()
if (tap === this) {
this.detachTapHook = null
return
}
const origRecord = tap.record.bind(tap)
tap.record = (entry: PacketTapEntry): void => {
origRecord(entry)
this.recordTapEntry(entry)
}
this.detachTapHook = (): void => {
tap.record = origRecord
}
}
/**
* Record a `PacketTapEntry` plus optional decoded `ServerEvent` / `ClientCommand`.
*/
recordTapEntry(
entry: PacketTapEntry,
decodedPayload?: ServerEvent | ClientCommand | Record<string, unknown>,
): InspectedPacketRow | null {
if (this.paused) return null
const copyBytes = new Uint8Array(entry.raw)
const opcode = resolvePacketOpcode(entry.conn, copyBytes, entry.packetId)
const phase = connToPhase(entry.conn)
const effectiveDecoded = decodedPayload ?? entry.decoded
const tapCopy: PacketTapEntry = {
t: entry.t,
conn: entry.conn,
dir: entry.dir,
raw: copyBytes,
packetId: opcode,
...(entry.packetName !== undefined ? { packetName: entry.packetName } : {}),
...(effectiveDecoded !== undefined ? { decoded: effectiveDecoded } : {}),
...(entry.isRawStreamChunk !== undefined ? { isRawStreamChunk: entry.isRawStreamChunk } : {}),
}
this.rawTapEntries.push(tapCopy)
if (this.rawTapEntries.length > this.maxEntries) {
this.rawTapEntries.shift()
}
const safeJson = (val: unknown): string => {
try {
return JSON.stringify(val, (_k, v: unknown) =>
typeof v === 'bigint' ? v.toString() : v instanceof Uint8Array ? bytesToHex(v) : v,
)
} catch {
return String(val)
}
}
const decodedSummary =
effectiveDecoded &&
typeof effectiveDecoded === 'object' &&
'type' in effectiveDecoded &&
typeof (effectiveDecoded as { readonly type?: unknown }).type === 'string'
? `${String((effectiveDecoded as { readonly type: string }).type)} ${safeJson(effectiveDecoded)}`
: effectiveDecoded !== undefined
? safeJson(effectiveDecoded)
: entry.packetName
const row: InspectedPacketRow = {
seq: this.nextSeq++,
timestampMs: entry.t,
conn: entry.conn,
phase,
direction: entry.dir,
opcode,
packetId: opcode,
packetName: entry.packetName,
opcodeHex: formatOpcodeHex(opcode),
byteLength: copyBytes.byteLength,
bytes: copyBytes,
hexDump: formatHexDump(copyBytes),
decodedSummary,
decodedPayload: effectiveDecoded,
decoded: effectiveDecoded,
}
this.rows.push(row)
if (this.rows.length > this.maxEntries) {
this.rows.shift()
}
this.syncDom()
return row
}
clear(): void {
this.rows.length = 0
this.rawTapEntries.length = 0
this.selectedSeq = null
this.syncDom()
}
setFilter(patch: Partial<PacketInspectorFilter>): PacketInspectorFilter {
this.filter = {
...this.filter,
...patch,
}
this.syncDom()
return this.filter
}
getFilter(): PacketInspectorFilter {
return this.filter
}
getAllRows(): readonly InspectedPacketRow[] {
return this.rows
}
getFilteredRows(): readonly InspectedPacketRow[] {
const rawPhaseOrConn =
this.filter.conn !== undefined && this.filter.conn !== 'all'
? this.filter.conn
: this.filter.phase ?? 'all'
const targetConn = rawPhaseOrConn === 'all' ? 'all' : phaseToConn(rawPhaseOrConn)
const dirFilter = this.filter.direction ?? 'all'
const opcodeFilter = parseOpcodeFilter(this.filter.opcode)
const search = (this.filter.search ?? '').trim().toLowerCase()
return this.rows.filter(r => {
if (targetConn !== 'all' && r.conn !== targetConn) return false
if (dirFilter !== 'all' && r.direction !== dirFilter) return false
if (opcodeFilter !== null && r.opcode !== opcodeFilter) return false
if (search !== '') {
const hay = `${r.opcodeHex} ${r.phase} ${r.conn} ${r.direction} ${r.decodedSummary ?? ''} ${r.hexDump}`.toLowerCase()
if (!hay.includes(search)) return false
}
return true
})
}
selectRow(seq: number | null): InspectedPacketRow | null {
this.selectedSeq = seq
this.syncDom()
if (seq === null) return null
return this.rows.find(r => r.seq === seq) ?? null
}
/**
* Export captured packets as a `.d2cap` JSON Lines string.
*/
exportD2CapJsonl(): string {
return this.rawTapEntries.map(e => formatD2CapEntry(e)).join('\n')
}
/**
* Load a `.d2cap` JSONL capture string for stepping or full deterministic replay.
*/
loadD2CapJsonl(jsonlText: string): readonly D2CapRecord[] {
const parsed = parseD2CapJsonl(jsonlText)
this.replayHeader = parsed.header
this.replayRecords = parsed.records
this.replayCursor = 0
return this.replayRecords
}
getReplayHeader(): D2CapHeader | null {
return this.replayHeader
}
getReplayStatus(): { readonly total: number; readonly cursor: number } {
return {
total: this.replayRecords.length,
cursor: this.replayCursor,
}
}
resetReplayCursor(): void {
this.replayCursor = 0
}
/**
* Advance the `.d2cap` replay cursor by 1 frame and invoke `callback` (and `onReplayRecord`).
*/
stepReplay(
callback?: (frame: ReplayedD2CapFrame, rawBytes: Uint8Array, index: number) => void,
): ReplayedD2CapFrame | null {
if (this.replayCursor >= this.replayRecords.length) {
return null
}
const index = this.replayCursor++
const record = this.replayRecords[index]!
const bytes = hexToBytes(record.hex)
const opcode = resolvePacketOpcode(record.conn, bytes, record.packetId)
const phase = connToPhase(record.conn)
const decodedPayload = this.decodeReplayEntry?.(record.conn, record.dir, bytes, record)
this.recordTapEntry(
{
t: record.t,
conn: record.conn,
dir: record.dir,
raw: bytes,
packetId: opcode,
...(record.packetName !== undefined ? { packetName: record.packetName } : {}),
...(record.stream !== undefined ? { isRawStreamChunk: record.stream } : {}),
},
decodedPayload,
)
const frame: ReplayedD2CapFrame = {
record,
opcode,
packetId: opcode,
conn: record.conn,
phase,
dir: record.dir,
bytes,
index,
}
this.onReplayRecord?.(frame, bytes, index)
callback?.(frame, bytes, index)
return frame
}
/**
* Replay all remaining (or all matching) `.d2cap` records synchronously in deterministic order.
*/
replayAll(
callback?: (frame: ReplayedD2CapFrame, rawBytes: Uint8Array, index: number) => void,
options?: {
readonly phase?: PacketPhase | PacketConn | undefined
readonly direction?: PacketDirection | undefined
},
): number {
let count = 0
const targetConn = options?.phase ? phaseToConn(options.phase) : undefined
while (this.replayCursor < this.replayRecords.length) {
const idx = this.replayCursor++
const rec = this.replayRecords[idx]!
if (targetConn && rec.conn !== targetConn) continue
if (options?.direction && rec.dir !== options.direction) continue
const bytes = hexToBytes(rec.hex)
const opcode = resolvePacketOpcode(rec.conn, bytes, rec.packetId)
const phase = connToPhase(rec.conn)
const decodedPayload = this.decodeReplayEntry?.(rec.conn, rec.dir, bytes, rec)
this.recordTapEntry(
{
t: rec.t,
conn: rec.conn,
dir: rec.dir,
raw: bytes,
packetId: opcode,
...(rec.packetName !== undefined ? { packetName: rec.packetName } : {}),
...(rec.stream !== undefined ? { isRawStreamChunk: rec.stream } : {}),
},
decodedPayload,
)
const frame: ReplayedD2CapFrame = {
record: rec,
opcode,
packetId: opcode,
conn: rec.conn,
phase,
dir: rec.dir,
bytes,
index: idx,
}
this.onReplayRecord?.(frame, bytes, idx)
callback?.(frame, bytes, idx)
count += 1
}
return count
}
destroy(): void {
this.detachTapHook?.()
this.detachTapHook = null
this.unsubscribeStore?.()
this.unsubscribeStore = null
if (this.rootEl && this.rootEl.parentNode) {
this.rootEl.parentNode.removeChild(this.rootEl)
}
this.rootEl = null
}
private mountDom(container: HTMLElement | null): void {
const doc = document
const root = doc.createElement('aside')
root.id = 'd2-packet-inspector'
root.className = 'd2-packet-inspector'
const list = doc.createElement('div')
list.id = 'd2-packet-inspector-list'
const detail = doc.createElement('pre')
detail.id = 'd2-packet-inspector-detail'
root.appendChild(list)
root.appendChild(detail)
if (container) {
container.appendChild(root)
}
this.rootEl = root
this.listEl = list
this.detailEl = detail
this.syncDom()
}
private syncDom(): void {
if (!this.rootEl) return
const open = this.isOpen()
this.rootEl.style.display = open ? 'flex' : 'none'
if (!open) return
if (this.listEl) {
const filtered = this.getFilteredRows()
this.listEl.textContent = `${filtered.length} / ${this.rows.length} packets`
}
if (this.detailEl) {
const selected =
this.selectedSeq !== null
? this.rows.find(r => r.seq === this.selectedSeq)
: this.rows[this.rows.length - 1]
this.detailEl.textContent = selected
? `[${selected.phase.toUpperCase()} ${selected.direction.toUpperCase()} ${selected.opcodeHex}] (${selected.byteLength}B)\n${selected.decodedSummary ?? ''}\n${selected.hexDump}`
: ''
}
}
}

View File

@ -6,7 +6,7 @@
* - Battle.net Pre-Game & Live Lobby (`FrontendRenderer` + `BnetFlowState` + `OnlineSession`) * - Battle.net Pre-Game & Live Lobby (`FrontendRenderer` + `BnetFlowState` + `OnlineSession`)
* - In-Game Isometric World + Automap + Lighting (`ClientWorldSceneSource`, `AutomapView`) * - In-Game Isometric World + Automap + Lighting (`ClientWorldSceneSource`, `AutomapView`)
* - Authentic D2 HUD Overlay (`HudManager` + `HudModel` + `CommandMapper` for every registered `ViewportProfile`) * - Authentic D2 HUD Overlay (`HudManager` + `HudModel` + `CommandMapper` for every registered `ViewportProfile`)
* - Dockable Packet Inspector & Deterministic `.d2cap` Capture Replay (`PacketInspector`) * - Hot-Swappable Plugin Host & Configuration Modal (`PluginHost` + `PluginMenu`)
* - Graceful disconnect & error recovery banner (`#play-disconnect-banner`) * - Graceful disconnect & error recovery banner (`#play-disconnect-banner`)
* *
* Strict import boundary: never imports `src/netproto/{bncs,mcp,d2gs,crypto}/**`. * Strict import boundary: never imports `src/netproto/{bncs,mcp,d2gs,crypto}/**`.
@ -55,7 +55,6 @@ import { isModalDomOverlayOpen } from './ui/dom-overlay.ts'
import { HudManager } from './ui/hud-manager.ts' import { HudManager } from './ui/hud-manager.ts'
import { Minimap } from './ui/minimap.ts' import { Minimap } from './ui/minimap.ts'
import { CommandMapper } from './input/command-mapper.ts' import { CommandMapper } from './input/command-mapper.ts'
import { PacketInspector } from './inspector/packet-inspector.ts'
import { MapService } from './map/map-service.ts' import { MapService } from './map/map-service.ts'
import type { D2WebPlugin } from './plugin-host/api.ts' import type { D2WebPlugin } from './plugin-host/api.ts'
import { PluginHost } from './plugin-host/plugin-host.ts' import { PluginHost } from './plugin-host/plugin-host.ts'
@ -99,7 +98,6 @@ export interface PlayAppController {
readonly plugins: PluginHost readonly plugins: PluginHost
readonly pluginMenu: PluginMenu readonly pluginMenu: PluginMenu
readonly toolbar: Toolbar readonly toolbar: Toolbar
readonly inspector: PacketInspector
readonly hudManager: HudManager readonly hudManager: HudManager
readonly hudModel: HudModel readonly hudModel: HudModel
readonly commandMapper: CommandMapper readonly commandMapper: CommandMapper
@ -135,7 +133,6 @@ export async function startPlayApp(options?: {
window.__d2SkipAutoBoot = true window.__d2SkipAutoBoot = true
const toolbarContainer = document.querySelector<HTMLElement>('#d2-toolbar-container') const toolbarContainer = document.querySelector<HTMLElement>('#d2-toolbar-container')
const inspectorContainer = document.querySelector<HTMLElement>('#d2-inspector-container')
const stageWrapperEl = document.querySelector<HTMLElement>('#play-stage-wrapper') const stageWrapperEl = document.querySelector<HTMLElement>('#play-stage-wrapper')
const stageEl = document.querySelector<HTMLElement>('#play-stage') const stageEl = document.querySelector<HTMLElement>('#play-stage')
const bnetShell = document.querySelector<HTMLElement>('#bnet-shell') const bnetShell = document.querySelector<HTMLElement>('#bnet-shell')
@ -191,10 +188,6 @@ export async function startPlayApp(options?: {
onAutomapRevealChange: (mode) => { onAutomapRevealChange: (mode) => {
sessionRef?.automapView.setMode(mode) sessionRef?.automapView.setMode(mode)
}, },
onInspectorToggle: (open) => {
sessionRef?.packetInspector.setOpen(open)
syncStageViewportFit()
},
onServerHostReconnect: (newHost) => { onServerHostReconnect: (newHost) => {
void sessionRef?.disconnect(`Reconnecting to ${newHost}`) void sessionRef?.disconnect(`Reconnecting to ${newHost}`)
}, },
@ -404,6 +397,8 @@ export async function startPlayApp(options?: {
plugins: options?.plugins ?? [], plugins: options?.plugins ?? [],
session, session,
toolbar, toolbar,
settingsStore,
enforceClientDefaults: true,
}) })
const pluginMenu = new PluginMenu({ const pluginMenu = new PluginMenu({
host: plugins, host: plugins,
@ -411,22 +406,13 @@ export async function startPlayApp(options?: {
}) })
pluginMenuRef = pluginMenu pluginMenuRef = pluginMenu
// Mount PacketInspector inside #d2-inspector-container if available
const inspector = session.packetInspector
if (inspectorContainer && inspectorContainer.firstChild === null) {
const existingInspectorEl = document.querySelector('#d2-packet-inspector')
if (existingInspectorEl && existingInspectorEl.parentElement !== inspectorContainer) {
inspectorContainer.appendChild(existingInspectorEl)
}
}
void assetsReadyPromise.then(async () => { void assetsReadyPromise.then(async () => {
const updatedStatus = await getAssetCacheStatus() const updatedStatus = await getAssetCacheStatus()
toolbar.setAssetCacheUsageBytes(updatedStatus.usageBytes) toolbar.setAssetCacheUsageBytes(updatedStatus.usageBytes)
}) })
// Apply the initial viewport profile's dimensions, stretched to fit the browser at its own aspect ratio // Apply the initial viewport profile's dimensions, stretched to fit the browser at its own aspect ratio
syncStageViewportFit(initialSettings.viewport) syncStageViewportFit(settingsStore.get().viewport)
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
syncStageViewportFit() syncStageViewportFit()
@ -450,10 +436,6 @@ export async function startPlayApp(options?: {
if (next.automapReveal !== prev.automapReveal) { if (next.automapReveal !== prev.automapReveal) {
session.automapView.setMode(next.automapReveal) session.automapView.setMode(next.automapReveal)
} }
if (next.inspectorOpen !== prev.inspectorOpen) {
inspector.setOpen(next.inspectorOpen)
syncStageViewportFit(next.viewport)
}
}) })
const flowState = createInitialFlowState() const flowState = createInitialFlowState()
@ -1054,7 +1036,6 @@ export async function startPlayApp(options?: {
plugins, plugins,
pluginMenu, pluginMenu,
toolbar, toolbar,
inspector,
hudManager: session.hudManager, hudManager: session.hudManager,
hudModel: session.hudModel, hudModel: session.hudModel,
commandMapper: session.commandMapper, commandMapper: session.commandMapper,

View File

@ -13,13 +13,53 @@ import {
setLang, setLang,
type GameLang, type GameLang,
} from '../i18n/lang.ts' } from '../i18n/lang.ts'
import {
CLIENT_LIGHTING_PRESET_ORDER,
LIGHTING_PRESETS,
type LightingPresetConfig,
} from '../scene/lighting-presets.ts'
import type { OnlineSession } from '../session/online-session.ts' import type { OnlineSession } from '../session/online-session.ts'
import {
VALID_AUTOMAP_REVEAL_MODES,
VALID_LIGHTING_PRESETS,
VALID_VIEWPORT_RESOLUTIONS,
type AutomapRevealMode,
type ClientLightingPreset,
type ViewportResolution,
} from '../settings/client-settings.ts'
import { getGlobalSettingsStore } from '../settings/settings-store.ts' import { getGlobalSettingsStore } from '../settings/settings-store.ts'
import {
EXTENDED_VIEWPORT_RESOLUTIONS,
NATIVE_113C_VIEWPORT_RESOLUTIONS,
VIEWPORT_PROFILES,
formatViewportLabel,
getViewportProfile,
isExtendedViewport,
type ViewportProfile,
} from '../view/viewport-profile.ts'
import { import {
PluginConfigStore, PluginConfigStore,
} from './plugin-config-store.ts' } from './plugin-config-store.ts'
export type { GameLang } export {
CLIENT_LIGHTING_PRESET_ORDER,
EXTENDED_VIEWPORT_RESOLUTIONS,
LIGHTING_PRESETS,
NATIVE_113C_VIEWPORT_RESOLUTIONS,
VALID_AUTOMAP_REVEAL_MODES,
VALID_LIGHTING_PRESETS,
VALID_VIEWPORT_RESOLUTIONS,
VIEWPORT_PROFILES,
formatViewportLabel,
getViewportProfile,
isExtendedViewport,
type AutomapRevealMode,
type ClientLightingPreset,
type GameLang,
type LightingPresetConfig,
type ViewportProfile,
type ViewportResolution,
}
export interface LocalizedText { export interface LocalizedText {
readonly zh: string readonly zh: string
@ -89,17 +129,50 @@ export interface PluginLogger {
error(msg: string, ...args: unknown[]): void error(msg: string, ...args: unknown[]): void
} }
export interface PluginToolbarSelectOption {
readonly value: string
readonly label: LocalizedText
}
export interface PluginToolbarSelectSpec {
readonly id: string
readonly selectDomId?: string | undefined
readonly label: LocalizedText
readonly options: readonly PluginToolbarSelectOption[]
getValue(): string
onChange(value: string): void
}
export interface PluginClientCapabilities {
getAutomapReveal(): AutomapRevealMode
setAutomapReveal(mode: AutomapRevealMode): void
subscribeAutomapReveal(fn: (mode: AutomapRevealMode) => void): () => void
getLightingPreset(): ClientLightingPreset
setLightingPreset(preset: ClientLightingPreset): void
subscribeLightingPreset(fn: (preset: ClientLightingPreset) => void): () => void
getViewport(): ViewportResolution
setViewport(viewport: ViewportResolution): void
subscribeViewport(fn: (viewport: ViewportResolution) => void): () => void
getAllowedViewports(): readonly ViewportResolution[]
setAllowedViewports(resolutions: readonly ViewportResolution[]): void
}
export interface PluginContext { export interface PluginContext {
readonly pluginId: string readonly pluginId: string
readonly session: PluginSessionView readonly session: PluginSessionView
readonly settings: { readonly settings: {
get(key: string): boolean | string get(key: string): boolean | string
set(key: string, value: boolean | string): void
subscribe(fn: (key: string) => void): () => void subscribe(fn: (key: string) => void): () => void
} }
readonly toolbar: { readonly toolbar: {
addLink(spec: { id: string; label: LocalizedText; href: string }): () => void addLink(spec: { id: string; label: LocalizedText; href: string }): () => void
addButton(spec: { id: string; label: LocalizedText; onClick(): void }): () => void addButton(spec: { id: string; label: LocalizedText; onClick(): void }): () => void
addSelect(spec: PluginToolbarSelectSpec): () => void
} }
readonly client: PluginClientCapabilities
readonly lang: { readonly lang: {
get(): GameLang get(): GameLang
subscribe(fn: (lang: GameLang) => void): () => void subscribe(fn: (lang: GameLang) => void): () => void

View File

@ -5,25 +5,42 @@
* - Fail-fast startup validation via `validatePluginRegistry`. * - Fail-fast startup validation via `validatePluginRegistry`.
* - Per-plugin serialized `setEnabled(id, enabled)` lifecycle queue so rapid toggles * - Per-plugin serialized `setEnabled(id, enabled)` lifecycle queue so rapid toggles
* converge deterministically to the final state. * converge deterministically to the final state.
* - Automatic teardown of toolbar contributions (`addLink` / `addButton`) and * - Automatic teardown of toolbar contributions (`addLink` / `addButton` / `addSelect`),
* setting/language subscriptions registered via `PluginContext` on deactivate. * client capability claims (`automapReveal`, `lightingPreset`, `allowedViewports`),
* and setting/language subscriptions registered via `PluginContext` on deactivate.
* - Runtime error isolation in `activate`, `deactivate`, setting callbacks, action * - Runtime error isolation in `activate`, `deactivate`, setting callbacks, action
* runners, and `ctx.log.error` (`console.error` + plugin status set to `level: 'error'` * runners, and `ctx.log.error` (`console.error` + plugin status set to `level: 'error'`
* + toolbar plugin button displays `⚠`). * + toolbar plugin button displays `⚠`).
*/ */
import { getGameLang, onLangChange, type GameLang } from '../i18n/lang.ts' import { getGameLang, onLangChange, type GameLang } from '../i18n/lang.ts'
import {
VALID_AUTOMAP_REVEAL_MODES,
VALID_LIGHTING_PRESETS,
VALID_VIEWPORT_RESOLUTIONS,
type AutomapRevealMode,
type ClientLightingPreset,
type ViewportResolution,
} from '../settings/client-settings.ts'
import {
getGlobalSettingsStore,
type SettingsStore,
} from '../settings/settings-store.ts'
import { NATIVE_113C_VIEWPORT_RESOLUTIONS } from '../view/viewport-profile.ts'
import { import {
isRelativePluginHref, isRelativePluginHref,
isValidLocalizedText, isValidLocalizedText,
validatePluginRegistry, validatePluginRegistry,
type D2WebPlugin, type D2WebPlugin,
type LocalizedText, type LocalizedText,
type PluginClientCapabilities,
type PluginContext, type PluginContext,
type PluginInstance, type PluginInstance,
type PluginLogger, type PluginLogger,
type PluginSessionView, type PluginSessionView,
type PluginStatus, type PluginStatus,
type PluginToolbarSelectOption,
type PluginToolbarSelectSpec,
} from './api.ts' } from './api.ts'
import { import {
PluginConfigStore, PluginConfigStore,
@ -43,6 +60,15 @@ export type ToolbarContributionSpec =
readonly label: LocalizedText readonly label: LocalizedText
readonly onClick: () => void readonly onClick: () => void
} }
| {
readonly domId: string
readonly selectDomId: string
readonly kind: 'select'
readonly label: LocalizedText
readonly options: readonly PluginToolbarSelectOption[]
readonly getValue: () => string
readonly onChange: (value: string) => void
}
export interface PluginHostToolbarPort { export interface PluginHostToolbarPort {
setPluginsButtonState(state: { setPluginsButtonState(state: {
@ -51,6 +77,8 @@ export interface PluginHostToolbarPort {
readonly open?: boolean | undefined readonly open?: boolean | undefined
}): void }): void
addContribution(spec: ToolbarContributionSpec): () => void addContribution(spec: ToolbarContributionSpec): () => void
getAllowedViewports?(): readonly ViewportResolution[]
setAllowedViewports?(resolutions: readonly ViewportResolution[]): void
} }
export interface PluginDescriptorSnapshot { export interface PluginDescriptorSnapshot {
@ -67,6 +95,8 @@ export interface PluginHostOptions {
readonly plugins: readonly D2WebPlugin[] readonly plugins: readonly D2WebPlugin[]
readonly session: PluginSessionView readonly session: PluginSessionView
readonly toolbar?: PluginHostToolbarPort | null | undefined readonly toolbar?: PluginHostToolbarPort | null | undefined
readonly settingsStore?: SettingsStore | undefined
readonly enforceClientDefaults?: boolean | undefined
readonly configStore?: PluginConfigStore | undefined readonly configStore?: PluginConfigStore | undefined
readonly storageOptions?: PluginConfigStoreOptions | undefined readonly storageOptions?: PluginConfigStoreOptions | undefined
readonly autoStart?: boolean | undefined readonly autoStart?: boolean | undefined
@ -88,9 +118,16 @@ const KEBAB_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
export class PluginHost { export class PluginHost {
private readonly session: PluginSessionView private readonly session: PluginSessionView
private readonly toolbar: PluginHostToolbarPort | null private readonly toolbar: PluginHostToolbarPort | null
private readonly settingsStore: SettingsStore
private readonly enforceClientDefaults: boolean
private readonly configStore: PluginConfigStore private readonly configStore: PluginConfigStore
private readonly records = new Map<string, InternalPluginRecord>() private readonly records = new Map<string, InternalPluginRecord>()
private readonly listeners = new Set<() => void>() private readonly listeners = new Set<() => void>()
private readonly automapClaims = new Set<string>()
private readonly lightingClaims = new Set<string>()
private readonly viewportClaims = new Set<string>()
private fallbackAllowedViewports: readonly ViewportResolution[] =
NATIVE_113C_VIEWPORT_RESOLUTIONS
private readonly unsubConfig: () => void private readonly unsubConfig: () => void
private ready = false private ready = false
private destroyed = false private destroyed = false
@ -100,6 +137,8 @@ export class PluginHost {
validatePluginRegistry(options.plugins) validatePluginRegistry(options.plugins)
this.session = options.session this.session = options.session
this.toolbar = options.toolbar ?? null this.toolbar = options.toolbar ?? null
this.settingsStore = options.settingsStore ?? getGlobalSettingsStore()
this.enforceClientDefaults = options.enforceClientDefaults ?? false
this.configStore = this.configStore =
options.configStore ?? options.configStore ??
new PluginConfigStore(options.plugins, options.storageOptions) new PluginConfigStore(options.plugins, options.storageOptions)
@ -171,6 +210,9 @@ export class PluginHost {
} }
} }
this.startPromise = Promise.all(tasks).then(() => { this.startPromise = Promise.all(tasks).then(() => {
if (this.enforceClientDefaults) {
this.reconcileUnclaimedClientCapabilities()
}
this.ready = true this.ready = true
this.syncToolbarState() this.syncToolbarState()
this.notifyListeners() this.notifyListeners()
@ -360,6 +402,9 @@ export class PluginHost {
} }
} }
await Promise.all(tasks) await Promise.all(tasks)
if (this.enforceClientDefaults) {
this.reconcileUnclaimedClientCapabilities()
}
this.syncToolbarState() this.syncToolbarState()
this.notifyListeners() this.notifyListeners()
} }
@ -401,6 +446,247 @@ export class PluginHost {
return next return next
} }
private createClientCapabilities(
record: InternalPluginRecord,
isDisposed: () => boolean,
): PluginClientCapabilities {
const pluginId = record.plugin.id
const claimAutomap = (): void => {
if (isDisposed() || this.automapClaims.has(pluginId)) return
this.automapClaims.add(pluginId)
const release = (): void => {
record.disposers.delete(release)
this.automapClaims.delete(pluginId)
if (this.automapClaims.size === 0 && this.settingsStore.get().automapReveal !== 'off') {
this.settingsStore.set({ automapReveal: 'off' })
}
}
record.disposers.add(release)
}
const claimLighting = (): void => {
if (isDisposed() || this.lightingClaims.has(pluginId)) return
this.lightingClaims.add(pluginId)
const release = (): void => {
record.disposers.delete(release)
this.lightingClaims.delete(pluginId)
if (this.lightingClaims.size === 0 && this.settingsStore.get().lightingPreset !== 'auto') {
this.settingsStore.set({ lightingPreset: 'auto' })
}
}
record.disposers.add(release)
}
const claimViewport = (): void => {
if (isDisposed() || this.viewportClaims.has(pluginId)) return
this.viewportClaims.add(pluginId)
const release = (): void => {
record.disposers.delete(release)
this.viewportClaims.delete(pluginId)
if (this.viewportClaims.size === 0) {
this.revertAllowedViewportsToNative()
}
}
record.disposers.add(release)
}
return {
getAutomapReveal: (): AutomapRevealMode => this.settingsStore.get().automapReveal,
setAutomapReveal: (mode: AutomapRevealMode): void => {
if (
typeof mode !== 'string' ||
!(VALID_AUTOMAP_REVEAL_MODES as readonly string[]).includes(mode)
) {
throw new Error(
`[PluginHost] Invalid automapReveal mode "${String(mode)}" from plugin "${pluginId}"`,
)
}
if (isDisposed()) return
claimAutomap()
if (this.settingsStore.get().automapReveal !== mode) {
this.settingsStore.set({ automapReveal: mode })
}
this.syncToolbarState()
},
subscribeAutomapReveal: (fn: (mode: AutomapRevealMode) => void): (() => void) => {
if (isDisposed()) return () => {}
claimAutomap()
const unsub = this.settingsStore.subscribe((next, prev, changed) => {
if (!changed.includes('automapReveal') || next.automapReveal === prev.automapReveal) {
return
}
try {
fn(next.automapReveal)
} catch (err) {
this.recordPluginError(record, 'automapReveal subscriber', err)
this.syncToolbarState()
this.notifyListeners()
}
})
let removed = false
const dispose = (): void => {
if (removed) return
removed = true
record.disposers.delete(dispose)
unsub()
}
record.disposers.add(dispose)
return dispose
},
getLightingPreset: (): ClientLightingPreset => this.settingsStore.get().lightingPreset,
setLightingPreset: (preset: ClientLightingPreset): void => {
if (
typeof preset !== 'string' ||
!(VALID_LIGHTING_PRESETS as readonly string[]).includes(preset)
) {
throw new Error(
`[PluginHost] Invalid lightingPreset "${String(preset)}" from plugin "${pluginId}"`,
)
}
if (isDisposed()) return
claimLighting()
if (this.settingsStore.get().lightingPreset !== preset) {
this.settingsStore.set({ lightingPreset: preset })
}
this.syncToolbarState()
},
subscribeLightingPreset: (fn: (preset: ClientLightingPreset) => void): (() => void) => {
if (isDisposed()) return () => {}
claimLighting()
const unsub = this.settingsStore.subscribe((next, prev, changed) => {
if (!changed.includes('lightingPreset') || next.lightingPreset === prev.lightingPreset) {
return
}
try {
fn(next.lightingPreset)
} catch (err) {
this.recordPluginError(record, 'lightingPreset subscriber', err)
this.syncToolbarState()
this.notifyListeners()
}
})
let removed = false
const dispose = (): void => {
if (removed) return
removed = true
record.disposers.delete(dispose)
unsub()
}
record.disposers.add(dispose)
return dispose
},
getViewport: (): ViewportResolution => this.settingsStore.get().viewport,
setViewport: (viewport: ViewportResolution): void => {
if (
typeof viewport !== 'string' ||
!(VALID_VIEWPORT_RESOLUTIONS as readonly string[]).includes(viewport)
) {
throw new Error(
`[PluginHost] Invalid viewport "${String(viewport)}" from plugin "${pluginId}"`,
)
}
if (isDisposed()) return
claimViewport()
if (this.settingsStore.get().viewport !== viewport) {
this.settingsStore.set({ viewport })
}
this.syncToolbarState()
},
subscribeViewport: (fn: (viewport: ViewportResolution) => void): (() => void) => {
if (isDisposed()) return () => {}
claimViewport()
const unsub = this.settingsStore.subscribe((next, prev, changed) => {
if (!changed.includes('viewport') || next.viewport === prev.viewport) {
return
}
try {
fn(next.viewport)
} catch (err) {
this.recordPluginError(record, 'viewport subscriber', err)
this.syncToolbarState()
this.notifyListeners()
}
})
let removed = false
const dispose = (): void => {
if (removed) return
removed = true
record.disposers.delete(dispose)
unsub()
}
record.disposers.add(dispose)
return dispose
},
getAllowedViewports: (): readonly ViewportResolution[] => {
if (this.toolbar?.getAllowedViewports) {
return this.toolbar.getAllowedViewports()
}
return this.fallbackAllowedViewports
},
setAllowedViewports: (resolutions: readonly ViewportResolution[]): void => {
if (!Array.isArray(resolutions) || resolutions.length === 0) {
throw new Error(
`[PluginHost] setAllowedViewports on plugin "${pluginId}" requires a non-empty array`,
)
}
const unique: ViewportResolution[] = []
for (const r of resolutions) {
if (
typeof r !== 'string' ||
!(VALID_VIEWPORT_RESOLUTIONS as readonly string[]).includes(r)
) {
throw new Error(
`[PluginHost] Invalid viewport resolution "${String(r)}" in setAllowedViewports on plugin "${pluginId}"`,
)
}
const vp = r as ViewportResolution
if (!unique.includes(vp)) {
unique.push(vp)
}
}
if (isDisposed()) return
claimViewport()
this.fallbackAllowedViewports = unique
if (this.toolbar?.setAllowedViewports) {
this.toolbar.setAllowedViewports(unique)
} else if (!unique.includes(this.settingsStore.get().viewport)) {
const fallbackVp: ViewportResolution = unique.includes('800x600')
? '800x600'
: unique[0]!
this.settingsStore.set({ viewport: fallbackVp })
}
},
}
}
private reconcileUnclaimedClientCapabilities(): void {
if (this.automapClaims.size === 0 && this.settingsStore.get().automapReveal !== 'off') {
this.settingsStore.set({ automapReveal: 'off' })
}
if (this.lightingClaims.size === 0 && this.settingsStore.get().lightingPreset !== 'auto') {
this.settingsStore.set({ lightingPreset: 'auto' })
}
if (this.viewportClaims.size === 0) {
this.revertAllowedViewportsToNative()
}
}
private revertAllowedViewportsToNative(): void {
this.fallbackAllowedViewports = NATIVE_113C_VIEWPORT_RESOLUTIONS
if (this.toolbar?.setAllowedViewports) {
this.toolbar.setAllowedViewports(NATIVE_113C_VIEWPORT_RESOLUTIONS)
} else if (
!(NATIVE_113C_VIEWPORT_RESOLUTIONS as readonly string[]).includes(
this.settingsStore.get().viewport,
)
) {
this.settingsStore.set({ viewport: '800x600' })
}
}
private async activateRecord(record: InternalPluginRecord): Promise<void> { private async activateRecord(record: InternalPluginRecord): Promise<void> {
if (record.active) { if (record.active) {
return return
@ -433,6 +719,10 @@ export class PluginHost {
get: (key: string): boolean | string => { get: (key: string): boolean | string => {
return this.configStore.getSetting(pluginId, key) return this.configStore.getSetting(pluginId, key)
}, },
set: (key: string, value: boolean | string): void => {
if (contextDisposed) return
this.configStore.setSetting(pluginId, key, value)
},
subscribe: (fn: (key: string) => void): (() => void) => { subscribe: (fn: (key: string) => void): (() => void) => {
if (contextDisposed) { if (contextDisposed) {
return () => {} return () => {}
@ -533,7 +823,77 @@ export class PluginHost {
record.disposers.add(dispose) record.disposers.add(dispose)
return dispose return dispose
}, },
addSelect: (spec: PluginToolbarSelectSpec): (() => void) => {
if (!spec || typeof spec.id !== 'string' || !KEBAB_ID_RE.test(spec.id)) {
throw new Error(
`[PluginHost] Toolbar select id "${String(spec?.id)}" on plugin "${pluginId}" must be kebab-case`,
)
}
if (!isValidLocalizedText(spec.label)) {
throw new Error(
`[PluginHost] Toolbar select "${spec.id}" on plugin "${pluginId}" must have localized label`,
)
}
if (!Array.isArray(spec.options) || spec.options.length === 0) {
throw new Error(
`[PluginHost] Toolbar select "${spec.id}" on plugin "${pluginId}" must have non-empty options`,
)
}
for (const opt of spec.options) {
if (
!opt ||
typeof opt.value !== 'string' ||
opt.value.trim().length === 0 ||
!isValidLocalizedText(opt.label)
) {
throw new Error(
`[PluginHost] Toolbar select "${spec.id}" on plugin "${pluginId}" has an invalid option`,
)
}
}
if (typeof spec.getValue !== 'function' || typeof spec.onChange !== 'function') {
throw new Error(
`[PluginHost] Toolbar select "${spec.id}" on plugin "${pluginId}" must provide getValue and onChange`,
)
}
if (contextDisposed) {
return () => {}
}
const domId = `d2-toolbar-plugin-${pluginId}-${spec.id}`
const selectDomId =
typeof spec.selectDomId === 'string' && spec.selectDomId.trim().length > 0
? spec.selectDomId.trim()
: `${domId}-select`
const removeDom =
this.toolbar?.addContribution({
domId,
selectDomId,
kind: 'select',
label: spec.label,
options: spec.options,
getValue: () => spec.getValue(),
onChange: (value: string) => {
try {
spec.onChange(value)
} catch (err) {
this.recordPluginError(record, `toolbar select "${spec.id}"`, err)
this.syncToolbarState()
this.notifyListeners()
}
},
}) ?? (() => {})
let removed = false
const dispose = (): void => {
if (removed) return
removed = true
record.disposers.delete(dispose)
removeDom()
}
record.disposers.add(dispose)
return dispose
},
}, },
client: this.createClientCapabilities(record, () => contextDisposed),
lang: { lang: {
get: (): GameLang => getGameLang(), get: (): GameLang => getGameLang(),
subscribe: (fn: (lang: GameLang) => void): (() => void) => { subscribe: (fn: (lang: GameLang) => void): (() => void) => {

View File

@ -9,7 +9,7 @@
* - `RemoteEntityInterpolator` (remote monster/NPC/player smooth motion) * - `RemoteEntityInterpolator` (remote monster/NPC/player smooth motion)
* - `ClientWorldSceneSource` (feeding `WorldRenderer` across `800×600` and `1024×768`) * - `ClientWorldSceneSource` (feeding `WorldRenderer` across `800×600` and `1024×768`)
* - `HudManager` + `HudModel` + `CommandMapper` + `OutboundRateLimiter` * - `HudManager` + `HudModel` + `CommandMapper` + `OutboundRateLimiter`
* - `SettingsStore`, `SoundService`, and `PacketInspector` (with deterministic `.d2cap` replay) * - `SettingsStore`, `SoundService`, and deterministic `.d2cap` capture replay
* - Graceful disconnect / error recovery without uncaught promise rejections or page crashes. * - Graceful disconnect / error recovery without uncaught promise rejections or page crashes.
* *
* Strict import boundary: imports only from `src/netproto/index.ts` (never `bncs/mcp/d2gs/crypto`). * Strict import boundary: imports only from `src/netproto/index.ts` (never `bncs/mcp/d2gs/crypto`).
@ -22,6 +22,8 @@ import {
createD2OnlineFlow, createD2OnlineFlow,
createWsBridgeResolver, createWsBridgeResolver,
decodeD2gsS2cPacket, decodeD2gsS2cPacket,
hexToBytes,
parseD2CapJsonl,
unitTypeFromId, unitTypeFromId,
unitTypeToId, unitTypeToId,
type CharClassId, type CharClassId,
@ -29,6 +31,7 @@ import {
type CharSummary, type CharSummary,
type ClientCommand, type ClientCommand,
type CloseReason, type CloseReason,
type D2CapRecord,
type D2OnlineConfig, type D2OnlineConfig,
type D2OnlineFlow, type D2OnlineFlow,
type D2OnlineSessionKind, type D2OnlineSessionKind,
@ -40,6 +43,8 @@ import {
type GameListEntry, type GameListEntry,
type GameServerAdapter, type GameServerAdapter,
type ItemDataTables, type ItemDataTables,
type PacketConn,
type PacketDirection,
type RealmSummary, type RealmSummary,
type ServerEvent, type ServerEvent,
type UnitType, type UnitType,
@ -56,7 +61,6 @@ import {
ViewportCamera, ViewportCamera,
type CameraLike, type CameraLike,
} from '../input/command-mapper.ts' } from '../input/command-mapper.ts'
import { PacketInspector, type ReplayedD2CapFrame } from '../inspector/packet-inspector.ts'
import type { LevelView } from '../map/level-view.ts' import type { LevelView } from '../map/level-view.ts'
import type { MapService } from '../map/map-service.ts' import type { MapService } from '../map/map-service.ts'
import { RemoteEntityInterpolator } from '../motion/interpolate.ts' import { RemoteEntityInterpolator } from '../motion/interpolate.ts'
@ -109,6 +113,16 @@ export interface OnlineSessionLobbySnapshot {
readonly pingMs: number readonly pingMs: number
} }
export interface ReplayedD2CapFrame {
readonly seq: number
readonly timestampMs: number
readonly conn: PacketConn
readonly dir: PacketDirection
readonly opcode: number
readonly raw: Uint8Array
readonly record: D2CapRecord
}
export interface OnlineSessionOptions { export interface OnlineSessionOptions {
readonly world?: ClientWorld | undefined readonly world?: ClientWorld | undefined
readonly hudManager?: HudManager | undefined readonly hudManager?: HudManager | undefined
@ -120,7 +134,6 @@ export interface OnlineSessionOptions {
readonly rateLimiter?: OutboundRateLimiter | undefined readonly rateLimiter?: OutboundRateLimiter | undefined
readonly settingsStore?: SettingsStore | undefined readonly settingsStore?: SettingsStore | undefined
readonly soundService?: SoundService | undefined readonly soundService?: SoundService | undefined
readonly packetInspector?: PacketInspector | undefined
readonly resolver?: EndpointResolver | undefined readonly resolver?: EndpointResolver | undefined
readonly flowFactory?: ((config: D2OnlineConfig) => D2OnlineFlow) | undefined readonly flowFactory?: ((config: D2OnlineConfig) => D2OnlineFlow) | undefined
readonly itemTables?: ItemDataTables | undefined readonly itemTables?: ItemDataTables | undefined
@ -232,7 +245,6 @@ export class OnlineSession {
readonly settingsStore: SettingsStore readonly settingsStore: SettingsStore
readonly soundService: SoundService readonly soundService: SoundService
readonly packetTap: InMemoryPacketTap readonly packetTap: InMemoryPacketTap
readonly packetInspector: PacketInspector
readonly hudModel: HudModel readonly hudModel: HudModel
readonly commandMapper: CommandMapper readonly commandMapper: CommandMapper
readonly sceneSource: ClientWorldSceneSource readonly sceneSource: ClientWorldSceneSource
@ -244,10 +256,9 @@ export class OnlineSession {
private readonly defaultHost: string private readonly defaultHost: string
private readonly defaultBncsPort: number private readonly defaultBncsPort: number
private readonly replayS2cFramer = new D2gsS2cFramer(false) private readonly replayS2cFramer = new D2gsS2cFramer(false)
private lastDecodedReplayRecord: { private replayQueue: D2CapRecord[] = []
readonly record: ReplayedD2CapFrame['record'] private replayCursor = 0
readonly events: readonly ServerEvent[] private replaySeq = 0
} | null = null
private flow: D2OnlineFlow | null = null private flow: D2OnlineFlow | null = null
private gameAdapter: GameServerAdapter | null = null private gameAdapter: GameServerAdapter | null = null
@ -348,22 +359,6 @@ export class OnlineSession {
this.itemTables = options.itemTables ?? getCanonicalItemDataTables() this.itemTables = options.itemTables ?? getCanonicalItemDataTables()
this.packetTap = new InMemoryPacketTap(4000) this.packetTap = new InMemoryPacketTap(4000)
this.packetInspector =
options.packetInspector ??
new PacketInspector({
store: this.settingsStore,
tap: this.packetTap,
decodeReplayEntry: (conn, dir, rawBytes, record) => {
const events = this.extractEventsFromReplayRecord(conn, dir, rawBytes, record)
this.lastDecodedReplayRecord = { record, events }
if (events.length === 1) return events[0]
if (events.length > 1) return { events }
return undefined
},
onReplayRecord: (frame: ReplayedD2CapFrame, rawBytes: Uint8Array) => {
this.handleReplayedPacketFrame(frame, rawBytes)
},
})
this.hudModel = new HudModel({ this.hudModel = new HudModel({
world: this.world, world: this.world,
@ -2157,22 +2152,56 @@ export class OnlineSession {
// ─── Deterministic `.d2cap` Capture Replay (F20) ───────────── // ─── Deterministic `.d2cap` Capture Replay (F20) ─────────────
/** /**
* Load a `.d2cap` JSONL capture into `PacketInspector` and transition to `'replay'` phase. * Load a `.d2cap` JSONL capture into the session replay queue and transition to `'replay'` phase.
*/ */
loadD2CapReplay(jsonlText: string): number { loadD2CapReplay(jsonlText: string): number {
this.replayS2cFramer.reset(false) this.replayS2cFramer.reset(false)
this.lastDecodedReplayRecord = null const parsed = parseD2CapJsonl(jsonlText)
const records = this.packetInspector.loadD2CapJsonl(jsonlText) this.replayQueue = [...parsed.records]
this.replayCursor = 0
this.disconnectReasonValue = null this.disconnectReasonValue = null
this.setPhase('replay') this.setPhase('replay')
return records.length return this.replayQueue.length
} }
/** /**
* Step 1 packet from the loaded `.d2cap` capture, decoding `game` `s2c` packets into `ServerEvent`s. * Step 1 packet from the loaded `.d2cap` capture, decoding `game` `s2c` packets into `ServerEvent`s.
*/ */
stepD2CapReplay(): ReplayedD2CapFrame | null { stepD2CapReplay(): ReplayedD2CapFrame | null {
return this.packetInspector.stepReplay() if (this.replayCursor >= this.replayQueue.length) {
return null
}
const record = this.replayQueue[this.replayCursor]!
this.replayCursor += 1
const rawBytes = hexToBytes(record.hex)
const opcode =
record.packetId ??
(record.conn === 'bnet' && rawBytes.length >= 2 && rawBytes[0] === 0xff
? rawBytes[1]!
: record.conn === 'realm' && rawBytes.length >= 3
? rawBytes[2]!
: rawBytes[0] ?? 0)
const frame: ReplayedD2CapFrame = {
seq: ++this.replaySeq,
timestampMs: record.t,
conn: record.conn,
dir: record.dir,
opcode,
raw: rawBytes,
record,
}
this.packetTap.record({
t: record.t,
conn: record.conn,
dir: record.dir,
packetId: opcode,
raw: rawBytes,
})
const events = this.extractEventsFromReplayRecord(record.conn, record.dir, rawBytes, record)
for (const ev of events) {
this.handleServerEvent(ev)
}
return frame
} }
/** /**
@ -2180,7 +2209,11 @@ export class OnlineSession {
*/ */
replayAllD2Cap(): number { replayAllD2Cap(): number {
this.setPhase('replay') this.setPhase('replay')
return this.packetInspector.replayAll() let count = 0
while (this.stepD2CapReplay() !== null) {
count += 1
}
return count
} }
private extractEventsFromReplayRecord( private extractEventsFromReplayRecord(
@ -2203,17 +2236,6 @@ export class OnlineSession {
return [decodeCapturedGameS2cPacket(rawBytes, this.itemTables)] return [decodeCapturedGameS2cPacket(rawBytes, this.itemTables)]
} }
private handleReplayedPacketFrame(frame: ReplayedD2CapFrame, rawBytes: Uint8Array): void {
const events =
this.lastDecodedReplayRecord?.record === frame.record
? this.lastDecodedReplayRecord.events
: this.extractEventsFromReplayRecord(frame.conn, frame.dir, rawBytes, frame.record)
this.lastDecodedReplayRecord = null
for (const ev of events) {
this.handleServerEvent(ev)
}
}
// ─── Internal Error & Lifecycle Helpers ────────────────────── // ─── Internal Error & Lifecycle Helpers ──────────────────────
resolveWsBridgeBaseUrl(serverHost?: string): string { resolveWsBridgeBaseUrl(serverHost?: string): string {

View File

@ -22,7 +22,6 @@ export interface ClientSettings {
readonly lightingPreset: ClientLightingPreset readonly lightingPreset: ClientLightingPreset
readonly automapReveal: AutomapRevealMode readonly automapReveal: AutomapRevealMode
readonly viewport: ViewportResolution readonly viewport: ViewportResolution
readonly inspectorOpen: boolean
readonly audioMuted: boolean readonly audioMuted: boolean
readonly masterVolume: number // 0.0 .. 1.0 readonly masterVolume: number // 0.0 .. 1.0
} }
@ -59,7 +58,6 @@ export function createDefaultClientSettings(defaultLang?: GameLang): ClientSetti
lightingPreset: 'auto', lightingPreset: 'auto',
automapReveal: 'off', automapReveal: 'off',
viewport: '800x600', viewport: '800x600',
inspectorOpen: false,
audioMuted: false, audioMuted: false,
masterVolume: 0.8, masterVolume: 0.8,
} }
@ -72,7 +70,6 @@ export const DEFAULT_CLIENT_SETTINGS: ClientSettings = {
lightingPreset: 'auto', lightingPreset: 'auto',
automapReveal: 'off', automapReveal: 'off',
viewport: '800x600', viewport: '800x600',
inspectorOpen: false,
audioMuted: false, audioMuted: false,
masterVolume: 0.8, masterVolume: 0.8,
} }
@ -359,9 +356,6 @@ export function sanitizeClientSettings(
? normalizeViewportResolution(rec.viewport) ? normalizeViewportResolution(rec.viewport)
: defaults.viewport : defaults.viewport
const inspectorOpen =
typeof rec.inspectorOpen === 'boolean' ? rec.inspectorOpen : defaults.inspectorOpen
const audioMuted = const audioMuted =
typeof rec.audioMuted === 'boolean' ? rec.audioMuted : defaults.audioMuted typeof rec.audioMuted === 'boolean' ? rec.audioMuted : defaults.audioMuted
@ -374,7 +368,6 @@ export function sanitizeClientSettings(
lightingPreset, lightingPreset,
automapReveal, automapReveal,
viewport, viewport,
inspectorOpen,
audioMuted, audioMuted,
masterVolume, masterVolume,
} }

View File

@ -37,7 +37,6 @@ const COMPARE_KEYS: readonly SettingsKey[] = [
'lightingPreset', 'lightingPreset',
'automapReveal', 'automapReveal',
'viewport', 'viewport',
'inspectorOpen',
'audioMuted', 'audioMuted',
'masterVolume', 'masterVolume',
] as const ] as const

View File

@ -8,11 +8,6 @@ import {
import { resolveLocalizedText } from '../plugin-host/api.ts' import { resolveLocalizedText } from '../plugin-host/api.ts'
import type { ToolbarContributionSpec } from '../plugin-host/plugin-host.ts' import type { ToolbarContributionSpec } from '../plugin-host/plugin-host.ts'
import { import {
CLIENT_LIGHTING_PRESET_ORDER,
LIGHTING_PRESETS,
} from '../scene/lighting-presets.ts'
import {
VALID_AUTOMAP_REVEAL_MODES,
VALID_VIEWPORT_RESOLUTIONS, VALID_VIEWPORT_RESOLUTIONS,
validateServerHost, validateServerHost,
type AutomapRevealMode, type AutomapRevealMode,
@ -27,6 +22,7 @@ import {
formatCacheBytesMb, formatCacheBytesMb,
} from '../sw/register.ts' } from '../sw/register.ts'
import { import {
NATIVE_113C_VIEWPORT_RESOLUTIONS,
formatViewportLabel, formatViewportLabel,
getViewportProfile, getViewportProfile,
type ViewportProfile, type ViewportProfile,
@ -61,7 +57,6 @@ export interface ToolbarCallbacks {
readonly onLightingChange?: ((preset: ClientLightingPreset) => void) | undefined readonly onLightingChange?: ((preset: ClientLightingPreset) => void) | undefined
readonly onAutomapRevealChange?: ((mode: AutomapRevealMode) => void) | undefined readonly onAutomapRevealChange?: ((mode: AutomapRevealMode) => void) | undefined
readonly onViewportChange?: ((profile: ViewportProfile) => void) | undefined readonly onViewportChange?: ((profile: ViewportProfile) => void) | undefined
readonly onInspectorToggle?: ((open: boolean) => void) | undefined
readonly onPluginsToggle?: (() => void) | undefined readonly onPluginsToggle?: (() => void) | undefined
readonly onClearCache?: (() => void | Promise<void>) | undefined readonly onClearCache?: (() => void | Promise<void>) | undefined
} }
@ -70,6 +65,7 @@ export interface ToolbarOptions {
readonly store?: SettingsStore | undefined readonly store?: SettingsStore | undefined
readonly container?: HTMLElement | null | undefined readonly container?: HTMLElement | null | undefined
readonly pageProtocol?: string | undefined readonly pageProtocol?: string | undefined
readonly allowedViewports?: readonly ViewportResolution[] | undefined
readonly confirmReconnect?: ((message: string) => boolean) | undefined readonly confirmReconnect?: ((message: string) => boolean) | undefined
readonly callbacks?: ToolbarCallbacks | undefined readonly callbacks?: ToolbarCallbacks | undefined
} }
@ -78,6 +74,7 @@ export interface ToolbarSnapshot {
readonly connection: ToolbarConnectionState readonly connection: ToolbarConnectionState
readonly settings: ClientSettings readonly settings: ClientSettings
readonly viewportProfile: ViewportProfile readonly viewportProfile: ViewportProfile
readonly allowedViewports: readonly ViewportResolution[]
readonly statusLabel: string readonly statusLabel: string
readonly rttLabel: string readonly rttLabel: string
readonly diagnosticBannerText: string | null readonly diagnosticBannerText: string | null
@ -85,13 +82,18 @@ export interface ToolbarSnapshot {
readonly serverInputValid: boolean readonly serverInputValid: boolean
} }
interface ContributionEntry {
readonly spec: ToolbarContributionSpec
readonly el: HTMLElement | null
readonly labelEl: HTMLLabelElement | null
readonly selectEl: HTMLSelectElement | null
}
/** /**
* Persistent 32px Top DOM Toolbar (`#d2-toolbar`) with primary controls: * Persistent 32px Top DOM Toolbar (`#d2-toolbar`) with primary controls:
* 1. Connection Status + RTT (`ms`) + Diagnostics (5-per-IP quota / WSS TLS error) + Inspector toggle + Plugins modal button (`#d2-toolbar-plugins-btn`) + Dynamic Plugin Contribution Slot (`#d2-toolbar-plugin-slot`) * 1. Connection Status + RTT (`ms`) + Diagnostics (5-per-IP quota / WSS TLS error) + Plugins modal button (`#d2-toolbar-plugins-btn`) + Dynamic Plugin Contribution Slot (`#d2-toolbar-plugin-slot`)
* 2. Live Language Toggle (`中` / `EN`) * 2. Live Language Toggle (`中` / `EN`)
* 3. Lighting Preset Selector (`auto | noon | dusk | night | torch | cold | fullbright`) * 3. Viewport Size Selector (`#d2-toolbar-viewport-select`, native 1.13c `640x480 | 800x600` by default, extensible via `extended-viewports` plugin) + Asset Cache Clear button
* 4. Automap Reveal Selector (`off | level | act`)
* 5. Viewport Size Selector (`800x600 | 1024x768 | 1068x600 | 1280x720`) + Asset Cache Clear button
*/ */
export class Toolbar { export class Toolbar {
private readonly store: SettingsStore private readonly store: SettingsStore
@ -103,6 +105,7 @@ export class Toolbar {
rttMs: null, rttMs: null,
diagnosticKind: 'none', diagnosticKind: 'none',
} }
private allowedViewports: readonly ViewportResolution[]
private serverInputValue: string private serverInputValue: string
private serverInputValid = true private serverInputValid = true
private cacheUsageBytes = 0 private cacheUsageBytes = 0
@ -110,10 +113,7 @@ export class Toolbar {
private pluginsReady = false private pluginsReady = false
private pluginsHasWarning = false private pluginsHasWarning = false
private pluginsMenuOpen = false private pluginsMenuOpen = false
private readonly contributions = new Map< private readonly contributions = new Map<string, ContributionEntry>()
string,
{ spec: ToolbarContributionSpec; el: HTMLElement | null }
>()
private unsubscribeStore: (() => void) | null = null private unsubscribeStore: (() => void) | null = null
private unsubscribeLang: (() => void) | null = null private unsubscribeLang: (() => void) | null = null
@ -124,13 +124,8 @@ export class Toolbar {
private rttTextEl: HTMLElement | null = null private rttTextEl: HTMLElement | null = null
private langBtnZhEl: HTMLButtonElement | null = null private langBtnZhEl: HTMLButtonElement | null = null
private langBtnEnEl: HTMLButtonElement | null = null private langBtnEnEl: HTMLButtonElement | null = null
private lightingLabelEl: HTMLElement | null = null
private lightingSelectEl: HTMLSelectElement | null = null
private automapLabelEl: HTMLElement | null = null
private automapSelectEl: HTMLSelectElement | null = null
private viewportLabelEl: HTMLElement | null = null private viewportLabelEl: HTMLElement | null = null
private viewportSelectEl: HTMLSelectElement | null = null private viewportSelectEl: HTMLSelectElement | null = null
private inspectorBtnEl: HTMLButtonElement | null = null
private pluginsBtnEl: HTMLButtonElement | null = null private pluginsBtnEl: HTMLButtonElement | null = null
private pluginSlotEl: HTMLElement | null = null private pluginSlotEl: HTMLElement | null = null
private cacheClearBtnEl: HTMLButtonElement | null = null private cacheClearBtnEl: HTMLButtonElement | null = null
@ -141,9 +136,16 @@ export class Toolbar {
this.pageProtocol = this.pageProtocol =
options?.pageProtocol ?? options?.pageProtocol ??
(typeof window !== 'undefined' && window.location ? window.location.protocol : 'https:') (typeof window !== 'undefined' && window.location ? window.location.protocol : 'https:')
this.allowedViewports =
options?.allowedViewports && options.allowedViewports.length > 0
? [...options.allowedViewports]
: [...NATIVE_113C_VIEWPORT_RESOLUTIONS]
this.confirmReconnectFn = this.confirmReconnectFn =
options?.confirmReconnect ?? options?.confirmReconnect ??
((msg: string) => (typeof window !== 'undefined' && typeof window.confirm === 'function' ? window.confirm(msg) : true)) ((msg: string) =>
typeof window !== 'undefined' && typeof window.confirm === 'function'
? window.confirm(msg)
: true)
this.callbacks = options?.callbacks ?? {} this.callbacks = options?.callbacks ?? {}
this.serverInputValue = this.store.get().serverHost this.serverInputValue = this.store.get().serverHost
@ -168,9 +170,6 @@ export class Toolbar {
if (changedKeys.includes('viewport') && next.viewport !== prev.viewport) { if (changedKeys.includes('viewport') && next.viewport !== prev.viewport) {
this.callbacks.onViewportChange?.(getViewportProfile(next.viewport)) this.callbacks.onViewportChange?.(getViewportProfile(next.viewport))
} }
if (changedKeys.includes('inspectorOpen') && next.inspectorOpen !== prev.inspectorOpen) {
this.callbacks.onInspectorToggle?.(next.inspectorOpen)
}
this.syncDom() this.syncDom()
}) })
@ -196,11 +195,15 @@ export class Toolbar {
return p === 'connecting' || p === 'bncs' || p === 'mcp' || p === 'd2gs' return p === 'connecting' || p === 'bncs' || p === 'mcp' || p === 'd2gs'
} }
setConnectionState(next: Partial<ToolbarConnectionState> & { readonly phase: ToolbarConnectionPhase }): void { setConnectionState(
next: Partial<ToolbarConnectionState> & { readonly phase: ToolbarConnectionPhase },
): void {
this.connection = { this.connection = {
phase: next.phase, phase: next.phase,
rttMs: next.rttMs !== undefined ? next.rttMs : this.connection.rttMs, rttMs: next.rttMs !== undefined ? next.rttMs : this.connection.rttMs,
diagnosticKind: next.diagnosticKind ?? (next.phase === 'error' ? this.connection.diagnosticKind ?? 'custom' : 'none'), diagnosticKind:
next.diagnosticKind ??
(next.phase === 'error' ? this.connection.diagnosticKind ?? 'custom' : 'none'),
diagnosticMessage: next.diagnosticMessage, diagnosticMessage: next.diagnosticMessage,
} }
this.syncDom() this.syncDom()
@ -319,30 +322,45 @@ export class Toolbar {
this.syncDom() this.syncDom()
} }
getAllowedViewports(): readonly ViewportResolution[] {
return this.allowedViewports
}
setAllowedViewports(resolutions: readonly ViewportResolution[]): void {
const valid = resolutions.filter((r): r is ViewportResolution =>
(VALID_VIEWPORT_RESOLUTIONS as readonly string[]).includes(r),
)
this.allowedViewports =
valid.length > 0 ? valid : [...NATIVE_113C_VIEWPORT_RESOLUTIONS]
const current = this.store.get().viewport
if (!this.allowedViewports.includes(current)) {
const fallback: ViewportResolution = this.allowedViewports.includes('800x600')
? '800x600'
: this.allowedViewports[0]!
this.store.set({ viewport: fallback })
}
this.syncDom()
}
setViewport(viewport: ViewportResolution): ViewportProfile { setViewport(viewport: ViewportResolution): ViewportProfile {
this.store.set({ viewport }) const target: ViewportResolution = this.allowedViewports.includes(viewport)
? viewport
: this.allowedViewports.includes('800x600')
? '800x600'
: this.allowedViewports[0]!
this.store.set({ viewport: target })
this.syncDom() this.syncDom()
return getViewportProfile(viewport) return getViewportProfile(target)
} }
/** Advance to the next resolution in `VALID_VIEWPORT_RESOLUTIONS` order, wrapping back to the first. */ /** Advance to the next resolution in `allowedViewports` order, wrapping back to the first. */
toggleViewport(): ViewportProfile { toggleViewport(): ViewportProfile {
const order = VALID_VIEWPORT_RESOLUTIONS const order = this.allowedViewports
const next = order[(order.indexOf(this.store.get().viewport) + 1) % order.length] const currentIdx = order.indexOf(this.store.get().viewport)
const next = order[(currentIdx + 1) % order.length]!
return this.setViewport(next) return this.setViewport(next)
} }
setInspectorOpen(open: boolean): void {
this.store.set({ inspectorOpen: open })
this.syncDom()
}
toggleInspector(): boolean {
const next = !this.store.get().inspectorOpen
this.setInspectorOpen(next)
return next
}
setPluginsButtonState(state: { setPluginsButtonState(state: {
readonly ready: boolean readonly ready: boolean
readonly hasWarning: boolean readonly hasWarning: boolean
@ -364,6 +382,9 @@ export class Toolbar {
} }
let el: HTMLElement | null = null let el: HTMLElement | null = null
let labelEl: HTMLLabelElement | null = null
let selectEl: HTMLSelectElement | null = null
if (typeof document !== 'undefined' && this.pluginSlotEl) { if (typeof document !== 'undefined' && this.pluginSlotEl) {
const lang = this.store.get().lang || getGameLang() const lang = this.store.get().lang || getGameLang()
const label = resolveLocalizedText(spec.label, lang) const label = resolveLocalizedText(spec.label, lang)
@ -377,7 +398,7 @@ export class Toolbar {
link.textContent = label link.textContent = label
link.title = label link.title = label
el = link el = link
} else { } else if (spec.kind === 'button') {
const btn = document.createElement('button') const btn = document.createElement('button')
btn.type = 'button' btn.type = 'button'
btn.id = spec.domId btn.id = spec.domId
@ -388,11 +409,40 @@ export class Toolbar {
spec.onClick() spec.onClick()
}) })
el = btn el = btn
} else {
const group = document.createElement('div')
group.id = spec.domId
group.className = 'd2-toolbar__group'
const lbl = document.createElement('label')
lbl.className = 'd2-toolbar__label'
lbl.htmlFor = spec.selectDomId
lbl.textContent = label
const sel = document.createElement('select')
sel.id = spec.selectDomId
sel.className = 'd2-toolbar__select'
for (const optSpec of spec.options) {
const opt = document.createElement('option')
opt.value = optSpec.value
opt.textContent = resolveLocalizedText(optSpec.label, lang)
sel.appendChild(opt)
}
sel.value = spec.getValue()
sel.addEventListener('change', () => {
spec.onChange(sel.value)
})
group.appendChild(lbl)
group.appendChild(sel)
el = group
labelEl = lbl
selectEl = sel
} }
this.pluginSlotEl.appendChild(el) this.pluginSlotEl.appendChild(el)
} }
const entry = { spec, el } const entry: ContributionEntry = { spec, el, labelEl, selectEl }
this.contributions.set(spec.domId, entry) this.contributions.set(spec.domId, entry)
return () => { return () => {
@ -447,6 +497,7 @@ export class Toolbar {
connection: this.connection, connection: this.connection,
settings, settings,
viewportProfile: getViewportProfile(settings.viewport), viewportProfile: getViewportProfile(settings.viewport),
allowedViewports: this.allowedViewports,
statusLabel, statusLabel,
rttLabel, rttLabel,
diagnosticBannerText, diagnosticBannerText,
@ -622,7 +673,7 @@ a.d2-toolbar__btn {
root.id = 'd2-toolbar' root.id = 'd2-toolbar'
root.className = 'd2-toolbar' root.className = 'd2-toolbar'
// Group 1: Connection Status + RTT + Packet Inspector button + Plugins button + Plugin Contribution Slot // Group 1: Connection Status + RTT + Plugins button + Plugin Contribution Slot
const statusGroup = doc.createElement('div') const statusGroup = doc.createElement('div')
statusGroup.className = 'd2-toolbar__group' statusGroup.className = 'd2-toolbar__group'
@ -640,14 +691,6 @@ a.d2-toolbar__btn {
statusBadge.appendChild(statusText) statusBadge.appendChild(statusText)
statusBadge.appendChild(rttText) statusBadge.appendChild(rttText)
const inspectorBtn = doc.createElement('button')
inspectorBtn.type = 'button'
inspectorBtn.id = 'd2-toolbar-inspector-btn'
inspectorBtn.className = 'd2-toolbar__btn'
inspectorBtn.addEventListener('click', () => {
this.toggleInspector()
})
const pluginsBtn = doc.createElement('button') const pluginsBtn = doc.createElement('button')
pluginsBtn.type = 'button' pluginsBtn.type = 'button'
pluginsBtn.id = 'd2-toolbar-plugins-btn' pluginsBtn.id = 'd2-toolbar-plugins-btn'
@ -663,7 +706,6 @@ a.d2-toolbar__btn {
pluginSlot.className = 'd2-toolbar__group' pluginSlot.className = 'd2-toolbar__group'
statusGroup.appendChild(statusBadge) statusGroup.appendChild(statusBadge)
statusGroup.appendChild(inspectorBtn)
statusGroup.appendChild(pluginsBtn) statusGroup.appendChild(pluginsBtn)
statusGroup.appendChild(pluginSlot) statusGroup.appendChild(pluginSlot)
@ -687,37 +729,7 @@ a.d2-toolbar__btn {
langGroup.appendChild(langBtnZh) langGroup.appendChild(langBtnZh)
langGroup.appendChild(langBtnEn) langGroup.appendChild(langBtnEn)
// Group 3: Lighting Selector // Group 3: Viewport Size Selector + Asset Cache Clear button
const lightingGroup = doc.createElement('div')
lightingGroup.className = 'd2-toolbar__group'
const lightingLabel = doc.createElement('label')
lightingLabel.className = 'd2-toolbar__label'
lightingLabel.htmlFor = 'd2-toolbar-lighting-select'
const lightingSelect = doc.createElement('select')
lightingSelect.id = 'd2-toolbar-lighting-select'
lightingSelect.className = 'd2-toolbar__select'
lightingSelect.addEventListener('change', () => {
this.setLightingPreset(lightingSelect.value as ClientLightingPreset)
})
lightingGroup.appendChild(lightingLabel)
lightingGroup.appendChild(lightingSelect)
// Group 4: Automap Reveal Selector (Off / Level / Act)
const automapGroup = doc.createElement('div')
automapGroup.className = 'd2-toolbar__group'
const automapLabel = doc.createElement('label')
automapLabel.className = 'd2-toolbar__label'
automapLabel.htmlFor = 'd2-toolbar-automap-select'
const automapSelect = doc.createElement('select')
automapSelect.id = 'd2-toolbar-automap-select'
automapSelect.className = 'd2-toolbar__select'
automapSelect.addEventListener('change', () => {
this.setAutomapReveal(automapSelect.value as AutomapRevealMode)
})
automapGroup.appendChild(automapLabel)
automapGroup.appendChild(automapSelect)
// Group 5: Viewport Size Selector (every VALID_VIEWPORT_RESOLUTIONS entry) + Asset Cache Clear button
const viewportGroup = doc.createElement('div') const viewportGroup = doc.createElement('div')
viewportGroup.className = 'd2-toolbar__group' viewportGroup.className = 'd2-toolbar__group'
const viewportLabel = doc.createElement('label') const viewportLabel = doc.createElement('label')
@ -726,12 +738,6 @@ a.d2-toolbar__btn {
const viewportSelect = doc.createElement('select') const viewportSelect = doc.createElement('select')
viewportSelect.id = 'd2-toolbar-viewport-select' viewportSelect.id = 'd2-toolbar-viewport-select'
viewportSelect.className = 'd2-toolbar__select' viewportSelect.className = 'd2-toolbar__select'
for (const vp of VALID_VIEWPORT_RESOLUTIONS) {
const opt = doc.createElement('option')
opt.value = vp
opt.textContent = formatViewportLabel(vp)
viewportSelect.appendChild(opt)
}
viewportSelect.addEventListener('change', () => { viewportSelect.addEventListener('change', () => {
this.setViewport(viewportSelect.value as ViewportResolution) this.setViewport(viewportSelect.value as ViewportResolution)
}) })
@ -755,8 +761,6 @@ a.d2-toolbar__btn {
root.appendChild(statusGroup) root.appendChild(statusGroup)
root.appendChild(langGroup) root.appendChild(langGroup)
root.appendChild(lightingGroup)
root.appendChild(automapGroup)
root.appendChild(viewportGroup) root.appendChild(viewportGroup)
root.appendChild(diagBanner) root.appendChild(diagBanner)
@ -770,13 +774,8 @@ a.d2-toolbar__btn {
this.rttTextEl = rttText this.rttTextEl = rttText
this.langBtnZhEl = langBtnZh this.langBtnZhEl = langBtnZh
this.langBtnEnEl = langBtnEn this.langBtnEnEl = langBtnEn
this.lightingLabelEl = lightingLabel
this.lightingSelectEl = lightingSelect
this.automapLabelEl = automapLabel
this.automapSelectEl = automapSelect
this.viewportLabelEl = viewportLabel this.viewportLabelEl = viewportLabel
this.viewportSelectEl = viewportSelect this.viewportSelectEl = viewportSelect
this.inspectorBtnEl = inspectorBtn
this.pluginsBtnEl = pluginsBtn this.pluginsBtnEl = pluginsBtn
this.pluginSlotEl = pluginSlot this.pluginSlotEl = pluginSlot
this.cacheClearBtnEl = cacheClearBtn this.cacheClearBtnEl = cacheClearBtn
@ -799,12 +798,6 @@ a.d2-toolbar__btn {
if (this.rttTextEl) { if (this.rttTextEl) {
this.rttTextEl.textContent = snap.rttLabel this.rttTextEl.textContent = snap.rttLabel
} }
if (this.inspectorBtnEl) {
this.inspectorBtnEl.textContent = tToolbar('toolbar.inspector.toggle', lang)
this.inspectorBtnEl.className = snap.settings.inspectorOpen
? 'd2-toolbar__btn d2-toolbar__btn--active'
: 'd2-toolbar__btn'
}
if (this.pluginsBtnEl) { if (this.pluginsBtnEl) {
const baseLabel = tToolbar('toolbar.plugins.open', lang) const baseLabel = tToolbar('toolbar.plugins.open', lang)
this.pluginsBtnEl.disabled = !this.pluginsReady this.pluginsBtnEl.disabled = !this.pluginsReady
@ -818,8 +811,38 @@ a.d2-toolbar__btn {
this.pluginsBtnEl.className = cls this.pluginsBtnEl.className = cls
} }
for (const entry of this.contributions.values()) { for (const entry of this.contributions.values()) {
if (entry.el) { if (!entry.el) continue
const label = resolveLocalizedText(entry.spec.label, lang) const spec = entry.spec
const label = resolveLocalizedText(spec.label, lang)
if (spec.kind === 'select') {
if (entry.labelEl) {
entry.labelEl.textContent = label
}
if (entry.selectEl && typeof document !== 'undefined') {
const currentOpts = Array.from(entry.selectEl.options)
if (
currentOpts.length !== spec.options.length ||
currentOpts.some((o, idx) => o.value !== spec.options[idx]?.value)
) {
entry.selectEl.innerHTML = ''
for (const optSpec of spec.options) {
const opt = document.createElement('option')
opt.value = optSpec.value
opt.textContent = resolveLocalizedText(optSpec.label, lang)
entry.selectEl.appendChild(opt)
}
} else {
for (let idx = 0; idx < spec.options.length; idx += 1) {
const optSpec = spec.options[idx]!
const optEl = currentOpts[idx]
if (optEl) {
optEl.textContent = resolveLocalizedText(optSpec.label, lang)
}
}
}
entry.selectEl.value = spec.getValue()
}
} else {
entry.el.textContent = label entry.el.textContent = label
entry.el.title = label entry.el.title = label
} }
@ -832,37 +855,23 @@ a.d2-toolbar__btn {
this.langBtnEnEl.className = this.langBtnEnEl.className =
lang === 'en' ? 'd2-toolbar__btn d2-toolbar__btn--active' : 'd2-toolbar__btn' lang === 'en' ? 'd2-toolbar__btn d2-toolbar__btn--active' : 'd2-toolbar__btn'
} }
if (this.lightingLabelEl) {
this.lightingLabelEl.textContent = tToolbar('toolbar.lighting.label', lang)
}
if (this.lightingSelectEl && typeof document !== 'undefined') {
this.lightingSelectEl.innerHTML = ''
for (const presetId of CLIENT_LIGHTING_PRESET_ORDER) {
const cfg = LIGHTING_PRESETS[presetId]
const opt = document.createElement('option')
opt.value = presetId
opt.textContent = lang === 'en' ? cfg.labelEn : cfg.labelZh
this.lightingSelectEl.appendChild(opt)
}
this.lightingSelectEl.value = snap.settings.lightingPreset
}
if (this.automapLabelEl) {
this.automapLabelEl.textContent = tToolbar('toolbar.automap.label', lang)
}
if (this.automapSelectEl && typeof document !== 'undefined') {
this.automapSelectEl.innerHTML = ''
for (const mode of VALID_AUTOMAP_REVEAL_MODES) {
const opt = document.createElement('option')
opt.value = mode
opt.textContent = tToolbar(`toolbar.automap.${mode}` as const, lang)
this.automapSelectEl.appendChild(opt)
}
this.automapSelectEl.value = snap.settings.automapReveal
}
if (this.viewportLabelEl) { if (this.viewportLabelEl) {
this.viewportLabelEl.textContent = tToolbar('toolbar.viewport.label', lang) this.viewportLabelEl.textContent = tToolbar('toolbar.viewport.label', lang)
} }
if (this.viewportSelectEl) { if (this.viewportSelectEl && typeof document !== 'undefined') {
const existingValues = Array.from(this.viewportSelectEl.options).map((o) => o.value)
const needsRebuild =
existingValues.length !== this.allowedViewports.length ||
existingValues.some((v, idx) => v !== this.allowedViewports[idx])
if (needsRebuild) {
this.viewportSelectEl.innerHTML = ''
for (const vp of this.allowedViewports) {
const opt = document.createElement('option')
opt.value = vp
opt.textContent = formatViewportLabel(vp)
this.viewportSelectEl.appendChild(opt)
}
}
this.viewportSelectEl.value = snap.settings.viewport this.viewportSelectEl.value = snap.settings.viewport
} }
if (this.cacheClearBtnEl) { if (this.cacheClearBtnEl) {

View File

@ -79,6 +79,17 @@ export const VIEWPORT_1068x600: ViewportProfile = createViewportProfile('1068x60
/** 16:9 720p: bar at (240, 120), panels at y = 60. */ /** 16:9 720p: bar at (240, 120), panels at y = 60. */
export const VIEWPORT_1280x720: ViewportProfile = createViewportProfile('1280x720', 1280, 720) export const VIEWPORT_1280x720: ViewportProfile = createViewportProfile('1280x720', 1280, 720)
export const NATIVE_113C_VIEWPORT_RESOLUTIONS: readonly ViewportResolution[] = [
'640x480',
'800x600',
] as const
export const EXTENDED_VIEWPORT_RESOLUTIONS: readonly ViewportResolution[] = [
'1024x768',
'1068x600',
'1280x720',
] as const
export const VIEWPORT_PROFILES: Readonly<Record<ViewportResolution, ViewportProfile>> = { export const VIEWPORT_PROFILES: Readonly<Record<ViewportResolution, ViewportProfile>> = {
'640x480': VIEWPORT_640x480, '640x480': VIEWPORT_640x480,
'800x600': VIEWPORT_800x600, '800x600': VIEWPORT_800x600,

View File

@ -978,7 +978,7 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
expect(session.world.areaId).toBe(40) expect(session.world.areaId).toBe(40)
expect(session.predictor.getSnapshot().logicalX).toBe(620) expect(session.predictor.getSnapshot().logicalX).toBe(620)
expect(session.predictor.getSnapshot().logicalY).toBe(640) expect(session.predictor.getSnapshot().logicalY).toBe(640)
expect(session.packetInspector.getAllRows().length).toBeGreaterThanOrEqual(2) expect(session.packetTap.entries.length).toBeGreaterThanOrEqual(2)
// 4. Graceful disconnect recovery // 4. Graceful disconnect recovery
const disconnectReasons: string[] = [] const disconnectReasons: string[] = []
@ -1023,10 +1023,7 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
expect(session.world.containers.belt.size).toBeGreaterThanOrEqual(4) expect(session.world.containers.belt.size).toBeGreaterThanOrEqual(4)
expect(session.hudManager.inventory.gridItems.length).toBeGreaterThanOrEqual(2) expect(session.hudManager.inventory.gridItems.length).toBeGreaterThanOrEqual(2)
const decodedRows = session.packetInspector expect(session.packetTap.entries.length).toBe(recordCount)
.getAllRows()
.filter((r) => r.decoded !== undefined)
expect(decodedRows.length).toBeGreaterThan(0)
session.destroy() session.destroy()
} }

View File

@ -11,10 +11,6 @@ import {
type AudioBackendLike, type AudioBackendLike,
} from '../../src/client/audio/sound-service.ts' } from '../../src/client/audio/sound-service.ts'
import { AutomapView } from '../../src/client/automap/automap-view.ts' import { AutomapView } from '../../src/client/automap/automap-view.ts'
import {
formatHexDump,
PacketInspector,
} from '../../src/client/inspector/packet-inspector.ts'
import { MapService } from '../../src/client/map/map-service.ts' import { MapService } from '../../src/client/map/map-service.ts'
import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts' import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts'
import { import {
@ -23,6 +19,7 @@ import {
parseClientSettingsJson, parseClientSettingsJson,
sanitizeClientSettings, sanitizeClientSettings,
SETTINGS_STORAGE_KEY, SETTINGS_STORAGE_KEY,
VALID_VIEWPORT_RESOLUTIONS,
validateServerHost, validateServerHost,
} from '../../src/client/settings/client-settings.ts' } from '../../src/client/settings/client-settings.ts'
import { import {
@ -61,6 +58,7 @@ import {
asSubTile, asSubTile,
asUnitId, asUnitId,
InMemoryPacketTap, InMemoryPacketTap,
parseD2CapJsonl,
} from '../../src/netproto/index.ts' } from '../../src/netproto/index.ts'
import { import {
applyLightingInputs, applyLightingInputs,
@ -113,7 +111,6 @@ describe('ClientSettings & SettingsStore (F14)', () => {
lightingPreset: 'non_existent_preset', lightingPreset: 'non_existent_preset',
automapReveal: 'invalid_mode', automapReveal: 'invalid_mode',
viewport: '1920x1080', viewport: '1920x1080',
inspectorOpen: 'yes',
audioMuted: 123, audioMuted: 123,
masterVolume: 5.5, masterVolume: 5.5,
extraHackField: 'should_be_stripped', extraHackField: 'should_be_stripped',
@ -135,7 +132,6 @@ describe('ClientSettings & SettingsStore (F14)', () => {
lightingPreset: 'auto', lightingPreset: 'auto',
automapReveal: 'off', automapReveal: 'off',
viewport: '800x600', viewport: '800x600',
inspectorOpen: false,
audioMuted: false, audioMuted: false,
masterVolume: 1.0, masterVolume: 1.0,
}) })
@ -182,7 +178,7 @@ describe('ClientSettings & SettingsStore (F14)', () => {
}) })
describe('Top DOM Toolbar & Live Language / Settings Controls (F14)', () => { describe('Top DOM Toolbar & Live Language / Settings Controls (F14)', () => {
it('supports all 6 controls, rejects bare IP on HTTPS, and prompts confirmation when changing host mid-session', () => { it('supports core controls, rejects bare IP on HTTPS, and prompts confirmation when changing host mid-session', () => {
const storage = new MemoryStorage() const storage = new MemoryStorage()
const store = new SettingsStore({ const store = new SettingsStore({
storage, storage,
@ -194,19 +190,18 @@ describe('Top DOM Toolbar & Live Language / Settings Controls (F14)', () => {
const lightingSpy = vi.fn() const lightingSpy = vi.fn()
const automapSpy = vi.fn() const automapSpy = vi.fn()
const viewportSpy = vi.fn() const viewportSpy = vi.fn()
const inspectorSpy = vi.fn()
let confirmResponse = false let confirmResponse = false
const toolbar = new Toolbar({ const toolbar = new Toolbar({
store, store,
pageProtocol: 'https:', pageProtocol: 'https:',
allowedViewports: VALID_VIEWPORT_RESOLUTIONS,
confirmReconnect: () => confirmResponse, confirmReconnect: () => confirmResponse,
callbacks: { callbacks: {
onServerHostReconnect: reconnectSpy, onServerHostReconnect: reconnectSpy,
onLightingChange: lightingSpy, onLightingChange: lightingSpy,
onAutomapRevealChange: automapSpy, onAutomapRevealChange: automapSpy,
onViewportChange: viewportSpy, onViewportChange: viewportSpy,
onInspectorToggle: inspectorSpy,
}, },
}) })
@ -275,11 +270,6 @@ describe('Top DOM Toolbar & Live Language / Settings Controls (F14)', () => {
expect(store.get().viewport).toBe('1024x768') expect(store.get().viewport).toBe('1024x768')
expect(viewportSpy).toHaveBeenCalledWith(VIEWPORT_1024x768) expect(viewportSpy).toHaveBeenCalledWith(VIEWPORT_1024x768)
// Packet Inspector Toggle
expect(toolbar.toggleInspector()).toBe(true)
expect(store.get().inspectorOpen).toBe(true)
expect(inspectorSpy).toHaveBeenCalledWith(true)
// Plugins button i18n // Plugins button i18n
expect(tToolbar('toolbar.plugins.open', 'zh')).toBe('插件') expect(tToolbar('toolbar.plugins.open', 'zh')).toBe('插件')
expect(tToolbar('toolbar.plugins.open', 'en')).toBe('Plugins') expect(tToolbar('toolbar.plugins.open', 'en')).toBe('Plugins')
@ -712,7 +702,7 @@ describe('HttpRangeMpqSource, OpfsMpqManager & SoundService (F19)', () => {
}) })
}) })
describe('OutboundRateLimiter & PacketInspector / .d2cap Replay (F20)', () => { describe('OutboundRateLimiter & PacketTap / .d2cap Capture (F20)', () => {
it('throttles rapid movement packets, coalesces trailing move target, and never drops Ping heartbeats', () => { it('throttles rapid movement packets, coalesces trailing move target, and never drops Ping heartbeats', () => {
const limiter = new OutboundRateLimiter({ const limiter = new OutboundRateLimiter({
moveMinIntervalMs: 120, moveMinIntervalMs: 120,
@ -747,20 +737,8 @@ describe('OutboundRateLimiter & PacketInspector / .d2cap Replay (F20)', () => {
expect(limiter.tryAcquire({ type: 'Ping' }, 1131).allowed).toBe(true) expect(limiter.tryAcquire({ type: 'Ping' }, 1131).allowed).toBe(true)
}) })
it('captures PacketTap traffic, filters by phase/direction/opcode, exports .d2cap JSONL, and replays deterministically', () => { it('captures PacketTap traffic, exports .d2cap JSONL, and parses records deterministically', () => {
const store = new SettingsStore({
storage: new MemoryStorage(),
defaultLang: 'en',
syncGlobalLang: false,
})
const tap = new InMemoryPacketTap() const tap = new InMemoryPacketTap()
const replayed: number[] = []
const inspector = new PacketInspector({
store,
tap,
onReplayRecord: frame => replayed.push(frame.opcode),
})
tap.record({ tap.record({
t: 10, t: 10,
@ -784,37 +762,15 @@ describe('OutboundRateLimiter & PacketInspector / .d2cap Replay (F20)', () => {
raw: new Uint8Array([0x03, 0x64, 0x00, 0x64, 0x00]), raw: new Uint8Array([0x03, 0x64, 0x00, 0x64, 0x00]),
}) })
expect(inspector.getAllRows()).toHaveLength(3) expect(tap.entries).toHaveLength(3)
expect(formatHexDump(new Uint8Array([0x15, 0x41, 0x42]))).toContain('15 41 42') const jsonl = tap.toD2CapJsonl({
format: 'd2cap-v1',
// Filter by d2gs + s2c createdAt: '2026-10-03T00:00:00Z',
inspector.setFilter({ phase: 'd2gs', direction: 's2c' }) server: '127.0.0.1',
const s2cRows = inspector.getFilteredRows() })
expect(s2cRows).toHaveLength(1) const parsed = parseD2CapJsonl(jsonl)
expect(s2cRows[0]!.opcode).toBe(0x15) expect(parsed.records).toHaveLength(3)
expect(parsed.records.map((r) => r.packetId)).toEqual([0x50, 0x15, 0x03])
// Filter by hex opcode string
inspector.setFilter({ phase: 'all', direction: 'all', opcode: '0x03' })
expect(inspector.getFilteredRows()).toHaveLength(1)
// Export to .d2cap JSONL and replay into a fresh inspector
const jsonl = inspector.exportD2CapJsonl()
expect(jsonl.split('\n')).toHaveLength(3)
inspector.clear()
inspector.setFilter({ phase: 'all', direction: 'all', opcode: null })
const loaded = inspector.loadD2CapJsonl(jsonl)
expect(loaded).toHaveLength(3)
const step1 = inspector.stepReplay()
expect(step1?.opcode).toBe(0x50)
expect(replayed).toEqual([0x50])
const remaining = inspector.replayAll()
expect(remaining).toBe(2)
expect(replayed).toEqual([0x50, 0x15, 0x03])
inspector.destroy()
}) })
}) })

View File

@ -109,6 +109,7 @@ import {
DEFAULT_CLIENT_SETTINGS, DEFAULT_CLIENT_SETTINGS,
DEFAULT_SERVER_HOST, DEFAULT_SERVER_HOST,
deriveRealmWsEndpoints, deriveRealmWsEndpoints,
VALID_VIEWPORT_RESOLUTIONS,
validateServerHost, validateServerHost,
} from '../../src/client/settings/client-settings.ts' } from '../../src/client/settings/client-settings.ts'
import { SettingsStore, type StorageLike } from '../../src/client/settings/settings-store.ts' import { SettingsStore, type StorageLike } from '../../src/client/settings/settings-store.ts'
@ -144,14 +145,11 @@ import {
InMemoryChunkCache, InMemoryChunkCache,
OpfsMpqManager, OpfsMpqManager,
} from '../../src/client/assets/opfs-mpq.ts' } from '../../src/client/assets/opfs-mpq.ts'
import { OnlineSession } from '../../src/client/session/online-session.ts'
import { import {
classifyClientCommand, classifyClientCommand,
OutboundRateLimiter, OutboundRateLimiter,
} from '../../src/client/session/rate-limiter.ts' } from '../../src/client/session/rate-limiter.ts'
import {
formatHexDump,
PacketInspector,
} from '../../src/client/inspector/packet-inspector.ts'
import { import {
D2S_MAGIC, D2S_MAGIC,
D2S_QUEST_OFFSET, D2S_QUEST_OFFSET,
@ -1430,13 +1428,13 @@ describe('Tier 1 — F16: Top Control Toolbar & i18n (en/zh)', () => {
toolbar.destroy() toolbar.destroy()
}) })
it('F16.2: Toolbar toggles viewport between 800x600 and 1024x768', () => { it('F16.2: Toolbar toggles viewport between 800x600 and 1024x768 when extended viewports are allowed', () => {
const store = new SettingsStore({ const store = new SettingsStore({
storage: new MemoryStorage(), storage: new MemoryStorage(),
defaultLang: 'en', defaultLang: 'en',
syncGlobalLang: false, syncGlobalLang: false,
}) })
const toolbar = new Toolbar({ store }) const toolbar = new Toolbar({ store, allowedViewports: VALID_VIEWPORT_RESOLUTIONS })
expect(store.get().viewport).toBe('800x600') expect(store.get().viewport).toBe('800x600')
const next = toolbar.toggleViewport() const next = toolbar.toggleViewport()
expect(next).toEqual(VIEWPORT_1024x768) expect(next).toEqual(VIEWPORT_1024x768)
@ -1792,10 +1790,8 @@ describe('Tier 1 — F20: Outbound Rate Limiter, ProtocolSession & Packet Inspec
expect(limiter.tryAcquire({ type: 'Ping' }, 1055).allowed).toBe(true) expect(limiter.tryAcquire({ type: 'Ping' }, 1055).allowed).toBe(true)
}) })
it('F20.4: PacketInspector captures PacketTap entries, filters by phase/direction/opcode, and formats hex dumps', () => { it('F20.4: InMemoryPacketTap captures PacketTap entries and serializes/parses .d2cap JSONL records', () => {
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
const tap = new InMemoryPacketTap() const tap = new InMemoryPacketTap()
const inspector = new PacketInspector({ store, tap })
tap.record({ tap.record({
t: 10, t: 10,
@ -1812,36 +1808,32 @@ describe('Tier 1 — F20: Outbound Rate Limiter, ProtocolSession & Packet Inspec
raw: new Uint8Array([0x15, 0x00, 0x01, 0x00, 0x00, 0x00]), raw: new Uint8Array([0x15, 0x00, 0x01, 0x00, 0x00, 0x00]),
}) })
expect(inspector.getAllRows()).toHaveLength(2) expect(tap.entries).toHaveLength(2)
expect(formatHexDump(new Uint8Array([0x15, 0x41, 0x42]))).toContain('15 41 42') const jsonl = tap.toD2CapJsonl({ format: 'd2cap-v1', createdAt: '2026-01-01T00:00:00Z', server: '127.0.0.1' })
const parsed = parseD2CapJsonl(jsonl)
inspector.setFilter({ phase: 'd2gs', direction: 's2c' }) expect(parsed.records).toHaveLength(2)
expect(inspector.getFilteredRows()).toHaveLength(1) const gameS2c = parsed.records.filter((r) => r.conn === 'game' && r.dir === 's2c')
expect(inspector.getFilteredRows()[0]!.opcode).toBe(0x15) expect(gameS2c).toHaveLength(1)
inspector.destroy() expect(gameS2c[0]!.packetId).toBe(0x15)
}) })
it('F20.5: PacketInspector exports and replays .d2cap JSONL traces via stepReplay and replayAll', () => { it('F20.5: OnlineSession loads and replays .d2cap JSONL traces via stepD2CapReplay and replayAllD2Cap', () => {
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false }) const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
const tap = new InMemoryPacketTap() const tap = new InMemoryPacketTap()
const replayed: number[] = [] const session = new OnlineSession({ settingsStore: store, itemTables })
const inspector = new PacketInspector({ const replayedEvents: string[] = []
store, session.onServerEvent((ev) => replayedEvents.push(ev.type))
tap,
onReplayRecord: (frame) => replayed.push(frame.opcode),
})
tap.record({ t: 5, conn: 'game', dir: 's2c', packetId: 0x00, raw: new Uint8Array([0x00]) }) tap.record({ t: 5, conn: 'game', dir: 's2c', packetId: 0x00, raw: new Uint8Array([0x00]) })
tap.record({ t: 15, conn: 'game', dir: 's2c', packetId: 0x02, raw: new Uint8Array([0x02]) }) tap.record({ t: 15, conn: 'game', dir: 's2c', packetId: 0x04, raw: new Uint8Array([0x04]) })
const jsonl = inspector.exportD2CapJsonl() const jsonl = tap.toD2CapJsonl({ format: 'd2cap-v1', createdAt: '2026-01-01T00:00:00Z', server: '127.0.0.1' })
inspector.clear() const loadedCount = session.loadD2CapReplay(jsonl)
const loaded = inspector.loadD2CapJsonl(jsonl) expect(loadedCount).toBe(2)
expect(loaded).toHaveLength(2)
expect(inspector.stepReplay()?.opcode).toBe(0x00) expect(session.stepD2CapReplay()?.opcode).toBe(0x00)
expect(inspector.replayAll()).toBe(1) expect(session.replayAllD2Cap()).toBe(1)
expect(replayed).toEqual([0x00, 0x02]) expect(replayedEvents).toEqual(['GameLoading', 'LoadDone'])
inspector.destroy() session.destroy()
}) })
}) })

View File

@ -107,8 +107,8 @@ import {
OpfsMpqManager, OpfsMpqManager,
} from '../../src/client/assets/opfs-mpq.ts' } from '../../src/client/assets/opfs-mpq.ts'
import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts' import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts'
import { formatHexDump, PacketInspector } from '../../src/client/inspector/packet-inspector.ts' import { OnlineSession } from '../../src/client/session/online-session.ts'
import { asSubTile } from '../../src/netproto/index.ts' import { asSubTile, bytesToHex } from '../../src/netproto/index.ts'
class MemoryStorage implements StorageLike { class MemoryStorage implements StorageLike {
readonly data = new Map<string, string>() readonly data = new Map<string, string>()
@ -1079,15 +1079,17 @@ describe('Tier 2 — Negative, Boundary, Malformed & Corner Cases (F1–F20)', (
toolbar.destroy() toolbar.destroy()
}) })
it('F16.B4: Toolbar.toggleInspector flips inspectorOpen boolean state cleanly', () => { it('F16.B4: Toolbar.setAllowedViewports clamps non-1.13c viewport back to 800x600 when restricted', () => {
const store = new SettingsStore({ const store = new SettingsStore({
storage: new MemoryStorage(), storage: new MemoryStorage(),
defaultLang: 'en', defaultLang: 'en',
syncGlobalLang: false, syncGlobalLang: false,
}) })
store.update({ viewport: '1024x768' })
const toolbar = new Toolbar({ store }) const toolbar = new Toolbar({ store })
expect(toolbar.toggleInspector()).toBe(true) toolbar.setAllowedViewports(['640x480', '800x600'])
expect(toolbar.toggleInspector()).toBe(false) expect(store.get().viewport).toBe('800x600')
expect(toolbar.getAllowedViewports()).toEqual(['640x480', '800x600'])
toolbar.destroy() toolbar.destroy()
}) })
@ -1303,23 +1305,24 @@ describe('Tier 2 — Negative, Boundary, Malformed & Corner Cases (F1–F20)', (
expect(third.reason).toBe('token_bucket_empty') expect(third.reason).toBe('token_bucket_empty')
}) })
it('F20.B3: PacketInspector respects maxRows ring buffer limit and stepReplay returns null when empty', () => { it('F20.B3: InMemoryPacketTap respects maxEntries ring buffer limit and OnlineSession.stepD2CapReplay returns null when empty', () => {
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false }) const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
const tap = new InMemoryPacketTap() const tap = new InMemoryPacketTap(2)
const inspector = new PacketInspector({ store, tap, maxRows: 2 }) const session = new OnlineSession({ settingsStore: store })
tap.record({ t: 1, conn: 'game', dir: 's2c', packetId: 0x00, raw: new Uint8Array([0x00]) }) tap.record({ t: 1, conn: 'game', dir: 's2c', packetId: 0x00, raw: new Uint8Array([0x00]) })
tap.record({ t: 2, conn: 'game', dir: 's2c', packetId: 0x02, raw: new Uint8Array([0x02]) }) tap.record({ t: 2, conn: 'game', dir: 's2c', packetId: 0x02, raw: new Uint8Array([0x02]) })
tap.record({ t: 3, conn: 'game', dir: 's2c', packetId: 0x04, raw: new Uint8Array([0x04]) }) tap.record({ t: 3, conn: 'game', dir: 's2c', packetId: 0x04, raw: new Uint8Array([0x04]) })
expect(inspector.getAllRows()).toHaveLength(2) expect(tap.entries).toHaveLength(2)
expect(inspector.getAllRows()[0]?.opcode).toBe(0x02) expect(tap.entries[0]?.packetId).toBe(0x02)
expect(inspector.stepReplay()).toBeNull() expect(session.stepD2CapReplay()).toBeNull()
inspector.destroy() session.destroy()
}) })
it('F20.B4: formatHexDump handles empty 0-byte buffer and multi-line (>16 byte) buffers', () => { it('F20.B4: bytesToHex and hexToBytes handle empty 0-byte buffer and multi-line (>16 byte) buffers', () => {
expect(formatHexDump(new Uint8Array(0))).toBeDefined() expect(bytesToHex(new Uint8Array(0))).toBe('')
const dump = formatHexDump(new Uint8Array(32).fill(0x41)) const hex = bytesToHex(new Uint8Array(32).fill(0x41))
expect(dump.split('\n').length).toBeGreaterThanOrEqual(2) expect(hex.length).toBe(64)
expect(hexToBytes(hex)).toHaveLength(32)
}) })
it('F20.B5: parseD2CapJsonl ignores blank and comment lines and throws on malformed JSON line', () => { it('F20.B5: parseD2CapJsonl ignores blank and comment lines and throws on malformed JSON line', () => {

View File

@ -94,6 +94,7 @@ import {
} from '../../tools/d2s-unlock.ts' } from '../../tools/d2s-unlock.ts'
import { import {
deriveRealmWsEndpoints, deriveRealmWsEndpoints,
VALID_VIEWPORT_RESOLUTIONS,
validateServerHost, validateServerHost,
} from '../../src/client/settings/client-settings.ts' } from '../../src/client/settings/client-settings.ts'
import { import {
@ -126,8 +127,8 @@ import {
InMemoryChunkCache, InMemoryChunkCache,
OpfsMpqManager, OpfsMpqManager,
} from '../../src/client/assets/opfs-mpq.ts' } from '../../src/client/assets/opfs-mpq.ts'
import { OnlineSession } from '../../src/client/session/online-session.ts'
import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts' import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts'
import { PacketInspector } from '../../src/client/inspector/packet-inspector.ts'
import { LightGrid } from '../../src/common/drlg/light-grid.ts' import { LightGrid } from '../../src/common/drlg/light-grid.ts'
class MemoryStorage implements StorageLike { class MemoryStorage implements StorageLike {
@ -747,7 +748,7 @@ describe('Tier 3 — Cross-Feature Pairwise Interactions (F1–F20)', () => {
it('X16 (F15 × F16): SettingsStore and Toolbar synchronize viewport, lighting, automapReveal, and language', () => { it('X16 (F15 × F16): SettingsStore and Toolbar synchronize viewport, lighting, automapReveal, and language', () => {
const storage = new MemoryStorage() const storage = new MemoryStorage()
const store = new SettingsStore({ storage, defaultLang: 'en', syncGlobalLang: false }) const store = new SettingsStore({ storage, defaultLang: 'en', syncGlobalLang: false })
const toolbar = new Toolbar({ store }) const toolbar = new Toolbar({ store, allowedViewports: VALID_VIEWPORT_RESOLUTIONS })
toolbar.toggleViewport() toolbar.toggleViewport()
toolbar.setLightingPreset('fullbright') toolbar.setLightingPreset('fullbright')
@ -795,7 +796,7 @@ describe('Tier 3 — Cross-Feature Pairwise Interactions (F1–F20)', () => {
it('X19 (F16 × F17): Toolbar viewport and lighting toggles drive ClientWorldSceneSource and LightGrid rendering', () => { it('X19 (F16 × F17): Toolbar viewport and lighting toggles drive ClientWorldSceneSource and LightGrid rendering', () => {
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false }) const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
const toolbar = new Toolbar({ store }) const toolbar = new Toolbar({ store, allowedViewports: VALID_VIEWPORT_RESOLUTIONS })
const automap = new AutomapView({ initialMode: 'level' }) const automap = new AutomapView({ initialMode: 'level' })
const levelView = automap.setContext(0x12345678, 0, 1) const levelView = automap.setContext(0x12345678, 0, 1)
@ -944,19 +945,10 @@ describe('Tier 3 — Cross-Feature Pairwise Interactions (F1–F20)', () => {
expect(new ByteReader(sentFrames[1]!.subarray(1)).u16LE()).toBe(5110) expect(new ByteReader(sentFrames[1]!.subarray(1)).u16LE()).toBe(5110)
}) })
it('X23 (F2 × F20): InMemoryPacketTap + PacketInspector filter, export .d2cap JSONL, and replay into ClientWorld', () => { it('X23 (F2 × F20): InMemoryPacketTap + OnlineSession serialize .d2cap JSONL and replay into ClientWorld', () => {
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false }) const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
const tap = new InMemoryPacketTap() const tap = new InMemoryPacketTap()
const world = new ClientWorld() const session = new OnlineSession({ settingsStore: store, itemTables })
const inspector = new PacketInspector({
store,
tap,
onReplayRecord: (frame, rawBytes) => {
if (frame.phase === 'd2gs' && frame.dir === 's2c') {
world.apply(decodeD2gsS2cPacket(rawBytes, { tables: itemTables }))
}
},
})
const w03 = new ByteWriter() const w03 = new ByteWriter()
w03.u8(0x03) w03.u8(0x03)
@ -967,33 +959,26 @@ describe('Tier 3 — Cross-Feature Pairwise Interactions (F1–F20)', () => {
tap.record({ t: 10, conn: 'game', dir: 's2c', packetId: 0x03, raw: w03.toUint8Array() }) tap.record({ t: 10, conn: 'game', dir: 's2c', packetId: 0x03, raw: w03.toUint8Array() })
tap.record({ t: 20, conn: 'game', dir: 's2c', packetId: 0x1d, raw: new Uint8Array([0x1d, 0, 77]) }) tap.record({ t: 20, conn: 'game', dir: 's2c', packetId: 0x1d, raw: new Uint8Array([0x1d, 0, 77]) })
const jsonl = inspector.exportD2CapJsonl() const jsonl = tap.toD2CapJsonl({ format: 'd2cap-v1', createdAt: '2026-01-01T00:00:00Z', server: '127.0.0.1' })
inspector.clear() session.loadD2CapReplay(jsonl)
inspector.loadD2CapJsonl(jsonl) session.replayAllD2Cap()
inspector.replayAll()
expect(world.mapSeed).toBe(0x13572468) expect(session.world.mapSeed).toBe(0x13572468)
expect(world.self.stats.get(0)).toBe(77) expect(session.world.self.stats.get(0)).toBe(77)
inspector.destroy() session.destroy()
}) })
it('X24 (F16 × F20): Toolbar toggleInspector and reportDiagnostic synchronize with SettingsStore and PacketInspector', () => { it('X24 (F16 × F20): Toolbar reportDiagnostic and plugin error indicator synchronize with SettingsStore', () => {
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false }) const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
const tap = new InMemoryPacketTap()
const toolbar = new Toolbar({ store }) const toolbar = new Toolbar({ store })
const inspector = new PacketInspector({ store, tap })
expect(inspector.isOpen()).toBe(false)
toolbar.setInspectorOpen(true)
expect(inspector.isOpen()).toBe(true)
toolbar.setPluginsButtonState({ ready: true, hasWarning: true })
toolbar.reportDiagnostic('custom', 'Unknown S2C 0xFE') toolbar.reportDiagnostic('custom', 'Unknown S2C 0xFE')
const snap = toolbar.getSnapshot() const snap = toolbar.getSnapshot()
expect(snap.connection.phase).toBe('error') expect(snap.connection.phase).toBe('error')
expect(snap.diagnosticBannerText).toContain('0xFE') expect(snap.diagnosticBannerText).toContain('0xFE')
expect(validateServerHost(snap.settings.serverHost, 'https:').valid).toBe(true) expect(validateServerHost(snap.settings.serverHost, 'https:').valid).toBe(true)
inspector.destroy()
toolbar.destroy() toolbar.destroy()
}) })
}) })

View File

@ -72,6 +72,7 @@ import {
} from '../../tools/netproto-replay.ts' } from '../../tools/netproto-replay.ts'
import { import {
deriveRealmWsEndpoints, deriveRealmWsEndpoints,
VALID_VIEWPORT_RESOLUTIONS,
validateServerHost, validateServerHost,
} from '../../src/client/settings/client-settings.ts' } from '../../src/client/settings/client-settings.ts'
import { import {
@ -108,7 +109,6 @@ import {
OpfsMpqManager, OpfsMpqManager,
} from '../../src/client/assets/opfs-mpq.ts' } from '../../src/client/assets/opfs-mpq.ts'
import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts' import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts'
import { PacketInspector } from '../../src/client/inspector/packet-inspector.ts'
// ─── Shared Test Helpers ───────────────────────────────────────────────────── // ─── Shared Test Helpers ─────────────────────────────────────────────────────
@ -575,7 +575,7 @@ describe('Tier 4 — End-to-End Real-World Application Scenarios (S1–S12)', ()
defaultLang: 'en', defaultLang: 'en',
syncGlobalLang: true, syncGlobalLang: true,
}) })
const toolbar = new Toolbar({ store }) const toolbar = new Toolbar({ store, allowedViewports: VALID_VIEWPORT_RESOLUTIONS })
const automap = new AutomapView({ initialMode: 'off' }) const automap = new AutomapView({ initialMode: 'off' })
const levelView = automap.setContext(0x12345678, 0, 1) const levelView = automap.setContext(0x12345678, 0, 1)
@ -706,10 +706,8 @@ describe('Tier 4 — End-to-End Real-World Application Scenarios (S1–S12)', ()
sound.destroy() sound.destroy()
}) })
it('S11: Anti-flood OutboundRateLimiter + InMemoryPacketTap + PacketInspector JSONL export & strict .d2cap replay round-trip', () => { it('S11: Anti-flood OutboundRateLimiter + InMemoryPacketTap JSONL export & strict .d2cap replay round-trip', () => {
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
const tap = new InMemoryPacketTap() const tap = new InMemoryPacketTap()
const inspector = new PacketInspector({ store, tap })
const limiter = new OutboundRateLimiter({ moveMinIntervalMs: 120 }) const limiter = new OutboundRateLimiter({ moveMinIntervalMs: 120 })
// Rapid movement burst coalesces while preserving the latest destination // Rapid movement burst coalesces while preserving the latest destination
@ -738,15 +736,14 @@ describe('Tier 4 — End-to-End Real-World Application Scenarios (S1–S12)', ()
tap.record({ t: 1210, conn: 'game', dir: 's2c', packetId: 0x03, raw: w03.toUint8Array() }) tap.record({ t: 1210, conn: 'game', dir: 's2c', packetId: 0x03, raw: w03.toUint8Array() })
tap.record({ t: 1220, conn: 'game', dir: 's2c', packetId: 0x1d, raw: new Uint8Array([0x1d, 0, 99]) }) tap.record({ t: 1220, conn: 'game', dir: 's2c', packetId: 0x1d, raw: new Uint8Array([0x1d, 0, 99]) })
expect(inspector.getAllRows().length).toBe(4) expect(tap.entries.length).toBe(4)
const jsonl = inspector.exportD2CapJsonl() const jsonl = tap.toD2CapJsonl({ format: 'd2cap-v1', createdAt: '2026-01-01T00:00:00Z', server: '127.0.0.1' })
const replayed = replayD2CapText(jsonl, { strict: true }) const replayed = replayD2CapText(jsonl, { strict: true })
expect(replayed.protocolErrors).toBe(0) expect(replayed.protocolErrors).toBe(0)
expect(replayed.unknownPackets).toBe(0) expect(replayed.unknownPackets).toBe(0)
expect(replayed.world.mapSeed).toBe(0x11223344) expect(replayed.world.mapSeed).toBe(0x11223344)
expect(replayed.world.self.stats.get(0)).toBe(99) expect(replayed.world.self.stats.get(0)).toBe(99)
inspector.destroy()
}) })
it('S12: Server host reconfiguration, WssBnet concurrency guard, malformed packet isolation, and clean reconnect lifecycle', async () => { it('S12: Server host reconfiguration, WssBnet concurrency guard, malformed packet isolation, and clean reconnect lifecycle', async () => {

View File

@ -119,7 +119,6 @@ import {
} from '../../src/client/audio/sound-service.ts' } from '../../src/client/audio/sound-service.ts'
import { AutomapView, getActForArea } from '../../src/client/automap/automap-view.ts' import { AutomapView, getActForArea } from '../../src/client/automap/automap-view.ts'
import { CommandMapper, ViewportCamera } from '../../src/client/input/command-mapper.ts' import { CommandMapper, ViewportCamera } from '../../src/client/input/command-mapper.ts'
import { PacketInspector } from '../../src/client/inspector/packet-inspector.ts'
import { RemoteEntityInterpolator } from '../../src/client/motion/interpolate.ts' import { RemoteEntityInterpolator } from '../../src/client/motion/interpolate.ts'
import { import {
findPathLocal, findPathLocal,
@ -2114,7 +2113,6 @@ describe('Tier 5 Adversarial Coverage — F11, F16–F20 Rate Limiter, OnlineSes
automapReveal: 'maphack', automapReveal: 'maphack',
audioMuted: 'yes', audioMuted: 'yes',
masterVolume: 9.5, masterVolume: 9.5,
inspectorOpen: 123,
}), }),
) )
const store2 = new SettingsStore({ storage: rawStorage, storageKey: 'd2.client.settings.v1' }) const store2 = new SettingsStore({ storage: rawStorage, storageKey: 'd2.client.settings.v1' })
@ -2122,7 +2120,6 @@ describe('Tier 5 Adversarial Coverage — F11, F16–F20 Rate Limiter, OnlineSes
expect(store2.get().automapReveal).toBe('off') expect(store2.get().automapReveal).toBe('off')
expect(store2.get().audioMuted).toBe(false) expect(store2.get().audioMuted).toBe(false)
expect(store2.get().masterVolume).toBe(1.0) expect(store2.get().masterVolume).toBe(1.0)
expect(store2.get().inspectorOpen).toBe(false)
// 3. HTTPS bare-IP WSS cert validation (`validateServerHost` & `deriveRealmWsEndpoints`) // 3. HTTPS bare-IP WSS cert validation (`validateServerHost` & `deriveRealmWsEndpoints`)
const bareIpHttps = validateServerHost('101.37.117.183', 'https:') const bareIpHttps = validateServerHost('101.37.117.183', 'https:')
@ -2405,10 +2402,8 @@ describe('Tier 5 Adversarial Coverage — F11, F16–F20 Rate Limiter, OnlineSes
expect(source.getViewportProfile().height).toBe(768) expect(source.getViewportProfile().height).toBe(768)
const tap = new InMemoryPacketTap(100) const tap = new InMemoryPacketTap(100)
const inspector = new PacketInspector({ tap })
tap.record({ t: 1, conn: 'game', dir: 's2c', raw: new Uint8Array([0x04]), packetId: 0x04 }) tap.record({ t: 1, conn: 'game', dir: 's2c', raw: new Uint8Array([0x04]), packetId: 0x04 })
expect(inspector.getAllRows()).toHaveLength(1) expect(tap.entries).toHaveLength(1)
inspector.destroy()
}) })
it('adv_31_hud_model_reactive_sync_and_item_to_ui_conversion_edge_cases', () => { it('adv_31_hud_model_reactive_sync_and_item_to_ui_conversion_edge_cases', () => {

View File

@ -1,11 +1,14 @@
import { readdirSync, statSync } from 'node:fs' import { readdirSync, statSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { automapRevealPlugin } from '../../plugins/automap-reveal/index.ts'
import { extendedViewportsPlugin } from '../../plugins/extended-viewports/index.ts'
import { BUILTIN_PLUGINS } from '../../plugins/index.ts' import { BUILTIN_PLUGINS } from '../../plugins/index.ts'
import { import {
createKillStatsPlugin, createKillStatsPlugin,
killStatsPlugin, killStatsPlugin,
} from '../../plugins/kill-stats/index.ts' } from '../../plugins/kill-stats/index.ts'
import { lightingPresetsPlugin } from '../../plugins/lighting-presets/index.ts'
import { import {
validatePluginRegistry, validatePluginRegistry,
type PluginActionSpec, type PluginActionSpec,
@ -22,6 +25,8 @@ import {
type OnlineSessionLobbySnapshot, type OnlineSessionLobbySnapshot,
type OnlineSessionPhase, type OnlineSessionPhase,
} from '../../src/client/session/online-session.ts' } from '../../src/client/session/online-session.ts'
import { SettingsStore } from '../../src/client/settings/settings-store.ts'
import { Toolbar } from '../../src/client/toolbar/toolbar.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts' import { ClientWorld } from '../../src/client/world/client-world.ts'
import type { ServerEvent } from '../../src/netproto/index.ts' import type { ServerEvent } from '../../src/netproto/index.ts'
import { MemoryKillStatsRepository } from './kill-stats/memory-repository.ts' import { MemoryKillStatsRepository } from './kill-stats/memory-repository.ts'
@ -39,7 +44,7 @@ class MemoryStorage implements Pick<Storage, 'getItem' | 'setItem' | 'removeItem
} }
} }
describe('BUILTIN_PLUGINS Registry & killStatsPlugin Lifecycle', () => { describe('BUILTIN_PLUGINS Registry & Built-In Plugin Lifecycles', () => {
it('validates BUILTIN_PLUGINS and ensures every plugins/<id>/ directory is registered', () => { it('validates BUILTIN_PLUGINS and ensures every plugins/<id>/ directory is registered', () => {
expect(() => validatePluginRegistry(BUILTIN_PLUGINS)).not.toThrow() expect(() => validatePluginRegistry(BUILTIN_PLUGINS)).not.toThrow()
@ -50,6 +55,9 @@ describe('BUILTIN_PLUGINS Registry & killStatsPlugin Lifecycle', () => {
const registeredIds = BUILTIN_PLUGINS.map((p) => p.id).sort() const registeredIds = BUILTIN_PLUGINS.map((p) => p.id).sort()
expect(registeredIds).toEqual(dirIds) expect(registeredIds).toEqual(dirIds)
expect(BUILTIN_PLUGINS).toContain(killStatsPlugin) expect(BUILTIN_PLUGINS).toContain(killStatsPlugin)
expect(BUILTIN_PLUGINS).toContain(automapRevealPlugin)
expect(BUILTIN_PLUGINS).toContain(lightingPresetsPlugin)
expect(BUILTIN_PLUGINS).toContain(extendedViewportsPlugin)
}) })
it('manages #d2-toolbar-plugin-kill-stats-open shortcut, reports mid-game warn/recording/ready statuses, and flushes pending kills on deactivate', async () => { it('manages #d2-toolbar-plugin-kill-stats-open shortcut, reports mid-game warn/recording/ready statuses, and flushes pending kills on deactivate', async () => {
@ -240,5 +248,134 @@ describe('BUILTIN_PLUGINS Registry & killStatsPlugin Lifecycle', () => {
await host.destroy() await host.destroy()
}) })
it('automap-reveal (#714), lighting-presets (#715), and extended-viewports (#716) synchronize with SettingsStore/Toolbar and enforce 1.13c fallbacks when disabled', async () => {
const settingsStore = new SettingsStore({
storage: new MemoryStorage(),
defaultLang: 'en',
syncGlobalLang: false,
})
const toolbar = new Toolbar({ store: settingsStore })
const session = new OnlineSession({ settingsStore })
const plugins = [
automapRevealPlugin,
lightingPresetsPlugin,
extendedViewportsPlugin,
] as const
const configStore = new PluginConfigStore(plugins, {
storage: new MemoryStorage(),
})
const registeredSelects = new Map<string, { getValue(): string; onChange(val: string): void }>()
const toolbarPort: PluginHostToolbarPort = {
setPluginsButtonState() {},
addContribution(spec) {
const dispose = toolbar.addContribution(spec)
if (spec.kind === 'select') {
registeredSelects.set(spec.selectDomId, {
getValue: spec.getValue,
onChange: spec.onChange,
})
return () => {
dispose()
registeredSelects.delete(spec.selectDomId)
}
}
return dispose
},
getAllowedViewports: () => toolbar.getAllowedViewports(),
setAllowedViewports: (vps) => toolbar.setAllowedViewports(vps),
}
const host = new PluginHost({
plugins,
configStore,
session,
toolbar: toolbarPort,
settingsStore,
enforceClientDefaults: true,
autoStart: false,
})
await host.start()
// 1. All 3 plugins start enabled by default:
// - #d2-toolbar-automap-select and #d2-toolbar-lighting-select are registered
// - extended-viewports unlocks all 5 viewport profiles (640x480..1280x720)
expect(registeredSelects.has('d2-toolbar-automap-select')).toBe(true)
expect(registeredSelects.has('d2-toolbar-lighting-select')).toBe(true)
expect(toolbar.getAllowedViewports()).toEqual([
'640x480',
'800x600',
'1024x768',
'1068x600',
'1280x720',
])
// 2. Automap Reveal (#714): two-way sync + showToolbarShortcut toggle + 1.13c fallback ('off') on disable
host.setSetting('automap-reveal', 'mode', 'act')
expect(settingsStore.get().automapReveal).toBe('act')
expect(registeredSelects.get('d2-toolbar-automap-select')?.getValue()).toBe('act')
registeredSelects.get('d2-toolbar-automap-select')?.onChange('level')
expect(settingsStore.get().automapReveal).toBe('level')
expect(host.getSetting('automap-reveal', 'mode')).toBe('level')
host.setSetting('automap-reveal', 'showToolbarShortcut', false)
expect(registeredSelects.has('d2-toolbar-automap-select')).toBe(false)
host.setSetting('automap-reveal', 'showToolbarShortcut', true)
expect(registeredSelects.has('d2-toolbar-automap-select')).toBe(true)
await host.setEnabled('automap-reveal', false)
expect(registeredSelects.has('d2-toolbar-automap-select')).toBe(false)
expect(settingsStore.get().automapReveal).toBe('off')
await host.setEnabled('automap-reveal', true)
expect(settingsStore.get().automapReveal).toBe('level')
// 3. Lighting Presets (#715): two-way sync + showToolbarShortcut toggle + 1.13c fallback ('auto') on disable
host.setSetting('lighting-presets', 'preset', 'fullbright')
expect(settingsStore.get().lightingPreset).toBe('fullbright')
expect(registeredSelects.get('d2-toolbar-lighting-select')?.getValue()).toBe('fullbright')
registeredSelects.get('d2-toolbar-lighting-select')?.onChange('torch')
expect(settingsStore.get().lightingPreset).toBe('torch')
expect(host.getSetting('lighting-presets', 'preset')).toBe('torch')
host.setSetting('lighting-presets', 'showToolbarShortcut', false)
expect(registeredSelects.has('d2-toolbar-lighting-select')).toBe(false)
host.setSetting('lighting-presets', 'showToolbarShortcut', true)
expect(registeredSelects.has('d2-toolbar-lighting-select')).toBe(true)
await host.setEnabled('lighting-presets', false)
expect(registeredSelects.has('d2-toolbar-lighting-select')).toBe(false)
expect(settingsStore.get().lightingPreset).toBe('auto')
await host.setEnabled('lighting-presets', true)
expect(settingsStore.get().lightingPreset).toBe('torch')
// 4. Extended Viewports (#716): select 1280x720 while enabled -> disable plugin -> clamps back to 800x600 & ['640x480', '800x600']
toolbar.setViewport('1280x720')
expect(settingsStore.get().viewport).toBe('1280x720')
await host.setEnabled('extended-viewports', false)
expect(toolbar.getAllowedViewports()).toEqual(['640x480', '800x600'])
expect(settingsStore.get().viewport).toBe('800x600')
await host.setEnabled('extended-viewports', true)
expect(toolbar.getAllowedViewports()).toEqual([
'640x480',
'800x600',
'1024x768',
'1068x600',
'1280x720',
])
await host.destroy()
session.destroy()
toolbar.destroy()
})
}) })

View File

@ -355,7 +355,6 @@ async function main(): Promise<void> {
hasToolbar: Boolean(document.getElementById('d2-toolbar-container')?.children.length), hasToolbar: Boolean(document.getElementById('d2-toolbar-container')?.children.length),
hasCanvas: canvas instanceof HTMLCanvasElement, hasCanvas: canvas instanceof HTMLCanvasElement,
hasHudCanvas: hudCanvas instanceof HTMLCanvasElement, hasHudCanvas: hudCanvas instanceof HTMLCanvasElement,
hasInspector: Boolean(document.getElementById('d2-inspector-container')),
hasLobbyOverlay: Boolean(document.getElementById('bnet-shell')), hasLobbyOverlay: Boolean(document.getElementById('bnet-shell')),
hasBnetCanvas: bnetCanvas instanceof HTMLCanvasElement, hasBnetCanvas: bnetCanvas instanceof HTMLCanvasElement,
hasBootProgressBar: Boolean(bootOverlay && bootBar && bootPercentEl && bootFileEl), hasBootProgressBar: Boolean(bootOverlay && bootBar && bootPercentEl && bootFileEl),
@ -375,7 +374,6 @@ async function main(): Promise<void> {
!gate1.hasToolbar || !gate1.hasToolbar ||
!gate1.hasCanvas || !gate1.hasCanvas ||
!gate1.hasHudCanvas || !gate1.hasHudCanvas ||
!gate1.hasInspector ||
!gate1.hasLobbyOverlay || !gate1.hasLobbyOverlay ||
!gate1.hasBnetCanvas || !gate1.hasBnetCanvas ||
!gate1.hasBootProgressBar || !gate1.hasBootProgressBar ||
@ -910,29 +908,19 @@ async function main(): Promise<void> {
throw new Error(`Gate 6 failed: ${JSON.stringify(automapCheck)}`) throw new Error(`Gate 6 failed: ${JSON.stringify(automapCheck)}`)
} }
// ─── Gate 7: Packet Inspector & All 4 `.d2cap` Capture Fixtures Replay ─ // ─── Gate 7: PacketTap Live Capture & All 4 `.d2cap` Capture Fixtures Replay ─
const gate7 = await cdp.evaluate<{ const gate7 = await cdp.evaluate<{
isOpen: boolean
liveCapturedRows: number liveCapturedRows: number
filteredGameRows: number filteredGameRows: number
selectedDetailNonEmpty: boolean
fixtureReplays: Record<string, { totalRecords: number; unknownS2cCount: number }> fixtureReplays: Record<string, { totalRecords: number; unknownS2cCount: number }>
missingArtValid: boolean missingArtValid: boolean
missingArtCount: number missingArtCount: number
missingArtEntries: unknown[] missingArtEntries: unknown[]
}>(`(async () => { }>(`(async () => {
const app = window.__d2Play; const app = window.__d2Play;
app.inspector.setOpen(true); const entries = app.session.packetTap.entries;
const liveCapturedRows = app.inspector.getAllRows().length; const liveCapturedRows = entries.length;
app.inspector.setFilter({ conn: 'game' }); const filteredGameRows = entries.filter((e) => e.conn === 'game').length;
const filteredGameRows = app.inspector.getFilteredRows().length;
const firstRow = app.inspector.getFilteredRows()[0] ?? app.inspector.getAllRows()[0];
let selectedDetailNonEmpty = false;
if (firstRow) {
const selected = app.inspector.selectRow(firstRow.seq);
selectedDetailNonEmpty = Boolean(selected && selected.hexDump.length > 0);
}
app.inspector.setFilter({ conn: 'all' });
const fixtures = ${JSON.stringify(captureFixtures)}; const fixtures = ${JSON.stringify(captureFixtures)};
const fixtureReplays = {}; const fixtureReplays = {};
@ -955,21 +943,18 @@ async function main(): Promise<void> {
} }
const missingArt = window.__d2MissingArt; const missingArt = window.__d2MissingArt;
return { return {
isOpen: app.inspector.isOpen(),
liveCapturedRows, liveCapturedRows,
filteredGameRows, filteredGameRows,
selectedDetailNonEmpty,
fixtureReplays, fixtureReplays,
missingArtValid: Array.isArray(missingArt) && missingArt.length === 0, missingArtValid: Array.isArray(missingArt) && missingArt.length === 0,
missingArtCount: Array.isArray(missingArt) ? missingArt.length : -1, missingArtCount: Array.isArray(missingArt) ? missingArt.length : -1,
missingArtEntries: Array.isArray(missingArt) ? missingArt : [], missingArtEntries: Array.isArray(missingArt) ? missingArt : [],
}; };
})()`) })()`)
console.log('[verify-play] Gate 7 (Packet Inspector & 4 .d2cap Fixtures Replay):', gate7) console.log('[verify-play] Gate 7 (PacketTap & 4 .d2cap Fixtures Replay):', gate7)
if ( if (
!gate7.isOpen ||
gate7.liveCapturedRows === 0 || gate7.liveCapturedRows === 0 ||
!gate7.selectedDetailNonEmpty || gate7.filteredGameRows === 0 ||
Object.values(gate7.fixtureReplays).some((r) => r.totalRecords === 0 || r.unknownS2cCount > 0) || Object.values(gate7.fixtureReplays).some((r) => r.totalRecords === 0 || r.unknownS2cCount > 0) ||
!gate7.missingArtValid !gate7.missingArtValid
) { ) {

View File

@ -293,28 +293,43 @@ async function run(): Promise<void> {
assert(playReady, 'play.html __d2PlayApp failed to reach ready=true') assert(playReady, 'play.html __d2PlayApp failed to reach ready=true')
await playCdp.evaluate('window.__d2PlayApp.plugins.whenStarted()') await playCdp.evaluate('window.__d2PlayApp.plugins.whenStarted()')
// 1. Open plugin modal menu via #d2-toolbar-plugins-btn // 1. Open plugin modal menu via #d2-toolbar-plugins-btn & verify all 4 built-in plugins + PacketInspector removal
const openStateZh = await playCdp.evaluate<{ const openStateZh = await playCdp.evaluate<{
btnExists: boolean btnExists: boolean
btnDisabled: boolean btnDisabled: boolean
btnText: string btnText: string
modalOpen: boolean modalOpen: boolean
cardExists: boolean cardIds: string[]
shortcutExists: boolean shortcutExists: boolean
automapSelectExists: boolean
lightingSelectExists: boolean
viewportOptionCount: number
hasInspectorBtn: boolean
hasInspectorContainer: boolean
titleText: string titleText: string
}>(`(() => { }>(`(() => {
const btn = document.getElementById('d2-toolbar-plugins-btn'); const btn = document.getElementById('d2-toolbar-plugins-btn');
btn?.click(); btn?.click();
const overlay = document.querySelector('[data-d2-modal-overlay="plugins"]'); const overlay = document.querySelector('[data-d2-modal-overlay="plugins"]');
const card = document.querySelector('[data-plugin-id="kill-stats"]'); const cardIds = Array.from(document.querySelectorAll('[data-plugin-id]')).map(
(el) => el.getAttribute('data-plugin-id') ?? '',
);
const shortcut = document.getElementById('d2-toolbar-plugin-kill-stats-open'); const shortcut = document.getElementById('d2-toolbar-plugin-kill-stats-open');
const automapSelect = document.getElementById('d2-toolbar-automap-select');
const lightingSelect = document.getElementById('d2-toolbar-lighting-select');
const viewportSelect = document.getElementById('d2-toolbar-viewport-select');
return { return {
btnExists: Boolean(btn), btnExists: Boolean(btn),
btnDisabled: Boolean(btn?.disabled), btnDisabled: Boolean(btn?.disabled),
btnText: btn?.textContent?.trim() ?? '', btnText: btn?.textContent?.trim() ?? '',
modalOpen: Boolean(overlay && !overlay.hasAttribute('hidden')), modalOpen: Boolean(overlay && !overlay.hasAttribute('hidden')),
cardExists: Boolean(card), cardIds,
shortcutExists: Boolean(shortcut), shortcutExists: Boolean(shortcut),
automapSelectExists: Boolean(automapSelect),
lightingSelectExists: Boolean(lightingSelect),
viewportOptionCount: viewportSelect ? viewportSelect.querySelectorAll('option').length : 0,
hasInspectorBtn: Boolean(document.getElementById('d2-toolbar-inspector-btn')),
hasInspectorContainer: Boolean(document.getElementById('d2-inspector-container')),
titleText: overlay?.querySelector('.d2-plugin-menu__title')?.textContent?.trim() ?? '', titleText: overlay?.querySelector('.d2-plugin-menu__title')?.textContent?.trim() ?? '',
}; };
})()`) })()`)
@ -322,10 +337,79 @@ async function run(): Promise<void> {
assert(openStateZh.btnExists, '#d2-toolbar-plugins-btn must exist') assert(openStateZh.btnExists, '#d2-toolbar-plugins-btn must exist')
assert(!openStateZh.btnDisabled, '#d2-toolbar-plugins-btn must be enabled after PluginHost starts') assert(!openStateZh.btnDisabled, '#d2-toolbar-plugins-btn must be enabled after PluginHost starts')
assert(openStateZh.modalOpen, 'Plugin modal overlay must be open after clicking #d2-toolbar-plugins-btn') assert(openStateZh.modalOpen, 'Plugin modal overlay must be open after clicking #d2-toolbar-plugins-btn')
assert(openStateZh.cardExists, 'Plugin card [data-plugin-id="kill-stats"] must exist') assert(
JSON.stringify(openStateZh.cardIds) ===
JSON.stringify(['kill-stats', 'automap-reveal', 'lighting-presets', 'extended-viewports']),
`Expected all 4 plugin cards, got ${JSON.stringify(openStateZh.cardIds)}`,
)
assert(openStateZh.shortcutExists, '#d2-toolbar-plugin-kill-stats-open must exist initially') assert(openStateZh.shortcutExists, '#d2-toolbar-plugin-kill-stats-open must exist initially')
assert(openStateZh.automapSelectExists, '#d2-toolbar-automap-select must exist initially via automap-reveal plugin')
assert(openStateZh.lightingSelectExists, '#d2-toolbar-lighting-select must exist initially via lighting-presets plugin')
assert(openStateZh.viewportOptionCount === 5, `Expected 5 viewport options when extended-viewports is enabled, got ${openStateZh.viewportOptionCount}`)
assert(!openStateZh.hasInspectorBtn, '#d2-toolbar-inspector-btn must be removed (#717)')
assert(!openStateZh.hasInspectorContainer, '#d2-inspector-container must be removed (#717)')
assert(openStateZh.titleText === '插件管理', `Expected Chinese title "插件管理", got "${openStateZh.titleText}"`) assert(openStateZh.titleText === '插件管理', `Expected Chinese title "插件管理", got "${openStateZh.titleText}"`)
// Verify hot-disabling automap-reveal (#714), lighting-presets (#715), and extended-viewports (#716) enforces 1.13c defaults
const fallbackCheck = await playCdp.evaluate<{
automapWhileDisabled: string
automapSelectWhileDisabled: boolean
lightingWhileDisabled: string
lightingSelectWhileDisabled: boolean
viewportWhileDisabled: string
viewportOptionsWhileDisabled: string[]
automapRestored: string
lightingRestored: string
viewportOptionsRestoredCount: number
}>(`(async () => {
const app = window.__d2PlayApp;
await app.plugins.setSetting('automap-reveal', 'mode', 'act');
await app.plugins.setSetting('lighting-presets', 'preset', 'fullbright');
app.toolbar.setViewport('1280x720');
await app.plugins.setEnabled('automap-reveal', false);
await app.plugins.setEnabled('lighting-presets', false);
await app.plugins.setEnabled('extended-viewports', false);
const automapWhileDisabled = app.settings.get().automapReveal;
const automapSelectWhileDisabled = Boolean(document.getElementById('d2-toolbar-automap-select'));
const lightingWhileDisabled = app.settings.get().lightingPreset;
const lightingSelectWhileDisabled = Boolean(document.getElementById('d2-toolbar-lighting-select'));
const viewportWhileDisabled = app.settings.get().viewport;
const vpSelect = document.getElementById('d2-toolbar-viewport-select');
const viewportOptionsWhileDisabled = vpSelect
? Array.from(vpSelect.querySelectorAll('option')).map((o) => o.value)
: [];
await app.plugins.setEnabled('automap-reveal', true);
await app.plugins.setEnabled('lighting-presets', true);
await app.plugins.setEnabled('extended-viewports', true);
await app.plugins.setSetting('automap-reveal', 'mode', 'off');
await app.plugins.setSetting('lighting-presets', 'preset', 'auto');
return {
automapWhileDisabled,
automapSelectWhileDisabled,
lightingWhileDisabled,
lightingSelectWhileDisabled,
viewportWhileDisabled,
viewportOptionsWhileDisabled,
automapRestored: app.settings.get().automapReveal,
lightingRestored: app.settings.get().lightingPreset,
viewportOptionsRestoredCount: vpSelect ? vpSelect.querySelectorAll('option').length : 0,
};
})()`)
assert(fallbackCheck.automapWhileDisabled === 'off', `Expected automapReveal='off' when automap-reveal disabled, got ${fallbackCheck.automapWhileDisabled}`)
assert(!fallbackCheck.automapSelectWhileDisabled, 'Expected #d2-toolbar-automap-select removed when automap-reveal disabled')
assert(fallbackCheck.lightingWhileDisabled === 'auto', `Expected lightingPreset='auto' when lighting-presets disabled, got ${fallbackCheck.lightingWhileDisabled}`)
assert(!fallbackCheck.lightingSelectWhileDisabled, 'Expected #d2-toolbar-lighting-select removed when lighting-presets disabled')
assert(fallbackCheck.viewportWhileDisabled === '800x600', `Expected viewport clamped to 800x600 when extended-viewports disabled, got ${fallbackCheck.viewportWhileDisabled}`)
assert(
JSON.stringify(fallbackCheck.viewportOptionsWhileDisabled) === JSON.stringify(['640x480', '800x600']),
`Expected only native 1.13c viewports when extended-viewports disabled, got ${JSON.stringify(fallbackCheck.viewportOptionsWhileDisabled)}`,
)
assert(fallbackCheck.viewportOptionsRestoredCount === 5, 'Expected 5 viewport options restored when extended-viewports re-enabled')
const shot1 = join(outDir, '01-plugin-menu-open-zh.png') const shot1 = join(outDir, '01-plugin-menu-open-zh.png')
const shot1Bytes = await playCdp.captureScreenshot(shot1) const shot1Bytes = await playCdp.captureScreenshot(shot1)
console.log(`[verify-plugin-menu] Captured ${shot1} (${shot1Bytes} bytes) — Plugin menu open (ZH)`) console.log(`[verify-plugin-menu] Captured ${shot1} (${shot1Bytes} bytes) — Plugin menu open (ZH)`)
@ -491,6 +575,7 @@ async function run(): Promise<void> {
console.log(`[verify-plugin-menu] Captured ${shot4} (${shot4Bytes} bytes) — Disabled notice visible on stats page`) console.log(`[verify-plugin-menu] Captured ${shot4} (${shot4Bytes} bytes) — Disabled notice visible on stats page`)
// Reload play.html while kill-stats is disabled and verify disabled state persists across reload // Reload play.html while kill-stats is disabled and verify disabled state persists across reload
await playCdp.evaluate(`delete window.__d2PlayApp`)
await playCdp.send('Page.reload') await playCdp.send('Page.reload')
for (let i = 0; i < 100; i++) { for (let i = 0; i < 100; i++) {
await sleep(150) await sleep(150)
@ -663,7 +748,10 @@ async function run(): Promise<void> {
// 7. Inject corrupt JSON into d2web.plugins.v1, reload play.html, verify ⚠ badge + #d2-plugin-menu-alert + preserved raw value, // 7. Inject corrupt JSON into d2web.plugins.v1, reload play.html, verify ⚠ badge + #d2-plugin-menu-alert + preserved raw value,
// then click #d2-plugin-menu-reset and verify ⚠ clears, alert hides, and valid JSON is persisted. // then click #d2-plugin-menu-reset and verify ⚠ clears, alert hides, and valid JSON is persisted.
await playCdp.evaluate(`localStorage.setItem('d2web.plugins.v1', '{corrupt-json')`) await playCdp.evaluate(`(() => {
localStorage.setItem('d2web.plugins.v1', '{corrupt-json');
delete window.__d2PlayApp;
})()`)
await playCdp.send('Page.reload') await playCdp.send('Page.reload')
for (let i = 0; i < 100; i++) { for (let i = 0; i < 100; i++) {
await sleep(150) await sleep(150)