dsh-mc-art-panel
v0.2.0
Published
MC 资产面板 + 「MC 模组工作室」模式 + mc-mod / mc-art 两个 skill:3D+物品栏、像素/结构编辑器、出错的报告自动放进输入框、GameTest 判定闭环。原生 Windows 可用(不需要 WSL),不绑定任何具体 mod。
Maintainers
Readme
dsh-mc-art-panel
MC 资产面板的真包形态:装进 profile 就在,不用在会话里发射动态插件。
dsh plugin --profile web add /path/to/dsh-mc-art/panel # 或 npm 上的包名
# 重启 DSH → 右侧栏出现「MC 资产」它凭什么"装完就在"
package.json 里三个字段决定一切(形状照 @deepseek-ai/dsh-* 的包抄的):
"main": "lib/index.js",
"exports": { "./client": "./lib/client.js" },
"dsh": { "bundle": { "patch": "./cordis.patch.yml" }, "client": { "platform": "web", "immediately": true, "inject": [...] } }dsh plugin … add 把包记进 profile 的 dsh.profile.bundles,启动时 cordis.patch.yml
把自己的那一行插进组合——dsh --profile <名> --dump-config 里能看到:
# == dsh-mc-art-panel
- id: mcart-panel
name: dsh-mc-art-panel升级之后记得重启进程
宿主的行是启动时挂载的,dsh plugin add 只换文件:不重启的话,客户端可能已经热重载成新版,
而宿主还是老实例(实测症状:面板报 宿主没有这个方法:…)。桌面端要把 Electron 主进程也退出
(托盘常驻时关窗口不算),命令行起的 web 则 Ctrl-C 后重启。
这个包带什么
装一个包,面板、模式、两个 skill 一起到位——不需要再跑仓库里的安装器,也不会往你的 用户目录写任何东西:
panel/ 包根
├── lib/ 宿主与客户端(由 build.mjs 从 tools/mcart-plugin/ 生成)
├── cordis.patch.yml ① 把面板插进组合 ② 把包内预设目录注册成 agent-presets 的一个 root
└── preset/mc-studio/ 「MC 模组工作室」模式
├── agent.cordis.yml 预设的宿主组合(自带 skill-filesystem 行)
├── preset.yml 名字与说明
└── skills/ 随预设走的 skill:mc-mod、mc-art多大:解包后约 1.1 MB(压缩后 306 kB)——lib/ 250 KB、mc-art skill 826 KB
(引擎 mc_art/ + 60 个测试 + 文档;它就是一份完整可离线用的美术引擎)、mc-mod skill 41 KB、
模式本身 17 KB。
panel/preset/ 是打包时生成的(prepare → panel/vendor.mjs),仓库里不留副本:
所以这个仓库不携带 mc-art 的内容(它是独立仓库)。
里面没有任何美术素材:参考贴图/模型是引擎在运行时扫 asset root、版本 jar、mods 得到的
(那是 mc-art 的设计,不是打包进来的资源)。砍掉的是纯垃圾:.git、__pycache__、*.pyc、
.pytest_cache、.cache(含 references/.cache/ 那份派生缓存——它跟机器和资产相关,
随包发还可能给出过期结果)。排除之前磁盘副本虚胖到 2.4 MB;npm 打包时本来也会排掉一部分,
但那不该是唯一防线。
两处机制,都照 DSH 出厂预设的做法:
- 模式:
cordis.patch.yml给agent-presets那一行加了一个 root(trust: system), 路径用!!js从baseUrl算 —— 在 profile 组合里baseUrl就是 profile 目录(实测), 所以node_modules/dsh-mc-art-panel/preset正好落在装好的包上。 - skill:预设自己带
skills/,用process.getBuiltinModule('node:url').fileURLToPath(new URL('skills/', baseUrl))定位 (预设组合里baseUrl是预设自己的目录)。这正是出厂cordis预设带它那两份 composition skill 的方式;web 组合里基础的skill-filesystem行是 disabled 的, 本地 skill 归预设管。
cert:mc-art 是独立仓库(有自己的历史),这里放的是它的一份快照(发布时从克隆复制,
约 2.9 MB)。想跟上游最新:跑仓库里的 install.mjs,它会把 mc-art clone/更新到
~/.dsh/skills/mc-art;~/.dsh/skills 是默认 skill 根,两边不会冲突(同名时预设层优先)。
怎么验(本机真跑过):用 DSH 自己的发现逻辑扫装好的包 ——
# 在某个装了这个包的 profile 里
node -e 'import("/…/@deepseek-ai/dsh-agent-presets/lib/index.js").then(async (m) => {
const r = await m.discoverPresets([{ path: "/…/profiles/<名>/node_modules/dsh-mc-art-panel/preset", trust: "system" }],
new URL("file:///…/@deepseek-ai/dsh/"))
console.log(r.map((p) => p.id + " / " + p.name + " / broken=" + (p.broken ?? "no")).join("\n"))
})'
# → mc-studio / MC 模组工作室 / broken=no同一份源码,两种送达
lib/ 是生成物(node build.mjs),来源是仓库里的 tools/mcart-plugin/{host,client}.js
——和动态插件用的是同一份源码。这个包只是把两半各包一层壳:
| | 动态插件 | 真包 |
|---|---|---|
| 宿主 harness.handle | 运行时注入 | lib/index.js 收成一张表 + 一条 POST /api/mcart/call 派发路由 |
| 客户端 React / host / styles | 运行时注入 | require('react') / fetch / 插 <style> |
所以面板的行为改动只改一处(那两份源码),两边都跟着变。
客户端模块的 id 就是包名(踩过一次)
lib/client.js 里的 window.__ModuleLoader__.load({ id }),那个 id 必须是包名
(dsh-mc-art-panel)。dsh 的 client-modules 按启动图里那一行的 id 去 factories 里找模块,
找不到只在页面顶上写一行 Failed to load plugins:
// dsh-client-modules/lib/client.js
if (!this.factories.has(id)) throw new Error(`bundle ${url} loaded without registering "${id}" via __ModuleLoader__.load`)曾经这里抄成了宿主行的名字 mcart-panel:包装得上、--dump-config 里有那一行、
两个入口单独测都过,页面上却什么都不出现。现在 id 从 package.json 的 name 读一份
(build.mjs 里的 PACKAGE_NAME),entry-test.mjs 与 serve-check.mjs 都拿同一个真相来判。
shell 在 Windows 上是 PowerShell,不是 bash
面板的宿主半要起 Python、要写二进制贴图,这些都经过 DSH 的 shell 服务。而 DSH 按平台换 shell:
POSIX 上是 bash -c,Windows 上是 pwsh … -Command <整串>(dsh-base 的 cordis.patch.yml
里 bash 那几行在 win32 上 disabled、pwsh 那几行启用)。所以 $(printf … | base64 -d)、rm -f、
mv -f、command -v 一个都不能写死——宿主现在探一次方言,然后按方言拼:
| 要做的事 | POSIX | PowerShell |
|---|---|---|
| 传一个参数 | "$(printf %s <base64> | base64 -d)"(绕开所有引号问题) | '值'(单引号里 '' 表示一个 ') |
| 判断有没有某个命令 | command -v x | (Get-Command 'x' -ErrorAction SilentlyContinue) -ne $null |
| 删 / 移 | rm -f / mv -f | Remove-Item -Force / Move-Item -Force |
| 把 base64 解成文件 | base64 -d < 暂存 > 目标 | [IO.File]::WriteAllBytes(目标, [Convert]::FromBase64String([IO.File]::ReadAllText(暂存))) |
图片数据不进命令行:Windows 的命令行总长上限约 32767 字符,一张 128×128 贴图的 base64
就有几十 KB。所以 base64 先用 fs.writeText 当文本落成一个暂存文件(同一条 sandbox 策略),
shell 只做"把这个文件解成字节"这一件事——命令行里永远只有路径。
node tools/mcart-plugin/shell-dialect-test.js 验这件事:它把 shell 桩装成 Windows PowerShell,
捕获宿主真正发出的命令,然后逐条交给真的 Windows PowerShell 执行,最后比对贴图字节;
--fault 让探针谎报 POSIX,要求门禁变红(实测:正常 12 项全绿,谎报 8 项红)。
宿主服务缺席时,面板自己还有退路
fs 和 shell 都是组合里可能不在的行。用户在 Windows 桌面端(0.2.0-rc.2)实测到过:
面板能扫描、能弹系统目录对话框,点"在这里新建项目"却回一句
「建目录失败:pack(宿主没有 shell 服务时建不出目录)」——而那句话是猜的
(ensureDir 只看了 exitCode,既没查服务在不在,也没把 shell 自己的报错带回来)。
现在每一件事都有退路,且走哪条路会写在结果里(面板会提示「目录用 … 建的」):
| 要做的 | 第一条 | 第二条 | 第三条 | 最后一条 |
|---|---|---|---|---|
| 建目录 | directoryPickerController.createDirectory(harness 自己的"新建文件夹"API,非递归,缺父目录时先补父目录) | shell(按方言 mkdir -p / [IO.Directory]::CreateDirectory) | fs.writeText(<目录>/.gitkeep) —— dsh-fs-local 的 writer 第一件事就是 mkdir recursive,代价是空目录里多个 .gitkeep | node:fs 垫片 |
| 写文本 | fs.writeText(带显式 sandbox 策略) | shell 重定向 | node:fs 垫片 | — |
| 写字节(贴图) | fs 落 base64 暂存 + shell 解码 | node:fs 垫片直接写字节 | — | — |
| 移 / 删 | shell 方言 | node:fs 垫片 | — | — |
| 读(扫描/预览) | fs 服务 | node:fs 垫片 | — | — |
node:fs 垫片在宿主入口里自带(build.mjs 生成 lib/index.js 时内联,改不了发布包的行为
也不依赖仓库里的 tools/)。它排在最后是有意的:它绕过宿主的 sandbox 策略(不产生文件效应记录、
不触发审批),只有前面几条都不可用时才用,结果里会写明 via=node:fs。
四条路全断时,错误里会逐个点名每一条说了什么,而不是含糊一句"没有 shell 服务"。
想知道这台机器上到底有什么,问一句 atlas.env:它回 {fs, shell, sessions, webServer,
directoryPickerController, localFs} 的在场情况、shell 方言、platform 与 node 版本。
门禁:node tools/mcart-plugin/project-test.js 用 buildHandlers() 把"谁缺席"造成四种状态
(只有 fs / 只有垫片 / 只有目录选择器 / 什么都没有),并逐条断言结果与 via;
把任意一条退路掐掉(变异测试做过:掐垫片 → 8 项红;掐 fs 那条 → 3 项红;去掉"先建项目目录" → 3 项红),
门禁必须变红。node panel/entry-test.mjs 另外用发布出去的那份 lib/index.js、
在"一个服务都没有"的 ctx 上真建一个工程,证明垫片随着包发得出去。
注释剥离器吞过 313 行代码(0.1.13 之前每个版本都中)
lib/ 里的源码是 tools/strip_comments.py 剥掉注释后的结果。老规则是"任意位置的
/* … */",而宿主源码里有一句行注释提到了 glob:
// Forge keeps every mod in versions/<version>/mods/*.jar. So "scan the那个 /* 被当成块注释开头,一路吃到 313 行之后的 */。发出去的宿主因此少了十个声明:
SCAN_SCRIPT、EXTRACT_SCRIPT、REFERENCE_MAX_BYTES、toolPaths、findTool、
findScanner、PYTHON_CANDIDATES、resolvePython、itemRoots、roots —— 整条参考/提取通道。
生成物照样解析、verify-build 照样绿(它比对的是"再剥一次"的结果,自证不了),
直到用户点开 ⚙ 设置,页面回一句 findScanner is not defined。
实测:0.1.2 到 0.1.12 每一份发布物都缺这两个定义。
现在三件事一起:
- 规则改成行首才算(
^[ \t]*/\*):行内/*一律不碰——宁可少剥一层,不能吞代码; - 触发它的两处注释改写了措辞(含 glob 的那句、
models/item/*那句); - 新增
tools/strip_gate.py:比对注释源与生成物里声明的名字,少一个就红;--fault用同一份夹具做 A/B(新规则一个都不吞、老规则必吞),证明这条检查能红。 同时entry-test.mjs从行为层面再拦一次:对发出去的那份真跑atlas.settings/atlas.refNamespaces/atlas.refBlocks/atlas.refItems, 任何is not defined都算失败。
复现验证(做过):把规则改回贪婪版 + 按原样插回那句 glob 注释 → strip_gate.py 报
「缺 labelFor、localOf、messageOf、policyFor、prefix、rel、tries」,entry-test.mjs 报
localOf is not defined,两个门禁同时红。
参考目录:对话框不可靠,所以必须能手输
用户在 Windows 上点开 ⚙ 设置、点"选择目录…",没反应,也没有报错。两个原因叠在一起:
- 那个按钮靠系统对话框,而对话框在有些环境里既不显示也不返回(shell 服务跑在非交互
窗口站上时,
FolderBrowserDialog就是这么个结局)——按钮于是永久停在"对话框已打开…"; - 设置卡当时不渲染
notice,而且没有任何手动输入路径的地方,所以"没反应"之后 真的没有下一步。
四条改动:
- 设置卡里加了手输框 + "用这个路径":对话框弹不出来时把路径贴进去就行
(
.minecraft、版本目录、mods 目录、单个 jar 都接受); - 设置卡现在也渲染
notice/failure,失败一定有字; runPicker有 120 秒上限:超时就放开按钮,并写明"请把路径贴进下面的输入框";detectGameRoots原来只认 WSL 形状的路径(/root/.minecraft、/mnt/c/Users/…、/home/…),在原生 Windows 上一条都不成立 —— 所以"检测到 … 用它"那一栏永远是空的。 现在按平台给候选(%APPDATA%\.minecraft、~/Library/Application Support/minecraft、~/.minecraft)、加上工程旁边的.minecraft/run、并展开启动器实例目录一层 (CurseForge / Prism / MultiMC 的容器里才是真正的游戏目录),逐个stat只报存在的。 想知道"它到底去哪儿找过",atlas.gameRoots一条命令回候选与命中。
门禁:node panel/ui-test.mjs —— 一个只实现 createElement/useState/useEffect 的假 React
真渲染一遍设置卡,然后按按钮的文字去点,断言:手输框在、用这个路径 在、检测到的候选
带 用它、对话框不可用时原因出现在设置卡里、打字后点按钮真的把路径写进
atlas.settings。--fault 把那一行输入框从源码里删掉,要求这些断言变红。
node tools/mcart-plugin/roots-test.js 用 Windows 形状的环境变量造一棵假树,
断言候选/命中/展开/上限都对;--fault 换回老的 WSL 形状逻辑,要求它找不到这台机器。
门禁
node panel/verify-build.mjs # lib/ 与源码去注释后重新生成的结果逐字节比对(挡漂移)
node panel/entry-test.mjs # 两个入口真的加载/apply/派发;含一次真注入
node panel/ui-test.mjs # 假 React 真渲染设置卡并按文字点按钮(--fault 证明能红)
node tools/mcart-plugin/roots-test.js # 参考目录检测按平台给候选(--fault 证明老逻辑找不到)
node ../tools/python_env.mjs ../tools/strip_gate.py # 剥离器不许吞代码:声明的名字一个都不能少(--fault 证明能红)
node panel/serve-check.mjs --url http://127.0.0.1:3099 --token <token> # 真送达:对着跑着的实例查serve-check.mjs 管的是前两个门禁够不着的那一层——装进 profile 之后,浏览器到底收到没有。
它对着一个真在跑的实例:读首页启动图里我们那一行 → 取 bundle 真实字节与 lib/client.js
逐字符比对 → 用真浏览器打开页面,要求我们的客户端半留下激活痕迹
(它 apply 时会插一个 <style data-plugin="dsh-mc-art-panel">)且没有 Failed to load plugins。
临时实例的起法(验证完就该删):
dsh plugin --profile mcart-check add /path/to/dsh-mc-art/panel
# 把 "@deepseek-ai/dsh-web-app" 加进那个 profile 的 package.json 的 dsh.profile.bundles
dsh --profile mcart-check --port 3099 --no-open # 启动日志里会打带 token 的 URL
node panel/serve-check.mjs --url http://127.0.0.1:3099 --token <那串 token>