Commit Graph
371 Commits
Author SHA1 Message Date
AskaEth 6dd88a27a3 fix(core): 守卫的剥注释不再吞文件 + 变异 D 变红(M213 复审 P1-1 / P2-1)
复审 P1-1:`code_lines()` 对每一行找 `/*`,找着就当块注释开头。本仓库的文档注释里到处是
glob(`/usr/lib/jvm/*`、`rules.d/*.yaml`、`crash-reports/*.txt`、`.github/workflows/**`),
字符串里也有(`"mods/*.jar"`),这些 `/*` 没有配对的 `*/`,于是 in_block 一旦为真就再也
不会变假 —— 该文件从那一行到 EOF 全部被吞掉。实测 crates/core/src 里 8/371 个文件中招,
其中一个是 game/java/discover.rs(它自己有一处 reg query),复审判的变异 D 因此全绿。

- code_lines 只在「去掉前导空白后以 /* 开头」的行上进入块注释态,行中/字符串里的 /* 一律
  当普通代码。本仓库零处行首块注释,所以结构上不可能再吞代码。
- 新增 code_lines_is_not_fooled_by_glob_slashes_in_strings_or_docs:字符串里的 /* 不许
  吞掉后一行、文档 glob 不许吞掉真代码、真块注释仍要被剥。
- 新增 the_scan_reaches_the_end_of_every_file:逐文件核对**最后一行代码的行号**必须出现在
  扫面结果里(比数总行数尖锐 —— 文件普遍以 } 结尾,光比内容会被别处的 } 蒙混),外加
  全仓可见行占比 ≥60% 的兜底(本机实测 81%)。
- 变异 D(discover.rs:156 改回裸 Command::new)现在 exit 101 并点名该行;A/B/C 复验仍红。

复审 P2-1:原注释说「两个 flag 不能叠加」,按 MSDN 这是错的。改成:两个 flag 在 Win32 层
可任意组合,真正的坑是 std 的 creation_flags 是 setter(后一次静默覆盖前一次)。结论不变
(launch/process.rs 仍零改动),顺带修正 DETACHED_PROCESS 的说明。
2026-11-09 15:07:56 +08:00
AskaEth 40a296471d fix(core): 所有子进程统一走 platform::child::quiet + 角色守卫(M213)
GUI 父进程(PE Subsystem = 2)spawn 控制台子进程时,Windows 会为子进程新建一块
控制台——桌面上就是「黑框闪一下」。改前 crates/core/src 有 21 处 Command::new,
只有 tree_kill 那一处带了 CREATE_NO_WINDOW,于是探 N 个 JDK 就闪 N 次 java -version。

- 新增 crates/core/src/platform/child.rs:唯一构造器 quiet(program),Windows 上统一
  .creation_flags(CREATE_NO_WINDOW),其它平台原样返回。tree_kill 里那份就地合并进来,
  不再保留第二份常量。
- 20 处 spawn 全部改走它(region_system / java{discover,manual,probe,proc,termux} /
  addon exec / platform{linux_caps,macos_version,theme_probe,vk_probe,windows_version,
  wallpaper/io})。
- launch/process.rs 零改动:游戏进程要 CREATE_NEW_PROCESS_GROUP 撑 taskkill /T,
  带不带控制台窗口是产品决定。
- crates/core/tests/child_quiet.rs:按角色判「有没有绕开构造器」的守卫,外加构造器
  本身的 4 条行为用例。三条变异(裸 Command::new / 别名 Spawn::new / 删掉 creation_flags)
  都实测变红并已还原。
2026-11-09 15:07:56 +08:00
AskaEth fbb4a79955 fix(agent): MCP 读日志/诊断崩溃改走 session_index::event_file(D2 第三处读取点)
M212(M211 复审观察 7 确认的真 bug):

- `agent/ports/events.rs::event_path` 仍在 `root.join("game").join(&entry.log_path)`,
  而 `log_path` 已经是**相对会话根**的 `game/<id>.jsonl` ⇒ 对每个真实会话解析成
  `game/game/<id>.jsonl`。后果是 `ull agent` / MCP 的 read_game_log 与 diagnose_crash
  两个工具对真实会话全报「读会话事件流失败」——内置 AI Agent 的看家本领是坏的。
  现在解析只走 M211 建立的唯一真相源 `session_index::event_file`(新旧两个根都走它)。
- 夹具:`ports/session.rs`、`ports/diagnose.rs` 的索引夹具此前写裸文件名
  `20261029T124501-9f3c2a.jsonl`,与生产差一层 `game/`,所以这个 bug 在测试里永远看不见。
  改成 `session_index::log_path_for(id)` + `session_index::event_file` 落盘。
- 用例:`ports/events.rs` 新增三条(解析结果 = 写入侧落盘的那一个 / 文件缺失时指向的仍是
  生产形状 / 旧根回落走同一套解析);`cli/tests/agent_mcp_stdio_test.rs` 新增一条真机
  MCP 用例(起 `ull agent serve --stdio`,对一份生产形状的会话调 list_game_sessions →
  read_game_log → diagnose_crash)。旧实现下两条路径都红,红的是
  `...\logs\game\game\20261030T101500-abcdef.jsonl`。
2026-11-09 14:28:42 +08:00
AskaEth a91930e021 test(core): crash_scan 用例改用 slice::from_ref(clippy::cloned_ref_to_slice_refs) 2026-11-09 14:12:23 +08:00
AskaEth 4b30bf07f0 fix(launch): 会话事件流路径只留一个真相源 + crash_found 只认真实证据 + ull launch 也写 launcher.log
M211(来自 M208 实机 e2e 的 D2/D3/D5):

- D2「game/ 双写」:log_path 的契约原来有两种说法(字段文档写「相对 game/」,写入侧一直
  写「相对会话根」的 game/<sid>.jsonl),于是两处读取点各自又多 join 一层 game/,
  ull logs / ull diag / GUI 日志页对每个真实会话都报「找不到事件流」。现在解析只走 core 的
  session_index::event_file(CLI 与 presenter 共用),写入侧只走
  session_index::log_path_for。方向选的是「读取侧不再 join」:存量索引里的 log_path 从第一版
  起就是 game/<id>.jsonl,改读取侧能把用户盘上已有的旧会话一起救回来(零迁移、零改写)。
- D2 夹具:cli/tests/support 与 presenter/host/logs_tests 的索引夹具改成生产形状
  (game/<id>.jsonl)。夹具此前自己说了第三种形状,所以这个 bug 在测试里永远看不见——
  修夹具比修路径更重要。
- D3「crash_found 恒为 true」:logs/latest.log 每局都写,不再被当现场文件(只在已经找到
  权威现场时作为补充证据)。崩溃结论改由 crash_scan::has_crash_evidence 判定(权威现场文件
  / 非 0 退出 + 错误行),索引旗标由 SessionWriter::mark_crash 写入;crash_found 事件恢复
  成纯事实记录。
- D5「ull launch 从不写 launcher.log」:现在与 GUI 共用
  launcher_log::record_start / record_end(字段名与顺序只在一个地方定义),写的是同一份
  <data>/logs/launcher.log。

测试:core 新增 session_path_tests(写入形状 = 读取解析结果 = 真落点的往返)、crash_scan 的
干净退出用例、launcher_log 的行格式锁;CLI 新增 a_launch_attempt_writes_the_launcher_log。
2026-11-09 14:12:23 +08:00
AskaEth 1748e046f8 fix(cli): progress.rs 测试 helper 的返回类型抽成 Recorder 别名
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
M210 引入 type_complexity(clippy 1.99 -D warnings)。M210 的 brief 里没有 clippy
这一项(五步门禁是它 spawn 之后才补上的),所以第一轮没抓到;新门禁在合并态立刻咬住。
2026-11-09 14:05:52 +08:00
AskaEth d8ca411117 fix(core): bmclapi 库镜像补 /maven;install 进度行改说实话
D1(high):mirrors.json 的 bmclapi-libraries 少了 inject,官方
libraries.minecraft.net/<path> 被重写成镜像根 + <path>(BMCLAPI 的 maven 仓库挂在
/maven 下),实测 70/70 个库全 404、全部回落官方源(单请求 EMA 6.3 s)。本轮实测:
guava:32.1.2-jre 无 /maven → 404 / 9 B;带 /maven → 200(2 次 302)/ 3041591 B,
sha1 与官方逐字节一致。补法与 bmclapi-assets-objects 的 /assets 同一机制(inject),
不把 /maven 拼进 to(to 是镜像根)。

守卫两道,抓不同的东西:
- 形态:凡 from 指向 maven 仓库的规则,to+inject 必须以 /maven 结尾(并点名覆盖的
  5 条规则 id,删/改名都会红);
- 行为:4 个真实官方 maven 根展开后必须逐字等于 /maven/<子路径>。
原有 prefix_rule_preserves_sub_path 的期望值曾把 D1 的 bug 钉成期望,一并改正。
变异(删掉 inject)实测三条同时红,恢复后 mirrors 14 passed。

D11(low):进度回调只累加「处理到第几个文件」,还打「下载 {done}/{total}」,
重装同版本(全部已就绪)时会连打 3961 行「下载」而一个字节都没下。拆出
install/progress.rs:计数全部来自 FileOutcome.status(Downloaded/Skipped/Failed),
下载数为 0 时人读行不出现「下载」二字(改说「已就绪 d/t(跳过 N 个)」),--json 的
progress 事件补 downloaded/skipped/failed。用例构造 96 个全 Skipped ⇒ 断言每行都不含
「下载」且末行为「已就绪 96/96(跳过 96 个)」;变异(退回旧文本)实测红。

其它镜像表(assets / jar / version_manifest / 索引 / fabric / forge)的 inject 形状
实测一致,另有 liteloader 目标路径 404、quilt maven 无规则两条【不同类】观察,
不在本轮范围,报告里交回 tower。
2026-11-09 13:57:32 +08:00
AskaEth 391ebf88b7 fix(ci): 修两处 clippy -D warnings 红(needless_lifetimes / drain_collect)
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
两处都来自 M201(5dda9ac),非本轮 mission 引入。tower 合并门此前只跑
fmt+check+test,从未跑 clippy,所以 dev 上 CI 的 clippy job 一直是红的没人发现。

- presenter/host/settings/download_tests.rs: chosen 的显式生命周期可省略
- app-iced/.../download_tests.rs: drain(..).collect() -> mem::take

语义等价,纯 lint。门禁已补上 clippy(见 00-恢复指引 §6)。
2026-11-09 13:56:09 +08:00
AskaEth e3ae71ac37 M207: UA 收成一份带联系方式 + 两处字符串副本(gh_proxy 键、用例注释)
1) UA 只有一份定义:core 的 `download::http::USER_AGENT`
   (`Unlucky-Launcher/<CARGO_PKG_VERSION> (+仓库 URL)`,不用邮箱)。
   - 引擎侧:`EngineConfig::default().user_agent` 直接取它(config.rs 等量替换,仍是 400 行)
   - 带 header 侧:presenter 不再持有 UA 字面量,`get_with_headers` 发 `engine.config().user_agent`
   M205 复审核实过的缺口:Modrinth 收到的是引擎那份 `UnluckyLauncher/<ver>`(无连字符、无联系方式),
   带联系方式的那份只发给 CurseForge 搜索 —— Modrinth 的 UA 政策在真正会被限流的路径上没被满足。

   断言:core 一条钉形状(版本 == CARGO_PKG_VERSION、联系方式段 == 仓库 URL、不含 `@`);
   presenter 一条在回环上抓**发出去的字节**(两条路径的 UA 逐字节相同且 == EngineConfig::default())。

2) `app-iced/.../java/downloads/engine.rs`:`"download.gh_proxy"` 字面量改成 `KEY_GH_PROXY`。

3) `presenter/src/host/addon/http.rs` 的用例注释改成事实(Modrinth 走 core 的 EngineTransport,
   `get` 的无 header 分支生产零调用者),模块说明表格同步。

4) `docs/download-settings.md`:§6.1 / §6.1.1 / §7 按 M201(iced 真控件 + 快照通路)之后的现状重写。
2026-11-09 13:13:20 +08:00
AskaEth b7a1abb18a fix(m206): 「当前默认」改用选中的语汇,不再套禁用配色
Java 组用 `Chip::badge` 表示「哪个 JDK 是默认」,而 badge 走的是「无消息 ⇒ 禁用」
那条路(`OnSurface`@38% 叠 surface):M204 实测字/底只有 1.84:1(浅)/ 2.28:1(深),
成了整页最不可读的一段,而它正是「能不能改默认 Java」这个问题的答案本身。
同一张设置页里相邻的「下载」组表达「选中」用的是 `SecondaryContainer` +
`OnSecondaryContainer`(7.21:1)——两个相邻页面对同一个语义用了两套语汇。

改法(不动禁用语义):

1. 新增 `Chip::current(label)` + `paint::current_item`:**与选中 chip 同一套颜色**,
   但没有消息、不吃五态(没有可点性就没有反馈)。它不等于「未选中」,也不等于
   「禁用」——第三档,判据表写在 `components/selection/chip.rs` 的模块文档里。
2. `Chip::paint(&self, ui)` 成为「哪个档位 → 什么颜色」的唯一入口,`chip()` 用它求
   静息色,断言也用它——档位的判据只有一处,「改回旧配色」的变异会被直接抓住。
3. Java 页的动作位抽成 `view::action_chip`,当前默认走 `Chip::current`,其余仍是可点
   的 `Chip::filter(.., false, ..)`。
4. 下载组**不改**:它本来就是参照语汇(M201 已验收),改完两页逐字段同色。

断言(`pages/settings/java/tests/marker.rs`,浅深两档都跑):
对比度 ≥4.5:1(实测 7.21/7.21)、与下载组选中 chip 逐字段相等、以及反向断言
「禁用态仍走 38% 字 + 12% 容器且与当前项肉眼可分」。两条变异都在报告里复现过:
`action_chip` 换回 `Chip::badge` ⇒ 对比度断言红(只有 1.84:1);`Chip::current` 里
`marker: true` 改 `false` ⇒ 三条全红。

举证图(离屏 `--screenshot`,浅/深 + 改前/改后 + 下载组对照)与实测色值表见
`DevDocs/artifacts/m206/`(gitignore,不入库)。
2026-11-09 12:44:18 +08:00
AskaEth 8a050c4402 fix(m204): 补「扫描中/还没扫描过/正在取消」三格种子 + try_lock 说明对得上代码
视觉复审 r1 判 p2-1items:六张举证图里「取消」全程是禁用态,「正在扫描… 3/7」
一格都没拍,而 README 却写成「两态」——证据覆盖不到结论(与 M201 同一个坑)。
本提交补种子与拆文件,不出产品行为改动。

- `pages/settings/java/seed.rs`:新增 `scanning` / `unscanned` / `cancelling` 三档。
  三档都把快照写进 `ScanSlot`(新增 `ScanSlot::seed_report`)而不只改 `State.scanning`:
  `clock.advance` 每拍用槽里的快照重算 `scanning`,只改 State 的话第一拍就被推回「没在扫」。
- `pages/settings/java/scan.rs`:`ScanReport` / `Job` / `ScanSlot` / `run_staged` / `rows`
  从 `store.rs` 拆出(加 `seed_report` 之后那个文件 408 行,越过 400 行硬线);
  `store.rs` 用 `pub use` 再导出 `ScanReport` / `ScanSlot`,既有路径一个都没改。
- `app/view.rs`:`note_screenshot_group` 的锁从 `lock()` 换成 `try_lock()`——代码复审 r1 指出
  注释写着「拿不到锁就不打、不能把界面帧卡在锁上」,而实现用的是阻塞 `lock()`,只兜住中毒
  不兜住等待。选 try_lock 而不是改注释:注释承诺的保证应由代码提供,不能靠「今天只有一个
  调用点、锁里只有一次比较」这种会随改动失效的前提。
- `pages/settings/java/tests/cmdline.rs`:新增 `the_scan_seeds_cover_the_running_states`,
  逐档钉 `State.scanning` 与 `scan.report().running`(后者才是「心跳下一拍会不会把它推开」)。
2026-11-09 12:09:02 +08:00
AskaEth 92ab3d4ddf fix(m204): Java 组写盘成功后请 host 重发设置快照
`pages/settings/java/store.rs` 的 `save()` / `set_selected()` / `prefs_store::save()`
是页面自己的一次同步原子落盘(`SettingStore::set` 多个键 + 一次 `save()`),
不经过 `AppCommand::SetSetting` 的写盘分支;而 `CoreEvent::SettingsChanged`
全仓只有 `dispatch.rs` 的 `SetSetting` 分支与 `host/mod.rs::refresh_all` 两处发出。
于是三键写成功之后,`AppState::settings_groups` 里 java 组那几行会停在旧值,
直到下一次任何别的写入顺手带一份新快照回来。

修法用一条**动作键**(`dto::JAVA_REFRESH_ACTION` = `java.action.refresh`,
与 `download.action.detect_region` 同一族:不写盘、见到就返回 Ok):
写盘成功后派发它,`SetSetting` 分支照常重发快照。

不用 `RefreshAll`:那个除重发快照之外还会 `versions::spawn_list` 与
`start_update_check`——每点一次「保存内存」打两个网络请求(实测:一次真 host 出图
就在数据根里落了 `cache/meta/version_manifest_v2.json`),代价与动作不相称。

另外给 D30 §2.1 补一行自证身份的日志:`--screenshot` 且身份变化时打
`[shot] settings route=… group=… theme=…`(`view` 每帧都跑,所以只在变化时落一行),
免得再出现「拿外观页当下载组证据」那种图与结论对不上的事故。

守卫(`tests/java_refresh.rs`,4 条):
- 真 `CoreHost` + 临时数据根跑完整链:写盘 ⇒ 快照里 `java.extra_args` 真的变了;
- 被拒的保存不许请求刷新;
- 成功之后确实派发了那条动作键(键名写错就红);
- 「立即扫描」在 App 层真的接线(此前没有任何用例驱动 `Message::Scan`)。
2026-11-09 12:09:01 +08:00
tower-worker 67a03f9402 M205 r1: 修 config.rs 的假话与「最坏 9」的措辞,UA 说明改成事实
- download/config.rs: KEY_GH_PROXY 的「本文件没有断言把它与 schema 登记绑起来」是 M205 自己造出来的假话,改成指向 proxy.rs 的新用例(纯文字替换,400 行不变)
- addon/online/download.rs: 断言消息与边界段改说「出厂档 3x3 = 9;retry_limit 调到 N 时是 3xN(上限 10 => 30)」,别再让人把出厂值读成事实上限
- presenter/host/addon/http.rs: 「Modrinth 的请求走 get_via_engine」是错的(实际走 core::addon::online::EngineTransport -> download_bytes,带的是引擎 UA;get_via_engine 今天只有单测在用),改成事实
2026-11-09 11:20:27 +08:00
tower-worker d4aa3b23f4 M205: gh-proxy 键绑定断言、外层重试预算判据、UA 版本号来源、下载设置文档更正
- setting/keys/proxy: 新增断言把 KEY_GH_PROXY 常量与 schema 登记绑起来(键名 / Text{max_len:256} / 默认空串)
- addon/online/download: RETRY_ATTEMPTS 与引擎每源次数是两件事(层级相乘、管的失败类型不同),写清判据并用两条编译期断言钉住 3 与 3x3=9 的边界
- presenter/host/addon/http: UA 版本号改为从 CARGO_PKG_VERSION 拼出,补断言钉住版本 == CARGO_PKG_VERSION 且联系方式段不丢
- docs/download-settings: §3.1 四条 CLI 命令都吃设置(update 是自更新路径),§6 补 M11-M13 三条 CLI 变异,§1/§7 里 gh_proxy 已合入的事实
2026-11-09 11:12:59 +08:00
AskaEth 5dda9ac31d M201 r2: 修视觉 r1 的两条 P2 + 代码复审的坏值提示 p2
视觉 r1(fix-then-merge):
- P2-1 分隔线贴字:downloaded 组的行间距从 0 改成 `Space::X3`(12dp)。实测
  说明末行→分隔线 1–3px → **12–13px**、分隔线→下一标题 4–5px → **12–17px**
  (同族页 9–18px;同一把尺子量演示回退卡 16–19px)。不动 `frame::divider` /
  `demo_card`;`spacing` 只作用于子节点之间,卡片首行顶距与末行底距仍是 20。
- P2-2 孤字「效」:`with_note` 把「原值 X 已按出厂 N 生效」从说明段落里挪到
  **独立一行**(`\n` 分隔,渲染器按行断)。浅/深/窄三张复验:无 <20px 窄行。
  两种措辞(出厂 8 / 已按 1)一字未改,presenter 侧新增 `ends_with("\n原值 …")`
  断言把「独占一行」钉住。

代码复审 p2(非整数坏值被静默丢掉):
- `raw_int`(`Value::as_i64`)→ `raw_text`:文件里显式写过的原值转**上屏文本**
  (字符串取原文不带 serde 引号、数字/布尔 `to_string`、`null` 当没写)。
  于是 `"auto"` / `8.5` / `true` / `-4` 都能进那句提示,不再「画着 8 却说不出
  用户写的 auto 被忽略」。
- 新用例 `every_bad_value_shape_says_what_the_raw_value_was`:负整数 / 字符串 /
  浮点 / 布尔 / null 五种形态。

验证:fmt --all --check / check -p unlucky-app-iced --all-targets /
test -p unlucky-app-iced(845+6) / test -p unlucky-presenter(316) /
test -p unlucky-i18n(36),全部 EXIT=0。
2026-11-09 10:51:16 +08:00
AskaEth ab573838f3 M201: 接通 iced 设置页的数据通路(下载组:真值 + 真控件 + 写回闭环)
M193 的用户投诉「设置页里改了不生效」的结构性根因是 iced 侧没有设置数据通路:
`CoreEvent::SettingsChanged` 在 `crates/app-iced/src` 零命中、`pages::Model` 没有设置快照、
`download::State` 是空 struct。presenter 那套设置组早就是真值来源(`host::settings::groups`
由 `refresh_all` 与 `dispatch` 的 `SetSetting` 分支发成 `SettingsChanged`),只是没有接到 iced。

这条线现在通了:

    host::settings::groups → SettingsChanged → AppState::settings_groups
      → pages::Model.settings → settings::States.settings → 下载组控件
      → AppCommand::SetSetting → 写盘 + 重新发 SettingsChanged(闭环)

主要改动
- presenter `host/settings/download.rs`:并发/重试两行改成画**引擎生效值**(`DownloadTuning`),
  文件原值与生效值不一致时写明「原值 X 已按出厂 N 生效」(P3-5,两个数都现读);
  gh-proxy 行补「当前生效:跟随内置默认/已关闭/覆盖为 X」。
- app-iced `pages/settings/download.rs`:`GROUP.body = Some(body)`,按 `SettingControl`
  画 choice(filter chip)/ text(输入框 + 应用)/ action(按钮)/ toggle / readonly;写回走
  `app/update/settings/download.rs` → `AppCommand::SetSetting`(不做乐观回显)。
- `pages::Model.settings` + `settings::States.settings`:快照进页面的唯一通道。
- i18n:新增 `SettingsDownloadsApply`(金表锚点追加,`split_keeps_every_translation` 绿)。
- 取证:`ULL_SCREENSHOT_REAL_HOST=1` 让 `--screenshot` 接真 host(截图通道默认强制 mock,
  而 mock 不发 `SettingsChanged`,真值屏拍不出来)。

测试(新增 11 条)
- presenter `download_tests.rs`:P3-5 的越界回落 / 0→1 / 无差异不出提示 + P3-3 的
  gh-proxy 文件→三态端到端链。
- app-iced `app/update/settings/download_tests.rs`:假后端闭环 ×4 + **真 `CoreHost` 端到端**
  (点 16 ⇒ 磁盘上 `launcher.json` 真的有 16)。
- `pages/settings/tests/download_section.rs`:`body.is_none()` 绊线翻成 `is_some()` +
  控件形状 / 空快照不编值。
2026-11-09 10:51:16 +08:00
AskaEth 7995f62908 fix(m203): 给第 4 条吃设置的路径(ull update)补打点,并清掉 KEY_GH_PROXY 的腐化 doc
复审 r1 P2-1(p2-1items / fix-then-merge):`ull update` 复用 `install::build_engine` 后成了第 4 条
吃设置的路径,但没有任何用例钉它——把 update 换回裸引擎,四条 download::tests 全绿。按 pack 的完全
同形接缝补上:

- `commands/update.rs` 新增 `pub(crate) fn engine(ctx)`(`job()` 改调它),doc 写明这条路径上并发
  不可观测(自更新只下一个 artifact),随设置变的是每源重试次数与 gh-proxy 前缀;
- `download/tests.rs` 新增 `update_engine_takes_the_concurrency_from_the_setting_store`:
  写 concurrency=4 ⇒ 断言 `config().max_concurrency` 与 `concurrency_probe()` 都是 4。

顺带收掉复审的三条观察:
1. 新增 `the_gh_proxy_setting_reaches_the_candidate_chain`:镜像优先地区档案下,出厂(空串 = 跟随
   内置)确实生成 gh-proxy 候选;手写 `gh_proxy: "off"` 后候选里没有 gh-proxy(读的是 `plan()` 的
   候选链,不是配置值;两段式,避免「本来就没有候选」的白绿)。
2. 测试写侧改用 `ctx.setting_path_for(KEY_CONCURRENCY)`(`ull config set` 用的那个表达式),不再与
   实现读侧的 `launcher_config()` 共用同一个表达式——实现改读别的文件时用例会红而不是一起漂移。
3. `core/download/config.rs` 的 `KEY_GH_PROXY` doc 已腐化(写「暂未登记、M197 才登记、别删这条弱
   断言」,而该键在 main 上就已登记、本文件根本没有针对该常量的断言):改成事实,并保留「弱在哪」
   ——常量值与 schema 登记没被断言绑在一起,改名只会让宿主侧静默退化成 Builtin。

变异验证(只改实现侧,逐次还原):
- update::engine 换回裸 `EngineConfig::default()` ⇒ 只有 update 那条红(left 8 / right 4),另五条绿;
- `download::tuning` 不读 `KEY_GH_PROXY`(传 None)⇒ 只有 gh_proxy 那条红(off 档仍有
  `https://gh-proxy.com/...` 候选),另五条绿;
- `gh_proxy_override("")` 返回 Off ⇒ gh_proxy 那条的「出厂该套内置前缀」与回落用例的
  `tuning == default` 同时红(两处都该红)。
2026-11-09 10:07:41 +08:00
AskaEth b31ca636d1 feat(m203): CLI 三条下载命令改吃 download.* 设置(并发/重试/gh-proxy)
`ull install` / `ull java` / `ull pack` 此前各自 `DownloadEngine::new(EngineConfig::default(), …)`:
用户在设置里配的 `download.concurrency` / `retry_limit` / `gh_proxy` 一概不生效;M193 把出厂并发
从 64 改成 8 之后,它们还静默跟着降了。现在三处都经 `crates/cli/src/download.rs::engine(ctx)`:

- core:`shared/tuned.rs` 新增 `engine_with_tuning(cache_root, tune)` = `set_tuning` + `engine_for`,
  由 `shared.rs` 重导出;`engine_for` 的签名与行为一字未改(presenter 的两步语义原样保留)。
  放子模块是为了不动 `engine.rs`(那是 400 行零余量),`shared.rs` 仍 398 行。
- CLI:`download::tuning(ctx)` 用 `SettingStore::load(ctx.launcher_config(), launcher_schema())` 读
  三个键(与 `ull config set` 同一份文件),默认值/夹取/回落在 `DownloadTuning::from_settings`;
  键缺失或坏值静默回落出厂值,`engine()` 没有错误通道,安装不会因此中断。
- 顺带:`ull update` 复用 `install::build_engine`,也随之吃到设置。
- 测试:`crates/cli/src/download/tests.rs` 三条命令各一条(读 `config().max_concurrency` 与
  `concurrency_probe()` 的真实值)+ 一条「缺失/越界/负数/类型不对 ⇒ 静默回落出厂值」;
  `shared/tuned.rs` 两条守住「灌调优与取引擎是一步」。变异实测:pack 换回裸 `EngineConfig::default()`
  ⇒ 只有 pack 那条红(left 8 / right 2)。
2026-11-09 10:07:41 +08:00
AskaEth 3b5f95375d docs(presenter): 「抓不住」那段别再点名 signature_cases.rs 管 manifest_sha256(复审 P2)
信封字段与钥表确实在 core/src/update/tests/signature_cases.rs;但 manifest_sha256 那条冗余校验
(signature.rs:161-171,要求「钥已钉住 + 方程验过 + 声明 sha 不同」)全仓没有任何用例覆盖,
现有篡改用例都先死在方程上。原文把这一项也推给那个文件,等于把责任推给了不存在的守卫。

改成如实写「全仓无用例,已立 finding」。只动 doc,用例逻辑与断言未改(394 → 395 行)。
2026-11-09 09:49:41 +08:00
AskaEth ba96a4fc94 docs(presenter): 更正 ATTEMPTS 注释里「拿不到宿主设置」的假话(复审 P1)
原文把「没读设置」写成了物理限制。事实:get_with_headers 手里就有 env,第一句 engine(env)
已经走过 apply_tuning(host/settings/download.rs → core/download/shared.rs),用户当前值就在
engine.config().max_attempts_per_source。这条路径之所以恒为出厂默认,只是因为它没读那个字段。

只改注释,行为不变(仍是编译期 const)。同时把不对称写清:另一条无 header 的路径走引擎,
本来就跟随 download.retry_limit(源冷却中时引擎给 1 次,那是恢复探测)。
2026-11-09 09:49:36 +08:00
AskaEth 133f14ba0b test(presenter): 坏清单用例按错误分类区分「JSON 坏」与「签名被拒」
台账 §10 第 7 条 / M199 复审 P3-2:原来只断言「结果是 Failed」,而它喂的每一份清单都签对了
——验签一旦 fail-open 或签名错误被误分类,这条用例照样绿。

现在两组各钉自己的分类:
- 内容坏(5 个变体,签名都对):kind 必须是 invalid_manifest / invalid_version;
- 字节好、只有签名对不上(放行就会报「有更新 0.9.0」):kind 必须是 signature_invalid 且不可重试。

变异证明(只改实现侧,跑完已还原):
- A core::update::signature::verify_manifest fail-open → ② 分支红,实际 Available(0.9.0);
- B core::update::manifest::from_json 把解析失败误分类成 SignatureInvalid → ① 分支红,
  「not json:坏内容分类是 signature_invalid」。
两个变异各只打红对应分支,说明两类坏输入确实各走各的分类。

文件仍在 400 行红线内(394 行)。
2026-11-09 09:32:58 +08:00
AskaEth 9163054d46 refactor(presenter): 带 header 的请求重试次数改引 core 的单一来源
台账 §10 第 4 条:http.rs 的 const ATTEMPTS: u32 = 3 是「数字 3 的第二处副本」。
改成引用 unlucky_core::download::config::DEFAULT_MAX_ATTEMPTS_PER_SOURCE(download.retry_limit
的 schema 默认值同源),core 侧一个字未改。

这条带 header 的路径(只有 CurseForge 用)拿不到宿主设置,所以只吃出厂默认——这一点写进了
doc 注释,免得下一个人以为它会跟随用户的 download.retry_limit。
2026-11-09 09:32:51 +08:00
AskaEth 50d13d26ab fix(m201): 测试钥改由 #[cfg] 编译期剔除,无测试档产物里不再留那段字节
`keyring()` 原先用 `cfg!(...)` 判断要不要并入测试钥 —— 那是「运行期值为假的分支」,
`TEST_ONLY` 的字节照编进产物(实测:无 feature 的 dev / release 两档 core rlib 里,
测试公钥十六进制与 key_id 都能 grep 到)。

* `TEST_ONLY` 拆成两条同名 const:`#[cfg(any(test, feature = "update-test-source",
  feature = "update-test-key"))]` 的真条目 + `#[cfg(not(any(...)))]` 的空表;
  `keyring()` 删掉 `cfg!` 判断,改成无条件 `extend_from_slice(TEST_ONLY)`(空表那档等于
  什么都没并,`mut` 两档都有用武之地,不需要 `unused_mut` 口子)。
* 文档把 M199 降级掉的那句强断言改回真话,并贴出六行实测读数(dev / release 均 0 命中,
  带 `--features update-test-key` 的对照臂 2 命中)。
* 明确划界:读数只到 rlib 为止;最终 exe 经 `lto = "fat"` + `strip = "symbols"` 之后是否
  仍有那段字节**未验**。

验收:cargo fmt --all --check → 0;cargo clippy --workspace --all-targets -- -D warnings → 0;
cargo test -p unlucky-presenter → 311 passed / 0 failed(1 ignored);
cargo test --workspace --all-targets → 0。
2026-11-09 08:19:04 +08:00
AskaEth 540b82e197 fix(m200-r1): 文档降到实测能撑的程度 + 守门收紧 core 声明内容(复审 P2-1 / P3-1)
复审 reviewer-presenterfix-r1 的 fix-then-merge 两条,逐条落地。

P2-1(a)「两档」措辞被本次提交自己作废 ⇒ 三处全改成三档:
* `crates/core/src/update/trust.rs` 模块文档(读者最先看到的那句,上次漏了)
* `crates/core/src/update/tests/support_sign.rs` 模块文档 + `TEST_SIGNING_SEED` 的文档

P2-1(b)「三者都进不了发布产物」被复证实测证伪(`cfg!()` 是运行时假分支,不是 `#[cfg]`
编译期剔除;无 feature 的 dev 与 --release 两档 core rlib 里公钥十六进制与 `test-only-key` 都在)
⇒ 降级到事实能撑的断言:「不会被 `keyring()` 并进信任表,正式构建永远不认这把测试钥」,
并明写「字节仍编在产物里」「字节级不在产物里未证、也未声称」「换 `#[cfg]` 是另一条结构性改动」。
没有换成 `#[cfg]`(那是复审建议另开的 mission)。

P3-1 守门对 core「自己的声明」只认形状 ⇒ 收紧为只许空数组:
`DECLARATION` 从 `^\s*update-test-key\s*=\s*\[` 改成 `^\s*update-test-key\s*=\s*\[\s*\]\s*$`,
并加一条自检向量(core 声明里偷偷带子 feature ⇒ 必须红)。`--self-test` 从 6 条变 7 条
(5 红 / 2 绿)+ 基线,`packaging/README.md` 的计数同步。

⚠ 一个自己踩出来的坑,已被既有守门抓住:收紧时我先在 `check_test_key_off.py` 里把那条更宽的
测试通道 feature 写成**字面量**(自检向量 + 文档表格),`check_test_source_off.py` 立刻红
(`packaging/**` 按字面扫,这次提交前它一直是绿的)。改法照该文件自己的声明办——用
`OTHER_FEATURE = "-".join(("update", "test", "source"))` 拼出来,源码里字面量为 0,
自检向量仍然构造出完整字符串并必须判红。

验证(均在最终树上):`cargo test --workspace --all-targets` 0、`cargo fmt --all --check` 0、
`check_test_key_off.py --self-test` 0(7 向量)/ 正式扫描 0、`check_test_source_off.py` 0、
`check_workflows_yaml.py` 0。
2026-11-09 07:56:33 +08:00
AskaEth 528f5706a0 docs(m200): 补 update-test-key 的两处文档一致性(cherry-pick 之外)
`80b291c` 原样落盘(与本提交前一个 commit 逐字节相同),这里只补两处**文档一致性**,
不改任何判据、不改任何 feature 行为:

1. `crates/core/src/update/trust.rs`:`TEST_ONLY` 的文档注释还写着「**只**在 `cfg(test)` 与
   `update-test-source` 构建里存在」——同一文件里 `keyring()` 新增的表已经把三档(`cfg(test)` /
   `update-test-source` / `update-test-key`)讲清了,上面这句变成了自相矛盾的旧口径。改成三档,
   指向 `keyring()` 的说明。
2. `packaging/README.md` §12「文件清单」:新守门 `packaging/check_test_key_off.py` 已接进
   `release-guard.yml`,但清单表里没有它(同类守门 `check_test_source_off.py` 在表里),补一行。

验证:`cargo test --workspace --all-targets` exit 0(311 passed)、`cargo clippy --workspace
--all-targets -- -D warnings` exit 0、`cargo fmt --all --check` exit 0、
`packaging/check_test_key_off.py`(自检 + 正式扫描)exit 0、`packaging/check_workflows_yaml.py`
exit 0。
2026-11-09 07:56:33 +08:00
AskaEth 19cc5c7950 fix(m190): presenter 更新链路用例补上清单签名(6 条既有测试回绿)+ 测试公钥守门
背景:M190 把「验签」做成了强制且唯一收口(`source::verified_manifest`,先验签再解析),而
`crates/presenter` 的更新链路用例只写 `manifest.json`、不写 `.sig` —— 它们写在验签之前,所以在
dev 上红成 6 条 `signature_missing`。真发布本来就必须带 `.sig`,所以修的是**夹具**,不是判据:
没有放宽任何断言、没有在测试里关掉验签、没有把 `available=true` 改成「接受 Failed」。

夹具(`crates/presenter/src/host/update_tests.rs`):
* `write_manifest()` 把清单**连签名**成对落盘(`dist/stable.json` + `dist/stable.json.sig`),
  `dir_source()` 改走它;三处直接写清单的用例(坏清单 / 空清单 / 缺本平台产物)也改走它。
* 签名用 `TEST_SIGNING_SEED` + `key_id = test-only-key`,信封形状与 core 的
  `tests/support_sign.rs` **同源**(文件头写明「改一处要改两处」;presenter 不依赖 core 的
  `#[cfg(test)]` 资产,也没把测试资产导出成生产 API)。

为了让这把**公开**测试公钥在 presenter 的测试构建里被认得:
* core 新增 feature `update-test-key`(**只**把 `trust::TEST_ONLY` 并进信任表,不开任何数据源开关);
* 只有 `crates/presenter` 的 `[dev-dependencies]` 打开它(库/发布构建拿不到);锁里只多
  `ed25519-dalek` 这一行(presenter 的 dev-dep,包已在锁里,无新包、无版本变动);
* 新守门 `packaging/check_test_key_off.py`(238 行,带 6 条变异自检)接进 `release-guard.yml`:
  core 的 `Cargo.toml` 只许自己的声明、presenter 只许 `[dev-dependencies]` 段内,其余(workflow /
  packaging 脚本 / 根 Cargo.toml / cargo 配置)出现即红 —— 这把钥进发布构建就等于「谁都能签出官方更新」。
* 没有用 `debug_assertions`/`cfg(test)` 旁路:那会让「测试构建的信任表和发布构建不同」变成一句注释,
  而不是一条门禁。
2026-11-09 07:56:32 +08:00
AskaEth aae9a233ff fix(m198): JAVAGUARD——首屏自动扫 / 子进程回收 / 参数长度三条守卫 + 设置页正文结构断言
- 首屏自动扫:删掉 `cfg!(test)` 分叉,生产入口在测试里真跑;新增
  `the_first_hydrate_starts_a_real_scan_by_itself`(删掉那一行、或把实参翻成 `true`
  都红)。顺手删掉恒为假的 `seeded && !live`:截图种子档在上方就 return 了,编译器也
  报 "assigned but never used"。
- proc:`kill()` + `wait()` 收进 `terminate()`(三个分支共用);用例先用同一条命令
  真跑一次证明子进程起得来(`spawn().ok()?` 的空跑假绿),并新增 Linux/Android 的
  僵尸回收用例(只 kill 不 wait ⇒ `/proc/<pid>` 留成僵尸)。
- java.extra_args:`MAX_JVM_ARGS = 1024` 镜像 schema,`Refusal::JvmTooLong` + 中英
  文案 + JVM 输入框变红;App 级用例断言回执是具名的「太长」而不是通用的「写不进去」。
- 设置页正文:判据①从「数 `stack = stack.push(` 的出现次数」升级成按角色断言
  (唯一来源 section_columns、`push(` 恰一次且实参是循环变量、尾表达式、禁
  extend/chain/once/concat/append),单行折叠变异体现在会红。
2026-11-09 06:46:29 +08:00
AskaEth 6e80fd3563 merge: origin/dev(1623f68)合入 M197 分支——解 golden/tail.rs 冲突
基线:合的是 **origin/dev**(`1623f68`)。`origin/main` 是 D41 的孤儿发布快照
(`d616d65` parents 为空),不参与合并。用 merge 而不是 rebase,保住三轮复审 clean 的 tip。

冲突只有一个:`crates/i18n/src/catalog/golden/tail.rs`(热点文件,多条 mission 都往这里加 key)。

解法(不重排别人的行、不改别人的译文):
1. 以 **dev 侧 398 行**为底稿(含其它 mission 后加的 key);
2. 把 M197 的 68 行(`SettingsJavaTooNew` … `SettingsJavaReasonInstallRootTooLong`)
   整块插在 `SettingsJavaReasonWriteFailed` 之后、M174 助手域(`SettingsGroupAssistant`)之前
   —— 即 `settings_java_entries` 那段之后,装配链的正确位置;
3. 两侧合起来 463 行会越过 400 行硬线,于是按既有先例把**M174 助手域起的那半截**
   (dev 的 260 行)整段纯搬移到 `golden/tail_ai.rs`:`tail.rs` 211 行 / `tail_ai.rs` 274 行,
   链序 `golden → tail → tail_ai → tail2` 不变,逐字节不删不改;
4. 解冲突时发现 dev 的 golden 行还是 M197 之前的旧文案(`SettingsJavaEmptyBody` /
   `SettingsJavaAddHint` 的反引号、`SettingsJavaNeverScannedBody` 的旧说法),已把这三条的
   金表行同步回域文件里的现行文案(`split_keeps_every_translation` 逐位置比对当场抓出来的)。

顺带(合并导致的越线):`crates/core/src/download/engine.rs` 两侧各 400 行、合并后 401
(我 +1 行 `kind: err.kind`,dev +1 行),把模块文档里那三行散文重排成两行(一字未删)→ 400 行。
同一次还把这文件里对 `MirrorTable::set_gh_proxy` 的旧描述改成 M193 的现实(那两个函数已被删)。

验证:fmt 0 · check(app-iced+core+i18n all-targets)0 · i18n 36 · engine_mock 8 · app-iced java 45 ·
core lib java 71 · setting 116 · app-iced lib 全量 832;全仓 file-size 守门脚本复制执行 → 超限 0。
2026-11-09 06:17:54 +08:00
tower-worker 1764301cdf merge: 解 origin/dev 三处冲突(M182 / M184+M190 / README §14),两边都留
冲突(都来自别人后来改同一批文件):
- crates/app-iced/src/platform/mod.rs:保留 M182 的 HostOs / host·linux·macos 分册表 /
  plan_corner_for,同时保留 M196 的 `[`win32::console`]` 落点说明;把 M182 措辞里已经不
  成立的「唯一 unsafe 落点 / 只此一处」改成「两处落点(transparency + console)」
  (unsafe-scope 门禁自 M196 起期望集合就是两处,不改这句文档就是错的)。
  注意:`pub mod console;` 属于 `platform/win32/mod.rs`,本次解冲突时一度被误加到
  `platform/mod.rs`(`cargo check` 立刻报 E0583 文件不存在),已修回。
- .github/workflows/release-build.yml:M190 的「PE 版本资源读回」步、M184 的
  linux-x64 xvfb 冒烟步与首帧上传步**全部逐字保留**;M196 的 PE 子系统门禁作为**兄弟步**
  插在导入表门禁之后(两道 PE 门禁互不替代)。按块比对:dev 的 10 个步骤逐字未变,
  合并后 11 个步骤。
- packaging/README.md:保留 M184 的 §11 补充与 §12 行、M190 的 §14 签名段;§12 里
  check_publish_slots 用 dev 的 11 条版本、另加 M196 的 check_pe_subsystem 行;§15 归位在
  §14 之后。

行数:release-build.yml 合并后 413 行会撞 400 行红线(dev 侧本来 393),故把 M196 那一步
压到 6 行 + 1 空行(说明改由步骤名指路 packaging/README.md §15 与脚本 docstring 承载),
最终正好 400 行。

验证见 mission 报告:cargo check/test/fmt 全绿,两个 PE 门禁在同一批产物上都 exit 0
(子系统 GUI=2 / CLI=3,版本资源的 subsystem 读数同为 2 / 3)。
2026-11-09 05:49:38 +08:00
worker-dlmsettings 54dde7f494 fix(m193): 复审 r2——台账三处失效引用、M39 注释逐字还原、schema doc 数字补守卫
- P2-1:docs 的契约表把 retry_limit 范围从 (1,10) 改成 0–10(0 与 1 都 = 只试一次),
  与 §4 和 keys.rs 对齐;§2 与 §6 的守卫名统一成 the_schema_contract_is_single_sourced
  (原两条已合并),并把变异 M9(去掉 .max(1) ⇒ 红)补进 §6 台账。
- P3-4:keys.rs 的 M39(MCP server)既有注释按 origin/dev 原文逐字还原(含「同一安全策略、
  同一审计日志」);腾出的 1 行用「去掉 alias use、两处常量改全路径」换,没有压任何既有论证。
  比对:版本史 / D20 / M39 三段与 origin/dev 逐字节相同。
- P3-2:SettingSpec.doc 是 &'static str(concat! 插不进常量),描述里的数字是第三处第二来源,
  现在有守卫 the_key_docs_quote_the_real_limits(改 1~64 → 1~99 ⇒ 红,变异 M10 已跑)。
  §6.1.1 用表登记三处「数字的第二来源」现状。
- P3-1:docs 里那个游离反引号删掉。
2026-11-09 05:24:12 +08:00
worker-dlmsettings 94f56f9d9c fix(m193): 复审 r1 三条 P2——版本史还原、retry_limit 0 有诚实语义、装配点措辞更正
- P2-2:keys.rs 的 schema 版本史按原文逐字还原(1.4.0/1.5.0 的键名、1.2.0 的迁移口径、
  旧读新的完整结论),不再用版本文档换行数;腾行改从我自己新加的注释里出。
- P2-1:download.retry_limit 范围恢复 0~10,并把 0 的语义定成「只试一次」(attempts = max(1, 0)):
  老配置合法、零迁移、ull config get 读到的 0 与引擎真做的事一致(不再回落出厂 3)。
  schema 描述与 presenter/iced 两处 UI 都写清「0 与 1 都 = 只试一次,不代表不下载」。
- P2-3:shared.rs 的「唯一一处装配」更正为「presenter 侧唯一一处」(CLI 还有三处自建),
  docs 加「哪些路径吃这套设置」表,并立 finding(CLI install/java/pack 不读设置、并发 64→8)。
- P3-3:mirrors.rs 的 gh_proxies 注释改成「无生产读者,仅解析留档」。
- P3-4(presenter 侧):并发/重试描述的出厂值与上限改用常量 format!,不再是第二处字面量。
- 出图重拍(含 0/1 = 只试一次)。
2026-11-09 05:24:12 +08:00
worker-dlmsettings e8544b19a7 feat(m193): 下载并发/重试/gh-proxy 真接进引擎(schema 与引擎同源常量)
- download.concurrency / download.retry_limit 以前引擎侧零消费者(恒用
  EngineConfig::default() 的 64,而 schema 说 8)。现在默认值与硬上限收成
  download/config.rs 的常量,schema、EngineConfig::default()、Semaphore 容量
  三处同源;宿主把三个键翻译成 DownloadTuning 后经 shared::set_tuning(按数据根)
  灌进装配点,改了设置下一次取引擎就生效。
- download.gh_proxy 三态(留空=跟随内置默认 / off=关 / 前缀=覆盖)下沉到
  shared::build,所有走共享引擎的下载路径(内容库/版本清单/字体/安装)一致。
- 删掉 MirrorTable::set_gh_proxy 与 default_gh_proxy 两个改了没用的入口。
- 下载设置页把三条假演示值换成说语义的中文行(含三态说明与 1~64 硬上限)。
- 守卫:探针读 Semaphore 真实许可数(不是读配置),每条配变异证明。
- docs/download-settings.md 记下取证表(另四个键仍零消费,已立 finding)。
2026-11-09 05:24:11 +08:00
AskaEth fb0b1a5311 merge(dev): 解 M196 与 dev(M179/M193)在 settings/mod.rs 的冲突
`origin/main` 是 D41 的孤儿快照(单提交、与开发史无共同祖先),M179/M193 这些提交在
`origin/dev` 上;按「跟上主线」的意图合的是 `origin/dev`。用 merge 不用 rebase:复审 clean 的
tip `e5a4b9b` 原样保留,本提交只负责解冲突。

唯一冲突:`settings/mod.rs` 里 `form` 的文档注释(M179 与 M195 重写过同一段)。两边都留:
- **M179/M193**:`type Body` 带 `Lang`、四条按下标分发(General / Update / Assistant / Java)、
  M193 的 download / logs 接线及其文档更新——只有一处措辞随之调整:「四组在下面各有 `else if`」
  → 「在 [`section_columns`] 里各有 `else if`」,因为 M195 把那层循环抽成了独立函数;
- **M195**:循环之后不再 push 那张「没有逐实例覆盖」的空态卡、`overrides_empty` 不存在、
  `section_columns` 与其行为断言(`frame.rs` 两条用例)。
合并后的注释同时说明两件事:`GROUP.body` 从形参拿生效语言(M179)+ 尾部不许再挂东西、
逐实例覆盖的真控件在实例详情页(M195)。

为守住 400 行硬线(冲突态 424 → 399),只压自己的重复叙述:M196 段压到 4 行、
`section_columns` 的文档改为指向 `form`(它原来重复了一遍分发点,且在 M179 之后已不准确)、
模块文档里一段被 M179 实现取代的历史「r3 订正」压成一句可执行的结论。

代码零语义改动(证据见汇报):`section_columns` 的循环体与 `origin/dev` 归一化后逐字节相同(46 行),
`GROUPS` / `GROUP_LABELS` 三方一致;相对 `origin/dev` 的差异只剩本 mission 的 4 个文件。
2026-11-09 05:18:29 +08:00
AskaEth 59011b2d56 fix(m195): 复审 r3 —— 失败不再被说成「取消太晚」,并给三档补推导用例
P2-1(界面撒谎):`cancel_view` 的 `_ if cancel_requested => TooLate` 把 `Failed` 也吞进去了。
「点取消的同时摘要不符」这类失败(取消置位之后、下一个检查点之前的非容忍失败)会得到
`status=Failed cancel=TooLate rolled_back=true`,于是同一屏上红卡说「已回滚 —— 目标目录零残留」,
下面还有一句「取消来得太晚:这次导入已经跑完,实例会保留,同名文件可能已被覆盖」——后半句是假的,
而且与红卡直接对立。改成**只有** `Success | Partial + cancel_requested` 才判 `TooLate`;
`Failed` 一律 `NotRequested`(失败卡自己用 `failure_stage` 分「没写过 / 回滚干净 / 回滚没做完」,
真相已经说全)。
另外 app-iced 的 `cancel_note` 加一道双保险:只有 `status` 是 `Success/Partial` 时才出那句话,
推导万一回归也不会贴到红卡上。

P2-2(推导零覆盖):此前只有「给定这一档、文案怎么画」的用例,把 `RollbackIncomplete` 的推导
删掉也全绿。现在 `convert/cancel.rs` 带三条推导用例:`Cancelled + !rolled_back ⇒ 回滚没做完`
(含对照 `rolled_back ⇒ 已回滚`)、`Failed + 取消 ⇒ NotRequested`(含对照 `Success/Partial ⇒ TooLate`)、
没点过取消 ⇒ `NotRequested`。

变异实测:
- P2-1:把 `Failed` 也判 `TooLate` ⇒ `a_failed_run_is_never_dressed_up_as_a_late_cancel` 红
  (`left: TooLate / right: NotRequested`,"失败的导入没有「跑完」这一说")。
- P2-2:删掉「回滚没做完」的推导(`Cancelled` 一律按已回滚)⇒ `an_incomplete_rollback_is_its_own_bucket` 红
  (`left: RolledBack / right: RollbackIncomplete`)。

验证:`cargo test -p unlucky-core -p unlucky-presenter -p unlucky-app-iced` = 2188 passed / 0 failed;
`-p unlucky-i18n` = 31 / 0;clippy 四 crate = 0 warning / 0 error。
2026-11-09 05:06:56 +08:00
AskaEth b1524635ba fix(m195): 复审 r2 —— 取消的真实结局必须到达界面(三档 + 终态穿透代次)
复审 r2 P2-1:界面无条件断言「已停手并回滚:没有留下半包,也没有覆盖已有文件」,而两条路径下
这句话是假的——①取消来得太晚(落在最后一个检查点之后)时管线已经跑完,用户的同名文件被覆盖、
实例已登记;②回滚本身没做完(`rolled_back=false`)。core 一直是诚实的,是 presenter 把报告丢了:
`cancel.load()` 早退 + `cancel_pack` bump 代次后 `handle` 按代次丢弃。

按"接通真相"做(不是改软话):

- **终态一定发出**:`import::run` 去掉 `cancel` 的无条件早退,收尾抽成 `finish`(清自建空目录 +
  无条件 `send(PackEvent::Report)`);取消标记在管线返回后**再读一次**,作为"太晚"的唯一证据。
- **终态穿透代次**:`HostPackState::terminal_epochs` 记下 `cancel_pack` 作废的那个代次,
  `handle` 对它**只放行一条 `PackEvent::Report`**(进度事件照旧按代次丢,`fresh_cancel` 起新
  一轮时清空)。这是路径 A 的成因与修法。
- **DTO 三档**(tower 批准扩 scope):`ImportStatusView` 加 `Cancelled`;新增
  `ImportCancelView { NotRequested, RolledBack, RollbackIncomplete, TooLate }` 挂在
  `ImportReportView::cancel` 上——已回滚 / 回滚没做完 / 取消太晚,三档各自可判。
  `PackEvent::Report` 到达时清掉 `ImportState.cancelled`(它现在只是"取消后还没有结果"的占位)。
- **界面按结局分支**:标题 `PackStatusCancelled`(已取消 — 已回滚)/ `PackCancelIncomplete`
  (已取消 — 回滚没做完)/ 取消太晚时保留真实状态标题并另起一句 `PackCancelTooLate`
  (实例会保留、同名文件可能已被覆盖);占位的 `PackCancelled` 改成"正在停手,真实结果随后给出"。
  图标:`Close`(描边,页面对填充字形有守门)/ 回滚不完整用 `Warn`,一档都不用 `Role::Error`。

守门与变异:
- 新增路径 A 用例 `a_late_cancel_still_delivers_the_real_outcome_to_the_ui`:真管线把取消置在
  **最后一条 Commit 进度**上(之后没有检查点)⇒ core 报 `Success`/未回滚、用户的 `mods/a.jar`
  确实被覆盖;再 `cancel_pack`(代次 +1 并记终态)→ `finish` → `handle`,断言界面收到
  `status == Success` + `rolled_back == false` + `cancel == TooLate`,且同代次的进度事件仍被丢。
- 变异①:`handle` 恢复"代次不符一律丢" ⇒ 该用例红("取消太晚时,真实结果必须到达界面")。
- 变异②:`finish` 恢复"取消就早退" ⇒ 该用例红(另带 P2-2 那条也红)。
- app-iced 新增 `model/tests/cancel.rs`:三档标题互不相同 + 「太晚」必须有那句补充说明 +
  已回滚不许显示成「部分成功」。

验证(CARGO_TARGET_DIR=C:/Temp/ull-target/wt-194):
`cargo test -p unlucky-core -p unlucky-presenter -p unlucky-app-iced` = 2185 passed / 0 failed;
`-p unlucky-i18n` = 31 / 0;clippy 四 crate = 0 warning / 0 error。
2026-11-09 05:06:55 +08:00
AskaEth db5b08b9ab fix(m195): 复审 r1 三项 —— 解压检查点真守卫、presenter 接线测试、取消文案改回真话
P2-1(解压循环检查点没有真守卫):那条用例原来的断言(status/target/files/bytes/无残留暂存)
在删掉 `extract_overrides` 的检查点后**依然成立**——提交前检查点⑤会替它停一次,于是几 GB 的包
会一直解到结束而测试全绿。改成数 `ImportStage::Extract` 事件:有检查点 = 1 条、删掉 = 3 条
(两面实测)。模块文档同时如实登记"还没有真守卫"的检查点:②③④⑤、全新导入 commit 的取消
分支、phase.rs 远端下载逐条、complete.rs 补全循环的 break。

P2-2(presenter 的真令牌接线零测试):新增 `pack/tests/cancel.rs`,直接调 `import::run` 并把
传入的 `Arc<AtomicBool>` **预先置 true**,判据只能是盘上事实(目标目录不存在 + profiles.json
零实例)——"没收到 Report"那种判据两条路都成立,`run` 早退条件里的 `cancel.load()` 会把
"没接线"完全遮住。

P2-3(用户可见文案说反了话):`PackCancelled` 中英双语从「导入会在后台继续跑完 / Import keeps
running in the background…已经写下的文件会留下」改为「导入已取消 —— 后台已停手并回滚:没有留下
半包,也没有覆盖已有文件」;golden 金表同步;`model/tests.rs` 那条把旧文案钉死的用例改成钉新
语义(必须说清停手+回滚,且**禁止**出现 background);`model.rs` 的 `cancelled` 字段文档与
`dispatch.rs` 的取消注释同族修正。

验证:`cargo test -p unlucky-core -p unlucky-presenter` 1480 passed / 0 failed;
i18n 31 / 0;app-iced packui 28 / 0;三 crate clippy 零 warning。
2026-11-09 05:06:53 +08:00
AskaEth 1441a240fe feat(m195): 导入管线取消令牌与原子回滚(取消不是失败)
M190 复核 §3 A-2:导入管线没有取消令牌,「取消导入」只改界面状态,后台仍跑完并写盘
(半包 / 覆盖风险,丢数据档)。本轮把取消做成管线级:

- core 新增 `import/cancel.rs`:`ImportCancel` 令牌(可 `from_flag` 接管既有
  `Arc<AtomicBool>`)+ 内部 `Stopped::{Cancelled, Failed}` 两条停止路线;
- 结果类型单列 `ImportStatus::Cancelled`(`error` 恒为 None、退出码 130、文案 key 独立),
  与 `Failed` 是两条路——UI 不弹红、脚本不会把「改主意」记成「装坏了」;
- 七个检查点:入口 → 解析后 → 补全后 → 开暂存目录前 → 提交前 → 合并态逐文件 → 登记前;
  解压 / 远端下载各逐条检查;单条下载在飞不可中断(fetcher 无取消参数)已在文档里说准;
- 取消后不留半包 / 不覆盖已有实例:暂存目录整个删、合并态 journal 逆序回滚(备份改回原名)、
  落盘后取消连内容一起回滚;`files / conflicts / missing / bytes_written` 清零,不留假账;
- presenter:同一个取消标记现在真交给 core(`with_cancel`),取消后不再「后台跑完并登记」;
  CLI 增 `cancelled → 130` 一档与三档人读名。

守门三条各带变异证明(见 mission 报告):删回滚 → 红、删登记前检查 → 红、把取消并进
`fail()` → 红。

没验:超大整合包的真实 IO 下取消响应延迟(最坏 = 当前一条文件读完/下完 + 一次回滚)、
单条下载在飞不可中断、macOS / Linux 未跑。
2026-11-09 05:06:52 +08:00
AskaEth 476bf92f97 test(addon): 给「失败路径取名字」的生产接线与降级补守卫 (M191 r3)
复审 r3 p2:那两处都只靠消费层用例撑着,生产接线上删掉/改坏也全绿。补两条能真红的守卫。

**接线层**:`install.rs` 里那次 `names_for_incompatible(..)` 原来不可驱动。现在下载器与
取名字器从 `Ports` 参数进来(生产 = 引擎 + `plan::fetch_title`,用例 = 假实现),
于是用例能驱动**生产那个 `collect`**:一份「一个文件下成功 + 一个不兼容前置 + 一条 404」的
计划跑完,断言不兼容那条拿到了名字、且**只**给不兼容档问过一次。
变异(把那一行短路成空名单)⇒ `the_failed_install_path_really_fetches_names_for_the_incompatible_items`
红:`left: "P7dR8mSH"` / `right: "Entity Culling (P7dR8mSH)"`。

**降级层**:把「一次查询 → 有名字/没名字」的折叠抽成 `title_from(query, id)`
(`fetch_title` 只剩「建客户端 + 调它」)。参数化之后三种「没名字」都能离线钉住:
查询失败 / 空标题 / 全空白 ⇒ `None`,不 panic。
变异 ①`ok` 换 `expect` ⇒ `a_failed_or_blank_lookup_yields_no_name` 红
(panic:`带断网`);②去掉空串过滤 ⇒ 同一条红(空白档)。

**顺手**:`fetch_title` 真发一次 HTTP 的那一跳没进用例(本轮实测它确实取到了真数据
`P7dR8mSH → "Fabric API"`,但把网络调用放进 `cargo test` 会变脆,所以只把可坏的那一步——折叠——
参数化测掉,报告里如实写「HTTP 这一跳仍无用例」)。

**行数**:`install.rs` 加到 415、`tests/retry.rs` 到 436,都越过 400 硬线 ⇒ 两步纯搬移:
`collect` + `Ports` + `Placement` → `install/collect.rs`(117 行,`install.rs` 321);
r3 用例 → `tests/retry/wire.rs`(115 行,父文件 332)。
2026-11-09 05:06:06 +08:00
AskaEth 26e7b7333e fix(content): 不兼容档在失败路径上取项目名 + 重试轮不再贴错根的标题 (M191 r2)
复审 r2 两条 p2,都修了,并按复审给的口径验过红。

**P2-1(续):不兼容档不再只有裸 id**
`FailureKind::Incompatible` 的项目**确实存在**(站点只是没有匹配目标版本/加载器的条目),
所以失败路径上值得现取一次名字——`plan::names_for_incompatible` 按失败项去重、一项一次 GET
(`Modrinth::project`),`fetch_title` 把任何失败都折成 `None`:
**拿名字失败不许影响安装结果**,取不到 / 拿到空标题就退回 id。只有这一档发请求;
「取不到」档 404 那部分项目根本不存在,再发一次只会再吃 404。

**P2-2(新):重试轮把根的标题贴到了前置头上**
根因是判「这条是不是根项目自己失败」用了 `plan.request.roots.first()`,而重试那一轮的 plan
来自 `retry_request()`,它的 `roots` 就是上一轮的失败名单——第一个是前置。
现在判据改成 **id 比对**(`Root{id,title}`,id 由 `remember` 一起记下来):
`label_of` 只在这条失败的 id 真的等于原始根 id 时才用根的标题。复审给的那个形状
(roots=["P7dR8mSH","1eAoo2KR"] + root_title="Sodium")现在画 `P7dR8mSH`,不再是
`Sodium (P7dR8mSH)`。

**守门(每条都真跑红过,且在拆完模块的最终代码上复跑)**
- 去掉不兼容档的名字来源 ⇒ `an_incompatible_item_shows_the_project_name_once_it_is_known` 红
  (`left: "P7dR8mSH"` / `right: "Entity Culling (P7dR8mSH)"`)。
- `label_of` 换回 `roots.first()` ⇒ `the_retry_round_does_not_paste_the_root_title_onto_a_dependency` 红
  (`left: ["Sodium (P7dR8mSH)","1eAoo2KR"]`,正是复审实测的那一行)。
- 取名字不降级(`expect`)⇒ `a_failed_name_lookup_falls_back_to_the_id_without_breaking_anything` 红。

**行数**:`plan.rs` 加完这套到 414 行、`install.rs` 到 403 行,都越过 400 硬线 ⇒
把「名字从哪来」整块(`Root`/`Names`/`names_for_incompatible`/`label_of`/`fetch_title`)
纯搬移到 `plan/labels.rs`(111 行):`plan.rs` 336、`install.rs` 388。
2026-11-09 05:06:06 +08:00
AskaEth 961ff453a0 fix(content): 重试面板画可读名,不再只有站点 id (M191 r1 P2-1)
复审 P2-1:面板原来只画 `P7dR8mSH` 这种项目键,用户拿到的是一串哈希。
可读名其实都在手边,只是没进 DTO——现在补上,按复审给的**最小修法**做,没扩散:

- `InstallFailureView` 加 `label: String`(**语言中立**:站点标题或文件名),
  配 `line()` 统一决定「名字 (id)」还是「只有 id」(名字为空时不编造)。
  `detail`(core 中文散文)**依旧不进界面**——那会踩 i18n R2。
- `plan::label_of` 三个来源,零额外请求:
  根项目 → 请求里的 `title`;计划里有节点(下载失败)→ `node.file.filename`;
  都没有(解析 404 / 不兼容的前置)→ 空,界面退回只画 id(复审认这条兜底)。
- 计划被记住时连显示名一起记(`plan::remember` 多一个 `title`),于是重试那一轮
  面板头与失败名单沿用的是同一个名字(顺手收掉复审 N5 的「重试时面板头显示 id」)。
- N2:删掉新加的 `ContentInstallDismiss`,复用既有的 `DownloadDismiss`(同一句话不立两条 key)。
- 行数/clippy:`collect` 的参数捆成 `Placement{dir,title}`(9 个参数超限);
  `plan.rs` 加完 `label_of` 到 429 行,把「记住的计划」表纯搬移到 `plan/remembered.rs`(363 + 82)。

守门(真跑红再还原):
- 把 `label_of` 置空 ⇒ `the_retry_list_shows_a_readable_name_not_just_the_project_id` 红
  (三行全变成 `["AANobbMI","P7dR8mSH","1eAoo2KR"]`);
- 审阅指出的另一半判据也补上了:`Done` + 残留名单 ⇒ 必须不画;现在**只去掉**
  `stage != Failed` 那一半就红(之前只去掉它仍是全绿,报告的表述不准,已改)。
2026-11-09 05:06:05 +08:00
AskaEth 5a52f38f35 feat(content): 安装失败的两档分组与「只重试这些」(M191)
M186 把「可只重试失败项」算到了 core/presenter(DependencyPlan::retry_request()),
但 DTO 没字段、界面没按钮,用户拿不到。这一轮补最后一公里:

- DTO:ContentFailureKind 加 Incompatible 一档(不再借 Unsupported,与「缺密钥」分开);
  InstallProgressView 加 retry: Vec<InstallFailureView>(project + 档位 + core 原话),
  档位是专门的 InstallFailureKind(Incompatible / Unreachable,穷尽映射 core 的 FailureKind);
  ContentInstallRequest 加 retry: bool(重试路径唯一读的字段是 instance)。
- presenter:plan.rs 把上一轮失败的计划按 host+实例记住(与 addon/pack.rs 同一手法,
  host/mod.rs 不在 scope);retry_request()/retry_plan() 就是「只重试这些」——
  调 core 的 retry_request()、只请求失败的那几个项目,present 重新扫目录。
  install.rs 的重试入口没有记录时回一条失败,不退化成整单重装;成功则 forget + 清名单。
- 界面:内容库页插入按档分组的失败面板(两段文案、各自条目)+「只重试这 N 个」实心按钮;
  判据只有一条且在模型里(stage==Failed && 名单非空),全成功时连同按钮一起不渲染。
- i18n:新增 6 条中英文案(不兼容 / 取不到 / 只重试这 N 个 / 标题 / 提示 / 关闭),金表同步。

守门(每条都做过变异验证,见报告):
① 把 retry_request() 换成根项目请求 ⇒ retrying_asks_for_the_failed_projects_only 红(roots 变 ["sodium"]);
② 两档合并 ⇒ the_retry_list_keeps_the_two_tiers_apart / the_two_tiers_are_two_groups_with_two_labels 红;
③ 恒渲染 ⇒ a_fully_successful_install_has_no_retry_surface_at_all 红。

数据包(datapack)本轮仍未做:ContentKind 没有这一项,界面上也没有半成品入口。
2026-11-09 05:06:04 +08:00
AskaEth 93bd704786 fix(m192): r1 复审 P1-1——动效起跑搬进 update 侧 + 帧基准兜底 + 按运行时顺序的守卫
复审的机制三步全部复核成立(iced_winit-0.14.1 `src/lib.rs:1222` 调 update、`1338` 在
`update()` 内部求值订阅表、`1233` 之后才 `build_user_interfaces` 调 view)。第一版把
「标识变了 → play」写在 `pages::render`(= view)里,于是:同一拍的订阅表看不见轨道 ⇒
`frames()` 要等下一条消息才打开;而那一帧的 dt 是整段空闲时长 ⇒ 轨道被当场判到期删掉。
用户看到的是「一帧 12/8px 偏移 + 闪跳」,比硬切更差;截图通道因为 `has_screenshot()` 恒真,
结构上不可能发现它。

修法(三件都做)
- **起跑搬进 update**:新增 `app/update/motion_sync.rs::App::sync_motion`,在 `App::update`
  收尾(`route_message` 之后、`subscription()` 之前)按**路由**算内容标识并 `motion::observe`。
  `pages::render` 只留 `motion::sample()`(纯采样)。
  顺带:`screen == None`(账户页 / AI 独立页)只记标识不起跑,于是「从它们回到某一屏」也跑换页。
- **帧基准兜底**:`observe` 起跑成功时把 `LAST_FRAME` 重置到「现在」;`Pump::advance` 再加一道
  `MAX_STEP_MS`(4 帧 = 64 ms)单次步进上限——掉帧/挂起时宁可把动画放慢,也不许一帧抹掉。
- **没有窗口就不起跑**:`window.is_none()` 且不是截图通道时 `sync_motion` 直接返回(也**不记**
  标识)⇒「首屏入场」等窗口到了才算第一眼,启动期那棵空树吃不掉它(原先被 1 Hz 心跳 /
  既有 `an_idle_app_...` 用例照出来的就是这个边界)。
- `on_heartbeat` 也推进动效时间轴:窗口被遮挡/最小化/合成器不送绘制时,帧一个都不来,而
  「有轨道」会让 1 Hz 推进整段停摆 ⇒ 轨道靠心跳按单次上限收尾(最坏 5 拍内自然退订)。

守卫(新增 5 条,全部按运行时顺序写)
- `a_state_change_opens_the_frame_subscription_in_the_same_tick`:**先** `assert!(!needs_frames)`
  →**一条真消息**(`Message::Page(Navigate(..))`)→ `assert!(needs_frames)`,并断言
  `watch_until` 仍是 `None`(排除「观察窗顺手开帧」这条假绿)。r1 那条顺序是反的,已删。
- `opening_a_detail_also_opens_the_frames_in_the_same_tick`、`pages_that_render_elsewhere_do_not_start_a_track`、
  `without_a_window_nothing_animates`、`a_stale_baseline_cannot_kill_the_track_in_one_sample`。

r1 两条 P2
- P2-1:`pump::FRAME_MS` 与 `mod.rs` 契约表补写「16 ms 只卡采样/布局推进,**整窗重绘跟随渲染器**
  (软件渲染 ≈970 帧/秒、一次 300 ms 换页 ≈290 次整窗重绘 ≈0.30 核·秒;GPU 档 AutoVsync 40~60)」。
- P2-2:删掉 `app/subscription/tests.rs`,测试并回 `app/subscription.rs` 的 `mod tests` ⇒ 本轮
  动过的文件全部落在声明 scope 内(新文件只有 `app/update/motion_sync.rs`,在 `app/update/**` 下)。

真机证据(GPU 档,`ULL_TICK_TRACE=1`,空闲 14 s 后由心跳触发一次**真实**导航,
`DevDocs/artifacts/m192/idle-click-evidence-gpu-final.log`):
  idle: frames=96 clock=idle-1hz motion=14(连续 7 拍不动)
  fire: clock=frames motion=16 → 17(同一拍订阅表就开了)
  end : frames=102 clock=idle-1hz motion=20(自己退订)
说明:本机会话没有前台窗口(`GetForegroundWindow()==0`),合成点击(`mouse_event` 与
`PostMessage(WM_LBUTTONDOWN)`)递不进 iced 的输入层,故用「心跳第 N 秒真导航」这条同构路径;
样本次数由**绘制送达**决定(该次动效只收到 ~5 次绘制 ⇒ +6 次采样),正常可见窗口下 60 Hz 呈现
对应 ~19 次。

变异证明 7/7 红(`DevDocs/artifacts/m192/mutate.sh`,含新增 M6「删掉 sync_motion」与
M7「去掉单次步进上限」),还原后自检全绿。

编译:cargo test -p unlucky-app-iced → 0(797 passed / 2 ignored,集成 6 passed);
clippy --all-targets -D warnings → 0;fmt --check → 0;build → 0。
出图基线复验:三张 rest 基线与九张序列帧的 sha256 与 r1 **逐字节相同**(常驻的那层 container 零内距)。
2026-11-09 04:52:40 +08:00
AskaEth a1876a08c0 feat(m192): 动效帧泵——design motion 令牌接进 iced 的有界帧订阅 + 首批 3 条入场动效
令牌早就在 `crates/design/src/motion/` 里齐着(16 档时长 / 可采样缓动 / 弹簧二维表 /
MotionScale 降级),但适配层从没消费过,所以一切状态变化都是硬切。本次补的正是那条中间层:

新增 `components/motion/`(时间轴,不是控件家族)
- `pump.rs`:`Slot`(boot/screen/detail 三个互斥触发点)× `Spec`(时长档 × 缓动档,
  **唯一一张表**,页面拿不到「给我 250ms」这种口子,D31)→ 进度采样;时间轴按 16ms 结算
  (≈60 次/秒),到期轨道**当场删除**。
- `mod.rs`:进程内单时间轴(thread-local;iced 在事件循环线程上串行调 update/view,
  与 `App::update(&mut self)` 同一条纪律,测试也因此天然隔离)+ `enter`/`shifted` 两个页面入口。
- **默认 NONE**:只有产品 `on_boot` 按档位打开;`pages_demo` 这类截图 harness 从不打开。

接线(都接在既有那条链上,不另开循环)
- `App::needs_frames` 多一条「有轨道」分支 → 与截图 / Busy / toast / watch_until 共享**同一个布尔**,
  `window::frames()` 在源码里仍只有一处(守卫:双订阅就红)。
- `on_frame` 每帧推进时间轴;`on_heartbeat` 不变(有帧时不推进)。
- 定档策略集中在 `update/boot_env.rs::motion_scale`:截图档 NONE(D30 出图可复现)、
  CPU 渲染档 HALF、GPU 档 STANDARD,`ULL_MOTION` 可强制。

首批 3 条动效(`pages::render` 一个漏斗,静息恒为 (0,0) ⇒ 既有截图基线一张都不用重出)
- 首屏进入 16px / 换页 12px(纵向) / 列表↔详情 8px(横向);
  时长 400/300/350ms 全部取自 `DurationToken`,曲线 `Easing::Standard`
  (`EmphasizedDecelerate` 把 75% 位移压在 51ms 内走完,12px 的整页平移会像跳一格)。

守卫与取证
- 11 条帧泵算术守卫 + 4 条接线守卫(含「NONE 档帧采样次数必须为 0」与「帧订阅源码只出现一处」);
- 5 条变异证明全部复现(删退订 / 删 NONE 短路 / 删 needs_frames 分支 / 删 16ms 闸门 /
  加第二条帧订阅 ⇒ 对应用例红);
- 多帧序列图 12 张在 `DevDocs/artifacts/m192/`(t=0 / 半程 / 末 + 静息基线,
  `--diff --diff-scan` 实测换页 12px/6px、详情 8px);
- 空闲实证:`ULL_TICK_TRACE=1` 跑真窗口,帧订阅窗口一过 `clock=idle-1hz` 且 `frames=` 冻结。
2026-11-09 03:56:19 +08:00
AskaEth a219a21db8 fix(m190): r2 复审——dalek strict 口径照实测写正、补小阶 R 用例、锁里 toml_datetime 退回 1.1.2
三条 P2 都是「文档/注释与事实不符 + 一条上报事实错了」,代码行为没变。

P2-1 dalek 的 strict vs 非 strict 口径改成正测试出来的样子:
* `VerifyingKey::from_bytes` **不**校验编码规范性(`y = p` 的非规范写法返回 Ok,`is_weak() == true`),
  先前写的「要求规范编码」是错的;
* 非 strict 的 `verify` **也**查 `S` 规范性 —— 先前把它写成「不做可锻检查」是半错;
* 于是这批拒绝类用例里,strict 与非 strict 唯一的分歧是**小阶点**:承重的是
  `small_order_public_key_is_rejected`,其余几条(malleable / non_canonical_r / empty)换掉 strict 后
  **仍然绿**,文件头的表与用例注释都改成这个口径,别让人误以为它们也是守卫。
* 新补 `small_order_r_is_rejected_only_by_strict`:R = 单位元 + S = k·a mod L 的伪造,**两边都断言**
  —— dalek 的非 strict `verify` 接受它、我们的 `verify` 拒绝。这是第二条「只由 strict 拒」的判据。
* `non_canonical_r_encoding_is_rejected` 里那条「非规范公钥编码」原来对两种实现都绿(等于没测),
  现在断言 dalek 的**真实**行为(`from_bytes(...).is_ok()` + `is_weak()`),dalek 升级改口径会红。

P2-2 `packaging/sign_manifest.py` 里指向已删除 `ed25519/{field,group}.rs` 的旧注释删掉(那段还写着
「same formulas」,而两套实现现在是**互相独立**才构成交叉验证)。

P2-3 `Cargo.lock`:`toml_datetime` 从 1.1.3 **退回 1.1.2**(`cargo update -p toml_datetime --precise
1.1.2` 实测可降、`cargo metadata --locked` 自洽)。先前报告里「被 `toml` 拒绝所以无法避免」的理由
**不成立**,已在报告里更正。现在相对 `44a0c87` 的锁变更是**纯新增**:16 个包、零既有版本变动。

变异证明(本轮按要求重跑):把 `verify_strict` 换成 `verify`(并补 `Verifier` trait 使其可编译)
⇒ `test result: FAILED. 110 passed; 2 failed`,红的两条正是
`small_order_public_key_is_rejected` 与 `small_order_r_is_rejected_only_by_strict`;其余拒绝类用例全绿
(与修正后的口径一致)。恢复后 `112 passed; 0 failed`。
2026-11-09 02:33:08 +08:00
AskaEth 918d826645 fix(m190): r1 复审——0x01 修成 \1 + YAML 门禁、被调方声明 secret、拆两个超限测试文件、验签换 ed25519-dalek
四条 P1 + 四条 P2 一并处置(不变的是判据,变的是「谁在承重」)。

P1-1 `.github/workflows/release-build.yml` 的 sed 替换串是裸 0x01(本意 `\1`):GitHub 解析该文件会
失败 ⇒ 整个 run 起不来;即便容忍,版本号也会被求值成单字节,PE 判据报「与 Cargo.toml 不同源?」把
排障引向错方向。修成字面 `\1`,并新增 `packaging/check_workflows_yaml.py`(真 YAML 解析器 parse
全部 workflow + 裸 C0 控制字符字节判据;带 --self-test 注入 0x01/坏结构断言必红),接进
`release-guard.yml` 两步。文本扫描型守卫看不见这类字节,这道门禁专门挡它。

P1-2 `release-publish.yml` 没在 `on.workflow_call.secrets` 里声明 `ULL_MANIFEST_SIGNING_KEY`,调用方
却传了:可复用工作流只拿得到自己声明过的 secret ⇒ 用户配好钥也会在签名步红,而错误文案反过来指控
Secret 没配。补声明(required: false,fail-closed 由步内 ::error:: + exit 1 承担,同
ULL_MSA_CLIENT_ID 的模式)。

P1-3 两个测试支撑文件越过 400 行守卫:签名夹具拆到 `tests/support_sign.rs`(support.rs 458→392),
CLI e2e 的签名部分拆到 `support/update_e2e_sign.rs`(update_e2e.rs 406→348)。纯搬家。

P2-2 「唯一收口点」改成结构事实:从 `UpdateSource` 删掉 `fetch_manifest` 默认实现,调用方只能拿
`verified_manifest(&source, channel)`(源只交原文,想绕过验签就得自己重写解析与渠道判据)。
P2-3 文档写的第 ④ 条命令补进流水线:`sign_manifest.py --verify-pinned` 用 trust.rs 里钉住的公钥
回验刚落盘的 .sig(不碰私钥),发布侧因此验证的是「客户端的判据」而不是「签名脚本的自我声明」。
P2-4 随自写实现一起删除(`Fe` 的错误 PartialEq 不再存在)。
P2-5 app-iced/build.rs 注释里那个 windows_subsystem 属性本分支不存在(M196 才有),改为说明
「本文件只写版本资源,子系统由 rustc 属性决定,check_pe_version.py 会把 subsystem 打出来」。
P2-6 坏清单不再带 error.path 是有意降级(来源 id 在 message 里),保持。

裁定项:自写 Ed25519 换成 `ed25519-dalek::verify_strict`(信任锚不许自写密码学;此刻信任表还是空表,
零兼容负担)。删除 `update/ed25519/{field,group,mod}.rs`(含只服务它们的单测),新 `update/ed25519.rs`
只剩一个薄封装;拒绝类用例(可锻 S+L / 小阶公钥 / 非规范 R / 空签名 / 未知 key)**一条没删**,
改成钉 dalek 的 strict 语义——实测 dalek 对全部五类的结论与旧实现一致(非规范 R 的拒绝理由不同:
dalek 走「小阶点」而非「编码非规范」,用例注释里写明)。签名侧留在 `packaging/ed25519_ref.py`
(纯 Python、零 pip 依赖),`--self-test` 用 RFC 8032 同一组向量钉两边。
2026-11-09 02:14:32 +08:00
AskaEth b7d9b99e12 feat(m190): 产物署名(PE VERSIONINFO)+ 清单 Ed25519 多签名与更新前验签
两件事:让「这是谁发的」在产物与清单上都留下可验证的痕迹。

① PE 版本资源(两个 bin):`packaging/pe_version.rs` 是唯一口径(CompanyName
   「Unlucky Launcher Project」/ ProductName、FileDescription「Unlucky Launcher」/
   LegalCopyright「© 2026 … · Apache-2.0」/ 四段式版本号与 Cargo.toml 同源),被
   `crates/{cli,app-iced}/build.rs` 各 include 一次;非 windows 目标整段不执行。
   判据是读产物字节的 `packaging/check_pe_version.py`(CI 的 Windows 槽位)。

② 清单签名:`manifest.json` 旁边多一份 `manifest.json.sig`(分离签名,签的是清单**原始
   字节**),信封是 `{format, signatures:[{alg,key_id,sig,manifest_sha256}]}` —— 多签名数组,
   任意一条命中钉住的钥即放行(换钥窗口)。信任锚是 `crates/core/src/update/trust.rs`
   里的编译期公钥表(正式表为空 = fail-closed,测试钥只在 cfg(test)/update-test-source 里)。
   管线顺序改成「先验签、再解析」:`UpdateSource` 只交原文 + `.sig`,`verified_manifest`
   是唯一收口点,`Job::apply` 因此在取包**之前**就拒。新增五类错误分类
   (signature_missing / malformed / unknown_key / keyring_empty / invalid ),全部不可重试,
   CLI 的 hint 单独说人话。

③ Ed25519 验签自带实现(`update/ed25519/*`,零新依赖):scope 不含 core 的 Cargo.toml,
   用 RFC 8032 §7.1 向量 + 可锻/小阶/非规范编码的拒绝用例钉住;签名侧是
   `packaging/sign_manifest.py`(纯 Python 参考实现,零 pip 依赖,`--self-test` 同向量)。

④ 守门:`release-publish.yml` 加签名步(`set +x`,只回显公钥;没 Secret / 钥没钉 / 签不上
   一律红 ⇒ 不出 Release);`release-build.yml` 加 PE 资源读回;`release-guard.yml` 跑两个
   脚本的 `--self-test`。密钥纪律与轮换(旧钥签新钥)写在 `packaging/docs/signing.md`。

Cargo.lock:新增 winresource(build-dependency)与 toml 系;toml_datetime 1.1.2→1.1.3 是
toml 1.2.1 的硬要求(`cargo update --precise 1.1.2` 已实测被拒)。
2026-11-09 01:59:05 +08:00
AskaEth 44a0c87a11 style: cargo fmt --all(合并期整合修复)
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
M187/M188 并入后全仓 fmt --check 为红(CI 的 fmt job 会红)。
本次仅 cargo fmt --all 的换行/缩进变化,无语义改动;
cargo fmt --check 与 cargo check 均已复验为绿。
2026-11-09 01:43:31 +08:00
worker-logplace 8c5bebf3e3 fix(m188): r1 复审 P2-1——后端启动失败的具名原因进 launcher.log
复审结论 p2-1/1 item + fix-then-merge:主线成立,但「启动器自己因 GPU / 渲染后端起不来」的
事故原因仍只进 stderr —— 双击启动没有 stderr,那条路径上等于没有日志。

- renderer/mod.rs::report_failure 的两行(`[gpu] <backend> 初始化失败(具名)` /
  `[gpu] 失败详情(具名)`)改走 diag!;
- renderer/log_bridge.rs 转出的 wgpu 日志(含 `…Selected: AdapterInfo…`,GPU 真生效的唯一证据
  行)改走 diag!;
- renderer/preflight.rs 的 `[gpu-preflight]`(ULL_PREFLIGHT=1)改走 diag!;
- probe.rs::apply_with_preflight 的三行 `[probe]` 改走 diag!。
- app/diag.rs 模块文档 + docs/logs-location.md §3 的判据改准:
  「逐帧度量不接;显式取证开关与一次性事故行可接」,并修掉 `[corner]` 两处自相矛盾
  (唯一打印点在 app/update/appearance.rs,走 diag! ⇒ 进日志;corner.rs 只 format_line)。
- 新守门 renderer::tests::a_backend_startup_failure_lands_in_the_launcher_log:
  断言那两行真的落进 launcher.log;变异(改回纯 eprintln)→ 红。
- README 两语的 launcher.log 行补上「渲染后端起不来的具名原因」。
2026-11-09 01:05:40 +08:00
worker-logplace 5fd5cd47dc feat(m188): launcher.log 早落盘 + 会话日志搬到 logs/game(旧家只读兼容)
用户投诉原话:「我们的启动日志放在哪?…他找半天没找到崩溃日志」「只有一个安装日志」。

启动器运行日志(.ULL/logs/launcher.log)
- presenter 构造 HostEnv 时就登记日志目录:启动早期缓冲的 200 行([probe]/[font]/[theme])
  当场刷盘,不再是「只有开过一局游戏才有日志」。
- app/** 里所有 eprintln! 诊断改走 app::diag::diag!(stderr + 文件双写,行首 [tag] 即 scope),
  含 boot/boot::fallback/state/update 各线。
- boot 那一拍补一行后端事实(request/effective/fallback)。
- 滚动上限(1 MiB → launcher.1.log,只留一份)写进 README 与 docs/logs-location.md。

会话日志搬家:data/agent/sessions → logs
- LauncherLayout: sessions_root()/game_logs() 新增,agent_sessions() 降级为只读兼容。
- host/logs 与 cli/commands/sessions 两个根合并(同 id 新根优先),event_path 回落旧家。
- 不搬不删;日志目录写一份 session-log-location.json 记录(moved=0, policy=read-only-compat)。

入口与文案
- 设置页「通用」新增「日志文件夹」行:路径 + 打开目录 + 复制路径(i18n 中英)。
- 日志页页头的「打开目录」不再是占位:走 M187 的 open_dir,失败文本带路径并送进剪贴板。
- README.md / README.zh-CN.md 新增「日志在哪」章节(含 crash-reports 只在游戏走到那一步才有),
  docs/logs-location.md 给出上限、双写判据、迁移策略与没验的部分。

守门(每条都有变异证明,见报告)
- 旧家会话仍列得出/打不开 ⇒ 只读新根变红;新会话写 logs/game ⇒ 只读旧根、写回旧家都变红。
- launcher.log 滚动 ⇒ 干掉 rename 变红。
- 打开日志文件夹指向真实目录 ⇒ 换成 cache/ 或去掉 create_dir_all 都变红。
- 日志根随三模式变化 ⇒ 把 HostEnv::logs_dir 写死成便携落点变红。
2026-11-09 00:43:45 +08:00
AskaEth 2b6dcbed40 fix(m187): r2 复审 p2——截断过的文件也必须脱敏(截断 改到文本流里做)
r2 复审挖到的隐私旁路:上一版在**字节**上切两刀,切在多字节字符中间时
String::from_utf8 失败 ⇒ 整份 entry 走「原样字节」分支,家目录 / 用户名 / 令牌原文
按字节进包,而清单什么也不说。命中的正是最容易超过 4 MiB 的 latest.log 与会话 JSONL;
中文 Windows 用户名(C:\Users\张三)让家目录串本身就是 CJK,切点落在附近必然命中。

修法(比复审给的三条更严一点:先脱敏再截断,避免切点留下半截 secret):
解码(lossy,任何字节都能进脱敏器)→ 脱敏整份 → 按**字符/行边界**截断 → 插截断标记。
新增 crash_bundle/clip.rs(切点逻辑 + 4 条用例),write_bundle 里只剩一条通路,
「二进制原样」这条分支彻底删除。

守卫:truncated_entries_are_still_redacted —— 造一个 '中' 正好跨在 HEAD_BYTES 上的
4 MiB+ 文件,内含注册过的会话 id 与家目录;断言 ①包里找不到原文 ②<home> 在
③尾部 exit 事件仍在。变异(改回字节级截断 + 仅解码成功才脱敏)⇒ 红在「仍带着家目录原文」。

顺带:
- P3-1 启动日志写两遍(WindowHandle(None) 分支重复调 record_boot_log)→ 删掉重复调用。
- P3-2 清单里的截断字节数改成**实际保留量**(原来报的是常量 HEAD_BYTES)。
- P3-4 truncated>0 时界面那句话补用例(拆出 dialog/tests/export.rs,两个文件都 < 400 行)。
- P3-6 BundleSpec::manifest() 自己也过脱敏,去掉「谁调谁泄漏」的口子。
- P3-5 卡片宽度取官方上限 560 的语义在代码里写清(不改行为,登记为台账)。
2026-11-08 23:57:49 +08:00