You are viewing a potentially older version of this package. View all versions.
HawChow-CombatPanel-1.19.19 icon

CombatPanel

GTFO 战斗统计面板:局内 HUD 与结算板显示队伍伤害占比、命中率、武器与工具明细、睡眠击杀、个人补给使用与失血。JSON 可配置称号、评分权重与字号。仅需 BepInEx,可选 EWC 集成。

Date uploaded 5 days ago
Version 1.19.19
Download link HawChow-CombatPanel-1.19.19.zip
Downloads 116
Dependency string HawChow-CombatPanel-1.19.19

This mod requires the following mods to function

BepInEx-BepInExPack_GTFO-3.2.4 icon
BepInEx-BepInExPack_GTFO

BepInEx pack for GTFO. Preconfigured and includes Unity Base DLLs.

Preferred version: 3.2.4

README

CombatPanel

独立 GTFO 战斗统计面板:局内查看战斗数据,成功或失败结算查看队伍贡献。仅依赖 BepInEx-BepInExPack_GTFO-3.2.4;EWC(ExtraWeaponCustomization)是可选集成,不是依赖。

源码 / Source:GitHub — hawchou1995/GTFOCombatPanel

反馈:qingju.me | 小黑盒语音

安装

  • r2modman / Thunderstore Mod Manager:搜索 CombatPanel 安装到 GTFO profile,BepInEx 依赖会自动带上。需要共享统计的每台机器装同一版本。
  • 手动安装:先把 BepInEx 装进 GTFO 目录,再把包内 BepInEx/plugins/GTFOCombatPanel/ 整个目录原样放进同一路径。
  • CombatPanel-config/scoring.json 只在缺失时写入一次;你手改过的配置不会被覆盖,写错的内容整份判为无效并保留上次有效值。
  • F8 在关卡内显示 / 隐藏 HUD 表。在结算页上它是「强制出板」:按一次立即用兜底条带把评分板画出来(不等标题、完成区、页脚或交互证明就绪,也不受三个可见性开关限制),再按一次交回自动判定。
  • 多人:快照协议为 v8,与旧协议明确不兼容(旧协议包会被拒收,不是"勉强可用")。

局内 HUD

F8 显示 / 隐藏。默认列:

玩家 | 伤害 占比 | 友伤 | 命中率 | 弱点率 | 总弹丸 命中 弱点

伤害只含敌伤;占比按已观测队伍敌伤计算。~ 表示估算或分母只覆盖已获得的数据;无观测显示 —。不按伤害反推命中或弱点。武器行占比使用该武器敌伤;玩家总伤可包含工具,因此两把枪占比之和不一定等于玩家总占比。

局内战斗面板

局外结算

成功 / 失败均显示固定布局的影响力矩阵,按综合评分降序排列。保留原表格行与布局,工具动作和工具统计只在结算显示。

十三项指标:总伤害、硬直、击杀、救援、弱点率、命中率、补给效率、工具效率、独存、倒地、友伤、近战击杀、触手击杀。击杀可分小怪 / 精英 / BOSS,基础价值分别 0.5 / 1 / 1.5。分档称号只分解同一击杀评分,不重复加分。友伤伤害不超过容差(scoring.json 的 FriendlyFireGrace,默认 10,范围 0–1000)时按 0 计入评分;友伤致倒次数不受容差影响,照常计入,仍可能因此被评「敌我不分」;超过容差的伤害照旧按全队最大友伤归一惩罚。

  • 炮台:发射、有效命中和工具敌伤。
  • 绊雷:工具部署、回收、触发及工具敌伤。消耗品野雷只归部署者总伤,不加入工具部署 / 触发或当前工具行。
  • 胶枪:真实弹丸发射、命中和胶住敌人等已有统计。
  • 扫描:使用与确认标记;候选可标记列表不是成功扫描。机器人窗口计次仍保留估算标记。
  • 近战只认近战伤害,Push 不算;触手击杀按游戏原生的 IsScout 身份判定。
  • 沉睡是致死前明确观察的状态;无法判定保留未知,未知无额外加成,不冒充清醒或沉睡。
  • 本人补给次数归实际受益者,不是拿包操作的人;全满且没有实际收益不计。给别人补给仍按原效率语义统计。
  • 失血只累计伤害回调中的实际生命损失,治疗不抵扣;上限变化本身不算失血。

结算看板(历史版本截图,展示布局)

结算板的落位与字号

两个结算页的板子都优先从页面自己量得到的上下边界落位,量不到时改用兜底条带,不会空屏:

  • 严格条带的前提是页面自己的动画已经结束:失败页要求原生 任务失败 标题文字已经完整显示(它的透明度乘以上层所有 CanvasGroup,达到满不透明)并且实测底边连续两次读数一致;成功页要求「已清理分区」图标行与完成区块的实测边稳定。文字还在淡入、只有矩形不动,不算就绪——那正是「标题字样还没渲染完就出评分面板」这一现象的来源。
  • 上下边界是写死的一条带:上边界取「已清理分区」图标行(含该行标签)的实测底边加净空(1080p 16 px,按窗口高度等比缩放);下边界取本机真正能看到的页脚——主机是「返回大厅 / 送所有囚犯回大厅」按钮上沿,客机是「正在等待…」文字块上沿——再减同一净空。板子自上边界向下顶对齐铺开,不是居中,所以带越高表越大、字号越大。
  • 真实锚点拿不到时,用「屏幕安全区」兜底出板,绝不空屏:兜底同样只用上面那两条边;两边都量不到时才退到窗口安全区,并且仍然避开扫描到的任何原生控件。兜底板逐帧重测,遇到遮挡是位移或降低不透明度,不是消失。一旦页面的真实锚点稳定可测,板子自动回到严格条带,不需要 F8 关开。
  • 带太高或太矮都不会压到上方:带高决定行高与字号(带不够时缩字号、收行距);板子比带还高时保持上边界不动、越过下沿时按 0.55 变淡绘制,绝不压上方图标行。
  • 字号由这段条带按固定行数搜索得到(到条带 92%–96% 或页面可用宽度即停,最高为局内字号的 3.5 倍);带太矮时按带高给出更小的字号,可读下限只是偏好——带不够时宁可缩小字号,也不把板子画到边界之外。板子落位后不会因为一两帧的抖动移动;页面本身在落位后又明显移动或改变尺寸时(别的模组重排页脚、分辨率变化等),板子会自动重新测量并按新条带重新拟合。
  • 结算页仍在持续收快照:数据让表格变大时,会先按落位时真正用到的条带与该帧按钮净空校验,装不下就保留已上板的网格,绝不压按钮或标题。

鼠标滚轮在板子上可缩放字号(F8 强制出板时同样有效),两个结算页各存一份([Results] SuccessFontScale / FailureFontScale,见下)。缩放只乘在页面自己量出的字号上,往上最多夹回该字号,因此不会盖住按钮。

JSON 配置

评分配置位于插件旁的 CombatPanel-config/scoring.json。修改已有文件对应字段;插件不覆盖已有手改配置,无效内容保留上次有效值。缺失字段使用默认值。

{
  "SleepingKillMultiplier": 2.0,
  "Titles": {
    "Damage": { "Title": "火力核心", "Description": "总伤害突出领先" },
    "MinorKills": { "Title": "杂兵清道夫", "Description": "小怪击杀贡献领先" },
    "EliteKills": { "Title": "精英猎手", "Description": "精英击杀贡献领先" },
    "BossKills": { "Title": "首领终结者", "Description": "BOSS 击杀贡献领先" },
    "TeamPillar": { "Title": "平平无奇", "Description": "没有突出领先的有效项目" }
  }
}

称号最多 16 字符,描述最多 96 字符;保留默认文字时随语言设置切换。ZeroScore 为旧配置保留项,本版不会绕过 top 规则强制授予零分称号。滚轮字号偏好写在 BepInEx 自己的 .cfg([Results] SuccessFontScale / FailureFontScale,默认 1.0,范围 0.5–2.0)。

分配顺序:总评分降序;同分按稳定玩家身份顺序。只取启用、权重非零、有有效数据且独占 top 的项目。并列 / 全零不产生领先;倒地和友伤要求真实负面最严重。按正向 / 负向实际贡献绝对值选最大候选;重复称号由排序靠前者先得,其余尝试下一候选。无候选时按名次收尾:#1(含并列第一)拿「全能中坚」,其余拿中性称号「平平无奇」;这两句都不占位、可以重复。有单项独占的第一名仍拿那一项(例如「清道夫」),不会被改判「全能中坚」;全队显示分都是 0.0 时没有「第一」可站,一律中性。

默认权重:TotalDamage .95, Stagger 1.40, Kills .70, Revives .28, WeakspotRate .44, Accuracy .28, SupplyEfficiency .78, ToolEfficiency .80, LoneSurvivor 1.60, Downs .55, FriendlyFire .75, MeleeKills .55, ScoutKills .60。评分限制在 0–100;无数据、失败独存、禁用项目不制造有效贡献。工具效率的评分来源仍为扫描首次标记,其他工具统计只是显示,不暗中增加权重。

多人和兼容

每台机器独立评分,不将本地权重强加给队友。快照传输观测数据,主机负责其可测的伤害,玩家本人提供其本机命中、补给和扫描。没有数据来源的字段仍可能为空;主机没装模组时,装模组客机之间只共享可观测子集,~ 不表示完整全队实测。称号与综合评分同样由本机判定:两端 scoring.json 不同时,同一局里各端可能看到不同的称号。

EWC 是可选集成,不是强依赖。公共 DLL 始终带无程序集强引用的运行时弹丸桥接;无 EWC 也可加载。接口契约按已检查的 EWC 版本验证,不保证任意第三方改版行为。普通射线与延迟弹丸分别去重,真实命中部位用于弱点判断。

结算可写 JSON 存档,Rundown 和关卡分开记录。更多实现和构建说明见 源码仓库。

验证边界

本模组的结论分两类,不混用:编译与托管重放断言(scripts/Verify.ps1 对真实 BepInEx / Interop 程序集构建并跑无 Unity 的策略与场景重放,可复现);实机观察(截图、日志、手感,只在明确记录时才算证据)。断言覆盖不到 Unity 原生回调时序、页面动画的实际轨迹和玩家屏幕前的观感,这类结论一律需要实机复核才能成立。逐版的验证记录与未验证项写在包内 CHANGELOG.md。

CHANGELOG

Changelog

1.19.20

  • 根因(结算板被判「太小」实际上是被判「不许画」):_boardFloor 过去是常量 270px——30 行 × 6px 最小可读字形 × 1.5em 行高——并被当成「这页能不能放板」的判据。实机日志(1920×1440,主机成功页)里同一条带子在两帧之间测得 269px 与 286px,得到的却是相反答案:success board deferred: need=270px ... have=240px(整块不画)对 glyph 6px filling 93%(画了)。269 与 286 只差 17px,跨过的却是一个可读性偏好,不是房间的真实容量——这张表在 HudTable.BuildGrid 的下限处只需 30 × 3px × 1.5 = 135px 就能画出来。
  • A:判定用的下限改成「这间房里最小能画多高」(新增纯托管 ResultsFontGrowth.DrawableBoardHeight)。MinimumBoardHeight(可读下限 270px)原样保留,它回答的是「表想要多高」,仍是拟合目标;页面的判定改用 DrawableBoardHeight(rows, rowEm, density, room):带子容得下可读下限就返回 270px(行为不变),容不下则返回最短可画网格,带子未测量(NaN/0/负)时仍返回可读下限。于是 269px 的带子得到 135px 的门槛并可画,而不是被判死。
  • D:消除悬崖,并让 need= 这个名字不再说谎。SuccessRegion 现在先用本帧实测带子(及其本帧行数)算出 stripFloor 再问角色策略,并把结果回写 _boardFloor,使拟合、fail-open 阶梯与所有日志行引用同一个数字。success board deferred 行的语义从「需要 270px(可读)」改为「按最短可画网格需要 N px(可读下限 M px)」,一页一行的拒绝理由从此是关于房间的,而不是关于偏好的。
  • B:击杀分级行数与「买到的字号」挂钩(ImpactBoard.SplitKillTiersFor)。ImpactBoard.Build(report, true, ...) 的常量 true 改为按本帧带子决定:glyph = floor(room / rows / emPerGlyph),只有当折叠两行(30→28 行)真的能把字形撑大一整像素时才折叠,把三档击杀合成面板一直就支持的单一 Kills 行。带子 ≥270px(含 286/400/588/800/1080)一律保持 30 行,与旧行为完全一致;过短的带子(≤200px,两行买不到任何字号)同样保持 30 行。实测只有 220px 与 252–269px 这类「刚好差一像素」的区间才折叠。
  • C:UsesBandBelowActions 的比较口径。该策略问的是「这条带子装不下板、而页面动作下方那条带装得下」,过去传入的是可读下限 270px,于是下方那条 85–88px 的自由带永远不可能通过——这条逃生路径在 1440 高的页面上一次都没进过。现在传入房间解算出的可画高度。如实说明:本次日志那页的下方带只有 85–88px,对 135px 的可画网格仍然不够,所以该页依然不会走这条分支;修的是「问题能不能被回答」,不是「这条分支现在会走」。
  • 验证:PASS: 2705 combat-panel assertions(1.19.19 为 2669,+36:A/D 的 269↔286 悬崖、无测量回退、100px 仍被拒;B 的 ≥270px 不裁行 / ≤200px 不裁行 / 269px 折叠 / NaN 保护;C 的四种 UsesBandBelowActions 情形)。Scan 38、Supply 50 均 PASS。ConfigWatcher 在本机仍为 6 个缺少 BepInEx 引用的编译错误,与本次改动无关(git stash 前后同为 6 个)。
  • 未验证 / 未改动:ResultsDisplay.cs 不在 smoke 套件的编译范围内(csproj 只链接 LayoutPolicy.cs、ImpactBoard.cs),所以 Prepare / SuccessRegion 的接线只经语法解析、没有运行时测试覆盖,需要在有游戏环境的机器上编一次并进 1920×1080 的结算页看日志是否出现 glyph 6px 而不再是 need=270px ... deferred。评分权重与公式、快照协议、网络同步、HUD、失败页的标题锚点与客机固定几何、F8 行为均未改动。

1.19.19

  • 新增结算称号「全能中坚」(称号契约的一处变更):旧规则下,四平八稳、每一项都是队内第二的玩家会拿到综合评分 #1 却配一句「平平无奇」,观感自相矛盾。现在落空的人按名次收尾:名次为 #1 且没有任何单项独占时授予「全能中坚」,#2 及以后仍是「平平无奇」。判定只看两件事——AssignRatings 走完「自己独占了哪一项」之后仍无所得,以及界面显示用的同一个 ImpactEntry.Rank 是否为 1,所以并列第一名(两人同为 #1 且都没独占)两人都拿这一句;第一名本身若独占某项(例如「清道夫」),仍保留那一项的称号,不会被改判。新称号和「平平无奇」同属兜底语义,不写入 used,可以被重复授予。授予判据读的是结算看板打印的那个一位小数分(IsDisplayedZero,与 #N 同一行看到的数字),所以「显示 0.0」与「能拿第一名称号」在代码与文档里是同一件事:全队显示分都是 0.0 时没有「第一」可站,一律保持「平平无奇」。CombatPanel-config/scoring.json 的 Titles / Reasons 各多一个 AllRound 条目(Reasons 不带占位符),改文案与其它称号一样遵循「留出厂文本即跟随语言设置」。
  • 结算看板「说明」行:新称号下输出「综合评分全队第一,无单项领先」,不再借用「平平无奇」那条按伤害与击杀拼出来的句子;该句没有占位符,因此没有新增任何占位符机制。
  • README 与代码不符的表述改准:①「四名玩家评价一定各不相同 / 什么都没领先的玩家也能拿到一句属于自己的」改为「有单项独占的人各拿一句,落空的人退回兜底称号,兜底不占位、可以重复」;②称号列表里的「阵亡 = 铁壁生存」改为代码实际的「仰卧起坐」;③「十三项指标按权重降序排列」改为「按指标枚举顺序输出,最重的单人存活 (1.60) 排第 9 行」,同一张表里的分隔行标签也从「—— 以下按权重降序 ——」改成实际的「指标」;④「显示分 0.0 固定拿战地游客」改为代码实际的「平平无奇」(AssignRatings 从不授予「战地游客」)。Thunderstore/README.md 里同一套表述同步改准,并补上「全能中坚」这一档;⑤CONTEXT.md 与 COMPATIBILITY-REPORT.md 里同一条「显示 0.0 → 战地游客」的陈旧口径也改成「显示 0.0 → 中性称号「平平无奇」;ZeroScore 只作为兼容键保留、不参与分配」。
  • 中英混排修正:「Scout 击杀 N 只」改为本模组自己的中文叫法「触手击杀 N 只」,与行标签「触手击杀 / 沉睡 / 伤害」、称号「触手猎手」一致;出厂 Reasons.ScoutKills、Titles.ScoutKills.Description 与 scoring.json 的 Guidance 同步改口。英文文案(N Scouts killed)保持不变,英文枚举与 JSON key 也不变。
  • 友伤容差阈值 FriendlyFireGrace(一处评分输入处理,权重与公式未动):友伤是反向指标,归一值是 1 − 值 / 全队最大,于是「别人都是 0、自己有一点」就等于「全场唯一最差」——过去 10 点友伤和 3000 点友伤后果完全一样:必得「敌我不分」,同时丢掉该项权重 0.75 / 9.68 ≈ 7.7% 的分。现在 scoring.json 新增绝对阈值 FriendlyFireGrace(默认 10,范围 0–1000,单位与面板显示的敌伤相同):不超过它的友伤「伤害」在进入评分前按 0 计,再走原有归一流程,所以「全队有效友伤伤害都是 0」时该项自然落到既有的「该项无人得分」分支,没有新增任何特判或魔法值;超过阈值的伤害与过去完全一致(仍按全队最大友伤归一、仍是「敌我不分」的候选)。权重 0.75、权重合计 9.68、评分公式、Normalize、Clamp、角色加成与分母累加方式均未改动。
  • 容差只作用于「友伤伤害」,友伤致倒次数不受容差影响(有意为之的口径,不是遗漏):FriendlyFireDeaths(友伤致倒)照旧参与该项归一,仍可能让玩家拿到「敌我不分」,所以「不超过 10 点就整项豁免」是错误读法。由此有一个既定结果:A 友伤 40 点、0 次友伤致倒;B 友伤 8 点(在容差内)、1 次友伤致倒 → 两人严重度都是 0.5(A = 伤害满额 + 致倒 0,B = 伤害按 0 计 + 致倒满额,两个分量等权平均),严重度并列 → 该项没有唯一最差者 → 谁都不获「敌我不分」。这是「只有唯一最差才授称号」的既有并列语义导致的既定结果,不是漏判;若要让致倒也吃容差,那是一次新的契约变更,本轮不做。
  • 称号显示名调整:「反复倒地」改为「仰卧起坐」:只改中文显示名(结算板的评价列、scoring.json 的 Titles.Downs 出厂标题、README 的称号列表),判定逻辑与授予条件未变;英文文案(Frequent faller)、枚举名与 JSON key(Downs)都没有动,既有用户配置继续生效。
  • 验证(2026-11-05,pwsh 7):scripts/Verify.ps1 对本机 r2modman profile 的真实 BepInEx / Interop 程序集运行 → VERIFY: PASS,插件 Release 构建 0 警告 / 0 错误,PASS: 2666 combat-panel assertions(1.19.18 为 2638;同一版本内三批共 +28:新称号 7 条 + 友伤容差 19 条 + 「仰卧起坐」显示名 2 条,其中友伤容差覆盖「容差内唯一友伤不评奖且不亏分」「超过容差照旧评奖并保持满额惩罚」「全队在容差内仍有有限分」「容差内 + 友伤致倒仍评奖」「40 点 0 致倒与 8 点 1 致倒严重度并列 → 无人获称号」「grace=0 严格模式」「夹取 1000 与 NaN/负值回退」「抬高容差后同一笔友伤不再评奖」「配置解析、非法值拒绝与磁盘往返」「第一名判据取显示分」),配置监听 9 / 补给归属 50 / 扫描重放 38;SCOUT_HOOK_SAFETY / OPTIONAL_EWC_BRIDGE / EWC_API_CONTRACT 与全部 wiring 检查 PASS。
  • 未改:评分权重(含友伤 0.75)与合计 9.68、评分公式、Normalize / Clamp / 角色加成、名次与并列规则、CountAtMax 与金牌逻辑、快照协议 v8(字段/版本/语义)、SyncProtocol / CombatMerge 与网络同步路径、布局/列宽/行序/锚点/时序/可见性门控、F8 行为、其余配置默认值。本轮唯一被批准的评分输入改动就是「友伤伤害进入归一前按容差归零」这一处;新称号的授予判据从「原始分 > 0」收紧为「显示分不是 0.0」,评分、排名与并列规则本身未动。
  • 仍未实机验证:新称号在真实四人局里的观感(尤其是同分并列、或第一名靠「每项都第二」取胜那一局)、「全能中坚」在这列宽度里的折行表现,以及容差 10 点在真实枪火下的手感(单发霰弹贴脸、穿透射击的个位数友伤应当不罚),都需要进游戏看一次。

1.19.18

  • 根因(结算板上界太松、下界失效):客机成功页把「整块完成区最深可见处」当成上界、把「屏幕底 − 8」当成下界,而 1.19.17 的兜底档位 ① 又是「按钮下方自由带/屏幕安全区」。成功页的主机/客机两条分支里,上界都来自 CompletionBounds.Deepest 对「白板 m_sectorsWhiteBox + 分区标签 m_sectorHeader + 四个分区图标」取最深处——白板是一块画在图标下方的底板,于是上界被它拉到远低于图标行的位置;客机分支的下界则由 ResultsGuestPageGeometry 取 screenHeight − 8·scale,完全不看原生那两行字。实机截图(2560×1440 的客机成功页):分区图标行底边约在 250/1080 处,我们的板顶却在 575/1080 处(上方空掉一整片),板底到 1030/1080,直接从原生「正在等待……」与「送回大厅」两行字上压过去。1.19.17 的兜底路径在客机上同样错:它的档位 ① 是「按钮下方自由带 / 屏幕安全区」,把等待文字吃进条带里正是它。
  • 新规则(新增纯托管 ResultsBounds.cs):一条带,两个边,两个角色共用,永不放宽。上界 = 「已清理分区」图标行(m_sectorHeader 标签 + m_sectorIconMain/Second/Third/AllCompleted 四个图标,不含白板)的实测底边 + 净空;本帧测不到用上一帧可信值(_iconRowBottom,随页面更换清空),两者都没有才退到 0.10·屏高。下界 = 页面上真实可测的三个下墙取最高者再减同一净空:客机的等待状态文字块(FooterTextTop:在 m_btnLeaveExpedition 子树里用 GetComponentsInChildren<TextMeshPro>(true) 取最高顶边,并按世界空间文字用菜单相机投影,即 TryScreenRect 的那条规则;测不到则退回该控件自身的布局矩形)、主机的「送回大厅」按钮(pressable 或 laid-out 都算)、以及交互扫描量到的任何控件。净空是设计像素:1080p 16px,按 screenHeight/1080 等比缩放(1440→21.3px、2160→32px)。带高不足 MinimumBand(48px) 时上界上移以保住下界并标记 clamped=true——下界是承诺,宁可变小也不越界。
  • 板自上界向下顶对齐,带高决定行高与字号(映射写进注释与断言):ResultsBandFit 把带高花在固定行数上——rowBudget = bandHeight / rows;rowHeight = max(font × 1.45 × density.Row, font × 1.5em)(这正是 HudTable.BuildGrid 实际建行用的式子);font = floor(rowBudget / max(1.45×density.Row, 1.5))。compact 密度下 1.45×0.80 = 1.16,但行高由 1.5em 的行距兜住,所以:588px 带 / 30 行 → 13px 字号(与实机日志里 glyph 12px/13px 逐像素吻合)、997px 带(本次客机)/ 30 行 → 22px(再受宽度与可读性上限约束)、200px 带 / 30 行 → 4px 字号、行高 6px,30×6=180 ≤ 200(低于 6px 时记为 squeezed,行高与字形一起缩,条带不被越过)。HudTable.BuildGrid 的 minimumPixels 不再被强制抬到 6(默认值仍是 6,关卡内 HUD 行为不变),调用方给的下限被如实执行,带太矮时才有真正可用的退化。
  • 严格路径与兜底路径接到同一条带上:成功页 SuccessRegion 在两条角色分支之后统一用 ResultsBounds.TryResolve 覆盖/收紧条带(top = 图标行底 + 净空,bottom = min(带下沿, 原条带下沿);1.19.16 的「按钮下方自由带」BelowActions 原样保留,因为那条带的两个边本身就是实测契约);失败页 FailureStrip 用标题底边作上界、等待文字/按钮作下界收紧(只收紧、不放宽,玩家配置的纵向偏移与 1.19.13 的客机几何都保留)。兜底路径先取这条带,只有在两个边都完全测不到时才退到屏幕安全区,并且那时仍避开扫描量到的任何控件;顺序在断言里钉死(FallbackPlacement → ResultsBounds.TryResolve → 之后才是 ResultFallbackLayout.TryResolve)。
  • 诊断补齐(本轮必须交付的一部分):新增 ResultsDisplay.ReportBand,每页一条 [DIAG] success|failure|fail-open band: top=… (source=icon-row|icon-row-last-trusted|title-anchor|screen-safe-area), bottom=… (source=footer-text|footer-control|measured-control|screen-foot), room=…px, clearance=…px, clamped=…, boardFloor=…px, screen=…, role=…,其中 iconRow= 与 waitingText= 是上一版日志里根本没有的两个读数——正是它们缺失才让「板顶太低」和「板底压字」在日志里看不出来。兜底落板那一行(… board placed from the fail-open safe area)的 reason= 现在带 (icon-row)/(footer-text) 这类来源词;InfluenceBoard 的绘制行不变。
  • 回归断言(tests/GTFOCombatPanel.Smoke/Release11918Scenarios.cs,78 条,已注册进 Program.cs;其中 29 条是评审收口补的:ResultPlacementPolicy/ResultFallbackTier/ResultFooterTextPolicy 的运行时断言,加上「只有 Fit 落板才清零」「ZoomAt 认 Forced」「兜底带按地板收口」「板高越带不得越上界」的静态接线断言):① 客机成功页(图标行 333、等待文字 1373、1440 高)→ top=354.3(=333+21.3,容差 2px)、bottom=1351.7(=1373−21.3)、room≈997,板顶贴带顶、板底 < 1373 即不压字;② 主机成功页(按钮 683)→ bottom=661.7(=683−21.3)、top 仍为图标行;额外被测控件(900)只能收紧成 MeasuredControl;③ 只有图标行、下界全未知 → 仍以图标行为顶、下底 1432,顶对齐铺开;④ 图标行不可测 → 用上次可信值(source=icon-row-last-trusted)而不是屏高比例;完全测不到才 0.10·屏高;⑤ 200px 带 → squeezed、floor 4px、30 行 × 6px = 180 ≤ 200;588px 带 → 12–15px(实机值);带被压到 48px 以下 → clamped 且下界不动;⑥ 兜底:ShouldFallback(0,true) 为真(F8 无条件),同一页的带与严格路径同源,并用旧档位在同一页算出 1432 > 1373 证明「上一版客机兜底真的会压字」,新带 1351.7 < 1373;静态断言钉死「先带后安全区」「top = band.Top; room = band.Height;」「IconRowBottom 不读 m_sectorsWhiteBox」;⑦ 净空 1080p=16、1440=21.33、4K=32。另有 4 条静态接线断言(ReportBand 存在、两个 helper 都被兜底调用、等待文字按 TMP 子树测量)。
  • 兜底档位 1 的边界约束(评审收口):兜底路径原来只要 band.Fits(带高 > 0)就采用页面自己的带,并且无条件 opacity=1;而板子高于带时 ResultFallbackLayout.TryPlaceInBand 是「贴下沿」,于是带高 48..270px 时板顶会跑到带顶上方约 87px,压在「已清理分区」图标行上、不透明度还是 1.0。现在:① ResultFallbackTier.AcceptsPageBand(band.Height, _boardFloor) 要求带高至少装得下板子自己的地板,否则退到 ResultFallbackLayout 的 OverlapsControls 档(opacity=0.55);② TryPlaceInBand 不再把板子往上滑——板子永远保持带的上沿,超出部分只会向下越过带下沿;③ Fit 在兜底态发现板子高于带时把不透明度降到 OverlapOpacity 并打一行 [DIAG] fail-open board taller than its band: overBand=(…), …。任何情况下不得越出上界去压图标行。
  • 落板成败由 Fit 决定,而不是 Placement(评审收口):Placement 只回答「页面给了一条带」,而带高 100px 也是非空、30 行板子在最小 3px 字号下也不可能低于约 135px;旧写法在 room ∈ (0,135) 时先把 _strictMisses 清零,再由 Fit 在同一帧拒绝,于是永远 no-placement,且 F8 也无法生效(ShouldFallback 根本不会被求值)。现在抽出 TryFit,只有它真正落板才清零计数;Fit 拒绝的同一帧计入 miss,并由新增的 ResultPlacementPolicy.Decide(placed, misses, forced) 决定该帧是否立刻走兜底——F8 因此在「Placement 成功但 Fit 拒绝」这条路径上也能立即出板。同时补上 [DIAG] prepare board not drawn: reason=fit-refused|strip-refused|fallback-fit-refused。
  • 等待文字块的读法与来源词(评审收口):FooterTextTop 现在与失败页口径一致——只有 activeInHierarchy 且 alpha > 0.02(ResultFooterTextPolicy.Counts)的 TMP 才算墙,隐藏/零 alpha 的残留不再抬高下界;来源区分 footer-text(页面真的画了文字)与 footer-text-control(只能读到控件自身的布局矩形),ResultBoundSource 新增 FooterTextControl,[DIAG] … band 与兜底那一行都会打印出来。
  • F8 强制态也认滚轮(评审收口):ZoomAt 现在先看 Forced——强制出板时三个可见性开关即使都为假,滚轮仍然作用在这块板上;开关判断原样保留在那个 if 里面。
  • Refresh 离开结算页的分支被恢复:上一轮改可见性门时误删了 if (!results) { RestoreHidden(); ResetPlacement(); ForgetPage(); return; }(results 因此成为未使用局部)。该分支现在写在可见性门之前:离开结算页必然还原被隐藏的原版块并清空本页状态,Forced 在非结算页不被读取。
  • 终审收口(仍 1.19.18,未发布过、不再跳号):①兜底档位 2 补上等待文字这堵墙——它原来只把按钮/控件当墙,客机页按钮量不到时下界退成「屏底 − 8」,带子巨大、opacity=1,板子按带拟合后压在「正在等待…」上,且因为板短于带,overBand 降透明度也不会触发;现在 ResultFallbackLayout.TryResolve 增加 footerTextTop 重载(旧的 6 参重载保留并委托),档位 2 与档位 3 都取 min(按钮/控件墙, 等待文字) − 净空,只有两堵墙都量不到才到窗口底。②逐帧重算位置时一并重算不透明度——RefreshFallbackSafety 现在每帧用同一次测量重算 _fallbackOpacity(越带 min(现值, 0.55)、回到带内恢复计划值),并同时写 _holdOpacity,日志打印当前 opacity;新增纯函数 ResultFallbackOpacity.Resolve。③逐帧抬板不得越过带顶——ResultBoardHold.TryHold 增加可选 topBound,兜底态传 _holdStrip.Top 并夹住 y >= topBound;抬完之后若仍够到按钮,则把 _holdOpacity 降到 0.55(宁可向下溢出并变淡,不许向上越)。④alpha 读不到时下墙不消失——ResultFooterTextPolicy.Counts 改为「只有明确 !activeInHierarchy 或可读 alpha ≤ 0.02 才排除」,NaN 按可见计入墙,与本文件 ControlAlpha 的既有约定一致。⑤兜底 fit 失败不再永久空屏——新增 _fallbackMisses 与 _fallbackFloorScale:兜底路径取带失败或落板失败都记一次 miss 并按 ResultFallbackLadder(×0.7,下限 0.05)逐级下调兜底自己的地板,直到 ResultsBandFit.MinimumDrawableHeight(30 行 × 3 px × 1.5 em = 135 px);FallbackFloor() 把该值用于档位 1 的 AcceptsPageBand 与档位 2 的 TryResolve,因此「带子太矮」会在下一帧被接受并画出一块更小的板;日志按页按原因节流(fallback-unavailable / fallback-fit-refused / fallback-floor-exhausted),并带 misses=、floor=、next=、opacity=。严格落板成功或换页时阶梯归零。⑥ 删掉 FallbackLayout.TryPlaceInBand 里已无作用的 limit 局部。⑦ 包内 README 三处措辞按实现改准:兜底是「位移或降低不透明度」(不再写「缩小」),板高越带时「越过下沿按 0.55 变淡、不压上方图标行」,字号一节不再说「条带不被越过」而说「宁可缩小字号,也不把板子画到边界之外」;README / manifest.json description 仍无版本号与更新日志。断言 2621 → 2638(+17:档位 2 两种墙、墙缺失时的屏底对照、ResultFallbackOpacity 四态、TryHold 带 topBound、ResultFallbackLadder 首步与到底、Counts(NaN))。
  • 验证(2026-11-04,pwsh 7):scripts/Verify.ps1 对本机 r2modman profile 的真实 BepInEx / Interop 程序集运行(SDK 6.0.428 rollForward: latestMajor、独立 scratch 输出目录)→ VERIFY: PASS,插件 Release 构建 0 警告 / 0 错误,PASS: 2638 combat-panel assertions(1.19.17 为 2543,本轮 +95:1.19.18 主体 49 条 + 评审收口 29 条 + 终审收口 17 条),配置监听 9 / 补给归属 50 / 扫描重放 38,全部 wiring 检查 PASS。
  • 未改:1.19.17 的 fail-open、F8 无条件强制、逐帧位移/变淡、严格门(标题 alpha、完成区边、页脚 settle、交互证明)与它们的日志口径;评分权重 / 称号 / 列宽与行序 / 快照协议 v8 / 配置键名与默认值 / 客机固定几何公式本身 / 1.19.13 的 strip policy 与胶水归属。
  • 仍未实机验证:① 客机成功页「正在等待……」文字到底是不是 m_btnLeaveExpedition 子树里的 TMP(本版按这个位置测量,测不到才退回控件自身的布局矩形),实机应看到 band: … bottom=… (source=footer-text) 且板底停在文字上方 21px;② 白板 m_sectorsWhiteBox 被排除后,图标行底边在真实页面上的读数是否就是截图里的 ~333(1440 空间),也就是板顶是否真的上移到 ~354;③ 带高 997px 时字号会被 FitBoard 放大到 ~22px,4 人满员的观感需要人眼确认;④ clamped 路径(页面上图标行很靠下、等待文字很靠上)在实机几乎不会触发,触发时的观感未验证。

1.19.17

  • 根因:主机结算页把「页面自身的锚点证明」当成落板的前提,而证明不成立时既没有降级出路、F8 也走不出同一套门,于是整块板永不出现:实机一局主机失败页整页正常渲染(原生标题、四个骷髅、红晶三栏、四份配装与评估、底部按钮全部可见可点,rawTextAlpha=1.00),本模组却读到 missionTitleActive=false,geometryMeasured=false;FailureTitleAnchorPolicy.Select 因此只可能回答 None,ResultTitleGate(ResultsPagePolicy.cs 的 ResultTitleGate.Observe)永不 settled,ResultsDisplay.FailureStrip 在 if (!anchorSettled) 处每帧返回 false → Placement 返回 false → Prepare 第一行 if (!Placement(…)) return; 直接返回,_placed 永远不置位,Draw 只打一条 [DIAG] draw board not drawn: reason=no-placement / [DIAG] failure board held back: titleSource=none … rawTextAlpha=1.00 … 就什么都不画。F8 之所以无效:按键本身被处理了([DIAG] F8 visibility: … requested=True),但它只做 PersistToggle + Invalidate(清 placement 与重试节奏),下一帧同一批门以同样的理由再拒一次,而 ReportHeldBack 是「每页每原因一条」,所以第二次拒收连日志都没有——玩家看到的是「按了没反应,也没有任何输出」。同样的因果链在成功页也存在另一形态:完成区上界 completion-edge-unsettled 被拒后,即使板子一度落成,逐帧 BoardStillSafe 也会因「放置后出现不可读/重叠控件」调用 DeferBoardForInteraction 把板子收回并再不返回。
  • 新行为(fail-open):只要结算页在屏幕上,就一定有板。新增纯托管策略文件 FallbackLayout.cs:ResultFallbackPolicy.ShouldFallback(misses, forced) 只有两条进入兜底的路——F8(forced)或严格落板连续被拒 MissBudget=60 帧(约 1 秒;重试节奏在 120 帧后本来就会降到每 30 帧一次,所以这个数字是「玩家几乎看不到空屏」的量级)。它不是「等够时间就把没量到的边当成量到了」:严格门一个都没改,页面一旦真的交出几何,下一次测量就以严格路径落板(_strictMisses 归零、_fallbackPlaced 清除)。ResultFallbackLayout.TryResolve 按三档回答条带:① 页面自身交互扫描量到的自由带(ResultBoardBands.TryBelowActions,即按钮下方那条,能装下 270px 硬地板时优先,opacity=1);② 上界 = 实测锚点底边 + 16px(无锚点则取窗口高度的 14%),下界 = 实测下墙(按钮顶边)− 6px 或窗口底线(opacity=1);③ 以上都装不下地板时,用整条下半安全区并把板子降到 opacity=0.55 画在原版页面上——永不返回空。落位由新增 ResultFallbackLayout.TryPlaceInBand 完成:板子高于条带时贴条带下沿、绝不越出窗口;Fit 里严格 ResultBoardHold.TryPlace 失败且处于兜底态时再走这条,所以「装不下」不再是「不画」。
  • 兜底同样受遮挡保护,但答案是位移/降透明度而不是消失:新增 ResultsDisplay.RefreshFallbackSafety(),在每一帧绘制前用同一次交互扫描重算条带;控件挪到板子下面时板子跟着挪(阈值 12px,ResultAnchorDrift.MinimumPixels),没有干净条带时按 OverlapOpacity 变淡。逐帧的严格判据 BoardStillSafe() 一行未动,只是对兜底板跳过(if (!_fallbackPlaced) { if (!BoardStillSafe()) { DeferBoardForInteraction(); return; } })——兜底板不会被 DeferBoardForInteraction 收回。
  • F8 变成无条件强制通路:PanelBehaviour.Update 在任何结算状态(ExpeditionSuccess/ExpeditionFail)下按 F8 调用新增的 ResultsDisplay.ToggleForcedFallback():翻转 Forced、Invalidate()(清 placement + 重试节奏),下一次 Repaint 立刻从兜底路径落板。它不依赖任何 gate 已 ready(不看标题 alpha、不看完成区边、不看页脚 settle、不看交互证明),也不受三个可见性开关限制(Refresh 与 Draw 的门都改成 ResultVisibility.Shows(...) || Forced),所以「held back / 未创建 / 已被放弃」三种状态下按 F8 都立即出板;再按一次回到自动判定。按键本身在 Update 里无条件读取(Input.GetKeyDown 与任何其它绑定无关),每次按下都写一行 [DIAG] F8 on a result page: force=…,日志里明确写出「这个键在这里被处理,无论是否另有绑定」。关卡内的 F8 语义(显隐 HUD 表、写 ShowPanel/ShowResultOverlay)一行未动。
  • 移除永久终止态:ResultEdgeGate.SettleAttempts/Exhausted 仍然只是「节奏降级」与「诊断用」信号,绝不成为「永不再画」的理由——真到那一步,兜底路径按 MissBudget 接管;ResultRetryCadence 每 30 帧继续测量到本局结束(回归断言直接驱动 5000 帧)。_strictMisses/_fallbackPlaced 随页面更换在 ForgetPage 归零,Forced 保留到玩家自己再按一次。
  • 补齐静默早退的诊断:Draw 的 no-placement/no-live-page 之外,这一版给四条过去完全无声的早退加了有界日志——成功页完成区未就绪(success-anchor=completion-not-ready)、客机失败页与成功页的安全区算不出来(failure-client-safe-area/success-client-safe-area = safe-area-unusable)、以及「结算状态在但两个页面对象都不活跃」(refresh=no-live-page)。兜底落板自己单独一行:[DIAG] success|failure board placed from the fail-open safe area: reason=…, band=(…), room=…px, opacity=…, boardFloor=…, anchorBottom=…, footerTop=…, controls=…, hidden=…, unmeasurable=…, scanComplete=…, strictRefusals=… of 60, forcedByF8=…, role=…。ResultFooterSource 新增 ScreenSafeArea = 5(fail-open-screen-safe-area),日志与 BoardStrip 都不会把兜底条带说成量到的几何。
  • 回归断言(tests/GTFOCombatPanel.Smoke/Release11917Scenarios.cs,75 条,已注册进 Program.cs):驱动本版真实策略,其中多组直接用实机数字(2560×1440、失败页标题底边 467、按钮 1151、成功页完成区 519/按钮 683/自由带 743..1432、need=270px):① 主机成功页无任何可测页脚 → TryResolve(1440, NaN, NaN, NaN, NaN, 270) 给出 screen-safe-area、上界 = 窗口 14%、下界 = 1432、opacity=1、板子落在窗口内;② 主机失败页「原生页完全渲染但扫描不完整」→ 标题 467 + 16 = 483 起、1151 − 6 = 1145 止、room 662 > 270,并且有自由带时优先用 743..1432(below-controls);③ 全部控件 inactive → ResultInteractionScanPolicy.ProvenEmpty 为真(计入 hidden 而非拒收),而 1 个 active 或 1 个不可测控件仍然为假;④ ResultEdgeGate 耗尽 160 帧后 Exhausted 为真、ShouldFallback(60,false) 为真、兜底条带依旧成立,ResultRetryCadence 在第 1030/2000/5000 帧仍在测;⑤ F8:ShouldFallback(0,true)/(1,true)/(100000,true) 全为真(不需要任何拒绝先发生),并静态检查 PanelBehaviour 真的在按键分支里调用 ToggleForcedFallback()、且该分支不以「板已挂上/某门已 ready」为前提;⑥ 遮挡:所有自由带都短于地板时回答 overlaps-controls + opacity=0.55(不是空),板子高于条带时贴条带下沿(y=360、底 900)且不越窗;⑦ 客机不回归:ResultsGuestPageGeometry 的两条 1080p 几何逐像素不变(396..1072、536)、UsesNoFooterSafeArea 只属于客机、UsesBandBelowActions 主机真客机假、1.19.13 的 ResultInteractionStripPolicy.TryResolve(boardTop=316/footerTop=400)与 OverlapsBoard 的 6px 净空语义不变;⑧ 胶水归属仍要求「已登记的 GlueGun Fire + 匹配 owner」(未登记不计、非发射者不计、每次射击只计一次、同一喷不会重复胶住同一敌人)。最后 14 条是静态源码接线检查(FallbackLayout.cs 存在且声明三个类型、Prepare 真的问 ResultFallbackPolicy.ShouldFallback(_strictMisses, Forced)、Fit 真的走 TryPlaceInBand、|| Forced; 与 ScreenSafeArea 都在、四条静默早退的日志都在、旧的 if (!Placement(…)) return; 已不存在),明确标注不是运行时证明。
  • 验证(2026-11-04,pwsh 7):scripts/Verify.ps1 对本机 r2modman profile 的真实 BepInEx / Interop 程序集运行(SDK 6.0.428 rollForward: latestMajor、独立 scratch 输出目录)→ VERIFY: PASS,插件 Release 构建 0 警告 / 0 错误,PASS: 2543 combat-panel assertions(1.19.16 为 2468,本轮 +75),SCOUT_HOOK_SAFETY / OPTIONAL_EWC_BRIDGE / EWC_API_CONTRACT 与全部 wiring 检查 PASS。
  • 未改:评分权重 / 称号 / 列宽与行序 / 字号搜索 / 快照协议 v8 / 配置键名与默认值 / manifest.json 的 description 语义 / 客机固定几何 / 关卡内 HUD 与锚点采样 / 1.19.13–1.19.16 修过的全部拒绝点与其日志口径。
  • 仍未实机验证:① 那次失败页的 missionTitleActive=false 到底是「页面把标题放在另一层、m_missionFailed_text 只是布局占位」还是「activeInHierarchy 在该帧被短暂关掉」,本版只是不再依赖它——实机上应看到 strictRefusals 在第 60 帧左右转为兜底落板,且落位在 483..1145 一带;② 兜底条带在真人局的观感(按钮下方 743..1432 那条会很宽,字号会被 FitBoard 放大),以及 opacity=0.55 那条最坏路径是否真的够读;③ F8 的第二次按键回到自动判定后,如果页面仍然不可测,下一帧会再落兜底板(预期行为,但需要人眼确认不会闪烁);④ 与其它模组(PreciseUI 等)在结算页新增控件时的逐帧位移观感。

1.19.16

  • 根因:主机成功页把「送回大厅按钮的上边缘」当成了条带的绝对下界,于是 132px 的条带去装 270px 的硬地板,逐帧静默不画:ResultsDisplay.SuccessRegion 的主机分支(ResultsDisplay.cs 的 else 分支,area = ResultsRegion.Resolve(completionMeasured, completion, true, buttonTop, …))把条带下界取成 buttonTop - MessageGap,Placement 再把玩家配置的纵向偏移加到上界,得到真实一局里的 strip=(538..670)(132px)。而 HudTable.BuildGrid 的可读性地板不允许结算板再小:FitBoard 在按钮高度不可读(该局 lobbyEdge=band,buttonHeight=NaN)时用绝对最小 6px 字号,30 行 × 9px = 270px。Fit 的 ResultBoardHold.TryPlace 因此每帧返回 false,只打一条 [DIAG] result board cannot fit 就返回,_placed 永不置位,Draw 什么都不画。日志里 [DIAG] settlement saved 与 [DIAG] influence board … drawn by IMGUI 的矛盾(前者有、后者 0 次)来自 Prepare 里 ResultsSaver.Save 在 Fit 之前无条件执行——JSON 落盘从来不等于落板。客机之所以有:客机走 ResultsGuestPageGeometry 固定几何(下界 = 屏高 − 8·scale ≈ 1432),完全不看任何按钮,所以只有主机会被按钮顶边夹死。「不遮挡交互」这条硬约束本身没错,错在把按钮顶边当成了页面的下界,从而放弃了按钮下方 683..1440 之间约 700px 的空白,也错在条带不足时没有任何降级出路。
  • 新行为:按钮矩形变成「必须避让的空档」,板子落到它下方那条自由带里。新增纯托管策略 ResultBoardBands(LayoutPolicy.cs):TryBelowActions 把交互扫描真实量到的每个控件矩形按页面自身 gap 外扩后从页面上「挖掉」,在 [锚点上沿, 屏高−8] 内取剩余的连续自由区间,跳过那条本来就属于调用方的「锚点起始带」,回答其中最矮高度最大的那条(ResultBoardBandKind.BelowActions)。页面上真正决定用不用它的是新增的显式角色策略 ResultPageRolePolicy(ResultVisibility.cs):UsesBandBelowActions(role, stripHeight, floorHeight, bandHeight) 只在主机 + 条带确实短于板子自身地板 + 自由带确实装得下地板时返回 true。地板值由 ResultsFontGrowth.MinimumBoardHeight(rows, rowEm, density) 给出(30 行 × 最小的 6px 字号行高,与日志里的 270px 逐像素一致)。真实那一局:按钮矩形 683 起、自由带为 (743..1432) 共 689px,板子落在这里,按钮上方 535..667 那段原版内容原样保留(空白带),没有任何交互控件被覆盖——自由带按构造就不含任何被量到的控件矩形,逐帧 ResultInteractionStripPolicy.OverlapsBoard 仍然对每一个 active 控件做重叠保护。ResultFooterSource 新增 BelowActions 记录这条落位的来源,逐帧 BoardStillSafe 只在来源不是这条带时才用「按钮净空线」判定;落在带里的板子不再被记成「靠猜落位」,因此不浪费那一次 ResultAnchorRecovery 二次测量。ResultBoardHold.TryPlace 依旧拒绝比带子更高的板子,bottomAligned: false(上界悬挂)、ResultsUpperBound.Decide 的延迟落位、按钮缺失时的 fail-closed 全部未变。
  • 主机不再比客机更差,也不再被灌输客机的语义:SNet.IsMaster 的读取收口到 ResultsDisplay.Role()(只有两处读取:角色助手与扫描自身的覆盖边),GuestClient() 变成 ResultPageRolePolicy.IsClient(Role())。ResultNoFooterGate 依旧是客机专属证明(ResultPageRolePolicy.UsesNoFooterSafeArea),但主机不再被迫依赖它:主机有「按钮可测 → 条带/自由带」与「按钮不可测 → 原版保留」两条显式结果。原先给主机打印的「the guest safe-area proof is incomplete」已删除,改为按角色如实描述(role=host|client、noFooterProof=available|not-this-role、count/unmeasurable/complete/nativeVisible/footerTop/need=)。
  • 日志说真话:[DIAG] success region 末尾那句「drawn, or laid out and still revealing…」被删掉——它只在量到条带时打印,却读起来像「板子已经在屏上」。现在这行只陈述量到了什么(anchorSource、completion、lobbyButton、lobbyEdge、boardTop、room、新增 boardBand=、boardFloor=、role=),是否落板由 fit 自己那行负责。[DIAG] result board cannot fit 现在带 role=、page=、strip=…px、grid=…、need=…px、have=…px、footerSource=、buttonTop=、screen=;新增 [DIAG] success strip cannot hold the board's own floor(条带不足、改走自由带,含条带/带子/按钮的真实像素)与 [DIAG] success board deferred(条带和带子都装不下时的诚实拒绝)。
  • 静默拒绝补有界诊断(每页每原因一条,不刷屏):新增 ResultsDisplay.ReportHeldBack(where, reason, detail),接入原先不说话的路径——Draw 的 _grid/_content/_page 空返回(reason=no-placement|no-live-page)、失败页持有板的 TryHold 失败、BoardStillSafe 里成功/失败两页的完成区/标题未就绪、板子落在条带外、板子触到按钮净空线、HeldGridFits 拒绝、Placement 里 room <= 0 与 grid == null、FailureStrip 的 titleKnown 与 top/bottom 算术失败;每行都带 reason=、role=、need=、heldStrip|have=、top/bottom、detail=,并且未落位时不打印假的 0..0 条带(heldStrip=none)。另外 Mathf.Max(0f, NaN) 的传播修复为两角色统一:玩家把 ResultsOffsetY 手改成 NaN/负值时按 0 处理(原先只有客机做了这件事,主机会得到整条 NaN 条带并静默不出板)。
  • 回归断言(tests/GTFOCombatPanel.Smoke/Release11916Scenarios.cs,82 条,已注册进 Program.cs):直接驱动本版真实策略,其中第一条就用了真实日志数字:completion=519 / lobbyButton=683 / gap=16 / 2560×1440 / offset=3 → ResultsRegion.Resolve 给出 535..667(132px)→ BoardStrip(538..670) + TryPlace(553×270) 必须拒绝(复现旧行为);MinimumBoardHeight(30, 1.5, compact) 必须等于日志里的 270px;同一页的按钮矩形 → TryBelowActions 给出 (743..1432)、689px、BelowActions;UsesBandBelowActions(host,132,270,689) 为真而 client 为假、条带 400px 为假、带子 40px 为假;板子 TryPlace 落在带子内且 OverlapsBoard 对按钮矩形为假(不遮挡);HeldGridFits 接受带内板子、拒绝越过带子下沿的板子;控制铺到屏幕底 → 无带子(明确拒绝);客机对照:ResultsGuestPageGeometry.TryResolve(519, 2560, 1440, 16, 96) → (540.33..1429.33) 与 1.19.15 逐像素相同,客机板子照旧装得下,并且客机永远不取主机的自由带。最后 21 条是静态接线检查(ResultBoardBands.TryBelowActions 在 ResultsDisplay.cs 恰好 1 次、带子分支位于 if (GuestClient()) 的 else 侧、SNet.IsMaster 恰好 2 处读取、那两句假话已消失、ReportHeldBack( ≥ 8 处、NaN 偏移的两个旧写法已消失),明确标注不是运行时证明。
  • 根因(诊断正确性):关卡时钟跨关卡残留,「新关首帧」被误报成「本关 1416 秒无人应答」:CombatSync.Tick 只在 state == InLevel && _levelSince < 0 时重启时钟与两条「每关一次」的闩锁,而清零那一行(旧 :325)位于「非 InLevel/成功/失败 → 提前 return」(旧 :302)之后,Lobby/Generating/Dropping 阶段根本走不到,于是关卡时钟跨关卡残留;Heard 却在 ResetExpedition(旧 :260)与进 Lobby(旧 :291)时被清空,两者生命周期不一致。_loggedNoInbound 的复位条件(旧 :323)在残留状态下永不为真 → 永不复位,_loggedOutboundRows 同理 → squad sync outbound 全会话只出现一次。实机 LogOutput.log 的 3351(进关)→ 3363-3373(两名 peer 的 hello/snapshot 被接受)→ 5563(NoLobby --> InLobby)→ 5819(state=Generating)→ 5979(新关首帧,now-_levelSince=1416s ≥ 20 且 Heard.Count=0 → 误报「3 名人类 peer 一个包都没收到」)→ 5988-5997(同一批 peer 的包又被正常接受)就是该序列;receivedButUnusable=0 只表示没有形状不可解码的包,不等于「什么都没到」。修法:新增纯托管 LevelDiagnosticClock(SquadDiagnostics.cs),一个对象同时持有相位(Outside/InLevel/Result,由 CombatSync.PhaseOf 从 eGameStateName 映射)、关卡时钟与两条闩锁;每 tick 先 Enter(phase, now)——只有进入 InLevel 的那一跳重启时钟并复位两条闩锁,离开 InLevel 的那一跳停表,同一相位重复不改变任何东西。Enter 返回 true(新关开始)时同时 Heard.Clear(),让「本关听到了谁」与关卡时钟共享同一生命周期;ResetExpedition 追加 LevelClock.Forget();判定改为 NoInboundOwed(now, Exchange.HumanPeers, Heard.Count)(分母是本关时钟)。同步协议 v8、发送/接收网关、HostRows/OnSnapshot/Project/CombatMerge 的合并语义一行未动。日志同时改成如实口径:从 {now - _levelSince:0}s in the level 改为 {LevelClock.Seconds(now):0}s since this level began,并明确 receivedButUnusable 只统计「到达但无法解码」的包,0 不表示没有包到达(旧文案 a zero means nothing arrived at all 已删)。
  • 根因(定位能力退化):锚点瞬态失败被永久化,整局退回配置坐标:PanelBehaviour.SampleAnchor 把首帧 camera == null(layer.GuiLayerBase.GetWorldCamera() 此刻尚未就绪)当作终局——一次性 _loggedAnchorFailure latch + _anchorUnavailable = true 永久生效,唯一复位点 UnlockAnchor 只由配置热重载/分辨率变化触发。实机日志 3358 首帧 icon=ok 而 camera=null,随后整局 18,760 行内再无任何 flashlight anchor locked,3360 明确 anchor=config PositionX/PositionY(1874,526 @2560×1440)。修法:新增纯托管 AnchorSamplePolicy(AnchorSamplePolicy.cs)给锚点一个有界重试预算——首次失败后 0.5s 重试,之后每次失败翻倍、封顶 4s,预算 Budget = 12 次失败(39.5s 墙钟:0.5+1+2+4×9;且只对未锁定的锚点每关重来,见后文限定);预算耗尽即 Abandoned 并停止采样,不每帧空转(Due(now) 是纯比较)。Refresh 仍以 !_anchorLocked && !_anchorUnavailable 短路,SampleAnchor 内部再由 Due 节流。首次失败打「原因 + 第几次尝试 + 下次重试间隔 + 剩余次数」,放弃时打「本关放弃 + 尝试次数 + 等待时长 + 最后原因」,成功锁定照旧打 flashlight anchor locked after …。兜底行为不变:自首次失败起即用配置坐标落板(新增 _anchorFallback,与 _anchorUnavailable 走同一 else 分支),HUD 迟到不会让表消失;TryResolveOrigin 仍只在「已测量但尚未稳定」的窗口内不画,避免跳动。进入新关卡或 UnlockAnchor 都 Restart() 重置预算并清除放弃判定;AnchorToFlashlight=false 的路径未动。
  • 文档与行为不一致(只改文案):Plugin.cs 里 ShowImpactBoard 的描述原本声称 F8 会把新值写回该键,而 TogglePolicy.Apply 只写 ShowPanel + ShowResultOverlay(ShowImpactBoard 实际由 ResultsDisplay.Draw 读 PanelBehaviour.ToggleState 兜住)。改为「F8 也隐藏/显示这块板,但从不写这个键:F8 存的是 ShowPanel 与 ShowResultOverlay」。F8 语义、OverlayPolicy/TogglePolicy 的写入集合、PersistToggle 落盘行为一行未动。
  • 回归断言(tests/GTFOCombatPanel.Smoke/Release11916bScenarios.cs,106 条,已注册进 Program.cs:1772):LevelDiagnosticClock 复现实机序列(100s 进关 → 收到 2 个 peer 的包 → 回 Lobby → Generating → 1516s 新关首帧)并断言新关首帧不会误报;同时断言「新关 20s 后仍无包」仍会报警、且每关只一条(真警报没有被一起关掉);跨两关断言 OutboundOwed/NoInboundOwed 各触发一次(旧行为是全会话一次);AnchorSamplePolicy 断言 0.5/1/2/4s 递增且封顶的节流、60Hz 连续失败 2 秒只尝试 3 次(每帧空转会 121 次)、Budget=12 次后 Abandoned 且 Due 永远为 false、成功后失败计数清零并回到 0.25s 采样节奏、Restart 发一份新预算;另有 28 条静态源码检查(旧的两处 _levelSince 行与旧文案已消失、新文案/放弃行/Due 节流/LevelClock.Enter 接线存在、TogglePolicy.Apply 写入集合未变),明确标注不是运行时证明。
  • 验证(2026-11-03,评审收口后重跑;最后一条收口 ⑧ 之后又整轮重跑):scripts/Verify.ps1 -GTFO_DIR <r2modman profile 目录> 对本机 r2modman Survival Test profile 的 BepInEx / Interop 程序集运行(同一轮早前的跑次用的是另一个 r2modman profile;收口期间 r2modman 自身重新部署了该 profile、其 BepInEx/interop 被清空,之后这一跑改用同机同样装了 EWC 的 Survival Test:BepInEx core 相同、EWC DLL 版本不同,EWC_API_CONTRACT 仍 PASS。构建环境:global.json 要求 SDK 6.0.428 且 rollForward: latestMajor,本机实际解析到 8.0.427;pwsh 7.6.9;独立 scratch 构建目录 verify-11916d → VERIFY: PASS;插件 Release 构建 0 警告 / 0 错误;PASS: 2468 combat-panel assertions(1.19.15 为 2280;结算板改动 +74,诊断修复再 +106,最终收口再 +8)、9 配置监听 / 50 补给归属 / 38 扫描重放;SCOUT_HOOK_SAFETY: PASS (57 compiled patch classes)、OPTIONAL_EWC_BRIDGE: PASS、EWC_API_CONTRACT: PASS,DISPLAY_PROJECTION_WIRING / SETTLEMENT_TOOL_PROJECTION / RESULT_BOARD_UPPER_BOUND / RESULT_PAGE_SETTLE / RESULT_FAILURE_TITLE_ANCHOR / RESULT_CLIENT_NO_FOOTER_SAFE_AREA / SETTLEMENT_TOOL_DATA_FIRST / HEALTH_LOSS_TARGET_WIRING / SQUAD_HELLO_ROSTER_RACE / GTFO_TMP_FONT_WIRING 全 PASS。构建产物 GTFOCombatPanel.dll 为 417792 字节、assembly 1.19.16.0、SHA-256 10FF633C5C1442003E880DBB666C993F0BC637242A2DAD3392F2866A59D4E386(本工程构建非确定性:不同输出目录的 MVID 含源绝对路径,跨目录 hash 不能当一致性判据)。测试项目在仅装了 .NET 8 SDK 的机器上会额外打印 NETSDK1138(net6.0 EOL)提示,那是 SDK 级建议、不是代码警告;插件自身构建为 0 警告 0 错误。
  • 未改:评分权重 / 称号 / 列宽与行序规则 / 字号搜索 / 快照协议 v8 / 配置键名与默认值 / Thunderstore/manifest.json 的 description 语义 / 成功页原生 Blink 时序(CompletionReadiness)/ F8 与滚轮行为(ZoomAt、_hitRect 语义)/ 客机固定几何。失败页主机条带的不足仍按原样拒绝(它的上界是页面顶部的标题,条带通常远高于地板),但新增了带真实数字的有界诊断。诊断修复这一轮另外确认未改:SyncProtocol(仍为 v8)、发送/接收网关条件与 SquadNet.MaySend 的判定、HostRows/OnSnapshot/Project/CombatMerge 的合并语义、TogglePolicy.Apply 写入集合与 PersistToggle 落盘、AnchorToFlashlight=false 的路径、HudTable/PanelGeometry/FitBoard、以及 ResultBoardBands/ResultPageRolePolicy 的语义。
  • 仍未实机验证:真实主机局里按钮矩形下沿的实际位置与按钮下方是否真为空白(本版按「矩形外的自由带」计算,按钮高度未知时就取扫描量到的矩形下沿);4 人满员时板子在带子里被 FitBoard 放大到 689px 条带的观感(字号会明显大于过去 6px 的情况);带子落位与完成区之间的那段空白(535..667)在原版页面上的观感;以及另一个模组在带子内新增控件时逐帧重叠保护的实际回收时机。诊断修复这一轮另有三项只能实机确认:真实主机/客机局里 layer.GuiLayerBase.GetWorldCamera() 究竟持续多久返回 null(决定 0.5s 起步、封顶 4s、12 次的重试预算是否够用)、重试成功锁定那一刻表从配置坐标跳到锚点坐标的一次位移观感、以及 squad sync inbound 是否真的一局最多一条且不再出现在新关首帧。
  • 评审收口 ①(BLOCK):「已落板」不再由早于 fit 的那行现在时声称。[DIAG] success strip cannot hold the board's own floor 原写「…and the board is laid out in the free band below them: band=(…)」,而它打印在 SuccessRegion 里、早于 Fit/Complete/Completion.Observe/ResultUpperBound 拒绝点(ResultsDisplay.cs 的 1226 完成区不可测、1232 交互扫描未完成、1239 Completion.Observe 未稳定、1283 ResultsUpperBound.Decide 拒绝落位,以及 Fit 里 1144 的 result board cannot fit)。于是「带子够高但扫描不完整 / 完成区未稳定 / 最小字号装不下」时,同一页会同时出现「laid out in the free band」与「cannot fit」——正是原 bug 报告里 success region … drawn 与 cannot fit 并存的翻版。现改为「…and the free band below them is offered to the fit: boardBand=(…)…」:只陈述把这条件交给 fit 去判,是否落板由 fit 那行负责(与同文件 1545 起已写明的规则一致)。同族按同一条规则自查出的另外两处也改了口径:ReportDowngradedControls 的「so its collision was ignored and the board is drawn as usual」同样早于 fit 打印,改为「so its collision was ignored and this page is not deferred on account of it. Whether the table is drawn is the fit's own answer」;[DIAG] result board kept at its measured size 原把 strip.ButtonTop 叫成「button top」,改为 wall=<px>, source=<band>(该数在 lobbyEdge=band 时是带顶,不是按钮顶)。
  • 评审收口 ②(BLOCK):lobbyButton= 不再冒充按钮,band= 不再与 lobbyEdge=band 撞词。buttonTop = wall.Top,而 LobbyEdgeSource.Band 时 wall.Top = controls.FooterTop;实机那一局正是 lobbyEdge=band(真实按钮顶边 683,带顶 743),旧写法会打 lobbyButton=743,差 60px 且字段名看着像量到了按钮。现在 DescribeLobbyWall(wall) 只在 Pressable/Layout(确实读到那个控件)时打 lobbyButton=<px>,band/none 时打 lobbyEdgeValue=<px|unmeasurable>,source=<band|none>;新增的 band= 字段改名 boardBand=,因为 lobbyEdge=band 说的是来源而不是一条带子。三处(success strip cannot hold…、success board deferred、success region)统一走该助手。
  • 评审收口 ③:「无条带」不再被打成 0。ReportHeldBack 原来一边打 heldStrip=none 一边打 top=0, bottom=0,与本文第 8 条自己的承诺矛盾;现在打 heldStrip=none, have=unmeasurable, top=unmeasurable, bottom=unmeasurable,有条带时照旧打真实上下沿。SuccessRegion 的 boardTop={area.Top}, room={area.Height} 在 ResultsRegion.Resolve 返回 default(没有可解析条带)时改为 boardTop=unmeasurable, room=unmeasurable;扫描三行(scan-incomplete / control-downgraded / board-overlap)统一走新增的 DescribeStrip(top, room):room <= 0 即「没解析出条带」,打 top=unmeasurable, room=unmeasurable 而不是坐标 0。
  • 评审收口 ④(真实但窄):采样抛异常不再免费。PanelBehaviour.SampleAnchor 原来在 Began 之后直接读 HUD;TryScreenRect 抛异常时 Failed 不会被调用,预算不消耗、_nextAttempt 不推进 → 每 0.25s 无限重试,与 Began 注释「a sample that throws still counts」相反。现在整次尝试由新的 AnchorSamplePolicy.Attempt(now, sample, out gaveUp) 运行:Began → 委托(PanelBehaviour.ReadAnchor:相机 / 图标 / 投影三处读到一处)→ 成功 Measured、失败或抛异常 Failed,任何路径都消耗一次预算;抛异常记为既有的 Unmeasurable(矩形读不到),并把异常 message 带进首次失败那行(, the read itself threw: …),不是静默放弃。AnchorSample.Measured 同时要求真有矩形(宽高 > 0),因此「预算已耗尽、这次根本没读」不会被读成一次测量。
  • 评审收口 ⑤:LevelDiagnosticClock.Enter(…, NaN) 不再把 _since 设成 0。原来 _since = float.IsFinite(now) ? now : 0f 把「时钟不可读」变成「本关从 t=0 开始」,下一帧有限时钟就会算出整局长度的关卡时长——正是本批次要消灭的假警报。现在 NaN 记为「未启动」(_since = NaN):Running 为 false、Seconds 返回 0、NoInboundOwed 在 _since 非有限时直接不报警(与该文件既有的「时钟不可读不报」语义一致);同一关卡里第一个可读时钟会开表(返回 false,不算新关卡、不清证据),从那一刻开始计时,所以真警报仍会被触发,只是不早于它能读到的时刻。
  • 评审收口 ⑥:测试不再比真实调用更强。Release11916Scenarios.cs 的「比带子高 1px 的板子被拒」原来只用 slack=0,而生产路径 Fit → ResultBoardHold.TryPlace(…, BoardSlack) 传的是 BoardSlack = 2f;实测两者答案不同:slack=0 拒绝,slack=2 接受(板子下沿进到窗口 8px 边距里 1px,这正是那 2px 容差的含义)。现在两个形态都断言:slack=0 拒绝、slack=2 接受且下沿不超过「带子下沿 + 2」与「屏高 − 8 + 2」、比「带子 + 2」更高的板子仍拒绝,注释里写明差异与「断言强度不高于实测」。另新增 6 条静态措辞断言(band 行改为交给 fit、boardBand=、lobbyEdgeValue=、两处 unmeasurable、the board is drawn as usual 已消失)。
  • 评审收口 ⑦:断言(含 2 条运行时)。Release11916bScenarios.cs 由 85 条增至 106 条,其中运行时:① AnchorSamplePolicy.Attempt 在采样体抛异常时 Attempts==1 && Failures==1、原因 Unmeasurable、异常 message 随行、0.5s 后才 due;连续抛异常时正好消耗 Budget=12 次并 Abandoned,Waited 落在 39–40s(即 0.5+1+2+4×9),耗尽后 Attempt 连读取都不再执行(Attempts 停在 0/12 不变)。② LevelDiagnosticClock:Enter(InLevel, NaN) 之后一个 5000s 的有限时钟既不报警也不给出 5000s 的关卡时长,同一关卡的第一个可读帧开表(Seconds 从那一刻算),20s 后真警报仍触发。Release11916Scenarios.cs 由 66 条增至 74 条(+2 slack 形态、+6 措辞),最终收口 ⑧ 再 +8(6 条运行时 + 2 条静态)→ 82 条。合计 PASS: 2468 combat-panel assertions(1.19.15 为 2280)。
  • 对第 11 条的限定(如实写清,别让下一位读者误读):文中「进入新关卡 Restart() 重置预算」只对未锁定的锚点成立。PanelBehaviour.Refresh 的采样入口是 if (Plugin.AnchorToFlashlight.Value && !_anchorLocked && !_anchorUnavailable) SampleAnchor(...)(PanelBehaviour.cs:217),_anchorLocked 一旦为 true 就再也不采样;Restart() 只清预算与「放弃」判定,不会让已经锁定的锚点重新测量。所以正确说法是「每关给一次新预算,前提是这一关开始时锚点还没锁定」,而不是「每关都会重新测量一次」。同理,Budget = 12 次失败合计 39.5s 墙钟(0.5 + 1 + 2 + 4×9),不是 35s。
  • 已知边界(本轮记下但不修):ScreenBounds.TryFromCorners 会丢弃「整窗」控件(LayoutPolicy.cs 的 857 一带),因此一个铺满全屏的可点控件既不会被挖成带子、也不会触发 OverlapsBoard——这是既有性质,本版没有改;实机上若遇到满屏遮罩式按钮,结算板仍可能压在它上面,需要单独一轮。另:ResultBoardBands.TryBelowActions 的 gap 是调用方契约(允许 gap=0),而逐帧判据 OverlapsBoard 用 6px,所以调用方必须传 gap >= ResultBoardHold.ButtonClearance;唯一调用点传页面自身 MessageGap(16px) 才安全,这条不等式已写进该函数的文档注释(未加运行时断言、未改签名)。失败页每帧算一次 controls.BelowActions 后丢弃(ResultsDisplay.cs 的 1854/2198 一带)是纯性能问题,本轮同样不修。
  • 评审收口 ⑧(最后一条 BLOCK):逐帧拒绝行不再把「带顶」冒充按钮顶,两处口径收到一个纯函数。ResultsDisplay.BoardStillSafe 的 board-reaches-button 行原先由 successButtonMeasured 放行、再打 ",lobbyButton=" + successButtonTop;而 successButtonMeasured = wall.Measured(ResultsDisplay.cs:1475)在 LobbyEdgeSource.Band 时也为 true,successButtonTop = wall.Top 此时正是 controls.FooterTop(:1458-1462/:1465/:1477)——实机那局真实按钮 683、带顶 743,于是这行会打 lobbyButton=743,与同文件 DescribeLobbyWall 已写明的不变式(lobbyButton= 只在真读到按钮时打)直接冲突;上一轮只改了三处调用点,漏了这一处。可达性不是死代码:客机成功页在 ResultsGuestPageGeometry 条带不足 MinimumGuestStripHeight 时保持 screenH-8,板底可越过 FooterTop - 6 触发该分支;主机在 band 来源 + 持有板跨条带时同理(!belowActions 只挡 BelowActions,挡不住 InteractiveBand)。修法(只改日志,拒绝逻辑一行未动):把该口径提炼为纯托管 ResultFooterProvenance(ResultVisibility.cs,无 Unity/BepInEx,可无头断言):Of(wall) 是「读数 → ResultFooterSource」的唯一映射(SuccessRegion 的 footerSource 也改用它),LobbyWall(source, top, measured) 只在 NamedControl 时打 lobbyButton=<px>,其余打 lobbyEdgeValue=<px|unmeasurable>,source=<band|none>;逐帧拒绝行与 success strip cannot hold…/success board deferred/success region 三行共用它,ResultsDisplay.DescribeLobbyWall 只剩一行转调,口径不会再分叉。BoardStillSafe 的 !belowActions && successButtonMeasured && board.Bottom > … 判定、ResultBoardBands/ResultPageRolePolicy 语义、带子几何、客机固定几何、评分/称号、SyncProtocol、配置键与默认值、manifest.json description 均未改。断言(+8):6 条运行时(source=InteractiveBand 且 measured=true 时输出里不出现 lobbyButton=、出现 lobbyEdgeValue=743,source=band;NamedControl 的 pressable 与 layout 各一条断言 lobbyButton=683;band/none 的读数映射;无墙时 lobbyEdgeValue=unmeasurable,source=none),2 条静态(DescribeLobbyWall(wall) 恰好 3 处且转调同一助手、逐帧拒绝行确实走 ResultFooterProvenance.LobbyWall(successFooterSource, …);以及扫全部生产源码、去注释后 lobbyButton= 只出现 1 次且该行含 ResultFooterSource.NamedControl——防止口径再次漂到别的行)。

1.19.15

  • 根因:客机结算页永远等不到「本页没有任何 active 控件」的证明,于是永远不落板:客机的两张结算页上固定存在一行「正在等待厅主」的状态文字,而这行文字本身就是一个 active 控件。1.19.13 / 1.19.14 把「完整交互扫描证明本页没有任何 active 控件」(ResultInteractionScanPolicy.ProvenEmpty → ResultNoFooterGate → ResultsDisplay.Placement 里的 !controls.Complete 与 NoFooter.Ready 判定)当成客机落板的前提,于是这条证明在客机上永远不成立:ResultNoFooterGate 永不 Ready,ResultsDisplay.Placement 静默地、永久地拒绝落板,客机根本看不到结算板。按 F8 只清 placement(ResetPlacement)而不清这个门,所以 F8 也救不回来——这就是「客机没有结算板」的直接原因。
  • 新行为:客机两页只等锚点渲染完成,然后用固定页面几何算板:失败页只等 m_missionFailed_text 的实际 alpha ≥ 0.99 且底边跨帧稳定,成功页只等完成区上界跨帧稳定;锚点落定后由新的纯托管策略 ResultsGuestPageGeometry(GuestPageGeometry.cs,不依赖任何 Unity / 原生类型)算条带:scale = screenHeight / 1080,下界取 screenHeight - 8 * scale 并夹紧到 [0, screenHeight],上界取「锚点实测底边 + gap * scale」并夹紧到 [0, screenHeight],宽度不参与;bottom <= top 或 bottom - top < minimumHeight 时明确回答不可信。锚点不可测(非有限数)、区域装不下最小板高、屏幕尺寸异常(宽或高 ≤ 8、非有限数)、gap < 0、minimumHeight <= 0 时一律明确不落板并保留原版页面,不做任何猜测性回退。客机路径不再读写 FailureNoFooter / SuccessNoFooter / ResultNoFooterGate / FooterControls.Complete / ProvenEmpty:FailureNoFooter.Observe 与 SuccessNoFooter.Observe 各只出现一次且都落在 if (GuestClient()) 的 else 分支里,BoardStillSafe 里每一处 ProvenEmpty 前面都有 GuestClient() 守卫,客机走不到任何一处。原生控件只用作落板之后的事后重叠保护与下界收紧(ResultInteractionStripPolicy.OverlapsBoard 与逐帧重扫照旧)。
  • 主机路径与其它一切未变:SNet.IsMaster == true 的页脚扫描、完成区判定、ResultEdgeGate / ResultNoFooterSafeArea 判定逐条未变;评分权重、称号、快照协议仍为 v8、HUD、行序/列宽/字号搜索、F8 语义(清 placement 并重新测量,不触碰任何门)、defaults/scoring.json 默认值均未改。
  • 验证(2026-11-02):scripts/Verify.ps1 对本机 r2modman Survival profile 的真实 BepInEx / Interop 程序集运行(SDK 6.0.428、pwsh 7、独立 scratch 构建目录)→ VERIFY: PASS,Release 构建 0 警告 / 0 错误,PASS: 2280 combat-panel assertions(1.19.14 为 2244;本轮新增 36 条,全部在 tests/GTFOCombatPanel.Smoke/Release11915Scenarios.cs:1080p 失败页 380/16/96 → 396..1072(高 676)、成功页 520 → 536..1072(高 536)、锚点 NaN 与 +∞ 必须拒绝、超宽 2560×1080 与 1920×1080 逐像素相同(宽度不是输入)、1280×1024 按 1024/1080 等比缩放到 395.17..1016.42 且整体夹在屏内、1060/450 装不下必须拒绝、零高 / 8 高 / NaN 高 / 4 宽屏幕、gap < 0、minimumHeight = 0 必须拒绝、最小板高 700 被 676 拒绝而 676 恰好装下,共 23 条;另加 13 条静态接线断言(后 3 条是 settle 接线防回归:ResultsDisplay.cs 里 ButtonSettled(false, float.NaN) 至少 4 处,且 FailureNoFooter.Observe 与 SuccessNoFooter.Observe 各自前 400 个字符内都出现它,证明主机两个无页脚分支仍先清按钮 settle 状态):GuestPageGeometry.cs 存在且含 class ResultsGuestPageGeometry、ResultsDisplay.cs 里 ResultsGuestPageGeometry.TryResolve 恰好出现 2 次、Placement 与 AnchorSample 两个方法体内都不再出现它、FailureNoFooter.Observe 与 SuccessNoFooter.Observe 各只出现 1 次、且都写在以 if (GuestClient()) 开头的分支链最后一个 else 里(按花括号配对判定客机分支在调用点之前已闭合;客机按角色走链条第一支,buttonMeasured 只是主机侧的次分支条件,不再作客机的分支条件)、BoardStillSafe 内每一处 ProvenEmpty 前 200 个字符内都有 GuestClient()——这几条只读源码字符串,是静态接线检查而不是运行时行为证明),另 9 配置监听 / 50 补给归属 / 38 扫描重放断言(合计 2377),SCOUT_HOOK_SAFETY: PASS(57 个 patch 类)、EWC API / bridge 与全部 wiring 检查 PASS。构建产物 GTFOCombatPanel.dll 为 399360 字节、assembly 1.19.15.0、SHA-256 84D2EBA4DB5036A10D741CEDB1CBFA1E2AA9BA1DDC00DF571742D8B043E869CC;包自身的字节数与 SHA-256 不进更新日志(本段文字本身就在包里,写进去必然对不上),以发布记录与线上包核对为准。本工程构建非确定性:同源不同输出目录会得到同尺寸但不同 SHA-256 的 DLL(MVID 含源绝对路径),跨目录 hash 不能当作一致性判据。本轮只有编译与托管重放验证,实机观感未验证:客机板子在真机上的位置、与「正在等待厅主」文字的实际间距、以及不同窗口分辨率下的观感,都需要在真实大厅里再看一次。

1.19.14

  • Thunderstore 依赖声明升级到 BepInEx-BepInExPack_GTFO-3.2.4:Thunderstore/manifest.json 的 dependencies 仍然只有这一条;scripts/Verify.ps1 与 scripts/Build-Package.ps1 改为按完整字符串断言它,写错或写旧版本会让构建与打包直接失败,不会带出去。这次依赖升级本身不带来任何运行时行为变化:3.2.4 与 3.2.3 的 BepInEx 载荷逐字节相同——两个包里的 34 个 BepInEx/core 程序集与 dotnet 运行时全部一致,都报 6.0.0-be.665+6aabdb5ff9500fca541dbd66df7fbd7fe1dacd78,BepInEx.Core.dll 在两边同为 SHA-256 521055ED30495B39CFF2327F534E83938FDAA912CE6A534DEECCA324508A6AE5;3.2.4 只多出 BepInEx/core/AssetsTools.NET.dll 与 BepInEx/cache/classdata.tpk,core 里没有任何文件被删除或替换。多人快照协议仍为 v8。
  • 3.2.4 把 GTFO-API 从「包内自带」改成「声明依赖」:3.2.3 直接内置 BepInEx/plugins/GTFO-API.dll,3.2.4 改为声明 GTFOModding-GTFO_API-0.6.0,由 r2modman / Thunderstore Mod Manager 安装进 profile。本模组对它的态度没有变:SquadNet.TryBindApi 只按类型名 GTFO.API.NetworkAPI 反射绑定、在名为 CombatPanelSquad 的 free-sized 事件上注册,公共 DLL 里没有任何 GTFO-API 程序集引用;API 不在时照旧退回 SNet.Core.SendBytes 发、SNet_Replication.RecieveBytes 前缀收。零第三方依赖(也不直接依赖 GTFO-API)的设计不变。
  • 结算页页脚扫描:矩形读不到的控件不再判「扫描不完整」,而「本页没有页脚」也不再由量得到的矩形数决定:以前一个 active、但这一帧量不到矩形的控件(典型是别的模组克隆的按钮还没完成布局)会让整次扫描判为不完整,板子被永久拒收。现在扫描按 ResultInteractionTally 分四类计数(active / hidden / hiddenPageActions / unmeasurable),并把这轮 code review 判定的两处「把量不到当成不存在」修掉,两条都写成代码里的不变量:只要页面上还有任何一个 active 控件(无论能否量到矩形),就不得声称该页没有页脚。 ① ResultsDisplay.ScanFooterControls 交给 FooterControls.Count(进而进入 Tally / ProvenEmpty / BoardStillSafe / ResultNoFooterGate.Observe)的是扫描自己的 active 计数 tally.Active,不再是 strip.FooterActionCount——那个数只统计读到矩形的项,现在只留在条带诊断里。旧写法在「一页所有 active 控件都量不到矩形」时得到 Count == 0,ProvenEmpty 因此为真,客机 success/fail 页会连续 3 个不同帧通过 ResultNoFooterGate 拿到 ResultNoFooterSafeArea 的整页下沿(Screen.height - 8),而 BoardStillSafe 也仍然只问 ProvenEmpty,不会把板子收回。现在 ResultInteractionScanPolicy.ProvenEmpty 要求 Active == 0 && Unmeasurable == 0,ResultNoFooterGate.Observe 也同时要求 active 与 unmeasurable 两个计数为 0(新增可选参数 unmeasurableCount,缺省 0,七个参数的既有调用不受影响)。② 受保护的等待提示行(失败页 m_returnToLobby_text)量不到矩形时,过去只记一个 UnmeasurableControl()、不计入 Active,于是 controls.FooterTop 为 NaN、ResultNoFooterSafeArea.TryResolve 不加下界,板子可以压到这条「必须保护」的提示上,而没有任何东西会把它收回;现在这条「存在但这一帧没量到」的提示本身计成一个 active 交互(tally.ActiveControl()),ProvenEmpty 与客机安全区门都不会放行——FooterTop 能否读到不再是它唯一的下界来源。诊断行里 count= 现在是 active 交互数(含量不到矩形的),scanned= 现在是扫描访问过的控件总数(active + hidden),不再把 active 计数冒充成「访问过的」。ResultInteractionScanPolicy.DowngradeIsWhy 让「仅因为量不到矩形才放行」的那一次扫描写一条有界 [DIAG] … board allowed past an unmeasurable interaction(只有计数、几何与来源,不含任何玩家标识);每次重绘都会重跑整次扫描,ResultInteractionStripPolicy.OverlapsBoard 一旦量到它压到板子上就立刻把原版页面拿回来。[DIAG] … scan-incomplete 与 [DIAG] failure strip 这两行都带 unmeasurable= 计数。
  • 验证(2026-11-01):scripts/Verify.ps1 对本机 r2modman Survival profile 的真实 BepInEx / Interop 程序集运行(SDK 6.0.428、pwsh 7、独立 scratch 构建目录)→ VERIFY: PASS,Release 构建 0 警告 / 0 错误,PASS: 2244 combat-panel assertions(本轮 review 修复前为 2235;新增 9 条:ProvenEmpty(true, true, (0,0,0,1)) 与 (0,0,0,2) 必须为 false、ResultNoFooterGate 在 active=0 而 unmeasurable=1 时始终不 Ready 而在两者都为 0 时第 3 个不同帧放行、FooterControls 的 count 只取自 tally.Active 且源码里不再出现 strip.FooterActionCount, tally.Active、两个客机安全区门都收到 active 与 unmeasurable 两个计数、scanned= 现在是访问过的控件总数、受保护等待提示行的计数接线,以及原先算出后从未断言的 noVerdict),另 9 配置监听 / 50 补给归属 / 38 扫描重放断言(合计 2341,修复前 2332),SCOUT_HOOK_SAFETY(57 个 patch 类)、EWC API/bridge 与全部 wiring 检查 PASS。构建产物 GTFOCombatPanel.dll 为 396800 字节、assembly 1.19.14.0、SHA-256 45C725BCFD1673389C22E79B7B58B5073F526CE454A1C09199DCFE9A442A0E6D。本工程构建非确定性:同源不同输出目录会得到同尺寸但不同 SHA-256 的 DLL(MVID 含源绝对路径),所以跨目录 hash 不能当作一致性判据;一致性只能用「同一输出目录原地重编,hash 与字节均不变」来证明。

1.19.13

  • 客机(非房主)结算页:没有可用原生页脚时改用「有明确来源的安全区」。成功页与失败页都先要求各自的锚点已就绪,然后才允许走这条路径:SNet.IsMaster == false、该页原生 m_lobbyButtonVisible == false、完整扫描(页面 m_allItems + 页面子层级 CM_Item/UnityEngine.UI.Button + 具名结算按钮的父节点子树)在 3 个不同 Unity 帧 上都没有找到任何可交互控件、且原生状态可读。安全区从实测锚点往下到 屏幕高度 - 8px,仍受按钮净空与逐帧重叠检查约束;任何一项不成立就保持原版页面并重试。生产落位不再使用 0.66H 比例回退:常量 ResultsRegion.FallbackBottomFraction 与 ResultsRegion.Resolve 里的该分支仍在源码中(供历史算术与测试引用),但生产调用点都传入实测标记(ResultsDisplay.cs 成功页传 buttonMeasured: true,FailureAlignment.Resolve 传 true),测不到按钮时的结果是不落板,因此这条比例回退在运行期不会被走到。页面上的其它文案(例如底部「正在等待厅主做出决定……」)只能作为下边界保护缩短条带,永远不作为板上界。
  • 失败页标题锚点只有两种可信来源:①原生 m_missionFailed_text 处于 active 且文字非空,玩家实际看到的 alpha(TMP 自身 alpha × 所有祖先 CanvasGroup)≥ 0.99,并且它实测底边跨帧稳定;②客机 + 原生 lobby 标记为 false + 该标题 inactive 时,必须等该页 ExpeditionFailedSequence() 返回的那个迭代器走到 terminal MoveNext(false)(Harmony 绑定同一实例指针),再用同一标题的 layout 连续两帧稳定几何。标题仍在淡入、alpha 不可读、几何不可测、序列未完成或换了页面实例,都不会落板;原版失败页原样保留,条件满足后自动落位,不需要 F8。
  • 同一 Unity 帧内的重复采样只算一次:ResultTitleGate、ResultEdgeGate、ResultRetryCadence、ResultAnchorDrift、ResultNoFooterGate 都按 Time.frameCount 去重。IMGUI 在同一帧多次 Repaint 时,冲突读数只作废稳定性,不会被当成第二次观测;这正好解释实机里「标题 alpha 读到 1.00,但 0 次合格不透明读数」的组合,而不是靠放宽门槛绕过它。
  • 胶枪(C-Foam)胶敌计数带 provenance:C-Foam 命中只统计来自真实 GlueGun 开火的 projectile,并核对其 owner;GlueGunProjectile.SyncDestroy() 会清掉对应状态,敌人指针复用时会清去重集合。一次喷射命中多个敌人各计一次,同一敌人只计一次;地图里野生的胶雷、未登记的 projectile、跨 owner 的命中都不计入该玩家。显示层的工具明细只读已记录的数值(名称只作提示),不会为了掩盖数据错误去改显示。
  • 本轮 review 修复:被游戏 SetActive(false) 关闭的 CM_Item/Button 不再把扫描判定为「不完整」——它这一帧拿不到指针,因此既不是下界也不构成覆盖风险(历史行为会让客机失败页永久静默拒绝落板)。只有「active 但矩形不可测(≤1px / 整窗 / 完全出框)」或「页面注册表 / 具名按钮父节点不可读」才保持 fail-closed,并在拒绝点各打一条低噪 [DIAG](scanComplete、parentCoverage、reason、count、hidden、hiddenPageActions、scanned、footerSource、top、room;只含几何与计数,不含任何玩家标识)。另外 ResultTitleGate 的锚点不再跨 inactive 复用:同一页对象下次被拉起时不会拿上一屏的标题矩形当上界。
  • 验证(2026-10-31):scripts/Verify.ps1 对真实 Survival profile 的 BepInEx / Interop 程序集运行(SDK 6.0.428、pwsh 7、独立 scratch 镜像 GTFOCombatPanel-verify-20261031-030825)→ VERIFY: PASS,Release 构建 0 警告 / 0 错误,PASS: 2190 combat-panel assertions,另 9 配置监听 / 50 补给归属 / 38 扫描重放断言;SCOUT_HOOK_SAFETY(57 个 patch 类)、EWC API/bridge 与全部 wiring 检查 PASS。本次验证产物 GTFOCombatPanel.dll 为 394752 字节、assembly 1.19.13.0、SHA-256 1F22BB8ABEAE657A80CDB3E8C9CAEC2265E92FB8F5164B257549774C750A3284。只有编译与托管重放验证:两个结算页在真实分辨率下的观感、客机无原生页脚时的安全区、失败页标题锚点、以及 ReturnToLobbyAnyway 克隆按钮的实际 rect/active 与扫描时刻是否一致,都还没有实机证据。本工程构建非确定性:同源不同输出目录会得到同尺寸但不同 SHA-256 的 DLL(MVID 含源绝对路径),所以跨目录 hash 不能当作一致性判据;本次改用「同一输出目录原地重编,hash 与字节均不变」来证明(1F22BB8A… 两次一致)。
  • 保持不变:成功页仍等原生 Blink 完成与完成区/页脚实测稳定后才落板,仍按完成区上界悬挂(bottomAligned: false);Fit 被拒仍保留原版 UI;F8、页面复用、切页、漂移重测、按钮净空、字号/列宽/行序/评分权重与 defaults/scoring.json 默认值、多人快照协议 v8 全部未改。

1.19.12

  • 修掉两个结算页「板子出现得太早、字号/尺寸被缓存,只有 F8 关开一次才正常」的时序缺陷。失败页过去只要 m_missionFailed_text 的 alpha 大于 0.001(淡入第一帧)就当作标题已到,当帧直接 Fit;同一帧页脚按钮的可测门槛是 alpha > 0.02,量不到按钮时条带下沿退回窗口高度的 0.66、字号可读下限退回绝对 6px,而 ButtonSettled(未测到) 立即返回真。Fit 成功即把 _holdValid/_holdBoard 连同这个偏小的尺寸锁住,Draw 之后只重读按钮上沿、TryHold 只会上抬不会放大,失败页也没有任何二次测量,所以尺寸一直不会自愈;F8 关开会经 ResetPlacement 强制在页面稳定后重测,看起来才「治好」。
  • 标题的就绪条件只有一条:ResultTitleGate(ResultsPagePolicy.cs)要求原生 任务失败 标题的文字已经完整显示——玩家实际看到的 alpha(TMP 自身 alpha × 上层所有 CanvasGroup)达到 OpaqueAlpha = 0.99——并且实测底边连续两次读数一致。几何稳定不再能替代文字渲染完成:矩形早已停在最终位置、文字仍在淡入(alpha 0.1 / 0.5)时永远不算就绪,面板不会出现。alpha 不可读也不算完成;alpha 恰好 0.99 算,0.98 不算。
  • ResultEdgeGate(页脚按钮 / 成功页完成区上界)只对「真的被读到两次一致值」的边回答就绪。预算不再是许可:SettleAttempts = 120 只报告 Exhausted,让调用方改走有界节流;一条从未交出真实几何的边,跑多少帧都不会变成 ready,因此不会再出现「120 帧后拿 0.66H 猜条带」这条回到约 11px 小板并锁死的路径。
  • 失败页:FailureStrip 在标题门未通过时直接返回 false;页脚门由 ButtonSettled → Footer.Observe 统一。两者任一未就绪即不落位、不隐藏原版结算区块(ResultBoardLatch.HidesVanilla(_placed) 保持),原版失败页原样留在屏幕上;一旦真实锚点稳定可测就落位,不需要 F8。
  • ResultRetryCadence(ResultsPagePolicy.cs):等待期间前 120 次读数逐帧测量(页面自己的 reveal 就在这段里),之后每 30 帧复测一次,避免每帧遍历页面 renderer 与按钮;Forget() 在换页、F8 重新打开、配置变更与每次自动重测时重置。节流只决定「要不要再看一眼」,绝不回答「就绪」。
  • 成功页:保留原生 BlinkIn 就绪(CompletionReadiness,无超时旁路)、ResultsUpperBound.Decide 的延迟落位、bottomAligned: false 的上界下悬挂与 1.19.11 的 Fit 拒绝闩锁;新增的只是「完成区上界连续两次读数一致」与同一套页脚门——任一锚点未就绪时不显示任何临时尺寸,原版页面保留。
  • 落位后页面明显变化(别的模组重排页脚、分辨率变化、完成区可选部件晚到)时自动失效并重新 Fit:以落位时真正用到的条带上沿与可用高度为基准,超过 max(12px, 8%) 且连续两次采样才触发,每页最多 2 次,因此不会每帧抖动、也不会持续全布局重建,不需要 F8。
  • BoardStrip 增加 AnchorMeasured;「一次二次测量」(ResultAnchorRecovery)的判据改为「按钮是否实测 + 上界是否已稳定 + 该按钮自己的高度是否可读」。由于严格门下不会再从不完整条带落位,实际可触发的只剩「下界来自页面已绘制按钮带、拿不到具名按钮高度」这一种精度问题;其余情况它退化为精度兜底,而不再是主力路径。
  • 失败页新增 OnEnable 前缀 ResultsDisplay.BeginFailurePage:游戏在整个会话里复用同一个结算页对象,没有它第二次远征的失败页会继承上一局已经花完的 settle 状态与节流计数。F8 只清 placement(ResetPlacement + 节流重置),不清 settle 状态,因此按钮缺失时 F8 也无法绕过门直接画出小板。
  • 回归用例 tests/GTFOCombatPanel.Smoke/Release11912Scenarios.cs:直接驱动生产 ResultTitleGate(矩形稳定 + alpha 0.1/0.5/0.98 多帧 → 永不就绪;alpha 0.99 + 同一边两次 → 就绪;alpha 不可读、矩形读不到 → 不就绪)、ResultEdgeGate(120+ 帧缺失 / 一直抖动 → 永不 ready 且 Exhausted;恢复稳定读数 → 就绪)、ResultRetryCadence(逐帧窗口、每 30 帧、Forget、帧计数重启)、失败页逐帧重放(第 42 次读数、标题满不透明且按钮稳定后才落位,字号 15px;首测只会得到 11px)以及「无真实锚点 → ResultBoardLatch.HidesVanilla 恒为 false / 原版保留」。文件末尾的 WIRING: RESULT_PAGE_SETTLE 是静态接线检查,明确不是运行时证明。
  • 与 FX.0 无关:用户提供的 FX.0 日志来自另一个 profile、没有本模组加载记录与任何本模组帧,本版不声称修复任何 FX.0 闪退,FX0 结论未改动。
  • 验证(2026-10-29):scripts/Verify.ps1 对本机 r2modman Survival profile 的真实 BepInEx / Interop 程序集运行,VERIFY: PASS:构建 0 警告 / 0 错误,PASS: 2136 combat-panel assertions(1.19.11 时为 2039),另 9 配置监听 / 50 补给归属 / 38 扫描重放断言,SCOUT_HOOK_SAFETY、EWC_API_CONTRACT、各 wiring 检查(含 RESULT_PAGE_SETTLE,其中新增「超时不得变成 ready」与节流接线断言)全 PASS;构建出的 GTFOCombatPanel.dll 为 377856 字节,SHA-256 9CA9399CC850F37625754A4276555AA934D51E5CB12F472A19D78A1A6D4631CC。只有编译与托管重放验证:两个结算页在真实分辨率下的观感没有实机证据。

1.19.11

  • 修掉结算板的空页缺陷:ResultsDisplay.Prepare 在 Fit(...) 之后无条件写入 _placed = true; _placedSucceeded = succeeded;,即使 fit 已经因为「条带装不下这块板」走 fit-failed 分支直接返回(ResultsDisplay.Fit)。Refresh 随后看到 _placed 就调用 ApplyVanillaBlocks 隐藏原版结算区块,而 Draw 在 _grid == null || _content == null 时立刻返回——那一帧既没有原版结算块,也没有本模组的板子。
  • Fit 现在返回「是否真的落位」(只有 ResultBoardHold.TryPlace 成功、或既有的 _holdValid 保持分支为真),Prepare 再把该结果连同「网格与内容是否就绪」交给新的无 Unity 策略 ResultBoardLatch.Placed(fitConfirmed, gridReady, contentReady),只有它为真才写入 _placed。Refresh 的隐藏动作改经 ResultBoardLatch.HidesVanilla(_placed):Fit 失败时保留原版结算块并等待重试,失败帧不隐藏任何原版内容。
  • 重试沿用既有 attach 路径:ResultsDisplay.NeedsAttach() 改走 ResultBoardLatch.NeedsAttach(_placed, _placedSucceeded == succeeded),未真正落位的页面下一帧重新测量并再次尝试落位,因此「当帧装不下」只损失一帧。
  • 成功路径未改:TryPlace 落位与 _holdValid 保持、按完成区块上界悬挂(bottomAligned: false)、ButtonClearance 按钮保护、行内容、评分、字号搜索与同步协议(v8)全部保持原样;没有任何布局重构。
  • 回归用例 tests/GTFOCombatPanel.Smoke/Release11911Scenarios.cs:用真实 ResultBoardHold.TryPlace 复现「274px 条带 vs 450px 板」被拒 → 未标记落位、不隐藏原版块、仍要求 attach;同一块板放进 511px 条带(脚注按钮 953/1080)→ 正常落位并应用既有隐藏策略。文件末尾另有一条静态接线检查(WIRING: RESULT_BOARD_LATCH,读生产源码确认调用顺序),它明确不是运行时行为测试。
  • 与外部整合包 FX.0 关卡日志无关:该日志来自另一个 profile、未加载本模组、没有任何本模组帧,也没有原生 crash dump。本版不声称修复任何 FX.0 闪退,也没有改动 FX0 相关结论。
  • 验证(2026-10-27):scripts/Verify.ps1 对本机 r2modman Survival profile 的真实 BepInEx / Interop 程序集运行,VERIFY: PASS:构建 0 警告 / 0 错误,PASS: 2039 combat-panel assertions(1.19.10 时为 2012;新增用例覆盖 Fit 被拒 → 保留原版块 → 下一帧落位),另 9 配置监听 / 50 补给归属 / 38 扫描重放断言,EWC 契约、Scout hook 安全检查、显示投影、结算工具行投影、上界接线、失血目标接线、hello roster 竞态接线与 GTFO TMP 字体接线全 PASS。协议仍为 v8。只有编译与自动化(托管重放)验证:结算页在该窗口尺寸下的实际观感没有实机证据。

1.19.10

  • The success board's upper bound is measured from the pixels the completion section really draws. Each part - the sector box, its header and the four cleared-sector icons - is read as a CompletionPart (present, active, visible alpha, its own container rect and the deepest renderer actually drawing under it), and CompletionBounds takes the deepest visible part. A sector icon the page switched off, a part still at zero alpha and a null field contribute nothing; a part that draws anything is measured from what it draws, and a padded container rectangle is only the fallback for a part that draws nothing measurable. Bottom() used to check Renderer.enabled alone, which stays true on a deactivated icon, so hidden prefabs and padded containers stretched the "completion section" past what the player sees - the empty band reported under the sector name and icons.
  • The board now hangs from that upper edge (ResultBoardHold.TryPlace(..., bottomAligned: false)): its top lands on the completion section's measured bottom plus the page gap, and the leftover room sits just above the lobby button instead of between the title and the table. The button is still the strict lower bound - a board that does not fit is not drawn at all, and a board already up is lifted onto the clearance line - so nothing may overlap it. Glyph size, row height, column widths, row order, metric content and scoring are untouched.
  • The measurement path itself is fixed too: a world-space renderer - the cleared-sector icons and their captions - is now measured by projecting the eight corners of Renderer.bounds through the camera, instead of being read as "unmeasurable" because its transform carries no RectTransform; RectTransform stays as the fallback for UI elements, and both paths go through the same ScreenBounds sanity rules (behind the camera, not a number, a pixel tall, the whole window: unusable). If not one visible part of the completion section can be measured in a frame, the success board is no longer placed at the historical 0.16-of-the-window top - which sits above those icons and captions - but deferred: nothing drawn, the vanilla page left exactly as the game drew it, asked for again next frame (ResultsUpperBound.Decide). The button remains the strict lower bound.
  • The failure page's spacing is now defined by its own native title: FailureAlignment.Resolve prefers this frame's measured bottom edge of CM_PageExpeditionFail.m_missionFailed_text (world-space TMP: component.transform.TryCast<RectTransform>(), CM_PageBase.Camera first, Renderer.bounds before the text box) and the title is no longer moved. The completion line remembered from a success page is demoted to the fallback for a frame in which the title cannot be measured yet, and only a session that measured neither keeps the historical fraction. The wait for a title that is still fading in is bounded (TitleSettleAttempts = 120 frames, decided by FailureAlignment.WaitForTitle), so the page always gets its board.
  • The settlement [DIAG] line now reports anchorSource (the part that produced the upper bound, or mission-failed-text / remembered-completion / window-fraction on the failure page) and the real blank bands blankAbove / blankBelow in pixels. Geometry and counts only - no player identity.
  • Sentry column showing data for only part of the squad (display side): ImpactBoard.ToolDetailOf reads the recorded figures first (non-zero SentryShots/Hits/Damage means sentry; likewise for tripmines and the glue gun, and a row with exactly one kind of record is that kind), with the tool's name demoted to a hint (and 炮台 / Turret added to it), so a Chinese, empty or placeholder name no longer swallows recorded sentry data. ResultsDisplay.WeaponOf goes through WeaponRows.For: when one slot holds several rows the real one wins (ArchetypeId != 0, then NameAuthoritative, then one that counted), instead of "the first row of this slot" landing on the (ArchetypeId=0, GearClass) placeholder. The capture side is untouched: ToolStatsTracker.TryTrackSentry's !SNet.IsMaster host gate, the protocol, the sync and the shot-ledger de-duplication all stay.
  • The EX.B "Survival rundown / layout 4444" crash: a mitigation, not a reproduced fix. HealthLossPatches observes every Receive* of Dam_PlayerDamageBase; its postfix is the only detour of this mod anywhere near that call, and HealthLossTargets now excludes exactly one method - ReceiveParasiteDamage - so that method carries no detour of this mod. Every other Receive* and the Setup / OnRevive / OnApplyDamageOverTimeModifier / OnKilledByTankTentacle events still report. The evidence is two same-signature Stack overflow; Repeat 1526 times; DMD<ReceiveParasiteDamage> <-> IL2CPP runtime invoke crashes on one machine (one host, one client) and the correlation with this detour; no callback frame of this mod appears in those stacks, so the causality is not strictly proven and there is no EX.B runtime A/B. Cost: parasite damage alone is no longer sampled for HealthLost (the game's damage is unchanged; attribution may under-count permanently, and that is not a delayed correction: HealthLost credits only a decrease it observes, so a heal, a revive or a maximum-health change landing before the next observation re-bases the watermark upward and that parasite damage is never credited at all). No re-entry guard was added - the loop lives inside the DMD bridge, where the postfix never gets a frame - and nothing in SquadNet, RemoteScanPatches, the Harmony priorities or the networking path moved.
  • The roster race at settlement: SquadNet.Drain() runs before Exchange.SetRoster(), so a hello that arrives in the same tick as the player who sent it can be refused by the previous frame's roster and then wait for the peer's two-second retry. SquadExchange now keeps a hello refused only for being outside the roster (at most 8, 3-second window) and CombatSync re-judges it with the same OnHello policy right after the roster is refreshed. The trust gate is unchanged: the game's own slot list is still the only authority, a packet from outside the current squad is still refused, and neither the wire format nor the two-second retry semantics moved, and the kept hellos are dropped on the explicit expedition/session reset (SquadExchange.ResetForExpedition, called from CombatSync.ResetExpedition) so a packet from the previous run can never be re-judged against the next run's roster. A snapshot that arrives late or from outside the squad is still refused exactly as before.
  • Verification (2026-10-27): scripts/Verify.ps1 against the real BepInEx / Interop assemblies of the local r2modman Survival profile printed VERIFY: PASS - 0 warnings / 0 errors, PASS: 2012 combat-panel assertions (1929 at 1.19.9; the new cases cover the projected-corner judgement, the deferred placement when the upper bound cannot be measured, and the expedition reset emptying the kept hellos), plus 9 config watcher, 50 supply attribution and 38 scan pipeline replay assertions, the EWC contract and bridge, the display projection, settlement-tool projection and GTFO TMP font wiring checks, and four new wiring checks (RESULT_BOARD_UPPER_BOUND, SETTLEMENT_TOOL_DATA_FIRST, HEALTH_LOSS_TARGET_WIRING, SQUAD_HELLO_ROSTER_RACE). Protocol stays v8. Compile and automated (managed replay) verification only: the upper bound and the failure page's spacing have NOT been looked at in game, the sentry column has no in-game numbers yet, and EX.B has not been re-run - the crash change is a mitigation.

1.19.9

  • The wheel's hit rectangle is re-earned on every repaint. ResultsDisplay.Draw now starts with InvalidateHitForRepaint(), which drops the previous frame's rectangle, its validity and the page kind it belonged to; only a frame that really draws the board writes them again. Before this, a frame that drew nothing - no grid yet, a strip that refused the placement, a page switched off - left the old rectangle standing, so a pointer resting where the board used to be still had its wheel consumed by this board and rescaled a table that was not on the screen. Such a frame now leaves the wheel to the game.
  • The same-frame gesture lock survives that invalidation: _hitFrame is deliberately not cleared, and ZoomAt now asks the frame lock before the rectangle. A notch writes the configuration, that write drops the layout cache, and the rest of the same gesture would otherwise find no rectangle at all; it is still consumed here instead of reaching the page underneath, and one gesture is still answered once.
  • F8 off, a page change and leaving the results screens still take the full reset (ClearHit(), frame lock included), so a hidden board is never a wheel target. Nothing else moved: protocol, damage attribution, scoring weights, squad sync, the column-width contract and the placement policy are unchanged.
  • Verification (2026-10-26): scripts/Verify.ps1 against the real BepInEx / Interop assemblies of the local r2modman Survival profile printed VERIFY: PASS - 0 warnings / 0 errors, PASS: 1929 combat-panel assertions (1926 at 1.19.8; the new cases cover the dropped rectangle and the surviving frame lock), plus 9 config watcher, 50 supply attribution and 38 scan pipeline replay assertions, and the EWC contract, display projection, settlement-tool projection and GTFO TMP font wiring checks. Compile and automated (managed replay) verification only: the board's placement and the feel of the wheel have NOT been checked in game.

1.19.8

  • The success page's board now takes its lower bound from the button's own geometry instead of the historical fraction of the window. ResultsRegion.ResolveLobbyEdge asks, in order, for the rectangle of the control the player can really press, then the same control's laid-out rectangle while the footer is still revealing from zero alpha (active in the hierarchy, alpha deliberately ignored), then the highest drawn button of a page whose own control cannot be read by name, and only then nothing, which leaves the caller on its fallback. The case this fixes: the strip used to be resolved before the footer had faded in, the button read as absent, and a 30-row table could only be drawn at 6px.
  • A board that was placed against that fallback is re-measured exactly once when the button's own rectangle becomes readable (ResultAnchorRecovery, one retry per page, never a per-frame re-measure). A board whose strip was measured is not moved by it, so a settled table still never jumps.
  • The settlement board's glyph size is now wheel-scaled, with one preference per page: [Results] SuccessFontScale and FailureFontScale, 1.0 by default, range 0.5-2.0, one notch = 0.1. The scale multiplies the size the page's own strip produced and can only ever reduce it - a value above 1.0 is clamped back onto the strip's own size - and the result is then measured against the real strip height and page width, stepping down until it fits and never below the absolute readable minimum (ResultsZoom.ScaleSize). Only a wheel over the board is accepted, and it is written back through the same controlled save F8 uses, with our own save suppressed from the watcher; a wheel resting on a bound writes nothing.
  • The board is drawn at the width its own cells measure again: column 1 (player name / row label) keeps the width its own text asked for, columns 2-5 all take the widest of their own measurements, and the page width is only a ceiling - the leftover width goes to no column. That is exactly the width contract 1.19.4 defined, now in one place (PanelGeometry.SettlementColumnWidths); the full-width step 1.19.7 shipped, and PanelGeometry.FillWidth with it, is gone.
  • Verification (2026-10-26): scripts/Verify.ps1 run against the real BepInEx / Interop assemblies of the local r2modman Survival profile printed VERIFY: PASS - the plugin builds with 0 warnings / 0 errors, PASS: 1926 combat-panel assertions (1857 at 1.19.7; the new cases cover the column-width contract, the button geometry with its single retry, and the wheel), plus 9 config watcher, 50 supply attribution and 38 scan pipeline replay assertions, and the EWC contract, display projection, settlement-tool projection and GTFO TMP font wiring checks. Compile and automated (managed replay) verification only: the runtime and gameplay appearance - the success strip's real height, where the board actually lands, how the wheel feels - has NOT been checked in game.

1.19.7

  • 结算板的下边界锚定「送所有囚犯回大厅 / 返回大厅」按钮本身(失败页 m_btnGoToLobby、成功页 m_btnLeaveExpedition),失败页再与「重开检查点」(m_btnRestartCheckpoint)取更高者:板子不压住这两个已命名按钮中的任何一个;只有两者都测不到时才回落到页面上所有已绘制按钮的最高上边缘(这条带本身就包含这两个按钮)。而页面上更高、本模组叫不出名字的控件不再削减板子高度。
  • 字号与行高按「该按钮上边缘 − 条带上边缘」这段真实间隙搜索:字号由这段间隙得到,最高到条带的 96%;到达条带的 92% 或撞到页面可用宽度即停止;测量不可用、或连可读下限都装不下时保留原字号,由既有 fit 兜底。1.19.5 那种「种子字号本身就超出条带、于是被静默缩到极小字号、按钮上方留一大片空白」的现象不再出现。
  • 恢复结算板铺满页面可用宽度:PanelGeometry.FillWidth 重新作为最后一步,多余宽度只摊给右对齐的数值列,标签列宽度、行高、字号与表格高度都不变。
  • 扫描「累计」不再由读不到的 m_tagging 垄断:CreditExecutedScanTag 在每次原生执行确认前开一个新窗口(ScanSession.BeginConfirmWindow 只清这一轮的已标记集合,不动 _countedThisSweep),因此本机的行与主机上客机玩家的行都按「每次确认记一次」累计(同一敌人被重复标记各计一次),而「首次」仍是全局唯一揭示数,「扫描次数」不受影响。面板只在「累计 ≠ 首次」时才打印第三段——此前两列恒等,是同一个事件源同一个去重键。
  • 胶枪「命中」现在必须对应一次已计数的发射:敌人近战砸已经胶住的门不再计入「命中」(此前它没有上限、也不对应任何发射,这正是归档里「射击 435 / 命中 516」的来源);SyncProtocol.Combine、CombatMerge.ApplyRemote、SettlementRows.Retain 三处改为一律以来源侧的 GlueShots > 0 整组取源(射击为 0 表示这一侧没有可用的胶枪样本),不再把两台机器的数字拼在一行;本机自身那一行也不会被远端快照改写,结算缓存另加「射击更多的一组胜出、相等则保留现值」的单调规则。
  • 结算板的字号下限与单元格高度仍取「返回大厅」按钮的实测高度(0.15 × 按钮高度,限 6–32px)与该按钮上沿;按钮高度不可测、或条带连可读下限都装不下时,退回 6px 绝对下限重新算一次(ResultsFontGrowth.FitBoardIntoStrip),绝不因为抬高下限而不画:旧行为的下限就是 6px,兜底后不会比它更差(28 行 / 470px 条带 / 按钮 120px 这一例按 6px 下限算回约 10px 的表格,而不是整块消失)。
  • 本版同时包含尚未发布的 1.19.6 两项(客机伤害兜底、rundown 名称解析),不是只有版面改动。本版只有编译与自动化断言、未在游戏内实机验证(scripts/Verify.ps1 的 VERIFY: PASS:构建 0 警告 0 错误,PASS: 1857 combat-panel assertions,另 9 配置监听 / 50 补给归属 / 38 扫描重放断言)。协议版本(v8)、伤害归因、计分权重与存档格式未改动;本轮改动的是扫描窗口的计数边界、胶枪三元组的取源与单调规则,以及结算板装不下可读下限时的字号兜底。

1.19.6

  • A client whose host runs no panel of its own now keeps its own damage figure when the game applies the blow elsewhere. The bullet and melee limb hooks pass the weapon's carried damage into the local fallback, which is used only when the health across the call did not move, capped by the health the limb still had.
  • A readable health loss still wins, so the 1.19.0 armour / precision reading is unchanged, and the fallback stays an estimate (~; layout, total and share formulas untouched). The one-shot diagnostic now prints healthBefore, healthAfter, dam and amount, which separates "the client is never told the blow" from "the limb was already empty".
  • The settlement rundown key resolves a local progressive rundown: the game exposes one only as the synthetic Local_<id>, so the archive now writes the name of the loaded RundownDataBlock when it can be read. Every other key, and a local id whose block cannot be read, is written exactly as the game gave it.
  • A limb blow whose game call throws is no longer estimated from the weapon's carried figure: the finalizer keeps the same exception and records nothing local for it.
  • The host guard is untouched: a host, and a client whose host runs this build, still record nothing locally, so an installed host cannot be double-counted. Protocol stays v8.
  • Verification (2026-10-24): the current scripts/Verify.ps1 was re-run with SDK 6.0.428 against the real BepInEx / Interop assemblies of the local r2modman Survival profile and printed VERIFY: PASS: the plugin builds with 0 warnings / 0 errors, PASS: 1778 combat-panel assertions (including the new fallback-rule and estimate-marker cases), 9 config watcher, 50 supply attribution and 38 scan pipeline replay assertions pass, and the EWC-bridge, display-projection, settlement-tool and GTFO TMP font wiring checks pass. This is compile and automated (managed replay) verification, not an in-game run: nothing here confirms that a Local_<id> key resolves to the loaded RundownDataBlock.name, and the live figures on a client behind an unmodded host are still unverified. No profile install and no publication.

1.19.5

  • Observe actual native tag requests and ToolSyncManager.DoTagEnemy execution, rather than matching late tags against per-frame candidate lists. Host validation captures the original sender without requiring a remote first-person EnemyScanner instance.
  • Queue confirmation values for main-thread resolution and counting; reject exceptions, unowned, expired and old-run events. Client pairing is estimated because broadcasts carry no scanner identity; host fallback counts are also estimated without sweep IDs. Missing receive senders never fall back to a concurrently scanning host.
  • Correction to 1.19.4: its candidate-snapshot fallback did not resolve the reported in-game scan loss. The previous claim that every StatDisplay damage difference is caused by an unmodded host was not established and is withdrawn. Damage code, actual-health-loss semantics, layout and protocol v8 are unchanged.
  • Add managed replay tests linked to the production scan patches and run them in Verify. Survival Test gameplay feedback confirms scan data for two bots; human Steam multiplayer, late client confirmation and remote fallback still require dedicated in-game verification.

1.19.4

  • The settlement board is drawn at the width its own cells measure instead of being spread across the page. The four player columns keep sharing one width - the widest cell of any of them - and no longer receive padding the figures did not ask for.
  • A client's own scan mark is applied by the host and only reaches that machine after the sweep returned. The late confirmation is now credited from the sweep this machine itself watched (the candidate list it recorded, inside a tag-duration window). Hosts keep attributing remote marks to their sender inside the native packet scope; nothing is attributed by proximity or by an unnamed guess. This is what left a Bio Tracker row showing Scan —.
  • Damage semantics are unchanged: every figure is the health the enemy actually lost. StatDisplay falls back to the weapon's carried damage - before the limb's armour and the precision multiplier - on a client whose host runs no StatDisplay, so the two panels can differ by a few thousand on the same run. That difference is a definition, not a loss, and this build does not adopt it.
  • Protocol is still v8; 1732 panel, 50 supply and 9 config-watcher assertions pass on both profiles, Release 0 warnings / 0 errors, EWC 4.13.4 contract checked. Native startup, the late-tag path and Steam multiplayer still need an in-game run.

1.19.3

  • Startup crash hotfix: remove all ScoutDetection/ScoutScream Harmony detours and the ES_Base-to-enemy reverse lookup. The 1.19.2 crash log enters that callback during JSON loading and fails in native Unity object comparison; shared native code is suspected, not RVA-proven.
  • Retain read-only Scout sampling from typed damage contexts. Charge-up is still sleeping; an observed Scream/Response/Done phase latches awake for that spawn. Screams outside those samples can be missed; stability takes priority over the removed callback coverage.
  • Verify the compiled DLL contains no Scout state detours or reverse owner lookup, in addition to managed regression tests. Native game startup and Steam multiplayer remain unverified; protocol v8 is unchanged.

1.19.2

  • Decode GTFO-API payloads using the CombatPanel frame length rather than trailing buffer capacity. Learn routes only from decoded packets and expire route hints after 10 seconds.
  • Keep detached settlement rows for the current expedition, refresh late data, and retain known cumulative values across transient empty projections and departing slots. Unobserved total damage is unavailable rather than zero; observed weapon subtotals can supply a marked lower bound.
  • Bind EWC explosive, shrapnel and DOT pre/post damage events without an assembly dependency; measure actual health loss and subtract native credit to avoid duplicate damage. Attribute delayed damage using the original pCWC slot.
  • Remember ammo pack givers at GiveAmmoRel and pair replicated applications by receiver and shares. Do not treat the forwarding host or a stale sender outside receive dispatch as the giver. Add native receive-scope confirmation for remote tracker tags.
  • Scout sleeping kills mean no actual scream yet: patrol, tendril contact and charge-up remain sleeping; DoStartScream latches awake for the rest of that spawn. Other enemies retain their existing sleep rules.
  • Independent review fixes: only the host awards lone-survivor credit; repeated sync diagnostics are bounded and deduplicated per expedition; retained kill groups keep their legacy subset fields together.
  • Protocol remains v8. Native callbacks, Steam multiplayer and in-game rendering have not been verified in this round; this is a local candidate, not an automatic installation or publication.

1.19.1

  • Squad sharing works again, which is what 1.19.0 broke. 1.19.0 refused to send anything - a greeting included - to a player it had no proof about, and required either a named SteamID64 or a packet from the other side first. Two machines that both waited therefore shared nothing at all, which is exactly what both logs from that session say: not one packet heard from any of them, on the host and on the client. This build shares with every player in the slot list by default, over the transport the rest of the GTFO modding scene already broadcasts on (GTFO-API's free-sized network event), whose bytes carry their own key, magic and version signature and are dropped by a machine that runs no mod without a handler being asked for one. A client also sends its own row straight to the other clients, so two clients no longer depend on what a host could measure about them.
  • [Squad Sync] ShareWithEveryPlayer (true by default) turns that off for a lobby that should stay restricted; [Squad Sync] AllowlistedPeers is then the way in, exactly as before. Both are re-read while the game runs, and a configuration file that cannot be read falls back to sharing rather than to silence.
  • The transport is chosen per machine and confirmed per peer: GTFO-API's event when the library is loaded, the game's own byte channel when it is not, and until a peer has answered the packet goes out both ways so a squad that mixes the two still pairs up. Which route a peer actually used is remembered, so the duplicate costs one packet per peer rather than one per second.
  • Blood loss is measured from the health a player actually lost, not from the health one damage method happened to move. A watermark per player is fed by every path that reports a figure - the damage entry points, the replicated health update a host sees a client's blows through, the revive, the spawn that sets them up - and only a decrease against an unchanged maximum is credited, once, however many paths report it. A maximum that moves in either direction is not blood loss, and a host now shows blood loss for a player it never applied a blow to. This is the "blood loss missing on the host" and the "blood loss wrong" report.
  • The settlement board is sized by measurement instead of by a multiplier: the table is measured at a candidate glyph size and the size is bisected up until it fills 92-96% of the strip between the completion section and the return-to-lobby button, or until it reaches the page's width or 3.5x the in-level size. On a 2560x1440 success page that is a table of roughly 1960x880 spread to the full 2512px width, where 1.19.0 drew about 600x370 in the middle of the same room. Columns 2-5 still share one width, the widest of them.
  • A squad packet queue that overflowed emptied itself and then stayed latched at the overflow count, which cost every later packet of the session; the bound now drops the oldest entry and nothing else.
  • The 说明 line under each 评价 is customisable too, in the same scoring.json. Reasons carries one template per settlement sentence, with the figures available as placeholders ({damage}, {share}, {count}, {kills}, {deaths}, {uses}, {firstMarks}, {weakspots}, {hits}, {shots}, {efficiency}) - each key accepts only the placeholders that sentence actually has. A template left exactly as shipped keeps following the language setting, an unknown placeholder or an over-long line makes the file invalid and the previous valid configuration is kept, and an empty template prints no explanation for that row.
  • Protocol is unchanged from 1.19.0 (8), so the figures are read the same way, but 1.19.0 still sends nothing: both machines have to be on 1.19.1 to exchange anything.
  • Verification: 1602 combat-panel assertions and 9 config-watcher assertions pass on both profiles, Release 0 warnings / 0 errors, EWC 4.13.4 API contract checked against the installed assembly. No in-game verification this round: Steam co-op, native callbacks and Unity rendering are not covered by these results.

1.19.0

  • Weapon damage is now the damage the game actually applied, not the figure the weapon carried into the strike: the armour of the limb that was hit and the precision / weakspot multiplier are included, for bullets, melee, explosives and EWC's delayed, burst and damage-over-time weapons alike. A client whose host runs no panel of its own reads the enemy's health before and after its own blow, so its row no longer reads low against a host that does run one.
  • Nothing is put on the wire for a player this machine has no evidence about - a greeting included. A peer is spoken to when its SteamID64 is named in [Squad Sync] AllowlistedPeers, or after it has itself sent this machine a packet of this build's protocol; a machine that runs no mod, a bot and a stranger are never handed bytes they have no handler for. Leave the list empty and the panel shares only with players who speak first, so two modded clients behind a host that runs no mod pair up when one of them names the other.
  • This build speaks protocol 8. A mixed lobby stays silent on purpose rather than decoding each other's fields, so both machines have to be on 1.19.0 to exchange anything.
  • Only the melee and Scout (tentacle) rows carry a sleeping-kill figure, each with its own multiplier (SleepingMeleeKillMultiplier / SleepingScoutKillMultiplier, default 2, range 1-10). The three weight classes (minor / elite / BOSS) score and display their total only and no longer take a sleeping multiplier; SleepingKillMultiplier is kept as the fallback for the two new fields when the file omits them.
  • The melee and Scout rows are three columns - total kills, the part landed on a sleeping enemy, and the damage that went into them. The Scout row's damage now travels with the row instead of being counted only on the machine that dealt the blow.
  • Every settlement cell uses one separator, /, with the unit or the meaning moved into the row label; %, ~ and — are unchanged. Settlement columns 2-5 share one width, the widest of them, so the numbers line up down the board.
  • The success page's board now runs from the completion section down to the return-to-lobby button the player can actually see: a control the page switched off, faded to zero alpha, or laid out past the window no longer costs the board its height.
  • A result page's footer animates in, so the board waits for the button edge to settle before it measures - bounded, so a hover that keeps nudging the edge cannot hold it back for ever.

1.18.1

  • Same code as 1.18.0. The 1.18.0 upload was recorded by the legacy upload handler as NSFW content, which keeps a package out of the community listing and out of search; this version is published with the flag off. Download the 1.18.1 package.

1.18.0

  • Settlement titles are JSON-configurable, each with its own description: total damage, minor/elite/BOSS kills, melee and Scout kills. A player takes only a metric they lead the squad in outright, that is enabled, weighted above zero and has real data; the choice is the candidate with the largest actual contribution to the final score, negative metrics by their real severity. A tie or an all-equal squad awards nothing, and no qualifying candidate falls back to the neutral title. The old ZeroScore bypass is gone, so a displayed zero no longer overrides the top rule.
  • Kill cells gain a sleeping / awake / unknown split of the same count, in the existing cell, with no new metric and no new row. A sleeping kill is worth twice an awake one of the same class by default (SleepingKillMultiplier, 1-10). The state is read from the enemy before the killing blow; a state that cannot be read stays unknown and never counts as awake or asleep.
  • Supply quality and downs cells now carry this player's own figures: the supply uses whose benefit actually reached that player and how many were ammo packs, and the health actually lost over the run. Giving a pack away is not a use by the giver, a full-health pack with no benefit is not counted, healing never offsets the loss, and a maximum-health change is not a loss.
  • Default weights: LoneSurvivor 1.60 and Stagger 1.40, the rest unchanged; thirteen defaults sum to 9.68. Kill tier values stay 0.5 / 1 / 1.5.
  • Kill attribution: a death notified before the damage postfix is settled from the health that was actually lost, so a melee killing blow keeps its cause and a second death callback cannot count the same corpse twice. An enemy pooled before its damage component existed no longer carries its predecessor's credited state.
  • Sentries: a round is one FireBullet scope, and a native OnBulletFired raised inside it is that same round; outside every scope the notification is the only evidence and counts. max(cumulative calls, cumulative events) is gone, so switching stream or losing one no longer eats rounds.
  • Tripmines: reset clears the counted-mine set too, a consumed mine recovered from the field no longer reports a tool recovery, and a tracked placer beats a conflicting detonation core. Glue projectiles credit the owner from Fire and reset per pooled projectile, so a reused projectile cannot inherit the previous hit.
  • Scans: a taggable list is not a completed scan. A first mark needs the native tagged state or a rising tag timer, bot windows close with their action, and a throttled bot scan keeps the window it actually had.
  • Sync is protocol v7 with a 352-byte snapshot: the kill-state split, the self-consumption pair and health lost travel with the row. A kill total and its split are one block and are taken from one machine, so a cell can never print a subset larger than its count. The squad total is held once with the run counter it came from instead of being re-read from whichever cached row was visited last. Older peers are refused by version, not silently half-read.
  • Verification: 1514 combat-panel assertions and 9 config-watcher assertions pass on both profiles, Release 0 warnings / 0 errors, and the EWC 4.13.4 API contract is checked against the installed assembly. No in-game verification this round: native callbacks, Steam co-op and rendering are not covered by these results, and the reconnect "otherworld" report is not proven or excluded.

1.17.31

  • Settlement board sizing: the failure and success boards measure the buttons they must clear only when those buttons are actually shown. The failure page keeps an unshown restart control above the lobby button, and reading it cost 143 px of height at 2560x1440; both screens now share the same rule.
  • Settlement board keeps a stable size: the first placement waits for the footer to stop moving, so the table is measured once against the settled page and never resizes on a redraw or F5/F8 toggle.
  • Column spacing tightened (0.625em to 0.436em between neighbouring columns); rows are at least as tall as the font's own line, so glyphs are no longer clipped top and bottom.
  • Sentry hits no longer collapse to one: the turret reports damageSearchID = 0 for a whole run, and keying on it recorded 33 shots as 1 hit. A zero id now counts each recorded damage event; a real id still deduplicates.
  • Player titles: a reverse metric (friendly fire, downs) never becomes a consolation title, and only the squad's outright worst earns it. Friendly-fire title is now "敌我不分"; the neutral fallback is "平平无奇".
  • EWC projectile weapons (涡轮电浆枪): hits were lost in 1.17.18 because the whole EWC bridge was compiled out of the shared DLL. A dependency-free runtime bridge now binds ExtraWeaponCustomization's public spawn/hit/post-hit/destroy events by name; original owner and weapon are frozen at spawn, and weakspots come from the actual limb.
  • Tripmines: a bot's deployer exposes no first-person face, so every bot mine was refused and the deployment column read zero. Mines are now attributed by gear category, and a carried-in (consumed) mine credits its placer's damage without touching deployment or trigger counts.
  • Melee kills: the kill cause came from whatever last touched the enemy, so a console kill or a recycled pointer could report dozens of melee kills against zero melee damage. Only the blow that emptied the health bar names the cause now.
  • Verification: 1279 combat-panel assertions and 9 config-watcher assertions pass on both test profiles, Release 0 warnings/errors, and the EWC 4.13.4 API contract is checked against the installed assembly. Live gameplay on success screens and in multiplayer is still unverified.

1.17.18

  • Fixes settlement tool rows rendering as dashes: ResultsDisplay.BuildReport now carries the equipped GearClass name into ImpactInput.ToolName, allowing observed scan, sentry, tripmine and glue details to reach their existing formatter. No counters or scoring sources were changed.
  • In-level and settlement panels now borrow a CJK-capable source font from GTFO's TMP default/fallback assets when available; both surfaces use the same renderer. Dynamic GTFO face-family and Windows CJK font fallbacks are logged if the TMP asset has no source font.
  • Both profile Verify runs pass: 1203 combat-panel assertions, 9 config-watcher assertions, Release 0 warnings/errors. GTFO Client Mods release; live font selection and result-screen rendering still need a fresh gameplay session.

1.17.17

  • In-level Friendly Fire now shows damage only; friendly-fire-caused down counts remain in settlement scoring and JSON.
  • Accuracy gold and the “Dead eye” title now follow the displayed raw hit rate, while the existing sample-confidence factor still affects composite score. A lower displayed rate cannot take that title via fallback.
  • Tool Efficiency details now show actions and values without repeating Sentry / Tripmine / Glue labels already present in the gear row.
  • Adds MineDeployerInstance.OnSpawn equipment-mine registration, requiring a non-consumable equipped deployer and matching nonzero gear category. Re-registering an existing item can attach its late detonation-core alias without double-counting deployment.
  • Adds MeleeKills and ScoutKills as separate settlement metrics; melee kills use the last melee-damage cause, Scout identity uses EnemyAgent.IsScout, and both retain death-hook de-duplication.
  • Settlement melee cells display the existing GearMelee cumulative damage as context; it is not scored again. Push interruptions do not count as melee kills.
  • Adds two four-character awards (近战强袭, 触手猎手), thirteen default weights summing to 8.55, legacy custom-weight migration and snapshot protocol v5.
  • Tool action text/data remain settlement-only; the in-level tool UI is unchanged. The shared local candidate is built without the optional EWC bridge because the Test profile has no EWC; no package was published.
  • Local candidate; test/build/install verification and remaining live-run boundary are recorded in reports/release-1.17.17.md. Not published.

1.17.16

  • Shows scan attempts and first-mark counts only for an equipped scanner; other tools no longer display scan placeholders. Tool Efficiency scoring continues to use scan first marks.
  • Resolves reusable mine identity through both the spawned item and detonation core. Mine blast damage uses the game's core owner, falls back to a registered equipment-mine ledger owner when needed, and never falls back to a recent unrelated attack when unresolved. Consumable pickups remain excluded from equipment-tool counters.
  • Settlement labels are left-aligned and every player/data column shares a right edge; the in-level table layout is unchanged.
  • Profile-specific Verify passes: 1174 combat-panel assertions, 9 config-watcher assertions, Release build 0 warnings/errors, with the optional EWC bridge absent on Test and present on Survival Test. Live mine/blast and settlement-render verification remains pending. Local test candidate; not published.

1.17.15

  • Adds eleven weighted settlement metrics, including separate friendly-fire damage and friendly-fire-caused down penalties. Down attribution requires a confirmed alive-to-downed transition.
  • Renames the scan-derived scoring row to Tool Efficiency without changing its first-time scanner-tag source. Adds sentry, reusable tool-slot mine and glue-gun statistics; picked-up consumable mines are excluded.
  • Records Rundown and level name as separate settlement JSON metadata fields. Snapshot protocol v4 carries friendly-fire downs and tool statistics.
  • Verification against the Survival Test profile assemblies: Release build 0 warnings/errors, optional EWC bridge PASS, 1165 combat-panel assertions and 9 config-watcher assertions. This is a local test build, not published; live multiplayer verification remains pending.

1.17.14

  • Fixes client damage/share flicker: SnapshotCache now keeps the latest host and player snapshots independently, then recombines them from their original sources. Repeated equal-rank client rows can no longer erase the host's damage field.
  • Keeps the settlement board and its one JSON file current as accepted rows arrive after the result page opens; unchanged data does not cause repeated writes or duplicate files.
  • Adds B10 for interleaved equal-rank host/client snapshots and archive refresh assertions. Full Verify: 1121 combat-panel assertions, 9 config-watcher assertions, Release build 0 warnings / 0 errors.

1.17.13

  • Removes local combat rows for owners no longer present in the live SlottedPlayers roster, so a departed human cannot survive as a duplicate slot row after a bot takes that seat.
  • Removes departed owners' remote snapshots and handshake state; snapshots arriving after the owner leaves are refused before they can recreate the stale display row.
  • Adds B9 roster-turnover regressions for local-store pruning, same-slot human-to-bot replacement, late snapshot rejection, and retention of the other guest's row. Verify passes with 1118 combat-panel assertions and 9 config-watcher assertions; the Release build has zero warnings/errors.
  • Published via the Thunderstore UI on 2026-10-17 to HawChow/CombatPanel, GTFO / Client Mods. UI returned Success!; the read-only API reports HawChow-CombatPanel-1.17.13, GTFO listing, and the downloaded ZIP matches SHA-256 32A93D5C734FBFD077F97EB2638257388A2B011732919EE010427199E72712DE. The targeted human-to-bot live handoff test remains pending.

更早版本的完整条目已归档到仓库的 docs/changelog-archive.md(不随发布包分发,原因为 Thunderstore 对包内 CHANGELOG.md 有 100,000 字节上限)。