40250 classic: parallel-dev checkpoint through increment 49 (W0 interface freeze)

Captures the uncommitted parallel-development work (increments 46-49) on the
40250 classic client port, folded into docs/CLIENT-GAP.md + CLIENT-GAP-FIX.md.

Increment 49 (W0 interface-freeze batch, Phase 1 prerequisite) lands the
cross-workflow shared-file scaffolding so the 4 Phase 1 workflows can run in
isolated worktrees without contention:

- entity_store.{h,cpp}: Entity gains empire/affect_flags/owner_vid/state_flags;
  new mut_spawn_full() (single-shot two-packet merge, §2.3), mut_ownership()
  (§2.5), mut_map_bgm() + take_bgm_dirty()/bgm_name()/bgm_volume() (§9.1),
  drain_dirty() (§2.1/§2.5 bare-field-update queue); mut_char_info() now stores
  empire; reset_for_map_change() clears m_dirty.
- m2_client.cpp: new bgm_changed(name, volume) signal; classic + m2dev pump
  loops drain drain_dirty() -> entity_info and take_bgm_dirty() -> bgm_changed;
  entity_dict() exposes the 4 new keys.
- classic/classic_parser.{h,cpp}: GC_MAIN_CHARACTER3_BGM /
  GC_MAIN_CHARACTER4_BGM_VOL route bgm_name/bgm_volume to mut_map_bgm() (was
  discarded); new m_pending_actor staging map (W1 fills §2.2 merge logic).
- project/bgm_director.gd (new) + game_scene.gd: BGM consumption split out of
  net_world.gd so W2/W4 don't collide; §8.6/§4.8 keybind convergence (digits
  1-4 -> quickslots 0-3, F1-F4 -> quickslots 4-7, Ctrl+1..9 -> _emote()).

Tests: cmake --build build clean; ctest 16/16; 15 GDScript regressions green
(netbridge, gamescene, netplay, p9, p10, p2b, p8, system_menu_ui, skill,
combat_fx, skill_fx, player_motion, char_status_ui, chat, inventory), no skips.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
This commit is contained in:
shenlei
2026-09-02 15:13:01 +09:00
co-authored by Claude Sonnet 5
parent 70f200e885
commit f32064c74a
36 changed files with 4473 additions and 202 deletions
+120 -1
View File
@@ -3,9 +3,14 @@
// session.stream().feed(), outgoing checked via take_outgoing().
#include "../src/net/classic/classic_session.h"
#include <arpa/inet.h>
#include <cstdio>
#include <cstring>
#include <fcntl.h>
#include <poll.h>
#include <string>
#include <sys/socket.h>
#include <unistd.h>
#include <vector>
using namespace mtnet::classic;
@@ -43,6 +48,42 @@ static std::vector<uint8_t> drain(ClassicSession &s) {
return out;
}
static int listen_loopback(uint16_t &port) {
int fd = ::socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
return -1;
}
int one = 1;
::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr.sin_port = 0;
if (::bind(fd, reinterpret_cast<const sockaddr *>(&addr), sizeof(addr)) < 0 ||
::listen(fd, 4) < 0) {
::close(fd);
return -1;
}
socklen_t len = sizeof(addr);
if (::getsockname(fd, reinterpret_cast<sockaddr *>(&addr), &len) < 0) {
::close(fd);
return -1;
}
int flags = ::fcntl(fd, F_GETFL, 0);
::fcntl(fd, F_SETFL, flags | O_NONBLOCK);
port = ntohs(addr.sin_port);
return fd;
}
static bool accept_loopback(int listener, int &accepted) {
pollfd pfd{listener, POLLIN, 0};
if (::poll(&pfd, 1, 500) <= 0) {
return false;
}
accepted = ::accept(listener, nullptr, nullptr);
return accepted >= 0;
}
int main() {
ClassicSession s;
s.set_now(0);
@@ -53,6 +94,7 @@ int main() {
s.on_entered_game = [&]() { entered = true; };
s.start_offline("admin", "123456789");
s.set_auto_entergame_delay(1500);
CHECK(s.stage() == mtnet::INetSession::Stage::Connecting, "start_offline -> Connecting");
// --- handshake: server HANDSHAKE -> client echoes it back ---
@@ -98,6 +140,8 @@ int main() {
ls.players[0].level = 42;
ls.players[0].ht = 20;
ls.players[0].change_name = 1;
ls.players[0].addr = static_cast<int32_t>(0x0100007f); // 127.0.0.1, little-endian lAddr
ls.players[0].port = 13002;
ls.players[1].id = 1002;
std::strcpy(ls.players[1].name, "Sura");
ls.players[1].job = 4;
@@ -110,6 +154,8 @@ int main() {
const auto &sl = s.char_slots();
CHECK(sl.size() == 4 && sl[0].name == "Warrior" && sl[0].level == 42 && sl[0].change_name,
"slot 0 + forced rename flag");
CHECK(sl[0].addr == static_cast<int32_t>(0x0100007f) && sl[0].port == 13002,
"slot 0 game address carried");
CHECK(sl[1].name == "Sura" && sl[1].job == 4, "slot 1");
CHECK(sl[2].empty() && sl[3].empty(), "slots 2,3 empty");
}
@@ -218,6 +264,79 @@ int main() {
CHECK(s.in_game() && entered, "in_game() + on_entered_game");
}
// --- known but unsupported integration packet is not silently accepted ---
{
ClassicSession unsupported;
unsupported.start_offline("admin", "123456789");
std::vector<uint8_t> packet(sizeof(GCMatrixCard), 0);
packet[0] = HDR_GC_MATRIX_CARD;
feed(unsupported, packet);
CHECK(unsupported.stage() == mtnet::INetSession::Stage::Failed,
"unsupported integration packet -> Failed");
CHECK(unsupported.last_error().find("does not handle") != std::string::npos,
"unsupported integration packet -> explicit error");
auto unsupported_headers = unsupported.parser().drain_unhandled_headers();
CHECK(unsupported_headers.size() == 1 && unsupported_headers[0] == HDR_GC_MATRIX_CARD,
"unsupported integration packet -> header event");
}
// --- DirectEnter opens the character slot's TCP endpoint ---
{
uint16_t initial_port = 0;
uint16_t slot_port = 0;
uint16_t warp_port = 0;
const int initial_listener = listen_loopback(initial_port);
const int slot_listener = listen_loopback(slot_port);
const int warp_listener = listen_loopback(warp_port);
if (initial_listener >= 0 && slot_listener >= 0 && warp_listener >= 0) {
ClassicSession direct;
CHECK(direct.connect_with_login_key("127.0.0.1", initial_port, "admin", 0x12345678),
"direct enter initial game connection");
int initial_connection = -1;
CHECK(accept_loopback(initial_listener, initial_connection),
"direct enter first TCP connection accepted");
GCLoginSuccess login{};
login.header = HDR_GC_LOGIN_SUCCESS_NEWSLOT;
login.players[0].id = 2001;
std::strcpy(login.players[0].name, "Direct");
login.players[0].addr = static_cast<int32_t>(0x0100007f); // 127.0.0.1
login.players[0].port = slot_port;
feed(direct, raw(login));
CHECK(direct.char_slots()[0].addr == static_cast<int32_t>(0x0100007f) &&
direct.char_slots()[0].port == slot_port, "direct enter slot endpoint parsed");
CHECK(direct.connect_direct_enter(0), "direct enter reconnect accepted");
int slot_connection = -1;
CHECK(accept_loopback(slot_listener, slot_connection),
"direct enter second TCP connection targets slot endpoint");
CHECK(direct.connect_warp("127.0.0.1", warp_port),
"GC_WARP reconnect keeps selected slot and login key");
int warp_connection = -1;
CHECK(accept_loopback(warp_listener, warp_connection),
"GC_WARP reconnect targets packet endpoint");
if (initial_connection >= 0) {
::close(initial_connection);
}
if (slot_connection >= 0) {
::close(slot_connection);
}
if (warp_connection >= 0) {
::close(warp_connection);
}
} else {
std::puts("SKIP: DirectEnter TCP target test (localhost bind unavailable)");
}
if (initial_listener >= 0) {
::close(initial_listener);
}
if (slot_listener >= 0) {
::close(slot_listener);
}
if (warp_listener >= 0) {
::close(warp_listener);
}
}
// --- GC_MOVE(3): NPC starts moving, interpolation advances on tick ---
{
GCMove mv{};
@@ -248,7 +367,7 @@ int main() {
feed(s, raw(warp));
auto warps = s.world().drain_warps();
CHECK(warps.size() == 1 && warps[0].x == 432100 && warps[0].y == 876500 &&
warps[0].same_server(), "classic GC_WARP parsed and surfaced");
!warps[0].same_server(), "classic GC_WARP always requests reconnect");
}
// --- GC_CHARACTER_POINTS(16): stat block ---