I'm reverse engineering the model format from the Chinese MOBA 300 Heroes (300英雄), newer engine.
Most of it is cracked and I can already export a rigged FBX. I'm stuck on three things
and would appreciate any pointers — full details below so you don't have to take my word for anything.
Tested on two samples: 157_skin11.model (complex character with wings and ribbons)
and 334.model (simple one, used as a control).
0x00 magic 'EG3D'
0x08 u32 main block size
0x0C JSON metadata, length = main block size
then raw binary buffers, referenced by offsets from the JSON
generator: "ezgame", version: "2.0", leftHandCoord: true.
Note this is not the older jumpX (.x) format that the public jumpxfbx tool handles.
This is a newer engine used by the same game — I couldn't find any existing tooling for it.
JSON meta — array of 8 elements
index
contents
meta[0]
header (generator, version, leftHandCoord)
meta[1]
root node
meta[2]
materials
meta[3]
groups → submeshes
meta[4]
bone bind palettes (inverse-bind matrices)
meta[5]
skeleton nodes
meta[6]
misc
meta[7]
animation clips
Submesh
sm = [materialIndex, vertexCount, [attrs...], [idxOffset, idxCount]]
Attributes (attr[0] = type, attr[5] = data offset):
type
what
bytes/vert
encoding
0
position
6
u16×3, dequant bias + raw*scale
1
Normals
4
NOT CRACKED ← question 1
6
Uv
4
u16×2, dequant, V-flip (v = 1-v)
5
Skin weights
8
float16×4
4
Bone indices
4
uint8×4 (index into the submesh's bind palette)
2
Tangents
8
not examined
Buffers are packed back-to-back with no padding between them.
Example for 157_skin11, submesh 0 (vertexCount = 13485):
positions 2711852 + 13485*6 = 2792762 (+2 pad, +12 scale, +12 bias)
normals 2792788 + 13485*4 = 2846728
UV 2846728 + 13485*4 = 2900668 (+8 scale, +8 bias)
weights 2900684 + 13485*8 = 3008564
bones 3008564 + 13485*4 = 3062504
tangents 3062504 + 13485*8 = 3170384 ← exactly the start of the index buffer
The last attribute lands exactly on the index buffer offset, so the layout checks out byte for byte.
Position dequantisation
scale and bias (3 floats each) sit immediately after the u16 array, with variable padding.
Don't trust attr[6] / attr[7] for this (they mean something else) — instead scan the
64 bytes following the data and take the first triple where
1e-9 < scale < 1e-2 and |bias| < 5.
Index buffer
u16, first 6 values are a header (garbage, values >= vertexCount) — skip them
then plain triples = triangle LIST
I brute-forced this: it's definitely a list. Strip, fan, planar layout (three separate
streams) and 14 different skip offsets all give roughly half the manifoldness, and the
planar interpretation is absurd (surface area 2151 vs 11). The buffer is monotonic and
vertex-cache optimised — index spread within a triangle has median 3, p90 = 12.
Skeleton
From the inverse-bind matrices in meta[4]: 64 bytes per bone, row-major → transpose →
invert = world bind matrix. For sample 157 that's 113 bones, named
Bip001 Pelvis / Spine / L Thigh … — a standard 3ds Max Biped, plus artist-added bones
Bone01, Bone39..Bone59 driving ribbons and hanging ornaments.
Zero conflicts across the 4 palettes.
What already works
The attached script runs inside Blender and does the whole pipeline:
decode → cleanup → skeleton → skinning (4 bones/vertex) → FBX export.
The result opens fine in Autodesk FBX Review with the skeleton correctly inside the mesh.
Two format-specific gotchas worth sharing:
1. strip→list conversion garbage. ~411 triangles out of 19074 (2% by count but
22% by surface area) with an edge longer than 20% of the model diagonal. They're bound
to dozens of unrelated bones — not an object, just scattered bridges. My guess is the game
converted its older jumpX assets to EG3D and the converter baked strip stitches straight
into the triangle list. Fixed by an edge-length threshold — but measure the diagonal over
the WHOLE model, not per submesh (I lost a lot of time to that: a small submesh has a
small diagonal, so its normal triangles get flagged as giants).
2. Billboard materials. A material whose name ends in _c (here 157_skin11_c) is a
camera-facing card set — the engine rotates it toward the camera every frame, so in a static
viewer its geometry looks like chaos. My control model 334.model has no _c materials,
which is exactly why it read clean from the start.
Question 1: can't crack the normal packing
Attribute type 1, exactly 4 bytes per vertex. What I tried:
interpretation
result
int8 xyz / 127
|n| median 0.815, only 14% unit length
uint8 (x-128)/127
|n| median 1.151
10:10:10:2 signed
|n| median 0.950, 14% unit
11:11:10 signed
|n| median 0.973, 16% unit
octahedral int16×2
|n| = 1.000, but agreement with face normals ~48% (i.e. random)
Octahedral gives unit length but the directions don't correlate with geometric face normals,
so either it's the wrong packing or the wrong component order. Raw bytes of the first vertices:
[ 48 -127 81 -65]
[ -49 -105 52 -66]
[ 89 -14 -102 59]
[-110 5 92 -76]
[-110 5 92 -76]Question: has anyone seen this style of 4-byte normal packing? Could it be a QTangent
(a quaternion encoding normal + tangent + binormal in one go)? That would explain why reading
it directly as a normal never lines up. Note there's also a separate 8-byte tangent attribute,
which argues against QTangent — but I'd rather ask than guess.
Question 2: telling a real hole from a legitimate open edge
The mesh has open boundaries. Some are legitimate (ribbon edges, cape hem, clothing slits),
some look like genuine holes in the body (chest, shoulders, feet).
Three metrics I tried all turned out to be useless:
rendering without backface culling simply cannot show holes — the inside of the far surface
gets shaded like a normal surface, so the model always looks solid;
centroid fan-filling large boundary loops creates enormous sheets across the model;
a "closed empty region in the 2D projection" detector counts the space between ribbons
as holes (157 has lots of them → 9%, the simple 334 → 0.6%).
Meanwhile the triangle density is correct: after welding, 15318 triangles over 8036 vertices
= 1.91, where a closed manifold would be 2.0. So no triangles are missing and the decode is complete.
Question: is there a reliable automated way to distinguish "hole in the body" from
"legitimate open edge" on a game model full of alpha cards and ribbons? For now I've fallen
back on doing it by hand in Blender via Select → All by Trait → Non Manifold.
Question 3: animation transfer
meta[7] holds 17 clips: bat_idle, bat_run, bat_specialidle, bingdong, dance,
dead, relive, single_attack_attcom_1/2, single_chuansong, single_run,
single_skill_1..4, single_touzhi_1/2.
About 390 channels per clip across 139 nodes, so essentially the whole skeleton is animated.
The problem: the animations are expressed in the node coordinate frame (meta[5]), while
I build the skeleton from the mesh bind matrices. No single rigid transform maps one onto the
other — it looks like it needs a per-bone transfer.
Question: what's the correct general approach for relating a node hierarchy to a bind
skeleton in formats like this? I'm after the general method, not necessarily anything specific
to this engine.
Time sinks, in case they save someone else
Compute thresholds against the whole model diagonal, not per submesh.
Multi-object OBJ (o s0, o s1) breaks in some viewers — faces of the second object start
referencing vertices of the first. Write a single object, or de-index the OBJ.
Windows paths: "D:\157_skin11.model" without the r prefix — Python eats \157 as an
octal escape. Use forward slashes.
Attached
file
what
ezgame_to_fbx.py
Blender script: .model → rigged FBX
EG3D_WORK_REPORT-EN.md
full technical write-up, including every rejected hypothesis
157_FINAL.obj
sample decode output
Any thoughts welcome, especially on the normals and the animation transfer.
ezgame_to_fbx.py
157_FINAL.zip
EG3D_WORK_REPORT_EN.zip
By
XeSHTeG ·