Files
mtgodot-poc/extension/tests/port_login_flow_server.cpp
T
shenleiandClaude Opus 5.5 a12e7fea42 Port route 4-c: NPC shop (PythonShop.cpp) + app cursor state
- UserInterface/PythonShop.cpp ported verbatim (shop module, initshop after initfly);
  pending stub and module stubs removed.
- 40250 PythonApplicationCursor.cpp and SetMouseHandler moved into the platform
  CPythonApplication on file-static members; GetCursorNum out of line, since uishop's
  app.GetCursor() dereferenced the missing application object.
- Fake server: general store (npclist 9003) answers CG_ON_CLICK with GC_SHOP START,
  CG_SHOP BUY/SELL2/END like CShop::Buy / CShopManager::Sell.
- port.login_flow step 10: click the shopkeeper, right-click buy, drag-sell, ESC close.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 09:30:47 +09:00

842 lines
29 KiB
C++

// See port_login_flow_server.h. Each step names the 40250 server function whose behaviour it plays.
#include "port_login_flow_server.h"
#include "../src/net/classic/classic_cipher.h"
#include "../src/net/classic/sequence_table.h"
#include "../src/net/classic/wire_classic.h"
#include <arpa/inet.h>
#include <netinet/in.h>
#include <poll.h>
#include <sys/socket.h>
#include <unistd.h>
#include <chrono>
#include <cstdio>
#include <cstring>
using namespace mtnet::classic;
namespace
{
constexpr std::uint32_t kHandshake = 0x2468ace0;
constexpr int kHandshakeRetryLimit = 32; // game/src/desc.h HANDSHAKE_RETRY_LIMIT
constexpr int kIdleMs = 20000;
// get_dword_time(): milliseconds on the server's clock.
std::uint32_t now_ms()
{
using namespace std::chrono;
static const auto start = steady_clock::now();
return static_cast<std::uint32_t>(duration_cast<milliseconds>(steady_clock::now() - start).count());
}
int listen_loopback(int* port)
{
int fd = socket(AF_INET, SOCK_STREAM, 0);
int on = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
sockaddr_in addr = {};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (bind(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0 || listen(fd, 4) != 0)
{
close(fd);
return -1;
}
socklen_t len = sizeof(addr);
getsockname(fd, reinterpret_cast<sockaddr*>(&addr), &len);
*port = ntohs(addr.sin_port);
return fd;
}
template <class T>
T zeroed()
{
T value;
std::memset(&value, 0, sizeof(value));
return value;
}
} // namespace
// One DESC: the socket, its cipher and the sequence position of the next client packet.
struct FakeLoginServer::Connection
{
int fd = -1;
ClassicCipher cipher;
size_t sequence = 0;
const std::atomic<bool>* stop = nullptr;
~Connection()
{
if (fd >= 0)
close(fd);
}
// DESC::Packet: encrypted once the key agreement finished.
bool Send(const void* data, size_t size)
{
std::vector<std::uint8_t> bytes(static_cast<const std::uint8_t*>(data), static_cast<const std::uint8_t*>(data) + size);
if (cipher.activated())
cipher.encrypt(bytes.data(), bytes.size());
size_t sent = 0;
while (sent < bytes.size())
{
ssize_t n = send(fd, bytes.data() + sent, bytes.size() - sent, 0);
if (n <= 0)
return false;
sent += static_cast<size_t>(n);
}
return true;
}
template <class T>
bool Send(const T& packet)
{
return Send(&packet, sizeof(packet));
}
// DESC::SetPhase sends the phase packet.
bool Phase(std::uint8_t phase)
{
Phase_ p = {HDR_GC_PHASE, phase};
return Send(p);
}
// Reads exactly `size` bytes, or returns 0 on EOF / -1 on timeout, stop or error.
int Read(void* data, size_t size)
{
auto* p = static_cast<std::uint8_t*>(data);
size_t got = 0;
for (int waited = 0; got < size;)
{
if (stop->load())
return -1;
pollfd pfd = {fd, POLLIN, 0};
int ready = poll(&pfd, 1, 50);
if (ready == 0)
{
if ((waited += 50) >= kIdleMs)
return -1;
continue;
}
ssize_t n = recv(fd, p + got, size - got, 0);
if (n == 0)
return got == 0 ? 0 : -1;
if (n < 0)
return -1;
got += static_cast<size_t>(n);
}
if (cipher.activated())
cipher.decrypt(data, size);
return 1;
}
};
FakeLoginServer::~FakeLoginServer()
{
Stop();
}
bool FakeLoginServer::Start()
{
m_auth_listener = listen_loopback(&m_auth_port);
m_game_listener = listen_loopback(&m_game_port);
m_state_listener = listen_loopback(&m_state_port);
if (m_auth_listener < 0 || m_game_listener < 0 || m_state_listener < 0)
return false;
m_thread = std::thread([this] { Run(); });
m_state_thread = std::thread([this] { ServeState(); });
return true;
}
void FakeLoginServer::Stop()
{
m_stop = true;
if (m_thread.joinable())
m_thread.join();
if (m_state_thread.joinable())
m_state_thread.join();
for (int* fd : {&m_auth_listener, &m_game_listener, &m_state_listener})
if (*fd >= 0)
{
close(*fd);
*fd = -1;
}
}
std::vector<std::string> FakeLoginServer::Events()
{
std::lock_guard<std::mutex> lock(m_mutex);
return m_events;
}
std::string FakeLoginServer::Error()
{
std::lock_guard<std::mutex> lock(m_mutex);
return m_error;
}
bool FakeLoginServer::Has(const std::string& event)
{
std::lock_guard<std::mutex> lock(m_mutex);
for (const auto& e : m_events)
if (e == event)
return true;
return false;
}
void FakeLoginServer::Event(const std::string& event)
{
std::lock_guard<std::mutex> lock(m_mutex);
m_events.push_back(event);
}
bool FakeLoginServer::Fail(const std::string& error)
{
std::lock_guard<std::mutex> lock(m_mutex);
if (m_error.empty())
m_error = error;
return false;
}
bool FakeLoginServer::Accept(int listener, Connection& c)
{
c.stop = &m_stop;
for (int waited = 0; !m_stop; waited += 50)
{
pollfd pfd = {listener, POLLIN, 0};
if (poll(&pfd, 1, 50) > 0)
{
c.fd = accept(listener, nullptr, nullptr);
return c.fd >= 0;
}
if (waited >= kIdleMs)
return Fail("no client connected");
}
return false;
}
void FakeLoginServer::Run()
{
{
Connection auth;
if (!Accept(m_auth_listener, auth) || !ServeAuth(auth))
return;
}
// The first game connection ends at the character list; selecting a character reconnects
// (net.DirectEnter → CPythonNetworkStream::ConnectGameServer) and that one goes on to loading.
for (int index = 1; !m_stop; ++index)
{
Connection game;
if (!Accept(m_game_listener, game) || !ServeGame(game, index))
return;
}
}
// DESC::Setup → SetPhase(PHASE_HANDSHAKE) + StartHandshake; CInputProcessor::Handshake /
// DESC::HandshakeProcess; SendKeyAgreement; CInputHandshake::Analyze(HEADER_CG_KEY_AGREEMENT).
bool FakeLoginServer::Handshake(Connection& c, std::uint8_t phase_after)
{
if (!c.Phase(PHASE_HANDSHAKE))
return Fail("send PHASE_HANDSHAKE");
mtnet::classic::Handshake out = {HDR_HANDSHAKE, kHandshake, now_ms(), 0};
std::uint32_t sent_time = out.time;
if (!c.Send(out))
return Fail("send GC_HANDSHAKE");
for (int retry = 0;; ++retry)
{
auto in = zeroed<mtnet::classic::Handshake>();
if (c.Read(&in, sizeof(in)) != 1 || in.header != HDR_HANDSHAKE)
return Fail("expected CG_HANDSHAKE");
if (in.handshake != kHandshake)
return Fail("CG_HANDSHAKE carries the wrong handshake id");
const std::uint32_t now = now_ms();
const int bias = static_cast<int>(now - (in.time + in.delta));
if (in.delta >= 0 && bias >= 0 && bias <= 50)
break;
if (retry + 1 > kHandshakeRetryLimit)
return Fail("handshake retry limit reached");
std::int32_t delta = static_cast<std::int32_t>(now - in.time) / 2;
if (delta < 0)
delta = static_cast<std::int32_t>(now - sent_time) / 2;
out = {HDR_HANDSHAKE, kHandshake, now, delta};
sent_time = now;
if (!c.Send(out))
return Fail("send GC_HANDSHAKE retry");
}
auto agreement = zeroed<KeyAgreement>();
size_t data_length = KEY_AGREEMENT_MAX_DATA_LEN;
const size_t agreed_length = c.cipher.prepare(agreement.data, &data_length);
if (agreed_length == 0)
return Fail("cipher prepare");
agreement.header = HDR_KEY_AGREEMENT;
agreement.agreed_length = static_cast<std::uint16_t>(agreed_length);
agreement.data_length = static_cast<std::uint16_t>(data_length);
if (!c.Send(agreement))
return Fail("send GC_KEY_AGREEMENT");
auto reply = zeroed<KeyAgreement>();
if (c.Read(&reply, sizeof(reply)) != 1 || reply.header != HDR_KEY_AGREEMENT)
return Fail("expected CG_KEY_AGREEMENT");
// "Send out the key agreement completion packet first to help client to enter encryption mode".
KeyAgreementCompleted completed = {HDR_GC_KEY_AGREEMENT_COMPLETED, {}};
if (!c.Send(completed))
return Fail("send GC_KEY_AGREEMENT_COMPLETED");
if (!c.cipher.activate(false, reply.agreed_length, reply.data, reply.data_length))
return Fail("key agreement failed");
c.cipher.set_activated(true);
return c.Phase(phase_after) || Fail("send phase after handshake");
}
// CServerStateChecker::Request connects to the first channel and sends the bare header. The game server
// has already started the handshake (DESC::Setup), so the client reads past those bytes one per Update;
// CInputHandshake::Analyze(HEADER_CG_STATE_CHECKER) asks the db, and CInputDB::RespondChannelStatus writes
// the header, the count, the TChannelStatus {short nPort; BYTE bStatus} array and a success byte.
void FakeLoginServer::ServeState()
{
Connection c;
c.stop = &m_stop;
for (;;)
{
if (m_stop)
return;
pollfd pfd = {m_state_listener, POLLIN, 0};
if (poll(&pfd, 1, 50) > 0)
break;
}
c.fd = accept(m_state_listener, nullptr, nullptr);
if (c.fd < 0)
return;
mtnet::classic::Handshake hs = {HDR_HANDSHAKE, kHandshake, now_ms(), 0};
if (!c.Phase(PHASE_HANDSHAKE) || !c.Send(hs))
return;
std::uint8_t header = 0;
if (c.Read(&header, 1) != 1 || header != 206)
{
Fail("state checker: expected CG_STATE_CHECKER");
return;
}
Event("state:checker");
std::vector<std::uint8_t> out = {210};
const std::int32_t count = 2;
out.insert(out.end(), reinterpret_cast<const std::uint8_t*>(&count), reinterpret_cast<const std::uint8_t*>(&count) + 4);
for (int port : {m_state_port, kOtherChannelPort})
{
const std::int16_t short_port = static_cast<std::int16_t>(port);
out.insert(out.end(), reinterpret_cast<const std::uint8_t*>(&short_port), reinterpret_cast<const std::uint8_t*>(&short_port) + 2);
out.push_back(static_cast<std::uint8_t>(kOtherChannelStatus));
}
out.push_back(1);
c.Send(out.data(), out.size());
// Keep the socket open until the client disconnects (CServerStateChecker::Initialize).
std::uint8_t rest;
while (c.Read(&rest, 1) == 1)
;
}
// Reads one client packet of a known header: the static size from packet_info.cpp plus the sequence
// byte CInputProcessor::Process checks against gc_abSequence.
static int read_packet(FakeLoginServer::Connection& c, std::uint8_t expect, void* packet, size_t size,
std::string* error)
{
auto* bytes = static_cast<std::uint8_t*>(packet);
int got = c.Read(bytes, 1);
if (got != 1)
return got;
if (bytes[0] != expect)
{
char text[64];
std::snprintf(text, sizeof(text), "expected CG header %u, got %u", expect, bytes[0]);
*error = text;
return -1;
}
if (static_cast<size_t>(packet_size_cg(expect)) != size)
{
*error = "size table disagrees with the packet struct";
return -1;
}
if (size > 1 && c.Read(bytes + 1, size - 1) != 1)
return -1;
if (is_sequence_cg(expect))
{
std::uint8_t sequence = 0;
if (c.Read(&sequence, 1) != 1)
return -1;
const std::uint8_t want = SEQUENCE_TABLE[c.sequence];
if (sequence != want)
{
char text[80];
std::snprintf(text, sizeof(text), "SEQUENCE mismatch 0x%x != 0x%x header %u", want, sequence, expect);
*error = text;
return -1;
}
c.sequence = (c.sequence + 1) % SEQUENCE_TABLE_SIZE;
}
return 1;
}
// g_bAuthServer: CInputAuth::Login → GC_AUTH_SUCCESS with the login key the game server will accept.
bool FakeLoginServer::ServeAuth(Connection& c)
{
if (!Handshake(c, PHASE_AUTH))
return false;
Event("auth:handshake");
auto login = zeroed<CGLogin3>();
std::string error;
if (read_packet(c, HDR_CG_LOGIN3, &login, sizeof(login), &error) != 1)
return Fail("auth: CG_LOGIN3: " + error);
if (std::strcmp(login.login, kLogin) != 0 || std::strcmp(login.passwd, kPassword) != 0)
return Fail("auth: CG_LOGIN3 carries the wrong account");
Event("auth:login3");
GCAuthSuccess success = {HDR_GC_AUTH_SUCCESS, kLoginKey, 1};
if (!c.Send(success))
return Fail("auth: send GC_AUTH_SUCCESS");
// CAccountConnector connects to the game server and then disconnects from us.
std::uint8_t byte = 0;
if (c.Read(&byte, 1) != 0)
return Fail("auth: expected the client to hang up");
Event("auth:closed");
return true;
}
bool FakeLoginServer::ServeGame(Connection& c, int index)
{
const std::string name = "game" + std::to_string(index);
if (!Handshake(c, PHASE_LOGIN))
return false;
Event(name + ":handshake");
// CInputLogin::LoginByKey → (DB) CInputDB::LoginSuccess: GC_EMPIRE, SetPhase(PHASE_SELECT),
// SendLoginSuccessPacket.
auto login = zeroed<CGLogin2>();
std::string error;
if (read_packet(c, HDR_CG_LOGIN2, &login, sizeof(login), &error) != 1)
return Fail(name + ": CG_LOGIN2: " + error);
if (std::strcmp(login.login, kLogin) != 0 || login.login_key != kLoginKey)
return Fail(name + ": CG_LOGIN2 carries the wrong account or login key");
Event(name + ":login2");
GCEmpire empire = {HDR_GC_EMPIRE, 1};
auto success = zeroed<GCLoginSuccess>();
success.header = HDR_GC_LOGIN_SUCCESS_NEWSLOT;
SimplePlayer& player = success.players[0];
player.id = 1;
std::strncpy(player.name, kCharacterName, sizeof(player.name) - 1);
player.job = 0;
player.level = 10;
player.st = player.ht = player.dx = player.iq = 5;
player.x = kX;
player.y = kY;
player.addr = static_cast<std::int32_t>(htonl(INADDR_LOOPBACK));
player.port = static_cast<std::uint16_t>(m_game_port);
success.handle = static_cast<std::uint32_t>(index);
success.random_key = 0x5a5a5a5a;
if (!c.Send(empire) || !c.Phase(PHASE_SELECT) || !c.Send(success))
return Fail(name + ": send login success");
// The character list screen: the client either reconnects (DirectEnter) or selects here.
auto select = zeroed<CGPlayerSelect>();
const int got = read_packet(c, HDR_CG_CHARACTER_SELECT, &select, sizeof(select), &error);
if (got == 0)
{
Event(name + ":closed");
return true;
}
if (got != 1)
return Fail(name + ": CG_CHARACTER_SELECT: " + error);
if (select.player_index != 0)
return Fail(name + ": CG_CHARACTER_SELECT picks an empty slot");
Event(name + ":select");
// CInputDB::PlayerLoad: SetPhase(PHASE_LOADING), CHARACTER::MainCharacterPacket, PointsPacket,
// SkillLevelPacket (1531 bytes; guards the client's 32-bit time_t TPlayerSkill layout).
auto main = zeroed<GCMainCharacter>();
main.header = HDR_GC_MAIN_CHARACTER;
main.vid = kMainVID;
main.race = 0;
std::strncpy(main.name, kCharacterName, sizeof(main.name) - 1);
main.x = kX;
main.y = kY;
main.empire = 1;
auto points = zeroed<GCPoints>();
points.header = HDR_GC_CHARACTER_POINTS;
points.points[1] = 10; // POINT_LEVEL
points.points[11] = kStartGold; // POINT_GOLD
auto skill = zeroed<GCSkillLevel>();
skill.header = HDR_GC_SKILL_LEVEL;
if (!c.Phase(PHASE_LOADING) || !c.Send(main) || !c.Send(points) || !c.Send(skill))
return Fail(name + ": send loading packets");
// The client answers the main character with its version (CInputProcessor::Version).
std::uint8_t header = 0;
if (c.Read(&header, 1) != 1)
return Fail(name + ": expected CG_CLIENT_VERSION");
const size_t size = header == HDR_CG_CLIENT_VERSION2 ? sizeof(CGClientVersion2)
: header == HDR_CG_CLIENT_VERSION ? sizeof(CGClientVersion)
: 0;
if (!size)
return Fail(name + ": expected CG_CLIENT_VERSION, got header " + std::to_string(header));
std::vector<std::uint8_t> rest(size - 1 + 1);
if (c.Read(rest.data(), rest.size()) != 1 || rest.back() != SEQUENCE_TABLE[c.sequence])
return Fail(name + ": CG_CLIENT_VERSION body or sequence");
c.sequence = (c.sequence + 1) % SEQUENCE_TABLE_SIZE;
Event(name + ":client_version");
// game.GameWindow.Open (after the loading steps' net.StartGame()) sends CG_ENTERGAME.
auto enter = zeroed<CGEnterGame>();
if (read_packet(c, HDR_CG_ENTERGAME, &enter, sizeof(enter), &error) != 1)
return Fail(name + ": CG_ENTERGAME: " + error);
Event(name + ":entergame");
// CInputLogin::Entergame order: ch->Show inserts the main actor while the client is still in
// the loading phase (CHARACTER::EncodeInsertPacket = ADD + ADDITIONAL_INFO, then UpdatePacket),
// then SendNPCPosition, PHASE_GAME, SendLandList, TIME, CHANNEL and the greet notice.
GCCharacterAdd actor = zeroed<GCCharacterAdd>();
actor.header = HDR_GC_CHARACTER_ADD;
actor.vid = kMainVID;
actor.x = kX;
actor.y = kY;
actor.type = 6; // CHAR_TYPE_PC (common/length.h ECharType)
actor.race = 0; // warrior male
actor.moving_speed = 100;
actor.attack_speed = 100;
GCCharAddInfo info = zeroed<GCCharAddInfo>();
info.header = HDR_GC_CHAR_ADDITIONAL_INFO;
info.vid = kMainVID;
std::strncpy(info.name, kCharacterName, sizeof(info.name) - 1);
info.empire = 1;
info.level = 10;
GCCharacterUpdate update = zeroed<GCCharacterUpdate>();
update.header = HDR_GC_CHARACTER_UPDATE;
update.vid = kMainVID;
update.moving_speed = 100;
update.attack_speed = 100;
if (!c.Send(actor) || !c.Send(info) || !c.Send(update))
return Fail(name + ": send main actor");
// packet_npc_position = DynHead + count, then TNPCPosition entries.
NPCPosition34 npc = zeroed<NPCPosition34>();
npc.type = 1; // CHAR_TYPE_NPC
std::strncpy(npc.name, "Npc", sizeof(npc.name) - 1);
npc.x = kX + 500;
npc.y = kY + 500;
DynHead npc_head = {HDR_GC_NPC_POSITION, static_cast<std::uint16_t>(sizeof(DynHead) + sizeof(GCNPCPositionHead) + sizeof(npc))};
GCNPCPositionHead npc_count = {1};
if (!c.Send(npc_head) || !c.Send(npc_count) || !c.Send(npc))
return Fail(name + ": send NPC positions");
if (!c.Phase(PHASE_GAME))
return Fail(name + ": send PHASE_GAME");
LandPacketElement24 land = zeroed<LandPacketElement24>();
land.id = 1;
land.x = kX + 1000;
land.y = kY + 1000;
land.width = 2000;
land.height = 2000;
DynHead land_head = {HDR_GC_LAND_LIST, static_cast<std::uint16_t>(sizeof(DynHead) + sizeof(land))};
GCTime game_time = {HDR_GC_TIME, static_cast<std::int32_t>(now_ms())};
GCChannel channel = {HDR_GC_CHANNEL, 1};
if (!c.Send(land_head) || !c.Send(land) || !c.Send(game_time) || !c.Send(channel))
return Fail(name + ": send land/time/channel");
// SendGreetMessage: a CHAT_TYPE_NOTICE line with no speaker.
const char greet[] = "Welcome";
GCChatHead chat = {HDR_GC_CHAT, static_cast<std::uint16_t>(sizeof(GCChatHead) + sizeof(greet)), 2 /* CHAT_TYPE_NOTICE */, 0, 1};
if (!c.Send(chat) || !c.Send(greet, sizeof(greet)))
return Fail(name + ": send greet notice");
// CItem::EncodeInsertPacket for an owned item in view: GC_ITEM_GROUND_ADD, then GC_ITEM_OWNERSHIP.
GCItemGroundAdd ground = zeroed<GCItemGroundAdd>();
ground.header = HDR_GC_ITEM_GROUND_ADD;
ground.x = kGroundItemX;
ground.y = kGroundItemY;
ground.vid = kGroundItemVID;
ground.vnum = kGroundItemVnum;
GCItemOwnership ownership = zeroed<GCItemOwnership>();
ownership.header = HDR_GC_ITEM_OWNERSHIP;
ownership.vid = kGroundItemVID;
std::strncpy(ownership.owner, kCharacterName, sizeof(ownership.owner) - 1);
if (!c.Send(ground) || !c.Send(ownership))
return Fail(name + ": send ground item");
// A monster in view: CHARACTER::EncodeInsertPacket sends ADDITIONAL_INFO only for PCs and NPCs, so a
// CHAR_TYPE_MONSTER is ADD + UPDATE (the client names it from mob_proto).
GCCharacterAdd mob = zeroed<GCCharacterAdd>();
mob.header = HDR_GC_CHARACTER_ADD;
mob.vid = kMobVID;
mob.x = kMobX;
mob.y = kMobY;
mob.type = 0; // CHAR_TYPE_MONSTER
mob.race = kMobRace;
mob.moving_speed = 100;
mob.attack_speed = 100;
GCCharacterUpdate mob_update = zeroed<GCCharacterUpdate>();
mob_update.header = HDR_GC_CHARACTER_UPDATE;
mob_update.vid = kMobVID;
mob_update.moving_speed = 100;
mob_update.attack_speed = 100;
if (!c.Send(mob) || !c.Send(mob_update))
return Fail(name + ": send monster");
// An NPC is ADD + ADDITIONAL_INFO + UPDATE like a PC (the name is the mob_proto locale name).
GCCharacterAdd keeper = zeroed<GCCharacterAdd>();
keeper.header = HDR_GC_CHARACTER_ADD;
keeper.vid = kShopVID;
keeper.x = kShopX;
keeper.y = kShopY;
keeper.type = 1; // CHAR_TYPE_NPC
keeper.race = kShopRace;
keeper.moving_speed = 100;
keeper.attack_speed = 100;
GCCharAddInfo keeper_info = zeroed<GCCharAddInfo>();
keeper_info.header = HDR_GC_CHAR_ADDITIONAL_INFO;
keeper_info.vid = kShopVID;
std::strncpy(keeper_info.name, "General Store", sizeof(keeper_info.name) - 1);
GCCharacterUpdate keeper_update = zeroed<GCCharacterUpdate>();
keeper_update.header = HDR_GC_CHARACTER_UPDATE;
keeper_update.vid = kShopVID;
keeper_update.moving_speed = 100;
keeper_update.attack_speed = 100;
if (!c.Send(keeper) || !c.Send(keeper_info) || !c.Send(keeper_update))
return Fail(name + ": send shopkeeper");
Event(name + ":burst_sent");
// A ping after the burst proves that GamePhase consumed every packet above and can reply.
Blank ping = {HDR_GC_PING};
Blank pong = {};
if (!c.Send(ping) || read_packet(c, HDR_CG_PONG, &pong, sizeof(pong), &error) != 1)
return Fail(name + ": CG_PONG after the entergame burst: " + error);
Event(name + ":burst_pong");
// Then hold the connection until the test stops us, reading what the game phase sends the way
// CInputProcessor::Process frames it (static size or TDynamicSizePacketHeader, then the sequence
// byte). A CG_MOVE (input_main.cpp CInputMain::Move) becomes "<name>:move <func> <arg> <rot> <x> <y>".
int mob_hp = kMobHits;
bool shopping = false;
std::int32_t gold = kStartGold;
std::uint32_t bag0 = 0; // vnum in inventory cell 0 (the fake character starts with an empty bag)
for (;;)
{
std::uint8_t packet[1024] = {};
if (c.Read(packet, 1) != 1)
return true;
const std::uint8_t header = packet[0];
size_t size = static_cast<size_t>(packet_size_cg(header));
if (is_dynamic_cg(header) || header == HDR_CG_SYNC_POSITION)
{
if (c.Read(packet + 1, sizeof(DynHead) - 1) != 1)
return true;
DynHead head;
std::memcpy(&head, packet, sizeof(head));
size = head.size;
if (size < sizeof(DynHead) || size > sizeof(packet) ||
(size > sizeof(DynHead) && c.Read(packet + sizeof(DynHead), size - sizeof(DynHead)) != 1))
return Fail(name + ": dynamic CG packet " + std::to_string(header) + " in the game phase");
}
else if (size == 0 || size > sizeof(packet))
return Fail(name + ": unknown CG header " + std::to_string(header) + " in the game phase");
else if (size > 1 && c.Read(packet + 1, size - 1) != 1)
return true;
// CInputMain::Shop returns the bytes each subheader carries past TPacketCGShop; the sequence byte follows them.
if (header == HDR_CG_SHOP)
{
const size_t extra = packet[1] == SHOP_CG_BUY || packet[1] == SHOP_CG_SELL2 ? 2 : packet[1] == SHOP_CG_SELL ? 1 : 0;
if (extra && c.Read(packet + size, extra) != 1)
return true;
}
if (is_sequence_cg(header))
{
std::uint8_t sequence = 0;
if (c.Read(&sequence, 1) != 1)
return true;
if (sequence != SEQUENCE_TABLE[c.sequence])
return Fail(name + ": SEQUENCE mismatch on CG header " + std::to_string(header));
c.sequence = (c.sequence + 1) % SEQUENCE_TABLE_SIZE;
}
// CInputMain::ItemPickup → CHARACTER::PickupItem → CItem::RemoveFromGround → EncodeRemovePacket.
if (header == HDR_CG_ITEM_PICKUP)
{
CGItemPickup pickup;
std::memcpy(&pickup, packet, sizeof(pickup));
Event(name + ":pickup " + std::to_string(pickup.vid));
GCItemGroundDel del = {HDR_GC_ITEM_GROUND_DEL, pickup.vid};
if (pickup.vid == kGroundItemVID && !c.Send(del))
return Fail(name + ": send GC_ITEM_GROUND_DEL");
}
// CInputMain::Exchange (input_main.cpp) → CHARACTER::ExchangeStart / CExchange::Cancel: the partner is
// played by the server itself — START names the requested vid, then the partner's item (vnum 19, cell 0)
// and gold arrive the way CExchange::AddItem/AddGold send them with is_me=false; CANCEL answers END.
if (header == HDR_CG_EXCHANGE)
{
CGExchange ex;
std::memcpy(&ex, packet, sizeof(ex));
// arg1 only means something for START (40250 SendExchangeExitPacket leaves it uninitialised).
Event(name + ":exchange " + std::to_string(ex.subheader) +
(ex.subheader == EXCHANGE_CG_START ? " " + std::to_string(ex.arg1) : std::string()));
GCExchange out = zeroed<GCExchange>();
out.header = HDR_GC_EXCHANGE;
out.arg2 = {0xff, 0xffff}; // NPOS
bool ok = true;
if (ex.subheader == EXCHANGE_CG_START)
{
out.subheader = EXCHANGE_GC_START;
out.arg1 = ex.arg1;
ok = c.Send(out);
out.subheader = EXCHANGE_GC_ITEM_ADD;
out.arg1 = kExchangeItemVnum;
out.arg2 = {1, 0}; // INVENTORY, cell 0
out.arg3 = 1;
ok = ok && c.Send(out);
out = zeroed<GCExchange>();
out.header = HDR_GC_EXCHANGE;
out.subheader = EXCHANGE_GC_GOLD_ADD;
out.arg2 = {0xff, 0xffff};
out.arg1 = kExchangeGold;
ok = ok && c.Send(out);
}
else if (ex.subheader == EXCHANGE_CG_CANCEL)
{
out.subheader = EXCHANGE_GC_END;
ok = c.Send(out);
}
if (!ok)
return Fail(name + ": send GC_EXCHANGE");
}
// CInputMain::Chat (input_main.cpp): the line comes back to the speaker (PacketAround includes them)
// as a CHAT_TYPE_TALKING GC_CHAT "<name> : <text>" carrying their vid and empire.
if (header == HDR_CG_CHAT)
{
CGChatHead head;
std::memcpy(&head, packet, sizeof(head));
const size_t text_len = size > sizeof(head) ? size - sizeof(head) : 0;
const std::string text(reinterpret_cast<const char*>(packet + sizeof(head)),
strnlen(reinterpret_cast<const char*>(packet + sizeof(head)), text_len));
Event(name + ":chat " + std::to_string(head.type) + " " + text);
const std::string line = std::string(kCharacterName) + " : " + text;
GCChatHead chat = {HDR_GC_CHAT, static_cast<std::uint16_t>(sizeof(GCChatHead) + line.size() + 1),
0 /* CHAT_TYPE_TALKING */, kMainVID, 1};
if (!c.Send(chat) || !c.Send(line.c_str(), line.size() + 1))
return Fail(name + ": send GC_CHAT");
}
// CInputMain::Target → CHARACTER::SetTarget → BroadcastTargetPacket: GC_TARGET with the monster's HP percent.
if (header == HDR_CG_TARGET)
{
CGTarget target;
std::memcpy(&target, packet, sizeof(target));
Event(name + ":target " + std::to_string(target.vid));
GCTarget out = {HDR_GC_TARGET, target.vid,
static_cast<std::uint8_t>(target.vid == kMobVID ? mob_hp * 100 / kMobHits : 0)};
if (!c.Send(out))
return Fail(name + ": send GC_TARGET");
}
// CInputMain::Attack → battle_hit → CHARACTER::Damage: GC_DAMAGE_INFO to the attacker, the target HP
// update, and at 0 HP CHARACTER::Dead's GC_DEAD.
if (header == HDR_CG_ATTACK)
{
CGAttack attack;
std::memcpy(&attack, packet, sizeof(attack));
Event(name + ":attack " + std::to_string(attack.type) + " " + std::to_string(attack.victim_vid));
if (attack.victim_vid == kMobVID && mob_hp > 0)
{
--mob_hp;
GCDamageInfo damage = {HDR_GC_DAMAGE_INFO, kMobVID, 1 /* DAMAGE_NORMAL */, 100};
GCTarget target = {HDR_GC_TARGET, kMobVID, static_cast<std::uint8_t>(mob_hp * 100 / kMobHits)};
if (!c.Send(damage) || !c.Send(target))
return Fail(name + ": send GC_DAMAGE_INFO");
GCDead dead = {HDR_GC_DEAD, kMobVID};
if (mob_hp == 0 && !c.Send(dead))
return Fail(name + ": send GC_DEAD");
}
}
// CInputMain::OnClick → CHARACTER::OnClick → CShopManager::StartShopping → CShop::AddGuest: GC_SHOP START with
// the owner's vid and the 40 shelf slots.
if (header == HDR_CG_ON_CLICK)
{
CGOnClick click;
std::memcpy(&click, packet, sizeof(click));
Event(name + ":click " + std::to_string(click.vid));
if (click.vid == kShopVID && !shopping)
{
shopping = true;
struct
{
DynHead head;
std::uint8_t subheader;
std::uint32_t owner_vid;
ShopItem43 items[SHOP_HOST_ITEM_MAX_NUM];
} __attribute__((packed)) start = zeroed<decltype(start)>();
static_assert(sizeof(start) == 4 + 4 + 40 * 43);
start.head = {HDR_GC_SHOP, static_cast<std::uint16_t>(sizeof(start))};
start.subheader = SHOP_SUB_START;
start.owner_vid = kShopVID;
start.items[0].vnum = kShopItemVnum;
start.items[0].price = kShopItemPrice;
start.items[0].count = 1;
if (!c.Send(start))
return Fail(name + ": send GC_SHOP START");
}
}
// CInputMain::Shop: END → CShop::RemoveGuest (GC_SHOP END); BUY → CShop::Buy (PointChange(POINT_GOLD), then
// CItem::AddToCharacter's GC_ITEM_SET into the first empty cell); SELL2 → CShopManager::Sell (the cell empties
// with the 42-byte header-20 packet, then PointChange(POINT_GOLD)).
if (header == HDR_CG_SHOP)
{
const std::uint8_t sub = packet[1];
Event(name + ":shop " + std::to_string(sub) +
(sub == SHOP_CG_BUY || sub == SHOP_CG_SELL2 ? " " + std::to_string(packet[2]) + " " + std::to_string(packet[3]) : std::string()));
auto gold_change = [&](std::int32_t amount) {
gold += amount;
GCPointChange change = zeroed<GCPointChange>();
change.header = HDR_GC_CHARACTER_POINT_CHANGE;
change.vid = kMainVID;
change.type = 11; // POINT_GOLD
change.amount = amount;
change.value = gold;
return c.Send(change);
};
bool ok = true;
if (sub == SHOP_CG_END && shopping)
{
shopping = false;
struct
{
DynHead head;
std::uint8_t subheader;
} __attribute__((packed)) end = {{HDR_GC_SHOP, 4}, SHOP_SUB_END};
ok = c.Send(end);
}
else if (sub == SHOP_CG_BUY && shopping && packet[3] == 0 && bag0 == 0)
{
bag0 = kShopItemVnum;
GCItemSet set = zeroed<GCItemSet>();
set.header = HDR_GC_ITEM_SET;
set.cell = {1, 0}; // INVENTORY, cell 0
set.vnum = kShopItemVnum;
set.count = 1;
ok = gold_change(-static_cast<std::int32_t>(kShopItemPrice)) && c.Send(set);
}
else if (sub == SHOP_CG_SELL2 && shopping && packet[2] == 0 && bag0 != 0)
{
bag0 = 0;
GCItemDelDeprecated del = zeroed<GCItemDelDeprecated>();
del.header = HDR_GC_ITEM_DEL;
del.cell = {1, 0};
ok = c.Send(del) && gold_change(kShopSellPrice);
}
if (!ok)
return Fail(name + ": send shop reply");
}
if (header == HDR_CG_MOVE)
{
CGMove move;
std::memcpy(&move, packet, sizeof(move));
Event(name + ":move " + std::to_string(move.func) + " " + std::to_string(move.arg) + " " +
std::to_string(move.rot) + " " + std::to_string(move.x) + " " + std::to_string(move.y));
}
}
}