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/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) |
| [`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) |
| [`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`,插件间严格物理隔离 | 独立于核心客户端的可拔插非 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/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) |

View File

@ -419,15 +419,6 @@
font-size: 11px;
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>
</head>
<body>
@ -464,7 +455,6 @@
<div id="play-disconnect-banner" hidden></div>
</div>
</div>
<div id="d2-inspector-container"></div>
</div>
<script type="module">
import { startPlayApp } from '/src/client/main.ts'

View File

@ -35,19 +35,25 @@
```text
plugins/
├── README.md # 插件系统架构说明、API 契约与后续迁移路线图
├── README.md # 插件系统架构说明、API 契约与内置插件一览
├── index.ts # BUILTIN_PLUGINS 静态注册表(由组合根 play.html 导入并注入 startPlayApp)
└── kill-stats/ # 内置插件:怪物击杀统计 (id: 'kill-stats')
├── index.ts # killStatsPlugin 定义、激活/卸载生命周期、状态汇报与工具栏快捷入口管理
├── index.html # 独立统计页面入口 (/plugins/kill-stats/index.html)
├── page-main.ts # 独立页面启动引导(调用 bootPluginPage(killStatsPlugin) 并捕获启动异常)
├── stats-page.ts # 独立统计页面 DOM 控制器(IndexedDB 读取、BroadcastChannel 同步、导入/导出/删除)
├── kill-tracker.ts # 实时击杀追踪器(监听 D2GS ServerEvent、严格校验角色身份并批量落盘)
├── kill-stats-model.ts # G-counter CRDT 模型、键值规范化与严格导入校验 (KillStatsImportError)
├── kill-stats-store.ts # IndexedDB ('d2web-kill-stats' v1) 存储仓库实现
├── kill-stats-view.ts # 86 行首领/超级精英看板与怪物分类表纯视图模型构建器
├── monster-kill-classes.ts# 基于 MonStats.txt (Align / killable / neverCount) 的可计数怪物分类表
└── stats-i18n.ts # 统计页面中英双语词典
├── kill-stats/ # 内置插件:怪物击杀统计 (id: 'kill-stats')
│ ├── index.ts # killStatsPlugin 定义、激活/卸载生命周期、状态汇报与工具栏快捷入口管理
│ ├── index.html # 独立统计页面入口 (/plugins/kill-stats/index.html)
│ ├── page-main.ts # 独立页面启动引导(调用 bootPluginPage(killStatsPlugin) 并捕获启动异常)
│ ├── stats-page.ts # 独立统计页面 DOM 控制器(IndexedDB 读取、BroadcastChannel 同步、导入/导出/删除)
│ ├── kill-tracker.ts # 实时击杀追踪器(监听 D2GS ServerEvent、严格校验角色身份并批量落盘)
│ ├── kill-stats-model.ts # G-counter CRDT 模型、键值规范化与严格导入校验 (KillStatsImportError)
│ ├── kill-stats-store.ts # IndexedDB ('d2web-kill-stats' v1) 存储仓库实现
│ ├── kill-stats-view.ts # 86 行首领/超级精英看板与怪物分类表纯视图模型构建器
│ ├── monster-kill-classes.ts# 基于 MonStats.txt (Align / killable / neverCount) 的可计数怪物分类表
│ └── 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/` 模块,导出以下核心类型与函数:
```ts
export type { GameLang }
export type {
AutomapRevealMode,
GameLang,
LightingPresetId,
ViewportResolutionId,
}
export interface LocalizedText {
readonly zh: string
@ -121,16 +132,39 @@ export type PluginSessionView = Pick<
'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 {
readonly pluginId: string
readonly session: PluginSessionView
readonly client: PluginClientCapabilities
readonly settings: {
get(key: string): boolean | string
set(key: string, value: boolean | string): void
subscribe(fn: (key: string) => void): () => void
}
readonly toolbar: {
addLink(spec: { id: string; label: LocalizedText; href: string }): () => 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: {
get(): GameLang
@ -160,7 +194,7 @@ export function bootPluginPage(plugin: D2WebPlugin): PluginPageContext
### 宿主生命周期与故障隔离保证(`PluginHost`)
- **启动期快速失败校验(`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` 显示 `⚠`,绝不中断主游戏循环或其他插件。
- **独立页面引导(`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/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/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/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/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`。 |
| [`plugins/kill-stats/`](./kill-stats) | ✅ 已落地 | 已上线 | 怪物击杀统计插件与独立统计页面(`/plugins/kill-stats/index.html`),按 `(host, realm, account, charName, hardcore)` 追踪普通/噩梦/地狱难度下的怪物、首领与超级精英击杀数。 |
| [`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/`](./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/`](./extended-viewports) | ✅ 已落地 | [Issue #716](https://git.projectdiablo2.cn/troytt/diablo2-web/issues/716) | 扩展分辨率视口插件,启用时在工具栏分辨率下拉框中解锁 `1024x768`、`1068x600`、`1280x720`;禁用时仅保留 1.13c 原版 `640x480` 与 `800x600`,并将扩展分辨率自动收敛回 `800x600`。 |
| 移除封包检查器 (`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 { automapRevealPlugin } from './automap-reveal/index.ts'
import { extendedViewportsPlugin } from './extended-viewports/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` 并启动客户端。
- 可选客户端扩展插件统一解耦至顶层 [`plugins/`](../../plugins) 目录(例如怪物击杀统计插件 [`plugins/kill-stats/`](../../plugins/kill-stats) 及其独立页面 [`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 强制守卫)。
- 可选客户端扩展插件统一解耦至顶层 [`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`)、受控客户端增强能力门面(`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`)**:
- `lib: ["ES2022", "DOM", "DOM.Iterable"]`, `types: []`。
- **严禁依赖 `src/server` 或 `src/baker`**,严禁导入任何 Node.js 内置模块(`fs`, `path`, `net`, `Buffer` 等)。
@ -22,7 +22,7 @@
```text
src/client/
├── main.ts # 客户端主入口:组装 WebGL2 画布、工具栏、前端战网状态机、OnlineSession、PluginHost 与 60fps 渲染循环
├── session/ # OnlineSession 会话协调器与 C2S 发包速率限制器 (OutboundRateLimiter)
├── session/ # OnlineSession 会话协调器、.d2cap 离线重放器与 C2S 发包速率限制器 (OutboundRateLimiter)
├── world/ # ClientWorld:由 ServerEvent 驱动的客户端权威状态镜像 (玩家/怪物/NPC/地面物品/投射物/传送门)
├── map/ # 种子驱动的客户端 DRLG 地图服务 (MapService, LevelView, TileAtlas GPU 瓦片加载)
├── motion/ # 本地玩家移动预测 (LocalMovementPredictor)、远端实体插值器与 A* 寻路 (pathfind)
@ -31,10 +31,9 @@ src/client/
├── ui/ # 游戏内 HUD 渲染与交互 (HudManager, 底部状态栏, 小地图 Automap, 7 大面板, D2 点阵字体渲染, DOM 浮层焦点守卫)
├── ui-model/ # UI 纯视图状态模型 (HudModel, 属性加点/技能树/背包网格/商店/赫拉迪克方块交互逻辑)
├── frontend/ # 战网前置界面 (FrontendRenderer:Gateway 选择、账号登录/注册、8 角色选择、建房/加房大厅)
├── toolbar/ # 顶部 32px 窄条工具栏 (FPS/Ping、网络抓包开关、插件快捷按钮插槽、Plugins 配置菜单入口、设置弹窗、全屏与音量控制)
├── plugin-host/ # 非侵入式插件宿主与配置中心 (PluginHost, PluginConfigStore, PluginMenu [data-d2-modal-overlay="plugins"] 与只读契约 api.ts)
├── settings/ # 客户端持久化设置存储 (SettingsStore / client-settings.ts,管理 WS Bridge 地址与显示偏好)
├── inspector/ # 实时网络封包检查器 (PacketInspector) 与 .d2cap 录制/回放面板
├── toolbar/ # 顶部 32px 窄条工具栏 (连接状态与 RTT、服务器地址、分辨率切换、音量/语言控制、Plugins 菜单入口与动态插件工具栏插槽)
├── plugin-host/ # 非侵入式插件宿主与配置中心 (PluginHost, PluginConfigStore, PluginMenu [data-d2-modal-overlay="plugins"] 与公共契约 api.ts)
├── settings/ # 客户端持久化设置存储 (SettingsStore / client-settings.ts,管理服务器地址与显示偏好)
├── sw/ # Service Worker (sw.ts) + CacheUpdater:基于 SHA-256 清单的离线资产增量缓存
├── audio/ # Web Audio API 音效与背景音乐服务 (SoundService)
└── assets/ # OPFS (Origin Private File System) 本地 MPQ 导入与缓存支持

View File

@ -281,7 +281,6 @@ export type ToolbarI18nKey =
| 'toolbar.automap.level'
| 'toolbar.automap.act'
| 'toolbar.viewport.label'
| 'toolbar.inspector.toggle'
| 'toolbar.plugins.open'
| 'toolbar.cache.clear'
| '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.act': { zh: '全幕', en: 'Act' },
'toolbar.viewport.label': { zh: '分辨率', en: 'Viewport' },
'toolbar.inspector.toggle': { zh: '抓包面板', en: 'Packets' },
'toolbar.plugins.open': { zh: '插件', en: 'Plugins' },
'toolbar.cache.clear': { zh: '清除缓存', en: 'Clear Cache' },
'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`)
* - In-Game Isometric World + Automap + Lighting (`ClientWorldSceneSource`, `AutomapView`)
* - 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`)
*
* 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 { Minimap } from './ui/minimap.ts'
import { CommandMapper } from './input/command-mapper.ts'
import { PacketInspector } from './inspector/packet-inspector.ts'
import { MapService } from './map/map-service.ts'
import type { D2WebPlugin } from './plugin-host/api.ts'
import { PluginHost } from './plugin-host/plugin-host.ts'
@ -99,7 +98,6 @@ export interface PlayAppController {
readonly plugins: PluginHost
readonly pluginMenu: PluginMenu
readonly toolbar: Toolbar
readonly inspector: PacketInspector
readonly hudManager: HudManager
readonly hudModel: HudModel
readonly commandMapper: CommandMapper
@ -135,7 +133,6 @@ export async function startPlayApp(options?: {
window.__d2SkipAutoBoot = true
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 stageEl = document.querySelector<HTMLElement>('#play-stage')
const bnetShell = document.querySelector<HTMLElement>('#bnet-shell')
@ -191,10 +188,6 @@ export async function startPlayApp(options?: {
onAutomapRevealChange: (mode) => {
sessionRef?.automapView.setMode(mode)
},
onInspectorToggle: (open) => {
sessionRef?.packetInspector.setOpen(open)
syncStageViewportFit()
},
onServerHostReconnect: (newHost) => {
void sessionRef?.disconnect(`Reconnecting to ${newHost}`)
},
@ -404,6 +397,8 @@ export async function startPlayApp(options?: {
plugins: options?.plugins ?? [],
session,
toolbar,
settingsStore,
enforceClientDefaults: true,
})
const pluginMenu = new PluginMenu({
host: plugins,
@ -411,22 +406,13 @@ export async function startPlayApp(options?: {
})
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 () => {
const updatedStatus = await getAssetCacheStatus()
toolbar.setAssetCacheUsageBytes(updatedStatus.usageBytes)
})
// 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', () => {
syncStageViewportFit()
@ -450,10 +436,6 @@ export async function startPlayApp(options?: {
if (next.automapReveal !== prev.automapReveal) {
session.automapView.setMode(next.automapReveal)
}
if (next.inspectorOpen !== prev.inspectorOpen) {
inspector.setOpen(next.inspectorOpen)
syncStageViewportFit(next.viewport)
}
})
const flowState = createInitialFlowState()
@ -1054,7 +1036,6 @@ export async function startPlayApp(options?: {
plugins,
pluginMenu,
toolbar,
inspector,
hudManager: session.hudManager,
hudModel: session.hudModel,
commandMapper: session.commandMapper,

View File

@ -13,13 +13,53 @@ import {
setLang,
type GameLang,
} 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 {
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 {
EXTENDED_VIEWPORT_RESOLUTIONS,
NATIVE_113C_VIEWPORT_RESOLUTIONS,
VIEWPORT_PROFILES,
formatViewportLabel,
getViewportProfile,
isExtendedViewport,
type ViewportProfile,
} from '../view/viewport-profile.ts'
import {
PluginConfigStore,
} 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 {
readonly zh: string
@ -89,17 +129,50 @@ export interface PluginLogger {
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 {
readonly pluginId: string
readonly session: PluginSessionView
readonly settings: {
get(key: string): boolean | string
set(key: string, value: boolean | string): void
subscribe(fn: (key: string) => void): () => void
}
readonly toolbar: {
addLink(spec: { id: string; label: LocalizedText; href: string }): () => void
addButton(spec: { id: string; label: LocalizedText; onClick(): void }): () => void
addSelect(spec: PluginToolbarSelectSpec): () => void
}
readonly client: PluginClientCapabilities
readonly lang: {
get(): GameLang
subscribe(fn: (lang: GameLang) => void): () => void

View File

@ -5,25 +5,42 @@
* - Fail-fast startup validation via `validatePluginRegistry`.
* - Per-plugin serialized `setEnabled(id, enabled)` lifecycle queue so rapid toggles
* converge deterministically to the final state.
* - Automatic teardown of toolbar contributions (`addLink` / `addButton`) and
* setting/language subscriptions registered via `PluginContext` on deactivate.
* - Automatic teardown of toolbar contributions (`addLink` / `addButton` / `addSelect`),
* client capability claims (`automapReveal`, `lightingPreset`, `allowedViewports`),
* and setting/language subscriptions registered via `PluginContext` on deactivate.
* - Runtime error isolation in `activate`, `deactivate`, setting callbacks, action
* runners, and `ctx.log.error` (`console.error` + plugin status set to `level: 'error'`
* + toolbar plugin button displays `⚠`).
*/
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 {
isRelativePluginHref,
isValidLocalizedText,
validatePluginRegistry,
type D2WebPlugin,
type LocalizedText,
type PluginClientCapabilities,
type PluginContext,
type PluginInstance,
type PluginLogger,
type PluginSessionView,
type PluginStatus,
type PluginToolbarSelectOption,
type PluginToolbarSelectSpec,
} from './api.ts'
import {
PluginConfigStore,
@ -43,6 +60,15 @@ export type ToolbarContributionSpec =
readonly label: LocalizedText
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 {
setPluginsButtonState(state: {
@ -51,6 +77,8 @@ export interface PluginHostToolbarPort {
readonly open?: boolean | undefined
}): void
addContribution(spec: ToolbarContributionSpec): () => void
getAllowedViewports?(): readonly ViewportResolution[]
setAllowedViewports?(resolutions: readonly ViewportResolution[]): void
}
export interface PluginDescriptorSnapshot {
@ -67,6 +95,8 @@ export interface PluginHostOptions {
readonly plugins: readonly D2WebPlugin[]
readonly session: PluginSessionView
readonly toolbar?: PluginHostToolbarPort | null | undefined
readonly settingsStore?: SettingsStore | undefined
readonly enforceClientDefaults?: boolean | undefined
readonly configStore?: PluginConfigStore | undefined
readonly storageOptions?: PluginConfigStoreOptions | undefined
readonly autoStart?: boolean | undefined
@ -88,9 +118,16 @@ const KEBAB_ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
export class PluginHost {
private readonly session: PluginSessionView
private readonly toolbar: PluginHostToolbarPort | null
private readonly settingsStore: SettingsStore
private readonly enforceClientDefaults: boolean
private readonly configStore: PluginConfigStore
private readonly records = new Map<string, InternalPluginRecord>()
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 ready = false
private destroyed = false
@ -100,6 +137,8 @@ export class PluginHost {
validatePluginRegistry(options.plugins)
this.session = options.session
this.toolbar = options.toolbar ?? null
this.settingsStore = options.settingsStore ?? getGlobalSettingsStore()
this.enforceClientDefaults = options.enforceClientDefaults ?? false
this.configStore =
options.configStore ??
new PluginConfigStore(options.plugins, options.storageOptions)
@ -171,6 +210,9 @@ export class PluginHost {
}
}
this.startPromise = Promise.all(tasks).then(() => {
if (this.enforceClientDefaults) {
this.reconcileUnclaimedClientCapabilities()
}
this.ready = true
this.syncToolbarState()
this.notifyListeners()
@ -360,6 +402,9 @@ export class PluginHost {
}
}
await Promise.all(tasks)
if (this.enforceClientDefaults) {
this.reconcileUnclaimedClientCapabilities()
}
this.syncToolbarState()
this.notifyListeners()
}
@ -401,6 +446,247 @@ export class PluginHost {
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> {
if (record.active) {
return
@ -433,6 +719,10 @@ export class PluginHost {
get: (key: string): boolean | string => {
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) => {
if (contextDisposed) {
return () => {}
@ -533,7 +823,77 @@ export class PluginHost {
record.disposers.add(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: {
get: (): GameLang => getGameLang(),
subscribe: (fn: (lang: GameLang) => void): (() => void) => {

View File

@ -9,7 +9,7 @@
* - `RemoteEntityInterpolator` (remote monster/NPC/player smooth motion)
* - `ClientWorldSceneSource` (feeding `WorldRenderer` across `800×600` and `1024×768`)
* - `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.
*
* Strict import boundary: imports only from `src/netproto/index.ts` (never `bncs/mcp/d2gs/crypto`).
@ -22,6 +22,8 @@ import {
createD2OnlineFlow,
createWsBridgeResolver,
decodeD2gsS2cPacket,
hexToBytes,
parseD2CapJsonl,
unitTypeFromId,
unitTypeToId,
type CharClassId,
@ -29,6 +31,7 @@ import {
type CharSummary,
type ClientCommand,
type CloseReason,
type D2CapRecord,
type D2OnlineConfig,
type D2OnlineFlow,
type D2OnlineSessionKind,
@ -40,6 +43,8 @@ import {
type GameListEntry,
type GameServerAdapter,
type ItemDataTables,
type PacketConn,
type PacketDirection,
type RealmSummary,
type ServerEvent,
type UnitType,
@ -56,7 +61,6 @@ import {
ViewportCamera,
type CameraLike,
} from '../input/command-mapper.ts'
import { PacketInspector, type ReplayedD2CapFrame } from '../inspector/packet-inspector.ts'
import type { LevelView } from '../map/level-view.ts'
import type { MapService } from '../map/map-service.ts'
import { RemoteEntityInterpolator } from '../motion/interpolate.ts'
@ -109,6 +113,16 @@ export interface OnlineSessionLobbySnapshot {
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 {
readonly world?: ClientWorld | undefined
readonly hudManager?: HudManager | undefined
@ -120,7 +134,6 @@ export interface OnlineSessionOptions {
readonly rateLimiter?: OutboundRateLimiter | undefined
readonly settingsStore?: SettingsStore | undefined
readonly soundService?: SoundService | undefined
readonly packetInspector?: PacketInspector | undefined
readonly resolver?: EndpointResolver | undefined
readonly flowFactory?: ((config: D2OnlineConfig) => D2OnlineFlow) | undefined
readonly itemTables?: ItemDataTables | undefined
@ -232,7 +245,6 @@ export class OnlineSession {
readonly settingsStore: SettingsStore
readonly soundService: SoundService
readonly packetTap: InMemoryPacketTap
readonly packetInspector: PacketInspector
readonly hudModel: HudModel
readonly commandMapper: CommandMapper
readonly sceneSource: ClientWorldSceneSource
@ -244,10 +256,9 @@ export class OnlineSession {
private readonly defaultHost: string
private readonly defaultBncsPort: number
private readonly replayS2cFramer = new D2gsS2cFramer(false)
private lastDecodedReplayRecord: {
readonly record: ReplayedD2CapFrame['record']
readonly events: readonly ServerEvent[]
} | null = null
private replayQueue: D2CapRecord[] = []
private replayCursor = 0
private replaySeq = 0
private flow: D2OnlineFlow | null = null
private gameAdapter: GameServerAdapter | null = null
@ -348,22 +359,6 @@ export class OnlineSession {
this.itemTables = options.itemTables ?? getCanonicalItemDataTables()
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({
world: this.world,
@ -2157,22 +2152,56 @@ export class OnlineSession {
// ─── 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 {
this.replayS2cFramer.reset(false)
this.lastDecodedReplayRecord = null
const records = this.packetInspector.loadD2CapJsonl(jsonlText)
const parsed = parseD2CapJsonl(jsonlText)
this.replayQueue = [...parsed.records]
this.replayCursor = 0
this.disconnectReasonValue = null
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.
*/
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 {
this.setPhase('replay')
return this.packetInspector.replayAll()
let count = 0
while (this.stepD2CapReplay() !== null) {
count += 1
}
return count
}
private extractEventsFromReplayRecord(
@ -2203,17 +2236,6 @@ export class OnlineSession {
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 ──────────────────────
resolveWsBridgeBaseUrl(serverHost?: string): string {

View File

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

View File

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

View File

@ -8,11 +8,6 @@ import {
import { resolveLocalizedText } from '../plugin-host/api.ts'
import type { ToolbarContributionSpec } from '../plugin-host/plugin-host.ts'
import {
CLIENT_LIGHTING_PRESET_ORDER,
LIGHTING_PRESETS,
} from '../scene/lighting-presets.ts'
import {
VALID_AUTOMAP_REVEAL_MODES,
VALID_VIEWPORT_RESOLUTIONS,
validateServerHost,
type AutomapRevealMode,
@ -27,6 +22,7 @@ import {
formatCacheBytesMb,
} from '../sw/register.ts'
import {
NATIVE_113C_VIEWPORT_RESOLUTIONS,
formatViewportLabel,
getViewportProfile,
type ViewportProfile,
@ -61,7 +57,6 @@ export interface ToolbarCallbacks {
readonly onLightingChange?: ((preset: ClientLightingPreset) => void) | undefined
readonly onAutomapRevealChange?: ((mode: AutomapRevealMode) => void) | undefined
readonly onViewportChange?: ((profile: ViewportProfile) => void) | undefined
readonly onInspectorToggle?: ((open: boolean) => void) | undefined
readonly onPluginsToggle?: (() => void) | undefined
readonly onClearCache?: (() => void | Promise<void>) | undefined
}
@ -70,6 +65,7 @@ export interface ToolbarOptions {
readonly store?: SettingsStore | undefined
readonly container?: HTMLElement | null | undefined
readonly pageProtocol?: string | undefined
readonly allowedViewports?: readonly ViewportResolution[] | undefined
readonly confirmReconnect?: ((message: string) => boolean) | undefined
readonly callbacks?: ToolbarCallbacks | undefined
}
@ -78,6 +74,7 @@ export interface ToolbarSnapshot {
readonly connection: ToolbarConnectionState
readonly settings: ClientSettings
readonly viewportProfile: ViewportProfile
readonly allowedViewports: readonly ViewportResolution[]
readonly statusLabel: string
readonly rttLabel: string
readonly diagnosticBannerText: string | null
@ -85,13 +82,18 @@ export interface ToolbarSnapshot {
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:
* 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`)
* 3. Lighting Preset Selector (`auto | noon | dusk | night | torch | cold | fullbright`)
* 4. Automap Reveal Selector (`off | level | act`)
* 5. Viewport Size Selector (`800x600 | 1024x768 | 1068x600 | 1280x720`) + Asset Cache Clear button
* 3. Viewport Size Selector (`#d2-toolbar-viewport-select`, native 1.13c `640x480 | 800x600` by default, extensible via `extended-viewports` plugin) + Asset Cache Clear button
*/
export class Toolbar {
private readonly store: SettingsStore
@ -103,6 +105,7 @@ export class Toolbar {
rttMs: null,
diagnosticKind: 'none',
}
private allowedViewports: readonly ViewportResolution[]
private serverInputValue: string
private serverInputValid = true
private cacheUsageBytes = 0
@ -110,10 +113,7 @@ export class Toolbar {
private pluginsReady = false
private pluginsHasWarning = false
private pluginsMenuOpen = false
private readonly contributions = new Map<
string,
{ spec: ToolbarContributionSpec; el: HTMLElement | null }
>()
private readonly contributions = new Map<string, ContributionEntry>()
private unsubscribeStore: (() => void) | null = null
private unsubscribeLang: (() => void) | null = null
@ -124,13 +124,8 @@ export class Toolbar {
private rttTextEl: HTMLElement | null = null
private langBtnZhEl: 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 viewportSelectEl: HTMLSelectElement | null = null
private inspectorBtnEl: HTMLButtonElement | null = null
private pluginsBtnEl: HTMLButtonElement | null = null
private pluginSlotEl: HTMLElement | null = null
private cacheClearBtnEl: HTMLButtonElement | null = null
@ -141,9 +136,16 @@ export class Toolbar {
this.pageProtocol =
options?.pageProtocol ??
(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 =
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.serverInputValue = this.store.get().serverHost
@ -168,9 +170,6 @@ export class Toolbar {
if (changedKeys.includes('viewport') && next.viewport !== prev.viewport) {
this.callbacks.onViewportChange?.(getViewportProfile(next.viewport))
}
if (changedKeys.includes('inspectorOpen') && next.inspectorOpen !== prev.inspectorOpen) {
this.callbacks.onInspectorToggle?.(next.inspectorOpen)
}
this.syncDom()
})
@ -196,11 +195,15 @@ export class Toolbar {
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 = {
phase: next.phase,
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,
}
this.syncDom()
@ -319,30 +322,45 @@ export class Toolbar {
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 {
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()
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 {
const order = VALID_VIEWPORT_RESOLUTIONS
const next = order[(order.indexOf(this.store.get().viewport) + 1) % order.length]
const order = this.allowedViewports
const currentIdx = order.indexOf(this.store.get().viewport)
const next = order[(currentIdx + 1) % order.length]!
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: {
readonly ready: boolean
readonly hasWarning: boolean
@ -364,6 +382,9 @@ export class Toolbar {
}
let el: HTMLElement | null = null
let labelEl: HTMLLabelElement | null = null
let selectEl: HTMLSelectElement | null = null
if (typeof document !== 'undefined' && this.pluginSlotEl) {
const lang = this.store.get().lang || getGameLang()
const label = resolveLocalizedText(spec.label, lang)
@ -377,7 +398,7 @@ export class Toolbar {
link.textContent = label
link.title = label
el = link
} else {
} else if (spec.kind === 'button') {
const btn = document.createElement('button')
btn.type = 'button'
btn.id = spec.domId
@ -388,11 +409,40 @@ export class Toolbar {
spec.onClick()
})
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)
}
const entry = { spec, el }
const entry: ContributionEntry = { spec, el, labelEl, selectEl }
this.contributions.set(spec.domId, entry)
return () => {
@ -447,6 +497,7 @@ export class Toolbar {
connection: this.connection,
settings,
viewportProfile: getViewportProfile(settings.viewport),
allowedViewports: this.allowedViewports,
statusLabel,
rttLabel,
diagnosticBannerText,
@ -622,7 +673,7 @@ a.d2-toolbar__btn {
root.id = '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')
statusGroup.className = 'd2-toolbar__group'
@ -640,14 +691,6 @@ a.d2-toolbar__btn {
statusBadge.appendChild(statusText)
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')
pluginsBtn.type = 'button'
pluginsBtn.id = 'd2-toolbar-plugins-btn'
@ -663,7 +706,6 @@ a.d2-toolbar__btn {
pluginSlot.className = 'd2-toolbar__group'
statusGroup.appendChild(statusBadge)
statusGroup.appendChild(inspectorBtn)
statusGroup.appendChild(pluginsBtn)
statusGroup.appendChild(pluginSlot)
@ -687,37 +729,7 @@ a.d2-toolbar__btn {
langGroup.appendChild(langBtnZh)
langGroup.appendChild(langBtnEn)
// Group 3: Lighting Selector
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
// Group 3: Viewport Size Selector + Asset Cache Clear button
const viewportGroup = doc.createElement('div')
viewportGroup.className = 'd2-toolbar__group'
const viewportLabel = doc.createElement('label')
@ -726,12 +738,6 @@ a.d2-toolbar__btn {
const viewportSelect = doc.createElement('select')
viewportSelect.id = 'd2-toolbar-viewport-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', () => {
this.setViewport(viewportSelect.value as ViewportResolution)
})
@ -755,8 +761,6 @@ a.d2-toolbar__btn {
root.appendChild(statusGroup)
root.appendChild(langGroup)
root.appendChild(lightingGroup)
root.appendChild(automapGroup)
root.appendChild(viewportGroup)
root.appendChild(diagBanner)
@ -770,13 +774,8 @@ a.d2-toolbar__btn {
this.rttTextEl = rttText
this.langBtnZhEl = langBtnZh
this.langBtnEnEl = langBtnEn
this.lightingLabelEl = lightingLabel
this.lightingSelectEl = lightingSelect
this.automapLabelEl = automapLabel
this.automapSelectEl = automapSelect
this.viewportLabelEl = viewportLabel
this.viewportSelectEl = viewportSelect
this.inspectorBtnEl = inspectorBtn
this.pluginsBtnEl = pluginsBtn
this.pluginSlotEl = pluginSlot
this.cacheClearBtnEl = cacheClearBtn
@ -799,12 +798,6 @@ a.d2-toolbar__btn {
if (this.rttTextEl) {
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) {
const baseLabel = tToolbar('toolbar.plugins.open', lang)
this.pluginsBtnEl.disabled = !this.pluginsReady
@ -818,8 +811,38 @@ a.d2-toolbar__btn {
this.pluginsBtnEl.className = cls
}
for (const entry of this.contributions.values()) {
if (entry.el) {
const label = resolveLocalizedText(entry.spec.label, lang)
if (!entry.el) continue
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.title = label
}
@ -832,37 +855,23 @@ a.d2-toolbar__btn {
this.langBtnEnEl.className =
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) {
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
}
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. */
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>> = {
'640x480': VIEWPORT_640x480,
'800x600': VIEWPORT_800x600,

View File

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

View File

@ -11,10 +11,6 @@ import {
type AudioBackendLike,
} from '../../src/client/audio/sound-service.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 { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts'
import {
@ -23,6 +19,7 @@ import {
parseClientSettingsJson,
sanitizeClientSettings,
SETTINGS_STORAGE_KEY,
VALID_VIEWPORT_RESOLUTIONS,
validateServerHost,
} from '../../src/client/settings/client-settings.ts'
import {
@ -61,6 +58,7 @@ import {
asSubTile,
asUnitId,
InMemoryPacketTap,
parseD2CapJsonl,
} from '../../src/netproto/index.ts'
import {
applyLightingInputs,
@ -113,7 +111,6 @@ describe('ClientSettings & SettingsStore (F14)', () => {
lightingPreset: 'non_existent_preset',
automapReveal: 'invalid_mode',
viewport: '1920x1080',
inspectorOpen: 'yes',
audioMuted: 123,
masterVolume: 5.5,
extraHackField: 'should_be_stripped',
@ -135,7 +132,6 @@ describe('ClientSettings & SettingsStore (F14)', () => {
lightingPreset: 'auto',
automapReveal: 'off',
viewport: '800x600',
inspectorOpen: false,
audioMuted: false,
masterVolume: 1.0,
})
@ -182,7 +178,7 @@ describe('ClientSettings & SettingsStore (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 store = new SettingsStore({
storage,
@ -194,19 +190,18 @@ describe('Top DOM Toolbar & Live Language / Settings Controls (F14)', () => {
const lightingSpy = vi.fn()
const automapSpy = vi.fn()
const viewportSpy = vi.fn()
const inspectorSpy = vi.fn()
let confirmResponse = false
const toolbar = new Toolbar({
store,
pageProtocol: 'https:',
allowedViewports: VALID_VIEWPORT_RESOLUTIONS,
confirmReconnect: () => confirmResponse,
callbacks: {
onServerHostReconnect: reconnectSpy,
onLightingChange: lightingSpy,
onAutomapRevealChange: automapSpy,
onViewportChange: viewportSpy,
onInspectorToggle: inspectorSpy,
},
})
@ -275,11 +270,6 @@ describe('Top DOM Toolbar & Live Language / Settings Controls (F14)', () => {
expect(store.get().viewport).toBe('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
expect(tToolbar('toolbar.plugins.open', 'zh')).toBe('插件')
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', () => {
const limiter = new OutboundRateLimiter({
moveMinIntervalMs: 120,
@ -747,20 +737,8 @@ describe('OutboundRateLimiter & PacketInspector / .d2cap Replay (F20)', () => {
expect(limiter.tryAcquire({ type: 'Ping' }, 1131).allowed).toBe(true)
})
it('captures PacketTap traffic, filters by phase/direction/opcode, exports .d2cap JSONL, and replays deterministically', () => {
const store = new SettingsStore({
storage: new MemoryStorage(),
defaultLang: 'en',
syncGlobalLang: false,
})
it('captures PacketTap traffic, exports .d2cap JSONL, and parses records deterministically', () => {
const tap = new InMemoryPacketTap()
const replayed: number[] = []
const inspector = new PacketInspector({
store,
tap,
onReplayRecord: frame => replayed.push(frame.opcode),
})
tap.record({
t: 10,
@ -784,37 +762,15 @@ describe('OutboundRateLimiter & PacketInspector / .d2cap Replay (F20)', () => {
raw: new Uint8Array([0x03, 0x64, 0x00, 0x64, 0x00]),
})
expect(inspector.getAllRows()).toHaveLength(3)
expect(formatHexDump(new Uint8Array([0x15, 0x41, 0x42]))).toContain('15 41 42')
// Filter by d2gs + s2c
inspector.setFilter({ phase: 'd2gs', direction: 's2c' })
const s2cRows = inspector.getFilteredRows()
expect(s2cRows).toHaveLength(1)
expect(s2cRows[0]!.opcode).toBe(0x15)
// 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()
expect(tap.entries).toHaveLength(3)
const jsonl = tap.toD2CapJsonl({
format: 'd2cap-v1',
createdAt: '2026-10-03T00:00:00Z',
server: '127.0.0.1',
})
const parsed = parseD2CapJsonl(jsonl)
expect(parsed.records).toHaveLength(3)
expect(parsed.records.map((r) => r.packetId)).toEqual([0x50, 0x15, 0x03])
})
})

View File

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

View File

@ -107,8 +107,8 @@ import {
OpfsMpqManager,
} from '../../src/client/assets/opfs-mpq.ts'
import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts'
import { formatHexDump, PacketInspector } from '../../src/client/inspector/packet-inspector.ts'
import { asSubTile } from '../../src/netproto/index.ts'
import { OnlineSession } from '../../src/client/session/online-session.ts'
import { asSubTile, bytesToHex } from '../../src/netproto/index.ts'
class MemoryStorage implements StorageLike {
readonly data = new Map<string, string>()
@ -1079,15 +1079,17 @@ describe('Tier 2 — Negative, Boundary, Malformed & Corner Cases (F1–F20)', (
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({
storage: new MemoryStorage(),
defaultLang: 'en',
syncGlobalLang: false,
})
store.update({ viewport: '1024x768' })
const toolbar = new Toolbar({ store })
expect(toolbar.toggleInspector()).toBe(true)
expect(toolbar.toggleInspector()).toBe(false)
toolbar.setAllowedViewports(['640x480', '800x600'])
expect(store.get().viewport).toBe('800x600')
expect(toolbar.getAllowedViewports()).toEqual(['640x480', '800x600'])
toolbar.destroy()
})
@ -1303,23 +1305,24 @@ describe('Tier 2 — Negative, Boundary, Malformed & Corner Cases (F1–F20)', (
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 tap = new InMemoryPacketTap()
const inspector = new PacketInspector({ store, tap, maxRows: 2 })
const tap = new InMemoryPacketTap(2)
const session = new OnlineSession({ settingsStore: store })
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: 3, conn: 'game', dir: 's2c', packetId: 0x04, raw: new Uint8Array([0x04]) })
expect(inspector.getAllRows()).toHaveLength(2)
expect(inspector.getAllRows()[0]?.opcode).toBe(0x02)
expect(inspector.stepReplay()).toBeNull()
inspector.destroy()
expect(tap.entries).toHaveLength(2)
expect(tap.entries[0]?.packetId).toBe(0x02)
expect(session.stepD2CapReplay()).toBeNull()
session.destroy()
})
it('F20.B4: formatHexDump handles empty 0-byte buffer and multi-line (>16 byte) buffers', () => {
expect(formatHexDump(new Uint8Array(0))).toBeDefined()
const dump = formatHexDump(new Uint8Array(32).fill(0x41))
expect(dump.split('\n').length).toBeGreaterThanOrEqual(2)
it('F20.B4: bytesToHex and hexToBytes handle empty 0-byte buffer and multi-line (>16 byte) buffers', () => {
expect(bytesToHex(new Uint8Array(0))).toBe('')
const hex = bytesToHex(new Uint8Array(32).fill(0x41))
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', () => {

View File

@ -94,6 +94,7 @@ import {
} from '../../tools/d2s-unlock.ts'
import {
deriveRealmWsEndpoints,
VALID_VIEWPORT_RESOLUTIONS,
validateServerHost,
} from '../../src/client/settings/client-settings.ts'
import {
@ -126,8 +127,8 @@ import {
InMemoryChunkCache,
OpfsMpqManager,
} 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 { PacketInspector } from '../../src/client/inspector/packet-inspector.ts'
import { LightGrid } from '../../src/common/drlg/light-grid.ts'
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', () => {
const storage = new MemoryStorage()
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.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', () => {
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 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)
})
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 tap = new InMemoryPacketTap()
const world = new ClientWorld()
const inspector = new PacketInspector({
store,
tap,
onReplayRecord: (frame, rawBytes) => {
if (frame.phase === 'd2gs' && frame.dir === 's2c') {
world.apply(decodeD2gsS2cPacket(rawBytes, { tables: itemTables }))
}
},
})
const session = new OnlineSession({ settingsStore: store, itemTables })
const w03 = new ByteWriter()
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: 20, conn: 'game', dir: 's2c', packetId: 0x1d, raw: new Uint8Array([0x1d, 0, 77]) })
const jsonl = inspector.exportD2CapJsonl()
inspector.clear()
inspector.loadD2CapJsonl(jsonl)
inspector.replayAll()
const jsonl = tap.toD2CapJsonl({ format: 'd2cap-v1', createdAt: '2026-01-01T00:00:00Z', server: '127.0.0.1' })
session.loadD2CapReplay(jsonl)
session.replayAllD2Cap()
expect(world.mapSeed).toBe(0x13572468)
expect(world.self.stats.get(0)).toBe(77)
inspector.destroy()
expect(session.world.mapSeed).toBe(0x13572468)
expect(session.world.self.stats.get(0)).toBe(77)
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 tap = new InMemoryPacketTap()
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')
const snap = toolbar.getSnapshot()
expect(snap.connection.phase).toBe('error')
expect(snap.diagnosticBannerText).toContain('0xFE')
expect(validateServerHost(snap.settings.serverHost, 'https:').valid).toBe(true)
inspector.destroy()
toolbar.destroy()
})
})

View File

@ -72,6 +72,7 @@ import {
} from '../../tools/netproto-replay.ts'
import {
deriveRealmWsEndpoints,
VALID_VIEWPORT_RESOLUTIONS,
validateServerHost,
} from '../../src/client/settings/client-settings.ts'
import {
@ -108,7 +109,6 @@ import {
OpfsMpqManager,
} from '../../src/client/assets/opfs-mpq.ts'
import { OutboundRateLimiter } from '../../src/client/session/rate-limiter.ts'
import { PacketInspector } from '../../src/client/inspector/packet-inspector.ts'
// ─── Shared Test Helpers ─────────────────────────────────────────────────────
@ -575,7 +575,7 @@ describe('Tier 4 — End-to-End Real-World Application Scenarios (S1–S12)', ()
defaultLang: 'en',
syncGlobalLang: true,
})
const toolbar = new Toolbar({ store })
const toolbar = new Toolbar({ store, allowedViewports: VALID_VIEWPORT_RESOLUTIONS })
const automap = new AutomapView({ initialMode: 'off' })
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()
})
it('S11: Anti-flood OutboundRateLimiter + InMemoryPacketTap + PacketInspector JSONL export & strict .d2cap replay round-trip', () => {
const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false })
it('S11: Anti-flood OutboundRateLimiter + InMemoryPacketTap JSONL export & strict .d2cap replay round-trip', () => {
const tap = new InMemoryPacketTap()
const inspector = new PacketInspector({ store, tap })
const limiter = new OutboundRateLimiter({ moveMinIntervalMs: 120 })
// 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: 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 })
expect(replayed.protocolErrors).toBe(0)
expect(replayed.unknownPackets).toBe(0)
expect(replayed.world.mapSeed).toBe(0x11223344)
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 () => {

View File

@ -119,7 +119,6 @@ import {
} from '../../src/client/audio/sound-service.ts'
import { AutomapView, getActForArea } from '../../src/client/automap/automap-view.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 {
findPathLocal,
@ -2114,7 +2113,6 @@ describe('Tier 5 Adversarial Coverage — F11, F16–F20 Rate Limiter, OnlineSes
automapReveal: 'maphack',
audioMuted: 'yes',
masterVolume: 9.5,
inspectorOpen: 123,
}),
)
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().audioMuted).toBe(false)
expect(store2.get().masterVolume).toBe(1.0)
expect(store2.get().inspectorOpen).toBe(false)
// 3. HTTPS bare-IP WSS cert validation (`validateServerHost` & `deriveRealmWsEndpoints`)
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)
const tap = new InMemoryPacketTap(100)
const inspector = new PacketInspector({ tap })
tap.record({ t: 1, conn: 'game', dir: 's2c', raw: new Uint8Array([0x04]), packetId: 0x04 })
expect(inspector.getAllRows()).toHaveLength(1)
inspector.destroy()
expect(tap.entries).toHaveLength(1)
})
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 { resolve } from 'node:path'
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 {
createKillStatsPlugin,
killStatsPlugin,
} from '../../plugins/kill-stats/index.ts'
import { lightingPresetsPlugin } from '../../plugins/lighting-presets/index.ts'
import {
validatePluginRegistry,
type PluginActionSpec,
@ -22,6 +25,8 @@ import {
type OnlineSessionLobbySnapshot,
type OnlineSessionPhase,
} 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 type { ServerEvent } from '../../src/netproto/index.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', () => {
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()
expect(registeredIds).toEqual(dirIds)
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 () => {
@ -240,5 +248,134 @@ describe('BUILTIN_PLUGINS Registry & killStatsPlugin Lifecycle', () => {
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),
hasCanvas: canvas instanceof HTMLCanvasElement,
hasHudCanvas: hudCanvas instanceof HTMLCanvasElement,
hasInspector: Boolean(document.getElementById('d2-inspector-container')),
hasLobbyOverlay: Boolean(document.getElementById('bnet-shell')),
hasBnetCanvas: bnetCanvas instanceof HTMLCanvasElement,
hasBootProgressBar: Boolean(bootOverlay && bootBar && bootPercentEl && bootFileEl),
@ -375,7 +374,6 @@ async function main(): Promise<void> {
!gate1.hasToolbar ||
!gate1.hasCanvas ||
!gate1.hasHudCanvas ||
!gate1.hasInspector ||
!gate1.hasLobbyOverlay ||
!gate1.hasBnetCanvas ||
!gate1.hasBootProgressBar ||
@ -910,29 +908,19 @@ async function main(): Promise<void> {
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<{
isOpen: boolean
liveCapturedRows: number
filteredGameRows: number
selectedDetailNonEmpty: boolean
fixtureReplays: Record<string, { totalRecords: number; unknownS2cCount: number }>
missingArtValid: boolean
missingArtCount: number
missingArtEntries: unknown[]
}>(`(async () => {
const app = window.__d2Play;
app.inspector.setOpen(true);
const liveCapturedRows = app.inspector.getAllRows().length;
app.inspector.setFilter({ conn: 'game' });
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 entries = app.session.packetTap.entries;
const liveCapturedRows = entries.length;
const filteredGameRows = entries.filter((e) => e.conn === 'game').length;
const fixtures = ${JSON.stringify(captureFixtures)};
const fixtureReplays = {};
@ -955,21 +943,18 @@ async function main(): Promise<void> {
}
const missingArt = window.__d2MissingArt;
return {
isOpen: app.inspector.isOpen(),
liveCapturedRows,
filteredGameRows,
selectedDetailNonEmpty,
fixtureReplays,
missingArtValid: Array.isArray(missingArt) && missingArt.length === 0,
missingArtCount: Array.isArray(missingArt) ? missingArt.length : -1,
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 (
!gate7.isOpen ||
gate7.liveCapturedRows === 0 ||
!gate7.selectedDetailNonEmpty ||
gate7.filteredGameRows === 0 ||
Object.values(gate7.fixtureReplays).some((r) => r.totalRecords === 0 || r.unknownS2cCount > 0) ||
!gate7.missingArtValid
) {

View File

@ -293,28 +293,43 @@ async function run(): Promise<void> {
assert(playReady, 'play.html __d2PlayApp failed to reach ready=true')
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<{
btnExists: boolean
btnDisabled: boolean
btnText: string
modalOpen: boolean
cardExists: boolean
cardIds: string[]
shortcutExists: boolean
automapSelectExists: boolean
lightingSelectExists: boolean
viewportOptionCount: number
hasInspectorBtn: boolean
hasInspectorContainer: boolean
titleText: string
}>(`(() => {
const btn = document.getElementById('d2-toolbar-plugins-btn');
btn?.click();
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 automapSelect = document.getElementById('d2-toolbar-automap-select');
const lightingSelect = document.getElementById('d2-toolbar-lighting-select');
const viewportSelect = document.getElementById('d2-toolbar-viewport-select');
return {
btnExists: Boolean(btn),
btnDisabled: Boolean(btn?.disabled),
btnText: btn?.textContent?.trim() ?? '',
modalOpen: Boolean(overlay && !overlay.hasAttribute('hidden')),
cardExists: Boolean(card),
cardIds,
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() ?? '',
};
})()`)
@ -322,10 +337,79 @@ async function run(): Promise<void> {
assert(openStateZh.btnExists, '#d2-toolbar-plugins-btn must exist')
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.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.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}"`)
// 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 shot1Bytes = await playCdp.captureScreenshot(shot1)
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`)
// 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')
for (let i = 0; i < 100; i++) {
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,
// 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')
for (let i = 0; i < 100; i++) {
await sleep(150)