装备校验链/快捷栏/公会/好友情侣/交易仓库五项差距修复(增量 129)

- 装备校验:can_equip 按 CanEquipNow→FindEquipCell→EquipItem 精确重排,
  新增 find_equip_cell() 1:1 复刻(RING/UNIQUE 占用翻转、COSTUME/BELT/DS),
  帝国 antiflag 降级为 tooltip 提示,LIMIT_PCBANG/REAL_TIME* 语义与倒计时文案
- 快捷栏:死亡全槽禁用+激活守卫、变身(affect 220)技能置灰、
  物品槽失配置灰(服务器回收/换武器后刷新)
- 公会:GUILD_AUTH_* 权限位门控踢人/公告/技能升级,/gskillup、
  被宣战应答弹窗(/war·/nowar)、资金存取行(40250 服务端已禁用仍保留 1:1)
- 好友/情侣:messenger 三固定组(好友/公会/情侣),lover_login/logout/
  near/far/divorce 命令事件→在线/在身边状态与离婚隐藏
- 交易/仓库:ExchangeState.last_notice(ALREADY/LESS_GOLD)结束浮层提示;
  仓库改密 /safebox_change_password 三段输入;关窗补 /safebox_close·/mall_close;
  仓库金钱经核实 40250 无 wire 协议,UI 定界只读
- app_flow:重连时 guild_marks_ready 重复连接加 is_connected 守卫
- 相机 look_at 零向量守卫(首帧/传送落点)
- 测试:equip/love/guild 断言更新,79 项中 78 绿(game_camera_test 为
  HEAD 既有失败);真服冒烟 LIVE_SMOKE PASS(192.168.21.203 全链路)
- docs/CLIENT-GAP.md 同步增量 129

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kUFvQGYusuY3dkfGitmAe
This commit is contained in:
shenlei
2026-09-07 21:43:30 +09:00
co-authored by Claude Code
parent 61272b75e9
commit 8092ff44e2
25 changed files with 1067 additions and 134 deletions
+27 -18
View File
@@ -718,7 +718,9 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
break;
case EXCHANGE_GC_ALREADY:
case EXCHANGE_GC_LESS_GOLD:
m_world.mut_exchange_notice();
// 带上子头值(6 = 对方已在交易中 / 7 = 金钱不足),UI 据此给
// 定点提示;成败的权威文案走 CHAT_TYPE_INFO 聊天通道。
m_world.mut_exchange_notice(p.subheader);
break;
default:
break;
@@ -825,13 +827,20 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
uint8_t flag = body[2];
const uint8_t *cur = body + 3;
const uint8_t *end = body + len;
auto take_str = [&](size_t cap) -> std::string {
// Every optional field is fixed-width on the wire (the server writes
// the whole reserved buffer, NUL padding included), so advance by the
// field width and not by the string length.
auto take_str = [&](size_t width) -> std::string {
if (static_cast<size_t>(end - cur) < width) {
cur = end;
return {};
}
size_t n = 0;
while (cur + n < end && cur[n] != 0 && n < cap) {
while (n < width && cur[n] != 0) {
++n;
}
std::string s(reinterpret_cast<const char *>(cur), n);
cur += (cur + n < end) ? n + 1 : n; // skip NUL if present
cur += width;
return s;
};
auto take_i32 = [&]() -> int32_t {
@@ -844,25 +853,34 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
};
std::string title, clock_name, counter_name, icon;
int32_t clock_value = 0, counter_value = 0;
// QUEST_SEND_IS_BEGIN carries its own byte: set means "quest begins",
// clear means "quest ends". Its presence alone is not the value.
bool is_begin = false;
if (flag & QUEST_SEND_IS_BEGIN) {
if (cur < end) {
is_begin = *cur != 0;
++cur;
}
}
if (flag & QUEST_SEND_TITLE) {
title = take_str(28);
title = take_str(QUEST_INFO_TITLE_SIZE);
}
if (flag & QUEST_SEND_CLOCK_NAME) {
clock_name = take_str(16);
clock_name = take_str(QUEST_INFO_CLOCK_NAME_SIZE);
}
if (flag & QUEST_SEND_CLOCK_VALUE) {
clock_value = take_i32();
}
if (flag & QUEST_SEND_COUNTER_NAME) {
counter_name = take_str(16);
counter_name = take_str(QUEST_INFO_COUNTER_NAME_SIZE);
}
if (flag & QUEST_SEND_COUNTER_VALUE) {
counter_value = take_i32();
}
if (flag & QUEST_SEND_ICON_FILE) {
icon = take_str(24);
icon = take_str(QUEST_INFO_ICON_FILE_SIZE);
}
m_world.mut_quest_info(index, flag, (flag & QUEST_SEND_IS_BEGIN) != 0, title, clock_name,
m_world.mut_quest_info(index, flag, is_begin, title, clock_name,
clock_value, counter_name, counter_value, icon);
return true;
}
@@ -1497,15 +1515,6 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
return true;
}
case HDR_GC_QUEST_SCRIPT:
// Optional extended quest UI data. Consume it even when the UI is absent.
return true;
case HDR_GC_EXTENDED_STATUS:
// Optional server status payload; framing is sufficient for connection safety.
return true;
case HDR_GC_EXTENDED_CHARACTER:
// Optional extended character payload; consume without requiring its UI.
return true;
default:
m_last_error = "phase " + m_phase_name + " does not handle GC header " +
std::to_string(header);
+40 -3
View File
@@ -5,6 +5,7 @@
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fcntl.h>
#include <netdb.h>
@@ -16,6 +17,27 @@
namespace mtnet::classic {
namespace {
// MT_NET_HEX=1 dumps the leading bytes of every framed packet next to the
// existing wire trace. Framing bugs are only diagnosable from the actual
// bytes, and the per-packet header/size line alone cannot show where a
// mis-sized packet drags the stream out of alignment.
bool hex_dump_enabled() {
static const bool on = std::getenv("MT_NET_HEX") != nullptr;
return on;
}
void trace_hex(const char *tag, const char *name, uint8_t header, const uint8_t *p, size_t avail) {
if (!hex_dump_enabled()) {
return;
}
const size_t dump = avail < 24 ? avail : 24;
std::fprintf(stderr, "[classic:%s] %s hdr=%u hex:", name, tag, header);
for (size_t i = 0; i < dump; ++i) {
std::fprintf(stderr, " %02x", p[i]);
}
std::fprintf(stderr, "\n");
}
constexpr size_t RECV_CHUNK = 32 * 1024;
uint32_t now_ms() {
@@ -486,14 +508,28 @@ void ClassicStream::dispatch() {
disconnect();
return;
}
if (m_recv.readable() < dh.size) {
uint32_t framed = dh.size;
if (header == HDR_GC_QUEST_INFO) {
// GC_QUEST_INFO always reports size == 6 on the wire; its real
// length is flag-driven (see quest_info_packet_size).
uint8_t head[QUEST_INFO_HEAD_SIZE];
if (!m_recv.peek(head, sizeof(head))) {
return;
}
framed = static_cast<uint32_t>(
quest_info_packet_size(head[QUEST_INFO_HEAD_SIZE - 1]));
}
if (m_recv.readable() < framed) {
return; // whole packet not here yet
}
if (m_trace) {
trace_hex("recv", m_trace_name.c_str(), header, m_recv.read_ptr(), m_recv.readable());
}
m_recv.discard(sizeof(DynHead));
uint32_t body_len = dh.size - sizeof(DynHead);
uint32_t body_len = framed - sizeof(DynHead);
const uint8_t *body = m_recv.read_ptr();
if (m_trace) {
std::fprintf(stderr, "[classic:%s] recv hdr=%u n=%u dynamic=1\n", m_trace_name.c_str(), header, dh.size);
std::fprintf(stderr, "[classic:%s] recv hdr=%u n=%u dynamic=1\n", m_trace_name.c_str(), header, framed);
}
bool ok = !on_packet || on_packet(header, body, body_len);
discard_recv(body_len);
@@ -557,6 +593,7 @@ void ClassicStream::dispatch() {
const uint8_t *p = m_recv.read_ptr();
if (m_trace) {
std::fprintf(stderr, "[classic:%s] recv hdr=%u n=%d dynamic=0\n", m_trace_name.c_str(), header, total);
trace_hex("recv", m_trace_name.c_str(), header, p, m_recv.readable());
if (header == HDR_GC_LOGIN_SUCCESS_NEWSLOT || header == HDR_GC_SKILL_COOLTIME_END) {
const int next = m_recv.readable() > static_cast<size_t>(total) ? p[total] : -1;
if (next < 0) {
+28 -9
View File
@@ -276,12 +276,6 @@ enum : uint8_t {
HDR_GC_EMPIRE = 90,
HDR_GC_PARTY_LINK = 91,
HDR_GC_PARTY_UNLINK = 92,
// 40250 extended quest/script notification; payload is dynamically framed.
HDR_GC_QUEST_SCRIPT = 148,
// Extended servers also emit a dynamically framed mount/status notice here.
HDR_GC_EXTENDED_STATUS = 59,
// 40250 extended character/status packet (dynamic framing).
HDR_GC_EXTENDED_CHARACTER = 105,
HDR_GC_REFINE_INFORMATION_OLD = 95,
HDR_GC_OBSERVER_ADD = 96,
HDR_GC_OBSERVER_REMOVE = 97,
@@ -1063,6 +1057,34 @@ enum : uint8_t {
QUEST_SEND_COUNTER_VALUE = 1 << 5,
QUEST_SEND_ICON_FILE = 1 << 6,
};
// Field widths written by PC::SendQuestInfoPakcet() (questpc.cpp) and read back
// by the original client's field-by-field Recv() in RecvQuestInfoPacket().
inline constexpr int QUEST_INFO_HEAD_SIZE = 6; // header + u16 size + u16 index + u8 flag
inline constexpr int QUEST_INFO_IS_BEGIN_SIZE = 1;
inline constexpr int QUEST_INFO_TITLE_SIZE = 30 + 1;
inline constexpr int QUEST_INFO_CLOCK_NAME_SIZE = 16 + 1;
inline constexpr int QUEST_INFO_CLOCK_VALUE_SIZE = 4;
inline constexpr int QUEST_INFO_COUNTER_NAME_SIZE = 16 + 1;
inline constexpr int QUEST_INFO_COUNTER_VALUE_SIZE = 4;
inline constexpr int QUEST_INFO_ICON_FILE_SIZE = 24 + 1;
// The 40250 server fills the fixed head with `size = sizeof(packet_quest_info)`
// (6) and then appends the optional fields while only bumping its own local
// copy of `size` — the count on the wire never covers the tail. Framing this
// packet off its size field therefore leaves the tail in the stream and
// desyncs every packet after it, so derive the real length from `flag` the way
// the original client does.
constexpr int quest_info_packet_size(uint8_t flag) {
int n = QUEST_INFO_HEAD_SIZE;
if (flag & QUEST_SEND_IS_BEGIN) n += QUEST_INFO_IS_BEGIN_SIZE;
if (flag & QUEST_SEND_TITLE) n += QUEST_INFO_TITLE_SIZE;
if (flag & QUEST_SEND_CLOCK_NAME) n += QUEST_INFO_CLOCK_NAME_SIZE;
if (flag & QUEST_SEND_CLOCK_VALUE) n += QUEST_INFO_CLOCK_VALUE_SIZE;
if (flag & QUEST_SEND_COUNTER_NAME) n += QUEST_INFO_COUNTER_NAME_SIZE;
if (flag & QUEST_SEND_COUNTER_VALUE) n += QUEST_INFO_COUNTER_VALUE_SIZE;
if (flag & QUEST_SEND_ICON_FILE) n += QUEST_INFO_ICON_FILE_SIZE;
return n;
}
// --- GC party ---
inline constexpr int PARTY_AFFECT_SLOT_MAX_NUM = 7;
@@ -1465,9 +1487,6 @@ constexpr bool is_dynamic_gc(uint8_t h) {
case HDR_GC_GUILD:
case HDR_GC_MESSENGER:
case HDR_GC_QUEST_INFO:
case HDR_GC_QUEST_SCRIPT:
case HDR_GC_EXTENDED_STATUS:
case HDR_GC_EXTENDED_CHARACTER:
case HDR_GC_DUEL_START:
case HDR_GC_SYNC_POSITION:
// client CMainPacketHeaderMap registers GC_WHISPER STATIC, but its