Metin2 game client (P0–P11) + mobile asset pipeline

Networked client on the existing Godot 4.7 + libgr2 renderer:
- net: m2dev wire protocol (libsodium KX + XChaCha20), auth/select/game
  phases, EntityStore world model, ~all GC/CG headers. char create/delete,
  private shop / mall / cube, SHOP_GC_START_EX, guild, party (+ CG_PARTY_SET_STATE),
  quests, dragon soul, refine, safebox, exchange.
- UI: in-game windows migrated 1:1 from the reference uiscript/root .py —
  char status (/stat), inventory+equipment, select-item ([SELECT_ITEM] quest
  token), system-option + game-option + ESC system menu, private-shop 39-grid,
  party info board, shop tabs, atlas, minimap, quickbar, chat, …
- EterGrnLib polish: GR2 material blend/two-sided, LOD crossfade, motion-event
  dispatch, contact shadow, ray-AABB picking, weapon grip pre-transform.

Portable asset IO (A1) — all extension/libgr2/formats/mtproto reads routed
through godot::FileAccess (res:// PCK works on iOS/Android); standalone-lib
*_path() kept for the non-Godot CTests. AssetResolver + PropertyRegistry
switched to a baked index (bake_asset_index.gd) instead of std::filesystem.

Mobile builds: build-{android,ios}.sh, export-android.sh, pack-assets.sh,
gen-debug-keystore.sh. Assets ship as a zip mounted at runtime by
project/asset_pack.gd (adb push now; HTTP download is a drop-in later).

ctest 10/10, 34 GDScript suites, macOS/iOS/Android all build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EJxkHiNKS4kybHS3XKyAJ
This commit is contained in:
shen
2026-08-31 20:02:12 +09:00
co-authored by Claude Sonnet 5
parent f4917a2b3b
commit 47baf6c0c6
414 changed files with 69568 additions and 385 deletions
+21
View File
@@ -0,0 +1,21 @@
# M0 工具。只依赖 libgr2。
add_executable(gr2dump gr2dump/main.cpp)
target_link_libraries(gr2dump PRIVATE xrender::libgr2)
add_executable(gr2fuzz gr2fuzz/main.cpp)
target_link_libraries(gr2fuzz PRIVATE xrender::libgr2)
# M2 门禁·主:libgr2 vs oracle 逐字段对拍(读 oracle/oracle.exe 产出的 .bin
add_executable(oracle_diff oracle_diff/main.cpp)
target_link_libraries(oracle_diff PRIVATE xrender::libgr2)
# bisectdump 每骨 world 3x3 的 det / shear / scale spread(查 dance_1 头塌陷)
add_executable(anim_probe anim_probe/main.cpp)
target_link_libraries(anim_probe PRIVATE xrender::libgr2)
# SHINSOO W0:一张地图的 load reportsetting / textureset / 区块二进制 / areadata
add_executable(map_probe map_probe/main.cpp)
target_link_libraries(map_probe PRIVATE xrender::formats)
# gr2dump --gltf 需要 tinygltfM0 T8 辅助、可延后):
# if(TARGET tinygltf) target_link_libraries(gr2dump PRIVATE tinygltf) endif()
+144
View File
@@ -0,0 +1,144 @@
// anim_probe — bisect the dance_1 "head/neck collapse".
//
// anim_probe <model.gr2> <anim.gr2> [--bone <substr>] [t0 t1 ...]
//
// libgr2's world pose per bone is well-formed (det, shear, finiteness) — the probe
// confirms that. The real question is the mtgodot bridge: metin2_anim.cpp feeds
// local = conv(world[parent])⁻¹ · conv(world[i])
// to Skeleton3D::set_bone_pose(), which in Godot 4 DECOMPOSES to position +
// Quaternion + per-axis scale. That representation cannot hold shear. So for every
// bone we also compute the parent-relative local, its shear, and the error of a
// T·R(quat)·S round-trip (= what Godot actually keeps). High round-trip error =
// Godot will visibly break that bone once the anim leaves bind pose.
#include "gr2/gr2.h"
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
using gr2::Mat4;
static double det3(const Mat4& m){
return m[0]*(m[5]*m[10]-m[6]*m[9]) - m[4]*(m[1]*m[10]-m[2]*m[9]) + m[8]*(m[1]*m[6]-m[2]*m[5]);
}
static void axes_len(const Mat4& m,double&a,double&b,double&c){
a=std::sqrt(m[0]*m[0]+m[1]*m[1]+m[2]*m[2]);
b=std::sqrt(m[4]*m[4]+m[5]*m[5]+m[6]*m[6]);
c=std::sqrt(m[8]*m[8]+m[9]*m[9]+m[10]*m[10]);
}
static double shear(const Mat4& m){
double r0[3]={m[0],m[1],m[2]}, r1[3]={m[4],m[5],m[6]}, r2[3]={m[8],m[9],m[10]};
double a,b,c; axes_len(m,a,b,c);
if(a<1e-9||b<1e-9||c<1e-9) return 9.99;
auto D=[&](double*x,double*y){return x[0]*y[0]+x[1]*y[1]+x[2]*y[2];};
return std::fmax(std::fabs(D(r0,r1))/(a*b),
std::fmax(std::fabs(D(r0,r2))/(a*c), std::fabs(D(r1,r2))/(b*c)));
}
static bool finite16(const Mat4& m){ for(float v:m) if(!std::isfinite(v)) return false; return true; }
static Mat4 mul(const Mat4& A,const Mat4& B){ // row-major, v'=v*M
Mat4 R{};
for(int r=0;r<4;++r)for(int c=0;c<4;++c){ double s=0; for(int k=0;k<4;++k) s+=(double)A[r*4+k]*B[k*4+c]; R[r*4+c]=(float)s; }
return R;
}
static Mat4 inv_affine(const Mat4& m){
double a=m[0],b=m[1],c=m[2],d=m[4],e=m[5],f=m[6],g=m[8],h=m[9],i=m[10];
double D=a*(e*i-f*h)-b*(d*i-f*g)+c*(d*h-e*g);
Mat4 r{}; if(std::fabs(D)<1e-12){ for(int k=0;k<16;++k) r[k]=std::nanf(""); return r; }
double id=1.0/D;
double m00=(e*i-f*h)*id,m01=(c*h-b*i)*id,m02=(b*f-c*e)*id;
double m10=(f*g-d*i)*id,m11=(a*i-c*g)*id,m12=(c*d-a*f)*id;
double m20=(d*h-e*g)*id,m21=(b*g-a*h)*id,m22=(a*e-b*d)*id;
r[0]=m00;r[1]=m01;r[2]=m02; r[4]=m10;r[5]=m11;r[6]=m12; r[8]=m20;r[9]=m21;r[10]=m22; r[15]=1;
double tx=m[12],ty=m[13],tz=m[14];
r[12]=-(tx*m00+ty*m10+tz*m20); r[13]=-(tx*m01+ty*m11+tz*m21); r[14]=-(tx*m02+ty*m12+tz*m22);
return r;
}
// Godot set_bone_pose keeps T · R(orthonormal, from unit-quat) · diag(S).
// R comes from Gram-Schmidt-style orthonormalization of the local basis (matching
// Basis::get_rotation_quaternion / orthonormalized), so shear IS dropped.
// Reconstruct R·diag(S) and return max |element - original local 3x3|.
static double trs_roundtrip_err(const Mat4& L){
double x[3]={L[0],L[1],L[2]}, y[3]={L[4],L[5],L[6]}, z[3]={L[8],L[9],L[10]};
auto nrm=[&](double*v){ double l=std::sqrt(v[0]*v[0]+v[1]*v[1]+v[2]*v[2]); if(l<1e-12) return 0.0; v[0]/=l;v[1]/=l;v[2]/=l; return l; };
auto dot=[&](double*a,double*b){return a[0]*b[0]+a[1]*b[1]+a[2]*b[2];};
// Gram-Schmidt: x normalized; y -= (y·x)x then normalized; z = x×y (keep handedness)
double sx=nrm(x); if(sx==0) return 9.99;
double d=dot(y,x); double yo[3]={y[0]-d*x[0],y[1]-d*x[1],y[2]-d*x[2]};
double sy=std::sqrt(yo[0]*yo[0]+yo[1]*yo[1]+yo[2]*yo[2]); if(sy<1e-12) return 9.99;
yo[0]/=sy;yo[1]/=sy;yo[2]/=sy;
double zo[3]={ x[1]*yo[2]-x[2]*yo[1], x[2]*yo[0]-x[0]*yo[2], x[0]*yo[1]-x[1]*yo[0] };
double sz=dot(z,zo); // signed
// recompose rows: sx*x, sy*yo, sz*zo (Godot's scale is per-axis on the orthonormal frame)
double M[9]={ sx*x[0],sx*x[1],sx*x[2], sy*yo[0],sy*yo[1],sy*yo[2], sz*zo[0],sz*zo[1],sz*zo[2] };
const double orig[9]={L[0],L[1],L[2],L[4],L[5],L[6],L[8],L[9],L[10]};
double e=0; for(int k=0;k<9;++k) e=std::fmax(e,std::fabs(M[k]-orig[k]));
return e;
}
int main(int argc,char**argv){
if(argc<3){ std::fprintf(stderr,"usage: anim_probe <model.gr2> <anim.gr2> [--bone <substr>] [t...]\n"); return 2; }
std::string bonefilter;
std::vector<float> ts;
for(int i=3;i<argc;++i){
if(!std::strcmp(argv[i],"--bone") && i+1<argc){ bonefilter=argv[++i]; }
else ts.push_back((float)atof(argv[i]));
}
gr2::LoadError e;
auto model=gr2::File::load_path(argv[1],&e);
if(!model){ std::fprintf(stderr,"model: %s %s\n",e.stage.c_str(),e.message.c_str()); return 1; }
auto anim=gr2::File::load_path(argv[2],&e);
if(!anim){ std::fprintf(stderr,"anim: %s %s\n",e.stage.c_str(),e.message.c_str()); return 1; }
const auto& sk=model->file_info().skeletons.at(0);
const auto& an=anim->file_info().animations.at(0);
std::printf("model=%s anim=%s dur=%.3f bones=%zu tracks=%zu\n\n",argv[1],an.name.c_str(),an.duration,sk.bones.size(),an.tracks.size());
if(ts.empty()) for(int k=0;k<=16;++k) ts.push_back(an.duration*k/16.0f);
// per-bone maxima across a fine sweep
std::vector<double> mxLocShear(sk.bones.size(),0), mxTRS(sk.bones.size(),0), mxWShear(sk.bones.size(),0), mnDet(sk.bones.size(),1e9);
std::vector<Mat4> W,S;
for(int k=0;k<=240;++k){
gr2::sample_pose(sk,an,an.duration*k/240.0f,W,S);
for(size_t i=0;i<W.size()&&i<sk.bones.size();++i){
if(!finite16(W[i])) continue;
mxWShear[i]=std::fmax(mxWShear[i],shear(W[i]));
mnDet[i]=std::fmin(mnDet[i],std::fabs(det3(W[i])));
int p=sk.bones[i].parent;
if(p>=0&&p<(int)W.size()&&finite16(W[p])){
Mat4 L=mul(inv_affine(W[p]),W[i]);
if(finite16(L)){ mxLocShear[i]=std::fmax(mxLocShear[i],shear(L)); mxTRS[i]=std::fmax(mxTRS[i],trs_roundtrip_err(L)); }
}
}
}
std::printf("== bones Godot's set_bone_pose can't represent (TRS round-trip err) ==\n");
std::printf("%-4s %-26s %10s %10s %10s %8s parent\n","idx","name","locShear","TRSerr","wShear","minDet");
bool any=false;
for(size_t i=0;i<sk.bones.size();++i){
if(mxTRS[i]>0.01 || mxLocShear[i]>0.02){
any=true;
std::printf("b%-3zu %-26s %10.4f %10.4f %10.4f %8.4f %d\n",
i,sk.bones[i].name.c_str(),mxLocShear[i],mxTRS[i],mxWShear[i],mnDet[i],sk.bones[i].parent);
}
}
if(!any) std::printf(" (none — every parent-relative local is T·R·S representable)\n");
if(!bonefilter.empty()){
std::printf("\n== per-frame detail for bones matching \"%s\" ==\n",bonefilter.c_str());
for(float t: ts){
gr2::sample_pose(sk,an,t,W,S);
std::printf("t=%7.3f\n",t);
for(size_t i=0;i<W.size()&&i<sk.bones.size();++i){
if(sk.bones[i].name.find(bonefilter)==std::string::npos) continue;
double a,b,c; axes_len(W[i],a,b,c);
int p=sk.bones[i].parent;
double locsh=0,trs=0;
if(p>=0&&p<(int)W.size()){ Mat4 L=mul(inv_affine(W[p]),W[i]); locsh=shear(L); trs=trs_roundtrip_err(L); }
std::printf(" b%-3zu %-24s worldAxes=(%.3f %.3f %.3f) wDet=%.4f locShear=%.4f TRSerr=%.4f\n",
i,sk.bones[i].name.c_str(),a,b,c,det3(W[i]),locsh,trs);
}
}
}
return 0;
}
+195
View File
@@ -0,0 +1,195 @@
// M0 T8 —— gr2dump CLI。见 docs/steps/M0-gr2-reader.md
// gr2dump <file.gr2> [--sections] [--gltf out.glb]
#include "gr2/gr2.h"
#include <cstdio>
#include <cstring>
#include <cmath>
#include <map>
#include <string>
// 从展开后的 section 里找可打印 ASCII 串(>= minlen)。
// 用途:Metin2 gr2 都内嵌 "d:\ymir work\...\xxx.gr2" 之类的路径 ——
// 解压正确的话这些串会原样出现,是不依赖 oracle 的内容正确性锚。
static int dump_strings(const gr2::File& f, size_t minlen) {
int hits = 0;
for (size_t i = 0; i < f.sections().size(); ++i) {
auto b = f.section_bytes(i);
std::string cur;
for (size_t k = 0; k <= b.size(); ++k) {
unsigned char c = (k < b.size()) ? b[k] : 0;
if (c >= 0x20 && c < 0x7f) { cur += char(c); continue; }
if (cur.size() >= minlen) { std::printf(" [sec %zu] %s\n", i, cur.c_str()); ++hits; }
cur.clear();
}
}
return hits;
}
int main(int argc, char** argv) {
if (argc < 2) {
std::fprintf(stderr, "usage: gr2dump <file.gr2> [--sections] [--strings] [--gltf out.glb]\n");
return 2;
}
const std::string path = argv[1];
bool want_sections = false, want_strings = false, want_members = false;
std::string gltf_out;
for (int i = 2; i < argc; ++i) {
if (!std::strcmp(argv[i], "--sections")) want_sections = true;
else if (!std::strcmp(argv[i], "--strings")) want_strings = true;
else if (!std::strcmp(argv[i], "--members")) want_members = true;
else if (!std::strcmp(argv[i], "--gltf") && i + 1 < argc) gltf_out = argv[++i];
}
gr2::LoadError err;
auto file = gr2::File::load_path(path, &err);
if (!file) {
std::fprintf(stderr, "load failed [%s]: %s\n", err.stage.c_str(), err.message.c_str());
return 1;
}
std::printf("magic : %s\n", gr2::magic_name(file->magic()));
std::printf("format_version : %u\n", file->format_version());
std::printf("total_size : %u\n", file->total_size());
std::printf("sections : %zu\n", file->sections().size());
if (want_sections) {
std::printf("\n idx compression data_size expanded ratio ptr_fixups decomp\n");
std::printf(" --- ----------- --------- -------- ----- ---------- ------\n");
int idx = 0, ok = 0, nonempty = 0;
for (const auto& s : file->sections()) {
double ratio = s.expanded_size ? double(s.data_size) / s.expanded_size : 0.0;
const char* st = s.expanded_size == 0 ? "empty"
: s.decompress_status == 0 ? "OK" : "FAIL";
if (s.expanded_size) { ++nonempty; if (s.decompress_status == 0) ++ok; }
std::printf(" %3d %-11s %9u %8u %4.2f %10u %s\n",
idx++, gr2::compression_name(s.compression),
s.data_size, s.expanded_size, ratio, s.pointer_fixup_count, st);
}
std::printf(" → decompress: %d/%d non-empty sections OK\n", ok, nonempty);
}
if (want_strings) {
std::printf("\nembedded strings (>=6 chars, from decompressed sections):\n");
int n = dump_strings(*file, 6);
std::printf(" → %d strings\n", n);
}
if (want_members) {
static const char* kMT[] = {
"End","Inline","Reference","RefToArray","ArrayOfRefs","VariantRef",
"Removed","RefToVariantArray","String","Transform","Real32","Int8",
"UInt8","BinormInt8","NormUInt8","Int16","UInt16","BinormInt16",
"NormUInt16","Int32","UInt32","Real16","EmptyRef"};
std::printf("\nroot object type members (T3 typetree walk):\n");
auto ms = gr2::dump_root_members(*file);
for (const auto& m : ms) {
const char* tn = (m.member_type >= 0 && m.member_type < 23) ? kMT[m.member_type] : "?";
std::printf(" %-24s %-18s array_width=%d\n", m.name.c_str(), tn, m.array_width);
}
std::printf(" → %zu members\n", ms.size());
}
// ── T4/T5/T6FileInfo 摘要 ──────────────────────────────────────
const auto& fi = file->file_info();
std::printf("\nFileInfo:\n");
std::printf(" from_file_name : %s\n", fi.from_file_name.c_str());
std::printf(" skeletons=%zu meshes=%zu animations=%zu materials=%u textures=%u models=%u\n",
fi.skeletons.size(), fi.meshes.size(), fi.animations.size(),
fi.material_count, fi.texture_count, fi.model_count);
for (size_t i = 0; i < fi.materials.size(); ++i)
std::printf(" material %zu: %-24s tex=%s\n", i,
fi.materials[i].name.c_str(), fi.materials[i].diffuse_texture.c_str());
for (size_t si = 0; si < fi.skeletons.size(); ++si) {
const auto& sk = fi.skeletons[si];
int nonI_ori = 0, nonI_ss = 0;
for (const auto& b : sk.bones) {
if (b.lt_flags & 0x2) ++nonI_ori;
if (b.lt_flags & 0x4) ++nonI_ss;
}
std::printf("\n skeleton %zu: %zu bones (non-identity: orientation=%d scaleshear=%d)\n",
si, sk.bones.size(), nonI_ori, nonI_ss);
for (size_t i = 0; i < sk.bones.size(); ++i) {
const auto& b = sk.bones[i];
int depth = 0;
for (int p = b.parent; p >= 0 && depth < 64; ) { ++depth; p = sk.bones[p].parent; }
std::printf(" %*s[%zu] %s (parent=%d)\n", depth * 2, "", i, b.name.c_str(), b.parent);
}
}
for (size_t mi = 0; mi < fi.meshes.size(); ++mi) {
const auto& me = fi.meshes[mi];
static const char* kK[] = {"PNT332","PNT3322","PNT332_Skinned","PNT3322_Skinned","Unknown"};
std::printf("\n mesh %zu: %s\n", mi, me.name.c_str());
std::printf(" kind=%s rigid=%d vertices=%zu indices=%zu tri_groups=%zu bone_bindings=%zu\n",
kK[int(me.kind)], me.rigid, me.vertices.size(), me.indices.size(),
me.tri_groups.size(), me.bone_bindings.size());
for (size_t g = 0; g < me.tri_groups.size(); ++g) {
int mix = me.tri_groups[g].material_index;
const char* tn = (mix >= 0 && mix < (int)me.material_textures.size())
? me.material_textures[mix].c_str() : "";
std::printf(" group %zu: material=%d tri_first=%d tri_count=%d tex=%s\n",
g, mix, me.tri_groups[g].tri_first,
me.tri_groups[g].tri_count, tn);
}
for (size_t b = 0; b < me.material_textures.size(); ++b)
std::printf(" binding %zu -> %s\n", b, me.material_textures[b].c_str());
int dangling = 0;
for (int32_t b : me.bone_bindings) if (b < 0) ++dangling;
if (!me.bone_bindings.empty())
std::printf(" dangling bone bindings: %d\n", dangling);
}
for (size_t ai = 0; ai < fi.animations.size(); ++ai) {
const auto& an = fi.animations[ai];
std::printf("\n animation %zu: %s duration=%.3f tracks=%zu\n",
ai, an.name.c_str(), an.duration, an.tracks.size());
std::map<std::string, int> hist;
int mapped = 0;
for (const auto& t : an.tracks) {
hist["pos:" + t.pos_type]++;
hist["rot:" + t.rot_type]++;
hist["scale:" + t.scale_type]++;
if (t.bone_index >= 0) ++mapped;
}
std::printf(" curve subtype histogram:\n");
for (const auto& [k, v] : hist)
std::printf(" %-28s %d\n", k.c_str(), v);
std::printf(" tracks mapped to skeleton bones: %d/%zu\n", mapped, an.tracks.size());
// T7b:在几个采样点求值,报非有限数 + root track 局部平移
int pos_c = 0, rot_c = 0, ss_c = 0;
for (const auto& t : an.tracks) {
if (!t.position.empty()) ++pos_c;
if (!t.orientation.empty()) ++rot_c;
if (!t.scale_shear.empty()) ++ss_c;
}
std::printf(" curves decoded (T7b): pos=%d rot=%d scaleshear=%d\n", pos_c, rot_c, ss_c);
for (float frac : {0.0f, 0.5f, 1.0f}) {
float tt = an.duration * frac;
std::vector<gr2::Mat4> loc;
an.sample_local(tt, loc);
int bad = 0;
for (const auto& m : loc) for (float x : m) if (!std::isfinite(x)) ++bad;
std::printf(" sample t=%7.3f non-finite=%d", tt, bad);
if (!loc.empty())
std::printf(" track0 '%s' pos=(%.2f,%.2f,%.2f)",
an.tracks[0].bone_name.c_str(),
loc[0][12], loc[0][13], loc[0][14]);
std::printf("\n");
}
}
// ── T5bind pose 自洽 ───────────────────────────────────────────
if (!fi.skeletons.empty()) {
double dev = file->bind_pose_self_check(0);
std::printf("\nbind pose self-check: %s (max|delta|=%.3g, threshold=1e-3)\n",
dev < 1e-3 ? "PASS" : "FAIL", dev);
} else {
std::printf("\nbind pose self-check: n/a (no skeleton in this file)\n");
}
if (!gltf_out.empty())
std::fprintf(stderr, "--gltf not implemented yet (M0 T8)\n");
return 0;
}
+257
View File
@@ -0,0 +1,257 @@
// M0 T9a/b/c —— 全量解析 + 非退化谓词 + 直方图。见 docs/steps/M0-gr2-reader.md
// gr2fuzz [assets-root] [--report out.json] [--list out.txt]
#include "gr2/gr2.h"
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <exception>
#include <filesystem>
#include <map>
#include <string>
#include <vector>
namespace fs = std::filesystem;
namespace {
struct Hist {
std::map<std::string, uint64_t> m;
void add(const std::string& k, uint64_t n = 1) { m[k] += n; }
};
struct Fails {
std::vector<std::string> lines;
void add(const std::string& path, const std::string& why) {
lines.push_back(path + "\t" + why);
}
};
// 非退化谓词。返回空 = 通过;否则第一条失败原因。
std::string check_predicates(const gr2::File& f) {
const auto& fi = f.file_info();
for (size_t s = 0; s < fi.skeletons.size(); ++s) {
const auto& sk = fi.skeletons[s];
if (sk.bones.empty() || sk.bones.size() > 512)
return "skeleton bone count out of [1,512]: " + std::to_string(sk.bones.size());
for (size_t i = 0; i < sk.bones.size(); ++i) {
int32_t p = sk.bones[i].parent;
if (p != -1 && (p < 0 || (size_t)p >= i))
return "bone parent not < self or -1 at " + std::to_string(i);
for (float v : sk.bones[i].local_transform)
if (!std::isfinite(v)) return "non-finite local_transform at bone " + std::to_string(i);
for (float v : sk.bones[i].inverse_world)
if (!std::isfinite(v)) return "non-finite inverse_world at bone " + std::to_string(i);
}
}
for (size_t mi = 0; mi < fi.meshes.size(); ++mi) {
const auto& me = fi.meshes[mi];
if (me.vertices.empty()) return "mesh " + std::to_string(mi) + " has 0 vertices";
for (uint32_t idx : me.indices)
if (idx >= me.vertices.size())
return "mesh " + std::to_string(mi) + " index " + std::to_string(idx) +
" >= vertex count " + std::to_string(me.vertices.size());
for (const auto& v : me.vertices)
for (float c : v.pos)
if (!std::isfinite(c)) return "mesh " + std::to_string(mi) + " non-finite position";
for (const auto& g : me.tri_groups)
if (g.tri_first < 0 || g.tri_count < 0 ||
(int64_t)(g.tri_first + g.tri_count) * 3 > (int64_t)me.indices.size())
return "mesh " + std::to_string(mi) + " tri group out of range";
if (!me.rigid) {
for (int32_t b : me.bone_bindings)
if (b < 0) return "mesh " + std::to_string(mi) + " dangling bone binding";
for (const auto& v : me.vertices) {
int wsum = 0;
for (int k = 0; k < 4; ++k) {
wsum += v.bone_weight[k];
if (!me.bone_bindings.empty() && v.bone_index[k] >= me.bone_bindings.size())
return "mesh " + std::to_string(mi) + " bone index out of range";
}
// u8 权重和:3 显式 + 1 隐式的情况下允许补齐,故用宽松带
if (wsum < 200 || wsum > 262) // 255 ± ~10%(含隐式第 4 权重补齐余量)
return "mesh " + std::to_string(mi) + " weight sum " + std::to_string(wsum) +
" out of [200,262]";
}
}
}
return {};
}
void dump_report(const fs::path& out, uint64_t total, uint64_t ok, uint64_t crashed,
uint64_t pred_fail, Hist& ver, Hist& comp, Hist& vkind, Hist& curve,
Hist& weights, Hist& ssuse, Hist& selfchk,
const Fails& crashes, const Fails& preds) {
FILE* fp = std::fopen(out.string().c_str(), "wb");
if (!fp) { std::fprintf(stderr, "cannot write %s\n", out.string().c_str()); return; }
auto obj = [&](const char* name, Hist& h, bool last) {
std::fprintf(fp, " \"%s\": {", name);
bool first = true;
for (auto& [k, v] : h.m) {
std::fprintf(fp, "%s\n \"%s\": %llu", first ? "" : ",", k.c_str(),
(unsigned long long)v);
first = false;
}
std::fprintf(fp, "\n }%s\n", last ? "" : ",");
};
auto arr = [&](const char* name, const Fails& fl, bool last) {
std::fprintf(fp, " \"%s\": [", name);
for (size_t i = 0; i < fl.lines.size(); ++i) {
std::string s = fl.lines[i];
std::string esc;
for (char c : s) { if (c == '\\' || c == '"') esc += '\\'; esc += (c == '\t' ? ' ' : c); }
std::fprintf(fp, "%s\n \"%s\"", i ? "," : "", esc.c_str());
}
std::fprintf(fp, "\n ]%s\n", last ? "" : ",");
};
std::fprintf(fp, "{\n");
std::fprintf(fp, " \"total\": %llu,\n \"ok\": %llu,\n \"crashed\": %llu,\n \"predicate_fail\": %llu,\n",
(unsigned long long)total, (unsigned long long)ok,
(unsigned long long)crashed, (unsigned long long)pred_fail);
obj("format_version", ver, false);
obj("section_compression", comp, false);
obj("vertex_kind", vkind, false);
obj("bone_weight_slots", weights, false);
obj("curve_subtype", curve, false);
obj("scaleshear_usage", ssuse, false);
obj("bind_pose_self_check", selfchk, false);
arr("crashes", crashes, false);
arr("predicate_failures", preds, true);
std::fprintf(fp, "}\n");
std::fclose(fp);
}
} // namespace
#ifndef XRENDER_ASSET_ROOT
#define XRENDER_ASSET_ROOT "." // 无编译期默认:从命令行传资产根目录
#endif
int main(int argc, char** argv) {
std::string root = XRENDER_ASSET_ROOT;
fs::path report = "fuzz-report.json";
fs::path listout;
for (int i = 1; i < argc; ++i) {
if (!std::strcmp(argv[i], "--report") && i + 1 < argc) report = argv[++i];
else if (!std::strcmp(argv[i], "--list") && i + 1 < argc) listout = argv[++i];
else root = argv[i];
}
if (!fs::exists(root)) {
std::fprintf(stderr, "asset root not found: %s\n", root.c_str());
return 2;
}
uint64_t total = 0, ok = 0, crashed = 0, pred_fail = 0;
Hist ver, comp, vkind, curve, weights, ssuse, selfchk;
Fails crashes, preds;
std::vector<std::string> good_files;
static const char* kK[] = {"PNT332", "PNT3322", "PNT332_Skinned", "PNT3322_Skinned", "Unknown"};
for (auto& e : fs::recursive_directory_iterator(
root, fs::directory_options::skip_permission_denied)) {
if (!e.is_regular_file()) continue;
auto p = e.path();
std::string ext = p.extension().string();
for (auto& c : ext) c = (char)std::tolower((unsigned char)c);
if (ext != ".gr2") continue;
++total;
gr2::LoadError err;
std::optional<gr2::File> f;
try {
f = gr2::File::load_path(p.string(), &err);
} catch (const std::exception& ex) {
++crashed; crashes.add(p.string(), std::string("exception: ") + ex.what());
continue;
} catch (...) {
++crashed; crashes.add(p.string(), "exception: unknown");
continue;
}
if (!f) {
++crashed; crashes.add(p.string(), "load[" + err.stage + "]: " + err.message);
continue;
}
ver.add(std::to_string(f->format_version()));
for (const auto& s : f->sections()) {
comp.add(gr2::compression_name(s.compression));
if (s.expanded_size && s.decompress_status != 0) {
++crashed;
crashes.add(p.string(), "decompress status " + std::to_string(s.decompress_status));
}
}
const auto& fi = f->file_info();
// bind pose 自洽(T5):对每个 skeleton 取最好(最小)偏差,按量级分桶
for (size_t s = 0; s < fi.skeletons.size(); ++s) {
if (fi.skeletons[s].bones.size() < 2) continue; // 单骨挂点跳过
double dev = f->bind_pose_self_check(int(s));
const char* bucket = dev < 1e-4 ? "<1e-4"
: dev < 1e-3 ? "<1e-3"
: dev < 1e-2 ? "<1e-2"
: dev < 1e-1 ? "<1e-1" : ">=1e-1";
selfchk.add(bucket);
if (dev >= 1e-1 && getenv("GR2_SELFCHK"))
std::fprintf(stderr, "SELFCHK %.4f skel%zu bones=%zu %s\n",
dev, s, fi.skeletons[s].bones.size(), p.string().c_str());
}
for (const auto& me : fi.meshes) {
vkind.add(kK[int(me.kind)]);
if (!me.rigid) {
weights.add(std::to_string(me.source_bone_weight_slots));
}
}
for (const auto& sk : fi.skeletons) {
uint64_t ss = 0;
for (const auto& b : sk.bones) if (b.lt_flags & 0x4) ++ss;
ssuse.add(ss ? "has_scaleshear" : "no_scaleshear", 1);
ssuse.add("scaleshear_bones", ss);
}
for (const auto& an : fi.animations)
for (const auto& t : an.tracks) {
curve.add("pos:" + t.pos_type);
curve.add("rot:" + t.rot_type);
curve.add("scale:" + t.scale_type);
}
std::string why;
try {
why = check_predicates(*f);
} catch (...) {
why = "predicate check threw";
}
if (!why.empty()) { ++pred_fail; preds.add(p.string(), why); continue; }
++ok;
good_files.push_back(p.string());
}
dump_report(report, total, ok, crashed, pred_fail, ver, comp, vkind, curve, weights, ssuse,
selfchk, crashes, preds);
if (!listout.empty()) {
FILE* fp = std::fopen(listout.string().c_str(), "wb");
if (fp) { for (auto& s : good_files) std::fprintf(fp, "%s\n", s.c_str()); std::fclose(fp); }
}
std::printf("gr2fuzz: total=%llu ok=%llu crashed=%llu predicate_fail=%llu\n",
(unsigned long long)total, (unsigned long long)ok,
(unsigned long long)crashed, (unsigned long long)pred_fail);
std::printf(" report: %s\n", report.string().c_str());
if (!crashes.lines.empty()) {
std::printf(" first crashes:\n");
for (size_t i = 0; i < crashes.lines.size() && i < 20; ++i)
std::printf(" %s\n", crashes.lines[i].c_str());
}
if (!preds.lines.empty()) {
std::printf(" first predicate failures:\n");
for (size_t i = 0; i < preds.lines.size() && i < 20; ++i)
std::printf(" %s\n", preds.lines[i].c_str());
}
return (crashed == 0 && pred_fail == 0) ? 0 : 1;
}
+202
View File
@@ -0,0 +1,202 @@
// map_probe — SHINSOO-WORLD-RENDERING.md W0 的 load report。
//
// map_probe <assets_root> [map_rel] map_rel 默认 OutdoorA1/metin2_map_a1
//
// 扫一张地图:setting.txt / TextureSet / 每个区块的 6 类文件 / areadata
// 逐项打印「成功 / 失败 + 原因」,末尾给汇总。不加载 .msenv / PropertyW0 剩余项)。
#include "area_data.h"
#include "asset_resolver.h"
#include "environment.h"
#include "m2_coord.h"
#include "map_setting.h"
#include "property.h"
#include "splat.h"
#include "terrain_files.h"
#include "texture_set.h"
#include <cstdio>
#include <cstdlib>
#include <map>
#include <set>
#include <string>
using namespace fmt;
static int g_ok = 0, g_bad = 0;
static void line(const char* tag, bool ok, const std::string& detail) {
std::printf(" [%s] %-28s %s\n", ok ? "OK " : "ERR", tag, detail.c_str());
ok ? ++g_ok : ++g_bad;
}
int main(int argc, char** argv) {
if (argc < 2) {
std::fprintf(stderr, "usage: map_probe <assets_root> [map_rel]\n");
return 2;
}
std::string assets = argv[1];
std::string map_rel = argc > 2 ? argv[2] : "OutdoorA1/metin2_map_a1";
std::string map = assets + "/" + map_rel;
std::printf("== map_probe: %s ==\n", map.c_str());
std::string err;
MapSetting s;
if (!parse_map_setting_file(map + "/setting.txt", s, &err)) {
line("setting.txt", false, err);
return 1;
}
line("setting.txt", true,
"MapSize " + std::to_string(s.map_size_x) + "x" + std::to_string(s.map_size_y) +
" CellScale " + std::to_string(s.cell_scale) + " HeightScale " +
std::to_string(s.height_scale) + " env=" + s.environment);
// TextureSetsetting 里是 `textureset\metin2_A1.txt`,实盘在 assets/textureset/textureset/ 且小写
{
std::string rel = s.texture_set;
for (auto& c : rel)
if (c == '\\') c = '/';
for (auto& c : rel) c = (char)std::tolower((unsigned char)c);
std::string p = assets + "/textureset/" + rel;
TextureSet ts;
if (parse_texture_set_file(p, ts, &err))
line("TextureSet", true,
std::to_string(ts.layers.size()) + " layers (+1 empty = runtime " +
std::to_string(ts.runtime_count()) + ")");
else
line("TextureSet", false, p + " : " + err);
}
// 每个区块
int chunks_found = 0, hm_ok = 0, tile_ok = 0, attr_ok = 0, water_ok = 0, area_ok = 0;
long total_objs = 0;
std::map<uint32_t, int> crc_hist;
for (int tx = 0; tx < s.map_size_x; ++tx) {
for (int ty = 0; ty < s.map_size_y; ++ty) {
std::string dir = map + "/" + m2coord::tile_dir(tx, ty);
std::string e;
HeightMap hm;
TileMap tm;
AttrMap am;
WaterMap wm;
AreaData ad;
bool any = false;
if (load_height_map(dir + "/height.raw", hm, &e)) { ++hm_ok; any = true; }
if (load_tile_map(dir + "/tile.raw", tm, &e)) ++tile_ok;
if (load_attr_map(dir + "/attr.atr", am, &e)) ++attr_ok;
if (load_water_map(dir + "/water.wtr", wm, &e)) ++water_ok;
if (parse_area_data_file(dir + "/areadata.txt", ad, &e)) {
++area_ok;
total_objs += (long)ad.objects.size();
for (auto& o : ad.objects) crc_hist[o.crc]++;
}
if (any) ++chunks_found;
}
}
int n = s.chunk_count();
line("chunk dirs", chunks_found == n,
std::to_string(chunks_found) + "/" + std::to_string(n));
line("height.raw", hm_ok == n, std::to_string(hm_ok) + "/" + std::to_string(n) + " as 131x131");
line("tile.raw", tile_ok == n, std::to_string(tile_ok) + "/" + std::to_string(n) + " as 258x258");
line("attr.atr", attr_ok == n, std::to_string(attr_ok) + "/" + std::to_string(n) + " magic 2634");
line("water.wtr", water_ok == n, std::to_string(water_ok) + "/" + std::to_string(n) + " magic 5426");
line("areadata.txt", area_ok == n, std::to_string(area_ok) + "/" + std::to_string(n) + " parsed");
std::printf(" ---- AreaData: %ld objects, %zu distinct Property CRCs ----\n",
total_objs, crc_hist.size());
// splat(区块 000000
{
TileMap tm;
std::string e;
if (load_tile_map(map + "/000000/tile.raw", tm, &e)) {
SplatSet ss;
build_splat(tm, 18, ss);
std::string used;
for (auto& L : ss.layers)
used += std::to_string(L.layer) + "(" + std::to_string(L.coverage) + ") ";
line("splat 000000", !ss.layers.empty(),
std::to_string(ss.layers.size()) + " used layers: " + used);
}
}
// .msenv(虚拟路径 environment\<name> -> ETC/ymir work/environment/<name>
Environment env;
{
std::string p = assets + "/ETC/ymir work/environment/" + s.environment;
for (auto& c : p) c = (c == '\\') ? '/' : (char)std::tolower((unsigned char)c);
// 只小写文件名部分即可,这里整体小写对 macOS/实盘均成立
std::string p2 = assets + "/ETC/ymir work/environment/" + s.environment;
std::string lp = p2;
for (auto& c : lp) c = (char)std::tolower((unsigned char)c);
if (parse_environment_file(lp, env, &err))
line(".msenv", true,
s.environment + " : fog=" + (env.fog.enable ? "on" : "off") + " gradient=" +
std::to_string(env.sky.gradient.size()) + " rows, cloudtex=" +
env.sky.cloud_texture);
else
line(".msenv", false, lp + " : " + err);
}
// Property CRC 注册表
PropertyRegistry reg;
if (reg.scan(assets + "/Property", &err)) {
std::string bytype;
for (auto& [k, v] : reg.type_counts) bytype += k + "=" + std::to_string(v) + " ";
line("Property registry", reg.parse_failed == 0,
std::to_string(reg.by_crc.size()) + " CRCs (" + std::to_string(reg.files_scanned) +
" files, " + std::to_string(reg.parse_failed) + " failed, " +
std::to_string(reg.crc_collisions) + " collisions) [" + bytype + "]");
// areadata 的 CRC 覆盖率
long hit = 0, total = 0;
std::set<uint32_t> missing;
for (auto& [crc, cnt] : crc_hist) {
total++;
if (reg.find(crc)) hit++;
else missing.insert(crc);
}
line("AreaData CRC coverage", missing.empty(),
std::to_string(hit) + "/" + std::to_string(total) + " distinct CRCs resolve" +
(missing.empty() ? "" : " (missing e.g. " + std::to_string(*missing.begin()) + ")"));
} else {
line("Property registry", false, err);
}
// AssetResolver
AssetResolver res;
if (res.build(assets, AssetResolver::default_priority(), &err)) {
line("AssetResolver", true,
std::to_string(res.files_indexed) + " files, " +
std::to_string(res.by_ymir.size()) + " ymir keys, " +
std::to_string(res.ymir_conflicts) + " conflicts");
// TextureSet 图层贴图解析率
TextureSet ts2;
std::string rel = s.texture_set, e3;
for (auto& c : rel) c = (c == '\\') ? '/' : (char)std::tolower((unsigned char)c);
if (parse_texture_set_file(assets + "/textureset/" + rel, ts2, &e3)) {
int miss = 0;
for (auto& L : ts2.layers)
if (res.resolve(L.texture, nullptr).empty()) ++miss;
line("TextureSet -> assets", miss == 0,
std::to_string(ts2.layers.size() - miss) + "/" +
std::to_string(ts2.layers.size()) + " layer textures resolve");
}
// Property 引用的模型文件解析率(buildingfile / treefile / effectfile
int pref = 0, pmiss = 0;
for (auto& [crc, p] : reg.by_crc) {
for (const char* k : {"buildingfile", "treefile", "effectfile", "modelfile"}) {
std::string v = p.get(k);
if (v.empty()) continue;
++pref;
if (res.resolve(v, nullptr).empty()) ++pmiss;
}
}
line("Property models -> assets", true,
std::to_string(pref - pmiss) + "/" + std::to_string(pref) +
" resolve (misses incl. .spt trees / missing packs)");
} else {
line("AssetResolver", false, err);
}
std::printf("\n== summary: %d ok, %d err ==\n", g_ok, g_bad);
return g_bad ? 1 : 0;
}
+180
View File
@@ -0,0 +1,180 @@
// oracle_diff —— libgr2 vs oracle 逐字段对拍(M2 门禁·主)。见 oracle/FORMAT.md、docs/steps/00-oracle.md
//
// oracle_diff <model.gr2> [<anim.gr2>|-] <oracle_dump.bin> [--t <sec>] [--mesh N]
//
// oracle_dump.bin = `oracle dump` 产出(层① 世界矩阵 + mesh N 蒙皮顶点)。
// 本工具用 libgr2 对同一 (model, anim, t) 算一遍,报 max|Δ|(矩阵)+ max‖Δ‖(顶点)。
#include "gr2/gr2.h"
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
namespace {
struct Reader {
const uint8_t* p;
const uint8_t* end;
bool ok = true;
uint32_t u32() {
if (p + 4 > end) { ok = false; return 0; }
uint32_t v; std::memcpy(&v, p, 4); p += 4; return v;
}
float f32() {
if (p + 4 > end) { ok = false; return 0; }
float v; std::memcpy(&v, p, 4); p += 4; return v;
}
std::string str() {
uint32_t n = u32();
if (!ok || p + n > end) { ok = false; return {}; }
std::string s((const char*)p, n); p += n; return s;
}
void skip(size_t n) { if (p + n > end) ok = false; else p += n; }
};
// 行主序 4x4 A*BColumnMatrixMultiply4x3 语义,平移 elem 12..14
void mul4x3(const float* A, const float* B, float* R) {
for (int i = 0; i < 3; ++i) {
for (int k = 0; k < 3; ++k)
R[i*4+k] = A[i*4+0]*B[0*4+k] + A[i*4+1]*B[1*4+k] + A[i*4+2]*B[2*4+k];
R[i*4+3] = 0.0f;
}
for (int k = 0; k < 3; ++k)
R[12+k] = A[12]*B[0*4+k] + A[13]*B[1*4+k] + A[14]*B[2*4+k] + B[12+k];
R[15] = 1.0f;
}
} // namespace
int main(int argc, char** argv) {
if (argc < 4) {
std::fprintf(stderr,
"usage: oracle_diff <model.gr2> <anim.gr2|-> <dump.bin> [--t <sec>] [--mesh N]\n");
return 2;
}
const char* model_path = argv[1];
const char* anim_path = std::strcmp(argv[2], "-") == 0 ? nullptr : argv[2];
const char* bin_path = argv[3];
float t_override = -1.0f;
int mesh_index = 0;
for (int i = 4; i < argc; ++i) {
if (!std::strcmp(argv[i], "--t") && i + 1 < argc) t_override = float(std::atof(argv[++i]));
else if (!std::strcmp(argv[i], "--mesh") && i + 1 < argc) mesh_index = std::atoi(argv[++i]);
}
// ── 读 oracle dump ──────────────────────────────────────────────
FILE* f = std::fopen(bin_path, "rb");
if (!f) { std::fprintf(stderr, "cannot open %s\n", bin_path); return 1; }
std::fseek(f, 0, SEEK_END); long n = std::ftell(f); std::fseek(f, 0, SEEK_SET);
std::vector<uint8_t> buf(n > 0 ? size_t(n) : 0);
if (std::fread(buf.data(), 1, buf.size(), f) != buf.size()) { std::fclose(f); return 1; }
std::fclose(f);
Reader r{buf.data(), buf.data() + buf.size()};
if (std::memcmp(r.p, "XORC", 4) != 0) { std::fprintf(stderr, "bad magic\n"); return 1; }
r.skip(4);
uint32_t ver = r.u32(), kind = r.u32(), coord = r.u32();
char gv[16]; std::memcpy(gv, r.p, 16); r.skip(16);
std::string omodel = r.str(), oanim = r.str();
float ot = r.f32();
(void)ver; (void)coord;
std::printf("oracle dump: kind=%u granny=%.15s t=%.6f\n model=%s\n anim =%s\n",
kind, gv, ot, omodel.c_str(), oanim.c_str());
uint32_t obone_count = r.u32();
std::vector<float> omat(size_t(obone_count) * 16);
for (uint32_t b = 0; b < obone_count; ++b)
for (int k = 0; k < 16; ++k) omat[b*16+k] = r.f32();
uint32_t overt_count = r.u32();
std::vector<float> overt(size_t(overt_count) * 3);
for (uint32_t v = 0; v < overt_count; ++v)
for (int k = 0; k < 3; ++k) overt[v*3+k] = r.f32();
if (!r.ok) { std::fprintf(stderr, "dump truncated\n"); return 1; }
std::printf(" bones=%u skinned_verts=%u\n", obone_count, overt_count);
// ── libgr2 侧算一遍 ────────────────────────────────────────────
gr2::LoadError e;
auto model = gr2::File::load_path(model_path, &e);
if (!model) { std::fprintf(stderr, "libgr2 model load: %s\n", e.message.c_str()); return 1; }
if (model->file_info().skeletons.empty()) { std::fprintf(stderr, "no skeleton\n"); return 1; }
const gr2::Skeleton& sk = model->file_info().skeletons[0];
std::vector<gr2::Mat4> world, skin;
float t = t_override >= 0 ? t_override : ot;
if (anim_path) {
auto af = gr2::File::load_path(anim_path, &e);
if (!af || af->file_info().animations.empty()) {
std::fprintf(stderr, "libgr2 anim load: %s\n", e.message.c_str()); return 1;
}
gr2::sample_pose(sk, af->file_info().animations[0], t, world, skin);
} else {
gr2::bind_pose(sk, world, skin);
}
// ── 门禁 1:骨骼世界矩阵 ──────────────────────────────────────────
if (world.size() != obone_count) {
std::fprintf(stderr, "bone count mismatch: libgr2=%zu oracle=%u\n", world.size(), obone_count);
return 1;
}
double mat_max = 0.0; int mat_worst = -1;
for (uint32_t b = 0; b < obone_count; ++b) {
double d = 0.0;
for (int k = 0; k < 16; ++k) d = std::fmax(d, std::fabs((double)world[b][k] - omat[b*16+k]));
if (d > mat_max) { mat_max = d; mat_worst = int(b); }
}
std::printf("\n[gate 1] 骨骼世界矩阵 vs oracle 层①:\n");
std::printf(" max|Δ| = %.6g (worst bone %d '%s')\n", mat_max, mat_worst,
mat_worst >= 0 ? sk.bones[mat_worst].name.c_str() : "");
// ── 门禁 2:蒙皮顶点 ────────────────────────────────────────────
double vtx_max = 0.0;
bool vtx_checked = false;
if (overt_count) {
const auto& meshes = model->file_info().meshes;
if (mesh_index < (int)meshes.size()) {
const auto& me = meshes[mesh_index];
vtx_checked = true;
// libgr2 侧手算 LBS(与 engine/skinning.cpp 同公式,不引 engine 依赖)
int vtx_worst = -1;
uint32_t nv = std::min<uint32_t>(overt_count, (uint32_t)me.vertices.size());
for (uint32_t i = 0; i < nv; ++i) {
const auto& sv = me.vertices[i];
float w[4]; float sw = 0;
for (int k = 0; k < 4; ++k) { w[k] = sv.bone_weight[k] / 255.0f; sw += w[k]; }
if (sw > 1e-6f) for (int k = 0; k < 4; ++k) w[k] /= sw; else w[0] = 1.0f;
float M[16] = {0};
for (int k = 0; k < 4; ++k) {
int slot = sv.bone_index[k];
int bone = (slot >= 0 && slot < (int)me.bone_bindings.size())
? me.bone_bindings[slot] : -1;
const float* S = (bone >= 0 && bone < (int)skin.size())
? skin[bone].data() : nullptr;
if (!S) continue;
for (int j = 0; j < 16; ++j) M[j] += S[j] * w[k];
}
float o[3];
o[0] = sv.pos[0]*M[0] + sv.pos[1]*M[4] + sv.pos[2]*M[8] + M[12];
o[1] = sv.pos[0]*M[1] + sv.pos[1]*M[5] + sv.pos[2]*M[9] + M[13];
o[2] = sv.pos[0]*M[2] + sv.pos[1]*M[6] + sv.pos[2]*M[10] + M[14];
double dx = o[0]-overt[i*3+0], dy = o[1]-overt[i*3+1], dz = o[2]-overt[i*3+2];
double d = std::sqrt(dx*dx + dy*dy + dz*dz);
if (d > vtx_max) { vtx_max = d; vtx_worst = int(i); }
}
std::printf("\n[gate 2] mesh %d 蒙皮顶点 vs oracle:\n", mesh_index);
std::printf(" verts compared = %u\n", nv);
std::printf(" max‖Δ‖ = %.6g (worst vert %d)\n", vtx_max, vtx_worst);
}
}
(void)mul4x3;
// 阈值:noise_floor.json 就绪前用硬编码 1e-3(远高于实测的 ~1e-4,留浮点累积余量)
const double kMatEps = 1e-3, kVtxEps = 1e-3;
bool pass = mat_max < kMatEps && (!vtx_checked || vtx_max < kVtxEps);
std::printf("\n[verdict] granny=%.15s vs libgr2 : %s (mat %.2g < %.0e, vtx %.2g < %.0e)\n",
gv, pass ? "PASS" : "FAIL", mat_max, kMatEps,
vtx_checked ? vtx_max : 0.0, kVtxEps);
return pass ? 0 : 1;
}