把 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 流程;不合并 BppTooltipSections 或 NativeCardTooltipContentRefresher;不移动 NativePostCombatImpactTooltipView.cs;不新增其他 consumer;不接管主 Card Tooltip 的 native 定位权。
六条不可违反的设计约束(红队评审产出,均有 file:line 证据,详见设计文档 §2)
NativePostCombatImpactTooltipView.cs 下称 View。
- 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)。
- B2
_renderGeneration 是 host/view 共用的单一计数器,递增顺序是载荷。 ++ 必须在 DisposeNativePreviews() 之前(View:632-633)。session 暴露只读 Generation,view 的 preview 任务与之比较。清理顺序 host(bump+停fade) → view(释放业务) → host(销毁+恢复)。拆成两个 generation 会让 _pendingPreviewCount 不归零 → IsReadyToReveal 永久卡住,唯一痕迹是 EntityPreviewCreateTimedOut。
- B3
Position 必须收内容裁剪回调。 Position(anchor, IPairedContentBudget);FitActiveContentToCanvas(View:393)夹在 host 定位流程正中间且是 view→host→view 重入循环。
- B4 仲裁留在 feature。 host 只提供
OwnsPrimary / OwnsAuxiliary / ReleasePrepared;三个 OnNative* 的 bool 语义与后续动作原样保留。必须保留 View:502-506 那行"非我的 controller 也要归还快照",漏掉会永久改写 native 布局 padding/anchors。
- B5 需要
ForceSettle(reason)。 fade 协程宿主是游戏的 AuxiliaryTooltipController(View:538),宿主失活即静默终止,generation 只能防"晚到"防不了"永不到达"。
- B6 host 只给 atomic layout primitive,不编排回滚。
BeginMaskedLayout()(Dispose/异常均恢复 alpha)+ RebuildLayout();apply/revert 与二次 Position 全留 View。回滚那次 Position 的 PlacementResult 也必须过 reason-code 日志 —— LogPlacementDegradationOnce 在不降级时会复位 wasLogged(View:1959-1978),条数可观测。
另需遵守:失败一律返回 false 不抛异常(否则 AuxiliaryTooltipContentUnavailable 与 TooltipRenderException 混淆);host 不实现 IDisposable(随 _nativeCardPreviewHost 先例);NativeAuxiliaryHostState.Capture 的两个捕获点(View:142 / View:195)相对 AssignTooltipFrame 的顺序原地冻结。
执行清单
Step 1 — 基线确认(无探针)
决策变更(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
Step 3 — 接入 Combat Impact
Step 4 — 纸面二方适配推演(不写代码)
背景:CurrentReplayRecordingButtonController.cs:257-261 是同一 native 单例的第二个非受管驱动方(还每帧 PositionOverUI,:297)。host 无法也不打算串行化它,今天靠 patch 链兜底,重构后仍靠它。
Step 5 — 测试与架构护栏
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
Step 7 — 针对搬迁边界的实机 smoke
不做前后 geometry JSON 对比(见 Step 1 决策变更)。改为针对每条实际搬迁的边界各验一次,见下方验收标准。
Step 8 — 收尾
验收标准
几何等价 —— 目视判定
不做 JSON 逐字段 diff(Step 1 决策变更)。以下逐项目视确认,任何一项看着"不对劲"即停下并加定向探针查那一项:
- placement side 与重构前一致(左/右选边不变)
- panel 宽度、与主 tooltip 的间距目视无变化
+N more 的出现时机与数字不变
- 文案、图标、颜色、CJK 排版不变
必测场景(按代码分叉推导;每条对应一个实际搬迁的边界)
日志等价(必须 diff reason_code 序列含重复次数,不能只看有没有报错)
<GameDir>/BepInEx/LogOutput.log 中 post_combat_impact.interaction.observed / .degraded 带 reason_code(PostCombatImpactLogEvents.cs:61-72)。前后各录同一段操作再比对。三个特别易变的点:
已知债务(本次不修,登记备查)
SetLockedFlag(false) 无条件清除(View:648)会解掉玩家自锁的主 tooltip —— 既有行为,迁移原样保留,但不得包装成"generic host restores locks"
- View 文件位于
Patches/PostCombatImpact/ 违反分层(它不是 Harmony patch),本次为控制 diff 规模不移动
- host 只有一个 consumer,架构测试证明不了 API 对第二个 consumer 可用 —— 通用性在引入第二个 consumer 前是未验证的假设,Step 4 的纸面推演是部分缓解而非证明
- 未验证:
Tooltip_Main 后代节点名是否在所有 tier / 平台(TooltipParentComponent.cs:191-193)下都存在;FindDescendant 找不到时 _preparedPrimaryGate 静默为 null(View:117-119),主 tooltip 不被遮罩
本 issue 未覆盖
给红队 reviewer 的简报遗漏了执行清单,因此 Step 1 与 Step 7 的验证步骤未经独立评审。reviewer 仅就"三条不变量能否被测量"给出了意见(已并入上方)。
把 Combat Impact 自绘 Tooltip 中与业务无关的原生双 Tooltip 宿主基建抽到
GameInterop/Tooltips/。设计与根因证据:
docs/drafts/2026-08-04-native-paired-tooltip-host-extraction-design.md(本 issue 只承载执行清单与验收标准)。硬约束
用户可见行为、视觉、布局、日志语义保持不变。
不做:不抽
BuildCaused/BuildReceived、分组/行/图标/文案;不抽CombatImpactPerspective与 Combat Impact DTO;不改 hover/锁定/等待 native tooltip/重试/requeue 流程;不合并BppTooltipSections或NativeCardTooltipContentRefresher;不移动NativePostCombatImpactTooltipView.cs;不新增其他 consumer;不接管主 Card Tooltip 的 native 定位权。六条不可违反的设计约束(红队评审产出,均有 file:line 证据,详见设计文档 §2)
NativePostCombatImpactTooltipView.cs下称 View。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)。_renderGeneration是 host/view 共用的单一计数器,递增顺序是载荷。++必须在DisposeNativePreviews()之前(View:632-633)。session 暴露只读Generation,view 的 preview 任务与之比较。清理顺序 host(bump+停fade) → view(释放业务) → host(销毁+恢复)。拆成两个 generation 会让_pendingPreviewCount不归零 →IsReadyToReveal永久卡住,唯一痕迹是EntityPreviewCreateTimedOut。Position必须收内容裁剪回调。Position(anchor, IPairedContentBudget);FitActiveContentToCanvas(View:393)夹在 host 定位流程正中间且是 view→host→view 重入循环。OwnsPrimary/OwnsAuxiliary/ReleasePrepared;三个OnNative*的 bool 语义与后续动作原样保留。必须保留 View:502-506 那行"非我的 controller 也要归还快照",漏掉会永久改写 native 布局 padding/anchors。ForceSettle(reason)。 fade 协程宿主是游戏的AuxiliaryTooltipController(View:538),宿主失活即静默终止,generation 只能防"晚到"防不了"永不到达"。BeginMaskedLayout()(Dispose/异常均恢复 alpha)+RebuildLayout();apply/revert 与二次Position全留 View。回滚那次Position的PlacementResult也必须过 reason-code 日志 ——LogPlacementDegradationOnce在不降级时会复位wasLogged(View:1959-1978),条数可观测。另需遵守:失败一律返回
false不抛异常(否则AuxiliaryTooltipContentUnavailable与TooltipRenderException混淆);host 不实现IDisposable(随_nativeCardPreviewHost先例);NativeAuxiliaryHostState.Capture的两个捕获点(View:142 / View:195)相对AssignTooltipFrame的顺序原地冻结。执行清单
Step 1 — 基线确认(无探针)
0f1c076c…./run.sh format-check通过Step 2 — 新增通用 Host
NativePairedTooltipContracts.cs:options、PlacementResult、PairSide、IPairedContentBudget、单一const float Epsilon = 0.5f(同时承担几何判定与 CanvasGroup alpha 阈值,历史事实非设计)NativePairedTooltipPlacementMath.cs:保留UnityEngine.CoreModule引用,公式与 0.5 epsilon 逐字保留NativePairedTooltipHost.cs:plugin-lifetime,单 active sessionLayoutElement.ignoreLayout = trueCombatImpact/PostCombatImpact/ feature namespace / feature 文案Step 3 — 接入 Combat Impact
BppComposition.cs:237-245创建共享 host 并注入(View 构造在:242);host 不 DisposeIPostCombatImpactTooltipView13 个成员签名与返回值语义完全不变PostCombatImpactController/PostCombatImpactModule/PostCombatImpactRecapPatch无语义 diff;若必须改,先停下说明原因Step 4 — 纸面二方适配推演(不写代码)
CurrentReplayRecordingButtonController的辅助 tooltip 定位(:279-309)做纸面推演,只检查 API 形状是否够用Step 5 — 测试与架构护栏
tests/NativePairedTooltipHost.Tests(xUnit +Microsoft.NET.Test.Sdk):右侧可容纳→选右;仅左侧可容纳→选左;两侧都不足→选空间更大一侧;空间相等→保持现有选右行为;宽度 clamp;上下边界修正;overflow diagnosticsCanvasGroupGate/NativeAuxiliaryHostState/PairSide等 token 只允许出现在GameInterop/Tooltips/下(今天它们在 View:2126-2355)GameInterop/Tooltips/**.cs不得出现using BazaarPlusPlus.Game./using BazaarPlusPlus.Patches/ 字面量PostCombatImpactdocs/ARCHITECTURE.md的 Tooltip 结构说明;不改MEMORY.mdStep 6 — 静态验证
-t:BuildAll(Debug+Release)均通过,零新增 warningStep 7 — 针对搬迁边界的实机 smoke
不做前后 geometry JSON 对比(见 Step 1 决策变更)。改为针对每条实际搬迁的边界各验一次,见下方验收标准。
Step 8 — 收尾
refactor(tooltips): extract native paired tooltip host验收标准
几何等价 —— 目视判定
不做 JSON 逐字段 diff(Step 1 决策变更)。以下逐项目视确认,任何一项看着"不对劲"即停下并加定向探针查那一项:
+N more的出现时机与数字不变必测场景(按代码分叉推导;每条对应一个实际搬迁的边界)
< TooltipReadableWidth(View:434)—— 窄窗口 / 高 DPI+N more(View:756-800)—— 选 group/target 很多的卡IsChinese()View:2087-2088;CJK 排版校验 View:2090-2101)CurrentReplayRecordingButtonController.ShowTooltip(:257)—— 唯一能实机复现 B4 的操作序列日志等价(必须 diff
reason_code序列含重复次数,不能只看有没有报错)<GameDir>/BepInEx/LogOutput.log中post_combat_impact.interaction.observed/.degraded带reason_code(PostCombatImpactLogEvents.cs:61-72)。前后各录同一段操作再比对。三个特别易变的点:PairPlacementOverflowed/PairPlacementTooNarrow/PairTopAlignmentAdjusted—— "不再降级就复位wasLogged"(View:1959-1978)+SetPerspective回滚让Position跑两遍(View:314/321),条数可观测AuxiliaryTooltipContentUnavailablevsTooltipRenderException的区分未被破坏EntityPreviewCreateTimedOut(PostCombatImpactController.cs:576)—— B2 那个 generation 拆分 bug 唯一会留下的痕迹,必须专门盯已知债务(本次不修,登记备查)
SetLockedFlag(false)无条件清除(View:648)会解掉玩家自锁的主 tooltip —— 既有行为,迁移原样保留,但不得包装成"generic host restores locks"Patches/PostCombatImpact/违反分层(它不是 Harmony patch),本次为控制 diff 规模不移动Tooltip_Main后代节点名是否在所有 tier / 平台(TooltipParentComponent.cs:191-193)下都存在;FindDescendant找不到时_preparedPrimaryGate静默为 null(View:117-119),主 tooltip 不被遮罩本 issue 未覆盖
给红队 reviewer 的简报遗漏了执行清单,因此 Step 1 与 Step 7 的验证步骤未经独立评审。reviewer 仅就"三条不变量能否被测量"给出了意见(已并入上方)。