## Converts raw M2Loader data into a Godot Node3D scene tree. ## Usage: ## var data = M2Loader.new().load_m2(abs_path) ## var node = M2Builder.build(data, "res://data/extracted") ## add_child(node) class_name M2Builder const WOW_M2_MATERIAL := preload("res://addons/mpq_extractor/loaders/wow_m2_material.gd") static var _texture_cache: Dictionary = {} static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: var root := Node3D.new() root.name = "M2" var verts: PackedVector3Array = data.get("vertices", PackedVector3Array()) var normals: PackedVector3Array = data.get("normals", PackedVector3Array()) var uvs: PackedVector2Array = data.get("uvs", PackedVector2Array()) var uvs2: PackedVector2Array = data.get("uvs2", PackedVector2Array()) var indices: PackedInt32Array = data.get("indices", PackedInt32Array()) var batches: Array = data.get("batches", []) if verts.is_empty() or indices.is_empty() or batches.is_empty(): return root var textures: PackedStringArray = data.get("textures", PackedStringArray()) var texture_types: PackedInt32Array = data.get("texture_types", PackedInt32Array()) var texture_flags: PackedInt32Array = data.get("texture_flags", PackedInt32Array()) var materials: Array = data.get("materials", []) var tex_combos: PackedInt32Array = data.get("texture_combos", PackedInt32Array()) var tex_coord_combos: PackedInt32Array = data.get("texture_coord_combos", PackedInt32Array()) var tex_weight_combos: PackedInt32Array = data.get("texture_weight_combos", PackedInt32Array()) var tex_transform_combos: PackedInt32Array = data.get("texture_transform_combos", PackedInt32Array()) var tex_combiner_combos: PackedInt32Array = data.get("texture_combiner_combos", PackedInt32Array()) var m2_colors: Array = data.get("m2_colors", []) var tex_weights: PackedFloat32Array = data.get("texture_weights", PackedFloat32Array()) var tex_transforms: Array = data.get("texture_transforms", []) var m2_flags: int = int(data.get("m2_flags", 0)) var model_path: String = str(data.get("model_path", "")) var mesh := ArrayMesh.new() for batch_variant in batches: if not (batch_variant is Dictionary): continue var batch: Dictionary = batch_variant var idx_start: int = int(batch.get("index_start", 0)) var idx_count: int = int(batch.get("index_count", 0)) var mat_id: int = int(batch.get("material_id", -1)) var tc_idx: int = int(batch.get("texture_combo_index", -1)) if idx_count <= 0 or idx_start + idx_count > indices.size(): continue var batch_indices := indices.slice(idx_start, idx_start + idx_count) if batch_indices.is_empty(): continue var arrays := [] arrays.resize(Mesh.ARRAY_MAX) arrays[Mesh.ARRAY_VERTEX] = verts if normals.size() == verts.size(): arrays[Mesh.ARRAY_NORMAL] = normals if uvs.size() == verts.size(): arrays[Mesh.ARRAY_TEX_UV] = uvs if uvs2.size() == verts.size(): arrays[Mesh.ARRAY_TEX_UV2] = uvs2 arrays[Mesh.ARRAY_INDEX] = batch_indices mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) var surf_idx := mesh.get_surface_count() - 1 var mat_def: Dictionary = materials[mat_id] if mat_id >= 0 and mat_id < materials.size() else {} mesh.surface_set_material( surf_idx, _build_material( mat_def, tc_idx, textures, texture_types, texture_flags, tex_combos, tex_coord_combos, tex_weight_combos, tex_transform_combos, tex_combiner_combos, m2_colors, tex_weights, tex_transforms, m2_flags, extracted_dir, model_path, batch)) if mesh.get_surface_count() == 0: return root var mi := MeshInstance3D.new() mi.name = "Mesh" mi.mesh = mesh root.add_child(mi) return root static func _build_material( mat_def: Dictionary, texture_combo_index: int, textures: PackedStringArray, texture_types: PackedInt32Array, texture_flags: PackedInt32Array, tex_combos: PackedInt32Array, tex_coord_combos: PackedInt32Array, tex_weight_combos: PackedInt32Array, tex_transform_combos: PackedInt32Array, tex_combiner_combos: PackedInt32Array, m2_colors: Array, tex_weights: PackedFloat32Array, tex_transforms: Array, m2_flags: int, extracted_dir: String, model_path: String, batch: Dictionary = {}) -> Material: var flags: int = int(mat_def.get("flags", 0)) var blend_mode: int = mat_def.get("blend_mode", 0) var tex_flags := 0 var tex: Texture2D = null var tex2: Texture2D = null var tex3: Texture2D = null var tex4: Texture2D = null var tex2_flags := 0 var tex3_flags := 0 var tex4_flags := 0 var resolved0 := _resolve_combo_texture(texture_combo_index, textures, texture_types, texture_flags, tex_combos, extracted_dir, model_path) tex = resolved0.get("texture", null) tex_flags = int(resolved0.get("flags", 0)) var texture_count := maxi(1, int(batch.get("texture_count", 1))) if texture_count > 1: var resolved1 := _resolve_combo_texture(texture_combo_index + 1, textures, texture_types, texture_flags, tex_combos, extracted_dir, model_path) tex2 = resolved1.get("texture", null) tex2_flags = int(resolved1.get("flags", 0)) if texture_count > 2: var resolved2 := _resolve_combo_texture(texture_combo_index + 2, textures, texture_types, texture_flags, tex_combos, extracted_dir, model_path) tex3 = resolved2.get("texture", null) tex3_flags = int(resolved2.get("flags", 0)) if texture_count > 3: var resolved3 := _resolve_combo_texture(texture_combo_index + 3, textures, texture_types, texture_flags, tex_combos, extracted_dir, model_path) tex4 = resolved3.get("texture", null) tex4_flags = int(resolved3.get("flags", 0)) var combiner := _resolve_combiner_state(blend_mode, texture_count, batch, tex_coord_combos, tex_combiner_combos, m2_flags) combiner["mesh_color"] = _resolve_mesh_color(batch, m2_colors, tex_weights, tex_weight_combos) combiner["tex_sample_alpha"] = _resolve_texture_sample_alpha(batch, tex_weights, tex_weight_combos) combiner["stage0_uv_transform"] = _resolve_texture_transform(batch, 0, tex_transform_combos, tex_transforms) combiner["stage1_uv_transform"] = _resolve_texture_transform(batch, 1, tex_transform_combos, tex_transforms) combiner["stage2_uv_transform"] = _resolve_texture_transform(batch, 2, tex_transform_combos, tex_transforms) combiner["stage3_uv_transform"] = _resolve_texture_transform(batch, 3, tex_transform_combos, tex_transforms) combiner["stage0_uv_scroll"] = _resolve_texture_scroll(batch, 0, tex_transform_combos, tex_transforms) combiner["stage1_uv_scroll"] = _resolve_texture_scroll(batch, 1, tex_transform_combos, tex_transforms) combiner["stage2_uv_scroll"] = _resolve_texture_scroll(batch, 2, tex_transform_combos, tex_transforms) combiner["stage3_uv_scroll"] = _resolve_texture_scroll(batch, 3, tex_transform_combos, tex_transforms) combiner["vertex_shader_id"] = _m2_vertex_shader_id(texture_count, int(batch.get("shader_id", 0))) combiner["pixel_shader_id"] = _m2_pixel_shader_id(texture_count, int(batch.get("shader_id", 0))) combiner["priority_plane"] = int(batch.get("priority_plane", 0)) if _is_soft_waterfall_material(model_path, blend_mode, combiner): combiner["effect_alpha_scale"] = 1.12 combiner["effect_alpha_power"] = 0.92 combiner["uv_edge_fade_strength"] = 0.22 var mat := WOW_M2_MATERIAL.build( tex, tex2, tex3, tex4, flags, blend_mode, tex_flags, tex2_flags, tex3_flags, tex4_flags, model_path.get_file().get_basename(), combiner) mat.set_meta("wow_flags", flags) mat.set_meta("wow_blend_mode", blend_mode) mat.set_meta("wow_m2_shader_id", int(batch.get("shader_id", 0))) mat.set_meta("wow_m2_vertex_shader_id", int(combiner.get("vertex_shader_id", 0))) mat.set_meta("wow_m2_pixel_shader_id", int(combiner.get("pixel_shader_id", 0))) mat.set_meta("wow_priority_plane", int(batch.get("priority_plane", 0))) return mat static func _is_soft_waterfall_material(model_path: String, blend_mode: int, combiner: Dictionary) -> bool: if blend_mode != 2: return false var lower_path := model_path.to_lower() if not lower_path.contains("waterfall"): return false var scroll0: Vector2 = combiner.get("stage0_uv_scroll", Vector2.ZERO) var scroll1: Vector2 = combiner.get("stage1_uv_scroll", Vector2.ZERO) return scroll0.length_squared() > 0.000001 or scroll1.length_squared() > 0.000001 static func _m2_vertex_shader_id(texture_count: int, shader_id: int) -> int: if (shader_id & 0x8000) != 0: return _m2_shader_array_vertex_id(shader_id & 0x7fff) if texture_count == 1: if (shader_id & 0x80) != 0: return 1 # Diffuse_Env if (shader_id & 0x4000) != 0: return 10 # Diffuse_T2 return 0 # Diffuse_T1 if (shader_id & 0x80) != 0: if (shader_id & 0x8) != 0: return 5 # Diffuse_Env_Env return 4 # Diffuse_Env_T1 if (shader_id & 0x8) != 0: return 3 # Diffuse_T1_Env if (shader_id & 0x4000) != 0: return 2 # Diffuse_T1_T2 return 7 # Diffuse_T1_T1 static func _m2_pixel_shader_id(texture_count: int, shader_id: int) -> int: if (shader_id & 0x8000) != 0: return _m2_shader_array_pixel_id(shader_id & 0x7fff) if texture_count == 1: return 1 if (shader_id & 0x70) != 0 else 0 if (shader_id & 0x70) != 0: match shader_id & 7: 3: return 8 # Combiners_Mod_Add 4: return 7 # Combiners_Mod_Mod2x 6: return 9 # Combiners_Mod_Mod2xNA 7: return 10 # Combiners_Mod_AddNA _: return 6 # Combiners_Mod_Mod match shader_id & 7: 0: return 5 # Combiners_Opaque_Opaque 3, 7: return 13 # Combiners_Opaque_AddAlpha 4: return 3 # Combiners_Opaque_Mod2x 6: return 4 # Combiners_Opaque_Mod2xNA _: return 2 # Combiners_Opaque_Mod static func _m2_shader_array_vertex_id(shader_array_index: int) -> int: match shader_array_index: 0: return 8 1: return 0 2: return 1 3: return 1 4: return 1 5: return 1 6: return 0 7: return 12 8: return 9 9: return 12 _: return 0 static func _m2_shader_array_pixel_id(shader_array_index: int) -> int: match shader_array_index: 0: return 35 1: return 0 2: return 7 3: return 1 4: return 6 5: return 4 6: return 0 7: return 7 8: return 1 9: return 6 _: return 0 static func _resolve_combo_texture( combo_index: int, textures: PackedStringArray, texture_types: PackedInt32Array, texture_flags: PackedInt32Array, tex_combos: PackedInt32Array, extracted_dir: String, model_path: String) -> Dictionary: if combo_index < 0 or combo_index >= tex_combos.size(): return {} var tex_idx: int = tex_combos[combo_index] if tex_idx < 0 or tex_idx >= textures.size(): return {} var tex_path: String = str(textures[tex_idx]).replace("\\", "/") var tex_type := int(texture_types[tex_idx]) if tex_idx < texture_types.size() else 0 var tex_flags := int(texture_flags[tex_idx]) if tex_idx < texture_flags.size() else 0 if tex_path.is_empty(): tex_path = _default_texture_path(model_path, tex_type) var tex: Texture2D = null if not tex_path.is_empty(): tex = _load_texture(tex_path, extracted_dir) return { "texture": tex, "flags": tex_flags, "path": tex_path, } static func _resolve_combiner_state( blend_mode: int, texture_count: int, batch: Dictionary, tex_coord_combos: PackedInt32Array, tex_combiner_combos: PackedInt32Array, m2_flags: int) -> Dictionary: var state := { "op0": 0 if blend_mode == 0 else 1, "op1": 1, "env0": false, "env1": false, "use_uv2_0": false, "use_uv2_1": false, } var shader_id := int(batch.get("shader_id", 0)) var coord_index := int(batch.get("texture_coord_combo_index", 0)) if (m2_flags & 0x8) != 0: for texture_index in mini(texture_count, 2): var op := 0 if texture_index == 0 and blend_mode == 0 else 1 var combiner_index := shader_id + texture_index if combiner_index >= 0 and combiner_index < tex_combiner_combos.size(): op = int(tex_combiner_combos[combiner_index]) state["op%d" % texture_index] = op _apply_texture_coord_state(state, texture_index, coord_index + texture_index, tex_coord_combos) else: _apply_texture_coord_state(state, 0, coord_index, tex_coord_combos) return state static func _apply_texture_coord_state(state: Dictionary, texture_index: int, combo_index: int, tex_coord_combos: PackedInt32Array) -> void: if combo_index < 0 or combo_index >= tex_coord_combos.size(): return var coord := int(tex_coord_combos[combo_index]) state["use_uv2_%d" % texture_index] = coord == 1 state["env%d" % texture_index] = coord > 2 static func _resolve_mesh_color( batch: Dictionary, m2_colors: Array, tex_weights: PackedFloat32Array, tex_weight_combos: PackedInt32Array) -> Color: var color := Color.WHITE var color_index := int(batch.get("color_index", -1)) if color_index >= 0 and color_index < m2_colors.size(): var color_entry: Dictionary = m2_colors[color_index] if m2_colors[color_index] is Dictionary else {} color = color_entry.get("color", Color.WHITE) var weight_combo_index := int(batch.get("texture_weight_combo_index", -1)) if weight_combo_index >= 0 and weight_combo_index < tex_weight_combos.size(): var weight_index := int(tex_weight_combos[weight_combo_index]) if weight_index >= 0 and weight_index < tex_weights.size(): color.a *= clampf(tex_weights[weight_index], 0.0, 1.0) return color static func _resolve_texture_sample_alpha( batch: Dictionary, tex_weights: PackedFloat32Array, tex_weight_combos: PackedInt32Array) -> Vector3: var result := Vector3.ONE var weight_combo_index := int(batch.get("texture_weight_combo_index", -1)) for stage in 3: var combo_index := weight_combo_index + stage if combo_index < 0 or combo_index >= tex_weight_combos.size(): continue var weight_index := int(tex_weight_combos[combo_index]) if weight_index >= 0 and weight_index < tex_weights.size(): result[stage] = clampf(tex_weights[weight_index], 0.0, 1.0) return result static func _resolve_texture_transform( batch: Dictionary, stage: int, tex_transform_combos: PackedInt32Array, tex_transforms: Array) -> Vector4: var combo_index := int(batch.get("texture_transform_combo_index", -1)) + stage if combo_index < 0 or combo_index >= tex_transform_combos.size(): return Vector4(1.0, 1.0, 0.0, 0.0) var transform_index := int(tex_transform_combos[combo_index]) if transform_index < 0 or transform_index >= tex_transforms.size(): return Vector4(1.0, 1.0, 0.0, 0.0) var transform: Dictionary = tex_transforms[transform_index] if tex_transforms[transform_index] is Dictionary else {} var scale: Vector2 = transform.get("scale", Vector2.ONE) var translation: Vector2 = transform.get("translation", Vector2.ZERO) return Vector4(scale.x, scale.y, translation.x, translation.y) static func _resolve_texture_scroll( batch: Dictionary, stage: int, tex_transform_combos: PackedInt32Array, tex_transforms: Array) -> Vector2: var combo_index := int(batch.get("texture_transform_combo_index", -1)) + stage if combo_index < 0 or combo_index >= tex_transform_combos.size(): return Vector2.ZERO var transform_index := int(tex_transform_combos[combo_index]) if transform_index < 0 or transform_index >= tex_transforms.size(): return Vector2.ZERO var transform: Dictionary = tex_transforms[transform_index] if tex_transforms[transform_index] is Dictionary else {} return transform.get("translation_speed", Vector2.ZERO) static func _default_texture_path(model_path: String, texture_type: int) -> String: var normalized := model_path.replace("\\", "/") var rel_model := _relative_extracted_model_path(normalized) var rel_lower := rel_model.to_lower() if rel_lower.begins_with("item/objectcomponents/weapon/") and texture_type == 2: var stem := rel_model.get_file().get_basename() if stem.to_lower() == "axe_1h_horde_a_01": return "Item/ObjectComponents/Weapon/Axe_1H_Horde_A_01Gray.blp" if not stem.is_empty(): return "Item/ObjectComponents/Weapon/%s.blp" % stem if rel_lower == "creature/wolf/wolf.m2" or rel_lower == "creature/wolf/wolf.mdx": if texture_type == 11: return "Creature/Wolf/WolfSkinCoyote.blp" if texture_type == 12: return "Creature/Wolf/WolfSkinCoyoteAlpha.blp" if rel_lower == "creature/boar/boar.m2" or rel_lower == "creature/boar/boar.mdx": if texture_type == 11: return "Creature/Boar/BoarSkinIvory.blp" if rel_lower == "creature/kobold/kobold.m2" or rel_lower == "creature/kobold/kobold.mdx": if texture_type == 11: return "Creature/Kobold/koboldskinAlbino.blp" if rel_lower == "creature/murloc/murloc.m2" or rel_lower == "creature/murloc/murloc.mdx": if texture_type == 11: return "Creature/Murloc/SahauginskinBlue.blp" return "" static func _relative_extracted_model_path(path: String) -> String: var marker := "/data/extracted/" var lower := path.to_lower() var marker_pos := lower.find(marker) if marker_pos >= 0: return path.substr(marker_pos + marker.length()) return path static func _load_texture(rel_path: String, extracted_dir: String) -> Texture2D: if rel_path.is_empty() or extracted_dir.is_empty(): return null var abs_path := ProjectSettings.globalize_path(extracted_dir.path_join(rel_path)) if _texture_cache.has(abs_path): return _texture_cache[abs_path] if not ClassDB.class_exists("BLPLoader"): return null var loader = ClassDB.instantiate("BLPLoader") if loader == null: return null var img: Image = loader.call("load_image", abs_path) if img == null or img.is_empty(): _texture_cache[abs_path] = null return null img.generate_mipmaps() var tex := ImageTexture.create_from_image(img) _texture_cache[abs_path] = tex return tex