![]() |
Jo Engine 2026.11.1
Jo Sega Saturn Engine
|
A model (.JOM) holds a mesh made with a 3D software: points, polygons, materials, textures, vertex colors, groups, and optionally a rigid skeleton with its animations. The engine loads it with jo_3d_load_model() (JO_COMPILE_WITH_3D_SUPPORT, see jo_engine/jo/model.h); the converter Compiler/COMMON/jo_model.py writes it from a Wavefront OBJ file (and its MTL materials). jo_model.py --info FILE.JOM prints the content of a file.
The data of a JOM file are in the memory format of the Saturn: the points and the polygons are copied as they are, nothing is converted when the file is loaded (jo_3d_create_model_in_place() even uses the content of the file in place).
| Type | Size | Description |
|---|---|---|
| u8, i8, u16, i16, u32, i32 | 1, 2, 4 | Integers (big endian) |
| fixed | 4 | Q16.16 fixed point number (jo_fixed) |
| char[16] | 16 | Name, ended by a zero (truncated to 15 characters) |
| angle | 2 | Angle (jo_angle): 0x10000 = 360 degrees, stored as i16 |
| rgb555 | 2 | Color of the VDP1: bit 15 set, 5 bits per channel (blue in the high bits) |
The converter transforms the OBJ (Y up, right handed) to the axes of the Saturn (X right, Y down, Z forward): a rotation of 180 degrees around X (x, -y, -z) and the vertex order of the polygons is reversed (the front faces are clockwise on the screen). --saturn-axes skips the conversion, --scale S multiplies the coordinates. The points, the normals and the pivots are fixed point numbers in the units of the model.
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | magic | char[4] | JOM1 |
| 4 | version | u16 | 1 (JO_3D_MODEL_VERSION) |
| 6 | reserved | u16 | 0 |
| 8 | center | fixed[3] | Center of the bounding sphere (x, y, z), see jo_3d_is_sphere_visible() |
| 20 | radius | fixed | Radius of the bounding sphere |
Size: 24 bytes. The chunks follow.
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | tag | char[4] | Identifier of the chunk (see below) |
| 4 | size | u32 | Size of the data in bytes (multiple of 4) |
| 8 | data | size bytes |
Most chunks start with a u32 count, then count records. A chunk of records of 2 bytes (QMAT, CNRM, GOUR) is padded with 2 bytes when its count is odd.
| Tag | Present | Content |
|---|---|---|
| TIMG | with --embed-textures (first chunk) | Embedded textures |
| PNTS | always | Points |
| QUAD | always | Polygons |
| QMAT | always | Material of each polygon |
| MATL | always | Materials |
| TEXT | when the model has textures | Names of the textures |
| NTAB | when all the faces of the OBJ have normals (vn) | Normals of the corners |
| CNRM | with NTAB | Normal of each corner of each polygon |
| VCOL | when the OBJ has vertex colors (v x y z r g b) | Vertex colors |
| GOUR | with VCOL, when a material has jo_gouraud | Gouraud colors of the corners |
| GRUP | when the OBJ has o or g | Groups |
| BONE | with --skeleton | Bones of the rigid skeleton |
| ANIM | with --skeleton | Animations of the skeleton |
| END | always (last chunk, size 0) | End of the file |
u32 count, then count textures. The textures are in the order of the TEXT chunk: the texture i of the model is the entry i. jo_3d_load_model() reads them one at a time and copies them to the VRAM (a texture already loaded by another model, with the same name, is skipped); they don't stay in the work RAM. Without this chunk, the textures are files on the CD named by TEXT (.BIN, or .TGA).
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | name | char[16] | Name of the texture: the start of the file name, a hash of the pixels and .BIN (WALL_1A2B3C.BIN), the same in TEXT. The hash makes the name unique for each texture of all the models: a texture shared by several models is loaded once |
| 16 | width | u16 | Width in pixels (multiple of 8) |
| 18 | height | u16 | Height in pixels |
| 20 | format | u16 | 0: RGB 555, 1: 16 colors with a lookup table (jo_lut 1 in the MTL) |
| 22 | reserved | u16 | 0 |
| 24 | pixels | See below, padded to 4 bytes |
u32 count, then count × (fixed x, fixed y, fixed z) (12 bytes per point), in the axes of the Saturn (Y down). The engine transforms each point once per frame: by default the converter merges the points at the same position (--no-weld keeps them), only between points used by the same groups.
u32 count, then count polygons of 20 bytes (jo_3d_polygon):
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | normal | fixed[3] | Normal of the polygon (x, y, z), unit length |
| 12 | v0, v1, v2, v3 | u16[4] | Indexes of the 4 corners A, B, C, D in PNTS. A triangle: v3 = v2 |
The VDP1 maps the whole texture on a quad (no texture coordinates): the corner with the top left of the texture becomes A, the texture is drawn from the edge A-B to the edge D-C. A triangle is a quad (A, B, C, C) with its shortest edge in A-B (--keep-triangles keeps the order of the OBJ). A face of 5 points or more is cut in quads by the converter.
u32 count (the number of polygons), then count × u16: the index of the material of each polygon in MATL (+ 2 bytes of padding if count is odd).
u32 count, then count materials of 32 bytes (jo_3d_material):
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | name | char[16] | MTL newmtl |
| 16 | r, g, b | u8[3] | Color (MTL Kd): the polygons without texture |
| 19 | shine | u8 | MTL Ns (0 to 255), for the program |
| 20 | texture | i16 | Index of the texture in TEXT, -1: no texture |
| 22 | subdivision_error | u16 | MTL jo_subdivision_error: distortion in pixels over which the polygons are drawn in parts (jo_3d_set_texture_subdivision_error()), 0: the default |
| 24 | flags | u32 | JO_3D_MATERIAL_xxx, see below |
| 28 | user | u32 | MTL jo_value, for the program |
Flags (jo_3d_material.flags): the bits 0 to 15 belong to the engine, the bits 16 to 31 to the program.
| Bit | Flag | MTL option | Description |
|---|---|---|---|
| 0 | JO_3D_MATERIAL_DUAL_PLANE | jo_dual 1 | Drawn on both sides |
| 1 | JO_3D_MATERIAL_SORT_MIN | jo_sort min | Painter's sort with the nearest point |
| 2 | JO_3D_MATERIAL_SORT_MAX | jo_sort max | Painter's sort with the farthest point |
| 1+2 | JO_3D_MATERIAL_SORT_PREVIOUS | jo_sort previous | Sorted with the depth of the polygon before it in the model (a decal drawn over the face before it). Neither bit: the center of the polygon (jo_sort center, the default) |
| 3 | JO_3D_MATERIAL_GOURAUD | jo_gouraud 1 | Gouraud shading with the vertex colors (GOUR chunk) |
| 4 | JO_3D_MATERIAL_HALF_TRANSPARENT | jo_half_transparent 1 (or d < 1) | Half transparent |
| 5 | JO_3D_MATERIAL_SCREEN_DOORS | jo_screen_doors 1 | Screen doors (mesh) |
| 6 | JO_3D_MATERIAL_LIGHT | jo_light 1 | Lit by the light of the engine (jo_3d_light()) |
| 7 | JO_3D_MATERIAL_FLIP_H | jo_flip h | Texture flipped horizontally |
| 8 | JO_3D_MATERIAL_FLIP_V | jo_flip v | Texture flipped vertically (jo_flip hv: both) |
| 9 | JO_3D_MATERIAL_END_CODES | jo_end_codes 1 | The texture stops each line after its last visible pixel (jo_sprite_add_end_codes()) |
| 10 | JO_3D_MATERIAL_MIPMAPS | jo_mipmaps 1 | Smaller copies of the texture far away (jo_3d_set_texture_mipmaps()) |
| 11 | JO_3D_MATERIAL_SUBDIVISION | jo_subdivision 1 | The polygons near the camera are drawn in parts (jo_3d_set_texture_subdivision()) |
| 12 | JO_3D_MATERIAL_SUBDIVISION_NEAR | jo_subdivision 2 | Only the polygons that cross the near plane are drawn in parts (jo_3d_set_texture_subdivision_near_only()) |
| 16 to 31 | JO_3D_MATERIAL_USER(N) | jo_user N [N...] | Flags of the program (N from 0 to 15) |
jo_lut 1 has no flag: it changes the format of the embedded texture (TIMG).
u32 count, then count × char[16]: the name of each texture (MTL map_Kd), a file name of the CD (8.3, uppercase, with the extension: WALL.BIN). With TIMG the pixels are in the file (the names are those of TIMG); otherwise the loader reads the file from the CD: a .BIN jo image (written by the converter from the PNG in --texture-dir: width multiple of 8, RGB 555, alpha becomes transparent) or a .TGA.
u32 count, then count × (fixed x, fixed y, fixed z): the normals of the OBJ (vn), for the lighting of the corners. Written only when every face of the OBJ has normals.
u32 count (4 × the number of polygons), then count × u16 (+ 2 bytes of padding if count is odd): the index in NTAB of the normal of the corners A, B, C, D of each polygon, in the order of QUAD.
u32 count (the number of points), then count × (fixed r, fixed g, fixed b): the color of each point of PNTS (OBJ v x y z r g b), 1.0 = 0x10000. When points are merged, the color of the first point is kept.
u32 count (4 × the number of polygons), then count × rgb555 (+ 2 bytes of padding if count is odd): the gouraud color of the corners A, B, C, D of each polygon, in the order of QUAD. A channel of 16 leaves the texture unchanged (the VDP1 adds channel - 16 to each pixel), 0 for the polygons of a material without JO_3D_MATERIAL_GOURAUD. jo_3d_load_model() gives one gouraud table to the polygons with the same colors.
u32 count, then count groups of 24 bytes (jo_3d_model_group). Each OBJ o or g is a group, for the animations of the program (and the bones): its polygons are together in the OBJ, and its points are the points used only by its polygons (a point shared with another group doesn't move with it).
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | name | char[16] | OBJ o or g |
| 16 | first_point | u16 | First point used only by the group |
| 18 | point_count | u16 | Number of points used only by the group, from first_point |
| 20 | first_polygon | u16 | First polygon of the group |
| 22 | polygon_count | u16 | Number of polygons of the group |
u32 count (at most JO_3D_MAX_BONES = 32), then count bones of 32 bytes (jo_3d_bone). A parent is always before its children. Written from the JSON file of --skeleton (see the usage of jo_model.py).
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | name | char[16] | Name of the bone |
| 16 | group | u16 | Index in GRUP of the group moved by the bone, JO_3D_BONE_NO_GROUP (0xFFFF): no polygons |
| 18 | parent | i16 | Index of the parent bone, -1: root |
| 20 | pivot | fixed[3] | Joint (model space): the bone turns around it |
u32 count, then count animations of 28 bytes (jo_3d_animation), then the keys of all the animations.
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | name | char[16] | Name of the animation (jo_3d_model_is_animation()) |
| 16 | key_count | u16 | Number of keys (at least 1) |
| 18 | key_ticks | u16 | Time between 2 keys in ticks (JO_3D_ANIMATION_TICKS_PER_SECOND = 60 per second) |
| 20 | flags | u16 | JO_3D_ANIMATION_LOOP (bit 0): the last key goes back to the first one |
| 22 | duplicate | u16 | 0 in the file (set by the loader: 1 when an animation before has the same name) |
| 24 | offset | u32 | Offset of the keys from the start of the data of the chunk (the count) |
A key (the keys of an animation follow each other):
| Offset | Field | Type | Description |
|---|---|---|---|
| 0 | root | fixed[3] | Offset of the whole model (a step, a fall) |
| 12 | angles | angle[bones][3] | For each bone of BONE, the angles around X, Y then Z of its parent |
| pad | i16 | 2 bytes when the number of bones is odd |
The engine interpolates between the keys (jo_3d_model_get_pose()) and draws the pose (jo_3d_model_draw_pose()).