first commit

This commit is contained in:
2026-04-20 20:25:10 +04:00
commit 1fe2a72ef1
1926 changed files with 156731 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
uid://cwsdumb6pp10m
+127
View File
@@ -0,0 +1,127 @@
## 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://extracted")
## add_child(node)
class_name M2Builder
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 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 materials: Array = data.get("materials", [])
var tex_combos: PackedInt32Array = data.get("texture_combos", PackedInt32Array())
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
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, tex_combos, extracted_dir))
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,
tex_combos: PackedInt32Array,
extracted_dir: String) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mat.roughness = 0.85
mat.metallic = 0.0
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.specular_mode = BaseMaterial3D.SPECULAR_DISABLED
mat.vertex_color_use_as_albedo = false
var blend_mode: int = mat_def.get("blend_mode", 0)
match blend_mode:
0: mat.transparency = BaseMaterial3D.TRANSPARENCY_DISABLED
1: mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
_: mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
if texture_combo_index >= 0 and texture_combo_index < tex_combos.size():
var tex_idx: int = tex_combos[texture_combo_index]
if tex_idx >= 0 and tex_idx < textures.size():
var tex_path: String = str(textures[tex_idx]).replace("\\", "/")
if not tex_path.is_empty():
var tex := _load_texture(tex_path, extracted_dir)
if tex:
mat.albedo_texture = tex
mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC
return mat
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
@@ -0,0 +1 @@
uid://bsr7e0tp543di
+164
View File
@@ -0,0 +1,164 @@
## Converts raw WMOLoader data into a Godot Node3D scene tree.
## Usage:
## var data = WMOLoader.new().load_wmo(abs_path)
## var node = WMOBuilder.build(data, "res://extracted")
## add_child(node)
class_name WMOBuilder
static var _texture_cache: Dictionary = {}
# Returns a Node3D containing one MeshInstance3D per WMO group.
static func build(data: Dictionary, extracted_dir: String = "") -> Node3D:
var root := Node3D.new()
root.name = "WMO"
if not data.has("groups"):
return root
var textures: PackedStringArray = data.get("textures", PackedStringArray())
var materials: Array = data.get("materials", [])
# Build Godot materials from WMO material definitions
var godot_materials: Array[StandardMaterial3D] = []
for mat_def in materials:
godot_materials.append(_build_material(mat_def, textures, extracted_dir))
# Build one MeshInstance3D per group
var groups: Array = data.get("groups", [])
for gi in groups.size():
var g: Dictionary = groups[gi]
if g.is_empty():
continue
var mesh_inst := _build_group_mesh(g, godot_materials)
mesh_inst.name = "Group_%d" % gi
root.add_child(mesh_inst)
return root
static func _build_group_mesh(
g: Dictionary,
godot_mats: Array[StandardMaterial3D]) -> MeshInstance3D:
var verts: PackedVector3Array = g.get("vertices", PackedVector3Array())
var normals: PackedVector3Array = g.get("normals", PackedVector3Array())
var uvs: PackedVector2Array = g.get("uvs", PackedVector2Array())
var colors: PackedColorArray = g.get("colors", PackedColorArray())
var indices: PackedInt32Array = g.get("indices", PackedInt32Array())
var batches: Array = g.get("batches", [])
var mesh := ArrayMesh.new()
if verts.is_empty() or indices.is_empty():
var empty := MeshInstance3D.new()
empty.mesh = mesh
return empty
if batches.is_empty():
batches = [{
"index_start": 0,
"index_count": indices.size(),
"material_id": -1,
}]
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))
if idx_count <= 0 or idx_start >= indices.size():
continue
var batch_indices := PackedInt32Array()
for i in range(idx_start, mini(idx_start + idx_count, indices.size()), 3):
if i + 2 >= indices.size():
break
# Reverse winding for the current local basis conversion.
batch_indices.append(indices[i + 0])
batch_indices.append(indices[i + 2])
batch_indices.append(indices[i + 1])
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 colors.size() == verts.size():
arrays[Mesh.ARRAY_COLOR] = colors
arrays[Mesh.ARRAY_INDEX] = batch_indices
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
var surf_idx := mesh.get_surface_count() - 1
if mat_id >= 0 and mat_id < godot_mats.size():
mesh.surface_set_material(surf_idx, godot_mats[mat_id])
var mi := MeshInstance3D.new()
mi.mesh = mesh
return mi
static func _build_material(
mat_def: Dictionary,
textures: PackedStringArray,
extracted_dir: String = "") -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mat.roughness = 0.85
mat.metallic = 0.0
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.specular_mode = BaseMaterial3D.SPECULAR_DISABLED
mat.vertex_color_use_as_albedo = false
var blend_mode: int = mat_def.get("blend_mode", 0)
match blend_mode:
0: mat.transparency = BaseMaterial3D.TRANSPARENCY_DISABLED
1: mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
_: mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
# Texture is loaded later by the scene assembler (needs BLP→Image conversion)
# Store the path as metadata for the assembler to pick up
var tex0_id: int = mat_def.get("texture0", -1)
if tex0_id >= 0 and tex0_id < textures.size():
var tex_path: String = str(textures[tex0_id]).replace("\\", "/")
mat.set_meta("texture0_path", tex_path)
var tex := _load_texture(tex_path, extracted_dir)
if tex:
mat.albedo_texture = tex
mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC
mat.set_meta("wow_flags", mat_def.get("flags", 0))
mat.set_meta("wow_shader", mat_def.get("shader", 0))
return mat
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.replace("\\", "/")))
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
@@ -0,0 +1 @@
uid://b7qb5j8qms0b0
@@ -0,0 +1,11 @@
[configuration]
entry_symbol = "mpq_extractor_init"
compatibility_minimum = "4.6"
[libraries]
windows.x86_64.debug = "res://addons/mpq_extractor/bin/libmpq_extractor.windows.x86_64.dll"
windows.x86_64.release = "res://addons/mpq_extractor/bin/libmpq_extractor.windows.x86_64.dll"
linux.x86_64.debug = "res://addons/mpq_extractor/bin/libmpq_extractor.linux.x86_64.so"
linux.x86_64.release = "res://addons/mpq_extractor/bin/libmpq_extractor.linux.x86_64.so"
macos.debug = "res://addons/mpq_extractor/bin/libmpq_extractor.macos.universal.dylib"
macos.release = "res://addons/mpq_extractor/bin/libmpq_extractor.macos.universal.dylib"
@@ -0,0 +1 @@
uid://bd6x7bgktb61c
+6
View File
@@ -0,0 +1,6 @@
[plugin]
name="MPQ Extractor"
description="Extracts WoW 3.3.5a (build 12340) MPQ archives respecting Blizzard client priority."
author="OpenWC"
version="1.0.0"
script="plugin.gd"
+21
View File
@@ -0,0 +1,21 @@
@tool
extends EditorPlugin
var _dock: Control
func _enter_tree() -> void:
var script: Script = load("res://addons/mpq_extractor/ui/mpq_tool.gd")
if script == null:
push_error("MPQ Extractor: не удалось загрузить mpq_tool.gd")
return
_dock = VBoxContainer.new()
_dock.name = "MPQExtractorDock"
_dock.set_script(script)
add_control_to_bottom_panel(_dock, "MPQ Extractor")
make_bottom_panel_item_visible(_dock)
func _exit_tree() -> void:
if _dock:
remove_control_from_bottom_panel(_dock)
_dock.queue_free()
_dock = null
+1
View File
@@ -0,0 +1 @@
uid://dds11be30harj
+191
View File
@@ -0,0 +1,191 @@
@tool
extends VBoxContainer
# ─── State ────────────────────────────────────────────────────────────────────
var _mpq = null # MPQManager
const LOCALES := ["ruRU", "enUS", "enGB", "deDE", "frFR", "esES", "esMX",
"koKR", "zhCN", "zhTW", "ptBR", "ptPT"]
# ─── UI nodes (created in _ready) ─────────────────────────────────────────────
var _client_path_edit: LineEdit
var _locale_option: OptionButton
var _filter_edit: LineEdit
var _output_edit: LineEdit
var _open_btn: Button
var _extract_btn: Button
var _list_btn: Button
var _status_label: Label
var _file_list: ItemList
var _archive_list: ItemList
var _progress: ProgressBar
# ─── Build UI ─────────────────────────────────────────────────────────────────
func _ready() -> void:
size_flags_horizontal = Control.SIZE_EXPAND_FILL
add_theme_constant_override("separation", 6)
_add_title()
_add_separator()
_add_client_row()
_add_locale_row()
_open_btn = _add_button("Open Client Archives", _on_open_pressed)
_add_separator()
_add_label("Loaded archives (highest priority first):")
_archive_list = _add_item_list(100)
_add_separator()
_add_filter_row()
_add_output_row()
var btns := HBoxContainer.new(); add_child(btns)
_list_btn = _add_button_to("List Files", btns, _on_list_pressed)
_extract_btn = _add_button_to("Extract", btns, _on_extract_pressed)
_progress = ProgressBar.new(); _progress.visible = false; add_child(_progress)
_add_label("Files matching filter:")
_file_list = _add_item_list(0, true) # expand vertically
_status_label = _add_label("Ready.")
_extract_btn.disabled = true
_list_btn.disabled = true
# ─── UI helpers ───────────────────────────────────────────────────────────────
func _add_title() -> void:
var lbl := Label.new()
lbl.text = "MPQ Extractor (WoW 3.3.5a)"
lbl.add_theme_font_size_override("font_size", 14)
add_child(lbl)
func _add_separator() -> void:
add_child(HSeparator.new())
func _add_label(text: String) -> Label:
var lbl := Label.new(); lbl.text = text
add_child(lbl); return lbl
func _add_item_list(min_height: int, expand := false) -> ItemList:
var il := ItemList.new()
if min_height > 0:
il.custom_minimum_size = Vector2(0, min_height)
if expand:
il.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(il); return il
func _add_button(text: String, cb: Callable) -> Button:
var btn := Button.new(); btn.text = text
btn.pressed.connect(cb); add_child(btn); return btn
func _add_button_to(text: String, parent: Control, cb: Callable) -> Button:
var btn := Button.new()
btn.text = text
btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
btn.pressed.connect(cb); parent.add_child(btn); return btn
func _add_row(label_text: String) -> HBoxContainer:
var row := HBoxContainer.new(); add_child(row)
var lbl := Label.new()
lbl.text = label_text
lbl.custom_minimum_size = Vector2(90, 0)
row.add_child(lbl); return row
func _add_client_row() -> void:
var row := _add_row("Client path")
_client_path_edit = LineEdit.new()
_client_path_edit.text = "res://sources"
_client_path_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_client_path_edit.placeholder_text = "res://sources"
row.add_child(_client_path_edit)
func _add_locale_row() -> void:
var row := _add_row("Locale")
_locale_option = OptionButton.new()
_locale_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
for loc in LOCALES:
_locale_option.add_item(loc)
row.add_child(_locale_option)
func _add_filter_row() -> void:
var row := _add_row("Filter")
_filter_edit = LineEdit.new()
_filter_edit.text = "*"
_filter_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_filter_edit.placeholder_text = "DBFilesClient\\*.dbc"
row.add_child(_filter_edit)
func _add_output_row() -> void:
var row := _add_row("Output dir")
_output_edit = LineEdit.new()
_output_edit.text = "res://extracted"
_output_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(_output_edit)
# ─── Handlers ─────────────────────────────────────────────────────────────────
func _on_open_pressed() -> void:
if not ClassDB.class_exists("MPQManager"):
_set_status("ERROR: MPQManager не найден. Собери GDExtension (build.bat Release).")
return
if _mpq:
_mpq.call("close")
_mpq = ClassDB.instantiate("MPQManager")
var client_path: String = ProjectSettings.globalize_path(_client_path_edit.text)
var locale: String = LOCALES[_locale_option.selected]
_set_status("Opening archives…")
var count: int = _mpq.call("open_client", client_path, locale)
if count == 0:
_set_status("ERROR: no archives opened. Check client path / locale.")
_extract_btn.disabled = true
_list_btn.disabled = true
return
_archive_list.clear()
var info_list: Array = _mpq.call("get_archive_info")
for info in info_list:
var lbl_text := "[%d] %s (%d files)" % [
info["priority"],
(info["path"] as String).get_file(),
info["file_count"]
]
_archive_list.add_item(lbl_text)
_set_status("Opened %d archives. Locale: %s" % [count, locale])
_extract_btn.disabled = false
_list_btn.disabled = false
func _on_list_pressed() -> void:
if not _mpq: return
_file_list.clear()
_set_status("Listing files…")
var filter: String = _filter_edit.text if not _filter_edit.text.is_empty() else "*"
var files: PackedStringArray = _mpq.call("list_files", filter)
for f in files:
_file_list.add_item(f)
_set_status("Found %d files matching '%s'" % [files.size(), filter])
func _on_extract_pressed() -> void:
if not _mpq: return
var filter: String = _filter_edit.text if not _filter_edit.text.is_empty() else "*"
var output_dir: String = ProjectSettings.globalize_path(_output_edit.text)
_set_status("Extracting…")
_progress.visible = true
_progress.value = 0
var count: int = _mpq.call("extract_files", filter, output_dir)
_progress.value = 100
_progress.visible = false
_set_status("Extracted %d files → %s" % [count, output_dir])
# ─── Helpers ──────────────────────────────────────────────────────────────────
func _set_status(text: String) -> void:
_status_label.text = text
print("[MPQTool] ", text)
+1
View File
@@ -0,0 +1 @@
uid://b1s1aqegqtlg
+102
View File
@@ -0,0 +1,102 @@
[gd_scene load_steps=2 format=3 uid="uid://mpq_tool"]
[ext_resource type="Script" path="res://addons/mpq_extractor/ui/mpq_tool.gd" id="1_script"]
[node name="MPQTool" type="VBoxContainer"]
script = ExtResource("1_script")
custom_minimum_size = Vector2(280, 0)
[node name="Title" type="Label" parent="."]
text = "MPQ Extractor (WoW 3.3.5a)"
theme_override_font_sizes/font_size = 14
[node name="HSep1" type="HSeparator" parent="."]
[node name="ClientPathRow" type="HBoxContainer" parent="."]
[node name="ClientLabel" type="Label" parent="ClientPathRow"]
text = "Client path"
custom_minimum_size = Vector2(80, 0)
[node name="ClientPathEdit" type="LineEdit" parent="ClientPathRow"]
unique_name_in_owner = true
size_flags_horizontal = 3
placeholder_text = "res://sources"
[node name="LocaleRow" type="HBoxContainer" parent="."]
[node name="LocaleLabel" type="Label" parent="LocaleRow"]
text = "Locale"
custom_minimum_size = Vector2(80, 0)
[node name="LocaleOption" type="OptionButton" parent="LocaleRow"]
unique_name_in_owner = true
size_flags_horizontal = 3
[node name="OpenBtn" type="Button" parent="."]
unique_name_in_owner = true
text = "Open Client Archives"
[node name="HSep2" type="HSeparator" parent="."]
[node name="ArchiveLabel" type="Label" parent="."]
text = "Loaded archives (highest priority first):"
[node name="ArchiveList" type="ItemList" parent="."]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 110)
[node name="HSep3" type="HSeparator" parent="."]
[node name="FilterRow" type="HBoxContainer" parent="."]
[node name="FilterLabel" type="Label" parent="FilterRow"]
text = "Filter"
custom_minimum_size = Vector2(80, 0)
[node name="FilterEdit" type="LineEdit" parent="FilterRow"]
unique_name_in_owner = true
size_flags_horizontal = 3
placeholder_text = "DBFilesClient\\*.dbc"
text = "*"
[node name="OutputRow" type="HBoxContainer" parent="."]
[node name="OutputLabel" type="Label" parent="OutputRow"]
text = "Output dir"
custom_minimum_size = Vector2(80, 0)
[node name="OutputEdit" type="LineEdit" parent="OutputRow"]
unique_name_in_owner = true
size_flags_horizontal = 3
placeholder_text = "res://extracted"
text = "res://extracted"
[node name="ActionRow" type="HBoxContainer" parent="."]
[node name="ListBtn" type="Button" parent="ActionRow"]
unique_name_in_owner = true
size_flags_horizontal = 3
text = "List Files"
[node name="ExtractBtn" type="Button" parent="ActionRow"]
unique_name_in_owner = true
size_flags_horizontal = 3
text = "Extract"
[node name="ProgressBar" type="ProgressBar" parent="."]
unique_name_in_owner = true
visible = false
[node name="FileListLabel" type="Label" parent="."]
text = "Files matching filter:"
[node name="FileList" type="ItemList" parent="."]
unique_name_in_owner = true
size_flags_vertical = 3
custom_minimum_size = Vector2(0, 200)
[node name="StatusLabel" type="Label" parent="."]
unique_name_in_owner = true
text = "Ready."
autowrap_mode = 3