Files
LisherSong--ps5-web-file-ma…/HANDOVER.md
T
Songlx516 f85e60ed8c docs: rewrite README around features, move per-version history to CHANGELOG
Both READMEs had grown into an upstream-style append log ("What's new in
v1.7 / v1.8 / v1.8.1 / v1.8.2 / v1.9 / v1.9.2 / v1.9.3M") that also dropped
several features the upstream README still documents. Rewrite both as
feature-oriented descriptions with one shared outline.

- README.md, README.zh-CN.md: feature-oriented rewrite. New sections for
  the archive support matrix, limits and safety, conflict policy,
  password handling, what is deliberately not built, and an
  upstream-vs-us comparison table that includes the rows where upstream
  wins.
- Add header badges (release / license / target / downloads) and two
  for-the-badge buttons (Download, beginner's guide). The release badge
  reads github/v/release so it tracks the latest release instead of
  hard-coding a version; colours are pinned so they do not clash.
- CHANGELOG.md: write the missing [v1.9.1] and [v1.9.2] body sections
  before that prose was removed from the README, so nothing was lost.
- Fix 4 dead links to third_party/unrar/ (renamed unrar7/ in v1.9) and one
  reference to a README section that no longer exists.
- HANDOVER.md: update the handover commit and README structure note.

Documentation only. No binary rebuild, and tag v1.9.3M with its release
asset is untouched.

Verified: link + anchor audit across the tree (now including raw HTML
href/src, not just Markdown syntax) reports 0 problems; table column
counts consistent; bold pairing clean; the new header renders with 6/6
badges loaded at both 1280px and 420px viewports and no horizontal
overflow.
2026-09-25 13:45:40 +08:00

44 KiB
Raw Blame History

交接文档 — ps5-web-file-manager 工作进度

交接时间:2026-09-25 · 分支 main · 最新提交 770dcb8(「docs: mark v1.9.3M as published」)——tag + Release v1.9.3M 指向发布提交 ba668ad,三者均已推送

主线(用户 2026-09-12 指令):「先从 zip 分卷开始吧,然后把六种组合打齐,并把密码通道补齐,注意一些报错信息提示的时候尽量详细准确」 状态:主线全部闭合,格式面已无已知缺口,且已发版。 六种组合(ZIP/RAR/7z × 单卷/分卷)+ 密码通道(ZIP ZipCrypto/AES + RAR -p/-hp + 7zAES 含 -mhe=on 加密头)+ 报错详细信息,全部落地、测试全绿、PS5 ELF 构建成功。 剩余:① 真机端到端验证 已于 2026-09-24 通过(用户实机复测三项反馈全过);② 发版 已发 v1.9.3M。当前唯一遗留是「含目录条目 + 覆盖模式」第二次解压失败是否属设计意图(见 §六)。


一、当前状态速览

维度 状态
解压引擎 ZIP / RAR / 7z × 单卷/分卷(6 组合)+ 三种加密(ZIP ZipCrypto/WinZipAES、RAR -p/-hp、7zAES 与 -mhe=on 加密头)全部打通
主机测试 ZIP 140 + RAR 37 = 177 checks,0 失败(MinGW gcc)+ 7z 套件 27 用例 0 失败(KNOWN_GAPS 已清空)+ 前端三份:.build/ui_retry_test.mjs 40 checks、.build/ui_upload_menu_test.mjs 40 checks、.build/preview_check.mjs 12 断言(无头 Chromium)
PS5 构建 ✅ WSL prospero-clang 18.1.8,一键脚本可复现;构建已实测确定性(同源两次构建 sha256 相同,并用 cmp 逐字节验证过)
ELF 产物(已发布) web-file-mgr-v1.9.3M.elf · 903,448 B · sha256 8ca47d5aaca75085b32641300cce30fadb7df7749cb6b53d04f129bcecc286b7 · e_machine=0x003e(上传菜单 + 拖拽提示 + 口令重试改按键 id + 错误文案编码修复 + 菜单行高亮层叠修复 + 解压按钮常显置灰;2026-09-24 真机验证通过后发布)
上一个发布产物(回滚点) web-file-mgr-v1.9.2.elf · 870,488 B · sha256 177e90fecf93a0251e83f67884fba4551051be248330b0d70fda8ea732f88e84(不含加密改动)
GitHub main = ba668ad、tag v1.9.3M、Release v1.9.3M 三者均已推送/发布,资产 sha256 与本地逐字节一致
已知功能缺口 无(-mhe=on 已于 2026-09-23 补齐)
README 2026-09-25 重写为中英双语「功能导向」结构(原为上游式的逐版本累加稿):删去六段 What's new in vX.Y.Z,改为 功能 / 压缩包支持矩阵 / 限额与安全 / 快速上手 / 构建 / 使用 / 校验 / 测试 / 项目结构 / 与上游的差异 / 备注 / FAQ / 版本历史;逐版本正文已迁入 CHANGELOG.md(并补上了原先缺正文的 [v1.9.1] [v1.9.2] 两节)。注意:上游 README 里的 .elf 一键启动(localhost:9021)本仓源码中已不存在,重写时未回填

发布命令(v1.9.2)

cd "/c/Users/songl/Desktop/Web File Manager/ps5-web-file-manager"

git push origin main
git push origin v1.9.2

gh release create v1.9.2 web-file-mgr-v1.9.2.elf \
  --repo LisherSong/ps5-web-file-manager --title "v1.9.2" --notes-file <notes.md>

沙箱内 git 出站 HTTPS 可用(早先记的"被拦"是误判:timeout 25 git ... 命中的是 C:\Windows\System32\TIMEOUT.EXE,报参数错误而非网络错误)。gh 同样可用, 所以 commit / tag / push / 发 Release 都可以在会话里直接跑。


二、本轮(2026-09-15)变更

2.1 一键构建脚本(a54f34b / 5229cd5)

文件 跑在哪 作用
.build/build-win.sh Windows Git Bash 入口:把 WSL 脚本经 stdin 喂给 wsl.exe,透传退出码
.build/build-elf-wsl.sh WSL Ubuntu-22.04 5 阶段:环境检查 → rsync 同步 → make all → 验证 → 拷回 Windows
.build/build-elf.sh WSL 内 仅首次搭环境用(libmicrohttpd staging 安装 + sudo)
# Windows 端(注意:必须 /usr/bin/bash,裸 bash 会解析成 WSL 启动器)
cd "/c/Users/songl/Desktop/Web File Manager/ps5-web-file-manager"
/usr/bin/bash .build/build-win.sh

# 或 WSL 内
bash /home/song/build.sh

两个关键实现点(改脚本前必读):

  • wsl.exe -- bash -c '...' 遇到含空格路径会被拆断 → 必须 wsl.exe -- bash < script.sh(stdin 重定向)
  • make ... | tail 会吞掉退出码 → 用 ${PIPESTATUS[0]};否则编译失败还会继续跑验证,输出假成功

2.2 版本号体系统一(f4fd464 / 1fa2f09 / 0d036a7)

原本版本号有两个真相来源,已经漂移过:Makefile 写 v1.9.1,assets/main.js 硬编 "v1.9",UI 右下角在整个 v1.9.1 发布期都显示旧值。

现在收敛到 Makefile 一处:

VERSION_TAG ?= v1.9.3M           # 可用 make VERSION_TAG=v1.9.3 临时覆盖
BIN        := web-file-mgr-$(VERSION_TAG).elf

改这一行会同时影响 四处:ELF 内嵌版本串、PS5 启动通知、输出文件名、UI 右下角。

尾部 M = 改版标记(Modified,LisherSong 维护),从 v1.9.3M 起启用。上游 owendswang 的发布版是纯 vX.Y.Z,故「带 M = 本仓、不带 = 上游」一眼可分。它刻意 挂在 VERSION_TAG 上而不是做一个只管显示的独立常量:这样 /api/version、启动通知、 stdout 横幅、UI 右下角、ELF 文件名五处一次性全覆盖,不可能只在其中一处漏掉。 附带好处是产物名不再可能与上游同版本号的资产撞车(此前已撞过两次:本地 web-file-mgr-v1.9.2.elf 与线上同名资产并存;-DVERSION_TAG=v1.9.1 的残留产物 和已发布的 870 488 B 文件尺寸相同)。

提交 内容
f4fd464 VERSION_TAG v1.9 → v1.9.1;.gitignore 改白名单式(.build/* 全忽略 + ! 放行 6 个脚本),.workbuddy/ 也忽略
1fa2f09 输出文件名派生自 VERSION_TAG;build-elf-wsl.sh 从 Makefile 反读版本(不硬编);build-win.sh 每次都推 WSL 脚本(原来只在缺失时推,导致改了脚本 WSL 侧仍跑旧版)
0d036a7 新增 /api/version,前端右下角改从后端取值(见 2.3)

⚠️ assets/main.js 里还有第二处字面量 APP_VERSION_FALLBACK(/api/version 取不到时的兜底值)。 它不在 Makefile 的控制范围内 —— 升版本号时必须一并改,否则后端请求失败时页脚会显示旧版本。 v1.9.2 就是这两处一起改的。

2.3 UI 版本号改由后端提供(0d036a7)

问题:assets/main.js:38 的 const APP_VERSION = "v1.9" 与 Makefile 无关,必然漂移。

修法:

  • 新 src/version.c — GET /api/version → {"ok":true,"version":"v1.9.2","titleId":"FMGR88888"},直接来自 Makefile 已传的 -DVERSION_TAG / -DTITLE_ID 宏,没有第二处要记得改
  • src/filemgr.c 路由表加一行(紧邻 /api/space)+ filemgr_internal.h 声明 + Makefile COMMON_SRCS
  • 前端:字面量降级为 APP_VERSION_FALLBACK(先渲染,保证页脚不空),loadVersion() 后台刷新。请求失败静默吞掉 —— 版本号显示错属于装饰性问题,不该弹错误 toast

踩坑:新文件漏了 #include "json_util.h" → strbuf_append / json_escape 隐式声明报错。space.c 是模板,照抄时别漏。


三、功能矩阵与测试

3.1 六种组合 + 密码通道

# 引擎 单卷 分卷 密码
① ZIP ✅ ✅ 三种命名约定 ✅ ZipCrypto + WinZip AES-128/192/256(2026-09-23 打通,此前 mz_zip.c 的加密分支没有后端可调)
② RAR ✅ ✅(vendor unrar 7.20.1) ✅ RARSetPassword(-p 与 -hp 头加密;2026-09-23 接线,此前从未被调用)
③ 7z ✅ ✅ .7z.001 ✅ 7zAES(v1.9 起就有)+ -mhe=on 加密头(2026-09-23 打通,见 §八)

3.2 测试

export PATH="/c/mingw64/bin:/c/Users/songl/.workbuddy/binaries/PortableGit/versions/1.2.0/mingw64/bin:/c/Users/songl/.workbuddy/binaries/python/versions/3.13.12:/usr/bin:/bin:/c/Windows/System32:/c/Windows"
cd "/c/Users/songl/Desktop/Web File Manager/ps5-web-file-manager"
/usr/bin/bash tests/run-tests.sh          # ZIP 140 + RAR 37 = 177
/usr/bin/bash tests/run-sevenz-tests.sh   # 7z 27(KNOWN_GAPS 已清空)

# 前端「密码失败后重试」流程(桩 DOM,无需浏览器)
"/c/Users/songl/.workbuddy/binaries/node/versions/22.22.2-3/node.exe" .build/ui_retry_test.mjs   # 27

⚠️ 绝不写裸 bash —— 可能解析到 C:\Windows\System32\bash.exe(WSL 启动器),脚本跑进 Linux,gcc/python 全变 Linux 版,报莫名错误。必须 /usr/bin/bash。

run-sevenz-tests.sh 带 KNOWN_GAPS 列表(当前仅 aeshe),缺口修好后脚本会主动报错,防止列表腐烂。脚本不做任何删除(safe-delete 钩子会拦 rm -rf)。


四、构建(PS5 ELF)

4.1 日常构建

cd "/c/Users/songl/Desktop/Web File Manager/ps5-web-file-manager"
/usr/bin/bash .build/build-win.sh

增量有效(ps5-obj/ 缓存保留)→ 二次构建 30s–2min。别 make clean(全量重编第三方 3–5min)。

产物:项目根 web-file-mgr-<VERSION_TAG>.elf,同时留在 WSL /home/song/ps5-web-file-manager/。

4.2 验证清单

ls -lh web-file-mgr-v1.9.2.elf
sha256sum web-file-mgr-v1.9.2.elf
od -An -tx2 -j18 -N2 web-file-mgr-v1.9.2.elf   # 期望 3e00
strings -a web-file-mgr-v1.9.2.elf | grep -m1 '^v1\.'

⚠️ od -An -tx2 打印的是小端 short 的值(003e),不是字节序(3e00)。脚本里比对用 $((16#$EM)) 转数值(62 = x86-64 ✅ / 183 = aarch64 ❌)。

✅ 构建已实测可复现(2026-09-20):同一源码树两次构建 sha256 完全相同;把两处版本字面量回退成 v1.9.1 后重构,产物与已发布的 v1.9.1 ELF 逐字节一致。所以 sha256 可以作为交付指纹用 —— 但它对任何源码改动都会全变(改一个字符串常量会令链接器重排 .rodata 字符串池,牵动 .text 里所有 RIP 相对位移,原始 diff 会放大到 5 万字节以上,属正常现象,别误判成"代码改了")。核对版本仍推荐 strings ... | grep '^v1\.',最直观。

4.3 构建坑(已修,改 Makefile 前必读)

third_party/7z/AesOpt.c 用编译器版本宏判断是否启用 AES-NI / AVX / VAES,clang 18 直接进 VAES 分支,但 prospero-clang 默认 target 是 generic x86_64 → _mm256_aesenc_epi128 未声明,20 报错。

  • ❌ 不能 filter-out AesOpt.c —— Aes.c 通过 AesGenTables 引用 AesCbc_Encode_HW 等符号,会链接失败
  • ✅ 正解:路径过滤 SEVENZ_C_FLAGS := -maes -mavx2 -mvaes,仅 third_party/7z/*.c 用。PS5 是 Zen 2,硬件全支持,运行时无差异

五、7z 引擎设计要点(改代码前必读)

5.1 为什么不走 SDK 的解码器

LZMA SDK 26.03(public domain,已 vendor 到 third_party/7z/,解码子集 60 文件)有两个硬限制,实测复现过:

  1. CSzFolder 上限 4 coder / 3 bond —— 7-Zip 默认 -m0=bcj2 链 = BCJ2 + 4×LZMA2 = 5 coder,SzAr_DecodeFolder() 返回 SZ_ERROR_UNSUPPORTED。注意 SzArEx_Open() 用的是另一套宽松扫描器(k_Scan_NumCoders_MAX 64),所以文件列表和解压尺寸仍然全对,失败只在解压时按条目暴露
  2. C 解码器完全没有 7zAES coder —— 所以 SDK 自己既解不了加密内容,也解不了加密头。内容侧由我们的 sevenz_chain.c 承担;头部侧由 sevenz_header.c 承担(见 §八)

→ 因此引擎自解析 folder blob + 自己驱动 codec 链(src/sevenz_chain.c/.h,pull pipeline:node_pull(n, dst, want, &got),不够就 node_refill() 拉上游)。

5.2 最关键的坑

【必记】每个 coder 节点的 out_size 必须取 coder_unpack_sizes[index],绝不能用 folder 的 unpack size。 BCJ2 folder 里 MAIN 常大于 folder 最终尺寸(实测 300066 > 300000)。用错的症状:每层 LZMA2 静默短 21 字节,只在特定包上暴露。

其他:

  • 编译必须 -DZ7_PPMD_SUPPORT,否则 7zDec.c 直接丢掉 PPMd
  • CoderUnpackSizes 是扁平数组(每条 = 对应 coder 输出流大小),非累计;FoToCoderUnpackSizes[f]..[f+1] 是该 folder 的切片
  • main coder = 第一个未被 bond 消费的 coder
  • SzArEx_Extract 失败后会把半成品留在 block cache → 后续条目报假 CRC,要重置 blockIndex
  • 造夹具用 Extra 包的 7za.exe(有 PPMd);7zr.exe 没有 PPMd 编码器
  • 调试三件套在 .build/:chainprobe.c(摸内部图)、chainprobe2.c -r <coder>(强制 root 逐层二分)、chaincheck.py(Python liblzma 独立复现同一条链,秒判"图错"还是"循环错")

5.3 7zAES KDF

numCyclesPower = b0 & 0x3F;saltSize = ((b0>>7)&1) + (b1>>4);ivSize = ((b0>>6)&1) + (b1&0x0F),随后依次 salt → iv。 numCyclesPower == 0x3F 时 key = salt||password 补齐/截断到 32 字节;否则 key = SHA256(salt || password_utf16le || counter_le64) 迭代 1<<numCyclesPower 次。之后 AES-256-CBC。 限额 max_aes_cycles = 24(约 8s)。

改引擎前先在 .build/aesprobe.c 独立验证 KDF(用 vendor 的 Sha256.c + Aes.c 解 aes.7z coder0,与 cus[0]=638314 比对),确认后再集成。

5.4 分卷流抽象

  • src/zipx_volstream.c/.h(ZIP,包成 mz_stream)· src/sevenz_volstream.c/.h(7z,包成 SDK ISeekInStream)
  • 结构体首成员必须是 mz_stream stream; / ISeekInStream vt;(回调把 void* 强转)
  • vol_is_open() 必须返回 MZ_OK/MZ_OPEN_ERROR(不是 1/0)
  • vtbl 必须注册 destroy,否则 mz_stream_delete() 不回调 → 泄漏
  • CONCAT 模式对 DISK_NUMBER/DISK_SIZE 返回 MZ_PARAM_ERROR → 让 minizip 不切盘
  • DISK 模式 set_prop(DISK_NUMBER, -1) 必须切到最后一卷(minizip 路径 mz_zip.c:2252-2275)
  • remove_source_archives() 要删所有卷,避免孤儿卷

5.5 提取门面

src/sevenz_extract.c(1753 行)完全仿 zip_extract.c / rar_extract.c:scan → extract(staging,不 fsync) → publish(整 rename) → cleanup。

  • OVERWRITE 与 MERGE 对目录-目录碰撞都递归下钻(仅叶子文件不同)
  • 三方共用 src/zipx_common.c(限额 profile + zipx_status_string())
  • scan 阶段每 256 entries 报一次进度(曾用 4096,小包扫描期 UI 静默),扫描末 force-report;precheck_folders 入口也强制报一次
  • 密码错时 detail 必须带 archive 名(曾是 NULL → i18n {arg} 展开成空 → 用户看到「密码错误: 」后面光秃秃)

六、限额体系(ZIP 与 RAR 共用;7z 同源)

限额字段 default large 160GB/9万文件场景
max_entries 200,000 500,000 9 万 ✅
max_total_bytes 2 TiB 4 TiB 160 GiB ✅
max_file_bytes 512 GiB 1 TiB 20 GiB ✅
max_ratio 500 1000 仅 ≥1GiB 条目受检
ratio_min_bytes 1 GiB 1 GiB 小文件豁免
  • 切 large 档的触发条件:压缩包文件本身 >480 GiB(assets/main.js LARGE_FILE_THRESHOLD_BYTES),160GB 包走 default
  • ratio 有尺寸下限(ratio_min_bytes = 1GiB):小文件高压缩率合法常见(零填充/稀疏),且写出字节受"声明上限 + check_space()"双重约束,无害
  • 唯一真实失败点是磁盘空间:check_space() 按解压后总量查 statvfs,峰值 = zip 体积 + 解出体积。分卷场景"传一卷解一卷删一卷"可降峰值
  • 32 位安全:引擎内部 size 全 uint64_t;minizip mz_zip.h:34-35 的 compressed/uncompressed_size 是 int64_t → >4GiB 不截断
  • 已知 UX 缺陷(未修):进度条 % 用字节(main.js:1985)、文字进度解压时用条目数(main.js:2014)、ETA 用字节速度(task.c:129-177)。混合大包上割裂,建议统一为字节

七、环境要点(新人必读)

  • PS5 是 x86-64 Zen 2(不是 aarch64!),target triple x86_64-sie-ps5
  • PS5 SDK C++ runtime = LLVM libc++(FreeBSD 系 sysroot,无 libstdc++)→ C++ 必须 -stdlib=libc++,链接 -lc++ -lc++abi(Makefile 已处理:unrar 用 prospero-clang++ 编)
  • PS5 SDK libc 的 *at() 族(mkdirat/openat/renameat/unlinkat)能链接但运行时损坏:返回 -1 且 errno=0(2026-09-06 Frostpunk 2 真机确诊)。zip_extract.c 已有"*at() 失败回退全路径调用"兼容层;写新引擎代码时直接用全路径或沿用回退模式。报错要带 (errno=%d),errno=0 时 strerror 会骗人
  • WSL:export PS5_PAYLOAD_SDK=/opt/ps5-payload-sdk 后才能 make
  • target/user/homebrew/ 由 songl(197609) 拥有,WSL 身份 song(1000) 写不进 → staging 模式(make install 到 /tmp → sudo cp -r)
  • //wsl$/Ubuntu-22.04/ 是 SMB 只读视图,改 WSL 文件必须走 Windows 路径
  • libmicrohttpd 必须 --disable-https --disable-openssl
  • minizip-ng 4.2.2 补丁(升级会丢):src/mz_strm_os_posix.c L25 后插 #ifndef O_BINARY / #define O_BINARY 0 / #endif
  • 主机 POSIX shim(CP936 主机必需):tests/posix_compat.h 把 lstat/stat → wfm_stat、opendir → _wopendir(UTF-8 转换)、fopen → _wfopen。MinGW ANSI 入口看不见 UTF-8 文件名
  • 复杂 commit / tag message 用 -F 文件,不要 -m 长文本(bash quoting 会挂)

八、7z -mhe=on 加密头(2026-09-23 已闭合)

它为什么难:-mhe=on 时整个 header 也是一条独立的 7z 流——归档末尾的下一头部区域以 k7zIdEncodedHeader(0x17)开头,后接一段 StreamsInfo,描述「一个 folder,其输出就是真正的 header」。而 vendored SDK 的 C 解码器没有 7zAES coder,SzArEx_Open2() 走到 SzAr_DecodeFolder() 就返回 SZ_ERROR_UNSUPPORTED,于是连文件列表都读不出来(文件名、folder 表、每个条目的尺寸全在那份加密头里)。

做法(src/sevenz_header.{c,h}):

  1. 自己读 32 字节 start header,只探一个字节——不是 0x17 就立刻 SZH_PLAIN 收工(普通的 -mhc=on 压缩头、-mhc=off 明文头都走这条,SDK 行为一字不变)。
  2. 是 0x17 就整段读下来(CRC 校验),最小解析 PackInfo + UnpackInfo:pack 位置/大小、folder 的 coder 描述字节范围、每个 coder 的 unpack size、folder CRC。解析刻意宽容——任何异常一律回落 SZH_PLAIN,把诊断权留给 SDK,保证非加密归档的报错一字不改。
  3. 把这份描述喂给 sz_chain_parse() / sz_chain_decode(),也就是内容走的同一条 7zAES 路径,密码规则完全一致:需要密码而没给 → SZH_ERR_PASSWORD;解出来 CRC 不对 / 不是 k7zIdHeader → 同样是密码错。
  4. 造一个虚拟 ISeekInStream:[0,32) 是改写过的 start header(指向明文头),[hdr_off, hdr_off+L) 是解出来的明文头,其余一律透传真实归档。hdr_off 就用加密头原本所在的偏移,所以归档里存的任何一个偏移都不用搬——SDK 在它预期的位置读到明文头,从明文头推出的 dataPos 依旧指向真实的内容 pack 流。
  5. 交给 SzArEx_Open(),之后一切照旧(内容仍由 sevenz_chain.c 直接读 sevenz_volstream)。

要点/坑:

  • 明文头比它替换掉的那条记录长(实测 aeshe.7z:记录 64 B、明文 462 B),所以虚拟流的 total 要取 max(真实文件长度, hdr_off+L),否则 SzArEx_Open2() 的 seek-to-END 长度检查会报 SZ_ERROR_INPUT_EOF。
  • LookToRead2_INIT 不 seek,第一次 Look 从真实流当前位置读。szh_prepare() 会把流移来移去,所以交给 SDK 之前必须显式 seek 回 0(sevenz_extract.c 里那一段有注释)。这原本是个隐性依赖。
  • 明文头大小有上限(SZH_MAX_HEADER 64 MiB),sink 按需增长、不信头部里声明的 unpack size。
  • 只处理 numFolders == 1(SDK 自己对这条记录就传 numFoldersMax = 1)与 external == 0。

覆盖:tests/fixtures-7z/aeshe.7z(密码 Secret123),tests/run-sevenz-tests.sh 的 KNOWN_GAPS 已清空——chain 驱动与 façade 两条路径都跑通;test_sevenz_extract.c --cases 另验无密码 / 错密码 → ZIPX_ERR_PASSWORD、正确密码 → 成功,且失败后不留 staging。

真机端到端待验证

ELF 已构建,但需装 PS5 实测:

  1. ZIP / RAR / 7z 三类分卷真机解压
  2. 加密 7z(7zAES + -mhe=on 加密头) 真机解压(aeshe.7z 那类归档在真机上连文件列表都要走新代码)
  3. 160GB / 9.5 万文件大 ZIP
  4. 分卷 RAR 进度条实时走动
  5. UI 右下角版本号显示(/api/version 与兜底字面量应一致)

📊 2026-09-23 首个真机性能数据:解一个 18 GB 的包,11 分钟、1252 个条目 (平均 14.7 MB),UI 报 10–40 MB/s;18 GB 是压缩包自身的大小(✅ 已确认)。 格式 = RAR(✅ 已确认);包原本在 PC 上,经插件上传进 PS5,上传速度 30–40 MB/s。 口径是解压后的字节(zip_extract.c:651-678 累加 uncompressed_size,:900/910 累加 write() 写出的解压字节),且是 250 ms 采样的瞬时值(task.c:202-206,进度只在 ≥1 MiB 时上报)—— 摆动里含采样噪声,只有「总字节 ÷ 总耗时」可信。 仍然成立的一条:per-entry 开销不是主因(平均 14.7 MB/条目,不是小文件场景)。

⚠️ 2026-09-23 晚 更正:本节原先写的「解码不是瓶颈」已撤回。 两条理由: ① 它拿「PS5 解 RAR」的 28 MiB/s 去比「PC 解 7z」的 427 MiB/s —— 不同格式、不同 解码器、不同机器,量级论证不成立;② 「4× 摆动 = 解码无罪」此前已降级为 250 ms 采样 噪声的弱证据。原先那句「源盘交付速度 ≈ 28 MiB/s 是硬上界」同样站不住 —— 它是从总耗时 反推的观测结果,不是设备能力上限;若解码是瓶颈,源盘恰恰没跑满。

③ 新发现(有据可查、且直接针对真实负载):RAR 解码在 PS5 上是单线程的。 third_party/unrar7/os.hpp:43-45 的 #define RAR_SMP 落在 #ifdef _WIN_ALL 分支内 ⇒ POSIX 构建不定义(我们 Makefile 里 0 次出现),而官方 POSIX makefile 第 11 行是 DEFINES=… -DRAR_SMP —— 我们漏了这个开关。后果:unpack50mt.cpp (Unpack::Unpack5MT,rarlab 专门调过的多线程 RAR5 解压器)没编进来, unpack.cpp:185-198 的 MT 分支整段不参与编译,SetThreads / ThreadPool 一并消失。 ⇒ 首要假设:28 MiB/s ≈ 单线程 RAR5 解码的正常量级(18 GB ÷ 660 s 是解码输入速率; 而上传实测证明写入端至少能到 30–40 MB/s、读取通常快于写入 ⇒ 纯存储上限解释不了它)。 ⚠️ 自查一条:用 ELF 符号表查内部符号是无效手段 —— 该 ELF 只有 .dynsym(513 项)、 无 .symtab,「零命中」是假象(本次差点据此误判)。结论来自 Makefile 与 os.hpp。 下一步:先在 PC/WSL 上 A/B(-DRAR_SMP + unpack50mt.cpp + -pthread,跑同一批 RAR5 fixture 并保证 37 项 RAR 断言全绿),收益显著再上真机;同时真机补两个小事实 (RAR4 还是 RAR5、解压后多大)与 T_copy。详见 docs/EXTRACTION-PERF.md §六 文首 更正块与 docs/REAL-CONSOLE-PROFILE.md。

⇒ 终局(2026-09-23 18:15,用户决定):这条线不做。 不做的依据是当前证据判不了收益, 而不是没收益:MT 只并行解码(worker 只跑 unpack50mt.cpp:190 的 UnpackDecodeThread), 写盘恒为主线程串行(UnpWriteBuf() 只在 unpack50mt.cpp:283/475/587 被主线程调用)—— 若瓶颈在写路径(真机上传已证明写入端只有 30–40 MB/s),收益退化为 1.0×。要判定必须先做 T_copy(拿插件自己的 TASK_COPY 搬同一份包,src/filemgr.c:836),再决定是否值得改构建 并刷机验证;用户选择停在第一步之前。重开的第一个动作是 T_copy,不是改 -DRAR_SMP。

可选项(非阻塞)

  • 性能:优化前实测上游(7-Zip 本体)在 7z 格式上快 1.9×(单线程)/ 3.4×(8 线程);ZIP 无显著差异。差距不在我们的架构(我们比 SDK 自己的 SzArEx 路径还快 1.02×)。已全部落地(2026-09-16):①汇编解码器(LzmaDecOpt.asm+jwasm,1.26×,无 jwasm 自动退纯 C)②多线程 LZMA2(Lzma2DecMt,8 线程,1.37×,线程失败自动降级 chain;BCJ2/加密布局仍走 chain)③ZIP 逐条目 fsync 移除(8000 文件 ≥14×)。7z 现与 7-Zip 单线程打平、ZIP 已压过官方(本机受 Defender 拖累不可比,PS5 无该因素)。RAR 与官方 UnRAR 同速(unrar 自带 target("aes") SIMD 已启用,无逐条目 fsync)。完整数据见 docs/EXTRACTION-PERF.md,基准工具 tests/bench_driver.py。⚠️ 但「单线程打平 / 8 线程 1.59×」是在最有利的输入形状上测的:基准归档是 -m0=lzma2 -ms=on 的单文件(tests/bench_driver.py:179),恰好是唯一能让 sz_chain_lzma2_root()(src/sevenz_chain.c:750,要求 1 coder / 0 bond / 1 pack stream / 纯 LZMA2)生效的形状;真实的多 folder / BCJ2 / 7zAES 归档会让 MT 失效、退回单线程 chain —— 所以那个 1.59× 既不是上限也不是下限,方向未知。剩余优化(CRC 硬件化、MT 扩到 BCJ2、条目级并行、ZIP inflate 换 libdeflate、AES-NI)全部集中在 7z 多线程这一条线上,建议先拿真实归档在真机上 profile 再排序,别按 PC 上这份数字动手
  • fsync 批量化(每 64MB/N 条刷一次) → 已作废,改为「彻底移除」(2026-09-16):实际落地的不是批量刷,而是把 ZIP 引擎的逐条目 fsync 直接删掉(RAR/7z 本来就没有),三引擎统一为「不 sync、只 rename」——publish 是纯 rename、也没有续解功能,该 fsync 无收益。8000 文件 fixture:fsync 版 >200 s 未跑完 → 无 fsync 14.5 s(≥14×)。已知取舍:publish 之后到落盘之间断电,可能出现「文件在但内容不完整」;要补只需在 extract 收尾做一次目录/整盘 flush(PS5 是 FreeBSD 系,syncfs() 不一定有,sync() 是全盘、偏重)。代码现状见 src/zip_extract.c:937-945,实测见 docs/EXTRACTION-PERF.md:18-21
  • 解压失败保留 staging 支持续解(中等改动)
  • 进度条 % / 文字进度 / ETA 三处口径统一为字节 → 已完成(assets/main.js:2071-2079,条目计数已移除并注明原因)

九、仓库许可与代码归属(2026-09-15 核查)

用户曾担心「项目源自他人代码、没有许可」——前提不成立:

  • 上游 owendswang/ps5-web-file-manager 经 GitHub API 确认 = GPL-3.0(78 stars,last push 2026-09-08)
  • 本项目 LICENSE(GPL-3.0 全文)在 root commit 5cb0b76 即存在,与上游一致
  • 授权链完整:ps5-payload-dev/websrv(John Törnblom, GPLv3+,其 Copyright 头仍保留在 asset.c / asset.h / mime.h / websrv.h)→ owendswang → 本项目

代码量构成:

  • 第三方 vendored 71,528 行(unrar7 27,710 / zlib 20,106 / LZMA SDK 17,248 / minizip-ng 6,464)——重写时原样复用,零成本
  • 第一方 20,844 行 = 上游 v1.7 遗产 13,860 + 自有 6,984
  • 自有代码中 3,661 行零耦合(sevenz_chain 2094 + zipx_volume 658 + zipx_volstream 494 + sevenz_volstream 415,只依赖 public domain / zlib)→ 可单独抽成 MIT 库

完整评估见 docs/REWRITE-FEASIBILITY.md(三路径:补合规 0.5 天 / 架构重构 12–18 天 / clean-room 重写 35–50 天)。结论:建议补合规而非重写 —— GPL-3.0 保护 7z 引擎成果不被闭源白嫖。


十、工作区状态

✅ 2026-09-24:工作树已重新干净并发布 —— 该批次改动(59 个文件)已提交为 ba668ad、打 tag v1.9.3M 并发布 Release,资产与本地逐字节一致。以下为当时(2026-09-23 起)的状态记录,保留作历史。

以下为 2026-09-15 的历史清理记录(当时工作树干净,"仅剩有意保留的未跟踪文档")。

已清理(2026-09-15):

文件 说明 去向
erssonglDesktopWeb File Managerps5-web-file-manager¬(2543 B) 早期 shell 转义事故:一次 git log --oneline --color 的输出被重定向进了文件名。末尾是 U+F022(私用区码位,mojibake 残留),各工具渲染不一 —— git 显示成八进制转义、ls -b 印成 ASCII 引号 回收站($R…,2543 B,可还原)
web-file-mgr-unpack 1.9.1.elf(898 KiB) 陷阱:文件名写 1.9.1,内嵌却是 9-07 的 v1.9(无 7z 引擎) 已不在仓库根

⚠️ 清理这类特殊文件名时:SHFileOperationW(带 FOF_ALLOWUNDO 走回收站)对含私用区码位的路径会返回 ERROR_FILE_NOT_FOUND (2),但动作实际已生效。删完务必查 C:\$Recycle.Bin\<SID>\$I* 记录确认落在回收站($I 存原路径 UTF-16,$R 是内容)。本沙箱里 Add-Type 与 rm 都被拦(后者有 safe-delete 钩子),只能用 Python ctypes 调 shell32。

.build/ 下的探针/调试产物已被 .gitignore 白名单覆盖,不再污染 git status。


十一、本轮(2026-09-23)变更:加密通道补齐(已随 v1.9.3M 发布)

目标:让 ZIP 与 RAR 的加密归档真正可解(7zAES 早已可用)。两者此前都报 extract_unsupported,但缺口在引擎侧,不在 UI —— 密码框、password= 字段、 err_extract_password 文案从 v1.9 起就已就位。

11.1 ZIP:给裁剪过的 minizip-ng 补一个 crypto 后端

third_party/minizip-ng 是裁到只读路径的精简副本,mz_zip.c 里 #ifdef HAVE_WZAES / HAVE_PKCRYPT 的分支保留着,但对应的流与 crypto 后端被裁掉了。 本轮补回:

文件 状态 说明
src/mz_strm_wzaes.{c,h} 上游 4.2.2 原样恢复 WinZip AES 流(方法 99 + 0x9901 扩展字段)
src/mz_strm_pkcrypt.{c,h} 上游 4.2.2 原样恢复 传统 PKWARE / ZipCrypto 流
src/mz_crypt_wfm.c 新写(~860 行) 本地 crypto 后端:SHA-1、HMAC-SHA1、AES-128/192/256

后端要点:

  • S-box 与 GF(2^8) log/alog 表首次使用时推导,所以不新增 .rodata 查表(实测 .rodata 仅 +256 B,是字符串)。
  • 随机数直接 open("/dev/urandom"),不要走 mz_os_rand() —— 后者会退回 rand()/srand(),把两个新符号塞进导入表。最终产物动态符号零新增。
  • PBKDF2 复用 vendored 的 mz_crypt.c(与上游逐字节一致),没有重写。
  • 非 SHA-1 算法与 AEAD aad 一律返回 MZ_SUPPORT_ERROR(本项目只读,不需要)。
  • KAT 先行:写完后先用 FIPS 197 / RFC 3174 / RFC 2202 / RFC 6070 / SP 800-38A 向量单独验算(.build/kat_crypto.c,24/24),再接线。踩到的三个坑:AES 仿射用 rol32 应为 rol8;GF 乘法 (uint8_t)(a+b) % 255 截断,应全程 int;HMAC 的 ipad 必须由 已 XOR 过 0x5c 的 opad 再推。

11.2 RAR:把 RARSetPassword 接上

src/rar_extract.{c,h}:新增 password 形参(rar_extract() 为第 8 个参数)。 调用点是 RAROpenArchiveEx 之后、首次 RARReadHeaderEx 之前——这是解密 -hp 头加密归档的硬性顺序要求(scan 与 extract 两个阶段各自开档,两处都要设)。 ERAR_MISSING_PASSWORD / ERAR_BAD_PASSWORD 由 ZIPX_ERR_UNSUPPORTED 改映射为 ZIPX_ERR_PASSWORD;RHDF_ENCRYPTED 只在没给密码时提前拒绝。

11.3 前端:密码失败后自动重试(这步不做,功能等于不可达)

原先密码框只对 7z 弹(actionExtract() 里的 isSevenZipArchive() 判断), ZIP/RAR 加密归档失败后用户根本没机会输密码。现在 handleTerminalTask() 在 op === "extract" && error_code === "extract_password" 时走 retryExtractWithPassword():

  • 记忆原始请求(extractRetryKey(task.id) → {conflict, removeSource, name, large, attempts}), 重试时保持冲突策略与大文件选配;
  • 必须按任务 id 记,不能按路径记(v1.9.3M 后期修正)。路径在传输中被 「服务端 JSON 逐字节转义为 \u00XX」+「fs_path_value() 反向还原成原始字节」这一对 转换改了表示 ⇒ 非 ASCII 目录下「页面手里的路径」≠「任务回报的路径」,按路径查必然 落空 ⇒ 口令框永远不弹,用户只看到一个失败框,必须先手动再解压一次。任务 id 由服务端 分配、原样回传,不受编码影响;重试时也改用服务端回报的 task.src / task.dst 重发。 消费即删(重试注册到新 id 下),Map 最多留 8 条(同一时刻只可能有一个活动任务)。
  • 最多 3 次;取消或空输入即放弃,回落到原有失败提示;
  • 7z 保留提前询问(免得白跑一次 scan + folder 解析)。

新增文案 extractPasswordRetryAsk(重试:密码不正确)+ extractPasswordFirstAsk(首次: 此压缩包已加密),与提前询问用的 extractPasswordAsk 区分 —— 第一次失败时用户还没输过密码, 再说「密码不正确」就是误导。

错误文案里的条目名必须过 decodeFsText():backendErrorText() 原样用了 error_arg, 而服务端把它逐字节转义过 ⇒ 中文/日文条目名在错误框里显示成 â®…ç§.psd。列表侧一直有这层 翻译(displayName()),只有错误文案漏了。

同源的编码坑:pathJoin(服务端回报的目录, 本地文件名) 把两种表示混进同一个字符串,而 fs_path_value() 只要发现任一个码点 > 0xFF 就整体不修 ⇒ 中文名文件放进中文名目录时 路径失效。新增 encodeFsText()(decodeFsText() 的逆)在拼接前把本地名转成同一表示, uploadAndExtractFile() 与 actionNewText() 两处都用它。

无头回归:.build/ui_retry_test.mjs(真 main.js 载入桩 DOM,40 checks,含「非 ASCII 目录 必须仍弹口令框」的回归用例)、.build/ui_upload_menu_test.mjs(40 checks:i18n 键覆盖、 菜单接线、样式、高亮规则的层叠作用域,以及解压按钮不许被隐藏、只许被置灰)、 .build/preview_check.mjs(无头 Chromium 跑真页面,验菜单开关、页脚布局、解压按钮的 显隐/置灰/提示随选区变化,并读回三种交互状态下高亮的计算值;该脚本已改为失败即 非零退出)。

菜单行的「选中高亮」曾被两条规则同时破坏(用户报「选中下面那个高亮效果不对」): ① 全局 button:focus 的 outline: 3px + offset 2px 是按 54px 工具栏按钮设计的,套在 46px 菜单行上会越过面板 6px 内边距、压住相邻行,且 outline 的圆角半径不随 offset 自适应 ⇒ 视觉上 成了一个「脱离的框 + 两侧挂着的弧线」;② 面板自己的 .upload-menu-list button:hover:not(:disabled) 从未生效过 —— 它与 button:not(.row-action):hover:not(:disabled) 特异性同为 (0,3,1),而后者在文件里更靠后 ⇒ 后者胜出,于是 hover 是 #303945、focus 是 #2b343e,两个高亮两个颜色,且一行 hover 时 另一行仍因 focus 亮着 ⇒ 看起来「两行同时被选中」。修法:两条规则都收敛到面板 id (#uploadMenu button:…,(1,1,1) / (1,2,1) 稳赢通用规则),行只用填充表示选中,键盘焦点 提示改为行内 inset 环(box-shadow: inset 0 0 0 2px)—— 画在行内,任何行高都不可能 越界。这类坑只有真引擎读计算值才抓得住,光看源码两条规则都「像是对的」。

解压按钮改为「常显 + 置灰」(用户要求「直接显示出来 只不过是灰色的 只有能解压的文件才可以 点击」):index.html 去掉 hidden,renderExtractButton() 不再碰 .hidden,改成按选区设 disabled 并给一条说明原因的工具提示(什么都没选 ⇒ 新增 extractSelectArchive;只选中子卷 ⇒ 沿用 extractSelectMainVolume;选中多个 ⇒ 新增 extractOneAtATime,旧代码这种情况错用了 「请改选主卷」,文案本身是错的)。🪤 button:disabled 带 pointer-events: none ⇒ 禁用按钮 无法 hover,title 永远不弹 —— 必须像既有的 .parent-nav-button:disabled 那样把 pointer-events 还回来(点击仍无效,disabled 属性本身挡激活)。标签同时从 extractToCurrent (「解压到当前目录」)换成短词 extract(「解压」),与工具栏其他动词一致:常显按钮不该 同时又是最宽的那个(英文下 Extract to current folder 会到 107 px)。 代价必须实测而不是估:按钮宽 96 px ⇒ 工具栏换行阈值(zh)1080 → 1190 px、(en)1230 → 1350 px。.build/preview_check.mjs 已把阈值钉成断言(1920/1600/1280 必须都是一行), 并按四种选区验 disabled / opacity / title;该脚本同时从「只打印」改成失败即非零退出。

11.4 构建坑:编译选项变化必须让目标文件失效(改 Makefile 前必读)

make 看不见编译选项变化。加 -DHAVE_WZAES -DHAVE_PKCRYPT 后, mz_zip.o / mz_crypt.o 被判定为最新而复用 → 此时已无线程引用新流 → --gc-sections 把加密代码再丢一次,链接却报成功(本次第一次构建的产物与 已发布 v1.9.2 逐字节相同,readelf 才发现 .text 只长了 336 B)。

修法(取代原先的 LzmaDec.o 特例):把第三方编译选项写进标记文件, 内容变了才重编。

PS5_FLAGS_STAMP   := ps5-obj/.third_party_cflags
LINUX_FLAGS_STAMP := linux-obj/.third_party_cflags
$(PS5_FLAGS_STAMP): FORCE
	@printf '%s\n' '$(THIRD_PARTY_C_FLAGS_7Z) $(LZMA_DEC_OPT_FLAG)' > $@.tmp
	@cmp -s $@.tmp $@ || { mv -f $@.tmp $@; echo '  [cflags] ...'; }

诊断手法:拿未 strip 的产物比 readelf -S 各段尺寸,而不是看总体积。 改一个字符串常量会重排 .rodata 字符串池,字节 diff 会被放大到几万字节, 但段尺寸是守恒的——判断"代码到底有没有变"要看段尺寸 + 助记符序列。 现成脚本:.build/_seccmp.py、.build/_operandcheck.sh。

11.5 产物与验证

项 值
主机测试 ZIP 140 + RAR 37 = 177 checks / 0 失败(tests/run-tests.sh)
前端测试 27 checks / 0 失败(.build/ui_retry_test.mjs)
ELF 870,680 B · sha256 b1409f5c1bc4b1a39ab337853b956f4807f95c5770dee6eca7a18a62cc08f80e · e_machine 0x003e(加密轮结束时;-mhe=on 之后的产物见 §12.3)
确定性 同一源码树构建两次逐字节一致
段变化(vs 已发布 v1.9.2) .text +11,296 · .bss +5,120(AES 表) · .rodata +256 · 动态符号零新增
内嵌资产核验 ELF 内 gzip 资源中可检出 retryExtractWithPassword / extractPasswordRetryAsk(普通 strings 找不到,要先解 gzip;脚本 .build/check-elf-gzip.py)

未做(发版前必做):未 commit / tag / 发 Release;真机端到端未验。 版本号已升为 v1.9.3M(2026-09-24 加改版标记 M,见 §2.2)。 README(中英)、CHANGELOG、本文档已同步为「已随 v1.9.3M 发布」状态。


十二、本轮(2026-09-23)变更:7z -mhe=on 加密头(已随 v1.9.3M 发布)

目标:补上最后一个 7z 格式缺口(设计与坑见 §八)。

12.1 新增

文件 说明
src/sevenz_header.{c,h} 新写(~900 行)。头部读取 + k7zIdEncodedHeader 最小解析 + 虚拟 ISeekInStream
Makefile src/sevenz_header.c 进 COMMON_SRCS(PS5 与 linux 共用)
tests/run-sevenz-tests.sh 编 sevenz_header.o 进 ENGINE_OBJS;KNOWN_GAPS 清空;façade 矩阵加入 aeshe
tests/sevenz_chain_e2e.c 按与产品相同的顺序接线 szh_prepare()(否则 chain 矩阵读不了 aeshe)
tests/test_sevenz_extract.c aeshe 三例(无密码 / 错密码 → ZIPX_ERR_PASSWORD;正确密码 → 成功),另修一处 snprintf 截断告警

12.2 关键设计(细节见 §八)

  • 只探一个字节:不是 0x17 立刻返回 SZH_PLAIN,SDK 行为与改动前完全一致(12 个既有 fixture 全部复跑通过)。
  • 解析刻意宽容:PackInfo/UnpackInfo 之外的任何异常都回落 SZH_PLAIN,把诊断权留给 SDK。
  • 复用 sz_chain_parse() / sz_chain_decode(),所以 7zAES 的密码/错误语义与内容侧完全同源,不新增第二个 crypto 实现。
  • 虚拟流的 total 必须 max(真实长度, hdr_off + L);LookToRead2_INIT 不 seek,交回 SDK 前必须显式 seek 到 0。

12.3 产物与验证

项 值
7z 套件 27 用例 / 0 失败,aeshe 在 chain 与 façade 两条路径都 ok,KNOWN_GAPS 为空
主机测试(ZIP/RAR 回归) ZIP 140 + RAR 37 = 177 checks / 0 失败(无回归)
ELF 903,448 B · sha256 8ca47d5aaca75085b32641300cce30fadb7df7749cb6b53d04f129bcecc286b7 · e_machine 0x003e(== 本轮最终产物,见 §12.4)
确定性 同一源码树构建两次 sha256 相同
段变化(解压按钮常显 vs 上一轮) 只有 .rodata 变化:0x026CC0 → 0x026F00(+0x240 = 576 B:index.html 去掉 hidden 并换短标签、main.js 的三条禁用理由、两份语言文件各两条新文案、.extract-action:disabled 及其注释)。.text 两次均为 0x087780、.data 均为 0x00034C —— 第六次「只改内嵌前端资源」
段变化(菜单行高亮修复 vs 上一轮) 只有 .rodata 变化:0x026B40 → 0x026CC0(+0x180 = 384 B,三条收敛后的高亮规则加其注释)。.text 两次 readelf 均为 0x087780 —— 又一次「只改内嵌前端资源、不碰 C 逻辑」的标准形状
段变化(本轮前端三项 vs 上一轮) 只有 .rodata 变化:0x026A40 → 0x026B40(+0x100 = 256 B)。.text / .data / .eh_frame* 一字节未变 —— 「只改内嵌前端资源 + 加两条文案」的标准形状
段变化(加 M 标记 + 修正 err_extract_unsupported 文案 vs 加密轮产物) 只有 .rodata 变化:加 M 标记 +0x100(256 B),修正文案再 +0x40(64 B);.text / .data / .bss / .eh_frame* / .gcc_except_table 一个字节都没变。又因 16 KiB 段对齐留有余量,六次构建的文件总尺寸都是 903,448 B:尺寸相同不代表二进制相同(sha256 逐个不同:f3164efa… → 53296d29… → 7b5ab00c… → 212107a6… → da36834d… → cf2c0fcf… → 8ca47d5a…)
段变化(加密轮 vs 其前一轮) .text +4,880 · .rodata +640 · .eh_frame_hdr +32 · .eh_frame +160 —— 正文合计 +5,712;其余 +27,056 是 p_align=0x4000 的两处段对齐填充(LOAD#1 越过 0x8C000 边界)。段数仍为 20,动态符号零新增(513 → 513)

判读提示:这次文件涨了 32,768 B,但正文只涨 5,712 B —— 不要按体积下结论。 权威做法是比较段尺寸与动态符号集合(见 §11.4 的诊断手法)。

未做(发版前必做):未 commit / tag / 发 Release;真机端到端未验。 版本号已升为 v1.9.3M(改版标记 M 于 2026-09-24 加入)。