diff --git a/extension/src/net/classic/classic_mark_client.h b/extension/src/net/classic/classic_mark_client.h index 31b4a26a..dd70cda7 100644 --- a/extension/src/net/classic/classic_mark_client.h +++ b/extension/src/net/classic/classic_mark_client.h @@ -168,6 +168,10 @@ private: if (header == HDR_GC_MARK_BLOCK) { return receive_block(packet, available); } + if (header == HDR_GC_MARK_DIFF_DATA) { + // GuildMarkDownloader.cpp: sizeof(BYTE), dispatch returns true. + return raw(ClassicStream::RawPacketStatus::Consumed, 1); + } return raw(ClassicStream::RawPacketStatus::NotHandled); } diff --git a/extension/src/net/classic/classic_parser.cpp b/extension/src/net/classic/classic_parser.cpp index 96e2f371..e2b71eff 100644 --- a/extension/src/net/classic/classic_parser.cpp +++ b/extension/src/net/classic/classic_parser.cpp @@ -326,12 +326,20 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) { p.anti_flags, p.sockets, a); return true; } - case HDR_GC_ITEM_DEL: { // 20 — inventory pos only - GCItemDel p; + case HDR_GC_ITEM_DEL: { // 20 — TPacketGCItemDelDeprecated, read as ITEM_SET by 40250 + GCItemDelDeprecated p; if (!fill(p, header, body, len)) { return false; } - m_world.mut_item_del(/*WINDOW_INVENTORY*/ 1, p.pos); + if (p.vnum == 0) { + m_world.mut_item_del(p.cell.window_type, p.cell.cell); + return true; + } + mtnet::ItemAttr a[ITEM_ATTRIBUTE_MAX_NUM]; + for (int i = 0; i < ITEM_ATTRIBUTE_MAX_NUM; ++i) { + a[i] = {p.attrs[i].type, p.attrs[i].value}; + } + m_world.mut_item_set(p.cell.window_type, p.cell.cell, p.vnum, p.count, 0, 0, p.sockets, a); return true; } case HDR_GC_ITEM_UPDATE: { // 25 @@ -1331,7 +1339,20 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) { m_world.mut_observer(ObserverEvent::Remove, packet.vid, 0, 0); return true; } - case HDR_GC_REFINE_INFORMATION_OLD: + case HDR_GC_REFINE_INFORMATION_OLD: { // 95 — RecvRefineInformationPacket, no type + GCRefineInfoOld packet; + if (!fill(packet, header, body, len)) { + return false; + } + RefineCue::Mat materials[5] = {}; + for (int i = 0; i < 5; ++i) { + materials[i].vnum = packet.materials[i].vnum; + materials[i].count = packet.materials[i].count; + } + m_world.mut_refine(0, packet.pos, packet.src_vnum, packet.result_vnum, + packet.material_count, packet.cost, packet.prob, materials); + return true; + } case HDR_GC_REFINE_INFORMATION: { GCRefineInfo packet; if (!fill(packet, header, body, len)) { diff --git a/extension/src/net/classic/classic_stream.cpp b/extension/src/net/classic/classic_stream.cpp index f6a0fac1..b2cea88b 100644 --- a/extension/src/net/classic/classic_stream.cpp +++ b/extension/src/net/classic/classic_stream.cpp @@ -518,6 +518,16 @@ void ClassicStream::dispatch() { } framed = static_cast( quest_info_packet_size(head[QUEST_INFO_HEAD_SIZE - 1])); + } else if (header == HDR_GC_GUILD) { + // GUILD_SUBHEADER_GC_SKILL_INFO reports one byte more than the + // server writes (see GUILD_SKILL_INFO_PACKET_SIZE). + uint8_t head[sizeof(DynHead) + 1]; + if (!m_recv.peek(head, sizeof(head))) { + return; + } + if (head[sizeof(DynHead)] == GUILD_SUBHEADER_GC_SKILL_INFO) { + framed = GUILD_SKILL_INFO_PACKET_SIZE; + } } if (m_recv.readable() < framed) { return; // whole packet not here yet diff --git a/extension/src/net/classic/wire_classic.h b/extension/src/net/classic/wire_classic.h index 6c593435..531ad8de 100644 --- a/extension/src/net/classic/wire_classic.h +++ b/extension/src/net/classic/wire_classic.h @@ -282,6 +282,7 @@ enum : uint8_t { HDR_GC_OBSERVER_MOVE = 98, HDR_GC_VIEW_EQUIP = 99, HDR_GC_MARK_BLOCK = 100, + HDR_GC_MARK_DIFF_DATA = 101, // mark connection only: bare header, ignored HDR_GC_MARK_IDXLIST = 102, HDR_GC_TIME = 106, HDR_GC_CHANGE_NAME = 107, @@ -944,6 +945,19 @@ struct GCViewEquip { }; struct GCChangeName { uint8_t header; uint32_t pid; char name[CHARACTER_NAME_MAX_LEN + 1]; }; struct GCRefineMaterial { uint32_t vnum; int32_t count; }; +// Header 95: client TPacketGCRefineInformation = [hdr][pos][TRefineTable], no type. +struct GCRefineInfoOld { + uint8_t header; + uint8_t pos; + uint32_t src_vnum; + uint32_t result_vnum; + uint8_t material_count; + int32_t cost; + int32_t prob; + GCRefineMaterial materials[5]; +}; +static_assert(sizeof(GCRefineInfoOld) == 59); +// Header 119: server TPacketGCRefineInformation == client TPacketGCRefineInformationNew. struct GCRefineInfo { uint8_t header; uint8_t type; @@ -1148,9 +1162,22 @@ struct GCItemSet { // packet_item_set (server packet.h:1134, header 21) }; static_assert(sizeof(GCItemSet) == 1 + 3 + 4 + 1 + 4 + 4 + 1 + 12 + 21); // 51 -struct GCItemDel { uint8_t header; uint8_t pos; }; // header 20 — inventory pos only +struct GCItemDel { uint8_t header; uint8_t pos; }; // TPacketGCItemDel — SAFEBOX_DEL / MALL_DEL static_assert(sizeof(GCItemDel) == 2); +// Header 20: the 40250 server sends TPacketGCItemDelDeprecated (packet.h:1124, +// char_item.cpp:424) when an inventory cell empties; the 40250 client reads the +// same 42 bytes as its HEADER_GC_ITEM_SET (no flags/anti_flags/highlight). +struct GCItemDelDeprecated { + uint8_t header; + ItemPos cell; + uint32_t vnum; + uint8_t count; + int32_t sockets[ITEM_SOCKET_MAX_NUM]; + ItemAttr3 attrs[ITEM_ATTRIBUTE_MAX_NUM]; +}; +static_assert(sizeof(GCItemDelDeprecated) == 1 + 3 + 4 + 1 + 12 + 21); // 42 + // GC_MESSENGER is dynamic: after [header][u16 size], body starts with the // subheader and then the subheader-specific records below. struct GCMessengerHead { uint8_t header; uint16_t size; uint8_t subheader; }; @@ -1249,6 +1276,14 @@ static_assert(sizeof(GuildMember38) == 38 && sizeof(GuildInfo35) == 35 && sizeof(GuildName16) == 16 && sizeof(GuildSkill17) == 17 && sizeof(GuildComment80) == 80 && sizeof(GuildInvite17) == 17); +// CGuild::SendSkillInfoPacket() (guild.cpp) announces +// `size = sizeof(pack) + 6 + GUILD_SKILL_COUNT` (22) but writes only +// skill_point, abySkill[12], power(2) and max_power(2) after the head — 21 +// bytes. The original client's RecvGuild() reads those fields one by one, so +// framing off the size field would swallow the next packet's header byte. +inline constexpr uint8_t GUILD_SUBHEADER_GC_SKILL_INFO = 12; +inline constexpr int GUILD_SKILL_INFO_PACKET_SIZE = 4 + sizeof(GuildSkill17); // 21 + // --- chat (dynamic) --- struct CGChatHead { uint8_t header; uint16_t length; uint8_t type; }; // + char szChat[] static_assert(sizeof(CGChatHead) == 4); @@ -1382,7 +1417,7 @@ constexpr int packet_size_gc(uint8_t h) { case HDR_GC_CHARACTER_POINTS: return sizeof(GCPoints); case HDR_GC_CHARACTER_POINT_CHANGE: return sizeof(GCPointChange); case HDR_GC_ITEM_SET: return sizeof(GCItemSet); - case HDR_GC_ITEM_DEL: return sizeof(GCItemDel); + case HDR_GC_ITEM_DEL: return sizeof(GCItemDelDeprecated); case HDR_GC_ITEM_USE: return sizeof(GCItemUse); case HDR_GC_ITEM_UPDATE: return sizeof(GCItemUpdate); case HDR_GC_ITEM_GROUND_ADD: return sizeof(GCItemGroundAdd); @@ -1447,7 +1482,7 @@ constexpr int packet_size_gc(uint8_t h) { case HDR_GC_LOVE_POINT_UPDATE: return sizeof(GCLovePointUpdate); case HDR_GC_DIG_MOTION: return sizeof(GCDigMotion); case HDR_GC_VIEW_EQUIP: return sizeof(GCViewEquip); - case HDR_GC_REFINE_INFORMATION_OLD: + case HDR_GC_REFINE_INFORMATION_OLD: return sizeof(GCRefineInfoOld); case HDR_GC_REFINE_INFORMATION: return sizeof(GCRefineInfo); case HDR_GC_DRAGON_SOUL_REFINE: return sizeof(GCDragonSoulRefine); case HDR_GC_QUEST_CONFIRM: return sizeof(GCQuestConfirm); diff --git a/extension/tests/net_classic_mark_test.cpp b/extension/tests/net_classic_mark_test.cpp index 6b172993..c2e1d744 100644 --- a/extension/tests/net_classic_mark_test.cpp +++ b/extension/tests/net_classic_mark_test.cpp @@ -59,6 +59,12 @@ static void test_mark_download() { assert(request.size() == sizeof(CGMarkIDXList)); assert(request[0] == HDR_CG_MARK_IDXLIST); + // GuildMarkDownloader.cpp:172/201 frames HEADER_GC_MARK_DIFF_DATA (101) as a + // bare header byte and ignores it; the next mark frame must still parse. + const uint8_t diff = 101; + client.stream().feed(&diff, 1); + assert(client.stream().last_error().empty()); + // One guild maps to image zero, position one. Feed the whole-size frame in // two chunks to exercise the custom raw framing path. std::vector idx(sizeof(GCMarkIDXList) + 4); diff --git a/extension/tests/net_classic_session_test.cpp b/extension/tests/net_classic_session_test.cpp index b93af6ee..a98ca667 100644 --- a/extension/tests/net_classic_session_test.cpp +++ b/extension/tests/net_classic_session_test.cpp @@ -183,6 +183,26 @@ int main() { "on_char_list fired with renamed slot"); } + // --- GC 9 create failure: input_login.cpp:442-465 sends a zeroed 10-byte + // TPacketGCLoginFailure under header 9 (blocked creation / bad name). The 40250 + // client reads TPacketGCCreateFailure (2 bytes) and PythonNetworkStream.cpp:509 + // skips the trailing zero bytes as blank headers. input_db.cpp:196 sends 2 bytes. --- + { + s.parser().drain_char_events(); + std::vector cf(10, 0); + cf[0] = HDR_GC_CREATE_FAILURE; + GCPlayerCreateFailure dup{HDR_GC_CREATE_FAILURE, 1}; + std::vector db = raw(dup); + cf.insert(cf.end(), db.begin(), db.end()); + feed(s, cf); + auto ev = s.parser().drain_char_events(); + CHECK(ev.size() == 2 && ev[0].kind == ClassicParser::CharEvent::CreateFail && + ev[0].fail_type == 0 && ev[1].kind == ClassicParser::CharEvent::CreateFail && + ev[1].fail_type == 1, + "10-byte and 2-byte GC 9 create failures both decode like 40250"); + CHECK(s.last_error().empty(), "no desync after 10-byte GC 9"); + } + // --- select_char(0) -> CG_CHARACTER_SELECT + seq[1] --- { CHECK(s.select_char(0), "select_char(0) ok"); @@ -523,9 +543,84 @@ int main() { feed(s, raw(iu)); CHECK(s.world().item_slot(1, 4).count == 3, "item_update count -> 3"); - GCItemDel id{HDR_GC_ITEM_DEL, 4}; - feed(s, raw(id)); + // GC 20 is TPacketGCItemDelDeprecated on the 40250 server (char_item.cpp:424, + // packet.h:1124): header, TItemPos, vnum, count, alSockets[3], aAttr[7] = 42 + // bytes; the 40250 client reads it as ITEM_SET. Sent on inventory load, so feed + // it glued to the next packet: a short frame desyncs the loading stream. + std::vector del(42, 0); + del[0] = HDR_GC_ITEM_DEL; + del[1] = 1; // WINDOW_INVENTORY + del[2] = 4; // cell 4 (u16 LE) + GCItemSet next{}; + next.header = HDR_GC_ITEM_SET; + next.cell = {1, 7}; + next.vnum = 11200; + next.count = 2; + std::vector glued = del; + std::vector nb = raw(next); + glued.insert(glued.end(), nb.begin(), nb.end()); + feed(s, glued); CHECK(s.world().item_slot(1, 4).vnum == 0, "item_del cleared inventory cell 4"); + CHECK(s.world().item_slot(1, 7).vnum == 11200 && s.world().item_slot(1, 7).count == 2, + "packet after 42-byte GC 20 framed correctly"); + CHECK(s.last_error().empty(), "no desync after GC 20"); + + // Same wire layout with a vnum: the 40250 client applies it as an item set. + std::vector set20(42, 0); + set20[0] = HDR_GC_ITEM_DEL; + set20[1] = 1; + set20[2] = 9; + const uint32_t vnum20 = 27001; + std::memcpy(&set20[4], &vnum20, 4); + set20[8] = 5; // count + const int32_t sock0 = 28030; + std::memcpy(&set20[9], &sock0, 4); + set20[21] = 7; // aAttr[0].bType + const int16_t av = 15; + std::memcpy(&set20[22], &av, 2); + feed(s, set20); + const mtnet::Item &it20 = s.world().item_slot(1, 9); + CHECK(it20.vnum == 27001 && it20.count == 5 && it20.sockets[0] == 28030 && + it20.attrs[0].type == 7 && it20.attrs[0].value == 15, + "GC 20 with vnum sets the cell like the 40250 client"); + s.world().mut_item_del(1, 9); + } + + // --- GC 95 HEADER_GC_REFINE_INFORMATION: the 40250 client registers it with + // TPacketGCRefineInformation = [hdr][pos][TRefineTable] = 59 bytes (no type byte; + // only 119 _NEW carries type). Glue a packet after it to prove the frame length. --- + { + s.world().drain_refine_cues(); + std::vector rf(59, 0); + rf[0] = 95; + rf[1] = 6; // pos + const uint32_t src = 11209, dst = 11210, mat_vnum = 30053; + const int32_t cost = 5000, prob = 90, mat_count = 2; + std::memcpy(&rf[2], &src, 4); + std::memcpy(&rf[6], &dst, 4); + rf[10] = 1; // material_count + std::memcpy(&rf[11], &cost, 4); + std::memcpy(&rf[15], &prob, 4); + std::memcpy(&rf[19], &mat_vnum, 4); + std::memcpy(&rf[23], &mat_count, 4); + GCItemSet after{}; + after.header = HDR_GC_ITEM_SET; + after.cell = {1, 11}; + after.vnum = 11210; + after.count = 1; + std::vector ab = raw(after); + rf.insert(rf.end(), ab.begin(), ab.end()); + feed(s, rf); + auto cues = s.world().drain_refine_cues(); + CHECK(cues.size() == 1 && cues[0].type == 0 && cues[0].pos == 6 && + cues[0].src_vnum == 11209 && cues[0].result_vnum == 11210 && + cues[0].material_count == 1 && cues[0].cost == 5000 && + cues[0].prob == 90 && cues[0].materials[0].vnum == 30053 && + cues[0].materials[0].count == 2, + "GC 95 refine information uses the 59-byte 40250 layout"); + CHECK(s.world().item_slot(1, 11).vnum == 11210, "packet after GC 95 framed correctly"); + CHECK(s.last_error().empty(), "no desync after GC 95"); + s.world().mut_item_del(1, 11); } // --- GC entity state: UPDATE(19) / CHANGE_SPEED(18) / POSITION(43) / MOTION(36) / @@ -743,22 +838,26 @@ int main() { cf[0].request_pid == 555, "quest confirm cue"); - // GC_QUEST_INFO dynamic: [hdr 81][u16 size][u16 index][u8 flag][TITLE\0][COUNTER_NAME\0][i32] - std::vector qb; + // GC_QUEST_INFO (questpc.cpp PC::SendQuestInfoPakcet): [hdr 81][u16 size][u16 index] + // [u8 flag] then fixed-width optional fields (title 31, counter name 17, i32). The + // head is written before size is bumped, so the wire size stays 6. + std::vector qpkt(QUEST_INFO_HEAD_SIZE, 0); + qpkt[0] = HDR_GC_QUEST_INFO; + uint16_t qsz = QUEST_INFO_HEAD_SIZE; + std::memcpy(&qpkt[1], &qsz, 2); uint16_t qidx = 3; - qb.insert(qb.end(), (uint8_t *)&qidx, (uint8_t *)&qidx + 2); - uint8_t qflag = QUEST_SEND_TITLE | QUEST_SEND_COUNTER_NAME | QUEST_SEND_COUNTER_VALUE; - qb.push_back(qflag); - const char *qt = "Kill 10 wolves"; - qb.insert(qb.end(), qt, qt + std::strlen(qt) + 1); - const char *cn = "Wolves"; - qb.insert(qb.end(), cn, cn + std::strlen(cn) + 1); + std::memcpy(&qpkt[3], &qidx, 2); + qpkt[5] = QUEST_SEND_TITLE | QUEST_SEND_COUNTER_NAME | QUEST_SEND_COUNTER_VALUE; + std::vector qt(QUEST_INFO_TITLE_SIZE, 0); + std::memcpy(qt.data(), "Kill 10 wolves", 14); + qpkt.insert(qpkt.end(), qt.begin(), qt.end()); + std::vector cn(QUEST_INFO_COUNTER_NAME_SIZE, 0); + std::memcpy(cn.data(), "Wolves", 6); + qpkt.insert(qpkt.end(), cn.begin(), cn.end()); int32_t cv = 4; - qb.insert(qb.end(), (uint8_t *)&cv, (uint8_t *)&cv + 4); - uint16_t qsz = (uint16_t)(3 + qb.size()); - std::vector qpkt = {HDR_GC_QUEST_INFO}; - qpkt.insert(qpkt.end(), (uint8_t *)&qsz, (uint8_t *)&qsz + 2); - qpkt.insert(qpkt.end(), qb.begin(), qb.end()); + qpkt.insert(qpkt.end(), (uint8_t *)&cv, (uint8_t *)&cv + 4); + CHECK(qpkt.size() == static_cast(quest_info_packet_size(qpkt[5])), + "quest info test packet matches 40250 flag-driven length"); feed(s, qpkt); const mtnet::QuestInfo *q = s.world().quest(3); CHECK(q && q->title == "Kill 10 wolves" && q->counter_name == "Wolves" && diff --git a/extension/tests/net_classic_stream_test.cpp b/extension/tests/net_classic_stream_test.cpp index 7900e5e7..5cd4b565 100644 --- a/extension/tests/net_classic_stream_test.cpp +++ b/extension/tests/net_classic_stream_test.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include using namespace mtnet::classic; @@ -248,6 +249,46 @@ int main() { CHECK(s.outgoing_pending() == 0, "unknown header does not resync into trailing bytes"); } + // ------------------------------------------- GC_GUILD SKILL_INFO short write + { + // guild.cpp CGuild::SendSkillInfoPacket: size = 4 + 6 + GUILD_SKILL_COUNT (22) + // but only skill_point + abySkill[12] + power(2) + max_power(2) follow the + // head (21 bytes). LoginMember sends it right before SendEnemyGuild's + // TPacketGCGuildName (size 20 = 0x14), which is how a real login desynced + // into "unknown GC header 200 (last: 75,20)". + ClassicStream s; + std::string err; + s.on_error = [&](const std::string &e) { err = e; }; + std::vector>> got; + s.on_packet = [&](uint8_t h, const uint8_t *body, uint32_t len) { + got.push_back({h, std::vector(body, body + len)}); + return true; + }; + std::vector skill = {HDR_GC_GUILD, 22, 0, 12, 3}; + for (uint8_t i = 0; i < 12; ++i) { + skill.push_back(static_cast(i + 1)); + } + skill.insert(skill.end(), {0x10, 0x00, 0x20, 0x00}); // power 16, max_power 32 + CHECK(skill.size() == 21, "server SKILL_INFO writes 21 bytes"); + std::vector name = {HDR_GC_GUILD, 20, 0, 16, 0x2A, 0, 0, 0}; + const char gname[12] = "Wolves"; + name.insert(name.end(), gname, gname + sizeof(gname)); + CHECK(name.size() == 20, "TPacketGCGuildName is 20 bytes"); + std::vector wire = skill; + wire.insert(wire.end(), name.begin(), name.end()); + s.feed(wire.data(), wire.size()); + CHECK(err.empty(), "guild skill info + guild name: no framing error"); + CHECK(got.size() == 2, "guild skill info + guild name: two packets delivered"); + if (got.size() == 2) { + CHECK(got[0].second.size() == 1 + 17 && got[0].second[0] == 12 && + got[0].second[1] == 3 && got[0].second[16] == 0x20, + "skill info body = subheader + 17 bytes"); + CHECK(got[1].first == HDR_GC_GUILD && got[1].second.size() == 17 && + got[1].second[0] == 16 && got[1].second[1] == 0x2A, + "guild name packet framed intact after skill info"); + } + } + // ---------------------------------------------------------- rejected handler { ClassicStream s;