第 33 章 · 反作弊近似
这一章解决:没有权威服务器时,哪些异常能挡住,哪些只能记录和降级,哪些必须在玩法承诺上避开。
先看一下
第 31 章允许客户端上报命中,Owner 做合理性检查。这个方案能让合作 PvE 顺滑,但也暴露了边界:如果某个客户端持续上报不可能的命中、超高频技能、离谱移动,GameState Owner 应该怎么处理?
这一章会拿到什么
- 一条基本判断:反作弊近似不是安全模型
- 五类轻量检查:冷却、范围、速度、分数突变、状态机合法性
- 一个异常计数 + 降级处理片段
- 一份主持人干预接口的边界说明
依赖前面
- 第 20 章的
requestId幂等与 GameState 端冷却 - 第 30 章的 Owner 伪权威
- 第 31 章的命中合理性检查
- 第 32 章的优雅降级顺序
先把承诺降下来
Section titled “先把承诺降下来”真正的反作弊依赖可信服务器:客户端只提交输入,服务器保存状态、计算结果、拒绝伪造。VRChat 世界没有这层服务器。GameState Owner 运行在玩家客户端上,能协调一局游戏,但不能成为可信安全边界。
所以本章讲的是反作弊近似:挡掉明显不可能的请求,记录可疑行为,在异常持续时降级玩法或交给主持人处理。它提高公开房的韧性,不保证竞技公平。
这个降级很重要。技术上做不到的承诺,应该在玩法设计阶段避开。把 PvP 命中改成低频技能、区域控制、合作目标、主持人房间,比在 Udon 里堆一套不可信的复杂校验更稳。
五类轻量检查
Section titled “五类轻量检查”| 检查 | 挡什么 | 成本 |
|---|---|---|
| 冷却验证 | 高频连发、按钮宏、重复请求 | 低 |
| 范围 / 角度验证 | 离谱命中、隔墙式报告的一部分 | 中 |
| 速度上限 | 瞬移、超速移动类请求 | 中 |
| 分数突变 | 单次请求加过大分数、重复结算 | 低 |
| 状态机合法性 | RESULT 阶段继续加分、LOBBY 阶段释放技能 | 低 |
这些检查都放在 Owner 裁决点。它们不证明请求可信,只判断「是否离谱到必须拒绝」。
private bool AcceptSkillRequest(SkillRequest r, VRCPlayerApi who){ int slot = GetPlayerSlot(who.playerId); if (slot < 0) return false;
if (phase != PHASE_INGAME) return Reject(who, REJECT_PHASE); if (r.requestId <= lastProcessedRequestId[slot]) return false; if (TooSoon(slot, r.skillId)) return Reject(who, REJECT_COOLDOWN); if (!IsTargetInRange(r.targetId, r.origin, r.maxRange)) return Reject(who, REJECT_RANGE); if (r.scoreDelta > maxScoreDeltaPerRequest) return Reject(who, REJECT_SCORE_SPIKE); return true;}Reject 可以同时发回执和记异常计数。拒绝重复请求时通常不记异常,因为网络重发和 UI 重复点击都可能造成重复;冷却、范围、分数突变连续出现时才记。
异常计数和降级
Section titled “异常计数和降级”用 per-player 计数即可,不要一开始就写复杂系统。
[UdonSynced] private int[] suspiciousScore = new int[100];
private bool Reject(VRCPlayerApi who, byte reason){ int slot = GetPlayerSlot(who.playerId); if (slot < 0) return false;
if (IsSuspiciousReason(reason)) suspiciousScore[slot] += 1;
SendRejectReceipt(who.playerId, reason); return false;}GetPlayerSlot 可以先做成范围检查:playerId 小于 0 或大于数组长度就拒绝记录。更完整的项目可以维护 playerId → slot 映射。重点是不要让外部传入的 playerId 直接决定数组写入位置。
ApplySoftPenalty 不建议直接踢人或毁掉一局。VRChat 世界里更稳的处理是软降级:拒绝该玩家的高风险请求一段时间;把他的攻击改成本地表现不结算;向主持人 UI 标红;在调试面板显示最近异常原因。
如果世界是熟人房或活动房,主持人干预比自动惩罚更可靠。如果世界是公开竞技房,这一套仍然不够。
日志要能定位,不要审判
Section titled “日志要能定位,不要审判”异常日志记录事实,不写判断。
| 字段 | 例子 |
|---|---|
playerId | 12 |
requestId | 48 |
reason | REJECT_RANGE |
phase | INGAME |
serverTime | 1234.56 |
details | distance=38.2 max=15.0 |
日志文本可以在本地 Debug 面板显示,也可以只在开发版打印。公开世界里不要把「作弊者」这类标签直接展示给所有人。先记录可复现事实:谁在什么阶段、发了哪条请求、哪个检查没过。
主持人干预接口
Section titled “主持人干预接口”有些世界需要主持人。接口要小,职责要清。
| 接口 | 作用 | 边界 |
|---|---|---|
MuteCombat(playerId) | 暂停某玩家的攻击结算 | 不影响移动和观战 |
ForceSpectator(playerId) | 转旁观 | 要写入流程字段,迟入可恢复 |
ResetSuspicion(playerId) | 清异常计数 | 只给主持人 UI |
ShowRecentRejects(playerId) | 查看最近拒绝原因 | 调试信息,不公开广播 |
主持人接口仍然要经过 GameState Owner,不要让任意客户端直接改全局字段。主持人身份本身也不能只靠本地 UI 开关,至少要在场景配置里固定名单、房主流程或活动模式里明确来源。更严格的身份系统属于跨会话与运营层,放到后续卷处理。
「防外挂」不作为本章承诺。客户端上报的命中只作为请求材料,IsMaster 也不作为可信管理员。复杂混淆代码不能让客户端变可信;惩罚逻辑膨胀到超过核心玩法时,维护成本会反过来拖垮世界。
本卷的目标是把状态系统做得自洽:普通网络抖动不会破局,明显异常不会轻易污染分数和胜负,开发者能从日志看出哪类请求在出问题。这个目标已经足够有价值。
挑一条试。
- 给第 31 章的 hitscan 检查加一个
REJECT_ANGLE。minAimDot设多严格时,正常玩家会开始误伤? - 设计一个主持人按钮:把某位玩家转成旁观。它要改哪些同步字段?迟入玩家如何看到这个结果?
| 场景 | 预期 | 实际 |
|---|---|---|
| 1. 冷却内连续发技能 | 请求被拒绝,回执显示冷却,异常计数不增加或轻微增加 | … |
| 2. 上报超远命中 | 请求被拒绝,记录 REJECT_RANGE | … |
| 3. 单次请求加超大分数 | 请求被拒绝,分数不变,记录 REJECT_SCORE_SPIKE | … |
| 4. RESULT 阶段继续攻击 | 状态机检查拒绝,普通 UI 提示无效 | … |
| 5. 异常计数超过阈值 | 触发软降级,主持人 UI 可见 | … |
- 能解释为什么 VRChat 世界只能做反作弊近似
- 能列出冷却、范围、速度、分数突变、状态机 5 类检查
- 能区分请求失败和可疑异常
- 能设计一个软降级,不直接破坏整局
- 能说明主持人接口为什么仍要经过
GameState Owner
- VRChat Creator Docs · Networking and Synchronization — Owner、同步变量和事件模型。
- Valve Developer Community · Latency Compensating Methods(访问日期:2026-06-17)— 权威服务器模型下的公平判定前提。
- 第 20 章 · 版本号、序号与幂等 — 请求幂等和 GameState 端冷却。
- 第 30 章 · Owner 伪权威与确认回包 — 伪权威的边界。
- 第 31 章 · Lag 补偿的可行边界 — 命中报告合理性检查。