40250 classic: G1 integrate W2 (§2.1 data-layer EntityStore + net_world visibility, §2.3/§2.4/§2.5)
Doc conflicts in CLIENT-GAP.md / CLIENT-GAP-FIX.md resolved by W0: keep both 增量 50 W1 + W2 change-log bullets and C.5 batch records. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014yvAPqPivoY7vmBbzgqK4W
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
#include "entity_store.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <utility>
|
||||
|
||||
@@ -30,6 +32,17 @@ constexpr uint16_t MOTION_CHEERUP = 347;
|
||||
constexpr uint16_t MOTION_BANTER = 348;
|
||||
constexpr uint16_t MOTION_JOY = 349;
|
||||
|
||||
// CLIENT-GAP §2.1/§2.2: the data-layer mutators never create an entity except
|
||||
// through mut_spawn / mut_spawn_full / mut_spawn_main. Every other mut_* that is
|
||||
// handed an unknown VID logs (when MT_NET_TRACE is set) and returns instead of
|
||||
// silently spawning a half-populated row or buffering a replay.
|
||||
void dbg_ignored(const char *what, uint32_t id) {
|
||||
static const bool trace = std::getenv("MT_NET_TRACE") != nullptr;
|
||||
if (trace) {
|
||||
std::fprintf(stderr, "[entity_store] %s: unknown VID %u -> ignored\n", what, id);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Entity &EntityStore::touch(uint32_t vid, bool &created) {
|
||||
@@ -75,13 +88,15 @@ void EntityStore::start_move(Entity &e, float tx, float ty, uint32_t start_ms, u
|
||||
|
||||
void EntityStore::mut_spawn(uint32_t vid, uint16_t race, uint8_t ch_type, const std::string &name,
|
||||
float x, float y, float z, float angle, uint16_t moving_speed, uint8_t attack_speed) {
|
||||
bool created;
|
||||
Entity &e = touch(vid, created);
|
||||
// CLIENT-GAP §2.3: a plain character-add is just a full spawn with the
|
||||
// remaining fields left at their defaults. Route it through mut_spawn_full so
|
||||
// there is exactly one "whole row replace" code path and VID reuse always
|
||||
// resets transient state (dead / stun / mount / guild / affects / move).
|
||||
Entity e{};
|
||||
e.vid = vid;
|
||||
e.race = race;
|
||||
e.ch_type = ch_type;
|
||||
if (!name.empty()) {
|
||||
e.name = name;
|
||||
}
|
||||
e.name = name;
|
||||
e.x = x;
|
||||
e.y = y;
|
||||
e.z = z;
|
||||
@@ -89,7 +104,7 @@ void EntityStore::mut_spawn(uint32_t vid, uint16_t race, uint8_t ch_type, const
|
||||
e.moving_speed = moving_speed;
|
||||
e.attack_speed = attack_speed;
|
||||
e.moving = false;
|
||||
m_changes.push_back({ChangeKind::Spawn, vid});
|
||||
mut_spawn_full(e);
|
||||
}
|
||||
|
||||
void EntityStore::mut_spawn_main(uint32_t vid, uint16_t race, const std::string &name, float x,
|
||||
@@ -113,22 +128,18 @@ void EntityStore::mut_spawn_main(uint32_t vid, uint16_t race, const std::string
|
||||
}
|
||||
|
||||
void EntityStore::mut_spawn_full(const Entity &src) {
|
||||
bool created;
|
||||
Entity &e = touch(src.vid, created);
|
||||
// Preserve the live interpolation state if the entity is already moving and
|
||||
// the caller did not carry an explicit move (spawn packets never do).
|
||||
const bool was_moving = e.moving;
|
||||
const float keep_x = e.x, keep_y = e.y, keep_z = e.z;
|
||||
e = src;
|
||||
// CLIENT-GAP §2.3 "one overwrite rule": a spawn replaces the whole row keyed
|
||||
// by vid. The reference (SNetworkActorData::__copy__) copies every field from
|
||||
// the incoming data on VID reuse and does NOT preserve a prior in-progress
|
||||
// move — so we do a clean value replace here. Transient state (dead, stun,
|
||||
// mount, guild, affect_flags, func/move) is whatever `src` carries, which for
|
||||
// a fresh Entity{} is the default. Copy into a local first to stay safe if a
|
||||
// caller ever passes `store.get(vid)` back in.
|
||||
Entity e = src;
|
||||
e.vid = src.vid;
|
||||
if (was_moving && !src.moving) {
|
||||
e.moving = true;
|
||||
e.x = keep_x;
|
||||
e.y = keep_y;
|
||||
e.z = keep_z;
|
||||
}
|
||||
m_ents[src.vid] = e;
|
||||
if (e.is_main) {
|
||||
m_main_vid = e.vid;
|
||||
m_main_vid = src.vid;
|
||||
}
|
||||
m_changes.push_back({ChangeKind::Spawn, src.vid});
|
||||
}
|
||||
@@ -144,6 +155,23 @@ void EntityStore::mut_ownership(uint32_t vid, uint32_t owner_vid) {
|
||||
}
|
||||
}
|
||||
|
||||
void EntityStore::mut_affect_flags(uint32_t vid, uint32_t lo, uint32_t hi) {
|
||||
// CLIENT-GAP §2.5: GC_CHARACTER_ADD[2] / GC_CHARACTER_UPDATE carry the affect
|
||||
// bitset as two 32-bit words (client CopyData(0,4,&flag[0]) +
|
||||
// CopyData(32,4,&flag[1])); re-assemble as lo | (hi << 32). No-op for an
|
||||
// unknown VID — the bitset lives on the entity row only.
|
||||
auto it = m_ents.find(vid);
|
||||
if (it == m_ents.end()) {
|
||||
dbg_ignored("mut_affect_flags", vid);
|
||||
return;
|
||||
}
|
||||
const uint64_t v = (uint64_t)lo | ((uint64_t)hi << 32);
|
||||
if (it->second.affect_flags != v) {
|
||||
it->second.affect_flags = v;
|
||||
m_dirty.push_back(vid);
|
||||
}
|
||||
}
|
||||
|
||||
void EntityStore::mut_map_bgm(const std::string &name, float volume) {
|
||||
bool changed = false;
|
||||
if (name != m_bgm_name) {
|
||||
@@ -181,10 +209,21 @@ void EntityStore::mut_move(uint32_t vid, float angle_deg, uint8_t func, float tx
|
||||
}
|
||||
|
||||
void EntityStore::mut_set_points(const int32_t *pts, int n) {
|
||||
if (n > 256) {
|
||||
n = 256;
|
||||
// CLIENT-GAP §2.4: GC_CHARACTER_POINTS is a full snapshot. The wire array is
|
||||
// exactly POINT_MAX_NUM (255) ints; clamp a longer count and zero the tail of
|
||||
// a shorter one so a stale value can never survive under a fresh snapshot.
|
||||
// (W1 owns wire_classic.h; GCPoints there already carries
|
||||
// static_assert(sizeof(GCPoints) == 1 + 255 * 4), i.e. points[POINT_MAX_NUM].)
|
||||
if (!pts || n <= 0) {
|
||||
return;
|
||||
}
|
||||
if (n > POINT_MAX_NUM) {
|
||||
n = POINT_MAX_NUM;
|
||||
}
|
||||
std::memcpy(m_points.v, pts, sizeof(int32_t) * (size_t)n);
|
||||
for (int i = n; i < POINT_MAX_NUM; ++i) {
|
||||
m_points.v[i] = 0;
|
||||
}
|
||||
m_current_stamina = (uint32_t)std::max(0, m_points.stamina());
|
||||
m_stamina_current_f = (float)m_current_stamina;
|
||||
m_points_dirty = true;
|
||||
@@ -206,6 +245,12 @@ void EntityStore::mut_set_points(const int32_t *pts, int n) {
|
||||
}
|
||||
|
||||
void EntityStore::mut_set_point(uint8_t type, int32_t value, uint32_t vid) {
|
||||
// CLIENT-GAP §2.4: a single-index point change must stay inside the array.
|
||||
// Valid indices are 0..POINT_MAX_NUM-1 (0..254).
|
||||
if (type >= POINT_MAX_NUM) {
|
||||
dbg_ignored("mut_set_point index out of range", type);
|
||||
return;
|
||||
}
|
||||
if (vid == m_main_vid) {
|
||||
m_points.v[type] = value;
|
||||
if (type == POINT_STAMINA) {
|
||||
@@ -334,9 +379,14 @@ void EntityStore::mut_char_update(uint32_t vid, const uint16_t *parts, uint8_t m
|
||||
e.attack_speed = attack_speed;
|
||||
changed = true;
|
||||
}
|
||||
e.guild = guild_id;
|
||||
e.alignment = alignment;
|
||||
e.pk_mode = pk_mode;
|
||||
// CLIENT-GAP §2.5: guild / alignment / pk-mode are part of the node refresh
|
||||
// apply order, so a change to any of them must re-mirror the entity too.
|
||||
if (e.guild != guild_id || e.alignment != alignment || e.pk_mode != pk_mode) {
|
||||
e.guild = guild_id;
|
||||
e.alignment = alignment;
|
||||
e.pk_mode = pk_mode;
|
||||
changed = true;
|
||||
}
|
||||
if (e.mount_vnum != mount_vnum) {
|
||||
e.mount_vnum = mount_vnum;
|
||||
m_mount_changes.push_back(vid);
|
||||
@@ -424,13 +474,17 @@ void EntityStore::mut_damage(uint32_t vid, uint8_t flag, int32_t amount) {
|
||||
}
|
||||
|
||||
void EntityStore::mut_mount(uint32_t vid, uint32_t mount_vnum) {
|
||||
bool created = false;
|
||||
Entity &e = touch(vid, created);
|
||||
e.mount_vnum = mount_vnum;
|
||||
m_mount_changes.push_back(vid);
|
||||
if (!created) {
|
||||
m_changes.push_back({ChangeKind::Info, vid});
|
||||
// CLIENT-GAP §2.1: mount toggles never create an entity. A GC_MOUNT for a VID
|
||||
// we have not spawned is dropped (the server always sends the character-add
|
||||
// first).
|
||||
auto it = m_ents.find(vid);
|
||||
if (it == m_ents.end()) {
|
||||
dbg_ignored("mut_mount", vid);
|
||||
return;
|
||||
}
|
||||
it->second.mount_vnum = mount_vnum;
|
||||
m_mount_changes.push_back(vid);
|
||||
m_changes.push_back({ChangeKind::Info, vid});
|
||||
}
|
||||
|
||||
void EntityStore::mut_fly(uint8_t type, uint32_t start_vid, uint32_t end_vid) {
|
||||
@@ -506,8 +560,15 @@ void EntityStore::mut_view_equipment(uint32_t vid, const Item *items, int n) {
|
||||
void EntityStore::mut_char_info(uint32_t vid, const std::string &name, const uint16_t *parts,
|
||||
uint8_t empire, int32_t guild_id, int32_t level, int16_t alignment, uint8_t pk_mode,
|
||||
uint32_t mount_vnum) {
|
||||
bool created;
|
||||
Entity &e = touch(vid, created);
|
||||
// CLIENT-GAP §2.2: GC_CHAR_ADDITIONAL_INFO decorates an entity the spawn
|
||||
// packet already created. An unknown VID means we missed (or already culled)
|
||||
// the add — drop it instead of touch()-ing a nameless placeholder into being.
|
||||
auto it = m_ents.find(vid);
|
||||
if (it == m_ents.end()) {
|
||||
dbg_ignored("mut_char_info", vid);
|
||||
return;
|
||||
}
|
||||
Entity &e = it->second;
|
||||
if (!name.empty()) {
|
||||
e.name = name;
|
||||
}
|
||||
@@ -525,24 +586,16 @@ void EntityStore::mut_char_info(uint32_t vid, const std::string &name, const uin
|
||||
if (empire) {
|
||||
e.empire = empire;
|
||||
}
|
||||
if (!created) {
|
||||
m_changes.push_back({ChangeKind::Info, vid});
|
||||
}
|
||||
m_changes.push_back({ChangeKind::Info, vid});
|
||||
}
|
||||
|
||||
void EntityStore::mut_shop_sign(uint32_t vid, const std::string &sign) {
|
||||
// CLIENT-GAP §2.2: the sign is a field on the entity that owns the private
|
||||
// shop. An unknown VID never spawns a bare row here — the character-add
|
||||
// packet is the only thing that creates entities. Drop it and log.
|
||||
auto it = m_ents.find(vid);
|
||||
if (it == m_ents.end()) {
|
||||
// A non-empty sign can legally race the character-add packet during a
|
||||
// shop broadcast. Keep it instead of silently dropping the server state;
|
||||
// the later character-add packet fills in the rest of this entity.
|
||||
if (sign.empty()) {
|
||||
return;
|
||||
}
|
||||
bool created = false;
|
||||
Entity &e = touch(vid, created);
|
||||
e.shop_sign = sign;
|
||||
m_changes.push_back({ChangeKind::Spawn, vid});
|
||||
dbg_ignored("mut_shop_sign", vid);
|
||||
return;
|
||||
}
|
||||
if (it->second.shop_sign == sign) {
|
||||
|
||||
@@ -481,6 +481,10 @@ public:
|
||||
void mut_spawn_full(const Entity &e);
|
||||
// summon / horse ownership (CLIENT-GAP §2.5); no-op if vid unknown.
|
||||
void mut_ownership(uint32_t vid, uint32_t owner_vid);
|
||||
// Affect bitset from GC_CHARACTER_ADD[2] / GC_CHARACTER_UPDATE, delivered as
|
||||
// the two 32-bit wire words (CLIENT-GAP §2.5: v = lo | (hi << 32)). Queues a
|
||||
// dirty tick so net_world re-mirrors the node; no-op if vid unknown.
|
||||
void mut_affect_flags(uint32_t vid, uint32_t lo, uint32_t hi);
|
||||
// Map background music (CLIENT-GAP §9.1). name is the resolved track id or
|
||||
// path; volume < 0 means "server did not specify, keep current".
|
||||
void mut_map_bgm(const std::string &name, float volume);
|
||||
|
||||
@@ -1590,6 +1590,49 @@ int main() {
|
||||
CHECK(despawn, "despawn change emitted");
|
||||
}
|
||||
|
||||
// --- CLIENT-GAP §2.1: EntityStore is the data layer. A far entity is a full
|
||||
// row here with a queued Spawn/dirty; no scene, no replay buffer. Scene-
|
||||
// node visibility (CHAR_STAGE_VIEW_BOUND cull / re-show) is asserted by
|
||||
// project/net_world_vis_test.gd, which drives net_world.gd directly. ---
|
||||
{
|
||||
EntityStore d;
|
||||
d.set_now(0);
|
||||
d.mut_spawn_main(1, 1, "hero", 0, 0, 0);
|
||||
d.drain_changes();
|
||||
|
||||
// GC_CHARACTER_ADD far outside any view bound -> still a full data row.
|
||||
d.mut_spawn(4242, 101, 2, "far-orc", 900000.0f, 0.0f, 0.0f, 0.0f, 300, 90);
|
||||
const Entity *orc = d.get(4242);
|
||||
CHECK(orc && orc->race == 101 && orc->attack_speed == 90 && orc->name == "far-orc",
|
||||
"§2.1 data layer: far GC_CHARACTER_ADD populates the entity row");
|
||||
{
|
||||
auto ch = d.drain_changes();
|
||||
bool spawn = false;
|
||||
for (auto &c : ch) {
|
||||
if (c.kind == EntityStore::ChangeKind::Spawn && c.vid == 4242) {
|
||||
spawn = true;
|
||||
}
|
||||
}
|
||||
CHECK(spawn, "§2.1 data layer: a Spawn change is queued for the far entity");
|
||||
}
|
||||
|
||||
// GC_CHARACTER_UPDATE for that same VID updates the row in place.
|
||||
d.mut_char_update(4242, nullptr, 40, 120, 7, -3, 1, 0);
|
||||
orc = d.get(4242);
|
||||
CHECK(orc && orc->attack_speed == 120 && orc->moving_speed == 40 && orc->guild == 7,
|
||||
"§2.1 data layer: UPDATE mutates the existing row");
|
||||
|
||||
// Unknown VID: no create, no replay buffer, no size growth.
|
||||
const size_t n_before = d.size();
|
||||
d.mut_char_update(999999, nullptr, 10, 10, 0, 0, 0, 0);
|
||||
d.mut_move(999999, 90.0f, 1, 5.0f, 5.0f, 200);
|
||||
d.mut_mount(999999, 20101);
|
||||
d.mut_char_info(999999, "ghost", nullptr, 1, 0, 5, 0, 0, 0);
|
||||
d.mut_shop_sign(999999, "ghost shop");
|
||||
CHECK(d.size() == n_before && d.get(999999) == nullptr,
|
||||
"§2.1 data layer: mutators for an unknown VID never spawn or buffer");
|
||||
}
|
||||
|
||||
// --- ClientVS22 loading reset: discard map-local state, retain no stale deltas ---
|
||||
es.mut_spawn(99, 101, 1, "reset-me", 10, 20, 0, 0);
|
||||
es.mut_ground_add(700, 27100, 12, 24, 0);
|
||||
|
||||
Reference in New Issue
Block a user