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:
shenlei
2026-09-02 16:08:59 +09:00
co-authored by Claude Sonnet 5
8 changed files with 599 additions and 52 deletions
+99 -46
View File
@@ -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) {
+4
View File
@@ -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);
+43
View File
@@ -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);