Skip to content
This repository was archived by the owner on Sep 20, 2026. It is now read-only.
This repository was archived by the owner on Sep 20, 2026. It is now read-only.

refactor(tooltips): extract native paired tooltip host — 执行清单与验收标准 #204

Description

@pengx17

把 Combat Impact 自绘 Tooltip 中与业务无关的原生双 Tooltip 宿主基建抽到 GameInterop/Tooltips/

设计与根因证据docs/drafts/2026-08-04-native-paired-tooltip-host-extraction-design.md(本 issue 只承载执行清单与验收标准)。

状态:设计文档待确认,确认前不开始实现。该文档目前仍是本地 untracked 草稿,尚未推送。


硬约束

用户可见行为、视觉、布局、日志语义保持不变。

不做:不抽 BuildCaused/BuildReceived、分组/行/图标/文案;不抽 CombatImpactPerspective 与 Combat Impact DTO;不改 hover/锁定/等待 native tooltip/重试/requeue 流程;不合并 BppTooltipSectionsNativeCardTooltipContentRefresher;不移动 NativePostCombatImpactTooltipView.cs;不新增其他 consumer;不接管主 Card Tooltip 的 native 定位权。

六条不可违反的设计约束(红队评审产出,均有 file:line 证据,详见设计文档 §2)

NativePostCombatImpactTooltipView.cs 下称 View

  1. B1 清理是两阶段,不是 once-only。 session.Release(bool restoreNativeContent)restoreNativeContentVisibility 是二态语义:RestorePreparedNativeHost 仅在 true 时清空 _preparedNativeHost(View:939-941),而 cleanup 闸门把它算作"有活内容"(View:620-627),Dispose(View:2357-2361)正靠第二遍恢复 native header/body/divider。做成幂等会让游戏自己的辅助 tooltip 变空框,且无任何日志。 实现时附 11 个调用点核对表(9 个 false:View:112/131/139/162/189/231/240/521/607;2 个 true:View:508/2360)。
  2. B2 _renderGeneration 是 host/view 共用的单一计数器,递增顺序是载荷。 ++ 必须在 DisposeNativePreviews() 之前(View:632-633)。session 暴露只读 Generation,view 的 preview 任务与之比较。清理顺序 host(bump+停fade) → view(释放业务) → host(销毁+恢复)。拆成两个 generation 会让 _pendingPreviewCount 不归零 → IsReadyToReveal 永久卡住,唯一痕迹是 EntityPreviewCreateTimedOut
  3. B3 Position 必须收内容裁剪回调。 Position(anchor, IPairedContentBudget)FitActiveContentToCanvas(View:393)夹在 host 定位流程正中间且是 view→host→view 重入循环。
  4. B4 仲裁留在 feature。 host 只提供 OwnsPrimary / OwnsAuxiliary / ReleasePrepared;三个 OnNative* 的 bool 语义与后续动作原样保留。必须保留 View:502-506 那行"非我的 controller 也要归还快照",漏掉会永久改写 native 布局 padding/anchors。
  5. B5 需要 ForceSettle(reason) fade 协程宿主是游戏的 AuxiliaryTooltipController(View:538),宿主失活即静默终止,generation 只能防"晚到"防不了"永不到达"。
  6. B6 host 只给 atomic layout primitive,不编排回滚。 BeginMaskedLayout()(Dispose/异常均恢复 alpha)+ RebuildLayout();apply/revert 与二次 Position 全留 View。回滚那次 PositionPlacementResult 也必须过 reason-code 日志 —— LogPlacementDegradationOnce 在不降级时会复位 wasLogged(View:1959-1978),条数可观测。

另需遵守:失败一律返回 false 不抛异常(否则 AuxiliaryTooltipContentUnavailableTooltipRenderException 混淆);host 不实现 IDisposable(随 _nativeCardPreviewHost 先例);NativeAuxiliaryHostState.Capture 的两个捕获点(View:142 / View:195)相对 AssignTooltipFrame 的顺序原地冻结。


执行清单

Step 1 — 基线确认(无探针)

  • 主 checkout(不是 codex worktree)上 Debug build,部署后哈希校验 DLL 与构建产物一致 —— 已完成,0f1c076c…
  • Debug 与 Release 均 0 warning
  • ./run.sh format-check 通过

决策变更(2026-08-04):本任务是行为不变的纯重构,现有知识与已验收行为足够,不做 pre-refactor 全量 geometry 采集
原计划的临时 #if DEBUG JSON geometry probe 已实现并验证可行(挂在 Reveal() / SetPerspective() 主路径,
复用控制器 PostCombatImpactController.cs:606-641 的多帧收敛),随后按决策整体撤除,工作树已回到 pristine。
代价:失去逐字段几何 diff 这一客观等价证据,改为依赖 Step 7 的定向实机 smoke。
只有在 smoke 发现差异时才重新加定向探针(而非全量采集)。

Step 2 — 新增通用 Host

  • NativePairedTooltipContracts.cs:options、PlacementResultPairSideIPairedContentBudget单一 const float Epsilon = 0.5f(同时承担几何判定与 CanvasGroup alpha 阈值,历史事实非设计)
  • NativePairedTooltipPlacementMath.cs保留 UnityEngine.CoreModule 引用,公式与 0.5 epsilon 逐字保留
  • NativePairedTooltipHost.cs:plugin-lifetime,单 active session
  • 所有 additive background 节点继续 LayoutElement.ignoreLayout = true
  • host 返回布局降级结果,不自行写 Combat Impact 日志
  • host 三个文件中无 CombatImpact / PostCombatImpact / feature namespace / feature 文案

Step 3 — 接入 Combat Impact

  • BppComposition.cs:237-245 创建共享 host 并注入(View 构造在 :242);host 不 Dispose
  • IPostCombatImpactTooltipView 13 个成员签名与返回值语义完全不变
  • View 将通用方法改为 session 委托;保留内容构造、preview、perspective、裁剪与日志代码;删除已迁出的重复实现与字段
  • PostCombatImpactController / PostCombatImpactModule / PostCombatImpactRecapPatch 无语义 diff;若必须改,先停下说明原因

Step 4 — 纸面二方适配推演(不写代码)

  • CurrentReplayRecordingButtonController 的辅助 tooltip 定位(:279-309)做纸面推演,只检查 API 形状是否够用
  • 结论写回设计文档

背景:CurrentReplayRecordingButtonController.cs:257-261 是同一 native 单例的第二个非受管驱动方(还每帧 PositionOverUI:297)。host 无法也不打算串行化它,今天靠 patch 链兜底,重构后仍靠它。

Step 5 — 测试与架构护栏

  • 新增 tests/NativePairedTooltipHost.Tests(xUnit + Microsoft.NET.Test.Sdk):右侧可容纳→选右;仅左侧可容纳→选左;两侧都不足→选空间更大一侧;空间相等→保持现有选右行为;宽度 clamp;上下边界修正;overflow diagnostics
  • 强 ratchet 架构测试(今天失败、重构后通过):CanvasGroupGate / NativeAuxiliaryHostState / PairSide 等 token 只允许出现在 GameInterop/Tooltips/ 下(今天它们在 View:2126-2355)
  • 弱护栏架构测试(今天已成立,不能单独证明重构成功):GameInterop/Tooltips/**.cs 不得出现 using BazaarPlusPlus.Game. / using BazaarPlusPlus.Patches / 字面量 PostCombatImpact
  • 测试注释中标明哪条是 ratchet、哪条是护栏
  • 更新 docs/ARCHITECTURE.md 的 Tooltip 结构说明;不改 MEMORY.md

Step 6 — 静态验证

./run.sh format-check
dotnet test tests/NativePairedTooltipHost.Tests/NativePairedTooltipHost.Tests.csproj
dotnet test tests/PostCombatImpact.Tests/PostCombatImpact.Tests.csproj
dotnet test tests/Architecture.Tests/Architecture.Tests.csproj
./run.sh test
  • Debug 与 -t:BuildAll(Debug+Release)均通过,零新增 warning

Step 7 — 针对搬迁边界的实机 smoke

不做前后 geometry JSON 对比(见 Step 1 决策变更)。改为针对每条实际搬迁的边界各验一次,见下方验收标准。

Step 8 — 收尾

  • 删除临时 Debug capture 代码,重复 Debug/Release 构建
  • 自审完整 diff,确认无业务渲染或文案变化
  • Conventional Commit:refactor(tooltips): extract native paired tooltip host
  • 走 PR 流程合并,不直推 master
  • 运行时验收失败则直接回滚该重构提交(本任务无数据或配置迁移)

验收标准

几何等价 —— 目视判定

不做 JSON 逐字段 diff(Step 1 决策变更)。以下逐项目视确认,任何一项看着"不对劲"即停下并加定向探针查那一项:

  • placement side 与重构前一致(左/右选边不变)
  • panel 宽度、与主 tooltip 的间距目视无变化
  • +N more 的出现时机与数字不变
  • 文案、图标、颜色、CJK 排版不变

必测场景(按代码分叉推导;每条对应一个实际搬迁的边界)

  • 左置 vs 右置(View:366-381 两分支)—— 卡片分别悬停屏幕左右两侧
  • 宽度收缩到 < TooltipReadableWidth(View:434)—— 窄窗口 / 高 DPI
  • 高度裁剪触发 +N more(View:756-800)—— 选 group/target 很多的卡
  • 竖直修正触发(View:410-411)—— 悬停靠近屏幕顶部/底部的 recap 项
  • Caused/Received 切换(Shift)以及切换失败回滚路径(View:317-322)
  • 中/英双语(IsChinese() View:2087-2088;CJK 排版校验 View:2090-2101)
  • 单例争用:先悬停 recap 卡出 Combat Impact,再把鼠标移到 BPP 录制按钮触发 CurrentReplayRecordingButtonController.ShowTooltip:257)—— 唯一能实机复现 B4 的操作序列
  • 空 impact、非 Bronze、skill、长名称
  • Shift 切换无一帧闪烁或跳位
  • 快速切换 hover 不出现 stale panel
  • pointer exit 恢复卡牌抬起状态并正确关闭两栏
  • Combat Impact 打开时触发其他 native auxiliary tooltip:前者正确退出、native tooltip 正常显示;隐藏后稳定 hover 能重新排队
  • 连续开关 ≥10 次,无残留背景、卡死 lock、永久透明 native tooltip

日志等价(必须 diff reason_code 序列含重复次数,不能只看有没有报错

<GameDir>/BepInEx/LogOutput.logpost_combat_impact.interaction.observed / .degradedreason_codePostCombatImpactLogEvents.cs:61-72)。前后各录同一段操作再比对。三个特别易变的点:

  • PairPlacementOverflowed / PairPlacementTooNarrow / PairTopAlignmentAdjusted —— "不再降级就复位 wasLogged"(View:1959-1978)+ SetPerspective 回滚让 Position 跑两遍(View:314/321),条数可观测
  • AuxiliaryTooltipContentUnavailable vs TooltipRenderException 的区分未被破坏
  • EntityPreviewCreateTimedOutPostCombatImpactController.cs:576)—— B2 那个 generation 拆分 bug 唯一会留下的痕迹,必须专门盯
  • 无 render exception、无 timeout、无新增 degraded outcome

已知债务(本次不修,登记备查)

  1. SetLockedFlag(false) 无条件清除(View:648)会解掉玩家自锁的主 tooltip —— 既有行为,迁移原样保留,但不得包装成"generic host restores locks"
  2. View 文件位于 Patches/PostCombatImpact/ 违反分层(它不是 Harmony patch),本次为控制 diff 规模不移动
  3. host 只有一个 consumer,架构测试证明不了 API 对第二个 consumer 可用 —— 通用性在引入第二个 consumer 前是未验证的假设,Step 4 的纸面推演是部分缓解而非证明
  4. 未验证:Tooltip_Main 后代节点名是否在所有 tier / 平台(TooltipParentComponent.cs:191-193)下都存在;FindDescendant 找不到时 _preparedPrimaryGate 静默为 null(View:117-119),主 tooltip 不被遮罩

本 issue 未覆盖

给红队 reviewer 的简报遗漏了执行清单,因此 Step 1 与 Step 7 的验证步骤未经独立评审。reviewer 仅就"三条不变量能否被测量"给出了意见(已并入上方)。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-agentFully specified, ready for an AFK agent

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions