Compare commits

...
3 Commits
Author SHA1 Message Date
shenlei 1db9e9a129 no message 2026-09-16 22:15:52 +09:00
shenleiandClaude Opus 5 400e3e8ea5 Merge classic enter-game framing fixes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
2026-09-12 21:29:22 +09:00
shenleiandClaude Opus 5 85ce75e707 fix(net): 修复 40250 进入游戏时的收包错位
- GC_ITEM_DEL(20) 按 40250 旧结构为 42 字节,GC_REFINE_INFORMATION(95) 为 59 字节
- 公会标记连接接受 GC_MARK_DIFF_DATA(101),按 1 字节消费
- GUILD_SUBHEADER_GC_SKILL_INFO 服务器声明 22 字节但实际只写 21 字节,
  按实际长度分帧,修复 "unknown GC header 200 (last: 75,20)" 断线
- 会话测试的 quest info 包改为 40250 按 flag 定长的格式,新增对应分帧测试

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
2026-09-12 21:29:21 +09:00
18 changed files with 697 additions and 53 deletions
@@ -168,6 +168,10 @@ private:
if (header == HDR_GC_MARK_BLOCK) { if (header == HDR_GC_MARK_BLOCK) {
return receive_block(packet, available); 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); return raw(ClassicStream::RawPacketStatus::NotHandled);
} }
+25 -4
View File
@@ -326,12 +326,20 @@ bool ClassicParser::on_gc(uint8_t header, const uint8_t *body, uint32_t len) {
p.anti_flags, p.sockets, a); p.anti_flags, p.sockets, a);
return true; return true;
} }
case HDR_GC_ITEM_DEL: { // 20 — inventory pos only case HDR_GC_ITEM_DEL: { // 20 — TPacketGCItemDelDeprecated, read as ITEM_SET by 40250
GCItemDel p; GCItemDelDeprecated p;
if (!fill(p, header, body, len)) { if (!fill(p, header, body, len)) {
return false; 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; return true;
} }
case HDR_GC_ITEM_UPDATE: { // 25 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); m_world.mut_observer(ObserverEvent::Remove, packet.vid, 0, 0);
return true; 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: { case HDR_GC_REFINE_INFORMATION: {
GCRefineInfo packet; GCRefineInfo packet;
if (!fill(packet, header, body, len)) { if (!fill(packet, header, body, len)) {
@@ -518,6 +518,16 @@ void ClassicStream::dispatch() {
} }
framed = static_cast<uint32_t>( framed = static_cast<uint32_t>(
quest_info_packet_size(head[QUEST_INFO_HEAD_SIZE - 1])); 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) { if (m_recv.readable() < framed) {
return; // whole packet not here yet return; // whole packet not here yet
+38 -3
View File
@@ -282,6 +282,7 @@ enum : uint8_t {
HDR_GC_OBSERVER_MOVE = 98, HDR_GC_OBSERVER_MOVE = 98,
HDR_GC_VIEW_EQUIP = 99, HDR_GC_VIEW_EQUIP = 99,
HDR_GC_MARK_BLOCK = 100, HDR_GC_MARK_BLOCK = 100,
HDR_GC_MARK_DIFF_DATA = 101, // mark connection only: bare header, ignored
HDR_GC_MARK_IDXLIST = 102, HDR_GC_MARK_IDXLIST = 102,
HDR_GC_TIME = 106, HDR_GC_TIME = 106,
HDR_GC_CHANGE_NAME = 107, 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 GCChangeName { uint8_t header; uint32_t pid; char name[CHARACTER_NAME_MAX_LEN + 1]; };
struct GCRefineMaterial { uint32_t vnum; int32_t count; }; 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 { struct GCRefineInfo {
uint8_t header; uint8_t header;
uint8_t type; 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 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); 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 // GC_MESSENGER is dynamic: after [header][u16 size], body starts with the
// subheader and then the subheader-specific records below. // subheader and then the subheader-specific records below.
struct GCMessengerHead { uint8_t header; uint16_t size; uint8_t subheader; }; 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(GuildName16) == 16 && sizeof(GuildSkill17) == 17 &&
sizeof(GuildComment80) == 80 && sizeof(GuildInvite17) == 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) --- // --- chat (dynamic) ---
struct CGChatHead { uint8_t header; uint16_t length; uint8_t type; }; // + char szChat[] struct CGChatHead { uint8_t header; uint16_t length; uint8_t type; }; // + char szChat[]
static_assert(sizeof(CGChatHead) == 4); 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_POINTS: return sizeof(GCPoints);
case HDR_GC_CHARACTER_POINT_CHANGE: return sizeof(GCPointChange); case HDR_GC_CHARACTER_POINT_CHANGE: return sizeof(GCPointChange);
case HDR_GC_ITEM_SET: return sizeof(GCItemSet); 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_USE: return sizeof(GCItemUse);
case HDR_GC_ITEM_UPDATE: return sizeof(GCItemUpdate); case HDR_GC_ITEM_UPDATE: return sizeof(GCItemUpdate);
case HDR_GC_ITEM_GROUND_ADD: return sizeof(GCItemGroundAdd); 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_LOVE_POINT_UPDATE: return sizeof(GCLovePointUpdate);
case HDR_GC_DIG_MOTION: return sizeof(GCDigMotion); case HDR_GC_DIG_MOTION: return sizeof(GCDigMotion);
case HDR_GC_VIEW_EQUIP: return sizeof(GCViewEquip); 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_REFINE_INFORMATION: return sizeof(GCRefineInfo);
case HDR_GC_DRAGON_SOUL_REFINE: return sizeof(GCDragonSoulRefine); case HDR_GC_DRAGON_SOUL_REFINE: return sizeof(GCDragonSoulRefine);
case HDR_GC_QUEST_CONFIRM: return sizeof(GCQuestConfirm); case HDR_GC_QUEST_CONFIRM: return sizeof(GCQuestConfirm);
+136 -23
View File
@@ -44,6 +44,17 @@ void dbg_ignored(const char *what, uint32_t id) {
} }
} }
// Server GetDegreeFromPosition (game/src/vector.cpp): compass heading of a
// server-frame direction, 0 = +Y, clockwise toward +X, in [0, 360). Same frame
// as the decoded GC_MOVE bRot, i.e. Entity::angle.
float heading_deg(float dx, float dy) {
float deg = std::atan2(dx, dy) * (180.0f / 3.14159265f);
if (deg < 0.0f) {
deg += 360.0f;
}
return deg;
}
} // namespace } // namespace
Entity &EntityStore::touch(uint32_t vid, bool &created) { Entity &EntityStore::touch(uint32_t vid, bool &created) {
@@ -240,7 +251,11 @@ void EntityStore::begin_state_walk(Entity &e, const StateCmd &c, uint8_t after_f
if (dur == 0) { if (dur == 0) {
dur = 1; // never a zero-length walk; tick() finishes it next frame dur = 1; // never a zero-length walk; tick() finishes it next frame
} }
e.angle = c.rot; // Reference MovementProcess faces a walking remote actor along Src->Dst
// (SetAdvancingRotation from the pixel positions); the packet's bRot is only
// applied once it stops (m_fDstRot). Server Follow() can send a bRot that
// faces the victim while Dst is a flank point, so using bRot here slides.
e.angle = dist > 0.0f ? heading_deg(dx, dy) : c.rot;
e.moving = true; e.moving = true;
e.func = FUNC_MOVE; e.func = FUNC_MOVE;
e.sx = e.x; e.sx = e.x;
@@ -3555,37 +3570,135 @@ void EntityStore::tick() {
// CLIENT-GAP §3.2: release any TCP state commands that have come due, then // CLIENT-GAP §3.2: release any TCP state commands that have come due, then
// advance the walk. A queued command released here starts its walk this tick. // advance the walk. A queued command released here starts its walk this tick.
process_states(); process_states();
uint32_t dt_ms = m_ticked && m_now > m_last_tick_ms ? m_now - m_last_tick_ms : 0;
if (dt_ms > MOVE_MAX_TICK_MS) {
dt_ms = MOVE_MAX_TICK_MS;
}
m_last_tick_ms = m_now;
m_ticked = true;
for (auto &kv : m_ents) { for (auto &kv : m_ents) {
Entity &e = kv.second; Entity &e = kv.second;
if (!e.moving) { if (!e.moving) {
continue; continue;
} }
float t = e.move_dur_ms == 0 const float speed = motion_move_speed(e);
? 1.0f if (speed > 0.0f) {
: (float)(m_now - e.move_start_ms) / (float)e.move_dur_ms; advance_walk_by_motion(e, speed, dt_ms);
if (t <= 0.0f) { } else {
t = 0.0f; advance_walk_by_duration(e);
} }
if (t >= 1.0f) { }
e.x = e.tx; }
e.y = e.ty;
void EntityStore::set_motion_speed(uint32_t vid, float walk_cm_s, float run_cm_s) {
auto it = m_ents.find(vid);
if (it == m_ents.end()) {
dbg_ignored("set_motion_speed", vid);
return;
}
it->second.walk_motion_speed = walk_cm_s > 0.0f ? walk_cm_s : 0.0f;
it->second.run_motion_speed = run_cm_s > 0.0f ? run_cm_s : 0.0f;
}
// Reference CActorInstance::Move(): SetLoopMotion(m_isWalking ? WALK : RUN, 0.15,
// m_fMovSpd) and CInstanceBase::SetMoveSpeed: m_fMovSpd = movSpd / 100 (> 1100 ->
// 0). The walk then advances by that motion's accumulation. Deviations: movSpd 0
// (not sent yet) counts as 100, and a zero factor (> 1100) falls back to the
// dwDuration lerp instead of freezing the actor mid-walk. The pushed speeds are
// the on-foot motions, so a mounted actor (horse motion set not measured) also
// uses the lerp.
float EntityStore::motion_move_speed(const Entity &e) {
const float base = e.walk_mode == WALKMODE_WALK ? e.walk_motion_speed : e.run_motion_speed;
if (base <= 0.0f || e.moving_speed > 1100 || e.mount_vnum != 0) {
return 0.0f;
}
const float factor = e.moving_speed == 0 ? 1.0f : (float)e.moving_speed / 100.0f;
return base * factor;
}
// Reference m_kMovAfterFunc switch on arrival: COMBO / ATTACK / MOB_SKILL snap
// to Dst and act; everything else snaps to Dst, faces m_fDstRot and stops.
void EntityStore::finish_walk(Entity &e, bool snap_to_dst) {
if (snap_to_dst) {
e.x = e.tx;
e.y = e.ty;
}
e.moving = false;
e.skip_collision = false;
if (e.mov_after_func == FUNC_COMBO || e.mov_after_func == FUNC_ATTACK ||
e.mov_after_func == FUNC_MOB_SKILL) {
e.func = e.mov_after_func;
} else {
e.func = FUNC_WAIT;
}
e.angle = e.mov_after_rot;
e.mov_after_func = FUNC_WAIT;
}
// Reference CInstanceBase::MovementProcess (non-main branch). Src/Dst are the
// server-frame points from the state command; Cur advances along Src->Dst by
// the motion speed and "arrives" on the frame whose step crosses Dst.
void EntityStore::advance_walk_by_motion(Entity &e, float speed, uint32_t dt_ms) {
float dir_x = e.tx - e.sx;
float dir_y = e.ty - e.sy;
float total = std::sqrt(dir_x * dir_x + dir_y * dir_y);
if (total > 0.0f) {
e.angle = heading_deg(dir_x, dir_y);
dir_x /= total;
dir_y /= total;
}
const float step = speed * (float)dt_ms / 1000.0f;
const float next_x = e.x + dir_x * step;
const float next_y = e.y + dir_y * step;
const float cx = e.x - e.sx, cy = e.y - e.sy;
const float nx = next_x - e.sx, ny = next_y - e.sy;
const float cur_len = std::sqrt(cx * cx + cy * cy);
const float next_len = std::sqrt(nx * nx + ny * ny);
if (total - cur_len < -MOVE_OVERSHOOT_CM) {
// latency overran Dst: re-source here, turn back toward Dst, and stop at
// the next arrival instead of carrying on with FUNC_MOVE.
e.sx = e.x;
e.sy = e.y;
e.angle = heading_deg(e.tx - e.x, e.ty - e.y);
if (e.mov_after_func == FUNC_MOVE) {
e.mov_after_func = FUNC_WAIT;
}
} else if (cur_len <= total && total <= next_len) {
if (e.dead || e.knock_down) {
e.moving = false; e.moving = false;
e.skip_collision = false; e.skip_collision = false;
// reference m_kMovAfterFunc: once the walk reaches Dst, run the return;
// action the state packet asked for (COMBO / ATTACK / MOB_SKILL);
// otherwise settle to WAIT.
if (e.mov_after_func == FUNC_COMBO || e.mov_after_func == FUNC_ATTACK ||
e.mov_after_func == FUNC_MOB_SKILL) {
e.func = e.mov_after_func;
e.angle = e.mov_after_rot;
} else {
e.func = FUNC_WAIT;
}
e.mov_after_func = FUNC_WAIT;
} else {
e.x = e.sx + (e.tx - e.sx) * t;
e.y = e.sy + (e.ty - e.sy) * t;
} }
if (e.mov_after_func == FUNC_MOVE) {
// FUNC_MOVE: keep walking past Dst until the next state command (or
// the overshoot guard above) — this is what keeps a moving remote PC
// from stop-starting between its periodic move packets.
e.x = next_x;
e.y = next_y;
return;
}
finish_walk(e, true);
return;
}
e.x = next_x;
e.y = next_y;
}
// Fallback when the actor's motion speed is unknown: lerp Src->Dst over the
// server-supplied dwDuration and stop on time.
void EntityStore::advance_walk_by_duration(Entity &e) {
float t = e.move_dur_ms == 0
? 1.0f
: (float)(m_now - e.move_start_ms) / (float)e.move_dur_ms;
if (t <= 0.0f) {
t = 0.0f;
}
if (t >= 1.0f) {
finish_walk(e, true);
} else {
e.x = e.sx + (e.tx - e.sx) * t;
e.y = e.sy + (e.ty - e.sy) * t;
} }
} }
+33 -1
View File
@@ -51,6 +51,20 @@ struct StateCmd {
// CLIENT-GAP-FIX.md §3.2. // CLIENT-GAP-FIX.md §3.2.
static constexpr uint32_t STATE_QUEUE_MAX_WAIT_MS = 1000; static constexpr uint32_t STATE_QUEUE_MAX_WAIT_MS = 1000;
// GC_WALK_MODE / TPacketGCWalkMode::mode (40250 Packet.h:2327, server packet.h:1946).
enum : uint8_t {
WALKMODE_RUN = 0,
WALKMODE_WALK = 1,
};
// Reference CInstanceBase::MovementProcess (non-main branch): a remote actor that
// has walked more than this far past its Dst re-sources the walk at its current
// position, turns back toward Dst and downgrades a pending FUNC_MOVE to FUNC_WAIT.
static constexpr float MOVE_OVERSHOOT_CM = 100.0f;
// Cap on one tick's walk step so a hitch (or the first tick after a long pause)
// cannot teleport an actor far past its Dst in a single frame.
static constexpr uint32_t MOVE_MAX_TICK_MS = 250;
struct Entity { struct Entity {
uint32_t vid = 0; uint32_t vid = 0;
uint16_t race = 0; uint16_t race = 0;
@@ -65,7 +79,7 @@ struct Entity {
uint8_t func = FUNC_WAIT; uint8_t func = FUNC_WAIT;
uint8_t position = 0; uint8_t position = 0;
uint8_t walk_mode = 0; // 0 walk, 1 run (server's WALKMODE_*) uint8_t walk_mode = WALKMODE_RUN; // server WALKMODE_* (0 run, 1 walk)
uint32_t fly_target_vid = 0; uint32_t fly_target_vid = 0;
int32_t fly_target_x = 0, fly_target_y = 0; int32_t fly_target_x = 0, fly_target_y = 0;
bool fly_target_set = false; bool fly_target_set = false;
@@ -74,6 +88,14 @@ struct Entity {
float sx = 0, sy = 0, tx = 0, ty = 0; float sx = 0, sy = 0, tx = 0, ty = 0;
uint32_t move_start_ms = 0; uint32_t move_start_ms = 0;
uint32_t move_dur_ms = 0; uint32_t move_dur_ms = 0;
// Root-motion speed of the race's WALK / RUN loop at movSpd 100, in cm/s
// (.msa Accumulation length / MotionDuration). Pushed by the presentation
// layer once the model is built (set_motion_speed). While known, a remote
// walk advances like the reference CActorInstance::AccumulationMovement
// (motion speed * movSpd/100) and arrives by crossing Dst; while 0 (no model /
// headless) it falls back to lerping over the server dwDuration.
float walk_motion_speed = 0;
float run_motion_speed = 0;
// CLIENT-GAP §3.2: TCP state queue + the "do this once the walk reaches Dst" // CLIENT-GAP §3.2: TCP state queue + the "do this once the walk reaches Dst"
// latch (reference m_kMovAfterFunc / m_dwMovAfterArg). `skip_collision` mirrors // latch (reference m_kMovAfterFunc / m_dwMovAfterArg). `skip_collision` mirrors
@@ -516,6 +538,9 @@ public:
// and again right after mut_move() enqueues, so an already-due command applies // and again right after mut_move() enqueues, so an already-due command applies
// synchronously. // synchronously.
void process_states(); void process_states();
// Root-motion speeds (cm/s at movSpd 100) of this actor's WALK / RUN loops;
// 0 = unknown. See Entity::walk_motion_speed. No-op for an unknown VID.
void set_motion_speed(uint32_t vid, float walk_cm_s, float run_cm_s);
// Feed one complete game-phase packet. `body` points at the packet start // Feed one complete game-phase packet. `body` points at the packet start
// (header/length included); `len` == that length. Unknown headers ignored. // (header/length included); `len` == that length. Unknown headers ignored.
@@ -1176,11 +1201,18 @@ private:
void begin_state_walk(Entity &e, const StateCmd &c, uint8_t after_func); // Src/Dst + skip-collision void begin_state_walk(Entity &e, const StateCmd &c, uint8_t after_func); // Src/Dst + skip-collision
static bool can_process_network_state(const Entity &e); // ~ __CanProcessNetworkStatePacket static bool can_process_network_state(const Entity &e); // ~ __CanProcessNetworkStatePacket
static bool is_enable_tcp_process(const Entity &e, uint8_t func); // ~ __IsEnableTCPProcess static bool is_enable_tcp_process(const Entity &e, uint8_t func); // ~ __IsEnableTCPProcess
// Remote-walk advance (reference CInstanceBase::MovementProcess, non-main).
static float motion_move_speed(const Entity &e); // cm/s, 0 = use dwDuration lerp
static void finish_walk(Entity &e, bool snap_to_dst); // m_kMovAfterFunc on arrival
void advance_walk_by_motion(Entity &e, float speed, uint32_t dt_ms);
void advance_walk_by_duration(Entity &e);
std::unordered_map<uint32_t, Entity> m_ents; std::unordered_map<uint32_t, Entity> m_ents;
uint32_t m_main_vid = 0; uint32_t m_main_vid = 0;
uint32_t m_now = 0; uint32_t m_now = 0;
uint32_t m_server_frame_ms = 0; uint32_t m_server_frame_ms = 0;
uint32_t m_last_tick_ms = 0; // m_now at the previous tick() (motion-driven walk dt)
bool m_ticked = false;
std::vector<Change> m_changes; std::vector<Change> m_changes;
std::vector<uint32_t> m_dirty; std::vector<uint32_t> m_dirty;
std::string m_bgm_name; std::string m_bgm_name;
+9
View File
@@ -100,6 +100,8 @@ void M2Client::_bind_methods() {
ClassDB::bind_method(D_METHOD("quest_cancel"), &M2Client::quest_cancel); ClassDB::bind_method(D_METHOD("quest_cancel"), &M2Client::quest_cancel);
ClassDB::bind_method(D_METHOD("get_quests"), &M2Client::get_quests); ClassDB::bind_method(D_METHOD("get_quests"), &M2Client::get_quests);
ClassDB::bind_method(D_METHOD("get_entity", "vid"), &M2Client::get_entity); ClassDB::bind_method(D_METHOD("get_entity", "vid"), &M2Client::get_entity);
ClassDB::bind_method(D_METHOD("set_entity_motion_speed", "vid", "walk_cm_s", "run_cm_s"),
&M2Client::set_entity_motion_speed);
ClassDB::bind_method(D_METHOD("get_entities"), &M2Client::get_entities); ClassDB::bind_method(D_METHOD("get_entities"), &M2Client::get_entities);
ClassDB::bind_method(D_METHOD("get_main_vid"), &M2Client::get_main_vid); ClassDB::bind_method(D_METHOD("get_main_vid"), &M2Client::get_main_vid);
ClassDB::bind_method(D_METHOD("get_main_pid"), &M2Client::get_main_pid); ClassDB::bind_method(D_METHOD("get_main_pid"), &M2Client::get_main_pid);
@@ -2523,6 +2525,13 @@ const mtnet::EntityStore *M2Client::active_world() const {
return game ? &game->world() : nullptr; return game ? &game->world() : nullptr;
} }
void M2Client::set_entity_motion_speed(int vid, double walk_cm_s, double run_cm_s) {
mtnet::EntityStore *w = classic_sess ? &classic_sess->world() : (game ? &game->world() : nullptr);
if (w) {
w->set_motion_speed((uint32_t)vid, (float)walk_cm_s, (float)run_cm_s);
}
}
Dictionary M2Client::get_entity(int vid) const { Dictionary M2Client::get_entity(int vid) const {
const mtnet::EntityStore *w = active_world(); const mtnet::EntityStore *w = active_world();
if (!w) { if (!w) {
+3
View File
@@ -288,6 +288,9 @@ public:
// --- networked world snapshot (positions already Godot-space, metres) --- // --- networked world snapshot (positions already Godot-space, metres) ---
godot::Dictionary get_entity(int vid) const; godot::Dictionary get_entity(int vid) const;
// Root-motion speeds (cm/s at movSpd 100) of a remote actor's WALK / RUN loop,
// measured by the view from its .msa; drives the reference-style remote walk.
void set_entity_motion_speed(int vid, double walk_cm_s, double run_cm_s);
godot::Array get_entities() const; godot::Array get_entities() const;
int get_main_vid() const; int get_main_vid() const;
int get_main_pid() const; int get_main_pid() const;
@@ -59,6 +59,12 @@ static void test_mark_download() {
assert(request.size() == sizeof(CGMarkIDXList)); assert(request.size() == sizeof(CGMarkIDXList));
assert(request[0] == HDR_CG_MARK_IDXLIST); 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 // One guild maps to image zero, position one. Feed the whole-size frame in
// two chunks to exercise the custom raw framing path. // two chunks to exercise the custom raw framing path.
std::vector<uint8_t> idx(sizeof(GCMarkIDXList) + 4); std::vector<uint8_t> idx(sizeof(GCMarkIDXList) + 4);
+115 -16
View File
@@ -183,6 +183,26 @@ int main() {
"on_char_list fired with renamed slot"); "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<uint8_t> cf(10, 0);
cf[0] = HDR_GC_CREATE_FAILURE;
GCPlayerCreateFailure dup{HDR_GC_CREATE_FAILURE, 1};
std::vector<uint8_t> 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] --- // --- select_char(0) -> CG_CHARACTER_SELECT + seq[1] ---
{ {
CHECK(s.select_char(0), "select_char(0) ok"); CHECK(s.select_char(0), "select_char(0) ok");
@@ -523,9 +543,84 @@ int main() {
feed(s, raw(iu)); feed(s, raw(iu));
CHECK(s.world().item_slot(1, 4).count == 3, "item_update count -> 3"); CHECK(s.world().item_slot(1, 4).count == 3, "item_update count -> 3");
GCItemDel id{HDR_GC_ITEM_DEL, 4}; // GC 20 is TPacketGCItemDelDeprecated on the 40250 server (char_item.cpp:424,
feed(s, raw(id)); // 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<uint8_t> 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<uint8_t> glued = del;
std::vector<uint8_t> 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, 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<uint8_t> 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<uint8_t> 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<uint8_t> 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) / // --- GC entity state: UPDATE(19) / CHANGE_SPEED(18) / POSITION(43) / MOTION(36) /
@@ -743,22 +838,26 @@ int main() {
cf[0].request_pid == 555, cf[0].request_pid == 555,
"quest confirm cue"); "quest confirm cue");
// GC_QUEST_INFO dynamic: [hdr 81][u16 size][u16 index][u8 flag][TITLE\0][COUNTER_NAME\0][i32] // GC_QUEST_INFO (questpc.cpp PC::SendQuestInfoPakcet): [hdr 81][u16 size][u16 index]
std::vector<uint8_t> qb; // [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<uint8_t> 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; uint16_t qidx = 3;
qb.insert(qb.end(), (uint8_t *)&qidx, (uint8_t *)&qidx + 2); std::memcpy(&qpkt[3], &qidx, 2);
uint8_t qflag = QUEST_SEND_TITLE | QUEST_SEND_COUNTER_NAME | QUEST_SEND_COUNTER_VALUE; qpkt[5] = QUEST_SEND_TITLE | QUEST_SEND_COUNTER_NAME | QUEST_SEND_COUNTER_VALUE;
qb.push_back(qflag); std::vector<uint8_t> qt(QUEST_INFO_TITLE_SIZE, 0);
const char *qt = "Kill 10 wolves"; std::memcpy(qt.data(), "Kill 10 wolves", 14);
qb.insert(qb.end(), qt, qt + std::strlen(qt) + 1); qpkt.insert(qpkt.end(), qt.begin(), qt.end());
const char *cn = "Wolves"; std::vector<uint8_t> cn(QUEST_INFO_COUNTER_NAME_SIZE, 0);
qb.insert(qb.end(), cn, cn + std::strlen(cn) + 1); std::memcpy(cn.data(), "Wolves", 6);
qpkt.insert(qpkt.end(), cn.begin(), cn.end());
int32_t cv = 4; int32_t cv = 4;
qb.insert(qb.end(), (uint8_t *)&cv, (uint8_t *)&cv + 4); qpkt.insert(qpkt.end(), (uint8_t *)&cv, (uint8_t *)&cv + 4);
uint16_t qsz = (uint16_t)(3 + qb.size()); CHECK(qpkt.size() == static_cast<size_t>(quest_info_packet_size(qpkt[5])),
std::vector<uint8_t> qpkt = {HDR_GC_QUEST_INFO}; "quest info test packet matches 40250 flag-driven length");
qpkt.insert(qpkt.end(), (uint8_t *)&qsz, (uint8_t *)&qsz + 2);
qpkt.insert(qpkt.end(), qb.begin(), qb.end());
feed(s, qpkt); feed(s, qpkt);
const mtnet::QuestInfo *q = s.world().quest(3); const mtnet::QuestInfo *q = s.world().quest(3);
CHECK(q && q->title == "Kill 10 wolves" && q->counter_name == "Wolves" && CHECK(q && q->title == "Kill 10 wolves" && q->counter_name == "Wolves" &&
@@ -5,6 +5,7 @@
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
#include <string> #include <string>
#include <utility>
#include <vector> #include <vector>
using namespace mtnet::classic; using namespace mtnet::classic;
@@ -248,6 +249,46 @@ int main() {
CHECK(s.outgoing_pending() == 0, "unknown header does not resync into trailing bytes"); 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<std::pair<uint8_t, std::vector<uint8_t>>> got;
s.on_packet = [&](uint8_t h, const uint8_t *body, uint32_t len) {
got.push_back({h, std::vector<uint8_t>(body, body + len)});
return true;
};
std::vector<uint8_t> skill = {HDR_GC_GUILD, 22, 0, 12, 3};
for (uint8_t i = 0; i < 12; ++i) {
skill.push_back(static_cast<uint8_t>(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<uint8_t> 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<uint8_t> 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 // ---------------------------------------------------------- rejected handler
{ {
ClassicStream s; ClassicStream s;
+83
View File
@@ -152,6 +152,89 @@ int main() {
"chk_time past cap: released immediately (staleness valve)"); "chk_time past cap: released immediately (staleness valve)");
} }
// --- 7) motion-driven remote walk (reference MovementProcess) --------------
// Speed comes from the WALK/RUN root motion * movSpd/100, not dwDuration;
// heading follows Src->Dst while walking and takes bRot on arrival.
{
EntityStore es;
es.set_now(0);
es.mut_spawn(70, 101, 2, "Wolf", 0, 0, 0, 0, 100 /*moving_speed*/, 0);
es.set_motion_speed(70, 87.5f, 425.0f);
es.tick();
// server says 100 ms (it prices NPC walks at RUN speed); ignored here.
es.mut_move(70, 45.0f, FUNC_WAIT, 1000.0f, 0.0f, 100, 0, 0);
CHECK(es.get(70)->moving && std::abs(es.get(70)->angle - 90.0f) < 1e-3f,
"motion walk: faces Src->Dst, not bRot");
uint32_t now = 0;
for (int i = 0; i < 10; ++i) {
now += 100;
es.set_now(now);
es.tick();
}
CHECK(es.get(70)->moving && std::abs(es.get(70)->x - 425.0f) < 1.0f,
"motion walk: run mode advances at RUN motion speed, ignores dwDuration");
es.mut_walk_mode(70, WALKMODE_WALK);
for (int i = 0; i < 10; ++i) {
now += 100;
es.set_now(now);
es.tick();
}
CHECK(std::abs(es.get(70)->x - 512.5f) < 1.0f, "motion walk: walk mode uses WALK motion speed");
es.mut_change_speed(70, 200); // movSpd 200 doubles the step
now += 1000;
es.set_now(now);
es.tick(); // capped at MOVE_MAX_TICK_MS
CHECK(std::abs(es.get(70)->x - (512.5f + 175.0f * 0.25f)) < 1.0f,
"motion walk: movSpd scales speed; tick step capped");
for (int i = 0; i < 100 && es.get(70)->moving; ++i) {
now += 100;
es.set_now(now);
es.tick();
}
CHECK(!es.get(70)->moving && es.get(70)->func == FUNC_WAIT, "motion walk: stops on arrival");
CHECK(es.get(70)->x == 1000.0f && es.get(70)->y == 0.0f, "motion walk: snaps to Dst");
CHECK(std::abs(es.get(70)->angle - 45.0f) < 1e-3f, "motion walk: bRot applied on arrival");
}
// --- 8) FUNC_MOVE walks past Dst, overshoot guard turns back and stops ------
{
EntityStore es;
es.set_now(0);
es.mut_spawn(80, 0, 0, "Pc", 0, 0, 0, 0, 100, 0);
es.set_motion_speed(80, 0.0f, 1000.0f);
es.tick();
es.mut_move(80, 90.0f, FUNC_MOVE, 100.0f, 0.0f, 100, 0, 0);
uint32_t now = 0;
for (int i = 0; i < 3; ++i) {
now += 50;
es.set_now(now);
es.tick();
}
CHECK(es.get(80)->moving && es.get(80)->x > 100.0f, "FUNC_MOVE: keeps walking past Dst");
bool turned = false;
for (int i = 0; i < 40 && es.get(80)->moving; ++i) {
now += 50;
es.set_now(now);
es.tick();
turned = turned || std::abs(es.get(80)->angle - 270.0f) < 1e-3f;
}
CHECK(turned, "FUNC_MOVE overshoot: turns back toward Dst");
CHECK(!es.get(80)->moving && es.get(80)->x == 100.0f && es.get(80)->func == FUNC_WAIT,
"FUNC_MOVE overshoot: returns to Dst and stops");
}
// --- 9) unknown motion speed keeps the dwDuration lerp, heading Src->Dst -----
{
EntityStore es;
es.set_now(0);
spawn(es, 90, 0, 0);
es.mut_move(90, 0.0f, FUNC_WAIT, 0.0f, -500.0f, 1000, 0, 0);
CHECK(std::abs(es.get(90)->angle - 180.0f) < 1e-3f, "duration walk: faces Src->Dst");
es.set_now(500);
es.tick();
CHECK(std::abs(es.get(90)->y + 250.0f) < 1e-3f, "duration walk: lerps over dwDuration");
}
if (g_fail == 0) { if (g_fail == 0) {
std::printf("PASS: net_state_queue_test (§3.2 TCP state queue / thresholds / gates)\n"); std::printf("PASS: net_state_queue_test (§3.2 TCP state queue / thresholds / gates)\n");
} }
+44
View File
@@ -5,9 +5,11 @@ extends SceneTree
const MobView = preload("res://ui/mob_view.gd") const MobView = preload("res://ui/mob_view.gd")
const Audio = preload("res://audio.gd") const Audio = preload("res://audio.gd")
const MsaMotion = preload("res://ui/msa_motion.gd")
var _fail := 0 var _fail := 0
var _resolution_done := false var _resolution_done := false
var _move_speed_done := false
func _ck(c: bool, m: String) -> void: func _ck(c: bool, m: String) -> void:
if not c: if not c:
_fail += 1 _fail += 1
@@ -26,6 +28,7 @@ class FakeAnim:
extends Node extends Node
var anim_path := "" var anim_path := ""
var loop := false var loop := false
var time_scale := 1.0
# 纯解析:不需要扩展和资产。旧客户端 CRaceData::GetMotionKey 找不到动作时 # 纯解析:不需要扩展和资产。旧客户端 CRaceData::GetMotionKey 找不到动作时
# SetLoopMotion/InterceptMotion 直接返回、保留当前动作;不能静默换成 WAIT。 # SetLoopMotion/InterceptMotion 直接返回、保留当前动作;不能静默换成 WAIT。
@@ -57,8 +60,49 @@ func _check_motion_resolution() -> void:
mv.free() mv.free()
_resolution_done = true _resolution_done = true
# CActorInstance::Move:走 / 跑按 m_fMovSpd = movSpd/100 播放;根运动速度
# = |.msa Accumulation| / MotionDurationwolf 03.msa255.45 / 0.6 = 425.75 cm/s)。
func _write_msa(path: String, duration: float, ay: float) -> void:
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string("ScriptType MotionData\n\nMotionFileName \"x.gr2\"\n" +
"MotionDuration %f\nAccumulation 0.00\t%.2f\t0.00\n" % [duration, ay])
f.close()
func _check_move_speed() -> void:
var walk_p := "user://__mob_view_test_walk.msa"
var run_p := "user://__mob_view_test_run.msa"
_write_msa(walk_p, 0.866667, -75.81)
_write_msa(run_p, 0.6, -255.45)
_ck(absf(MsaMotion.move_speed(run_p) - 425.75) < 0.01, "run msa 速度 425.75%f" % MsaMotion.move_speed(run_p))
_ck(absf(MsaMotion.move_speed(walk_p) - 87.47) < 0.01, "walk msa 速度 87.47%f" % MsaMotion.move_speed(walk_p))
_ck(MsaMotion.move_speed("user://__no_such.msa") == 0.0, "缺文件 -> 0")
var mv: Node3D = MobView.new()
mv._dir = "res://__no_such_mob_dir__"
mv._motions = {"WAIT": "wait.msa", "WALK": walk_p, "RUN": run_p}
var sp: Vector2 = mv.get_move_motion_speeds()
_ck(absf(sp.x - 87.47) < 0.01 and absf(sp.y - 425.75) < 0.01, "get_move_motion_speeds%s" % sp)
var anim := FakeAnim.new()
mv.anim = anim
mv.set_move_speed(200)
mv.set_anim_state("wait")
_ck(anim.time_scale == 1.0, "wait 不受移动速度影响")
mv.set_anim_state("run")
_ck(anim.anim_path == run_p and anim.time_scale == 2.0, "run 按 movSpd/100 播放(%f" % anim.time_scale)
mv.set_move_speed(150)
_ck(anim.time_scale == 1.5, "跑动中改速立即生效(%f" % anim.time_scale)
mv.set_move_speed(1200)
_ck(anim.time_scale == 1.0, ">1100 保持原速(%f" % anim.time_scale)
mv.anim = null
anim.free()
mv.free()
DirAccess.remove_absolute(ProjectSettings.globalize_path(walk_p))
DirAccess.remove_absolute(ProjectSettings.globalize_path(run_p))
_move_speed_done = true
func _run() -> void: func _run() -> void:
_check_motion_resolution() _check_motion_resolution()
_check_move_speed()
_ck(_move_speed_done, "移动速度断言全部执行")
# 脚本运行时错误只会中止函数、不计失败;用哨兵保证整段断言都执行过。 # 脚本运行时错误只会中止函数、不计失败;用哨兵保证整段断言都执行过。
_ck(_resolution_done, "动作解析断言全部执行") _ck(_resolution_done, "动作解析断言全部执行")
if not ClassDB.class_exists("Metin2Model") or not ClassDB.class_exists("Metin2Proto"): if not ClassDB.class_exists("Metin2Model") or not ClassDB.class_exists("Metin2Proto"):
+27 -4
View File
@@ -48,6 +48,12 @@ const FUNC_COMBO := 3
const FUNC_MOB_SKILL := 4 const FUNC_MOB_SKILL := 4
const FUNC_EMOTION := 5 const FUNC_EMOTION := 5
const FUNC_SKILL := 0x80 const FUNC_SKILL := 0x80
# packet.h EWalkModesGC_WALK_MODE / GC_CHARACTER_ADD_INFO.bWalkMode
const WALKMODE_RUN := 0
const WALKMODE_WALK := 1
# CActorInstance c_fDefaultRotationSpeed / c_fDefaultHorseRotationSpeed(度/秒)
const ROTATION_SPEED := 1200.0
const ROTATION_SPEED_HORSE := 300.0
# EDamageFlag / IsShowDamage / ProcessDamage 分类见 damage_effect.gdDamageEffect.DAMAGE_*)。 # EDamageFlag / IsShowDamage / ProcessDamage 分类见 damage_effect.gdDamageEffect.DAMAGE_*)。
@@ -423,12 +429,26 @@ func _on_spawn(d: Dictionary) -> void:
_refresh_shop_sign(node, str(d.get("shop_sign", ""))) _refresh_shop_sign(node, str(d.get("shop_sign", "")))
_refresh_pvp_tag(vid) _refresh_pvp_tag(vid)
_apply_field_updates(node, d) _apply_field_updates(node, d)
_push_motion_speeds(vid, node)
_apply_name_color(vid) _apply_name_color(vid)
_apply_text_tail(vid) _apply_text_tail(vid)
entity_added.emit(node, vid) entity_added.emit(node, vid)
if vid == _main_vid: if vid == _main_vid:
main_entity_ready.emit(node, vid) main_entity_ready.emit(node, vid)
# CActorInstance::Move -> SetLoopMotion(WALK/RUN, .., m_fMovSpd):远端位移按动作根运动
# 累计(.msa Accumulation / MotionDuration × movSpd/100)推进。模型建好后把走 / 跑
# 速度交给 EntityStore;占位胶囊 / 骑马(马的动作不在 general 目录)不推,EntityStore
# 退回按服务端 duration 线性插值。
func _push_motion_speeds(vid: int, n: Node3D) -> void:
if not n.has_method("get_move_motion_speeds") or not client.has_method("set_entity_motion_speed"):
return
if int(n.get_meta("mount_vnum", 0)) != 0:
return
var sp: Vector2 = n.call("get_move_motion_speeds")
if sp.x > 0.0 or sp.y > 0.0:
client.call("set_entity_motion_speed", vid, sp.x, sp.y)
# GC_CHAR_ADD_INFO 到了:刷新头顶名字 / HP 上限(节点已存在)。 # GC_CHAR_ADD_INFO 到了:刷新头顶名字 / HP 上限(节点已存在)。
func _on_info(vid: int, d: Dictionary) -> void: func _on_info(vid: int, d: Dictionary) -> void:
var n: Node3D = _by_vid.get(vid, null) var n: Node3D = _by_vid.get(vid, null)
@@ -1287,12 +1307,15 @@ func _process(dt: float) -> void:
want = _pushed_position(vid, e, dt, want) want = _pushed_position(vid, e, dt, want)
n.position = want n.position = want
# 朝向:Metin2 heading(度) -> Godot yaw。经验换算,真机再校准。 # 朝向:Metin2 heading(度) -> Godot yaw。经验换算,真机再校准。
# CActorInstance::RotationProcess:匀角速度转向 c_fDefaultRotationSpeed 1200°/s
# 骑马 c_fDefaultHorseRotationSpeed 300°/s(而不是指数逼近)。
var yaw := MapCoord.heading_to_yaw(float(e.get("angle_deg", 0.0))) var yaw := MapCoord.heading_to_yaw(float(e.get("angle_deg", 0.0)))
n.rotation.y = lerp_angle(n.rotation.y, yaw, clampf(10.0 * dt, 0.0, 1.0)) var turn_rate := ROTATION_SPEED_HORSE if int(n.get_meta("mount_vnum", 0)) != 0 else ROTATION_SPEED
n.rotation.y = rotate_toward(n.rotation.y, yaw, deg_to_rad(turn_rate) * dt)
# 动画状态 # 动画状态
var f := int(e.get("func", FUNC_WAIT)) var f := int(e.get("func", FUNC_WAIT))
var moving := bool(e.get("moving", false)) var moving := bool(e.get("moving", false))
var walk_mode := int(e.get("walk_mode", 1)) var walk_mode := int(e.get("walk_mode", WALKMODE_RUN))
var dead := bool(e.get("dead", false)) var dead := bool(e.get("dead", false))
if f != int(n.get_meta("func", -1)) or moving != bool(n.get_meta("moving", false)) \ if f != int(n.get_meta("func", -1)) or moving != bool(n.get_meta("moving", false)) \
or walk_mode != int(n.get_meta("walk_mode", -1)) or dead != bool(n.get_meta("dead", false)): or walk_mode != int(n.get_meta("walk_mode", -1)) or dead != bool(n.get_meta("dead", false)):
@@ -1303,13 +1326,13 @@ func _process(dt: float) -> void:
_apply_anim(n, f, moving, walk_mode, dead) _apply_anim(n, f, moving, walk_mode, dead)
_update_hp_bars() _update_hp_bars()
func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := 1, dead := false) -> void: func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := WALKMODE_RUN, dead := false) -> void:
var state := "wait" var state := "wait"
if dead: if dead:
# CActorInstance::__SetMotionIsDead() 时拒绝 DEAD/DAMAGE_FLYING 以外的动作。 # CActorInstance::__SetMotionIsDead() 时拒绝 DEAD/DAMAGE_FLYING 以外的动作。
state = "dead" state = "dead"
elif f == FUNC_MOVE or (f == FUNC_WAIT and moving): elif f == FUNC_MOVE or (f == FUNC_WAIT and moving):
state = "walk" if walk_mode == 0 else "run" state = "walk" if walk_mode == WALKMODE_WALK else "run"
elif f == FUNC_ATTACK or f == FUNC_COMBO: elif f == FUNC_ATTACK or f == FUNC_COMBO:
state = "attack" state = "attack"
elif f == FUNC_MOB_SKILL or (f & FUNC_SKILL) != 0: elif f == FUNC_MOB_SKILL or (f & FUNC_SKILL) != 0:
+39
View File
@@ -24,6 +24,9 @@ class FakeClient extends Node:
signal entity_dead(vid: int) signal entity_dead(vid: int)
signal damage(vid: int, amount: int, flag: int) signal damage(vid: int, amount: int, flag: int)
var ents := {} var ents := {}
var motion_speeds := {} # vid -> Vector2(walk, run)
func set_entity_motion_speed(vid: int, walk: float, run: float) -> void:
motion_speeds[vid] = Vector2(walk, run)
func get_entity(vid: int) -> Dictionary: return ents.get(vid, {}) func get_entity(vid: int) -> Dictionary: return ents.get(vid, {})
func get_entities() -> Array: return ents.values() func get_entities() -> Array: return ents.values()
func get_pvp_relations() -> Array: return [] func get_pvp_relations() -> Array: return []
@@ -126,6 +129,7 @@ func _run() -> void:
_ck(int(added.get(2, 0)) == 2, "entity 2 rebuilt exactly once on re-entry") _ck(int(added.get(2, 0)) == 2, "entity 2 rebuilt exactly once on re-entry")
await _dead_motion(fc, nw) await _dead_motion(fc, nw)
await _walk_mode_motion(fc, nw)
class AnimView extends Node3D: class AnimView extends Node3D:
var states: Array[String] = [] var states: Array[String] = []
@@ -159,3 +163,38 @@ func _dead_motion(fc: FakeClient, nw: Node) -> void:
fc.entity_dead.emit(7) fc.entity_dead.emit(7)
await create_tween().tween_interval(0.4).finished await create_tween().tween_interval(0.4).finished
_ck(placeholder != null and placeholder.rotation.x < -1.0, "placeholder capsule (no motions) still falls over as its only death cue") _ck(placeholder != null and placeholder.rotation.x < -1.0, "placeholder capsule (no motions) still falls over as its only death cue")
class MoveView extends AnimView:
func get_move_motion_speeds() -> Vector2:
return Vector2(87.5, 425.75)
# packet.h WALKMODE_RUN = 0 / WALKMODE_WALK = 1CActorInstance::Move 按 m_isWalking
# 选 WALK / RUN。模型建好后把走 / 跑根运动速度推给 EntityStore(骑马不推)。
func _walk_mode_motion(fc: FakeClient, nw: Node) -> void:
nw.set_model_factory(func(_d: Dictionary) -> Node3D: return MoveView.new())
var walker := fc.add(8, NEAR_CM + Vector2(900, 0), {"race": 101, "ch_type": 2, "func": 1, "walk_mode": 1})
fc.entity_spawned.emit(walker)
var runner := fc.add(9, NEAR_CM + Vector2(1200, 0), {"race": 101, "ch_type": 2, "func": 1, "walk_mode": 0})
fc.entity_spawned.emit(runner)
var rider := fc.add(10, NEAR_CM + Vector2(1500, 0), {"race": 101, "ch_type": 0, "mount_vnum": 20030})
fc.entity_spawned.emit(rider)
await process_frame
await process_frame # process_frame 在节点 _process 之前发
var wv := nw.node_for(8) as AnimView
var rv := nw.node_for(9) as AnimView
_ck(wv != null and wv.states.back() == "walk", "walk_mode 1 (WALKMODE_WALK) -> walk: %s" % [wv.states if wv else []])
_ck(rv != null and rv.states.back() == "run", "walk_mode 0 (WALKMODE_RUN) -> run: %s" % [rv.states if rv else []])
_ck(fc.motion_speeds.get(8, Vector2.ZERO) == Vector2(87.5, 425.75), "spawn pushes walk/run motion speeds: %s" % [fc.motion_speeds])
_ck(not fc.motion_speeds.has(10), "mounted actor keeps the duration lerp (no push)")
walker["walk_mode"] = 0
await process_frame
await process_frame
_ck(wv.states.back() == "run", "GC_WALK_MODE flip to run re-selects RUN: %s" % [wv.states])
# 匀角速度转向:一帧转角不超过 1200°/s × dt。
var before := wv.rotation.y
walker["angle_deg"] = 180.0
await process_frame
var t0 := Time.get_ticks_usec()
await process_frame
var dt := float(Time.get_ticks_usec() - t0) / 1e6
_ck(absf(angle_difference(before, wv.rotation.y)) <= deg_to_rad(1200.0) * (dt + 0.05), "turn limited to 1200deg/s")
+22 -1
View File
@@ -30,6 +30,8 @@ const STATE_MOTIONS := {
"standup_back": ["BACK_STANDUP"], "standup_back": ["BACK_STANDUP"],
} }
const HitReaction = preload("res://ui/hit_reaction.gd") const HitReaction = preload("res://ui/hit_reaction.gd")
const MsaMotion = preload("res://ui/msa_motion.gd")
const MOVE_STATES := ["walk", "run"]
const HitCollision = preload("res://hit_collision.gd") const HitCollision = preload("res://hit_collision.gd")
# CActorInstance::__SetMotion 尾:动作绑定完成。 # CActorInstance::__SetMotion 尾:动作绑定完成。
@@ -42,6 +44,7 @@ var _mesh_stem := ""
var _hit := HitReaction.new() var _hit := HitReaction.new()
var _motions := {} # MOTION_NAME(String) -> 绝对 .msa 路径 var _motions := {} # MOTION_NAME(String) -> 绝对 .msa 路径
var _state := "" var _state := ""
var _move_speed_ratio := 1.0
var _last_resolution := {} var _last_resolution := {}
var _audio: Node var _audio: Node
var _sound_instances: Array = [] var _sound_instances: Array = []
@@ -156,7 +159,25 @@ func set_anim_state(s: String) -> void:
if msa == "": if msa == "":
# 旧客户端 GetMotionKey 失败时 SetLoopMotion/InterceptMotion 直接返回:保留当前动作。 # 旧客户端 GetMotionKey 失败时 SetLoopMotion/InterceptMotion 直接返回:保留当前动作。
return return
_bind(s, msa, s in HitReaction.LOOP_STATES, 1.0) _bind(s, msa, s in HitReaction.LOOP_STATES, _loop_speed(s))
# CActorInstance::SetMoveSpeedm_fMovSpd = movSpd / 100,走 / 跑循环动作按它播放
# Move -> SetLoopMotion(WALK/RUN, 0.15, m_fMovSpd))。>1100 时参考端不走动作累计,
# EntityStore 退回按 duration 插值,这里保持原速播放。
func set_move_speed(moving_speed: int) -> void:
_move_speed_ratio = 1.0 if moving_speed <= 0 or moving_speed > 1100 \
else float(moving_speed) / 100.0
if anim != null and _state in MOVE_STATES and not _hit.active:
anim.set("time_scale", _hit.scale_for(_move_speed_ratio))
func _loop_speed(state: String) -> float:
return _move_speed_ratio if state in MOVE_STATES else 1.0
## 走 / 跑动作的根运动速度(cm/smovSpd 100):x = walky = run。0 = 取不到。
## net_world 推给 EntityStore,远端移动按动作累计量推进(CActorInstance::AccumulationMovement)。
func get_move_motion_speeds() -> Vector2:
return Vector2(MsaMotion.move_speed(String(resolve_motion("walk").path)),
MsaMotion.move_speed(String(resolve_motion("run").path)))
# CGraphicThingInstance::InsertDelay(fStiffenTime) # CGraphicThingInstance::InsertDelay(fStiffenTime)
func insert_delay(d: float) -> void: func insert_delay(d: float) -> void:
+38
View File
@@ -0,0 +1,38 @@
# MsaMotion —— 读 .msa 文本里的根运动:移动动作每秒位移(cm/s,movSpd 100 时)。
#
# MotionDuration 0.600000
# Accumulation 0.00 -255.45 0.00
#
# 服务端 CHARACTER::GetMoveMotionSpeed = -Accumulation.y / MotionDuration
# 客户端 CActorInstance::AccumulationMovement 按同一累计量推进。这里取 xy 长度。
extends RefCounted
static var _cache := {} # path -> float
static func move_speed(path: String) -> float:
if path == "" or not path.to_lower().ends_with(".msa"):
return 0.0
if _cache.has(path):
return float(_cache[path])
var speed := 0.0
var f := FileAccess.open(path, FileAccess.READ)
if f:
var duration := 0.0
var accum := Vector2.ZERO
while not f.eof_reached():
var parts := _fields(f.get_line())
if parts.size() < 2:
continue
match String(parts[0]):
"MotionDuration":
duration = String(parts[1]).to_float()
"Accumulation":
if parts.size() >= 3:
accum = Vector2(String(parts[1]).to_float(), String(parts[2]).to_float())
if duration > 0.0:
speed = accum.length() / duration
_cache[path] = speed
return speed
static func _fields(line: String) -> PackedStringArray:
return line.strip_edges().replace("\t", " ").split(" ", false)
+24 -1
View File
@@ -18,6 +18,8 @@ const FEMALE_RACES := [1, 3, 4, 6]
const WEAPON_BONE := ["equip_right_hand", "equip_right", "equip_right", "equip_right"] const WEAPON_BONE := ["equip_right_hand", "equip_right", "equip_right", "equip_right"]
const WEAPON_LEFT_BONE := ["", "equip_left", "", "equip_left"] const WEAPON_LEFT_BONE := ["", "equip_left", "", "equip_left"]
const HitReaction = preload("res://ui/hit_reaction.gd") const HitReaction = preload("res://ui/hit_reaction.gd")
const MsaMotion = preload("res://ui/msa_motion.gd")
const MOVE_STATES := ["walk", "run"]
const HitCollision = preload("res://hit_collision.gd") const HitCollision = preload("res://hit_collision.gd")
# CRaceMotionData::MOTION_MODE_* -> playersettingmodule 注册动作用的目录(与 general 同级) # CRaceMotionData::MOTION_MODE_* -> playersettingmodule 注册动作用的目录(与 general 同级)
const MOTION_MODE_DIRS := {1: "general", 2: "onehand_sword", 3: "twohand_sword", 4: "dualhand_sword", const MOTION_MODE_DIRS := {1: "general", 2: "onehand_sword", 3: "twohand_sword", 4: "dualhand_sword",
@@ -43,6 +45,7 @@ var anim: Node # Metin2AnimPlayer
var motion_dir := "" var motion_dir := ""
var action_dir := "" var action_dir := ""
var _state := "" var _state := ""
var _move_speed_ratio := 1.0
var _assets_root := "" var _assets_root := ""
var _audio: Node var _audio: Node
var _sound_instances: Array = [] var _sound_instances: Array = []
@@ -135,7 +138,27 @@ func set_anim_state(s: String) -> void:
if not FileAccess.file_exists(msa): if not FileAccess.file_exists(msa):
msa = motion_dir.path_join(s + ".gr2") msa = motion_dir.path_join(s + ".gr2")
if FileAccess.file_exists(msa): if FileAccess.file_exists(msa):
_bind(s, msa, s in HitReaction.LOOP_STATES, 1.0) _bind(s, msa, s in HitReaction.LOOP_STATES, _loop_speed(s))
# CActorInstance::SetMoveSpeedm_fMovSpd = movSpd / 100,走 / 跑循环动作按它播放
# Move -> SetLoopMotion(WALK/RUN, 0.15, m_fMovSpd))。>1100 时参考端不走动作累计,
# EntityStore 退回按 duration 插值,这里保持原速播放。
func set_move_speed(moving_speed: int) -> void:
_move_speed_ratio = 1.0 if moving_speed <= 0 or moving_speed > 1100 \
else float(moving_speed) / 100.0
if anim != null and _state in MOVE_STATES and not _hit.active:
anim.set("time_scale", _hit.scale_for(_move_speed_ratio))
func _loop_speed(state: String) -> float:
return _move_speed_ratio if state in MOVE_STATES else 1.0
## 走 / 跑动作的根运动速度(cm/smovSpd 100):x = walky = run。0 = 取不到。
## net_world 推给 EntityStore,远端移动按动作累计量推进(CActorInstance::AccumulationMovement)。
func get_move_motion_speeds() -> Vector2:
if motion_dir == "":
return Vector2.ZERO
return Vector2(MsaMotion.move_speed(motion_dir.path_join("walk.msa")),
MsaMotion.move_speed(motion_dir.path_join("run.msa")))
# Play one of the 40250 CRaceMotionData one-shot motions. The protocol sends # Play one of the 40250 CRaceMotionData one-shot motions. The protocol sends
# the numeric motion id; paired emotions use the other entity's race to select # the numeric motion id; paired emotions use the other entity's race to select