prestonzen/vibecat-speedrunning-framework
GitHub: prestonzen/vibecat-speedrunning-framework
为 NUSGreyHats DEFCON CTF 的 Vibecat 游戏构建的自动化竞速机器人,结合协议逆向、服务端 RNG 预测和锦标赛式参数搜索来持续刷新通关记录。
Stars: 1 | Forks: 0
# vibecat-speedrunner
用于 Vibecat speedrun 的持续机器人+锦标赛+搜索流水线
CTF (NUSGreyHats DEFCON 挑战赛于 `vibecat.nusgreyhats.org`)。运行器会产生 N 个并行的游戏子进程,每个子进程使用一组扰动的参数通关 10 个房间,记录有效的操作,并将获胜的组合反馈到未来的运行中。一个独立的离线服务会对即将到来的 seed 进行评分(预测敌人刷新点 + 视线 + 弹道)并固定最佳的 seed,这样实时运行就不会被“寻找好 seed”所阻塞。
## 快速开始
两个长期运行的进程:
```
# 1. Seed scorer (systemd 服务 — 开机运行;每 30 秒产生一次 CPU 突发)
./install_seedscorer.sh # one-time install (sudo prompt)
# 2. Tournament runner (交互式,使用 Ctrl-C 停止)
./run_matrix.sh # default: 24 concurrent slots
CONC=32 ./run_matrix.sh # override
```
实时视图(在浏览器中打开):
| URL | 页面 |
|------------------------------------|-----------------------------------------|
| `http://localhost:8000` | 锦标赛仪表板 / 槽位网格 |
| `http://localhost:8000/scoreboard.html` | 单房间冠军,完美缝合 |
| `http://localhost:8000/traces.html`| 轨迹查看器 (路径, 偏好, 录像) |
| `http://localhost:8000/stats.html` | 战斗统计矩阵 |
| `http://localhost:8765+N` | 槽位 N 的实时游戏画布 |
## 架构
```
┌─────────────────────────────────────────────────────────────────┐
│ Tournament loop (matrix_run.py + tournament.py) │
│ pick params → spawn bot subprocess → save splits + trace │
│ auto-promote per-room champions → feed back into next pick │
└──────┬──────────────────────────────────────┬───────────────────┘
│ │
│ writes traces/, splits, runs │ reads matrix.db
▼ ▼
matrix.db ─────────────────────────► Web UI (selector_view.py)
▲ │ scoreboard, traces,
│ pinned seeds, prefs │ user paths, prefs UI
│ │
┌──────┴──────────────────────────────────────┴──────────────────┐
│ Seed scorer service (seedset/) │
│ Go simulator mirrors server RNG → LoS + trajectory + prefs │
│ scoring per seed → top-K written to matrix.db.seed_pool │
└─────────────────────────────────────────────────────────────────┘
```
## 文件
### 实时流水线 (repo 根目录)
| 文件 | 用途 |
|-------------------------------|------------------------------------------------------|
| `accounts.json` | 静态账户池 (4 个条目; 其余为动态) |
| `browser_view.py` | 每个机器人子进程的实时 HTTP+画布查看器 (端口 8765+N) |
| `check_accounts.py` | 根据实时服务器验证 accounts.json |
| `clean_wedged.py` | 丢弃在到达房间 1 之前卡住的运行 |
| `install_seedscorer.sh` | seedscorer systemd 单元的一次性安装程序 |
| `lan_proxy.py` | 到仅限 LAN 的游戏服务器的 TCP 代理 |
| `map_rooms.py` | 用 ASCII 打印每个房间的墙壁地图 + 刷新位置 |
| `matrix_run.py` | **锦标赛运行器。** 生成机器人,保存结果。 |
| `path_follow.py` | 读取轨迹;`load_room_waypoints` 将 run_ids/用户路径的逗号列表转换为每个房间的路径点 |
| `path_record.py` | 每个机器人子进程将其自身的移动、射击、命中、击杀、退出时的 HP 记录到 traces/.json.gz 中 |
| `prune_traces.py` | 将每个房间的轨迹数量限制在 top-100 (每保存 100 次运行自动执行) |
| `release_user_path.sh` | 移除用户绘制路径的便捷手机辅助脚本 |
| `room_budgets.py` | budget_hits 记分板的每房间时间预算 |
| `run_matrix.sh` | 选择服务器 + 并发数的封装器,运行 `matrix_run.py --tournament` |
| `selector_view.py` | 大型 Web 仪表板。包含多个页面:实时槽位、记分板、轨迹、统计信息。托管 /userpath/* 和 /seedpref/* 端点。 |
| `tournament.py` | **参数搜索 + 冠军逻辑。** PARAM_SPACE, perturb, pick_path_source, auto_promote_champions, perfect_stitch_ms 等。 |
| `vibecat-seedscorer.service` | seedscorer 的 systemd 单元 |
| `vibecat-speedrunner.service` | 矩阵运行器的 systemd 单元 (可选 — 大多数用户通过 `run_matrix.sh` 交互式运行) |
| `vibecat_walls.py` | 每个房间的静态墙壁 + 路径点 + 敌人刷新数据 |
| `viewer.py` | Pygame 查看器 (browser_view.py 的替代方案) |
| `zenspeedrun_param.py` | **机器人主体。** 协议,asyncio 循环,A* 寻路,武器/商店/金币循环。约 3k 行代码。所有可调参数均来自 `VIBECAT_*` 环境变量。 |
### 子目录
| 路径 | 用途 |
|----------------|------------------------------------------------------------|
| `seedset/` | Go 离线 seed 评分器 (参见 `seedset/main.go` 头部说明) |
| `traces/` | gzip 压缩的 JSON 轨迹,每次运行一个。自动修剪至每房间 top-100 |
| `matrix_log/` | feed.log + 每次运行的日志。可使用 `tail feed.log` 实时查看 |
| `archive/` | 旧的实验性构建 (zenspeedrun_A.py..J.py,计划,临时脚本)。不属于实时流水线 — 参见 `archive/README.md` |
| `source/` | 服务器端 Go 源代码 (只读参考;我们未附带分发) |
## 各部分详细功能说明
### 机器人 (`zenspeedrun_param.py`)
一个单一的 asyncio 进程,其功能如下:
- 向 WebSocket 游戏服务器进行身份验证,启动一次运行
- 维护服务器状态的本地镜像 (玩家、敌人、拾取物、门)
- 并行运行后台循环:输入 ticker (20Hz)、射击、防挂机、武器优化器、金币拾取、商店消费、多武器开火 (仅限房间 10)
- 每个房间:如果存在继承的路径则沿其行走,否则在方向扫描上进入追逐模式
- 在运行结束时,将其移动、射击、命中、击杀、武器切换和退出时的 HP 记录到轨迹文件中
每个数值/分类旋钮都是一个环境变量
(`VIBECAT_KITE_DISTANCE`、`VIBECAT_SHOP_ITEM_1_9`、`VIBECAT_PATH_FROM` 等)。锦标赛会在每次选择时设置这些参数。
### 运行器 (`matrix_run.py`)
`--tournament` 模式会永远循环下去:
1. 调用 `tournament.pick_next_params(conn)` 以获取 `(params, label)`。
2. 将参数作为 `VIBECAT_*` 环境变量转发给新的机器人子进程。
3. 等待其退出;解析日志以获取分段计时 + 结果。
4. 将数据行保存至 `runs` + `splits`;轨迹文件由机器人写入。
5. 调用 `tournament.auto_promote_champions` — 如果任何房间的通关击败了前任冠军,则写入一个 `path_weights:auto` 数据行。
6. 每保存 100 次运行,执行 `prune_traces.keep_set` 并删除不在任何房间 top-100 之列的运行所对应的轨迹文件。
`RampController` 会随着运行证明它们能够通过房间 2,将活跃槽位数从 1 逐渐提升至 `--concurrency`。
在出现连续失败集群时会自动缩减软上限,这样一连串的失败运行就不会向服务器疯狂发送登录请求。
### 参数搜索 (`tournament.py`)
每次选择的模式概率:
| % | 模式 | 作用 |
|-----|---------------------|---------------------------------------------------|
| 30% | stitch-champions | 最佳参数,无扰动,每个房间的冠军路径,强制固定 seed。测试“机器人能否在一次运行中重现所有冠军?” |
| 10% | random | 从 PARAM_SPACE 中进行纯随机抽样 |
| 20% | perturb-elite | 对随机的 top-5 精英进行高斯扰动 |
| 40% | perturb-best + path | 对当前的 #1 进行高斯扰动,并在每个房间混入冠军路径 |
独立地,70% 的选择会固定一个来自 `seed_pool` 的 seed
(排名 × 年龄加权) — 参见 [Seed scorer](#seed-scorer)。
#### 冠军逻辑
“房间冠军”是指该房间通关时间最短(与上一个房间的分段时间差)的运行。过滤条件:
- 必须在防卡死的 `CLEAR_BUDGET_MS` 上限之内
- 磁盘上必须有对应的轨迹文件
- 对于房间 10:必须具备 `outcome='completed'` (服务器会为失败的运行记录一个虚假的死亡时间分段 — 参见“已知数据怪癖”)
- 对于记录了 `exit_hp` 的 1-9 房间:必须 ≥ `ROOM_EXIT_HP_TARGET` (默认 50%),这样缝合的路径才能继承一个可行的后续状态
冠军有 90% 的时间会被确定性复用
(`pick_path_source`)。10% 的时间会探索替代方案,这样更快的路径仍然有机会浮现。
#### 用户覆盖具有 100% 的优先级
手绘路径 (`user_paths` 表,权重 15.0) 和用户固定的运行 (`path_weights source='pin-user'`,权重 10.0) 会覆盖 AI 冠军。只要它们存在,机器人就会严格遵循用户绘制的路径 (8px 的路径点容差,4秒/路径点超时),这样轨迹历史就能反映用户的意图。删除用户路径 (通过 `release_user_path.sh` 或轨迹查看器的删除按钮) 即可将控制权交还给 AI。
### Seed 评分器 (`seedset/`)
Vibecat 服务器基于 `time.Now().UnixMilli()` 来生成其敌人位置和巡逻路径的 seed。预测了 seed = 预测了整个运行过程。
该评分器是一个 Go 二进制文件,它逐字节镜像服务器的 `*rand.Rand` 提取顺序,包括 `NearestNonWall` 对齐,并根据以下内容对每个 seed 进行评分:
1. **可见性 (Visibility)**:从出生点和每房间的 `wave2_point` 评估点看,有多少初始敌人 + 第二波敌人在视线范围内
2. **弹道 (Trajectory)**:(敌人刷新点 → 第一个巡逻路径点) 与 (朝向玩家的方向) 的点积 — 奖励那些敌人向你走来而不是走进角落的 seed
3. **用户方向偏好**:如果敌人的初始方向与用户选择的 N/E/S/W 等相匹配,则为每个 `(room, eid)` 偏好提供加成
每个房间根据其在运行中所占的挂钟时间份额进行加权 (源自 `matrix.db` 的分段计时差值),因此在房间 10 中 LoS 的边际改进其权重约为房间 4 改进的 5 倍。
评分器作为 systemd 服务 (`vibecat-seedscorer.service`) 运行,每 30 秒重新扫描即将到来的 25 分钟 seed 窗口,并将排名前 1000 的 seed 写入 `matrix.db.seed_pool`。锦标赛在选择要固定的 seed 时会读取该池。
### Web UI (`selector_view.py`)
从端口 8000 提供服务的多个页面:
- **仪表板** — 显示每个并发运行的槽位网格,包含 HP / 房间 / 已用时间,以及指向每个槽位画布查看器的 iframe
- **记分板** — 顶级运行,每个房间的冠军,完美缝合目标
- **轨迹查看器** — 每个房间的路径,悬停查看运行信息,点击固定轨迹,绘制自定义路径,设置每个敌人的方向偏好
- **统计** — 战斗矩阵 (武器 × 敌人类型 → 射击/命中/伤害/击杀)
- **回放** — 重新渲染单次运行
## 🏴☠️ 漏洞与利用
Vibecat 是 NUSGreyHats DEFCON CTF 的挑战项目 — 寻找 bug 是其明确的目标。以下是我们在实时机器人中发现并利用的漏洞目录,大致按影响程度排序。包含 PoC 和根本原因分析的正式报告将发布在 **[1337sheets.com](https://1337sheets.com)** (即将推出)。
### 🥇 服务器信任的拾取物认领 (`pickups.go:applyPickup`)
```
case PickupHealthPotion: ApplyHeal(p, 20)
case PickupAmmoCrate: /* +20 ammo */
case PickupCoin: p.Coins++
```
`applyPickup` 是根据**客户端数据包**中的 `claimedType` 进行切换的,而不是拾取物实际的 `Type` 字段。再加上 `HandleInteract` 的 ±2500 像素范围 (房间大小为 1280×768 — 所有东西都在范围内),任何拾取物都可以被认领为任何物品:
- 🩸 将金币拾取物 → 认领为 `HealthPotion` → +20 HP
- 🔫 将生命值拾取物 → 认领为 `AmmoCrate` → 为当前装备的武器增加 +20 弹药
- 💰 将弹药拾取物 → 认领为 `Coin` → +1 金币
机器人的 `coin_grab_loop` 使用一个优先级阶梯 (致命低血量 → 生命值,低弹药 → 弹药,否则 → 金币) 来驱动此过程。每个拾取物都会变成机器人最需要的物品。
### 🥈 同一 tick 的武器切换 (`combat.go:42-92`)
```
if now.Before(p.SwapCooldownEnd) && !sameTickSwap(p.SwapCooldownEnd, now) {
return
}
func sameTickSwap(end, now time.Time) bool {
return end.Sub(now) == SwapCooldownMs * time.Millisecond
}
```
400毫秒的 `SwapCooldownEnd` 检查在 `end - now == 400ms` 完全相等时存在一个例外 — 这在发出切换指令的**同一服务器 tick** 上成立。连续发送 `SWITCH_WEAPON` 后紧跟 `SHOOT`,该 SHOOT 就会绕过切换惩罚。每把武器自身的 CD 依然适用,因此这允许你在单个 tick 上叠加来自步枪 (75 ms cd) + 霰弹枪 (520 ms cd) + 手枪 (150 ms cd) 的伤害。**多武器开火**在 Boss 房间中使用了此技巧 — 启用后,我们机器上的 Boss 通关时间从 5.10 秒降至 3.95 秒。
### 🥉 无冷却的交互 (`pickups.go:HandleInteract`)
`HandleShoot` 依赖于 `SwapCooldownEnd` 进行限制,但 `HandleInteract` 没有。在 `SWITCH_WEAPON` 之后,我们不必等待 400 毫秒的切换惩罚就可以发送 `INTERACT(claimed_type=AmmoCrate)` — 弹药会立即装载到刚装备的武器上。机器人的金币拾取循环过去常为了“安全”而等待 450 毫秒;移除这个等待时间每次跨武器弹药装填大约可节省 ~450 毫秒,这会在拾取物丰富的房间中产生累积效应。
### ⏱ 负时间奖励的竞态条件 (`killlog_common.go:ComputeRoomBonus`)
```
duration := log[len(log)-1].Timestamp.Sub(log[0].Timestamp)
if duration < 0 {
return int64(duration/time.Millisecond) - 500
}
```
房间通关奖励公式:当房间内首次和最后一次记录击杀之间的持续时间变为**负数**时,奖励值为 `(negative_ms) - 500`。此值会被添加到 `TimeAdjustmentMs` 并在每次房间转换时并入 `TotalMs`。
持续时间怎么会变成负数呢?`loop.go:380`:
```
for _, id := range deaths {
go ProcessEnemyDeathAsync(e.room, id) // ← goroutine
e.send(...)
}
```
每次死亡都会生成一个 goroutine,在其*内部*调用 `time.Now()` 并附加到 `r.KillLog`,且**没有加锁**。两种竞态叠加在一起:
1. **Append 竞态** — 并发的 slice 追加,条目可能以任何顺序落地或相互覆盖。
2. **时间戳竞态** — `time.Now()` 是在每个 goroutine 实际运行时捕获的 (取决于 Go 调度器顺序,而非调用顺序)。一个较晚发生的击杀,如果其 goroutine 先运行,最终会获得一个更早的时间戳。
结果:`KillLog` 的顺序和时间戳发生偏移。当第一个槽位最终持有比最后一个槽位*更晚的*时间戳时,持续时间就会变为负数,从而使得奖励从 `TotalMs` 中减去数秒。服务器 schema 为 `total_ms INTEGER NOT NULL` 且带有 `ORDER BY total_ms ASC` — **负值会被接受并在排行榜上排在 0 之上**。这在霰弹枪密集的集群 + 神风特攻队敌人成群的房间中会自然触发;可靠地工程化这种竞态翻转已在我们的待办事项列表中。
### 💀 死亡房间的幽灵分段 (`loop.go:755`)
```
e.run.Splits = append(e.run.Splits, protocol.SplitData{
RoomIndex: e.run.CurrentRoom,
TimeMs: totalMs,
...
})
```
`finishRun` 为玩家死亡的房间追加了一个“分段” — 但其 `TimeMs` 是 `totalMs` (死亡时间),而不是通关时间。这曾用虚假的“快速通关”污染了我们的冠军表,直到我们添加了过滤。这对于房间 10 来说尤其有害 (不存在转换分段),因为一个一进门就死亡的记录会显示为一个 2-3 秒的通关且击杀数=0。
我们在写入时进行了清理 (`matrix_run.save_to_db` 在 `outcome != 'completed'` 时丢弃编号最高的分段),并在查询时进行了清理 (`tournament.champion_run_ids` 对房间 10 强制要求 `outcome='completed'`)。我们回填并清除了数据库中约 500 条历史虚假数据行。
### ⏲ `ts=0` 数据包绕过 (`loop.go:143`)
```
func (e *Engine) validateTsWindow(ts int64, now time.Time) bool {
if e.ReplayMode || ts == 0 {
return true
}
...
}
```
在数据包封装中设置 `ts: 0` 可以绕过时间戳窗口验证,该验证原本会对超出时钟偏移容差的数据包进行限流。其副作用是 `lastClientTs` 不会更新,但目前似乎没有服务器逻辑依赖它来处理我们的用例。自项目启动以来,机器人一直在每个数据包上使用 `ts=0`。
### 🗺 可预测的 seed → 可预测的敌人
`mobai.go:SeedMob` 从一个由客户端提供的 `start_time` 作为种子的单一 `rand.New(rand.NewSource(start_time))` 中派生出每个敌人的刷新偏移、巡逻路径、抖动和首次攻击延迟。**整个运行的敌人行为是一个 int64 的确定性函数。** 我们在 `seedset/main.go` 中逐字节重新实现了服务器的 RNG 提取顺序,对即将到来的 25 分钟回放窗口中的每个 seed 的可见性 / 巡逻时间 LoS / 弹道 / 方向偏好进行评分,并将最佳的 seed 固定到实时运行中。这本身不算是一个漏洞 — 但客户端控制着 `start_time`,所以你可以选择自己的战斗。
### 📊 直接写入数据库 — *不可行*
我们检查过。排行榜的插入路径是引擎内部的 — 没有任何 HTTP 端点接受原始的 `CompletedRun` 有效载荷。上面提到的负时间奖励是让 `TotalMs` 小于零的最接近的途径。
### 🛠 我们构建的工具
- 🎯 `seedset/` — 逐字节镜像 `*rand.Rand` 提取顺序的 Go 模拟器。预先计算了全对 LoS 表 (每房间 160 KB),以实现 O(1) 的单元格 LoS 查找。多组件评分 (出生时的 LoS + 巡逻时的可见性 + 弹道 + 用户偏好),速度约为 ~80k seeds/秒/核心。
- 🏆 锦标赛 + 冠军表 — 每次运行的每房间通关结果都会反馈到路径重放系统中。自动晋升冠军,为兼容状态缝合提供退出时的 HP 过滤,支持用户绘制覆盖。
- 📡 `watch_ammo.py` / `watch_stats.py` — 实时趋势工具,用于检测更改是否真正提升了运行质量。
### 致谢
与 **Hari Nagarajan** (本仓库中的 `hari/`) 的交流互通 — 他 90 秒的排行榜成绩迫使我们将评分系统升级为巡逻时间多视角分析和预计算的 LoS 表。
📝 **包含 PoC、计时数据和根本原因分析的正式报告:[1337sheets.com](https://1337sheets.com) (即将推出)。**
## 已知数据怪癖
服务器的 `finishRun` (`loop.go:755`) 会在运行结束时为当前房间追加一个带有 `TimeMs = totalMs` 的“分段”,包括失败的运行。那是死亡时间,而不是通关时间。我们在两处对此进行了过滤:
- `matrix_run.save_to_db` 在 `outcome != 'completed'` 时丢弃编号最高的分段,因此虚假数据行永远不会进入数据库
- `tournament.champion_run_ids` 专门针对房间 10 过滤为 `outcome='completed'` (额外安全措施;如果有任何修复前的数据行残留,这将很重要)
这影响了所有房间 — 特别是机器人经常死亡的 7-9 号房间。一次回填操作清理了 504 条历史虚假数据行。
## 可调环境变量 (经常更改)
大多数参数会由锦标赛自动扰动 (参见 `tournament.py:PARAM_SPACE`)。锦标赛级别的旋钮:
| 环境变量 | 默认值 | 效果 |
|--------------------------------|---------|-------------------------------------------------|
| `VIBECAT_SEED_PIN_RATE` | 0.70 | 固定顶级 seed 的选择比例 |
| `VIBECAT_STITCH_RATE` | 0.30 | 进行完整 stitch-champions 的选择比例 |
| `VIBECAT_PATH_EXPLORE_RATE` | 0.10 | 当存在冠军时,选择替代方案的比例 |
| `VIBECAT_ROOM_EXIT_HP_TARGET` | 50 | 合格成为 1-9 房间冠军的最低 %HP |
| `VIBECAT_BUDGET_SLACK_MS` | 5000 | 机器人运行期间,当超出冠军缝合预算这么多毫秒时将中止运行 |
| `VIBECAT_MULTI_WEAPON_FIRE` | 0 | 设为 1 以启用 Boss 房间同 tick 的 SWITCH+SHOOT 漏洞利用 |
| `VIBECAT_SHOTGUN_RESERVE` | 4 | 为下一个重甲敌人/Boss 保留的最少霰弹枪弹药 |
机器人端环境变量 (服务器 / 账号选择):
| 环境变量 | 默认值 | 效果 |
|--------------------------------|---------------|-----------------------------------|
| `VIBECAT_HOST` | (自动) | 服务器主机 (由 run_matrix.sh 设置)|
| `VIBECAT_TLS` | (自动) | "1" 代表 wss,"0" 代表 ws |
| `VIBECAT_ACCOUNT_BASE` | 派生自主机名 | 动态账户的起始槽位 |
| `VIBECAT_FAIL_FAST` | (空) | 每房间的时间上限,例如 `1:4,2:5,...` |
## 维护
- 轨迹目录会在每保存 100 次运行时自动修剪至每房间 top-100
- `seed_pool` 会自动修剪过期的 seed (早于服务器约 30 分钟的回放窗口)
- `release_user_path.sh` 移除用户绘制的路径/固定,以便 AI 冠军接管该房间
## 许可证 / 源代码
`source/` 目录包含 Vibecat 服务器源代码的快照以供参考 (以便机器人的协议镜像保持正确)。我们不对其进行再分发;有关许可条款,请咨询上游。
标签:A/B测试, A*寻路, DEFCON, NUSGreyHats, pygame, Python, WebSocket, 云资产清单, 依赖分析, 内核驱动, 协议分析, 博弈AI, 参数调优, 启发式搜索, 实时对战, 并行计算, 微服务架构, 数字取证, 无后门, 日志审计, 权限提升, 游戏AI, 游戏机器人, 竞速游戏, 算法优化, 网络协议, 自动化脚本, 计算机取证, 逆向工具, 逆向工程, 速度通关, 锦标赛系统