diff --git a/README.md b/README.md
index c12edc9..96abd2a 100644
--- a/README.md
+++ b/README.md
@@ -15,9 +15,19 @@ own output, untouched.
## How it works
-Claude Code's native binary embeds its JS bundle as plain text. ccskin finds
-the display-related string anchors in that text and performs **byte-length-equal
-replacements** (offsets stay valid), then re-signs the binary ad-hoc. Pipeline:
+ccskin locates the display-related strings, performs **byte-length-equal
+replacements** (offsets stay valid), then re-signs the binary ad-hoc.
+
+Which copy of those strings gets patched depends on how the binary was built,
+and the tool detects this automatically:
+
+- **2.1.26x and later** are compiled with `bun build --bytecode`. The JS source
+ text is still embedded, but the runtime reads the **bytecode constant pool** —
+ patching the source text passes every check while changing nothing on screen,
+ so patches go to the pool.
+- **Earlier builds** have no bytecode; the JS source text is still patched.
+
+Pipeline:
```
analyze patch sign verify
@@ -100,9 +110,24 @@ All replacements must be byte-length equal, therefore:
ccskin preview design/octopus.toml # render a design without touching binaries
```
-Each slot in a design file holds visible characters; `glyphpack` re-packs them
-into the exact escape/literal shape of the original slots. Add your own
-`design/*.toml` and point `icon_design` at it.
+Each slot in a design file holds visible characters. **Slot boundaries are
+detected from the binary** — upstream shifts characters between slots across
+versions (2.1.261 grew `r1E` from 5 to 6 columns and shrank `r1R` from 1 to 0)
+while the total row width stays the same, so a design only has to get each
+*row* width right. Add your own `design/*.toml` and point `icon_design` at it.
+
+Note that identical strings are deduplicated inside the binary, so variants
+sharing a slot cannot be given different art (upstream itself shares `r1E`
+between `default` and `arms-up`). `analyze` reports such conflicts instead of
+silently picking one.
+
+## Version number on bytecode builds
+
+The startup banner and `--version` read the *same* constant, so the displayed
+version cannot be faked on its own — it is skipped by default. Set
+`rewrite_real_version = true` if you accept that `--version` and the update
+check will report the fake version too (it must be the same length as the real
+one).
## Updating Claude Code
diff --git a/README.zh.md b/README.zh.md
index 10c1d02..e6fbc1b 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -9,8 +9,14 @@ API 上报等行为完全不变。
实际效果 —— 图标 `design/octopus.toml` · 名字 `Loop Code` · 版本 `v9.99` ·
主题色 `#2E9BFF`。模型那一行及以下都是 Claude Code 自己的输出,未做任何改动。
-原理一句话:Bun 单文件二进制把 JS 源码原样嵌在 Mach-O 里,找到显示相关的
-字符串锚点,做**逐字节等长替换**,再 ad-hoc 重签名即可。
+原理一句话:找到显示相关的字符串,做**逐字节等长替换**,再 ad-hoc 重签名。
+
+具体改哪份字符串取决于构建方式,工具会自动判定:
+
+- **2.1.26x 起**用 `bun build --bytecode` 编译。二进制里那份 JS 源码文本还在,
+ 但运行时读的是 **bytecode 常量池** —— 改源码文本能通过一切校验而屏幕毫无变化,
+ 所以补丁打到常量池。
+- **更早的版本**没有 bytecode,仍改 JS 源码文本。
## 快速开始
@@ -91,36 +97,45 @@ claude_entitlements.plist # 重签名用的授权文件
## 显示位清单(改了哪些)
-| 位置 | 内容 |
-|---|---|
-| 启动大标题 | `children:"Claude Code"` + 后面的 `["v",变量]` |
-| 输入框边框(展开) | `("Claude Code")` 与模板 `` `v${变量}` `` |
-| 输入框边框(紧凑) | `(" Claude Code ")` |
-| 头部/任务面板标题 | `["Claude Code"," "]` 与 `["v",变量]` |
-| 像素图标 | logo 表 4 种动画形态 + 第三行小脚 + N2p 底行 |
-| HTML 登录/错误页 | 反引号模板里的 3 行 ASCII logo |
+显示位随版本增减,analyze 会如实报告每一处命中与缺失。
-**没改的**:`--version` 输出、`/doctor`、更新提示、状态消息等 —— 那些用的
-是内部常量或属信息性文本,不属于启动画面。
+| 位置 | 2.1.261(bytecode) | 更早的版本(源码文本) |
+|---|---|---|
+| 启动大标题 | ✅ | ✅ |
+| 首次运行欢迎页 | ✅ | — |
+| 输入框边框 / 头部标题 | 上游已移除 | ✅ |
+| 版本号显示 | ❌ 与 `--version` 共用常量 | ✅ |
+| 像素图标(4 种动画形态 + 第三行 + 底行) | ✅ | ✅ |
+| HTML 登录/错误页 logo | ✅ | ✅ |
+| 主题色 | ✅ | ✅ |
+
+**没改的**:`--version` 输出(除非显式开 `rewrite_real_version`)、`/doctor`、
+更新提示、状态消息等 —— 那些用的是内部常量或属信息性文本,不属于启动画面。
## 图标设计指南
-`design/octopus.toml` 里每个槽位填"可见字符",工具按二进制里原槽位的
-转义(`\uXXXX`,6 字节)/字面(1 字节)形状等长打包。槽位字符数约束:
+`design/octopus.toml` 里每个槽位填"可见字符"。**槽位边界由二进制现场探测**
+—— 上游会在版本之间挪动字符在槽位间的分配(2.1.261 就把 r1E 从 5 列加到 6 列、
+r1R 从 1 列减到 0),但整行列数不变,所以设计稿只要保证**每行总列数**对就行:
-- `default` / `look-left` / `look-right`:r1L=2、r1E=5、r1R=1、r2L=2、r2R=2
- (第一行 8 列;第二行共 9 列)
-- `arms-up`:r1L=2、r1E=5、r1R=2(第一行 9 列)
-- `mid.row`=5:第二行机身中部(四种变体共用一个常量,无法按变体区分;
- 章鱼的眼睛就在这里)
+- `default` / `look-left` / `look-right`:第一行 8 列、第二行 9 列
+- `arms-up`:第一行 9 列、第二行 9 列
+- `mid.row`=5:第二行机身中部(四种变体共用一个常量;章鱼的眼睛在这里)
- `feet.row`=5(第三行)、`n2p.row`=7、`html.row1/2/3`=8/9/7
+**去重约束**:相同内容的字符串在二进制里只有一条,被多个变体共用。上游自己
+就让 `default` 与 `arms-up` 共用 r1E,所以设计稿不能给它们不同图案 —— 举手
+姿势只能靠 r1L/r1R 表现。冲突时 analyze 会直接报错并指出是哪两个槽位。
+
改完设计稿先 `python3 -m patcher preview your.toml` 看效果,
再跑 analyze 生成新定义。可用字符:任意 BMP 的 `\uXXXX`(方块字符
▟▙▛▜▐▌▄▀█▖▗▘▝、◉●○、╵ 等)+ 槽位字面位上的 ASCII。
## 注意
+- **bytecode 构建上版本号改不了**:启动画面的版本与 `--version` 共用同一条
+ 常量,没法只改显示,默认自动跳过。愿意接受 `--version` 和更新检查一起变的话,
+ 配 `rewrite_real_version = true`(新版本号必须与真实版本等长)
- **自动更新会覆盖补丁**:在 claude 设置里关掉 autoUpdates,或设
`DISABLE_AUTOUPDATER=1`;每次升级后重跑本工具即可
- 首次运行补丁后的二进制如被系统拦(Killed: 9):设置 → 隐私与安全性 →
diff --git a/SKILL.md b/SKILL.md
index e3a8ed9..8c66b64 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -1,6 +1,6 @@
---
name: ccskin
-version: 0.2.0
+version: 0.3.0
description: >
给 Claude Code CLI 换肤/换主题/改启动画面:替换像素图标、产品名、版本号显示、
主题色(仅显示层,不动内部逻辑)。当用户提到 claude code 换肤、改主题、改图标、
@@ -18,11 +18,23 @@ allowed-tools: Bash, Read, Write, Edit, Glob, Grep
通过"等长字节替换 + ad-hoc 重签名"修改 claude 二进制的**显示层**。
内部版本常量、更新检查、API 行为完全不变(`--version` 仍打印真实版本,这也是验证手段)。
+## 两条补丁通道(先搞清楚当前二进制走哪条)
+
+- **bytecode 构建**(文件里有 `// @bun @bytecode`,2.1.26x 起):运行时读的是
+ bytecode 常量池,二进制里那份 JS 源码文本**不驱动 UI**。改源码文本能通过
+ 一切计数与 sha1 校验而屏幕毫无变化 —— 必须打到常量池(`bytecode.py`)。
+- **老版本**:仍是源码文本通道。
+
+`discovery.build_patches` 自动判定并切换,`verify` 跟着切。排障先看
+`docs/pitfalls.md` 第 0 条。
+
## 硬性规则(违反会搞坏二进制)
- 任何替换必须逐字节等长;`expected` 计数或 sha1 指纹不符**必须中止**,禁止强行 patch
- 只在用户自己的二进制上操作;不分发二进制;不提交 `config.toml`
- 备份(`.orig`)已存在时绝不覆盖
+- 常量池按内容去重:同一条可能被多处共用。设计稿要求一条同时变成两种图案时
+ **报错而不是随便挑一个**;版本号与 `--version` 共用一条,默认跳过不改
## 怎么用(按场景)
@@ -39,7 +51,10 @@ allowed-tools: Bash, Read, Write, Edit, Glob, Grep
3. **改显示内容(名字/版本/颜色/图标)**: 引导用户跑 `skin` 向导;
或编辑 `config.toml`(name ≤11 字符、version ≤5 字符、theme_color 6 位 hex、
- icon_design 指向 design/*.toml),然后 `all --apply -y`
+ icon_design 指向 design/*.toml),然后 `all --apply -y`。
+ bytecode 构建上版本号改不了(与 `--version` 共用常量),会自动跳过;
+ 用户坚持要改再开 `rewrite_real_version`,并说清 `--version` 和更新检查
+ 都会看到假版本
4. **新图标设计**: 复制 `design/octopus.toml` 改字符,先
`python3 -m patcher preview <新设计.toml>` 看效果,再进 skin 流程。
@@ -49,9 +64,10 @@ allowed-tools: Bash, Read, Write, Edit, Glob, Grep
## 验证清单(做完必须过)
-- `verify` 图标预览形状正确;显示名/版本计数 > 0
+- `verify` 图标预览形状正确;显示名在常量池/源码里命中 > 0
- `--version` 输出**真实**版本号(证明内部没被碰)
-- `--pty` 抓屏含显示名和版本
+- **bytecode 构建必须做 `--pty` 抓屏**:计数类校验证明不了 UI 真的变了,
+ 只有抓屏能。抓屏里看到旧图标/旧名字 = 补丁打错了通道
## 参考文档
diff --git a/config.example.toml b/config.example.toml
index b783c69..8bc86c0 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -5,8 +5,13 @@
# 仅限可打印 ASCII(名字会嵌进二进制内的 JS 源码,非 ASCII 无法等长替换);
# 不足自动用空格居中补齐
name = 'Loop Code'
-# 版本号显示。1-5 个字符,不足自动补空格;内部真实版本不受影响
+# 版本号显示。1-5 个字符,不足自动补空格;内部真实版本不受影响。
+# 注意: bun --bytecode 构建(2.1.26x 起)上启动画面的版本号与 --version
+# 共用同一条常量,没法只改显示 —— 这种情况版本位会被自动跳过。
version = 'v9.99'
+# 仅 bytecode 构建: 强行改写那条共用的版本常量。
+# 代价是 --version 与自动更新检查也会看到假版本,默认关闭。
+rewrite_real_version = false
# 图标设计稿(design/ 目录下的文件名)
icon_design = 'octopus.toml'
# 终端主题色(logo/边框/高亮的橙色),6 位 hex;留空 = 保持原橙 #D97757
diff --git a/design/octopus.toml b/design/octopus.toml
index 14bda5f..b29c2b3 100644
--- a/design/octopus.toml
+++ b/design/octopus.toml
@@ -34,7 +34,8 @@ r2R = '█▙'
[slots.arms-up]
r1L = '▗ '
-r1E = '▗▄▄▄▖'
+# r1E 与 default 共用一条常量池条目(上游自己也共用),只能靠 r1L/r1R 表现举手
+r1E = '▄▄▄▄▖'
r1R = ' ▖'
r2L = ' ▟'
r2R = '▙ '
diff --git a/docs/pitfalls.md b/docs/pitfalls.md
index 506c4ad..f578a20 100644
--- a/docs/pitfalls.md
+++ b/docs/pitfalls.md
@@ -2,6 +2,31 @@
每条都附规避方法,工具里已固化的用 → 标注。
+## 0. bytecode 构建上改 JS 源码文本是无效的(最大的坑)
+2.1.26x 起 claude 用 `bun build --bytecode` 编译。二进制里那份 JS 源码
+**文本还在**,但运行时读的是 bytecode 常量池 —— 改源码文本能通过锚点计数、
+sha1、`--version` 全部校验,**屏幕上却毫无变化**。
+判定:文件里出现 `// @bun @bytecode` 即为 bytecode 构建。
+→ `discovery.build_patches` 检测到就整体切到常量池通道(`bytecode.py`),
+`verify` 也改成查常量池;老版本(非 bytecode)仍走源码文本通道。
+
+常量池条目布局:
+```
+
+
+
+ ← 不是终止符,当成终止符解析会漏掉一半条目
+```
+
+## 0b. 常量池按内容去重,一条可能被多处共用
+池里内容相同的字面量只有一条。后果:
+- **图标**: 上游自己就让 `default` 与 `arms-up` 共用 r1E,设计稿给它们不同
+ 图案物理上做不到 → `_pool_icon_map` 检测冲突并报错,不静默取其一。
+- **版本号**: 启动画面的版本与 `--version` 共用同一条常量,没法只改显示
+ → 默认跳过版本位;要强改得显式开 `rewrite_real_version`。
+- **名字**: `Claude Code` 是一条,但 `--version` 里的 ` (Claude Code)`
+ 与机器标识 `claude-code` 是**另外的**条目,不受影响。
+
## 1. 替换必须逐字节等长
Bun 单文件二进制内嵌的是 JS 源码**文本**,任何长度变化都会让后续内容的
偏移整体错位,直接损坏二进制。所有替换(old/new)字节数必须相等。
@@ -11,10 +36,11 @@ Bun 单文件二进制内嵌的是 JS 源码**文本**,任何长度变化都会
minified bundle 经常整个模块一行,`grep -c` 数的是"含匹配的行数"不是次数。
→ 统计出现次数用 `grep -abo file | wc -l`(工具内用 `bytes.count`)。
-## 3. 二进制里有"假锚点"区
-V8 字节码快照/字符串表区也能搜到 `arms-up`、`look-left` 等词,但那不是
-JS 源码,**不能改**。辨别方法:上下文不是可读的 JS。
-→ `discovery.py` 只认完整结构锚点(`r1L:" \u2590"` + 括号配对),不会误伤。
+## 3. 源码文本区与常量池区要分清
+同一个字符串在二进制里有两份: 源码文本区(\uXXXX 转义形式)和 bytecode
+常量池(UTF-16 / latin1 裸字符)。**bytecode 构建下只有后者驱动 UI**(见 0)。
+→ 源码通道只认完整结构锚点(槽位正则 + 括号配对);常量池通道用
+`bytecode.find()` 按条目边界精确匹配,短串再用 `region_for()` 限定区间消歧。
## 4. 压缩变量名每个版本都变
`M2p`/`N2p`/`Ida`/`YZy`/`Vxr` 是构建时随机生成的,换版本全变。
diff --git a/patcher/analyze.py b/patcher/analyze.py
index 60635e7..ac5699a 100644
--- a/patcher/analyze.py
+++ b/patcher/analyze.py
@@ -46,14 +46,22 @@ def load_design(project_root: pathlib.Path, design_name: str) -> dict:
return tomlmini.loads(f.read())
-def _show_preview(patches, name, version, theme_color=None):
+def _show_preview(patches, name, version, theme_color=None, design=None,
+ rewrote_real_version=False):
"""原图标 vs 新图标对照 + 名字/版本/颜色汇总。"""
for p in patches:
- if p["name"] == "icon_table" and p.get("art_old") and p.get("art_new"):
+ if p.get("art_old") and p.get("art_new"):
ui.info(ui.bold("图标对照(左原 / 右新):"))
for old_line, new_line in zip(p["art_old"], p["art_new"]):
ui.info(" " + old_line.ljust(14) + ui.dim("→") + " " + new_line)
break
+ else:
+ # 常量池通道逐条替换,没有「整表」可对照,直接渲染设计稿
+ art = discovery.pool_art(design) if design else None
+ if art and any(p.get("cat") == "icon" for p in patches):
+ ui.info(ui.bold("新图标:"))
+ for line in art:
+ ui.info(" " + line)
n_name = sum(p["expected"] for p in patches if p.get("cat") == "name")
n_ver = sum(p["expected"] for p in patches if p.get("cat") == "version")
n_color = sum(p["expected"] for p in patches if p.get("cat") == "color")
@@ -61,8 +69,11 @@ def _show_preview(patches, name, version, theme_color=None):
ui.info(ui.bold("名字:") + " " + ui.arrow('"Claude Code"', f'"{name}"')
+ ui.dim(f"(共 {n_name} 处显示位)"))
if n_ver:
+ note = ("内部版本常量已被改写,--version 与更新检查同步变化"
+ if rewrote_real_version else
+ f"共 {n_ver} 处显示位,内部版本常量不动")
ui.info(ui.bold("版本:") + " " + ui.arrow("真实版本", version)
- + ui.dim(f"(共 {n_ver} 处显示位,内部版本常量不动)"))
+ + ui.dim(f"({note})"))
if n_color and theme_color:
hexs = "%02X%02X%02X" % theme_color
ui.info(ui.bold("主题色:") + " " + ui.arrow("品牌橙", f"#{hexs}")
@@ -107,9 +118,11 @@ def run(binary: str, cfg: dict, project_root: pathlib.Path,
if name != raw_name:
ui.info(f"名字不足 {discovery.NAME_MAX} 字符,已居中补齐: {raw_name!r} → {name!r}")
# real_version 时版本串不会被使用,不做校验
+ rewrite_version = bool(display.get("rewrite_real_version"))
if not real_version:
try:
- discovery.validate_version(version)
+ discovery.validate_version(
+ version, max_len=None if rewrite_version else discovery.VERSION_MAX)
except ValueError as e:
ui.fail(f"配置错误: {e}")
return 1
@@ -118,14 +131,19 @@ def run(binary: str, cfg: dict, project_root: pathlib.Path,
version_arg = None if real_version else version
ui.step("发现锚点")
- patches, report, problems = discovery.build_patches(
- data, name, version_arg, design, theme_color=theme_color)
+ probed = probe_version(binary)
+ patches, report, problems, warnings = discovery.build_patches(
+ data, name, version_arg, design, theme_color=theme_color,
+ real_version=probed,
+ rewrite_real_version=rewrite_version)
for line in report:
ui.ok(line)
+ for line in warnings:
+ ui.warn(line)
for line in problems:
ui.fail(line)
- label = safe_label(label or probe_version(binary) or "unknown")
+ label = safe_label(label or probed or "unknown")
if label == "unknown":
ui.warn("--version 探测失败,定义文件将用 unknown 命名")
out = resolve_out(project_root, label, out=out, force=force)
@@ -152,7 +170,9 @@ def run(binary: str, cfg: dict, project_root: pathlib.Path,
return 2
ui.step("替换预览")
- _show_preview(patches, name, version, theme_color)
+ _show_preview(patches, name, version, theme_color, design,
+ rewrote_real_version=rewrite_version
+ and any(p.get('cat') == 'version' for p in patches))
pathlib.Path(out).write_text(defio.dumps(meta, patches), encoding="utf-8")
ui.step("完成")
diff --git a/patcher/bytecode.py b/patcher/bytecode.py
new file mode 100644
index 0000000..e18eb65
--- /dev/null
+++ b/patcher/bytecode.py
@@ -0,0 +1,122 @@
+"""bun/JSC bytecode 常量池: 条目定位与等长替换。
+
+新版 claude 用 `bun build --bytecode` 编译。二进制里那份 JS 源码文本还在,
+但运行时读的是 bytecode 常量池 —— 改源码文本能通过一切计数与 sha1 校验,
+屏幕上却毫无变化。显示层补丁必须打到常量池。
+
+条目布局(实测 2.1.261):
+
+
+
+
+
+hash 不参与运行时校验(实测改 chars 不改 hash 照常渲染),所以只替换 chars。
+池是去重的: 内容相同的字面量在整个二进制里只有一条,因此同一条会被多处
+共用 —— 调用方必须自己判断改它的连带范围。
+"""
+import re
+import struct
+from collections import namedtuple
+
+Entry = namedtuple("Entry", "start chars nbytes eight text")
+
+_ALIGN = 4
+_EIGHT_BIT = 0x80000000
+_HEADER = 8 # len(4) + hash(4)
+
+
+def encode(text: str, eight: bool) -> bytes:
+ return text.encode("latin-1") if eight else text.encode("utf-16-le")
+
+
+def find(data: bytes, text: str, region=None):
+ """内容恰好等于 text 的常量池条目。池去重,正常是 0 或 1 条。
+
+ region=(lo, hi) 用于给短串消歧 —— 单字符条目的字节模式很短,
+ 全文件搜索可能撞上随机数据。
+ """
+ if not text:
+ return []
+ lo, hi = region if region else (0, len(data))
+ lo = max(0, lo)
+ out = []
+ for eight in (True, False):
+ try:
+ chars = encode(text, eight)
+ except UnicodeEncodeError:
+ continue
+ pad = b"\x00" * (-len(chars) % _ALIGN)
+ head = struct.pack(" bytes:
+ """新 chars 字节;字符数必须与原条目一致(否则条目长度字段就得改)。"""
+ if len(new_text) != len(entry.text):
+ raise ValueError(
+ f"字符数不一致: 条目 {entry.text!r} 有 {len(entry.text)} 字符,"
+ f"替换串 {new_text!r} 有 {len(new_text)} 字符")
+ buf = encode(new_text, entry.eight)
+ if len(buf) != entry.nbytes:
+ raise ValueError(
+ f"{new_text!r} 无法以原编码({'latin-1' if entry.eight else 'UTF-16'})"
+ f"等长写回 {entry.text!r}")
+ return buf
+
+
+def is_bytecode_build(data: bytes) -> bool:
+ return b"// @bun @bytecode" in data
+
+
+_ART_RE = re.compile(r"^[▀-▟─-╿ \n]+$")
+
+
+def art_entries_in(data: bytes, lo: int, hi: int, min_len: int = 1):
+ """扫出区间内所有「只由制表/方块字符组成」的 UTF-16 条目。
+
+ 用于发现上游新增或改名的 logo 串: 已知槽位都替换完之后,这里还剩下的
+ 就是没被设计稿覆盖到的图案,报出来比默默漏掉强。
+ """
+ out = []
+ pos = max(0, lo)
+ hi = min(hi, len(data))
+ while pos + _HEADER < hi:
+ (raw,) = struct.unpack_from(" hi:
+ pos += _ALIGN
+ continue
+ chunk = data[cstart:cstart + nbytes]
+ try:
+ text = chunk.decode("latin-1" if eight else "utf-16-le")
+ except UnicodeDecodeError:
+ pos += _ALIGN
+ continue
+ if not eight and len(text) >= min_len and _ART_RE.match(text):
+ out.append(Entry(pos, cstart, nbytes, eight, text))
+ pos = cstart + nbytes + (-nbytes % _ALIGN)
+ continue
+ pos += _ALIGN
+ return out
diff --git a/patcher/defio.py b/patcher/defio.py
index 4ccb091..ba9d564 100644
--- a/patcher/defio.py
+++ b/patcher/defio.py
@@ -40,8 +40,12 @@ def dumps(meta: dict, patches: list) -> str:
lines.append(f"sha1 = {tomlmini.emit_str(p['sha1'])}")
if p.get("offset") is not None:
lines.append(f"offset = {p['offset']}")
+ # 常量池条目是 UTF-16,字节里带 NUL,按 enc 存可读文本而不是裸字节
+ enc = p.get("enc") or "ascii"
+ if enc != "ascii":
+ lines.append(f"enc = {tomlmini.emit_str(enc)}")
lines += [
- f"new = {tomlmini.emit_str(p['new'].decode('ascii'))}",
+ f"new = {tomlmini.emit_str(p['new'].decode(enc))}",
f"expected = {p['expected']}",
]
return "\n".join(lines) + "\n"
@@ -53,13 +57,16 @@ def loads(text: str):
patches = []
for i, p in enumerate(doc.get("patch", [])):
try:
+ enc = p.get("enc", "ascii")
+ if enc not in ("ascii", "utf-16-le", "latin-1"):
+ raise ValueError(f"不支持的 enc: {enc!r}")
old = p["old"].encode("ascii") if "old" in p else None
if old is None:
if "sha1" not in p:
raise KeyError("old/sha1")
if "offset" not in p:
raise KeyError("offset")
- new = p["new"].encode("ascii")
+ new = p["new"].encode(enc)
expected = int(p["expected"])
if old is not None and len(old) != len(new):
raise ValueError(f"old/new 长度不等({len(old)} vs {len(new)})")
@@ -70,6 +77,7 @@ def loads(text: str):
"cat": p.get("cat", ""),
"desc": p.get("desc", ""),
"old": old,
+ "enc": enc,
"sha1": p.get("sha1", ""),
"offset": int(p["offset"]) if "offset" in p else None,
"new": new,
diff --git a/patcher/discovery.py b/patcher/discovery.py
index 3420ab3..37f7da0 100644
--- a/patcher/discovery.py
+++ b/patcher/discovery.py
@@ -20,7 +20,7 @@
import hashlib
import re
-from . import glyphpack
+from . import bytecode, glyphpack
# ---------- 稳定锚点(raw 字符串,保留字面反斜杠) ----------
_LOGO_ANCHOR = br'r1L:" \u2590"' # logo 表第一个槽位
@@ -41,11 +41,22 @@
# ---------- 名字显示位 ----------
NAME_MAX = 11 # 原串 "Claude Code" 的字符数,替换必须等长
+# 大 logo 的第三行是与小 logo 不同的另一个常量(小 logo 走 _FEET_CONTENT)
+_FEET_BIG_VISIBLE = "▝▝ ▝▝"
+
+# 常量池里的产品名条目(池按内容去重,一条对应所有引用它的显示位)
+_POOL_NAME_SITES = [
+ ("Claude Code", lambda n: n, "name"),
+ ("Welcome to Claude Code", lambda n: "Welcome to " + n, "name_welcome"),
+]
+
+# 显示位在版本之间会被上游整个删掉(如 2.1.261 移除了输入框边框上的产品名)。
+# required=False 的锚点缺失只提示,不算失败;全都找不到才是结构真的变了。
NAME_SITES = [
- ("name_title", 'children:"Claude Code"', lambda n: f'children:"{n}"', "启动/面板标题"),
- ("name_border", '("Claude Code")', lambda n: f'("{n}")', "输入框边框(展开)"),
- ("name_border_compact", '(" Claude Code ")', lambda n: f'(" {n} ")', "输入框边框(紧凑)"),
- ("name_header", '["Claude Code"," "]', lambda n: f'["{n}"," "]', "头部显示"),
+ ("name_title", 'children:"Claude Code"', lambda n: f'children:"{n}"', "启动/面板标题", True),
+ ("name_border", '("Claude Code")', lambda n: f'("{n}")', "输入框边框(展开)", False),
+ ("name_border_compact", '(" Claude Code ")', lambda n: f'(" {n} ")', "输入框边框(紧凑)", False),
+ ("name_header", '["Claude Code"," "]', lambda n: f'["{n}"," "]', "头部显示", False),
]
@@ -77,8 +88,11 @@ def validate_name(raw: str) -> str:
return " " * (pad // 2) + raw + " " * (pad - pad // 2)
-def validate_version(raw: str) -> str:
- """校验版本显示串(1-VERSION_MAX 个字符);不合法抛 ValueError,返回原串。
+def validate_version(raw: str, max_len: int = VERSION_MAX) -> str:
+ """校验版本显示串(1-max_len 个字符);不合法抛 ValueError,返回原串。
+
+ max_len=None 用于改写内部版本常量的场景: 那时长度由真实版本决定
+ (必须等长),而不是由 ["v",X] 显示位的宽度决定。
和名字一样受 JS 语法约束(见 _js_bad_chars)。补齐到各显示位的实际
可用宽度在 _versions 里做,这里只做字符集与长度上限校验。
@@ -90,8 +104,8 @@ def validate_version(raw: str) -> str:
raise ValueError(
f"display.version 含不支持的字符 {bad[0]!r}: 只允许可打印 ASCII,"
"且不能是引号/反斜杠/反引号/$(会破坏二进制内嵌的 JS)")
- if len(raw) > VERSION_MAX:
- raise ValueError(f"display.version 超过 {VERSION_MAX} 字符({len(raw)}): {raw!r}")
+ if max_len is not None and len(raw) > max_len:
+ raise ValueError(f"display.version 超过 {max_len} 字符({len(raw)}): {raw!r}")
return raw
_CONTENT = r'((?:\\u[0-9A-Fa-f]{4}|[^"\\])*)'
@@ -104,6 +118,24 @@ def validate_version(raw: str) -> str:
r'"?(default|look-left|look-right|arms-up)"?:"' + _CONTENT + r'"'
)
_SLOT_NAMES = ("r1L", "r1E", "r1R", "r2L", "r2R")
+# 槽位按显示行分组: 设计稿按整行给,边界由二进制现场决定(见 glyphpack.pack_row)
+_ICON_ROWS = (("r1L", "r1E", "r1R"), ("r2L", "r2R"))
+
+
+def _layout_sig(slot_contents: dict) -> str:
+ """探测到的槽位列数,如 'r1L=2 r1E=6 r1R=0 | r2L=2 r2R=2'。"""
+ return " | ".join(
+ " ".join(f"{s}={len(glyphpack.tokenize(slot_contents[s]))}" for s in row)
+ for row in _ICON_ROWS)
+
+
+def _report_layouts(layouts, report):
+ """把探测到的槽位布局写进报告(相同布局的变体合并成一行)。"""
+ grouped = {}
+ for variant, sig in layouts:
+ grouped.setdefault(sig, []).append(variant)
+ for sig, variants in grouped.items():
+ report.append(f"槽位布局 {'/'.join(variants)}: {sig}")
# ---------- JS 源码扫描辅助 ----------
@@ -209,19 +241,24 @@ def _icon_table(data, design, patches, report, problems):
# 位置化重建: 逐个变体按正则命中位置替换槽位,
# 同一原型槽位内容在不同变体里可以映射到不同图案
- new_parts, last = [], 0
+ new_parts, last, layouts = [], 0, []
for m in slot_matches:
variant = m.group(1)
dslots = design.get("slots", {}).get(variant)
if not dslots:
problems.append(f"设计稿缺少变体 [{variant}] 的槽位定义")
continue
+ old_contents = dict(zip(_SLOT_NAMES, m.groups()[1:]))
+ layouts.append((variant, _layout_sig(old_contents)))
+ packed = dict(old_contents)
try:
- packed = [
- glyphpack.pack(glyphpack.tokenize(old_c), dslots[slot_name])
- if slot_name in dslots else old_c
- for slot_name, old_c in zip(_SLOT_NAMES, m.groups()[1:])
- ]
+ for row_slots in _ICON_ROWS:
+ if not all(s in dslots for s in row_slots):
+ continue
+ # 设计稿按整行给,现场按二进制的槽位边界切回去
+ packed.update(zip(row_slots, glyphpack.pack_row(
+ [(s, old_contents[s]) for s in row_slots],
+ "".join(dslots[s] for s in row_slots))))
except ValueError as e:
problems.append(f"设计稿与槽位不匹配({variant}): {e}")
continue
@@ -229,12 +266,12 @@ def _icon_table(data, design, patches, report, problems):
new_parts.append(table1[last:m.start()])
new_parts.append(
prefix
- + f'r1L:"{packed[0]}",r1E:"{packed[1]}",r1R:"{packed[2]}",'
- + f'r2L:"{packed[3]}",r2R:"{packed[4]}"' + "}"
+ + ",".join(f'{s}:"{packed[s]}"' for s in _SLOT_NAMES) + "}"
)
last = m.end()
if problems:
return
+ _report_layouts(layouts, report)
new_table1 = "".join(new_parts) + table1[last:]
# 底脚表(N2p): 所有变体用同一设计,全局内容替换即可
@@ -384,9 +421,9 @@ def _icon_html(data, design, patches, report, problems):
report.append(f"icon_html @ {offs[0]:,}: HTML 页 logo 模板 × {len(offs)}")
-def _names(data, display_name, patches, report, problems):
+def _names(data, display_name, patches, report, problems, warnings):
offsets = {}
- for entry, old_s, make_new, where in NAME_SITES:
+ for entry, old_s, make_new, where, required in NAME_SITES:
old_b = old_s.encode("ascii")
new_b = make_new(display_name).encode("ascii")
if len(old_b) != len(new_b):
@@ -395,7 +432,8 @@ def _names(data, display_name, patches, report, problems):
f"{display_name!r}")
offs = find_all(data, old_b)
if not offs:
- problems.append(f"名字锚点未找到: {old_s}(可能已打过补丁或结构变化)")
+ msg = f"名字锚点未找到: {old_s}(可能已打过补丁或结构变化)"
+ (problems if required else warnings).append(msg)
continue
patches.append({
"name": entry, "cat": "name",
@@ -405,6 +443,8 @@ def _names(data, display_name, patches, report, problems):
})
report.append(f"{entry} @ {offs[0]:,}: {where} × {len(offs)}")
offsets[entry] = offs
+ if not offsets:
+ problems.append("所有名字锚点都未找到 —— 该版本结构变了,不要猜")
return offsets
@@ -430,14 +470,27 @@ def grab(kind, pos):
if not found:
problems.append("版本号显示位未找到(名字锚点附近没有 [\"v\",变量] / `v${变量}`)")
return
+
+ # 显示位容量取决于该版本压缩后的变量名长度(["v",ms] 比 ["v",c3e] 窄一格),
+ # 因此按二进制现场探测而非用固定上限。容量必须先按最窄位统一校验:
+ # 逐个跳过放不下的位会让一部分显示位是假版本、另一部分是真版本。
+ caps = {old_b: len(old_b) - (4 if kind == "arr" else 2)
+ for old_b, (kind, _) in found.items()}
+ tightest = min(caps, key=caps.get)
+ # 模板位 `v${x}` 渲染时前面还有一个空格,占一格
+ budget = min(caps[b] - (1 if found[b][0] == "tpl" else 0) for b in caps)
+ report.append(
+ f"版本位容量(自动探测): 最多 {budget} 字符(最窄位 {tightest.decode()})")
+ if len(display_version) > budget:
+ problems.append(
+ f"版本号 {display_version!r}({len(display_version)} 字符)放不下: "
+ f"本版本最窄显示位 {tightest.decode()} 只有 {budget} 字符 —— "
+ f"改短 display.version,或用 real_version 保持真实版本")
+ return
+
for old_b, (kind, offs) in sorted(found.items()):
- inner = len(old_b) - (4 if kind == "arr" else 2)
+ inner = caps[old_b]
v = (" " + display_version) if kind == "tpl" else display_version
- if len(v) > inner:
- problems.append(
- f"版本号 {display_version!r} 放不下显示位 {old_b.decode()}(最多 {inner} 字符)"
- )
- continue
v = v.ljust(inner, " ")
new_b = (b'["' + v.encode() + b'"]') if kind == "arr" else (b'`' + v.encode() + b'`')
cnt = data.count(old_b)
@@ -523,9 +576,270 @@ def _theme_color(data, color, patches, report, problems):
report.append(f"theme_color @ {offs[0]:,}: {prefix} → {packed} × {len(offs)}")
+def _pool_record(mapping, conflicts, old_vis, new_vis, where):
+ """登记「池里这条内容 → 换成什么」。池按内容去重,一条只能有一个目标。"""
+ if not old_vis or old_vis == new_vis:
+ return
+ prev = mapping.get(old_vis)
+ if prev is None:
+ mapping[old_vis] = (new_vis, [where])
+ return
+ if prev[0] != new_vis:
+ conflicts.setdefault(old_vis, {prev[0]: "/".join(prev[1])})[new_vis] = where
+ return
+ prev[1].append(where) # 多个槽位共用同一条,记全便于人工核对
+
+
+def _pool_icon_map(data, design, problems, warnings):
+ """从源码 logo 表学到「当前可见内容 → 设计稿新内容」,供常量池替换用。
+
+ 源码文本虽然不驱动 UI,但它保留了「哪个变体的哪个槽位是什么内容」这层
+ 语义;常量池只有一堆去重后的字符串,没有这层信息。所以用源码表做地图,
+ 把补丁落到池上。
+ """
+ p = data.find(_LOGO_ANCHOR)
+ if p < 0:
+ problems.append('logo 像素表锚点未找到(r1L:" ...")——版本结构可能变了')
+ return None, [], {}
+ start = data.rfind(b'{default:', 0, p)
+ if start < 0:
+ problems.append("logo 表起点 {default: 未找到")
+ return None, [], {}
+ try:
+ end1 = match_brace(data, start)
+ nstart = data.find(b'{', end1)
+ nend = match_brace(data, nstart)
+ except ValueError as e:
+ problems.append(f"logo 表括号解析失败: {e}")
+ return None, [], {}
+ table1 = data[start:end1].decode("ascii")
+ table2 = data[nstart:nend].decode("ascii")
+
+ mapping, conflicts, layouts, default_vis = {}, {}, [], {}
+ for m in _SLOT_RE.finditer(table1):
+ variant = m.group(1)
+ dslots = design.get("slots", {}).get(variant)
+ if not dslots:
+ problems.append(f"设计稿缺少变体 [{variant}] 的槽位定义")
+ continue
+ old_vis = {s: glyphpack.decode_escapes(c)
+ for s, c in zip(_SLOT_NAMES, m.groups()[1:])}
+ if variant == "default":
+ default_vis = dict(old_vis)
+ layouts.append((variant, _layout_sig(dict(zip(_SLOT_NAMES, m.groups()[1:])))))
+ for row_slots in _ICON_ROWS:
+ if not all(s in dslots for s in row_slots):
+ continue
+ try:
+ parts = glyphpack.split_row(
+ [len(old_vis[s]) for s in row_slots],
+ "".join(dslots[s] for s in row_slots), row_slots)
+ except ValueError as e:
+ problems.append(f"设计稿与槽位不匹配({variant}): {e}")
+ continue
+ for s, new in zip(row_slots, parts):
+ _pool_record(mapping, conflicts, old_vis[s], new, f"{variant}.{s}")
+
+ n2p = design.get("n2p", {}).get("row")
+ if n2p:
+ for m in _ROW_RE.finditer(table2):
+ _pool_record(mapping, conflicts, glyphpack.decode_escapes(m.group(2)),
+ n2p, f"n2p.{m.group(1)}")
+
+ mid = design.get("mid", {}).get("row")
+ if mid:
+ _pool_record(mapping, conflicts, glyphpack.decode_escapes(_MID_CONTENT),
+ mid, "mid.row")
+ feet = design.get("feet", {}).get("row")
+ if feet:
+ # 小 logo 与大 logo 的第三行是两个不同常量,设计稿的 feet 都适用
+ _pool_record(mapping, conflicts, glyphpack.decode_escapes(_FEET_CONTENT),
+ feet, "feet.row(小 logo)")
+ _pool_record(mapping, conflicts, _FEET_BIG_VISIBLE, feet, "feet.row(大 logo)")
+
+ for old_vis, targets in conflicts.items():
+ problems.append(
+ f"常量池按内容去重,{old_vis!r} 只有一条,但设计稿要求它同时变成 "
+ + " / ".join(f"{n!r}({w})" for n, w in targets.items())
+ + " —— 让这些槽位用同一个图案,或接受其中之一")
+ return (None if conflicts else mapping), layouts, default_vis
+
+
+def pool_art(design):
+ """设计稿渲染成三行(default 变体),给预览用。"""
+ slots = design.get("slots", {}).get("default", {})
+ if not slots:
+ return None
+ row1 = "".join(slots.get(s, "") for s in ("r1L", "r1E", "r1R"))
+ row2 = (slots.get("r2L", "") + design.get("mid", {}).get("row", "")
+ + slots.get("r2R", ""))
+ return [row1, row2, design.get("feet", {}).get("row", "")]
+
+
+def _pool_html(data, design, warnings):
+ """HTML 登录/错误页的三行 logo 在池里是一条含换行的整串,不在 logo 条目区。"""
+ html = design.get("html", {})
+ if not (html.get("row1") and html.get("row2") and html.get("row3")):
+ return {}
+ rows_new = "\n".join((html["row1"], html["row2"], html["row3"]))
+ # 老串由当前 logo 拼出来,不硬编码某个版本的图案
+ m = _SLOT_RE.search(data[data.rfind(b'{default:', 0, data.find(_LOGO_ANCHOR)):]
+ .decode("ascii", "replace"))
+ if not m:
+ return {}
+ r1L, r1E, r1R, r2L, r2R = (glyphpack.decode_escapes(c) for c in m.groups()[1:])
+ mid = glyphpack.decode_escapes(_MID_CONTENT)
+ row1, row2 = r1L + r1E + r1R, r2L + mid + r2R
+ for feet in (_FEET_BIG_VISIBLE, glyphpack.decode_escapes(_FEET_CONTENT)):
+ old = "\n".join((row1, row2, " " + feet))
+ if bytecode.unique(data, old):
+ if len(old) != len(rows_new):
+ warnings.append(
+ f"HTML 页 logo 共 {len(old)} 字符,设计稿 html 行合计 "
+ f"{len(rows_new)} 字符,跳过")
+ return {}
+ return {old: (rows_new, "html")}
+ warnings.append("HTML 页 logo 未在常量池找到(非启动画面,可忽略)")
+ return {}
+
+
+def _pool_emit(data, mapping, region, cat, patches, report, problems, prefix=""):
+ """把「内容 → 新内容」映射落成 offset+sha1 的池条目补丁。"""
+ done = 0
+ for old_vis, (new_vis, where) in sorted(mapping.items()):
+ sites = where if isinstance(where, list) else [where]
+ label = "/".join(sites)
+ hits = bytecode.find(data, old_vis, region)
+ if not hits:
+ problems.append(f"常量池里找不到 {old_vis!r}({label})")
+ continue
+ if len(hits) > 1:
+ problems.append(
+ f"{old_vis!r}({label}) 在常量池命中 {len(hits)} 条,无法确定改哪条")
+ continue
+ entry = hits[0]
+ try:
+ new_b = bytecode.replacement(entry, new_vis)
+ except ValueError as e:
+ problems.append(f"{label}: {e}")
+ continue
+ old_b = data[entry.chars:entry.chars + entry.nbytes]
+ if old_b == new_b:
+ continue
+ patches.append({
+ "name": f"{prefix}{sites[0].replace('.', '_').replace(' ', '_')}",
+ "cat": cat,
+ # 只写替换成什么,不写原内容: 定义文件是 offset+sha1 模式,
+ # 原文一律 patch 时从用户自己的二进制现读现验(与 defio 的约定一致)
+ "desc": f"{label} → {new_vis!r}",
+ "offset": entry.chars,
+ "sha1": hashlib.sha1(old_b).hexdigest(),
+ "enc": "latin-1" if entry.eight else "utf-16-le",
+ "new": new_b,
+ "expected": 1,
+ })
+ done += 1
+ if done:
+ report.append(f"常量池 [{cat}]: {done} 条条目")
+ return done
+
+
+def _pool_channel(data, display_name, display_version, design, theme_color,
+ real_version, rewrite_real_version,
+ patches, report, problems, warnings):
+ """bytecode 构建: 补丁全部打到常量池。"""
+ region = None
+ if design:
+ mapping, layouts, _default_vis = _pool_icon_map(
+ data, design, problems, warnings)
+ _report_layouts(layouts, report)
+ if mapping is not None:
+ region = bytecode.region_for(
+ data, [s for s in mapping if len(s) >= 5]) or None
+ _pool_emit(data, mapping, region, "icon", patches, report, problems,
+ prefix="icon_")
+ _pool_emit(data, _pool_html(data, design, warnings), None, "icon",
+ patches, report, problems, prefix="icon_")
+ _pool_leftover_art(data, mapping, region, report, warnings)
+
+ name_map = {}
+ for old_vis, make_new, where in _POOL_NAME_SITES:
+ if bytecode.unique(data, old_vis):
+ name_map[old_vis] = (make_new(display_name), where)
+ else:
+ warnings.append(f"常量池里没有 {old_vis!r},跳过({where})")
+ if not name_map:
+ problems.append("常量池里一个产品名条目都没有 —— 该版本结构变了,不要猜")
+ _pool_emit(data, name_map, None, "name", patches, report, problems)
+
+ _pool_version(data, display_version, real_version, rewrite_real_version,
+ patches, report, problems, warnings)
+
+ if theme_color:
+ try:
+ packed = pack_rgb(*theme_color)
+ except ValueError as e:
+ problems.append(f"主题色格式失败: {e}")
+ else:
+ color_map = {}
+ for site in _COLOR_SITES:
+ old_rgb = site.split('"', 1)[1].rstrip('"')
+ if bytecode.unique(data, old_rgb):
+ color_map[old_rgb] = (packed, site.split(":", 1)[0])
+ if not color_map:
+ report.append("theme_color: 常量池里没有已知配色条目,跳过")
+ _pool_emit(data, color_map, None, "color", patches, report, problems,
+ prefix="color_")
+
+
+def _pool_version(data, display_version, real_version, rewrite,
+ patches, report, problems, warnings):
+ """bytecode 构建上启动画面的版本号与 --version 共用同一条常量。"""
+ if not display_version:
+ return
+ if not real_version:
+ warnings.append("未探测到真实版本,跳过版本显示位")
+ return
+ entry = bytecode.unique(data, real_version)
+ if entry is None:
+ warnings.append(f"常量池里找不到版本常量 {real_version!r},跳过版本显示位")
+ return
+ if not rewrite:
+ warnings.append(
+ f"启动画面的版本号与 --version 共用同一条常量({real_version!r} "
+ f"@ {entry.chars}),改它会连内部版本一起改 —— 已跳过。"
+ "确要改: 配置 [display] rewrite_real_version = true")
+ return
+ if len(display_version) != len(real_version):
+ problems.append(
+ f"改内部版本常量需等长: 真实版本 {real_version!r}({len(real_version)} 字符),"
+ f"display.version {display_version!r}({len(display_version)} 字符)")
+ return
+ _pool_emit(data, {real_version: (display_version, "version")}, None,
+ "version", patches, report, problems)
+ warnings.append("已改写内部版本常量: --version 与更新检查都会看到假版本")
+
+
+def _pool_leftover_art(data, mapping, region, report, warnings):
+ """报出区间内没被设计稿覆盖到的图案条目,让上游新增的图案不至于默默漏掉。"""
+ if not region:
+ return
+ leftover = [e.text for e in bytecode.art_entries_in(data, *region)
+ if e.text not in mapping and len(e.text) >= 4]
+ if leftover:
+ warnings.append("常量池里还有未被设计稿覆盖的图案条目: "
+ + " ".join(repr(t) for t in leftover[:6]))
+
+
def build_patches(data: bytes, display_name: str, display_version: str, design: dict,
- theme_color=None):
- """返回 (patches, report, problems)。
+ theme_color=None, real_version=None, rewrite_real_version=False):
+ """返回 (patches, report, problems, warnings)。
+
+ problems 是致命问题(草稿不可直接用),warnings 只是提示。
+
+ bytecode 构建走常量池通道: 运行时读的是 bytecode 常量池,二进制里那份
+ JS 源码文本已经不驱动 UI —— 改它能通过一切计数校验,屏幕上却毫无变化。
+ 老版本(非 bytecode)仍走源码文本通道。
design=None → 不动图标(保持原图标)
display_version=None → 不动版本位(显示真实版本)
@@ -533,17 +847,24 @@ def build_patches(data: bytes, display_name: str, display_version: str, design:
patches: list = []
report: list = []
problems: list = []
+ warnings: list = []
+ if bytecode.is_bytecode_build(data):
+ report.append("bytecode 构建: 补丁打到常量池(源码文本不驱动 UI)")
+ _pool_channel(data, display_name, display_version, design, theme_color,
+ real_version, rewrite_real_version,
+ patches, report, problems, warnings)
+ return patches, report, problems, warnings
+
if design:
_icon_table(data, design, patches, report, problems)
_icon_feet(data, design, patches, report, problems)
_icon_mid(data, design, patches, report, problems)
_icon_html(data, design, patches, report, problems)
- name_offsets = _names(data, display_name, patches, report, problems)
- if not problems:
- if display_version:
- _versions(data, name_offsets, display_version, patches, report, problems)
- _theme_color(data, theme_color, patches, report, problems)
- return patches, report, problems
+ name_offsets = _names(data, display_name, patches, report, problems, warnings)
+ if display_version and name_offsets:
+ _versions(data, name_offsets, display_version, patches, report, problems)
+ _theme_color(data, theme_color, patches, report, problems)
+ return patches, report, problems, warnings
def collect_contexts(data: bytes, limit_per_kind: int = 3) -> list:
diff --git a/patcher/glyphpack.py b/patcher/glyphpack.py
index e6e6e92..d6d5162 100644
--- a/patcher/glyphpack.py
+++ b/patcher/glyphpack.py
@@ -38,6 +38,38 @@ def decode_escapes(s: str) -> str:
return ESC_RE.sub(lambda m: chr(int(m.group(1), 16)), s)
+def split_row(widths, glyphs: str, labels=None) -> list:
+ """把设计稿的一整行字符,按给定的各槽位列数切开。
+
+ 上游会在版本之间挪动字符在槽位间的边界(如 r1E 5→6 列、r1R 1→0 列)
+ 而整行列数不变。按整行接收设计稿再现场切分,设计稿就不必跟着每个版本改。
+ """
+ total = sum(widths)
+ if len(glyphs) != total:
+ names = labels or [f"#{i}" for i in range(len(widths))]
+ detail = " + ".join(f"{n}={w}" for n, w in zip(names, widths))
+ raise ValueError(
+ f"整行列数不符: 二进制槽位共 {total} 列({detail}),"
+ f"设计稿给了 {len(glyphs)} 列: {glyphs!r}")
+ out, i = [], 0
+ for w in widths:
+ out.append(glyphs[i:i + w])
+ i += w
+ return out
+
+
+def pack_row(slot_contents, glyphs: str) -> list:
+ """按二进制现有的槽位切分,把整行设计字符重新分配并打包成源码形式。
+
+ slot_contents: [(槽位名, 源码形式内容), ...],按显示顺序
+ 返回与之等长的新源码内容列表。
+ """
+ token_lists = [tokenize(c) for _, c in slot_contents]
+ parts = split_row([len(t) for t in token_lists], glyphs,
+ [name for name, _ in slot_contents])
+ return [pack(tokens, part) for tokens, part in zip(token_lists, parts)]
+
+
def pack(tokens, glyphs: str) -> str:
"""按原 token 形状,用设计稿的可见字符重新生成源码字符串。"""
chars = list(glyphs)
diff --git a/patcher/verify.py b/patcher/verify.py
index 2f18020..9df6aba 100644
--- a/patcher/verify.py
+++ b/patcher/verify.py
@@ -12,7 +12,7 @@
import subprocess
import time
-from . import discovery, glyphpack, ui
+from . import bytecode, discovery, glyphpack, ui
_PREVIEW_WINDOW = 4000 # logo 表附近查找 mid/feet 的半径
@@ -123,6 +123,44 @@ def _capture_pty(binary: str, seconds: int = 10) -> str:
return out.decode("utf-8", errors="replace")
+def _verify_pool(data: bytes, design, raw_name: str, padded: str) -> int:
+ """bytecode 构建: 只有常量池说了算,数源码文本会给出假阳性。
+
+ 这里用「至少命中一条」而不是「恰好一条」: 原始池按内容去重,但补丁是
+ 就地覆写,几个原本不同的条目被改成同一个图案后,内容就不再唯一了。
+ """
+ failures = 0
+ ui.step("图标预览(常量池)")
+ art = discovery.pool_art(design) if design else None
+ if art:
+ for line in art:
+ ui.info(" " + line)
+ for label, text in (("mid", design.get("mid", {}).get("row")),
+ ("feet", design.get("feet", {}).get("row")),
+ ("n2p", design.get("n2p", {}).get("row"))):
+ if not text:
+ continue
+ hits = bytecode.find(data, text)
+ (ui.ok if hits else ui.fail)(
+ f"设计稿 {label} {text!r} 在常量池: "
+ + (f"{len(hits)} 条 @ {hits[0].chars:,}" if hits else "未找到"))
+ if not hits:
+ failures += 1
+ else:
+ ui.info("未选择设计稿,跳过图标校验")
+
+ ui.step("显示内容计数(常量池)")
+ if raw_name:
+ hits = bytecode.find(data, padded)
+ (ui.ok if hits else ui.fail)(
+ f"显示名 {padded!r} 在常量池: "
+ + (f"{len(hits)} 条 @ {hits[0].chars:,}" if hits
+ else "未找到 —— 二进制可能没打补丁"))
+ if not hits:
+ failures += 1
+ return failures
+
+
def run(binary: str, cfg: dict, pty: bool = False) -> int:
data = pathlib.Path(binary).read_bytes()
display = cfg.get("display", {})
@@ -132,6 +170,7 @@ def run(binary: str, cfg: dict, pty: bool = False) -> int:
# 定位第三行小脚的期望内容(设计稿),给预览用
feet_expected = None
+ design = None
if not display.get("keep_icon"):
try:
from . import analyze
@@ -145,38 +184,45 @@ def run(binary: str, cfg: dict, pty: bool = False) -> int:
except Exception: # noqa: BLE001 — 预览是尽力而为,不因设计稿问题中断验证
pass
- ui.step("图标预览")
- _preview(data, feet_expected)
+ try:
+ padded = discovery.validate_name(raw_name) if raw_name else ""
+ except ValueError:
+ padded = raw_name
failures = 0
- ui.step("显示内容计数")
- if raw_name:
- # 锚定 children:"..." 上下文计数;裸子串对 'Code' 这类常见词会假阳性
- try:
- padded = discovery.validate_name(raw_name)
- except ValueError:
- padded = raw_name
- anchored = f'children:"{padded}"'.encode("ascii", errors="replace")
- cnt = data.count(anchored)
- (ui.ok if cnt else ui.fail)(f"显示名锚点 {anchored.decode()!r}: {cnt} 次")
- if cnt == 0:
- ui.warn("显示名未出现在标题显示位——二进制可能没打补丁")
- failures += 1
- if version:
- # 按补丁写入的形状计数: ["v9.99 "](数组位)/ ` v9.99 `(模板位)
- vb = re.escape(version.encode())
- cnt = len(re.findall(rb'\["' + vb + rb' *"\]', data)) \
- + len(re.findall(rb'` ?' + vb + rb' *`', data))
- (ui.ok if cnt else ui.fail)(f"显示版本 {version!r}: {cnt} 处显示位")
- if cnt == 0:
- failures += 1
- elif real_version:
- ui.info("已选择显示真实版本,跳过版本计数")
+ if bytecode.is_bytecode_build(data):
+ failures += _verify_pool(data, design, raw_name, padded)
+ if version:
+ ui.info("bytecode 构建上版本显示位与内部版本共用一条常量,跳过版本计数")
+ else:
+ ui.step("图标预览")
+ _preview(data, feet_expected)
+ ui.step("显示内容计数")
+ if raw_name:
+ # 锚定 children:"..." 上下文计数;裸子串对 'Code' 这类常见词会假阳性
+ anchored = f'children:"{padded}"'.encode("ascii", errors="replace")
+ cnt = data.count(anchored)
+ (ui.ok if cnt else ui.fail)(f"显示名锚点 {anchored.decode()!r}: {cnt} 次")
+ if cnt == 0:
+ ui.warn("显示名未出现在标题显示位——二进制可能没打补丁")
+ failures += 1
+ if version:
+ # 按补丁写入的形状计数: ["v9.99 "](数组位)/ ` v9.99 `(模板位)
+ vb = re.escape(version.encode())
+ cnt = len(re.findall(rb'\["' + vb + rb' *"\]', data)) \
+ + len(re.findall(rb'` ?' + vb + rb' *`', data))
+ (ui.ok if cnt else ui.fail)(f"显示版本 {version!r}: {cnt} 处显示位")
+ if cnt == 0:
+ failures += 1
+ elif real_version:
+ ui.info("已选择显示真实版本,跳过版本计数")
if failures:
ui.fail("显示内容计数未通过")
return 1
- ui.step("运行 --version(证明内部版本未受影响)")
+ rewrote = bool(display.get("rewrite_real_version")) and version
+ ui.step("运行 --version" + ("(已选择改写内部版本,这里会是假版本)" if rewrote
+ else "(证明内部版本未受影响)"))
try:
r = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=60)
out = (r.stdout or r.stderr).strip()
diff --git a/patches/2.1.261.toml b/patches/2.1.261.toml
new file mode 100644
index 0000000..7020a05
--- /dev/null
+++ b/patches/2.1.261.toml
@@ -0,0 +1,218 @@
+# 由 patcher analyze 生成(锚点全部发现时 verified 自动为 true,可直接用)
+[meta]
+target_version = '2.1.261'
+design = 'octopus.toml'
+display_name = ' Loop Code '
+display_version = 'v9.9'
+theme_color = '2E9BFF'
+verified = true
+
+[[patch]]
+name = 'icon_default_r1L'
+cat = 'icon'
+desc = "default.r1L/look-left.r1L/look-right.r1L → ' ▗'"
+sha1 = '7fa82235158786192df2fa8f545b1e7f632e385b'
+offset = 71887832
+enc = 'utf-16-le'
+new = ' ▗'
+expected = 1
+
+[[patch]]
+name = 'icon_n2p_default'
+cat = 'icon'
+desc = "n2p.default/n2p.arms-up → ' ╵ ╵ '"
+sha1 = '9ffe32f30954ab90c14c360327f9813230b17e46'
+offset = 71887976
+enc = 'utf-16-le'
+new = ' ╵ ╵ '
+expected = 1
+
+[[patch]]
+name = 'icon_n2p_look-left'
+cat = 'icon'
+desc = "n2p.look-left → ' ╵ ╵ '"
+sha1 = '6d79748e9a4ad9b75ca91ea11604bc828b60fdd3'
+offset = 71888000
+enc = 'utf-16-le'
+new = ' ╵ ╵ '
+expected = 1
+
+[[patch]]
+name = 'icon_arms-up_r2L'
+cat = 'icon'
+desc = "arms-up.r2L → ' ▟'"
+sha1 = '62873b7cd1b4c3f27df342248377a6b72f46513a'
+offset = 71887952
+enc = 'utf-16-le'
+new = ' ▟'
+expected = 1
+
+[[patch]]
+name = 'icon_n2p_look-right'
+cat = 'icon'
+desc = "n2p.look-right → ' ╵ ╵ '"
+sha1 = 'd9525d308b500e6aad7c51039fce8407b57d24af'
+offset = 71888024
+enc = 'utf-16-le'
+new = ' ╵ ╵ '
+expected = 1
+
+[[patch]]
+name = 'icon_arms-up_r1R'
+cat = 'icon'
+desc = "arms-up.r1R → '▖'"
+sha1 = '98c8b0c548da4389251a838b8b7437b001c69c35'
+offset = 71887940
+enc = 'utf-16-le'
+new = '▖'
+expected = 1
+
+[[patch]]
+name = 'icon_default_r2R'
+cat = 'icon'
+desc = "default.r2R/look-left.r2R/look-right.r2R → '█▙'"
+sha1 = '24708067485053e75d9b350ae2bf4a7066b726bc'
+offset = 71887876
+enc = 'utf-16-le'
+new = '█▙'
+expected = 1
+
+[[patch]]
+name = 'icon_mid_row'
+cat = 'icon'
+desc = "mid.row → '█◉█◉█'"
+sha1 = '1a2851d7d77422cda11d9d5bf19eebcca6039aab'
+offset = 71888152
+enc = 'utf-16-le'
+new = '█◉█◉█'
+expected = 1
+
+[[patch]]
+name = 'icon_arms-up_r2R'
+cat = 'icon'
+desc = "arms-up.r2R → '▙ '"
+sha1 = 'db47383ab40595fe21010688dd8fccdde7075620'
+offset = 71887964
+enc = 'utf-16-le'
+new = '▙ '
+expected = 1
+
+[[patch]]
+name = 'icon_look-right_r1E'
+cat = 'icon'
+desc = "look-right.r1E → '▄▄▄▄▖ '"
+sha1 = '1f680c766f483b024aba2999b6a9ba448a2473d4'
+offset = 71887908
+enc = 'utf-16-le'
+new = '▄▄▄▄▖ '
+expected = 1
+
+[[patch]]
+name = 'icon_arms-up_r1L'
+cat = 'icon'
+desc = "arms-up.r1L → '▗ '"
+sha1 = '48b2f79ecbac8076266aad73d011a1bb70fd1660'
+offset = 71887928
+enc = 'utf-16-le'
+new = '▗ '
+expected = 1
+
+[[patch]]
+name = 'icon_feet_row(小_logo)'
+cat = 'icon'
+desc = "feet.row(小 logo) → '╵╵ ╵╵'"
+sha1 = '2b8f084752a0a8d27bb33226ae68773c5f80bd80'
+offset = 71888216
+enc = 'utf-16-le'
+new = '╵╵ ╵╵'
+expected = 1
+
+[[patch]]
+name = 'icon_default_r1E'
+cat = 'icon'
+desc = "default.r1E/arms-up.r1E → '▄▄▄▄▖ '"
+sha1 = 'cb894373e2166bcd43b58d442f9e33129844a55d'
+offset = 71887844
+enc = 'utf-16-le'
+new = '▄▄▄▄▖ '
+expected = 1
+
+[[patch]]
+name = 'icon_default_r2L'
+cat = 'icon'
+desc = "default.r2L/look-left.r2L/look-right.r2L → '▟█'"
+sha1 = 'c40266bc3aa12427324e268d6734ee3e3de38cd2'
+offset = 71887864
+enc = 'utf-16-le'
+new = '▟█'
+expected = 1
+
+[[patch]]
+name = 'icon_feet_row(大_logo)'
+cat = 'icon'
+desc = "feet.row(大 logo) → '╵╵ ╵╵'"
+sha1 = '3c16187c7b50d914d912f7e98cfc0b28772a1749'
+offset = 71888172
+enc = 'utf-16-le'
+new = '╵╵ ╵╵'
+expected = 1
+
+[[patch]]
+name = 'icon_look-left_r1E'
+cat = 'icon'
+desc = "look-left.r1E → '▄▄▄▄▖ '"
+sha1 = '7cfcacc8da196eeda6b6585129fdbde82d1e6109'
+offset = 71887888
+enc = 'utf-16-le'
+new = '▄▄▄▄▖ '
+expected = 1
+
+[[patch]]
+name = 'icon_html'
+cat = 'icon'
+desc = "html → ' ▗▄▄▄▄▖ \\n▟██◉█◉██▙\\n ╵╵ ╵╵'"
+sha1 = 'd07dc94eeba8462368684e333293ef95317c7d5f'
+offset = 78321248
+enc = 'utf-16-le'
+new = " ▗▄▄▄▄▖ \n▟██◉█◉██▙\n ╵╵ ╵╵"
+expected = 1
+
+[[patch]]
+name = 'name'
+cat = 'name'
+desc = "name → ' Loop Code '"
+sha1 = '61f2de21bc4ec3ea02fbbbcc718e953a2692d5f8'
+offset = 70649376
+enc = 'latin-1'
+new = ' Loop Code '
+expected = 1
+
+[[patch]]
+name = 'name_welcome'
+cat = 'name'
+desc = "name_welcome → 'Welcome to Loop Code '"
+sha1 = '5dbda879ebc1ae7b03c10b815c6c0ab53587f5e3'
+offset = 72548348
+enc = 'latin-1'
+new = 'Welcome to Loop Code '
+expected = 1
+
+[[patch]]
+name = 'color_clawd_body'
+cat = 'color'
+desc = "clawd_body → 'rgb(46,155,255)'"
+sha1 = 'e3161cd3c031125bdbed6c0a3599f3a040036d88'
+offset = 76318392
+enc = 'latin-1'
+new = 'rgb(46,155,255)'
+expected = 1
+
+[[patch]]
+name = 'color_claude'
+cat = 'color'
+desc = "claude → 'rgb(46,155,255)'"
+sha1 = '9eedcf8b3b272bea5582b21be49541e5f2713caf'
+offset = 76319848
+enc = 'latin-1'
+new = 'rgb(46,155,255)'
+expected = 1
diff --git a/pyproject.toml b/pyproject.toml
index 4b66cfa..bd166cf 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ccskin"
-version = "0.2.0"
+version = "0.3.0"
description = "Display-layer theming for the Claude Code CLI binary (icon, name, version, theme color)"
readme = "README.md"
requires-python = ">=3.9"
diff --git a/tests/test_core.py b/tests/test_core.py
index 2558fea..8607033 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -3,7 +3,9 @@
合成 bundle 只复刻结构(槽位形状/锚点模式),槽位内容是我们自己编的,
不包含任何 Claude Code 的代码。运行: python3 -m unittest discover -s tests -v
"""
+import hashlib
import os
+import struct
import pathlib
import sys
import tempfile
@@ -11,22 +13,46 @@
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
-from patcher import defio, discovery, glyphpack, locate, patch as patch_mod, tomlmini
+from patcher import (bytecode, defio, discovery, glyphpack, locate,
+ patch as patch_mod, tomlmini)
+
+
+def esc(s: str) -> str:
+ """可见字符 → 二进制里的源码形式(空格是字面位,其余走 \\uXXXX 转义)。"""
+ return "".join(ch if ch == " " else chr(92) + "u%04X" % ord(ch) for ch in s)
# ---------- 合成 bundle(结构仿真,内容自编) ----------
-LOGO_TABLE = (
- '{default:{r1L:" \\u2590",r1E:"\\u2591\\u2592\\u2593\\u2588\\u2591",r1R:"\\u258C",'
- 'r2L:"\\u259D\\u259C",r2R:"\\u259B\\u2598"},'
- '"look-left":{r1L:" \\u2590",r1E:"\\u2591\\u2592\\u2593\\u2588\\u2591",r1R:"\\u258C",'
- 'r2L:"\\u259D\\u259C",r2R:"\\u259B\\u2598"},'
- '"look-right":{r1L:" \\u2590",r1E:"\\u2591\\u2592\\u2593\\u2588\\u2591",r1R:"\\u258C",'
- 'r2L:"\\u259D\\u259C",r2R:"\\u259B\\u2598"},'
- '"arms-up":{r1L:"\\u2597\\u259F",r1E:"\\u2591\\u2592\\u2593\\u2588\\u2591",'
- 'r1R:"\\u2599\\u2596",r2L:" \\u259C",r2R:"\\u259B "}},'
- 'N2p={default:" \\u2597 \\u2596 ","look-left":" \\u2598 \\u2598 ",'
- '"look-right":" \\u259D \\u259D ","arms-up":" \\u2597 \\u2596 "}'
-)
+# 同一张图的两种槽位切分。上游在 2.1.261 把 r1E 从 5 列加到 6 列、r1R 从 1 列减到 0,
+# 整行列数不变 —— 设计稿不该因此改动。
+ROW1 = {v: " ▐░▒▓█░▌" for v in ("default", "look-left", "look-right")}
+ROW1["arms-up"] = "▗▟░▒▓█░▙▖"
+ROW2 = {v: "▝▜▛▘" for v in ("default", "look-left", "look-right")}
+ROW2["arms-up"] = " ▜▛ "
+N2P = {"default": " ▗ ▖ ", "look-left": " ▘ ▘ ",
+ "look-right": " ▝ ▝ ", "arms-up": " ▗ ▖ "}
+NARROW = {v: (2, 5, 1) for v in ROW1} | {"arms-up": (2, 5, 2)}
+WIDE = {v: (2, 6, 0) for v in ROW1} | {"arms-up": (2, 6, 1)}
+
+
+def make_logo_table(widths) -> str:
+ """按给定的槽位列数合成 logo 表(同一张图,不同的槽位边界)。"""
+ variants = []
+ for v, (a, b, c) in widths.items():
+ r1, r2 = ROW1[v], ROW2[v]
+ assert len(r1) == a + b + c, v
+ key = v if v == "default" else '"%s"' % v
+ variants.append(
+ '%s:{r1L:"%s",r1E:"%s",r1R:"%s",r2L:"%s",r2R:"%s"}'
+ % (key, esc(r1[:a]), esc(r1[a:a + b]), esc(r1[a + b:]),
+ esc(r2[:2]), esc(r2[2:])))
+ feet = ",".join('%s:"%s"' % (v if v == "default" else '"%s"' % v, esc(row))
+ for v, row in N2P.items())
+ return "{" + ",".join(variants) + "}," + "N2p={" + feet + "}"
+
+
+LOGO_TABLE = make_logo_table(NARROW)
+LOGO_TABLE_WIDE = make_logo_table(WIDE)
FEET = '"\\u2598\\u2598 \\u259D\\u259D"'
MID = 'clawd_background",children:"\\u2588\\u2588\\u2588\\u2588\\u2588"'
HTML_LOGO = '` \\u2590\\u259B\\u2588\\u2588\\u2588\\u259C\\u258C\n\\u259D\\u259C\\u2588\\u2588\\u2588\\u2588\\u2588\\u259B\\u2598\n \\u2598\\u2598 \\u259D\\u259D`;'
@@ -39,15 +65,15 @@
COLORS = 'claude:"rgb(215,119,87)";clawd_body:"rgb(215,119,87)";'
-def make_bundle() -> bytes:
+def make_bundle(logo_table: str = None, names: str = None) -> bytes:
parts = [
os.urandom(500),
- LOGO_TABLE.encode(),
+ (logo_table or LOGO_TABLE).encode(),
os.urandom(300),
FEET.encode(), os.urandom(100), FEET.encode(),
os.urandom(200), MID.encode(),
os.urandom(200), HTML_LOGO.encode(),
- os.urandom(300), NAMES.encode(),
+ os.urandom(300), (names or NAMES).encode(),
os.urandom(300), COLORS.encode(),
os.urandom(500),
]
@@ -59,7 +85,8 @@ def make_bundle() -> bytes:
"slots": {
v: {"r1L": " ▗", "r1E": "▄▄▄▄▖", "r1R": " ", "r2L": "▟█", "r2R": "█▙"}
for v in ("default", "look-left", "look-right")
- } | {"arms-up": {"r1L": "▗ ", "r1E": "▗▄▄▄▖", "r1R": " ▖", "r2L": " ▟", "r2R": "▙ "}},
+ # arms-up 的 r1E 与 default 共用同一条常量池条目,只能靠 r1L/r1R 表现举手
+} | {"arms-up": {"r1L": "▗ ", "r1E": "▄▄▄▄▖", "r1R": " ▖", "r2L": " ▟", "r2R": "▙ "}},
"mid": {"row": "█◉█◉█"},
"feet": {"row": "╵╵ ╵╵"},
"n2p": {"row": " ▄ ▄ "},
@@ -93,6 +120,31 @@ def test_decode_escapes(self):
self.assertEqual(glyphpack.decode_escapes(r"\u2597\u2596"), "▗▖")
+ def test_pack_row_splits_by_binary_slots(self):
+ slots = [("r1L", esc(" ▐")), ("r1E", esc("░▒")), ("r1R", "")]
+ self.assertEqual(
+ glyphpack.pack_row(slots, " ▗▄▖"),
+ [esc(" ▗"), esc("▄▖"), ""])
+
+ def test_pack_row_regroups_same_glyphs(self):
+ """同一行字符,槽位边界挪动后,打包拼起来必须完全一致。"""
+ narrow = [("r1L", esc(" ▐")), ("r1E", esc("░▒")), ("r1R", esc("▓"))]
+ wide = [("r1L", esc(" ▐")), ("r1E", esc("░▒▓")), ("r1R", "")]
+ row = " ▗▄▖▄"
+ self.assertEqual(
+ "".join(glyphpack.pack_row(narrow, row)),
+ "".join(glyphpack.pack_row(wide, row)))
+
+ def test_pack_row_reports_both_widths(self):
+ slots = [("r1L", esc(" ▐")), ("r1E", esc("░▒"))]
+ with self.assertRaises(ValueError) as cm:
+ glyphpack.pack_row(slots, "▗▄")
+ msg = str(cm.exception)
+ self.assertIn("共 4 列", msg)
+ self.assertIn("给了 2 列", msg)
+ self.assertIn("r1E=2", msg)
+
+
class TestColor(unittest.TestCase):
def test_pack_rgb_exact_length(self):
self.assertEqual(discovery.pack_rgb(215, 119, 87), "rgb(215,119,87)")
@@ -116,7 +168,7 @@ def test_parse_hex(self):
class TestDiscovery(unittest.TestCase):
def setUp(self):
self.data = make_bundle()
- self.patches, self.report, self.problems = discovery.build_patches(
+ self.patches, self.report, self.problems, self.warnings = discovery.build_patches(
self.data, " Loop Code ", "v9.99", DESIGN, theme_color=(46, 155, 255))
def test_no_problems(self):
@@ -143,7 +195,7 @@ def test_counts(self):
self.assertEqual(by_name["name_title"], 1)
def test_skip_icon_and_version(self):
- patches, _, problems = discovery.build_patches(
+ patches, _, problems, _w = discovery.build_patches(
self.data, " Loop Code ", None, None)
self.assertEqual(problems, [])
cats = {p["cat"] for p in patches}
@@ -151,10 +203,267 @@ def test_skip_icon_and_version(self):
self.assertNotIn("version", cats)
+# ---------- 合成 bytecode 常量池 ----------
+def pool_entry(text: str) -> bytes:
+ """按实测布局造一条常量池条目: len|flag + hash + chars + 4 字节对齐补 0。"""
+ eight = all(ord(c) < 256 for c in text)
+ chars = text.encode("latin-1") if eight else text.encode("utf-16-le")
+ head = struct.pack(" bytes:
+ return b"".join(pool_entry(t) for t in texts)
+
+
+def pool_strings(widths=NARROW, name="Claude Code", version="9.9.999"):
+ """合成 bundle 里应当出现在常量池里的全部字符串(与源码 logo 表一致)。"""
+ out = []
+ for v, (a, b, c) in widths.items():
+ r1, r2 = ROW1[v], ROW2[v]
+ out += [r1[:a], r1[a:a + b], r1[a + b:], r2[:2], r2[2:]]
+ out += list(N2P.values())
+ out += ["\u2588\u2588\u2588\u2588\u2588", "\u2598\u2598 \u259d\u259d", "\u259d\u259d \u259d\u259d",
+ name, "Welcome to " + name, version,
+ "rgb(215,119,87)", "rgb(255,153,51)"]
+ return [t for t in dict.fromkeys(out) if t]
+
+
+def make_bytecode_bundle(widths=NARROW, **kw) -> bytes:
+ """源码文本(给语义地图) + @bun @bytecode 标记 + 常量池(驱动 UI)。"""
+ return b"".join([
+ os.urandom(400),
+ make_logo_table(widths).encode(),
+ os.urandom(200), MID.encode(),
+ os.urandom(200), NAMES.encode(),
+ os.urandom(200), b"// @bun @bytecode\n",
+ os.urandom(200), make_pool(pool_strings(widths, **kw)),
+ os.urandom(400),
+ ])
+
+
+class TestBytecodePool(unittest.TestCase):
+ def setUp(self):
+ self.data = make_bytecode_bundle()
+
+ def test_detects_bytecode_build(self):
+ self.assertTrue(bytecode.is_bytecode_build(self.data))
+ self.assertFalse(bytecode.is_bytecode_build(make_bundle()))
+
+ def test_finds_utf16_and_8bit_entries(self):
+ u = bytecode.unique(self.data, "\u2588\u2588\u2588\u2588\u2588")
+ a = bytecode.unique(self.data, "Claude Code")
+ self.assertIsNotNone(u)
+ self.assertIsNotNone(a)
+ self.assertFalse(u.eight)
+ self.assertTrue(a.eight)
+
+ def test_entry_chars_roundtrip(self):
+ e = bytecode.unique(self.data, "Claude Code")
+ self.assertEqual(self.data[e.chars:e.chars + e.nbytes], b"Claude Code")
+
+ def test_alignment_padded_entry_found(self):
+ # 5 个 UTF-16 字符 = 10 字节,要补 2 字节才 4 字节对齐
+ e = bytecode.unique(self.data, "\u2588\u2588\u2588\u2588\u2588")
+ self.assertEqual(e.nbytes, 10)
+
+ def test_replacement_rejects_length_change(self):
+ e = bytecode.unique(self.data, "Claude Code")
+ self.assertEqual(len(bytecode.replacement(e, " Loop Code ")), e.nbytes)
+ with self.assertRaises(ValueError):
+ bytecode.replacement(e, "Loop")
+
+ def test_replacement_rejects_non_latin1_in_8bit_entry(self):
+ e = bytecode.unique(self.data, "Claude Code")
+ with self.assertRaises(ValueError):
+ bytecode.replacement(e, "\u2588oop Code ")
+
+ def test_region_narrows_search(self):
+ region = bytecode.region_for(self.data, ["\u2588\u2588\u2588\u2588\u2588"], pad=64)
+ self.assertIsNotNone(region)
+ self.assertEqual(bytecode.find(self.data, "Claude Code", region), [])
+
+
+class TestPoolChannel(unittest.TestCase):
+ """bytecode 构建必须走常量池 —— 改源码文本能过校验但屏幕上没反应。"""
+
+ def _build(self, widths=NARROW, design=None, display_version="v9.9", **kw):
+ data = make_bytecode_bundle(widths)
+ return data, discovery.build_patches(
+ data, " Loop Code ", display_version, design or DESIGN,
+ theme_color=(46, 155, 255), real_version="9.9.999", **kw)
+
+ def _apply(self, patches, data):
+ buf = bytearray(data)
+ for p in patches:
+ buf[p["offset"]:p["offset"] + len(p["new"])] = p["new"]
+ return bytes(buf)
+
+ def _rendered_row1(self, widths):
+ """打完补丁后 default 变体第一行实际会渲染成什么。"""
+ data, (patches, _, problems, _w) = self._build(widths)
+ self.assertEqual(problems, [])
+ out = self._apply(patches, data)
+ a, b, _c = widths["default"]
+ r1 = ROW1["default"]
+ parts = []
+ for old in (r1[:a], r1[a:a + b], r1[a + b:]):
+ if not old:
+ continue
+ e = bytecode.find(data, old)[0]
+ parts.append(out[e.chars:e.chars + e.nbytes].decode("utf-16-le"))
+ return "".join(parts)
+
+ def test_patches_target_pool_not_source(self):
+ data, (patches, _, problems, _w) = self._build()
+ self.assertEqual(problems, [])
+ src = data.find(make_logo_table(NARROW).encode())
+ src_end = src + len(make_logo_table(NARROW))
+ self.assertTrue(patches)
+ for p in patches:
+ self.assertFalse(src <= p["offset"] < src_end, p["name"])
+ self.assertIsNone(p.get("old")) # 池补丁一律 offset+sha1
+
+ def test_same_rendered_row_across_slot_layouts(self):
+ """槽位边界变了,每条条目的内容跟着变,但渲染出来的整行必须一致。"""
+ want = "".join(DESIGN["slots"]["default"][s]
+ for s in ("r1L", "r1E", "r1R"))
+ self.assertEqual(self._rendered_row1(NARROW), want)
+ self.assertEqual(self._rendered_row1(WIDE), want)
+
+ def test_dedup_conflict_is_reported(self):
+ bad = dict(DESIGN)
+ bad["slots"] = dict(DESIGN["slots"])
+ # arms-up 的 r1E 与 default 共用一条池条目,给不同图案必须报错
+ bad["slots"]["arms-up"] = dict(DESIGN["slots"]["arms-up"], r1E="\u2596\u2596\u2596\u2596\u2596")
+ _, (_, _, problems, _w) = self._build(design=bad)
+ self.assertTrue(any("去重" in p for p in problems), problems)
+
+ def test_version_shares_constant_with_real_version(self):
+ _, (patches, _, problems, warnings) = self._build()
+ self.assertEqual(problems, [])
+ self.assertFalse([p for p in patches if p["cat"] == "version"])
+ self.assertTrue(any("--version" in w for w in warnings), warnings)
+
+ def test_version_rewrite_is_opt_in(self):
+ _, (patches, _, problems, _w) = self._build(
+ display_version="9.9.998", rewrite_real_version=True)
+ self.assertEqual(problems, [])
+ self.assertEqual([p["cat"] for p in patches].count("version"), 1)
+
+ def test_version_rewrite_requires_equal_length(self):
+ _, (_, _, problems, _w) = self._build(rewrite_real_version=True)
+ self.assertTrue(any("等长" in p for p in problems), problems)
+
+ def test_name_and_welcome_both_patched(self):
+ _, (patches, _, _p, _w) = self._build()
+ names = {p["name"] for p in patches if p["cat"] == "name"}
+ self.assertEqual(names, {"name", "name_welcome"})
+
+ def test_applying_patches_changes_pool(self):
+ data, (patches, _, problems, _w) = self._build()
+ self.assertEqual(problems, [])
+ for p in patches:
+ off, new = p["offset"], p["new"]
+ self.assertEqual(
+ hashlib.sha1(data[off:off + len(new)]).hexdigest(), p["sha1"])
+ out = self._apply(patches, data)
+ self.assertEqual(len(out), len(data))
+ self.assertIsNotNone(bytecode.unique(out, " Loop Code "))
+ self.assertTrue(bytecode.find(out, DESIGN["mid"]["row"]))
+ self.assertIsNone(bytecode.unique(out, "Claude Code"))
+
+ def test_defio_roundtrips_utf16_patches(self):
+ _, (patches, _, problems, _w) = self._build()
+ self.assertEqual(problems, [])
+ meta, loaded = defio.loads(defio.dumps({"verified": True}, patches))
+ self.assertEqual(len(loaded), len(patches))
+ for a, b in zip(patches, loaded):
+ self.assertEqual(a["new"], b["new"], a["name"])
+ self.assertEqual(a["sha1"], b["sha1"])
+
+
+class TestSlotLayoutDrift(unittest.TestCase):
+ """上游挪动槽位边界后,同一份设计稿仍要打出同一张图。"""
+
+ def _icon(self, table):
+ data = make_bundle(logo_table=table)
+ patches, report, problems, _w = discovery.build_patches(
+ data, " Loop Code ", "v9.9", DESIGN)
+ self.assertEqual(problems, [])
+ return data, next(p for p in patches if p["name"] == "icon_table"), report
+
+ def test_same_art_across_layouts(self):
+ _, narrow, _ = self._icon(LOGO_TABLE)
+ _, wide, _ = self._icon(LOGO_TABLE_WIDE)
+ self.assertEqual(narrow["art_new"], wide["art_new"])
+
+ def test_span_length_and_sha1_hold(self):
+ for label, table in (("narrow", LOGO_TABLE), ("wide", LOGO_TABLE_WIDE)):
+ data, t, _ = self._icon(table)
+ span = data[t["offset"]:t["offset"] + len(t["new"])]
+ self.assertEqual(len(span), len(t["new"]), label)
+ self.assertEqual(hashlib.sha1(span).hexdigest(), t["sha1"], label)
+
+ def test_detected_layout_is_reported(self):
+ _, _, report = self._icon(LOGO_TABLE_WIDE)
+ self.assertTrue(any("r1E=6 r1R=0" in line for line in report), report)
+ _, _, report = self._icon(LOGO_TABLE)
+ self.assertTrue(any("r1E=5 r1R=1" in line for line in report), report)
+
+ def test_row_width_mismatch_names_both_sides(self):
+ bad = dict(DESIGN)
+ bad["slots"] = dict(DESIGN["slots"])
+ bad["slots"]["default"] = dict(DESIGN["slots"]["default"], r1E="▄▄▄")
+ _, _, problems, _w = discovery.build_patches(
+ make_bundle(), " Loop Code ", "v9.9", bad)
+ self.assertTrue(any("整行列数不符" in p for p in problems), problems)
+
+
+class TestOptionalNameAnchors(unittest.TestCase):
+ """上游删掉某个显示位不该连坐掉其余补丁(2.1.261 移除了边框上的产品名)。"""
+
+ TITLE_ONLY = 'children:"Claude Code";x=["v",Ttt];'
+
+ def setUp(self):
+ self.data = make_bundle(names=self.TITLE_ONLY)
+ self.patches, _, self.problems, self.warnings = discovery.build_patches(
+ self.data, " Loop Code ", "v9.9", DESIGN, theme_color=(46, 155, 255))
+
+ def test_missing_optional_sites_are_warnings_not_failures(self):
+ self.assertEqual(self.problems, [])
+ self.assertEqual(len(self.warnings), 3)
+
+ def test_version_and_color_still_generated(self):
+ cats = {p["cat"] for p in self.patches}
+ self.assertIn("version", cats)
+ self.assertIn("color", cats)
+
+ def test_all_names_missing_is_fatal(self):
+ _, _, problems, _w = discovery.build_patches(
+ make_bundle(names="nothing to see here;"), " Loop Code ", "v9.9", DESIGN)
+ self.assertTrue(any("所有名字锚点" in p for p in problems), problems)
+
+ def test_version_capacity_comes_from_binary(self):
+ # 压缩后的变量名短一格,显示位就窄一格 —— 固定上限会漏判
+ _, report, problems, _w = discovery.build_patches(
+ make_bundle(names='children:"Claude Code";x=["v",m];'),
+ " Loop Code ", "v9.9", DESIGN)
+ self.assertTrue(any("最多 3 字符" in line for line in report), report)
+ self.assertTrue(any("放不下" in p for p in problems), problems)
+
+ def test_capacity_failure_is_atomic(self):
+ """一个位放不下就整批不改,避免一半假版本一半真版本。"""
+ _, _, problems, _w = discovery.build_patches(
+ make_bundle(names='children:"Claude Code";x=["v",m]+["v",Ttttt];'),
+ " Loop Code ", "v9.9", DESIGN)
+ self.assertTrue(any("放不下" in p for p in problems), problems)
+
+
class TestDefio(unittest.TestCase):
def test_roundtrip(self):
data = make_bundle()
- patches, _, problems = discovery.build_patches(
+ patches, _, problems, _w = discovery.build_patches(
data, " Loop Code ", "v9.99", DESIGN)
self.assertEqual(problems, [])
text = defio.dumps({"target_version": "t", "verified": True}, patches)
@@ -174,7 +483,7 @@ def _apply(self, tmpdir):
target = pathlib.Path(tmpdir) / "fake-claude"
target.write_bytes(make_bundle())
data = target.read_bytes()
- patches, _, problems = discovery.build_patches(
+ patches, _, problems, _w = discovery.build_patches(
data, " Loop Code ", "v9.99", DESIGN, theme_color=(46, 155, 255))
assert not problems
def_path = pathlib.Path(tmpdir) / "def.toml"