Files
metin2-client/libgr2/src/gr2_anim.cpp
T
shenleiandClaude Opus 5 e0c1853909 fix(net): 主角 VID 归参考端所有,GC_CHARACTER_DEL 不再把玩家「去主角化」
实机故障:进入游戏后整屏灰紫,主角还是蓝色胶囊占位体,2D UI 与小地图正常。

根因(已实证,非推测)。`EntityStore::mut_despawn()` 在 despawn 的 vid 等于
主角 vid 时把 `m_main_vid` 清零。40250 服务器在 spawn-in 头几秒会对玩家自己的
vid 反复下发 GC_CHARACTER_DEL + GC_CHARACTER_ADD(视野重算,实测约 10 次)。
第一次 DEL 就把玩家永久去主角化:`get_main_vid()` 返回 0 →
`_sync_main_character()` 直接 return → 建模代码从未跑过(main_attempts=0)→
占位胶囊留在 y=0 → 相机跟着它钻到 C1 地形底下 → 满屏灰紫。地图其实一直是好的
(chunks_built 20/20、objects_placed 565)。

参考端怎么做(40250 ClientVS22)。主角身份属于 `CNetworkActorManager::m_dwMainVID`,
只由 `SetMainActorVID()`(GC_MAIN_CHARACTER)写,只在管理器析构时清;
`RemoveActor()` 删 actor 行和角色实例,但绝不碰它;重新加入时
`__AppendCharacterManagerActor()` 用 `kCreateData.m_isMain = __IsMainActorVID(dwVID)`
重新推导,并再跑一次 `SetMainCharacterIndex(dwVID)`。

修法(entity_store.cpp):
- `mut_despawn()` 只删行、只发 Despawn,不动 m_main_vid;m2dev 分支的
  GC_CHARACTER_DEL 同规则。
- `mut_spawn_full()` 在 `e.vid == m_main_vid` 时把 `is_main` 置回 true,
  并重新广播 MainSet,对齐参考端的重新推导。

顺带落地的渲染修复(同一次实机排查中定位):
- libgr2 `sample_pose()` 增加 `root_offset` 形参;`Metin2AnimPlayer::apply_pose()`
  显式传单位阵而不是模型的 InitialPlacement。Metin2 动画 gr2 的根骨轨道自带骨盆
  高度,再乘一次 IP 会把整个人抬高一个骨盆高(武 99.4cm / 刺 92.6 / 巫 103.7 /
  萨 95.5,正好各自的 IP.z)——即此前的「人物悬空」。`bind_pose()` 语义不变。
- `Metin2Model::_load_hair()` 改用与身体/武器同一条 `make_material()`
  (specular_disabled)而不是 roughness=1.0 的 StandardMaterial3D,头发不再被
  PBR 高光洗成奶白色块。

取证与回归工具:
- `project/net_trace.gd`(新):主角 vid 生命周期取证。挂在 AppFlow 之后逐帧读
  `get_main_vid()`,归零时打印 `NETTRACE: MAIN_LOST was=.. now=0 reason=..` 加最近
  24 条环形事件;`reason` 区分 despawn 与 silent(reset_for_map_change 清空
  m_changes,是静默清零)。`MT_NETTRACE=1` 打开逐事件日志。
- `game_scene.gd`:诊断 tick 支持重复采样(分辨主角装配是「没跑」还是「没跑完」),
  新增 main_sync_ready / main_loading / main_view_key / main_map_vid /
  main_retry_in_ms 字段;`MT_DIAG_SHOT=<png>` 让游戏自己存一帧实机画面
  (screencapture 拍到的是终端窗口,没用)。
- `project/model_render_test.gd` + `test/rendering/model_baseline.json`(新):
  八个种族的模型渲染基线(--bless 重新落盘)。
- `project/char_bench_test.gd`(新):无网络无地图的单角色实验台,五变体 ×
  六机位出证据图,只产图不做阈值判定。
- `project/package_render_test.gd`(新):导出包内的渲染自检入口
  (导出模板不接受 --script,只能走 MT_TEST_MODE)。
- `/project/build/` 加入 .gitignore(渲染用例的落图输出)。

实机复验(真实服务器,非模拟):
- 修前 `LIVE_SMOKE FAIL: 主角色 VID 有效`;
  `NETTRACE: MAIN_LOST was=25910 now=0 reason=despawn t=+7737ms`,
  环里紧跟 `spawn vid=25910 is_main=false`。
- 修后 `LIVE_SMOKE RESULT: PASS`,main_vid=25933、entities=19;
  40s 实机跑 16 次诊断采样全部 model_built=True / main_attempts=1 /
  main_sync_ready=True,player_pos 落在 C1 地形上而非 y=0;
  MT_DIAG_SHOT 存下的帧里地形、建筑、NPC、树木、真实角色模型、HUD、小地图俱全。

回归:gamescene_test / equip_model_test / gamescene_visual_test(feet_gap
0.001373)/ model_render_test(8 races)/ char_select_visual_test / 包内
MT_TEST_MODE=render 全绿;ctest 22/23,唯一失败 net.classic_session 已用
「还原本次改动 → 重跑 → diff 失败集合」确认与本次修改无关(失败集合完全相同)。

注:画面整体偏暗属于光照/色调差距,受 PARITY-GAP.md §0 门禁约束(对照帧未落盘前
不做相机/光照/色调/材质调参),本次不碰。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJugvEJwz3FK4hw9ti3SRb
2026-09-08 17:15:55 +09:00

275 lines
13 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// M0 T7a —— 曲线子类型分类(gr2fuzz 直方图依赖)。T7b(解码)见文末。
// 见 docs/reference/steps/M0-gr2-reader.md
// 规格:granny_track_group.cpp (TrackGroupType/TransformTrackType/Curve2Type) /
// granny_curve.cpp (CurveData*Type,均以 CurveDataHeaderType 开头) /
// granny_curve.h (curve_data_header{ u8 Format; u8 Degree })
#include "internal.h"
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <algorithm>
#include <cmath>
#define ADBG(...) do { if (getenv("GR2_DEBUG")) fprintf(stderr, "[anim] " __VA_ARGS__); } while (0)
namespace gr2 {
using detail::Ref;
using detail::TypeMember;
// Metin2 = Granny 2.4:曲线是 OldCurveType(granny_curve.cpp):
// {Int32 Degree; RefToArray Knots(Real32[]); RefToArray Controls(Real32[])} —— 20B,无压缩变体。
// curve_obj 指向 transform_track 里 inline 的那个 curve 对象本体。
// 新版(2.6+)是 curve2{variant CurveData},本函数一并识别、打标签。
std::string File::Impl::classify_curve_obj(Ref curve_obj, Ref curve_type) {
if (!curve_obj.valid() || !curve_type.valid()) return "none";
const auto& ms = parse_type(curve_type);
if (ms.empty()) return "Unknown";
// 老格式:第一个成员是 "Degree"
if (ms[0].name == "Degree") {
const TypeMember* mD = &ms[0];
const TypeMember* mK = find_member(ms, "Knots");
const TypeMember* mC = find_member(ms, "Controls");
int32_t degree = rd_i32(member_slot(curve_obj, *mD));
uint32_t kn = mK ? uint32_t(std::max(0, rd_i32(member_slot(curve_obj, *mK)))) : 0;
uint32_t cn = mC ? uint32_t(std::max(0, rd_i32(member_slot(curve_obj, *mC)))) : 0;
uint32_t dim = kn ? cn / kn : 0;
char buf[64];
std::snprintf(buf, sizeof buf, "Old(d=%d,dim=%u)", degree, dim);
return buf;
}
// 新格式:curve2{variant CurveData}
if (ms[0].name == "CurveData") {
Ref type_ref = follow(member_slot(curve_obj, ms[0])); // variant.Type
if (!type_ref.valid()) return "Identity";
const auto& dm = parse_type(type_ref);
if (dm.empty()) return "Unknown";
const std::string& n = dm[0].name;
const char* pfx = "CurveDataHeader_";
return (n.rfind(pfx, 0) == 0) ? n.substr(std::strlen(pfx)) : n;
}
return "Unknown";
}
// 读一条 OldCurveType 曲线({Int32 Degree; RefToArray Knots; RefToArray Controls})到 Curve。
void File::Impl::read_old_curve(Ref curve_obj, Ref curve_type, Curve& out) {
if (!curve_obj.valid() || !curve_type.valid()) return;
const auto& ms = parse_type(curve_type);
if (ms.empty() || ms[0].name != "Degree") return; // 新格式暂不解
const TypeMember* mD = &ms[0];
const TypeMember* mK = find_member(ms, "Knots");
const TypeMember* mC = find_member(ms, "Controls");
if (!mK || !mC) return;
out.degree = rd_i32(member_slot(curve_obj, *mD));
auto read_farr = [&](const TypeMember& m, std::vector<float>& dst) {
Ref slot = member_slot(curve_obj, m);
int32_t cnt = rd_i32(slot);
if (cnt <= 0 || cnt > (1 << 24)) return;
Ref base = follow({slot.section, slot.offset + 4});
const uint8_t* p = at(base, size_t(cnt) * 4);
if (!p) return;
dst.resize(cnt);
for (int32_t k = 0; k < cnt; ++k) std::memcpy(&dst[k], p + size_t(k) * 4, 4);
};
read_farr(*mK, out.knots);
read_farr(*mC, out.controls);
out.dim = out.knots.empty() ? 0 : uint32_t(out.controls.size() / out.knots.size());
if (out.dim && out.knots.size() * out.dim != out.controls.size()) out.dim = 0; // 不一致 → 弃
// 有些资产的曲线控制点本身是 NaN(如 redthief_general/*_damage.gr2 的 finger track)。
// 不 sanitize 会把 NaN 灌进整条蒙皮链。→ 整条曲线弃(eval 返回空,调用方回退到 bind)。
for (float v : out.knots) if (!std::isfinite(v)) { out.dim = 0; return; }
for (float v : out.controls) if (!std::isfinite(v)) { out.dim = 0; return; }
}
// animation: {String Name; Real32 Duration; Real32 TimeStep; Real32 Oversampling;
// ArrayOfRefs TrackGroups; ...}
// track_group: {String Name; RefToArray VectorTracks; RefToArray TransformTracks; ...}
// transform_track: {String Name; Int32 Flags; Inline OrientationCurve(curve2);
// Inline PositionCurve(curve2); Inline ScaleShearCurve(curve2)}
bool File::Impl::extract_animation(Ref anim_obj, Ref anim_type, const Skeleton* skel, Animation& out) {
if (!anim_obj.valid() || !anim_type.valid()) return false;
const auto& ams = parse_type(anim_type);
if (const TypeMember* m = find_member(ams, "Name"))
out.name = rd_str(member_slot(anim_obj, *m));
if (const TypeMember* m = find_member(ams, "Duration"))
out.duration = rd_f32(member_slot(anim_obj, *m));
const TypeMember* mTG = find_member(ams, "TrackGroups");
if (!mTG) return true;
auto groups = read_array_of_refs(anim_obj, *mTG);
const auto& tgms = parse_type(mTG->ref_type);
const TypeMember* mTT = find_member(tgms, "TransformTracks");
if (!mTT) return true;
const auto& ttms = parse_type(mTT->ref_type);
if (getenv("GR2_DEBUG")) {
fprintf(stderr, "[anim] transform_track type members=%zu:\n", ttms.size());
for (auto& m : ttms)
fprintf(stderr, "[anim] %-20s type=%d aw=%d off=%u size=%u reftype=(%d,%u)\n",
m.name.c_str(), m.type, m.array_width, m.offset, m.size,
m.ref_type.section, m.ref_type.offset);
}
const TypeMember* mName = find_member(ttms, "Name");
const TypeMember* mOri = find_member(ttms, "OrientationCurve");
const TypeMember* mPos = find_member(ttms, "PositionCurve");
const TypeMember* mScl = find_member(ttms, "ScaleShearCurve");
for (Ref g : groups) {
if (!g.valid()) continue;
Ref base; uint32_t count = 0, stride = 0;
if (!read_ref_to_array(g, *mTT, base, count, stride) || count == 0) continue;
for (uint32_t i = 0; i < count; ++i) {
Ref t = {base.section, base.offset + i * stride};
BoneTrack bt;
if (mName) bt.bone_name = rd_str(member_slot(t, *mName));
if (mPos) {
bt.pos_type = classify_curve_obj(member_slot(t, *mPos), mPos->ref_type);
read_old_curve(member_slot(t, *mPos), mPos->ref_type, bt.position);
}
if (mOri) {
bt.rot_type = classify_curve_obj(member_slot(t, *mOri), mOri->ref_type);
read_old_curve(member_slot(t, *mOri), mOri->ref_type, bt.orientation);
}
if (mScl) {
bt.scale_type = classify_curve_obj(member_slot(t, *mScl), mScl->ref_type);
read_old_curve(member_slot(t, *mScl), mScl->ref_type, bt.scale_shear);
}
if (skel) for (size_t k = 0; k < skel->bones.size(); ++k)
if (skel->bones[k].name == bt.bone_name) { bt.bone_index = int32_t(k); break; }
out.tracks.push_back(std::move(bt));
}
}
return true;
}
// ── T7b:曲线解码 ─────────────────────────────────────────────────────
// Metin2 曲线全是 OldCurveType(B 样条:Degree + Knots[] + Controls[knot*dim])。
// 求值照 granny_bspline.cpp SampleBSpline{0,1,2}xN + granny_bspline_inlines.h
// {Linear,Quadratic}Coefficients。degree 0=常量,1=线性,2=二次 B 样条。
// KnotIndex = "第一个 > t 的 knot"(granny CurveFindKnot),窗口越界按端点 clamp。
//
// dim==4(四元数):blend 之前对本 span 参与的 degree+1 个控制点做
// EnsureQuaternionContinuity(granny_math.cpp)—— 逐点若与前一点点积 < 0 则整体取反。
// 少了这一步,跨半球(q vs -q)的控制点线性混合会收缩到近零,归一后得到一个
// 合法但方向错约 180° 的旋转(表情动作里脖子/头会折叠塌陷)。
void Curve::eval(float t, float* out) const {
const uint32_t n = (uint32_t)knots.size();
if (n == 0 || dim == 0) return;
auto Kc = [&](int i) -> float {
i = i < 0 ? 0 : (i >= (int)n ? (int)n - 1 : i);
return knots[(uint32_t)i];
};
if (n == 1 || degree <= 0) { // 常量
for (uint32_t d = 0; d < dim; ++d) out[d] = controls[d];
return;
}
if (t <= knots[0]) t = knots[0];
if (t >= knots[n - 1]) t = knots[n - 1];
int i = (int)n - 1;
for (uint32_t k = 0; k < n; ++k) if (knots[k] > t) { i = (int)k; break; }
if (i < 1) i = 1;
const int span = (degree == 1) ? 2 : 3; // 本 span 的控制点数 = degree+1
const int first = (degree == 1) ? (i - 1) : (i - 2);
// 拷本 span 的控制点(越界按端点 clamp),四元数做半球连续化
// Metin2 的 ScaleShearCurve 是 9 维;旧的 3*4 缓冲区会在这里发生
// 栈溢出(degree=2 时写入 27 个 float),随后被 __stack_chk_fail 终止。
// 目前支持的曲线维度为 position=3、orientation=4、scale/shear=9。
if (dim > 9) return;
float cp[3 * 9];
for (int s = 0; s < span; ++s) {
int ci = first + s;
ci = ci < 0 ? 0 : (ci >= (int)n ? (int)n - 1 : ci);
for (uint32_t d = 0; d < dim; ++d) cp[s * dim + d] = controls[(uint32_t)ci * dim + d];
}
if (dim == 4) {
for (int s = 1; s < span; ++s) {
const float* p = &cp[(s - 1) * 4];
float* q = &cp[s * 4];
if (p[0]*q[0] + p[1]*q[1] + p[2]*q[2] + p[3]*q[3] < 0.0f)
for (int d = 0; d < 4; ++d) q[d] = -q[d];
}
}
if (degree == 1) {
float denom = Kc(i) - Kc(i - 1);
float c0 = denom != 0.0f ? (t - Kc(i - 1)) / denom : 0.0f;
c0 = c0 < 0 ? 0 : (c0 > 1 ? 1 : c0);
float c1 = 1.0f - c0;
for (uint32_t d = 0; d < dim; ++d)
out[d] = c1 * cp[0 * dim + d] + c0 * cp[1 * dim + d];
} else { // degree >= 2 → 二次(Metin2 无 degree 3)
float ti_2 = Kc(i - 2), ti_1 = Kc(i - 1), ti = Kc(i), ti1 = Kc(i + 1);
auto fdiv = [](float a, float b) { return b != 0.0f ? a / b : 0.0f; };
float L0 = fdiv(t - ti_1, ti - ti_1);
float L1_1 = fdiv(t - ti_2, ti - ti_2);
float L1_2 = fdiv(t - ti_1, ti1 - ti_1);
float tmp = (L1_1 + L0) - L0 * L1_1;
float c0 = L0 * L1_2; // ci
float c1 = tmp - c0; // ci_1
float c2 = 1.0f - tmp; // ci_2
for (uint32_t d = 0; d < dim; ++d)
out[d] = c2 * cp[0 * dim + d] + c1 * cp[1 * dim + d] + c0 * cp[2 * dim + d];
}
if (dim == 4) { // 四元数:归一
float m = std::sqrt(out[0]*out[0] + out[1]*out[1] + out[2]*out[2] + out[3]*out[3]);
if (m > 1e-12f) for (int d = 0; d < 4; ++d) out[d] /= m;
}
}
// t 处每个 track 的局部 4x4(BuildCompositeTransform4x4 语义:C 上 3x3 = transpose(R3*SS3),
// 平移在 elem 12..14)。position 缺省 (0,0,0),orientation 缺省单位四元数,scaleshear 缺省 I。
void Animation::sample_local(float t, std::vector<Mat4>& out) const {
out.assign(tracks.size(), Mat4{1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1});
for (size_t ti = 0; ti < tracks.size(); ++ti) {
const BoneTrack& tr = tracks[ti];
float pos[3] = {0, 0, 0};
float ori[4] = {0, 0, 0, 1};
float ss[9] = {1,0,0, 0,1,0, 0,0,1};
if (!tr.position.empty()) tr.position.eval(t, pos);
if (!tr.orientation.empty()) tr.orientation.eval(t, ori);
bool have_ss = false;
if (!tr.scale_shear.empty()) {
if (tr.scale_shear.dim == 9) { tr.scale_shear.eval(t, ss); have_ss = true; }
else if (tr.scale_shear.dim == 3) {
float s[3]; tr.scale_shear.eval(t, s);
ss[0] = s[0]; ss[4] = s[1]; ss[8] = s[2]; have_ss = true;
}
}
// R3 (行主序) from quat
const float xx = ori[0]*ori[0], yy = ori[1]*ori[1], zz = ori[2]*ori[2];
const float xy = ori[0]*ori[1], xz = ori[0]*ori[2], yz = ori[1]*ori[2];
const float wx = ori[3]*ori[0], wy = ori[3]*ori[1], wz = ori[3]*ori[2];
float r3[9] = {
1 - 2*(yy+zz), 2*(xy-wz), 2*(xz+wy),
2*(xy+wz), 1 - 2*(xx+zz), 2*(yz-wx),
2*(xz-wy), 2*(yz+wx), 1 - 2*(xx+yy)
};
float u3[9];
if (have_ss) {
for (int r = 0; r < 3; ++r)
for (int c = 0; c < 3; ++c)
u3[r*3+c] = r3[r*3+0]*ss[0*3+c] + r3[r*3+1]*ss[1*3+c] + r3[r*3+2]*ss[2*3+c];
} else {
for (int k = 0; k < 9; ++k) u3[k] = r3[k];
}
Mat4& m = out[ti];
m = Mat4{};
m[0] = u3[0]; m[1] = u3[3]; m[2] = u3[6]; m[3] = 0;
m[4] = u3[1]; m[5] = u3[4]; m[6] = u3[7]; m[7] = 0;
m[8] = u3[2]; m[9] = u3[5]; m[10] = u3[8]; m[11] = 0;
m[12] = pos[0]; m[13] = pos[1]; m[14] = pos[2]; m[15] = 1;
}
}
} // namespace gr2