Commit Graph
87 Commits
Author SHA1 Message Date
Songlx516 c46f5969c8 build(check): make the demo5 sweep assertion able to fail
The old pin ("running progress bars sweep, capacity bars do not") only read
animationName — but getComputedStyle is readable on a display:none subtree, and
the element it found was the .track.pulse inside #view-tasks, hidden by default.
So it was green while nothing on screen was animating. That is exactly the
defect the user reported ("demo5 has no flashing progress bar").

ui_demos_check.mjs
- strength: require a non-zero layout box AND animationName !== "none"; switch to
  #view-tasks inside the assertion and switch back (synchronous, so it stays
  atomic under Promise.all). The negative half only checks the track's
  visibility, not the fill's — a queued row's fill is legitimately 0 wide.
- scope: also cover the task table's progress column (.row .bar[data-p]), which
  is the same task as the big card; classifying .bar by class had filed it under
  "capacity meter, never animate".
- print the total at the end: the "N assertions" figure in the docs had been
  obtained by grepping the output and was one too high (claimed 99, actually 98).
- comment: 8888 -> 2026 (the default port changed, the comment did not).

proposal_check.mjs
- keyword 99 项 -> 98 项, matching the measured count.
2026-09-27 13:03:57 +08:00
Songlx516 9c06fca17f test(ui): 候选收敛到 1/5 + 定名/端口/状态口径/动效成族的断言
第十一轮用户五条:去掉 etaHEN 状态显示、新项目起新名字与新端口、HTTP 与 SMB
服务也用「小绿灯」、demo 只留 1 和 5、把 demo1 进度条的闪光效果移植到 demo5
并在其他能加的地方也加。

拍板:项目定名 PS5 Nexus(仓库/ELF ps5-nexus),默认端口 2026。

- ui_demos_check.mjs:DEMOS 5 档 → 2 档(B/C/D 归档到工作区根的
  _retired-demos/),删掉对应断言 209 → 96;再加 3 条回归钉(demo1 三盏绿灯
  与地址都在吸顶导航条里且状态区不列打包发行版、demo5 状态区口径、demo5
  运行中进度条有扫光而静态容量条没有)→ 99 项全过。
- proposal_check.mjs:209 → 99,并新增 8 条命名/状态/动效关键词断言,另加
  一条「8888 只能作为『已改掉』的引述出现」的引述约束。

注:断言一律用渲染后的纯文本片段。textContent 会剥离标签,写
"<code>2026</code>" 这类关键词永远匹配不上,且不会抛错(假失败)。
2026-09-27 12:46:19 +08:00
Songlx516 400784942e test(ui): 本机地址断言 + 1100 断点档 + 地址硬规则
- ui_demos_check.mjs:
  · 新增本机地址断言(形如 IP:PORT / 非回环 / 命中区 ≥40px /
    点一下有可见反馈且 1.5s 后复原 / PS5 档在首屏内)
  · 命中区必须量 ::after —— 视觉高度按风格只有 24~37px,
    只量 getBoundingClientRect() 会把「看着小但点得中」误判成不合格
  · 回归钉改为逐个 await(点了按钮要等一个 tick 才看得到结果)
  · 对比度采样 25 → 30(每个 demo 加一处地址文字),
    备用主题下额外复测一次地址(--fg2/--fg3 是主题令牌,换色就可能掉出阈值)
  · 断点矩阵补 1100 档:1280 与 390 都给绿时,1100 上风格 C 顶栏溢出 24px
- proposal_check.mjs: 新增 8 条本机地址硬规则关键词断言
2026-09-27 11:07:58 +08:00
Songlx516 cfa02562b2 test(ui): 加 PS5 视口档与逐 demo 回归钉
背景:用户在 PS5 上直接打开这个插件用,屏幕形状与桌面不同
(浏览器可视区约 1920×970,横向充裕、纵向紧缺),且本轮起
游戏页要显示 PKG 封面、存档页对标 Garlic SaveMgr 的三栏骨架。

- ui_demos_check.mjs
  · 新增 PS5 视口档(1920×970):断言一级导航单行不折行、落在
    首屏顶部、无横向溢出,并逐个切视图再各量一遍
  · 单页长滚动的 demo(B/C/D)没有一级导航 ⇒ 导航项 SKIP 而不是
    FAIL —— 假失败和假通过一样会让人忽略整组断言
  · 新增 EXTRA 逐 demo 回归钉:demo1(导航置顶后触控目标仍 ≥44px、
    不再是左侧竖栏)、demo5(封面网格 ≥6、含抽不到 icon0.png 的
    回退态与加密锁定态、筛选真会筛、存档页有快照列与操作日志终端、
    空状态可来回切换)
  · EXTRA 的判定必须以字符串形式传入:playwright 的 evaluate 在
    序列化阶段就拒绝函数数组
- proposal_check.mjs
  · 新增 8 条界面硬规则关键词断言(kstuff 启动 / 1920×970 /
    顶部单行吸顶 / sce_sys/icon0.png / .covers/ / 抽不到必须回退 /
    底部操作日志终端 / 以及刻意不抄的那四个动作)
2026-09-27 10:02:49 +08:00
Songlx516 9a03fb7c05 test(ui): measure every view, not just the one that happens to be visible
Style A and E are view-switched pages (mutually exclusive `.view` containers).
The runner only ever measured the default view, so the other four were
unguarded — and the switch itself was never asserted. Style E's first version
had navigation that only moved the `aria-current` highlight while the content
stayed identical, which is exactly the failure this now catches.

Both gaps paid for themselves immediately:

- style A: `view-tasks` overflowed 65px at 390px. `.task` is a grid item whose
  `min-width:auto` resolved to min-content (371px) and burst the single column
  out of a 288px content area. Fixed with an explicit `minmax(0,1fr)` track,
  plus `overflow-wrap:anywhere` on the mono path line (a long
  `Media/Streaming/…/pak` segment has no break opportunity).
- style E: the fake navigation had to be replaced by real view switching.

Also tightened two things that were measuring the wrong element:

- style E's contrast targets moved into the default view — reading
  `getComputedStyle` on a `display:none` node returns values, but "measuring an
  element nobody can see" is not a check.
- the focus-ring group now switches to a view that actually contains a
  focusable row; otherwise the "inline inset ring" convention was silently
  skipped and the nav button was measured instead.

140 assertions, all passing.
2026-09-27 09:46:19 +08:00
Songlx516 9740a252a9 docs(check): pin the save-writeback "forced snapshot" vacuum as a regression
Enumerated every independent save-writeback implementation by fingerprinting the one
system call a write-back cannot avoid — sceFsCreatePfsSaveDataImage. 25 code hits,
minus SDK stubs and verbatim derived copies leaves 5 distinct projects; read each
write path. Result: 0 of 5 force a snapshot before write-back.

  garlic-savemgr      copy_file(local, ORIGINAL) with O_TRUNC -> the original is
                      truncated in place; no copy at all when it already lives on
                      /data/.  (src/main.c:419 + :692)
  garlic-worker       no write-back step at all, and save_periodic_cleanup()
                      unlinks every leftover copy -> using it as a backup loses
                      the save.  (src/ps5/savedata.c:287-298)
  elf-arsenal family  verbatim copy of the above
  ps5-sd-tool         tmp image -> copy_recursive -> unmount; 0 backup keywords
  apollo-ps4          real backup UI, but _addBackupCommands() lists "Apply Changes
                      & Resign" BEFORE the "File Backup" section -> edit first
  savescum (MIT)      best backup UX (timestamped), but restore never forces a
                      backup first, and FTP sees only plaintext
  vsh-utils/trophies  0 hits

So this is the domain's only vacuum, and it is a live data-loss path in the shipped
competitor rather than merely a missing feature. Recorded as the domain's admission
criterion, not an optional extra.

proposal_check.mjs:
- tables 14 -> 15 (new section 2-2-bis evidence table)
- NEW named assertion: no table may exceed the 960px content column. The generic
  page-overflow check did catch this, but pointed at the page instead of the cause.
  TRAP: the sheet's global `td:first-child{white-space:nowrap}` also matches a
  `td[colspan]` full of prose (it IS the first child of its row), so one note row
  rendered as a single 2265px line and blew the table out to 2085px. Fix was to
  remove the conflict — move the prose out of the table — not to raise specificity.
- keyword assertions for the four hard rules and the evidence
- quotedOnly guard: the mis-stated install-layer AuthID 0x3800000000000010 (zero hits
  in etaHEN; section 6 carried a stale copy of it until now) may only survive inside
  a correction, never as a live claim
2026-09-27 09:28:12 +08:00
Songlx516 9e0032c019 test(ui-demos): 将风格 E 纳入无头 UI 验收
- DEMOS 注册 ps5-ui-demo-5-harness.html,并补 5 个对比度采样点
- 备用主题分支由「仅 demo4 暗色」改为按 demo 配置:
  D 亮→暗、E 暗→亮,两个方向都验(原来只测了一半)
- 截图前先 scrollTo(0,0):焦点组调用 el.focus() 会把目标行滚进视口,
  导致长页 demo 的整页截图拍到中段而非首屏
2026-09-26 19:57:54 +08:00
Songlx516 3af704f854 test(ui-demos): pin archive-suffix stripping for the new-subdir name
The four UI demos and the proposal spelled the auto-created folder as
`PPSA28495-app.rar/` -- the archive's full filename including its
extension. The folder must be `PPSA28495-app/`.

- demos 1-4: the subdir target is now `.../PPSA28495-app/`
- proposal: add the derivation spec -- strip the WHOLE archive suffix,
  not just the last extension (`a.part01.rar` -> `a`, not `a.part01`;
  `a.7z.001` -> `a`, not `a.7z`), reusing the regexes already in
  assets/main.js:816-869 -- plus a worked strip table covering .rar /
  .zip / .7z / RAR5 volumes / 7z byte-split sets
- proposal_check: table count 13 -> 14, assert the spec text survives,
  and quotedOnly("-app.rar/") so the wrong target cannot return as a
  live claim
- ui_demos_check: assert no `.(zip|rar|7z)/` path appears in any demo
2026-09-26 17:58:53 +08:00
Songlx516 2fb1a1cf56 test(ui): add headless UI-demo checker; whitelist it
Four UI style demos for the rewrite project now share one information
architecture, so the review criteria that keep them honest are worth
pinning as assertions rather than eyeballing:

- horizontal overflow at 1920 / 1280 / 390, including *after* opening
  the notes drawer and the extract dialog — a transform-translated
  drawer still extends the scrollable area, and `visibility:hidden`
  does not stop it
- WCAG-AA contrast on 20 sampled text/background pairs, measured from
  computed styles with an ancestor walk for the effective background.
  This is how a 4.43:1 secondary colour was found (threshold 4.5:1)
- focus ring actually renders, and the focused row stays inside its
  parent box (the inline-inset-ring convention for 46-52px list rows)
- no `button[disabled][title]` — `disabled` implies
  `pointer-events:none`, so the tooltip explaining *why* a button is
  disabled can never appear; the demos use `aria-disabled` instead
- content baseline: the four nav domains, the kstuff platform chip, the
  average-speed progress wording, the save-mount singleton notice, the
  pick-a-directory extract target, and "make subdirectory" defaulting
  to unchecked

The script also screenshots each demo and inlines them into the
workspace-root comparison page as data URIs (kept single-file, no
external requests), marking placeholders with a `data-shot` attribute
so re-runs are idempotent.

104 assertions pass.
2026-09-26 17:39:52 +08:00
Songlx516 9f3e91241e test(proposal-check): pin the singleDPI / no-etaHEN install-layer facts
Round 7 of the rewrite proposal added section 2-1-bis, which pins the install
path to a self-built installer that needs kstuff only (no etaHEN). Guard the
load-bearing facts so a later edit cannot silently drop them:

- GPL-3.0-or-later reuse right + the NOTICE attribution obligation
- the AuthID that actually works: DEBUG_AUTHID 0x4800000000000006
  (the documented 0x3800000000000010 has zero hits in the etaHEN source)
- self-elevation (kernel_set_ucred_authid / -lkernel_sys) and status polling
  (sceAppInstUtilGetInstallStatus), both of which this repo currently lacks
- the MetaInfo 0x38 -> 0x30 ABI correction: slot / is_playgo_enabled came from
  the Mono managed signature, not from the native struct
- DPI v2 being browser-reachable (Access-Control-Allow-Origin)

Also generalise the falsified-claim guard into quotedOnly() and add a second
case for "the SDK grants the permission" (rebutted by singleDPI's explicit
kernel_set_ucred_authid call, which is what actually runs on the console).

Tables 10 -> 13; 37 assertions, all passing.
2026-09-26 16:53:21 +08:00
Songlx516 de4b74c354 test(proposal-check): fix blind 390px table assertion, add install-layer guards
- 旧的窄屏表格断言用 scrollWidth/clientWidth 比较,而 `table{overflow:hidden}`
  让二者恒等 ⇒ 该断言永远不可能触发(假绿)。改为量 bounding rect 与视口
  比较,并放行 .dectable 横向滚动容器内的宽表 —— 正是这个盲点让 §8 表格
  的 12px 溢出漏检。
- 新增 6 条内容断言:kstuff / VoidShell / elf-arsenal / /proc/kstuff /
  autoload.txt / "没安装过 etaHEN"。
- 新增 1 条反向断言:被证伪的「HEN 事实标准就是 etaHEN」不得以断言形式
  回归;作为历史引文(上下文含 作废/修正/已删除)则允许存在。
- 结构断言:表格 9 → 10(新增 §2③ 竞品格局)。
2026-09-26 16:31:49 +08:00
Songlx516 fb7ac30c15 test: track the rewrite-proposal render check
`.build/proposal_check.mjs` verifies `ps5-nas-rewrite-proposal.html` -- a
document that deliberately lives outside the repo -- by loading it in real
Chromium and asserting: zero external requests, no horizontal overflow at
1100px and 390px, and the structural counts its sections depend on.

It was the last harness with no copy anywhere but this machine, so clearing
`.build` would have lost it. Adds it to the .gitignore allowlist next to the
other render checks and extends its assertions for the fourth capability
domain (save management):

- tables 8 -> 9, phase cards 5 -> 6
- six content assertions (garlic-savemgr, /dev/pfsmgr, sceFsMountSaveData,
  save-management heading, Phase 5, re-sign) so a later edit cannot silently
  drop that whole section
2026-09-26 16:22:39 +08:00
Songlx516 ede12cb914 docs: the READMEs said the harnesses aren't tracked -- they are now
Follow-up to the .gitignore whitelist. Both READMEs described the frontend
harnesses as living "outside the gitignore whitelist", which stopped being true
one commit ago. Reword both, and add .build/ to the project-layout tree now
that it holds tracked files.
2026-09-26 12:14:18 +08:00
Songlx516 edd1a1c177 chore: track the .build validation harnesses in git
The eight validation scripts live under .build/, which is an otherwise
whitelisted scratch directory. They were not on the whitelist, so they were
tracked nowhere -- a scratch-tree cleanup deletes them permanently, and there
is no copy on the release page either.

- ui_retry_test.mjs, ui_upload_menu_test.mjs -- headless-DOM tests against
  assets/main.js (extract password retry flow; upload menu, i18n key coverage,
  CSS cascade scoping, extract-button state)
- preview_build.py, preview_stub.html, preview_check.mjs -- serve the real
  index.html against a stub API and drive it in headless Chromium (hidden and
  focus state, layout overflow, computed highlight values, wrap thresholds)
- readme_header_render.mjs, compare_html_check.mjs, compare_simple_check.mjs --
  render checks for the README header badges and the comparison pages

Scripts themselves are unchanged; this only stops .gitignore from hiding them.
2026-09-26 12:12:41 +08:00
Songlx516 9d8c49a1d4 docs: correct upstream comparison claims, add a short release-notes template
Three "only this fork has it" claims contradicted upstream's own source:

* Encrypted archives are not fork-only. Upstream's frontend carries a full
  password flow (archive_password_required, prompt(), and it persists the
  password to localStorage), and its helper IS 7-Zip. Moved to "shared".
* Split volumes are not fork-only either: upstream's extension regex already
  matches .001 and its README lists .part01.rar.
* "The helper is ~100 MB" was wrong -- the released asset is 1,017,616 B, and
  the correction reverses the verdict: our single file is 33.7% smaller than
  upstream's two files combined.

Neither claim could have been caught from the READMEs: upstream's never says
the word "password". Same for the mixed-encoding note, which now says what the
fork actually adds (decoding the error text) instead of claiming the mechanism.

Also restored the inherited "rename" feature to both READMEs -- upstream lists
it, we have it, the fork's feature list had dropped it.

Release notes are now a flat "What's Changed" bullet list instead of an essay;
.github/release-notes-template.md records the shape so they stay short.
2026-09-25 14:43:53 +08:00
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
Songlx516 770dcb8a9f docs: mark v1.9.3M as published
The release is out, so every "unreleased / not yet committed" statement in the
docs became false. Updated:

  - CHANGELOG: the artifact header says published and links the release; the
    [v1.9.3M] section carries the release date; the sentence claiming the
    GitHub release was "still v1.9.2" is gone
  - README (both languages): the version line reads v1.9.3M, the "what's new"
    heading loses its "(unreleased)" qualifier, and the note around it links
    the release instead of saying the published binary lacks all of it
  - HANDOVER: current commit / tag / release, the artifact table row (no longer
    "worktree, uncommitted"), the test counts (27 -> 40 retry checks, plus the
    40 menu checks and the 12 headless assertions), and the two section headers
    that said "not committed"
  - DEVICE-TEST: the checklist has now been run and passed, so it is an
    acceptance record rather than a to-do; the rollback pointer also offers the
    v1.9.2 release page
  - FORUM-POST-v1.9.2: the note about two gaps closed in the worktree but not
    yet released now says they shipped in v1.9.3M

Left alone deliberately: the single remaining "the then-unreleased worktree"
phrase in DEVICE-TEST, which states a historical fact about the copy that was
named v1.9.2.
2026-09-24 21:11:40 +08:00
Songlx516 ba668ade50 release: v1.9.3M -- encrypted archives, plus the UI round that followed
First release under the fork-marker convention: VERSION_TAG carries a trailing
`M`, so /api/version, the PS5 start-up notification, the stdout banner, the UI
footer and the ELF file name all read "v1.9.3M" in one move -- and a fork build
can no longer collide with an upstream artifact of the same version, a mix-up
that already happened twice. The footer carries a tooltip spelling the marker
out.

Two bodies of work.

1. Encrypted archives (ZIP / RAR / 7z)

   - ZIP: ZipCrypto (traditional PKWARE) and WinZip AES-256, through
     minizip-ng plus a vendored crypto layer (mz_crypt_wfm.c,
     mz_strm_pkcrypt.c, mz_strm_wzaes.c).
   - RAR: RARSetPassword, wired after RAROpenArchiveEx and before the first
     RARReadHeaderEx. The ordering is load-bearing, not stylistic.
   - 7z: 7zAES including -mhe=on encrypted headers, via a virtual
     ISeekInStream that splices a pseudo-header + the real archive + the
     decrypted header, so no offset stored inside the archive has to move.

   A wrong password is reported as ZIPX_ERR_PASSWORD, and a failed attempt
   leaves no staging directory behind.

2. Reporting, and the UI round that on-device testing produced

   - A RAR whose dictionary exceeds what the build supports now gets its own
     extract_dict_too_large code instead of being mis-reported as "entry too
     large"; the message names both the required and the supported size. The
     behaviour is deliberately unchanged -- such archives are still refused,
     because admitting one means allocating the whole window up front, which
     is why rarlab's own CLI refuses them by default.
   - The upload entry is a menu again: one "Upload" button opening "Upload
     files / Upload folder". The previous main-button-plus-small-arrow made
     "upload folder" effectively undiscoverable.
   - A drag-and-drop hint sits in the footer (hidden on the console browser,
     where drag is not how anyone uploads).
   - The extract button is now always present and merely disabled until
     exactly one archive is selected, instead of appearing out of nowhere.
   - Upload-and-extract on an encrypted archive now prompts for the password
     directly. The retry table used to be keyed by PATH, and for a non-ASCII
     directory the string the page holds and the string the server reports
     are not the same bytes -- the lookup missed, so the user got a bare
     error box and had to press Extract by hand before the prompt appeared.
     It is now keyed by task id, which the server assigns and echoes back
     verbatim.
   - Error text passes through decodeFsText(), so a GBK entry name no longer
     surfaces as `â®…ç§.psd`.
   - Local names are encoded with encodeFsText() before being joined onto a
     server-side path. fs_path_value() declines to rewrite a path if ANY code
     point exceeds 0xFF, so concatenating a local name onto a server directory
     produced a mixed representation and a silently dead path.
   - The menu row highlight was losing the cascade to the generic button rule
     (identical specificity, later in the file) while inheriting the toolbar's
     3px focus ring, which overflowed a 46px row. Both rules are now scoped to
     the panel and the keyboard cue is an inset ring, so it cannot escape the
     row at any line height.
   - The footer status line is clamped to a single line; a long
     "uploading 3/12: some-name.zip" used to wrap out of the 46px footer.
   - A first failed password attempt now says the archive is encrypted,
     instead of blaming a password the user was never asked for.

Artifact
  web-file-mgr-v1.9.3M.elf
  903,448 B
  sha256 8ca47d5aaca75085b32641300cce30fadb7df7749cb6b53d04f129bcecc286b7
  e_machine 0x003e (x86-64 / PS5)

  The file size is identical to the four builds before it, and every one of
  them carries a different sha256: only .rodata moved, and by less than the
  16 KiB section alignment absorbs. Compare sections with `readelf -SW` --
  never infer "nothing changed" from the byte count.

Verification
  - host suites: 140 ZIP + 37 RAR = 177 checks, 0 failures
  - 7z suite: 27 cases, 0 failures. The `aeshe` entry that used to sit in
    KNOWN_GAPS is gone -- the -mhe=on fixture now passes both the folder
    decoder and the extraction facade
  - frontend: .build/ui_retry_test.mjs (40 checks), .build/ui_upload_menu_test.mjs
    (40 checks), .build/preview_check.mjs (12 assertions in headless Chromium
    against the real page and a fixture API). Two layout regressions and the
    highlight cascade bug were caught by the last one and by nothing else --
    reading the source, both CSS rules "look correct"
  - built twice from this tree: byte-identical (cmp clean). rsync refreshes
    every asset mtime, so this is a genuine recompile, not make short-circuiting
    on unchanged sources
  - embedded assets verified in place with .build/check-elf-gzip.py, because
    gen-asset-module.py gzips them and plain `strings` finds none of their text
  - exercised end to end on a real PS5; the checklist is
    docs/DEVICE-TEST-v1.9.3M.md

Docs
  - docs/USER-GUIDE-zh-CN.md (new, simplified Chinese user guide)
  - docs/DEVICE-TEST-v1.9.3M.md (new, on-device acceptance checklist)
  - docs/REAL-CONSOLE-PROFILE.md (new, measured console behaviour)
  - docs/archive/HANDOVER-v1.8-planning.md (superseded v1.8 design notes)
  - CHANGELOG / README (both languages) / HANDOVER updated with the artifact
    fingerprint, the section deltas and the new test counts
v1.9.3M
2026-09-24 21:07:12 +08:00
Songlx516 3f80eb4692 release: v1.9.2 -- re-cut the release so tag = source = binary
The v1.9.1 tag was a lightweight tag sitting on 9a36c3c, four commits BEHIND
the tree that produced the released ELF, so `git clone --branch v1.9.1` could
not rebuild the published artifact. v1.9.2 is that same tree with the version
literal bumped, which puts the tag back on the commit the binary came from.

Source delta vs the v1.9.1 tag (all of it already shipped inside the v1.9.1
binary -- the tag was just wrong, nothing was missing from the download):

  - src/demangle_stub.c + -Wl,--icf=all: 1,034,328 -> 870,488 B (-15.8%)
    The demangler was only reachable from the uncaught-exception path.
  - upload entry: single-click file/folder pick, plus drag-and-drop upload
  - docs: language switcher in both READMEs

Artifact
  web-file-mgr-v1.9.2.elf
  870,488 B
  sha256 177e90fecf93a0251e83f67884fba4551051be248330b0d70fda8ea732f88e84
  e_machine 0x003e (x86-64 / PS5)

Verification
  - 163 checks (ZIP 108 + RAR 27 + 7z 28), 0 failures; `aeshe` remains the
    known -mhe=on gap
  - the build is reproducible: reverting the two version literals and
    rebuilding reproduces the v1.9.1 ELF byte for byte, and re-applying them
    reproduces this one. That is what pins the byte-level difference between
    the two artifacts down to the version string alone
  - docs/SIZE-OPTIMIZATION.md gains appendix A (artifact-consistency
    verification, incl. the 55,280-byte raw diff explained as linker string
    pool relayout) and appendix B (per-symbol accounting: 628 symbols
    removed, 0 added, 76 ICF fold groups)

Not yet validated on retail hardware.
v1.9.2
2026-09-20 17:33:32 +08:00
Songlx516 807d129d8d build: drop the unreachable C++ demangler, fold identical code (-15.8%) 2026-09-20 15:44:30 +08:00
Songlx516 70eaa1027d ui: 上传入口去掉二级菜单,新增拖拽上传
- 上传按钮拆为两个一步入口:主按钮选文件、箭头选文件夹,
  删除 split 下拉菜单(含 .split-menu 样式与 toggleUploadMenu)。
- 单选压缩包时询问一次是否解压,取代原「上传单文件并解压」菜单项;
  识别范围放宽到 7z / 分卷(复用 isExtractableArchive)。
- 新增拖拽上传:webkitGetAsEntry 递归收集文件与文件夹,
  全屏遮罩提示「松开即上传」。
2026-09-20 13:19:20 +08:00
Songlx516 575f94cd39 docs: 英文 README 顶部补语言切换条
之前提交漏掉了 README.md 的切换条,仅中文版有;现补上,两侧互相链接。
2026-09-17 12:14:09 +08:00
Songlx516 2059c0e4b5 docs: 中英文 README 顶部加语言切换条
两份 README 互相链接 English/简体中文;顺手修正英文版版本号 v1.9 -> v1.9.1。
2026-09-16 17:36:22 +08:00
Songlx516 9a36c3cb30 docs: 新增简体中文 README (README.zh-CN.md)
翻译自 README.md,版本号同步为 v1.9.1,并补全 7z 解压章节、修正项目结构。
v1.9.1
2026-09-16 16:57:49 +08:00
Songlx516 5e8b56f23e docs: record the multithreaded LZMA2 path and the ZIP fsync removal 2026-09-16 14:31:25 +08:00
Songlx516 5cc493d152 perf: multithread the plain-LZMA2 7z folders via Lzma2DecMt
Most 7z folders are a single plain LZMA2 coder (7-Zip default -m0=lzma2
-ms=on). For that shape the facade now drives the SDK's own parallel
decoder (Lzma2DecMt, vendored with MtDec/Threads) instead of the
single-threaded chain walk; the chain stays responsible for every other
shape (BCJ2 stacks, encrypted folders, exotic methods) and as the
fallback when the platform cannot provide threads (SZ_ERROR_THREAD
downgrades, never fails).

Adapters: ISeqInStream over the read_at callback, ISeqOutStream into the
staging sink (runs on the calling thread, so the sink contract is
unchanged), ICompressProgress polling the cancel hook. The folder CRC is
taken over the decoded stream as before.

329 MiB fixture, internal timing: 1050 ms (asm, 1 thread) -> 930 ms
(4 threads) -> 765 ms (8 threads + 1 MiB inBufSize_MT), i.e. 1.37x,
now at parity with 7za -mmt=off (898 ms); 7za -mmt=8 is 485 ms. The PS5
count is 8 Zen 2 cores like the dev host; SZX_MT_THREADS=8.

Test matrices: 7z 28 + ZIP 108 + RAR 27 checks green.
2026-09-16 14:30:29 +08:00
Songlx516 8766178aa1 perf: stop fsyncing every ZIP entry before its staging rename
The per-entry flush made entry count the dominant cost: an 8000-file
fixture spent most of its wall time in fsync (the extraction phase ran
past 200 s with it, 14.5 s without, same machine). Nothing in the
design needs that durability: a crash mid-extract leaves the staging
tree, which the next run discards, and publish is a rename-only phase.
The RAR and 7z engines never flushed per entry; all three now share
the same policy.

Measured on the same fixture (8000 small files, 1.9 MiB packed):
- with per-entry fsync: >200 s, not finished
- without: 14.5 s, extract phase 14.47 s CPU (scan 0.01 s, publish ~0)
- official 7-Zip on this host: >400 s (real-time AV scans every file
  it creates; the PS5 has no such factor)

ZIP/RAR matrices green (108 + 27 checks).
2026-09-16 14:01:43 +08:00
Songlx516 fd48232b6c perf: enable the SDK's assembly LZMA decoder (1.26x on LZMA2) 2026-09-16 13:07:56 +08:00
Songlx516 49636b0b8a perf: measure our engines against 7-Zip, and record why bash cannot time here 2026-09-16 13:07:55 +08:00
Songlx516 f1633321d2 docs: compare upstream v1.8's extraction design against ours 2026-09-16 13:07:54 +08:00
Songlx516 9058d5a97e docs: record the repo-root cleanup and ship the license audit 2026-09-16 13:07:52 +08:00
Songlx516 5d88914674 docs: rewrite HANDOVER.md for the closed-out 7z + versioning state
The handover doc had grown to 349 lines through accretion: three separate
7z sections that repeated each other's facts, a full Frostpunk 2 debugging
transcript that has been resolved since e0bc4a6, and a "current status" line
still pointing at 00b750d. Anyone picking this up would have to read the
whole thing to work out what is actually left to do.

Rewritten around the questions a new maintainer actually asks:

  * what is the state, in one table, with the exact ELF hash
  * what do I run to build / test (the one-click script, plus the
    /usr/bin/bash caveat and the PIPESTATUS/od-encoding traps)
  * what are the design constraints I must not break (7z coder out_size,
    volstream struct layout, AesOpt.c flags, *at() on PS5)
  * what is left (only -mhe=on, plus real-hardware verification)

History is compressed to the facts that still constrain future work, not
the sequence in which they were discovered. New sections cover the version
plumbing (2.2/2.3), the license/ownership audit result, and the two
trap files sitting untracked in the repo root.

No code changes.
2026-09-15 16:50:00 +08:00
Songlx516 0d036a74a7 feat(ui): source the footer version from /api/version instead of a JS literal
The footer string in the browser was a hard-coded `const APP_VERSION = "v1.9"`
in assets/main.js, completely disconnected from the Makefile's VERSION_TAG.
It had already drifted: the UI kept showing "v1.9" through the entire v1.9.1
release, while the startup notification and the ELF name both said v1.9.1.
Two sources of truth for one fact is one too many.

Backend:
  * new src/version.c -- GET /api/version -> {"ok":true,"version":"v1.9.1",
    "titleId":"FMGR88888"}, built straight from the -DVERSION_TAG /
    -DTITLE_ID macros the Makefile already passes, so there is nothing left
    to forget when bumping a release.
  * registered in filemgr_api_request() next to /api/space and declared in
    filemgr_internal.h; added to COMMON_SRCS so both the PS5 and the linux
    targets link it.

Frontend:
  * the literal becomes APP_VERSION_FALLBACK and is rendered first so the
    footer is never empty, then loadVersion() refreshes it from the API in
    the background. A failed request is deliberately swallowed -- a wrong
    version string is cosmetic and should not raise an error toast.
  * both the host i18n path and the PlayStation browser branch are
    untouched; the footer element itself (#versionText) does not move.

Note the earlier answer to "does the version show in the PS5 UI?" was that it
does -- the bottom-right footer -- but it was showing the stale literal, not
the build's real tag. That is what this fixes.

Verified:
  * WSL prospero-clang 18.1.8 -- ELF 1017864 B, e_machine=0x003e, contains
    both the string "v1.9.1" and the "/api/version" route.
  * MinGW gcc 16.2.0 host tests -- ZIP 108 + RAR 27 + 7z 28 = 163 checks,
    0 failures.
2026-09-15 16:48:53 +08:00
Songlx516 1fa2f0953f build: name the output ELF after VERSION_TAG (web-file-mgr-v1.9.1.elf)
Every build overwrote the same `web-file-mgr.elf`, so the only way to tell two
builds apart was the sha256 -- and the one thing you cannot see from a
filename on a USB stick is which firmware you are about to run. Derive the
binary name from VERSION_TAG:

  BIN       := web-file-mgr-$(VERSION_TAG).elf
  LINUX_BIN := web-file-mgr-linux-$(VERSION_TAG)

Because VERSION_TAG also feeds -DVERSION_TAG, bumping it now updates the
embedded version string, the PS5 notification and the filename in one place.

The build scripts must not hardcode the name or they break the moment the
version changes, so build-elf-wsl.sh now reads it back out of the Makefile:

  VERSION=$(sed -n 's/^VERSION_TAG *[?:]*= *//p' Makefile | head -1)
  BIN_NAME="web-file-mgr-${VERSION}.elf"

rsync exclude widened to /web-file-mgr-*.elf, and build-win.sh now always
re-pushes the WSL script (it only copied it when missing, so editing
build-elf-wsl.sh silently kept running the stale copy).

Verified: same sources as the previous commit produce a byte-identical binary
under the new name -- sha256 94851c26ebe1a3b296fb78748aa8de90ea0c022aa4004d64e700d4156a0cc64a
both before and after, so this commit is pure plumbing.
2026-09-15 13:35:38 +08:00
Songlx516 f4fd464353 build: bump VERSION_TAG to v1.9.1 and whitelist .build/ in .gitignore
The ELF we tagged v1.9.1 still reported "Version: v1.9" -- Makefile line 16
pinned VERSION_TAG by hand and nobody updated it when the tag was cut, so the
PS5 boot notification and `web-file-mgr.elf --version` both mislabeled the
binary. Bump it to v1.9.1 and switch := to ?= so a one-off build can override
it (`make VERSION_TAG=v1.9.2`) without touching the file.

Rebuilt ELF:
  1017864 B, sha256 94851c26ebe1a3b296fb78748aa8de90ea0c022aa4004d64e700d4156a0cc64a
  e_machine=0x003e, `strings` confirms the embedded literal is now v1.9.1

.gitignore: the per-directory deny list for .build/ never kept up -- every
debugging session drops a new probe directory (aespw/, bigzip/, chaincheck.py,
dbg/, engine-e2e/, ...) and they all showed up as untracked. Flip to a
whitelist: ignore .build/* and re-allow only the files that are genuinely part
of the repo. Also ignore .workbuddy/ (session data: never commit, never delete).
2026-09-15 13:24:04 +08:00
Songlx516 5229cd59df docs: .gitignore comment -- list the three tracked .build scripts 2026-09-15 13:16:26 +08:00
Songlx516 a54f34bcab build: add one-click Windows -> WSL ELF build scripts
Two scripts, so building for PS5 is one command:

  /usr/bin/bash .build/build-win.sh

build-win.sh (Windows side, Git Bash)
  Just pipes the WSL script into wsl.exe via stdin and propagates the
  exit code. Uses `wsl.exe -- bash < script` rather than `-- bash -c '...'`
  because the repo path contains spaces and -c's argument gets split.
  Copies the WSL script over first (.build/ is excluded from rsync so
  the WSL side may not have it yet).

build-elf-wsl.sh (WSL side)
  5 stages, each failing loudly:
    1. env check   -- SDK present, prospero-clang runnable,
                      libmicrohttpd in the sysroot
    2. rsync       -- Windows repo -> /home/song/ps5-web-file-manager,
                      incremental, skipping ps5-obj/ gen/ tests/ docs/,
                      then a smoke check that this session's key files
                      landed (sevenz_*.c, zipx_common.c, Makefile, ...)
    3. make all
    4. verify      -- size / sha256 / e_machine, with an explicit
                      aarch64 guard (183) since PS5 is x86-64 (62)
    5. copy back   -- ELF -> Windows project root

make's exit status is read through PIPESTATUS (the `| tail -120`
would otherwise swallow it), so a failed compile actually fails the
script instead of sailing on to the verify stage.

One gotcha worth recording: `od -An -tx2 -j 18 -N 2` prints the
2-byte little-endian *value*, so the on-disk bytes "3e 00" come out
as "003e", not "3e00". Matching on the byte order fails; convert with
$((16#$EM)) and compare numerically (62 = x86-64, 183 = aarch64).

Verified end to end: 1017864 bytes, sha256 2eb04581...473a157,
e_machine 0x003e (62), copied back to the Windows project root.
2026-09-15 13:15:10 +08:00
Songlx516 98679423e0 docs: HANDOVER.md status table -- 六种组合 + 密码 + PS5 构建全部闭合
补充本会话后半部(commit 112f8a6 UX 修补 + 00b750d PS5 真机构建)的进度记录:
  * 第五节任务清单状态表从「进行中/⏳」改为「✅」并补一行「ⓩ PS5 真机构建」
  * 当前矩阵更新为 ZIP 108 / RAR 27 / 7z 28 = 163 checks 全绿
  * 「其他遗留」补「新增 ZIP/RAR/7z 三类分卷的真机验证」「新增加密 7z 的真机验证」
  * 新增「七组合细节」小节列出七组合每个跨引擎不变量
  * 新增「PS5 真机构建已闭环」记录 AesOpt.c 失败 → 不能简单删 → 路径过滤 -maes -mavx2 -mvaes 的完整决策链
  * 新增「7z facade UX 修补」回顾 5 项 i18n + 进度报告回归锁定
2026-09-13 14:14:21 +08:00
Songlx516 00b750d80b build: enable -maes -mavx2 -mvaes for the 7z TU family so prospero-clang accepts AesOpt.c
PS5 (Zen 2) has every AES-NI / AVX / VAES instruction set, but prospero-clang
defaults to a generic x86_64 target that does NOT pre-declare
_mm256_aesenc_epi128 -- AesOpt.c relies on a compiler-version macro that lets
clang 18 in unconditionally, so it blew up with 20 errors and stopped at
-ferror-limit= before the rest of the build could even run.

You can't drop AesOpt.c either: Aes.c references the
AesCbc_{Encode,Decode}_HW / AesCtr_Code_HW[256] symbols via AesGenTables,
so without AesOpt.c the link fails (five undefined-symbol errors).

Fix: add a path-filtered CFLAGS_7Z = -maes -mavx2 -mvaes and apply it only to
third_party/7z/*.c -- zlib and minizip-ng don't need it, the project sources
don't need it, and it's a no-op at runtime since the symbols exist on PS5.

Verified:
  * WSL prospero-clang++ 18.1.8 (FreeBSD sysroot) -- success
    web-file-mgr.elf 1017864 B, sha256 2eb04581...473a157, e_machine=0x003e
  * MinGW gcc 16.2.0 host tests -- 163 checks (ZIP 108 + RAR 27 + 7z 28) all pass
2026-09-13 14:11:35 +08:00
Songlx516 112f8a6b72 fix(7z): keep the spinner alive on small archives; surface password errors with the archive path
UI regression: a 7z with fewer than 4096 entries spun a "scanning archive"
label and never replaced it until extraction had been running long enough
for the byte-level callback to fire, on a slow PS5 that meant "looks hung".
Lower the per-entry barrier in scan_entries to 256 entries, force a final
report after the scan so entries_total / bytes_total always reach the UI,
and force a report at the start of precheck_folders so the throttled
"between scan end and first extraction byte" window is not silent either.

Error regression: when the password was wrong the engine returned
ZIPX_ERR_PASSWORD with an empty detail field; extract_set_error copied it
into task->error_arg verbatim, so the i18n template's {arg} expanded to ""
and the user saw "Wrong password: ". Carry the original archive path into
the error detail so the toast reads "Wrong password: myfile.7z".

Languages: drop the stale "only ZIP/RAR" line from err_extract_unsupported
(7z has been supported since v1.9), and add the new err_extract_password
key in both en/zh.

Tests: lock down both regressions.
  * progress recorder: bytes_done monotonic, total > 0, final byte count
    reaches >= 90% of the declared total.
  * wrong password: r.detail contains the archive basename.

7z 28 + ZIP 108 + RAR 27 = 163 checks, 0 failures.
2026-09-13 10:16:53 +08:00
Songlx516 27eec8c399 docs: HANDOVER.md -- 7z 引擎 + 分卷 + 7zAES + dispatch 全落地 2026-09-13 10:00:07 +08:00
Songlx516 200b38604f feat(7z): dispatch + archive recognition + password UI
With the extraction engine in place (021c9cb), the /api/extract task
finally picks .7z files up.

* src/filemgr_internal.h -- file_task_t gains extract_password (256
  bytes, UTF-8, the size a sane user will never exceed but enough for the
  encrypted RAR / 7z passwords the engine actually accepts).
* src/extract.c -- extract_dispatch() routes a single .7z to sevenz_extract()
  and a byte-split set (name.7z.001, .002, ...) to the same call (the
  volume detector classifies by extension).  api_extract() reads the
  optional "password" form field and copies it into the task; the error
  code map gains ZIPX_ERR_PASSWORD -> "extract_password".
* Makefile -- the sevenz_extract / sevenz_chain / sevenz_volstream sources
  join COMMON_SRCS, and every third_party/7z/*.c (LZMA SDK 26.03, public
  domain, decoder subset) joins THIRD_PARTY_C_SRCS.  The C flag set gains
  -Ithird_party/7z and -DZ7_PPMD_SUPPORT, the latter so PPMd is built
  in (the SDK guards PPMd sources behind a macro that defaults to off).

* assets/main.js -- isSevenZipArchive + isSevenZipSplitVolume; both feed
  isExtractableArchive.  actionExtract() now prompts for a password on
  7z archives (encrypted-stream support is opt-in -- the engine rejects
  unencrypted archives with no password just fine, but the prompt saves
  a wasted scan when an archive is encrypted).  startExtractTask() takes
  the password and sends it as a form field.

* assets/lang-{en,zh}.js -- extractPasswordAsk string for the new prompt.

ZIP 108 / RAR 27 / 7z 26 still all green on the host.

No real-device build attempted in this commit: the WSL runner is the
one that knows the PS5 toolchain, and the user has to be the one to
push it through.  The Makefile changes are the wiring the cross
compiler needs; a `make linux` on a Linux host with libmicrohttpd-dev
should link end-to-end.
2026-09-13 09:59:30 +08:00
Songlx516 021c9cb339 feat(7z): extraction facade + format dispatch + UTF-8 host shim
The 7z chain decoder (commit 2748b38) handles bytes; what the /api/extract
task needed was a third sibling of zip_extract.c and rar_extract.c that
runs that chain over a real archive, mirrors their staging / publish /
rollback / name-validation machinery, and fills in zipx_result_t the same
way so the dispatcher and the web UI can treat every format alike.

* src/sevenz_extract.[ch] -- one pass over the archive (scan: validate every
  name, sum bytes, drop archives the chain cannot drive), then folder by
  folder through the chain.  The publish phase recurses with OVERWRITE and
  MERGE alike for matching directories, and only differs at the file
  leaves -- a strict non-recursive OVERWRITE would have made the policy
  useless for any archive that overlaps a directory already on disk.
* src/zipx_common.c -- the limits profiles and zipx_status_string() that
  the three engines share.  Pulled out of zip_extract.c so every consumer
  gets the same error text and the same MAX_* numbers.
* src/zip_extract.[ch] -- ZIPX_ERR_PASSWORD joined the contract (7zAES).
* tests/run-sevenz-tests.sh -- the engine matrix now also runs the facade
  over every fixture, plus 22 error and policy checks against
  test_sevenz_extract.  KNOWN_GAPS holds only "aeshe" (encrypted header);
  the plain 7zAES fixture is in the matrix.
* tests/test_sevenz_extract.c -- end-to-end driver for the facade.

The POSIX shim the host test runner injects needed three more pieces so
the tests pass on a CP936 host:

* lstat/stat -> wfm_stat (the MinGW ANSI entry points can't see a UTF-8
  filename in CP936; without the redirect a non-ASCII entry causes the
  publish phase to lstat() a mangled path and fail with ENOENT).
* opendir/readdir/closedir -> the wide variants, so readdir() hands us
  real UTF-8 names instead of CP936 bytes that nothing can round-trip.
* fopen -> _wfopen, for the test helpers that read a non-ASCII file
  back to verify it.

ZIP 108 checks / RAR 27 checks / 7z 26 checks, all green; the AES
fixture extracts with the password "Secret123" and rejects wrong / absent
passwords with "password required or wrong" / "wrong password, or the
archive is damaged".

tests/fixtures/ gains the volume-set fixtures (broken.zip.001, gap.zip.*,
disks.*, parts.part*.zip, plain.zip.*, split_single.zip) that were
generated by tests/make_split_fixtures.py in earlier sessions but never
made it into the index.

Next commit wires sevenz_extract() into the dispatch in src/extract.c and
recognises 7z archives in assets/main.js.
2026-09-13 09:52:44 +08:00
Songlx516 4da345a6e8 feat(7z): decrypt 7zAES packed streams
7-Zip wraps the packed stream of a password protected archive in a 7zAES
coder (method 0x06F10701).  The bundled LZMA SDK has no C implementation of
it, so add one to the folder chain:

  * aes_props_layout() splits the property bytes into numCyclesPower, salt
    and IV exactly as CDecoder::SetDecoderProperties2() does, and rejects a
    block whose declared lengths do not match its size;
  * aes_derive_key() runs the 7-Zip KDF: SHA-256 over
    (salt || password_utf16le || counter_le64) repeated 1 << numCyclesPower
    times, with the 0x3F power meaning "no derivation", the key being the
    salt and password copied into 32 bytes;
  * utf8_to_utf16le() converts the caller's password, since that is the
    encoding 7-Zip hashes;
  * the SZ_N_AES node decrypts one block at a time with Aes_SetKey_Dec() /
    AesCbc_Init() / g_AesCbc_Decode(), keeping the trailing partial block in
    the node so the declared (unpadded) unpack size is what gets delivered.

sz_chain_decode() gains a `password` argument, and sz_chain_needs_password()
lets a caller ask for one before it touches the filesystem.  A folder that
needs a password and did not get one fails as SZ_CHAIN_ERR_PASSWORD with a
message naming 7zAES, rather than as a generic corrupt archive.

Because a wrong password decrypts to plausible looking rubbish, a failure
from a folder that carried a 7zAES coder gets "(a wrong password looks like
this)" appended, so the UI can offer a retry instead of calling the file
damaged.

numCyclesPower comes from the archive and drives 2^n SHA-256 passes, so it is
capped by the limits profile (max_aes_cycles, 2^24 for both the default and
the large profile).

An encrypted *header* (-mhe=on) is a different problem: the archive header is
encrypted with the same coder and has to be decrypted before any folder
exists.  That stays out of scope and is reported as such.

tests/run-sevenz-tests.sh: aes leaves KNOWN_GAPS and passes; aeshe stays
there with the reason spelled out.
2026-09-13 09:13:04 +08:00
Songlx516 9b2f5a07c3 feat(7z): read multi-volume 7z sets as one stream
A split 7z (`name.7z.001`, `.002`, ...) only contains the first slice of the
archive, so SzArEx_Open() followed the header offsets off the end of the file
and gave up with SZ_ERROR_INPUT_EOF -- the set was unusable even though the
file listing of each part looked fine.

src/sevenz_volstream.c backs ISeekInStream with the ordered part list, so the
SDK sees one continuous archive and never learns it was split. Nothing is
merged on disk: a 160 GiB set would otherwise need a second 160 GiB scratch
copy. A 7z split is a plain byte split, so offsets stored inside the archive
are already absolute and the Stream/Seek callbacks stay trivial.

The ordered list comes from zipx_volume, which already understands
`name.7z.001` naming, so an incomplete set fails before any decoding starts
and names the missing part. Two further checks report a set that could not
have worked anyway: a lone first volume, and a part whose size differs from
the earlier ones (7-Zip cuts equal sized parts and lets only the last one be
short).

To reuse the split-layout constants without dragging the ZIP stream header
into the 7z build, ZIPX_VOL_MODE_* moved to zipx_volume.h: they describe the
set, and every consumer of zipx_volume_t needs them.

The e2e driver now opens its input through the volume stream, so the packed
data reads go through it too.

Matrix: all 10 single-volume fixtures plus vol.7z.001 are byte-identical
(13 passed, 0 failed). Remaining known gaps: 7zAES (aes, aeshe).
Regression: ZIP 108 / RAR 27 checks, 0 failures.
2026-09-12 17:53:17 +08:00
Songlx516 2748b383bd feat(7z): decode folders with our own folder header parser and coder chain
The LZMA SDK's CSzFolder caps a folder at four coders, so anything 7-Zip
writes for BCJ2 (4 packed streams + 5 coders) comes back as
SZ_ERROR_UNSUPPORTED from SzAr_DecodeFolder. The header scanner has no such
limit (64 coders), which makes the failure look like corrupt data instead.

src/sevenz_chain.c parses the folder descriptor itself and drives the codec
chain as a pull pipeline: every decoder hands up to N bytes to its consumer
and asks upstream for more when it needs it, so a folder is decoded straight
into the caller's sink without staging the whole thing. Covered here:

  - Copy / LZMA / LZMA2 / PPMd, Delta, and the x86, PPC, IA64, ARM, ARMT,
    SPARC branch converters
  - BCJ2, whose three side streams have to be materialised while MAIN keeps
    streaming
  - per coder output sizes taken from UNPACK_INFO, which is what keeps the
    chains exact

tests/sevenz_chain_e2e.c decodes each folder once and splits the byte stream
across the entries that share it, mirroring the real extraction path, and
checks the per-entry and per-folder CRCs on the way past.
tests/make_sevenz_fixtures.py builds the fixture matrix (including
non-solid `-ms=off` folders), and tests/run-sevenz-tests.sh runs it while
keeping a KNOWN_GAPS list that fails loudly once a gap closes.

Two real bugs surfaced while bringing the matrix up:

  - build_coder() gave every coder node the *folder's* unpack size instead of
    its own entry in UNPACK_INFO. In a BCJ2 folder MAIN is regularly larger
    than the folder's final size (300066 vs 300000 in the fixture), so the
    node silently truncated its own output and the chain came up short by 21
    bytes per LZMA2 layer. Only archives where a coder is bigger than the
    folder tripped it, which is why single-folder BCJ2 passed and the same
    archive split into per-file folders did not.
  - the e2e driver kept `written` across folders, so a folder that failed
    mid-entry made every later entry look half-written and the failures
    cascaded.

Scripts no longer delete anything: objects are overwritten and each case gets
a fresh output directory, so the suite runs under a guarded shell.

Matrix: 10/10 single-volume fixtures byte-identical (store, lzma2, lzma,
ppmd, bcj, delta, utf8, bcj2, solidoff, bcj2off). Still open and listed as
known gaps: 7zAES (aes, aeshe) and multi-volume input (vol.7z.001).
2026-09-12 17:46:32 +08:00
Songlx516 8bee84bd49 feat(7z): vendor LZMA SDK and add the 7z fixture/verification harness
Groundwork for 7z support (the second of the six format x volume
combinations). No engine code yet: this lands the vendor subset, real
fixtures and the end-to-end driver, so the engine has something to be
built and checked against.

Vendor (third_party/7z, LZMA SDK 26.03, public domain):

- decoder-only subset: 7zArcIn/7zDec container, Lzma/Lzma2/Ppmd7/Bcj2/
  Bra/Delta codecs, Aes/Sha256 for the password channel to come
- the encoder half (LzmaEnc, Xz*, Sort, Threads, ...) is deliberately
  not vendored; the engine never encodes
- README.md records two SDK limitations that shape the engine design:
  * CSzFolder caps a folder at 4 coders / 3 bonds, so 7-Zip's own BCJ2
    chain (BCJ2 + 4xLZMA2 = 5 coders) is refused by SzAr_DecodeFolder
    even though SzArEx_Open parses it happily - the engine therefore
    parses folder blobs and drives the codec chain itself
  * the C decoder ships no 7zAES coder at all, so both -p and -mhe=on
    archives are rejected until the engine implements it on top of the
    vendored Aes.c / Sha256.c

Tests:

- make_sevenz_fixtures.py builds real archives with an actual 7-Zip
  binary (7za from the Extra package; falls back to the reduced 7zr,
  which has no PPMd encoder): store/lzma/lzma2/ppmd/bcj/delta/bcj2,
  AES with plain and with encrypted headers, a 7-part -v100k volume
  set, and UTF-8 entry names
- sevenz_e2e.c extracts an archive and validates every entry against
  the stored CRC; it calls the wide-character file APIs on Windows, so
  non-ASCII entry names are really created instead of silently failing
- run-sevenz-tests.sh builds the subset and the driver, then runs the
  fixture matrix with an explicit KNOWN_GAPS list that fails loudly
  when one of the gaps starts passing

Status: store, lzma, lzma2, ppmd, bcj, delta and utf8 all extract
byte-identical to the source tree (7/10). bcj2, aes, aeshe and
vol.7z.001 are the known gaps, each mapped to a specific piece of
engine work still to come.
2026-09-12 17:17:45 +08:00
Songlx516 da565ccb7c feat(zip): extract multi-file ZIP volume sets
A split ZIP is several files that only form an archive together. Until now a
`.zip.001` was rejected as an unsupported format and a `.z01`/`.partN.zip`
member could not be resolved, so these downloads were unusable.

Three naming conventions are recognised, from any member of the set:

  name.zip.001, name.zip.002, ...   byte split (7-Zip)
  name.part1.zip, name.part2.zip    byte split (WinRAR)
  name.z01, ..., name.zip           zip split disks (Info-ZIP / PKZIP)

The parts are served through a new stream that makes them look like one
archive, so nothing is copied to disk first (a 160 GiB set would otherwise
need a second 160 GiB scratch copy):

- src/zipx_volstream.c implements an mz_stream over the ordered part list.
  Byte splits keep absolute offsets; zip split disks carry offsets relative to
  the disk an entry starts on, so that mode honours
  MZ_STREAM_PROP_DISK_NUMBER the way mz_zip_entry_seek_local_header drives it
  and starts on the last volume, where the central directory lives.
- src/zipx_volume.c groups the set, verifies the numbering is complete and
  reports exactly which volume is missing.
- zip_extract.c tries the layout implied by the naming first and the other one
  as a fallback, so tools that disagree about offset bases still work.
- extract.c dispatches volume members to the engines and, when the caller
  asked for the source to be deleted, removes every volume instead of leaving
  orphaned parts behind.

Error messages are specific on purpose: an incomplete set reports the missing
file name (e.g. "gap.zip.002 is missing"), a lone first volume says the
remaining volumes are missing, `.rar.001` sets explain the rename that makes
them work, and 7z volumes say 7z is not supported yet.

Tests: tests/make_split_fixtures.py builds all three layouts (the disk set is
written with per-disk offsets exactly as APPNOTE 4.4.11 describes) plus two
broken sets; test_zip_extract.c extracts each layout from a different member
and byte-compares the nested binary entries. 108 ZIP + 27 RAR checks, 0
failures.
2026-09-12 16:13:20 +08:00
Songlx516 01e27f3825 fix(rar+zip): byte-accurate RAR progress; stop false "compression bomb" on small entries
Two issues found in real-machine testing of v1.9:

1. RAR multi-volume extraction never advanced the progress bar. The engine
   only accounted bytes_done after RARProcessFile() returned for a whole
   entry, so a multi-GB entry spanning volumes froze the UI for its entire
   duration. Wire RARSetCallback/UCM_PROCESSDATA, which unrar fires per
   decompressed chunk during disk extraction, and fold each chunk into
   bytes_done + a throttled progress report. (DLL-mode break handling is
   never armed, so cancellation stays entry-granular; returning -1 would
   be ignored.)

2. A 95k-file game zip was refused as a "compression bomb" because one
   small entry exceeded the per-entry uncompressed/compressed ratio cap
   (500/1000). Such entries are common and harmless in legitimate archives
   (zero-filled placeholders, sparse blobs): actual bytes written are
   bounded by the declared size (enforced during extract) and check_space()
   verifies the full declared total against real free space before any
   writes. Ratio screening now only applies to entries >= 1 GiB
   (ratio_min_bytes, default in both profiles); sub-GiB high-ratio entries
   are accepted. Error text now includes compressed -> uncompressed sizes.

Tests updated: small high-ratio bomb.zip now extracts OK under both
profiles and is rejected again when ratio_min_bytes is lowered below its
size; RAR multi-volume fixture asserts mid-entry progress events and that
final bytes_done reaches bytes_total. 73 ZIP + 27 RAR checks, 0 failures.
2026-09-07 22:25:11 +08:00
Songlx516 e0bc4a6ae0 fix(zip): fall back to plain path calls when *at() fails at runtime
Real-machine diagnosis of the Frostpunk 2 failure: mkdirat() returns -1
with errno 0 ("cannot create directory ... (No error)") on PS5 hardware.
The *at() symbols link against the SDK libc but misbehave at runtime,
while plain path-based calls work (the staging root open() succeeds and
small flat archives extract fine).

Every *at() call in the extract phase now retries as a full-path call
built from the staging root before reporting an I/O error:

- open_parent_dirs: mkdirat -> mkdir, openat -> open
- write_entry: openat -> open, renameat -> rename, unlinkat -> unlink

Error messages now append (errno=%d) so errno=0 can no longer hide
behind a misleading "No error" strerror string. Host suite stays green:
94 checks, 0 failures.
2026-09-06 22:25:09 +08:00