## Converts raw ADTLoader data into a Godot Node3D terrain scene.
extends RefCounted
class_name ADTBuilder

const WOW_LIQUID_MATERIAL := preload("res://addons/mpq_extractor/loaders/wow_liquid_material.gd")

const TILE_SIZE := 533.33333
const CHUNK_SIZE := 33.33333
const UNIT_SIZE := CHUNK_SIZE / 8.0
const IGNORE_TERRAIN_HOLES := false
const USE_GEOMETRY_NORMALS := true

static var _terrain_shader: Shader
static var _control_splat_shader: Shader
static var _single_texture_shader: Shader
static var _empty_alpha_texture: Texture2D
static var _native_adt_baker: Object
static var _fallback_material: StandardMaterial3D
static var _single_texture_material_cache: Dictionary = {}
static var _baked_texture_material_cache: Dictionary = {}
static var _alpha_texture_cache: Dictionary = {}
static var _layered_material_cache: Dictionary = {}


static func _ensure_wow_shader_globals() -> void:
	pass

## Build a Node3D with 256 terrain chunk meshes.
## `extracted_dir` must be an absolute path to the extracted/ folder so BLPs can be loaded.
static func build(data: Dictionary, extracted_dir: String = "") -> Node3D:
	var root := Node3D.new()
	root.name = "ADT"
	var tile_origin := get_tile_origin(data)
	root.position = tile_origin

	var tex_names: PackedStringArray = data.get("textures", PackedStringArray())
	var tex_cache: Dictionary = {}

	for chunk in data.get("chunks", []) as Array:
		if chunk.is_empty():
			continue
		var mi := _build_chunk_mesh(chunk, tex_names, tex_cache, extracted_dir, 0, tile_origin)
		if mi:
			root.add_child(mi)

	var water_root := build_tile_water_scene(data, tile_origin)
	if water_root:
		root.add_child(water_root)
	return root


func build_scene(data: Dictionary, extracted_dir: String = "") -> Node3D:
	return build(data, extracted_dir)


func get_tile_origin_for_data(data: Dictionary) -> Vector3:
	return get_tile_origin(data)


func build_chunk_scene(
		chunk: Dictionary,
		tex_names: PackedStringArray,
		tex_cache: Dictionary,
		extracted_dir: String = "",
		lod: int = 0,
		origin_offset: Vector3 = Vector3.ZERO) -> MeshInstance3D:
	return _build_chunk_mesh(chunk, tex_names, tex_cache, extracted_dir, lod, origin_offset)


func build_chunk_render_payload(
		chunk: Dictionary,
		tex_names: PackedStringArray,
		tex_cache: Dictionary,
		extracted_dir: String = "",
		lod: int = 0,
		origin_offset: Vector3 = Vector3.ZERO) -> Dictionary:
	return build_chunk_payload(chunk, tex_names, tex_cache, extracted_dir, lod, origin_offset)


static func build_tile_water_scene(data: Dictionary, origin_offset: Vector3 = Vector3.ZERO) -> Node3D:
	return _build_tile_water_root(data, origin_offset)


static func build_tile_coarse_scene(
		data: Dictionary,
		tex_cache: Dictionary,
		extracted_dir: String = "",
		lod: int = 3) -> MeshInstance3D:
	return _build_tile_coarse_mesh(data, tex_cache, extracted_dir, lod)


static func build_chunk_payload(
		chunk: Dictionary,
		tex_names: PackedStringArray,
		tex_cache: Dictionary,
		extracted_dir: String = "",
		lod: int = 0,
		origin_offset: Vector3 = Vector3.ZERO) -> Dictionary:
	return _build_chunk_payload(chunk, tex_names, tex_cache, extracted_dir, lod, origin_offset)


static func build_tile_coarse_render_payload(
		data: Dictionary,
		tex_cache: Dictionary,
		extracted_dir: String = "",
		lod: int = 3) -> Dictionary:
	return _build_tile_coarse_payload(data, tex_cache, extracted_dir, lod)


static func build_baked_tile_render_payload(
		data: Dictionary,
		image_cache: Dictionary,
		extracted_dir: String = "",
		lod: int = 0,
		texture_size: int = 512) -> Dictionary:
	return _build_baked_tile_payload(data, image_cache, extracted_dir, lod, texture_size)


static func build_control_splat_tile_render_payload(
		data: Dictionary,
		image_cache: Dictionary,
		extracted_dir: String = "",
		lod: int = 0,
		texture_size: int = 256) -> Dictionary:
	return _build_control_splat_tile_payload(data, image_cache, extracted_dir, lod, texture_size)


static func apply_control_splat_material(
		mesh: Mesh,
		texture_images: Array,
		alpha_atlas: Texture2D,
		layer_index_map: Texture2D) -> Mesh:

	if mesh == null or texture_images.is_empty() or alpha_atlas == null or layer_index_map == null:
		return mesh
	var texture_array := _build_terrain_texture_array_from_images(texture_images)
	if texture_array == null:
		return mesh
	var array_mesh := mesh as ArrayMesh
	if array_mesh == null:
		return mesh
	array_mesh.surface_set_material(
		0,
		_build_control_splat_material(texture_array, alpha_atlas, layer_index_map))
	return array_mesh


static func build_baked_tile_render_payload_pair(
		data: Dictionary,
		image_cache: Dictionary,
		extracted_dir: String = "",
		full_lod: int = 0,
		full_texture_size: int = 2048,
		coarse_lod: int = 3,
		coarse_texture_size: int = 512) -> Dictionary:
	return _build_baked_tile_payload_pair(
		data,
		image_cache,
		extracted_dir,
		full_lod,
		full_texture_size,
		coarse_lod,
		coarse_texture_size)


static func upgrade_cached_baked_mesh_materials(
		mesh: Mesh,
		uv_scale: float = 1.0,
		mip_bias: float = -1.25,
		sharpen_strength: float = 0.35) -> Mesh:

	var array_mesh := mesh as ArrayMesh
	if array_mesh == null:
		return mesh

	for surface_idx in array_mesh.get_surface_count():
		var mat := array_mesh.surface_get_material(surface_idx)
		if not (mat is ShaderMaterial):
			continue
		var shader_mat := mat as ShaderMaterial
		var tex: Texture2D = shader_mat.get_shader_parameter("tex0")
		if tex == null:
			continue
		var upgraded := ShaderMaterial.new()
		upgraded.shader = _get_single_texture_shader()
		upgraded.set_shader_parameter("uv_scale", uv_scale)
		upgraded.set_shader_parameter("mip_bias", mip_bias)
		upgraded.set_shader_parameter("sharpen_strength", sharpen_strength)
		upgraded.set_shader_parameter("tex0", tex)
		array_mesh.surface_set_material(surface_idx, upgraded)

	return array_mesh


static func get_tile_origin(data: Dictionary) -> Vector3:
	var found := false
	var min_x := 0.0
	var min_z := 0.0

	for chunk in data.get("chunks", []) as Array:
		if chunk.is_empty():
			continue
		var origin: Vector3 = chunk.get("origin", Vector3.ZERO)
		if not found:
			min_x = origin.x
			min_z = origin.z
			found = true
		else:
			min_x = min(min_x, origin.x)
			min_z = min(min_z, origin.z)

	return Vector3(min_x, 0.0, min_z) if found else Vector3.ZERO


static func _outer(row: int, col: int) -> int:
	return row * 17 + col


static func _inner(row: int, col: int) -> int:
	return row * 17 + 9 + col


static func _build_chunk_mesh(
		chunk: Dictionary,
		tex_names: PackedStringArray,
		tex_cache: Dictionary,
		extracted_dir: String,
		lod: int = 0,
		origin_offset: Vector3 = Vector3.ZERO) -> MeshInstance3D:
	var payload := _build_chunk_payload(chunk, tex_names, tex_cache, extracted_dir, lod, origin_offset)
	return _build_mesh_instance_from_payload(payload)


static func _build_chunk_payload(
		chunk: Dictionary,
		tex_names: PackedStringArray,
		tex_cache: Dictionary,
		extracted_dir: String,
		lod: int = 0,
		origin_offset: Vector3 = Vector3.ZERO) -> Dictionary:

	var heights: PackedFloat32Array = chunk.get("heights", PackedFloat32Array())
	var origin: Vector3 = chunk.get("origin", Vector3.ZERO)
	var layers: Array = chunk.get("layers", [])
	var alpha_maps: Array = chunk.get("alpha_maps", [])
	var positions := _build_chunk_positions(heights)
	if positions.is_empty():
		return {}

	var geometry := _build_chunk_geometry(
		positions,
		chunk.get("normals", PackedVector3Array()),
		chunk.get("holes", 0),
		lod)
	var verts: PackedVector3Array = geometry["verts"]
	if verts.is_empty():
		return {}
	var nrms: PackedVector3Array = geometry["nrms"]
	var uvs_arr: PackedVector2Array = geometry["uvs"]
	var indices: PackedInt32Array = geometry["indices"]

	var arrays := []
	arrays.resize(Mesh.ARRAY_MAX)
	arrays[Mesh.ARRAY_VERTEX] = verts
	arrays[Mesh.ARRAY_NORMAL] = nrms
	arrays[Mesh.ARRAY_TEX_UV] = uvs_arr
	arrays[Mesh.ARRAY_INDEX] = indices

	var mesh := ArrayMesh.new()
	mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
	mesh.surface_set_material(
		0,
		_build_chunk_material(layers, alpha_maps, tex_names, tex_cache, extracted_dir, lod))

	return {
		"mesh": mesh,
		"position": origin - origin_offset,
		"name": "Chunk_%d_%d" % [chunk.get("index_x", 0), chunk.get("index_y", 0)],
	}


static func _build_chunk_positions(heights: PackedFloat32Array) -> PackedVector3Array:
	if heights.size() < 145:
		return PackedVector3Array()

	var positions := PackedVector3Array()
	positions.resize(145)
	for r in range(9):
		for c in range(9):
			var idx := _outer(r, c)
			positions[idx] = Vector3(c * UNIT_SIZE, heights[idx], r * UNIT_SIZE)
	for r in range(8):
		for c in range(8):
			var idx := _inner(r, c)
			positions[idx] = Vector3(
				c * UNIT_SIZE + UNIT_SIZE * 0.5,
				heights[idx],
				r * UNIT_SIZE + UNIT_SIZE * 0.5)
	return positions


static func _build_chunk_geometry(
		positions: PackedVector3Array,
		normals: PackedVector3Array,
		holes: int,
		lod: int) -> Dictionary:
	if IGNORE_TERRAIN_HOLES:
		holes = 0

	var verts := PackedVector3Array()
	var nrms := PackedVector3Array()
	var uvs_arr := PackedVector2Array()
	var indices := PackedInt32Array()

	if lod < 3:
		_append_full_chunk_geometry(positions, normals, holes, verts, nrms, uvs_arr, indices)
	else:
		var step: int = 1 << (lod - 1)   # LOD1→step=1, LOD2→step=2
		_append_outer_grid_geometry(positions, normals, holes, 8, verts, nrms, uvs_arr, indices)

	return {
		"verts": verts,
		"nrms": nrms,
		"uvs": uvs_arr,
		"indices": indices,
	}


static func _append_full_chunk_geometry(
		positions: PackedVector3Array,
		normals: PackedVector3Array,
		holes: int,
		verts: PackedVector3Array,
		nrms: PackedVector3Array,
		uvs_arr: PackedVector2Array,
		indices: PackedInt32Array) -> void:

	for iy in range(8):
		for ix in range(8):
			var hx := ix / 2
			var hy := iy / 2
			if holes & (1 << (hy * 4 + hx)):
				continue

			var tl := _outer(iy, ix)
			var tr := _outer(iy, ix + 1)
			var bl := _outer(iy + 1, ix)
			var br := _outer(iy + 1, ix + 1)
			var mid := _inner(iy, ix)

			for tri: Array in [[mid, tl, bl], [mid, bl, br], [mid, br, tr], [mid, tr, tl]]:
				_append_triangle(tri, positions, normals, verts, nrms, uvs_arr, indices)


static func _append_outer_grid_geometry(
		positions: PackedVector3Array,
		normals: PackedVector3Array,
		holes: int,
		step: int,
		verts: PackedVector3Array,
		nrms: PackedVector3Array,
		uvs_arr: PackedVector2Array,
		indices: PackedInt32Array) -> void:

	for iy in range(0, 8, step):
		for ix in range(0, 8, step):
			if _coarse_cell_has_hole(holes, ix, iy, step):
				continue

			var tl := _outer(iy, ix)
			var tr := _outer(iy, ix + step)
			var bl := _outer(iy + step, ix)
			var br := _outer(iy + step, ix + step)

			_append_triangle([tl, bl, br], positions, normals, verts, nrms, uvs_arr, indices)
			_append_triangle([tl, br, tr], positions, normals, verts, nrms, uvs_arr, indices)


static func _coarse_cell_has_hole(holes: int, start_x: int, start_y: int, step: int) -> bool:
	if step >= 8:
		return false

	for cell_y in range(start_y, start_y + step):
		for cell_x in range(start_x, start_x + step):
			var hx := cell_x / 2
			var hy := cell_y / 2
			if holes & (1 << (hy * 4 + hx)):
				return true
	return false


static func _append_triangle(
		tri: Array,
		positions: PackedVector3Array,
		normals: PackedVector3Array,
		verts: PackedVector3Array,
		nrms: PackedVector3Array,
		uvs_arr: PackedVector2Array,
		indices: PackedInt32Array) -> void:

	var base := verts.size()
	var p0: Vector3 = positions[int(tri[0])]
	var p1: Vector3 = positions[int(tri[1])]
	var p2: Vector3 = positions[int(tri[2])]
	var face_normal := (p1 - p0).cross(p2 - p0).normalized()
	if USE_GEOMETRY_NORMALS and face_normal.y < 0.0:
		face_normal = -face_normal
	for vi: int in tri:
		var p: Vector3 = positions[vi]
		verts.append(p)
		uvs_arr.append(Vector2(p.x / CHUNK_SIZE, p.z / CHUNK_SIZE))
		if USE_GEOMETRY_NORMALS:
			nrms.append(face_normal if face_normal.length_squared() > 0.0 else Vector3.UP)
		else:
			nrms.append(normals[vi] if vi < normals.size() else Vector3.UP)
	indices.append(base)
	indices.append(base + 1)
	indices.append(base + 2)


static func _build_tile_coarse_mesh(
		data: Dictionary,
		tex_cache: Dictionary,
		extracted_dir: String,
		lod: int) -> MeshInstance3D:
	var payload := _build_tile_coarse_payload(data, tex_cache, extracted_dir, lod)
	return _build_mesh_instance_from_payload(payload)


static func _build_tile_coarse_payload(
		data: Dictionary,
		tex_cache: Dictionary,
		extracted_dir: String,
		lod: int) -> Dictionary:

	var tile_origin := get_tile_origin(data)
	var tex_names: PackedStringArray = data.get("textures", PackedStringArray())
	var surfaces: Dictionary = {}

	for chunk in data.get("chunks", []) as Array:
		if chunk.is_empty():
			continue

		var heights: PackedFloat32Array = chunk.get("heights", PackedFloat32Array())
		var positions := _build_chunk_positions(heights)
		if positions.is_empty():
			continue

		var geometry := _build_chunk_geometry(
			positions,
			chunk.get("normals", PackedVector3Array()),
			chunk.get("holes", 0),
			lod)

		var verts: PackedVector3Array = geometry["verts"]
		if verts.is_empty():
			continue

		var material: Material = _build_chunk_material(
			chunk.get("layers", []),
			chunk.get("alpha_maps", []),
			tex_names,
			tex_cache,
			extracted_dir,
			lod)
		var mat_key := material.get_instance_id()
		if not surfaces.has(mat_key):
			surfaces[mat_key] = {
				"material": material,
				"verts": PackedVector3Array(),
				"nrms": PackedVector3Array(),
				"uvs": PackedVector2Array(),
				"indices": PackedInt32Array(),
			}

		var surface: Dictionary = surfaces[mat_key]
		var offset: Vector3 = chunk.get("origin", Vector3.ZERO) - tile_origin
		var dst_verts: PackedVector3Array = surface["verts"]
		var dst_nrms: PackedVector3Array = surface["nrms"]
		var dst_uvs: PackedVector2Array = surface["uvs"]
		var dst_indices: PackedInt32Array = surface["indices"]
		var base := dst_verts.size()

		for v: Vector3 in verts:
			dst_verts.append(v + offset)
		for n: Vector3 in geometry["nrms"]:
			dst_nrms.append(n)
		for uv: Vector2 in geometry["uvs"]:
			dst_uvs.append(uv)
		for idx: int in geometry["indices"]:
			dst_indices.append(base + idx)

		surface["verts"] = dst_verts
		surface["nrms"] = dst_nrms
		surface["uvs"] = dst_uvs
		surface["indices"] = dst_indices
		surfaces[mat_key] = surface

	if surfaces.is_empty():
		return {}

	var mesh := ArrayMesh.new()
	for surface in surfaces.values():
		var arrays := []
		arrays.resize(Mesh.ARRAY_MAX)
		arrays[Mesh.ARRAY_VERTEX] = surface["verts"]
		arrays[Mesh.ARRAY_NORMAL] = surface["nrms"]
		arrays[Mesh.ARRAY_TEX_UV] = surface["uvs"]
		arrays[Mesh.ARRAY_INDEX] = surface["indices"]
		mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
		mesh.surface_set_material(mesh.get_surface_count() - 1, surface["material"])

	return {
		"mesh": mesh,
		"position": Vector3.ZERO,
		"name": "TileLOD%d" % lod,
	}


static func _build_baked_tile_payload(
		data: Dictionary,
		image_cache: Dictionary,
		extracted_dir: String,
		lod: int,
		texture_size: int) -> Dictionary:

	var payload := _build_baked_tile_geometry_payload(data, lod)
	if payload.is_empty():
		return {}

	var baked_image := _build_tile_baked_albedo(data, image_cache, extracted_dir, texture_size)
	_apply_baked_albedo_material(payload.get("mesh", null), baked_image)
	return payload


static func _build_control_splat_tile_payload(
		data: Dictionary,
		image_cache: Dictionary,
		extracted_dir: String,
		lod: int,
		texture_size: int) -> Dictionary:

	var payload := _build_baked_tile_geometry_payload(data, lod)
	if payload.is_empty():
		return {}

	var tex_names: PackedStringArray = data.get("textures", PackedStringArray())
	var texture_images := _build_terrain_texture_images(tex_names, image_cache, extracted_dir, texture_size)
	var texture_array := _build_terrain_texture_array_from_images(texture_images)
	if texture_array == null:
		return payload

	payload = _build_control_splat_tile_geometry_payload(data, lod, tex_names.size())
	if payload.is_empty():
		return {}

	var alpha_atlas := _build_tile_alpha_atlas(data)
	var layer_index_map := _build_tile_layer_index_map(data, tex_names.size())
	var material := _build_control_splat_material(texture_array, alpha_atlas, layer_index_map)
	var mesh: Mesh = payload.get("mesh", null)
	if mesh != null:
		mesh.surface_set_material(0, material)

	payload["texture_array"] = texture_array
	payload["texture_images"] = texture_images
	payload["alpha_atlas"] = alpha_atlas
	payload["layer_index_map"] = layer_index_map
	payload["texture_names"] = tex_names
	return payload


static func _build_control_splat_tile_geometry_payload(data: Dictionary, lod: int, texture_count: int) -> Dictionary:
	var tile_origin := get_tile_origin(data)
	var verts := PackedVector3Array()
	var nrms := PackedVector3Array()
	var uvs_arr := PackedVector2Array()
	var colors := PackedColorArray()
	var indices := PackedInt32Array()
	var max_id := mini(maxi(texture_count - 1, 0), 255)

	for chunk in data.get("chunks", []) as Array:
		if chunk.is_empty():
			continue

		var heights: PackedFloat32Array = chunk.get("heights", PackedFloat32Array())
		var positions := _build_chunk_positions(heights)
		if positions.is_empty():
			continue

		var geometry := _build_chunk_geometry(
			positions,
			chunk.get("normals", PackedVector3Array()),
			chunk.get("holes", 0),
			lod)

		var local_verts: PackedVector3Array = geometry["verts"]
		if local_verts.is_empty():
			continue

		var layer_color := _get_chunk_layer_color(chunk, max_id)
		var offset: Vector3 = chunk.get("origin", Vector3.ZERO) - tile_origin
		var base := verts.size()

		for v: Vector3 in local_verts:
			var tv := v + offset
			verts.append(tv)
			uvs_arr.append(Vector2(tv.x / TILE_SIZE, tv.z / TILE_SIZE))
			colors.append(layer_color)
		for n: Vector3 in geometry["nrms"]:
			nrms.append(n)
		for idx: int in geometry["indices"]:
			indices.append(base + idx)

	if verts.is_empty():
		return {}

	var arrays := []
	arrays.resize(Mesh.ARRAY_MAX)
	arrays[Mesh.ARRAY_VERTEX] = verts
	arrays[Mesh.ARRAY_NORMAL] = nrms
	arrays[Mesh.ARRAY_TEX_UV] = uvs_arr
	arrays[Mesh.ARRAY_COLOR] = colors
	arrays[Mesh.ARRAY_INDEX] = indices

	var mesh := ArrayMesh.new()
	mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)

	return {
		"mesh": mesh,
		"position": Vector3.ZERO,
		"name": "ControlSplatTileLOD%d" % lod,
	}


static func _build_baked_tile_payload_pair(
		data: Dictionary,
		image_cache: Dictionary,
		extracted_dir: String,
		full_lod: int,
		full_texture_size: int,
		coarse_lod: int,
		coarse_texture_size: int) -> Dictionary:

	var full_payload := _build_baked_tile_geometry_payload(data, full_lod)
	if full_payload.is_empty():
		return {}

	var coarse_payload := _build_baked_tile_geometry_payload(data, coarse_lod)
	var full_image := _build_tile_baked_albedo(data, image_cache, extracted_dir, full_texture_size)
	var coarse_image: Image = null
	if full_image != null:
		coarse_image = full_image.duplicate()
		var coarse_size := _get_baked_albedo_size(coarse_texture_size)
		if coarse_image.get_width() != coarse_size or coarse_image.get_height() != coarse_size:
			coarse_image.resize(coarse_size, coarse_size, Image.INTERPOLATE_BILINEAR)

	_apply_baked_albedo_material(full_payload.get("mesh", null), full_image)
	if not coarse_payload.is_empty():
		_apply_baked_albedo_material(coarse_payload.get("mesh", null), coarse_image)

	return {
		"full": full_payload,
		"coarse": coarse_payload,
	}


static func _build_baked_tile_geometry_payload(data: Dictionary, lod: int) -> Dictionary:
	var tile_origin := get_tile_origin(data)
	var verts := PackedVector3Array()
	var nrms := PackedVector3Array()
	var uvs_arr := PackedVector2Array()
	var indices := PackedInt32Array()

	for chunk in data.get("chunks", []) as Array:
		if chunk.is_empty():
			continue

		var heights: PackedFloat32Array = chunk.get("heights", PackedFloat32Array())
		var positions := _build_chunk_positions(heights)
		if positions.is_empty():
			continue

		var geometry := _build_chunk_geometry(
			positions,
			chunk.get("normals", PackedVector3Array()),
			chunk.get("holes", 0),
			lod)

		var local_verts: PackedVector3Array = geometry["verts"]
		if local_verts.is_empty():
			continue

		var offset: Vector3 = chunk.get("origin", Vector3.ZERO) - tile_origin
		var base := verts.size()

		for v: Vector3 in local_verts:
			var tv := v + offset
			verts.append(tv)
			uvs_arr.append(Vector2(tv.x / TILE_SIZE, tv.z / TILE_SIZE))
		for n: Vector3 in geometry["nrms"]:
			nrms.append(n)
		for idx: int in geometry["indices"]:
			indices.append(base + idx)

	if verts.is_empty():
		return {}

	var arrays := []
	arrays.resize(Mesh.ARRAY_MAX)
	arrays[Mesh.ARRAY_VERTEX] = verts
	arrays[Mesh.ARRAY_NORMAL] = nrms
	arrays[Mesh.ARRAY_TEX_UV] = uvs_arr
	arrays[Mesh.ARRAY_INDEX] = indices

	var mesh := ArrayMesh.new()
	mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)

	return {
		"mesh": mesh,
		"position": Vector3.ZERO,
		"name": "TileLOD%d" % lod,
	}


static func _apply_baked_albedo_material(mesh: Mesh, baked_image: Image) -> void:
	if mesh == null:
		return
	if baked_image:
		baked_image.generate_mipmaps()
		var baked_texture := ImageTexture.create_from_image(baked_image)
		mesh.surface_set_material(0, _build_baked_texture_material(baked_texture))
	else:
		mesh.surface_set_material(0, _build_fallback_material())


static func _build_mesh_instance_from_payload(payload: Dictionary) -> MeshInstance3D:
	if payload.is_empty():
		return null

	var mi := MeshInstance3D.new()
	mi.mesh = payload.get("mesh", null)
	mi.position = payload.get("position", Vector3.ZERO)
	mi.name = payload.get("name", "TerrainMesh")
	return mi


static func _build_tile_water_root(data: Dictionary, origin_offset: Vector3) -> Node3D:
	var root := Node3D.new()
	root.name = "Water"
	var surfaces: Dictionary = {}

	for chunk in data.get("chunks", []) as Array:
		if chunk.is_empty():
			continue
		for liquid in chunk.get("liquids", []) as Array:
			if liquid.is_empty():
				continue
			var liquid_id := int(liquid.get("liquid_id", 0))
			if not surfaces.has(liquid_id):
				surfaces[liquid_id] = {
					"verts": PackedVector3Array(),
					"nrms": PackedVector3Array(),
					"uvs": PackedVector2Array(),
					"indices": PackedInt32Array(),
				}
			_append_liquid_geometry(chunk, liquid, origin_offset, surfaces[liquid_id])

	for liquid_id in surfaces.keys():
		var surface: Dictionary = surfaces[liquid_id]
		var verts: PackedVector3Array = surface["verts"]
		if verts.is_empty():
			continue

		var arrays := []
		arrays.resize(Mesh.ARRAY_MAX)
		arrays[Mesh.ARRAY_VERTEX] = verts
		arrays[Mesh.ARRAY_NORMAL] = surface["nrms"]
		arrays[Mesh.ARRAY_TEX_UV] = surface["uvs"]
		arrays[Mesh.ARRAY_INDEX] = surface["indices"]

		var mesh := ArrayMesh.new()
		mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
		mesh.surface_set_material(0, _build_liquid_material(int(liquid_id)))

		var mi := MeshInstance3D.new()
		mi.mesh = mesh
		mi.name = "Liquid_%d" % int(liquid_id)
		mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
		root.add_child(mi)

	if root.get_child_count() > 0:
		return root
	root.free()
	return null


static func _append_liquid_geometry(
		chunk: Dictionary,
		liquid: Dictionary,
		origin_offset: Vector3,
		surface: Dictionary) -> void:

	var liquid_mask: PackedByteArray = liquid.get("mask", PackedByteArray())
	var liquid_heights: PackedFloat32Array = liquid.get("heights", PackedFloat32Array())
	if liquid_mask.size() < 8 * 8 or liquid_heights.size() < 9 * 9:
		return

	var chunk_origin: Vector3 = chunk.get("origin", Vector3.ZERO)
	var tile_local_origin := chunk_origin - origin_offset
	var verts: PackedVector3Array = surface["verts"]
	var nrms: PackedVector3Array = surface["nrms"]
	var uvs_arr: PackedVector2Array = surface["uvs"]
	var indices: PackedInt32Array = surface["indices"]

	for z in range(8):
		for x in range(8):
			if liquid_mask[z * 8 + x] == 0:
				continue

			var base := verts.size()
			var x0 := tile_local_origin.x + x * UNIT_SIZE
			var x1 := tile_local_origin.x + (x + 1) * UNIT_SIZE
			var z0 := tile_local_origin.z + z * UNIT_SIZE
			var z1 := tile_local_origin.z + (z + 1) * UNIT_SIZE
			var y00 := liquid_heights[z * 9 + x] - origin_offset.y
			var y10 := liquid_heights[z * 9 + x + 1] - origin_offset.y
			var y01 := liquid_heights[(z + 1) * 9 + x] - origin_offset.y
			var y11 := liquid_heights[(z + 1) * 9 + x + 1] - origin_offset.y
			var u0 := x0 / TILE_SIZE
			var u1 := x1 / TILE_SIZE
			var v0 := z0 / TILE_SIZE
			var v1 := z1 / TILE_SIZE

			verts.append(Vector3(x0, y00, z0))
			verts.append(Vector3(x0, y01, z1))
			verts.append(Vector3(x1, y11, z1))
			verts.append(Vector3(x1, y10, z0))

			nrms.append(Vector3.UP)
			nrms.append(Vector3.UP)
			nrms.append(Vector3.UP)
			nrms.append(Vector3.UP)

			uvs_arr.append(Vector2(u0, v0))
			uvs_arr.append(Vector2(u0, v1))
			uvs_arr.append(Vector2(u1, v1))
			uvs_arr.append(Vector2(u1, v0))

			indices.append(base)
			indices.append(base + 1)
			indices.append(base + 2)
			indices.append(base)
			indices.append(base + 2)
			indices.append(base + 3)

	surface["verts"] = verts
	surface["nrms"] = nrms
	surface["uvs"] = uvs_arr
	surface["indices"] = indices


static func _build_liquid_mesh(
		chunk: Dictionary,
		liquid: Dictionary,
		origin_offset: Vector3) -> MeshInstance3D:

	var liquid_mask: PackedByteArray = liquid.get("mask", PackedByteArray())
	var liquid_heights: PackedFloat32Array = liquid.get("heights", PackedFloat32Array())
	if liquid_mask.size() < 8 * 8 or liquid_heights.size() < 9 * 9:
		return null

	var chunk_origin: Vector3 = chunk.get("origin", Vector3.ZERO)
	var verts := PackedVector3Array()
	var nrms := PackedVector3Array()
	var uvs_arr := PackedVector2Array()
	var indices := PackedInt32Array()

	for z in range(8):
		for x in range(8):
			if liquid_mask[z * 8 + x] == 0:
				continue

			var base := verts.size()
			var y00 := liquid_heights[z * 9 + x] - chunk_origin.y
			var y10 := liquid_heights[z * 9 + x + 1] - chunk_origin.y
			var y01 := liquid_heights[(z + 1) * 9 + x] - chunk_origin.y
			var y11 := liquid_heights[(z + 1) * 9 + x + 1] - chunk_origin.y

			verts.append(Vector3(x * UNIT_SIZE, y00, z * UNIT_SIZE))
			verts.append(Vector3(x * UNIT_SIZE, y01, (z + 1) * UNIT_SIZE))
			verts.append(Vector3((x + 1) * UNIT_SIZE, y11, (z + 1) * UNIT_SIZE))
			verts.append(Vector3((x + 1) * UNIT_SIZE, y10, z * UNIT_SIZE))

			nrms.append(Vector3.UP)
			nrms.append(Vector3.UP)
			nrms.append(Vector3.UP)
			nrms.append(Vector3.UP)

			uvs_arr.append(Vector2(float(x) / 8.0, float(z) / 8.0))
			uvs_arr.append(Vector2(float(x) / 8.0, float(z + 1) / 8.0))
			uvs_arr.append(Vector2(float(x + 1) / 8.0, float(z + 1) / 8.0))
			uvs_arr.append(Vector2(float(x + 1) / 8.0, float(z) / 8.0))

			indices.append(base)
			indices.append(base + 1)
			indices.append(base + 2)
			indices.append(base)
			indices.append(base + 2)
			indices.append(base + 3)

	if verts.is_empty():
		return null

	var arrays := []
	arrays.resize(Mesh.ARRAY_MAX)
	arrays[Mesh.ARRAY_VERTEX] = verts
	arrays[Mesh.ARRAY_NORMAL] = nrms
	arrays[Mesh.ARRAY_TEX_UV] = uvs_arr
	arrays[Mesh.ARRAY_INDEX] = indices

	var mesh := ArrayMesh.new()
	mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
	mesh.surface_set_material(0, _build_liquid_material(int(liquid.get("liquid_id", 0))))

	var mi := MeshInstance3D.new()
	mi.mesh = mesh
	mi.position = chunk_origin - origin_offset
	mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
	mi.name = "Liquid_%d_%d_%d" % [
		chunk.get("index_x", 0),
		chunk.get("index_y", 0),
		int(liquid.get("liquid_id", 0)),
	]
	return mi


static func _build_chunk_material(
		layers: Array,
		alpha_maps: Array,
		tex_names: PackedStringArray,
		tex_cache: Dictionary,
		extracted_dir: String,
		lod: int) -> Material:

	if lod <= 0:
		return _build_terrain_material(layers, alpha_maps, tex_names, tex_cache, extracted_dir)

	var base_tex := _get_layer_texture(0, layers, tex_names, tex_cache, extracted_dir)
	if base_tex:
		return _build_single_texture_material(base_tex)

	return _build_fallback_material()


static func _build_liquid_material(liquid_id: int) -> Material:
	return WOW_LIQUID_MATERIAL.build(liquid_id)


static func _build_terrain_material(
		layers: Array,
		alpha_maps: Array,
		tex_names: PackedStringArray,
		tex_cache: Dictionary,
		extracted_dir: String) -> Material:

	if layers.is_empty():
		return _build_fallback_material()

	var textures: Array = []
	for li in range(min(layers.size(), 4)):
		var layer: Dictionary = layers[li]
		var tex_id: int = layer.get("texture_id", -1)
		var tex: Texture2D = null
		if tex_id >= 0 and tex_id < tex_names.size():
			tex = _load_texture(tex_names[tex_id], tex_cache, extracted_dir)
		textures.append(tex)

	if textures.is_empty() or textures[0] == null:
		return _build_fallback_material()

	if textures.size() == 1:
		return _build_single_texture_material(textures[0])

	return _build_layered_material(textures, alpha_maps)


static func _get_layer_texture(
		layer_index: int,
		layers: Array,
		tex_names: PackedStringArray,
		tex_cache: Dictionary,
		extracted_dir: String) -> Texture2D:

	if layer_index < 0 or layer_index >= layers.size():
		return null

	var layer: Dictionary = layers[layer_index]
	var tex_id: int = layer.get("texture_id", -1)
	if tex_id < 0 or tex_id >= tex_names.size():
		return null

	return _load_texture(tex_names[tex_id], tex_cache, extracted_dir)


static func _build_fallback_material() -> StandardMaterial3D:
	if _fallback_material:
		return _fallback_material

	_fallback_material = StandardMaterial3D.new()
	_fallback_material.roughness = 0.9
	_fallback_material.albedo_color = Color(0.4, 0.55, 0.3)
	_fallback_material.cull_mode = BaseMaterial3D.CULL_DISABLED
	_fallback_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
	return _fallback_material


static func _build_baked_texture_material(tex: Texture2D) -> Material:
	if tex and _baked_texture_material_cache.has(tex.get_instance_id()):
		return _baked_texture_material_cache[tex.get_instance_id()]

	var mat := ShaderMaterial.new()
	mat.shader = _get_single_texture_shader()
	mat.set_shader_parameter("uv_scale", 1.0)
	mat.set_shader_parameter("mip_bias", -1.25)
	mat.set_shader_parameter("sharpen_strength", 0.35)
	mat.set_shader_parameter("tex0", tex)
	if tex:
		_baked_texture_material_cache[tex.get_instance_id()] = mat
	return mat


static func _build_single_texture_material(tex: Texture2D) -> Material:
	if tex and _single_texture_material_cache.has(tex.get_instance_id()):
		return _single_texture_material_cache[tex.get_instance_id()]

	var mat := ShaderMaterial.new()
	mat.shader = _get_single_texture_shader()
	mat.set_shader_parameter("uv_scale", 8.0)
	mat.set_shader_parameter("mip_bias", -0.75)
	mat.set_shader_parameter("sharpen_strength", 0.0)
	mat.set_shader_parameter("tex0", tex)
	if tex:
		_single_texture_material_cache[tex.get_instance_id()] = mat
	return mat


static func _build_layered_material(textures: Array, alpha_maps: Array) -> ShaderMaterial:
	var mat := ShaderMaterial.new()
	mat.shader = _get_terrain_shader()
	mat.set_shader_parameter("uv_scale", 8.0)
	mat.set_shader_parameter("mip_bias", -0.75)
	mat.set_shader_parameter("layer_count", textures.size())
	var base_tex: Texture2D = textures[0]

	for i in range(4):
		var tex: Texture2D = base_tex
		if i < textures.size() and textures[i] != null:
			tex = textures[i]
		mat.set_shader_parameter("tex%d" % i, tex)

	for i in range(3):
		var alpha_tex: Texture2D = _get_empty_alpha_texture()
		if i < alpha_maps.size():
			var built_alpha := _build_alpha_texture(alpha_maps[i])
			if built_alpha:
				alpha_tex = built_alpha
		mat.set_shader_parameter("alpha%d" % (i + 1), alpha_tex)

	return mat


static func _build_alpha_texture(alpha_bytes: PackedByteArray) -> Texture2D:
	if alpha_bytes.size() != 64 * 64:
		return null

	var image := Image.create_from_data(64, 64, false, Image.FORMAT_L8, alpha_bytes)
	if image == null:
		return null

	return ImageTexture.create_from_image(image)


static func _get_empty_alpha_texture() -> Texture2D:
	if _empty_alpha_texture:
		return _empty_alpha_texture

	var bytes := PackedByteArray()
	bytes.resize(64 * 64)
	_empty_alpha_texture = ImageTexture.create_from_image(
		Image.create_from_data(64, 64, false, Image.FORMAT_L8, bytes))
	return _empty_alpha_texture


static func _build_terrain_texture_images(
		tex_names: PackedStringArray,
		image_cache: Dictionary,
		extracted_dir: String,
		texture_size: int) -> Array[Image]:

	if tex_names.is_empty():
		var fallback := Image.create(maxi(16, texture_size), maxi(16, texture_size), false, Image.FORMAT_RGBA8)
		fallback.fill(Color(0.4, 0.55, 0.3, 1.0))
		fallback.generate_mipmaps()
		return [fallback]

	var target_size := maxi(16, texture_size)
	var images: Array[Image] = []
	for tex_name in tex_names:
		var source_img := _load_image(tex_name, image_cache, extracted_dir)
		var img: Image = null
		if source_img != null:
			img = source_img.duplicate()
		else:
			img = Image.create(target_size, target_size, false, Image.FORMAT_RGBA8)
			img.fill(Color(0.4, 0.55, 0.3, 1.0))

		if img.get_format() != Image.FORMAT_RGBA8:
			img.convert(Image.FORMAT_RGBA8)
		if img.get_width() != target_size or img.get_height() != target_size:
			img.resize(target_size, target_size, Image.INTERPOLATE_LANCZOS)
		img.generate_mipmaps()
		images.append(img)

	return images


static func _build_terrain_texture_array_from_images(images: Array) -> Texture2DArray:
	if images.is_empty():
		return null

	var fallback: Image = null
	for image_variant in images:
		var candidate := image_variant as Image
		if candidate != null and candidate.get_width() > 0 and candidate.get_height() > 0:
			fallback = candidate
			break
	if fallback == null:
		fallback = Image.create(16, 16, false, Image.FORMAT_RGBA8)
		fallback.fill(Color(0.4, 0.55, 0.3, 1.0))
		fallback.generate_mipmaps()

	var valid_images: Array[Image] = []
	for image_variant in images:
		var img := image_variant as Image
		if img != null and img.get_width() > 0 and img.get_height() > 0:
			valid_images.append(img)
			continue
		valid_images.append(fallback)

	if valid_images.is_empty():
		return null

	var texture_array := Texture2DArray.new()
	var err := texture_array.create_from_images(valid_images)
	if err != OK:
		return null
	return texture_array


static func _build_tile_alpha_atlas(data: Dictionary) -> Texture2D:
	var atlas_size := 16 * 64
	var image := Image.create(atlas_size, atlas_size, false, Image.FORMAT_RGBA8)
	image.fill(Color(0.0, 0.0, 0.0, 1.0))
	var tile_origin := get_tile_origin(data)

	for chunk in data.get("chunks", []) as Array:
		if chunk.is_empty():
			continue
		var chunk_coord := _get_chunk_grid_coord(chunk, tile_origin)
		var base_x := chunk_coord.x * 64
		var base_y := chunk_coord.y * 64
		var alpha_maps: Array = chunk.get("alpha_maps", [])
		for py in range(64):
			for px in range(64):
				var a0 := 0
				var a1 := 0
				var a2 := 0
				if alpha_maps.size() > 0:
					var bytes0: PackedByteArray = alpha_maps[0]
					if bytes0.size() == 64 * 64:
						a0 = bytes0[py * 64 + px]
				if alpha_maps.size() > 1:
					var bytes1: PackedByteArray = alpha_maps[1]
					if bytes1.size() == 64 * 64:
						a1 = bytes1[py * 64 + px]
				if alpha_maps.size() > 2:
					var bytes2: PackedByteArray = alpha_maps[2]
					if bytes2.size() == 64 * 64:
						a2 = bytes2[py * 64 + px]
				image.set_pixel(
					base_x + px,
					base_y + py,
					Color8(a0, a1, a2, 255))

	return ImageTexture.create_from_image(image)


static func _build_tile_layer_index_map(data: Dictionary, texture_count: int) -> Texture2D:
	var image := Image.create(16, 16, false, Image.FORMAT_RGBA8)
	image.fill(Color8(0, 0, 0, 255))
	var tile_origin := get_tile_origin(data)
	var max_id := mini(maxi(texture_count - 1, 0), 255)

	for chunk in data.get("chunks", []) as Array:
		if chunk.is_empty():
			continue
		var layers: Array = chunk.get("layers", [])
		if layers.is_empty():
			continue
		var ids := PackedByteArray([0, 0, 0, 0])
		for i in range(mini(4, layers.size())):
			var layer: Dictionary = layers[i]
			ids[i] = clampi(int(layer.get("texture_id", 0)), 0, max_id)

		var chunk_coord := _get_chunk_grid_coord(chunk, tile_origin)
		image.set_pixel(
			chunk_coord.x,
			chunk_coord.y,
			Color8(ids[0], ids[1], ids[2], ids[3]))

	return ImageTexture.create_from_image(image)


static func _get_chunk_layer_color(chunk: Dictionary, max_id: int) -> Color:
	var layers: Array = chunk.get("layers", [])
	if layers.is_empty():
		return Color(0.0, 0.0, 0.0, 0.0)

	var ids := PackedFloat32Array([0.0, 0.0, 0.0, 0.0])
	for i in range(mini(4, layers.size())):
		var layer: Dictionary = layers[i]
		ids[i] = float(clampi(int(layer.get("texture_id", 0)), 0, max_id)) / 255.0
	return Color(ids[0], ids[1], ids[2], ids[3])


static func _get_chunk_grid_coord(chunk: Dictionary, tile_origin: Vector3) -> Vector2i:
	var origin: Vector3 = chunk.get("origin", Vector3.ZERO)
	var local := origin - tile_origin
	return Vector2i(
		clampi(int(round(local.x / CHUNK_SIZE)), 0, 15),
		clampi(int(round(local.z / CHUNK_SIZE)), 0, 15))


static func _build_control_splat_material(
		texture_array: Texture2DArray,
		alpha_atlas: Texture2D,
		layer_index_map: Texture2D) -> ShaderMaterial:

	var mat := ShaderMaterial.new()
	mat.shader = _get_control_splat_shader()
	mat.set_shader_parameter("terrain_tex", texture_array)
	mat.set_shader_parameter("alpha_atlas", alpha_atlas)
	mat.set_shader_parameter("layer_index_map", layer_index_map)
	mat.set_shader_parameter("uv_scale", 8.0)
	mat.set_shader_parameter("mip_bias", -0.75)
	return mat


static func _get_control_splat_shader() -> Shader:
	if _control_splat_shader:
		return _control_splat_shader

	_ensure_wow_shader_globals()
	_control_splat_shader = Shader.new()
	_control_splat_shader.code = """
shader_type spatial;
render_mode unshaded, cull_disabled, depth_draw_opaque;

uniform sampler2DArray terrain_tex : source_color, filter_linear_mipmap_anisotropic, repeat_enable;
uniform sampler2D alpha_atlas : filter_linear, repeat_disable;
uniform sampler2D layer_index_map : filter_nearest, repeat_disable;

uniform float uv_scale = 8.0;
uniform float mip_bias = -0.75;
uniform vec3 light_dir = vec3(-0.35, 0.82, -0.45);
uniform float ambient = 0.62;
uniform float diffuse = 0.38;
uniform float hemi = 0.12;

global uniform vec4 wow_ambient_color;
global uniform vec4 wow_light_color;
global uniform vec3 wow_light_dir;
global uniform vec4 wow_fog_color;
global uniform vec2 wow_fog_range;
global uniform float wow_fog_density;

varying vec3 world_pos;
varying vec3 world_normal;
varying vec3 view_pos;

void vertex() {
	world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
	world_normal = normalize(MODEL_NORMAL_MATRIX * NORMAL);
	view_pos = (MODELVIEW_MATRIX * vec4(VERTEX, 1.0)).xyz;
}

vec3 sample_layer(float layer, vec2 continuous_chunk_uv) {
	return texture(terrain_tex, vec3(continuous_chunk_uv * uv_scale, layer), mip_bias).rgb;
}

vec3 apply_wow_fog(vec3 color) {
	float dist = length(view_pos);
	float range_fog = smoothstep(wow_fog_range.x, wow_fog_range.y, dist);
	float fog_amount = range_fog * clamp(wow_fog_density, 0.0, 0.55);
	return mix(color, wow_fog_color.rgb, fog_amount);
}

void fragment() {
	vec2 tile_uv = clamp(UV, vec2(0.0), vec2(0.999999));
	vec2 chunk_uv = tile_uv * 16.0;
	vec2 chunk_f = floor(chunk_uv);
	vec2 local_uv = chunk_uv - chunk_f;

	vec4 packed_ids = COLOR;
	vec4 layers = floor(packed_ids * 255.0 + vec4(0.5));
	vec2 alpha_px = clamp(local_uv * 64.0, vec2(0.5), vec2(63.5));
	vec3 alpha = texture(alpha_atlas, (chunk_f * 64.0 + alpha_px) / 1024.0).rgb;

	float w1 = alpha.r;
	float w2 = alpha.g;
	float w3 = alpha.b;
	float w0 = max(0.0, 1.0 - w1 - w2 - w3);
	float sum = max(w0 + w1 + w2 + w3, 0.0001);
	w0 /= sum;
	w1 /= sum;
	w2 /= sum;
	w3 /= sum;

	vec3 albedo =
		sample_layer(layers.x, chunk_uv) * w0 +
		sample_layer(layers.y, chunk_uv) * w1 +
		sample_layer(layers.z, chunk_uv) * w2 +
		sample_layer(layers.w, chunk_uv) * w3;

	vec3 n = normalize(world_normal);
	if (!FRONT_FACING) {
		n = -n;
	}
	vec3 l = normalize(wow_light_dir);
	float ndl = max(dot(n, l), 0.0);
	float hemi_term = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
	vec3 ambient_tint = mix(vec3(0.74), max(wow_ambient_color.rgb, vec3(0.08)), 0.22);
	vec3 light_tint = mix(vec3(1.0), wow_light_color.rgb, 0.12);
	vec3 light = ambient_tint * ambient + light_tint * diffuse * ndl + vec3(hemi * hemi_term);
	light = clamp(light, vec3(0.48), vec3(1.08));
	ALBEDO = apply_wow_fog(albedo * light);
}
"""
	return _control_splat_shader


static func _build_tile_baked_albedo(
		data: Dictionary,
		image_cache: Dictionary,
		extracted_dir: String,
		texture_size: int) -> Image:

	var native_image := _build_tile_baked_albedo_native(
		data,
		image_cache,
		extracted_dir,
		texture_size)
	if native_image != null:
		return native_image

	var chunk_pixels := int(_get_baked_albedo_size(texture_size) / 16)
	var size := chunk_pixels * 16
	var image := Image.create(size, size, false, Image.FORMAT_RGB8)
	image.fill(Color(0.4, 0.55, 0.3))

	var tex_names: PackedStringArray = data.get("textures", PackedStringArray())
	for chunk in data.get("chunks", []) as Array:
		if chunk.is_empty():
			continue

		var chunk_x: int = chunk.get("index_x", -1)
		var chunk_y: int = chunk.get("index_y", -1)
		if chunk_x < 0 or chunk_x >= 16 or chunk_y < 0 or chunk_y >= 16:
			continue

		var base_x := chunk_x * chunk_pixels
		var base_y := chunk_y * chunk_pixels
		for py in range(chunk_pixels):
			var local_v := (float(py) + 0.5) / float(chunk_pixels)
			for px in range(chunk_pixels):
				var local_u := (float(px) + 0.5) / float(chunk_pixels)
				var color := _sample_chunk_baked_albedo(
					chunk,
					tex_names,
					image_cache,
					extracted_dir,
					local_u,
					local_v)
				image.set_pixel(base_x + px, base_y + py, color)

	return image


static func _build_tile_baked_albedo_native(
		data: Dictionary,
		image_cache: Dictionary,
		extracted_dir: String,
		texture_size: int) -> Image:

	if not ClassDB.class_exists("ADTBaker"):
		return null
	if _native_adt_baker == null:
		_native_adt_baker = ClassDB.instantiate("ADTBaker")
	if _native_adt_baker == null:
		return null
	return _native_adt_baker.call(
		"bake_tile_albedo",
		data,
		image_cache,
		extracted_dir,
		texture_size) as Image


static func _get_baked_albedo_size(texture_size: int) -> int:
	return maxi(8, int(ceil(float(maxi(texture_size, 16)) / 16.0))) * 16


static func _sample_chunk_baked_albedo(
		chunk: Dictionary,
		tex_names: PackedStringArray,
		image_cache: Dictionary,
		extracted_dir: String,
		local_u: float,
		local_v: float) -> Color:

	var layers: Array = chunk.get("layers", [])
	if layers.is_empty():
		return Color(0.4, 0.55, 0.3)

	var textures: Array = []
	for li in range(min(layers.size(), 4)):
		var layer: Dictionary = layers[li]
		var tex_id: int = layer.get("texture_id", -1)
		var img: Image = null
		if tex_id >= 0 and tex_id < tex_names.size():
			img = _load_image(tex_names[tex_id], image_cache, extracted_dir)
		textures.append(img)

	if textures.is_empty() or textures[0] == null:
		return Color(0.4, 0.55, 0.3)

	var alpha_maps: Array = chunk.get("alpha_maps", [])
	var weights := PackedFloat32Array([1.0, 0.0, 0.0, 0.0])
	for li in range(1, min(layers.size(), 4)):
		var alpha: PackedByteArray = alpha_maps[li - 1] if li - 1 < alpha_maps.size() else PackedByteArray()
		if alpha.size() == 64 * 64:
			var ax := clampi(int(floor(local_u * 63.999)), 0, 63)
			var ay := clampi(int(floor(local_v * 63.999)), 0, 63)
			weights[li] = float(alpha[ay * 64 + ax]) / 255.0

	weights[0] = maxf(0.0, 1.0 - weights[1] - weights[2] - weights[3])
	var sum: float = weights[0] + weights[1] + weights[2] + weights[3]
	if sum > 0.0:
		for i in range(4):
			weights[i] /= sum

	var tiled_u := local_u * 8.0
	var tiled_v := local_v * 8.0
	var out := Color(0, 0, 0, 1)
	for i in range(min(textures.size(), 4)):
		var img: Image = textures[i]
		if img == null:
			continue
		out += _sample_image_repeat(img, tiled_u, tiled_v) * weights[i]

	return Color(out.r, out.g, out.b, 1.0)


static func _sample_image_repeat(image: Image, u: float, v: float) -> Color:
	var width: int = image.get_width()
	var height: int = image.get_height()
	if width <= 0 or height <= 0:
		return Color(1, 1, 1, 1)

	var fu: float = wrapf(u, 0.0, 1.0)
	var fv: float = wrapf(v, 0.0, 1.0)
	var px: float = fu * float(width) - 0.5
	var py: float = fv * float(height) - 0.5
	var x0: int = posmod(int(floor(px)), width)
	var y0: int = posmod(int(floor(py)), height)
	var x1: int = posmod(x0 + 1, width)
	var y1: int = posmod(y0 + 1, height)
	var tx: float = px - floor(px)
	var ty: float = py - floor(py)
	var c00: Color = image.get_pixel(x0, y0)
	var c10: Color = image.get_pixel(x1, y0)
	var c01: Color = image.get_pixel(x0, y1)
	var c11: Color = image.get_pixel(x1, y1)
	var top: Color = c00.lerp(c10, tx)
	var bottom: Color = c01.lerp(c11, tx)
	return top.lerp(bottom, ty)


static func _get_terrain_shader() -> Shader:
	if _terrain_shader:
		return _terrain_shader

	_ensure_wow_shader_globals()
	_terrain_shader = Shader.new()
	_terrain_shader.code = """
shader_type spatial;
render_mode unshaded, cull_disabled, depth_draw_opaque;

	uniform sampler2D tex0 : source_color, filter_linear_mipmap_anisotropic, repeat_enable;
	uniform sampler2D tex1 : source_color, filter_linear_mipmap_anisotropic, repeat_enable;
	uniform sampler2D tex2 : source_color, filter_linear_mipmap_anisotropic, repeat_enable;
	uniform sampler2D tex3 : source_color, filter_linear_mipmap_anisotropic, repeat_enable;

	uniform sampler2D alpha1 : filter_linear, repeat_disable;
	uniform sampler2D alpha2 : filter_linear, repeat_disable;
	uniform sampler2D alpha3 : filter_linear, repeat_disable;

uniform int layer_count = 1;
uniform float uv_scale = 8.0;
uniform float mip_bias = -0.75;
uniform vec3 light_dir = vec3(-0.35, 0.82, -0.45);
uniform float ambient = 0.62;
uniform float diffuse = 0.38;
uniform float hemi = 0.12;

global uniform vec4 wow_ambient_color;
global uniform vec4 wow_light_color;
global uniform vec3 wow_light_dir;
global uniform vec4 wow_fog_color;
global uniform vec2 wow_fog_range;
global uniform float wow_fog_density;

varying vec3 world_pos;
varying vec3 world_normal;
varying vec3 view_pos;

void vertex() {
	world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
	world_normal = normalize(MODEL_NORMAL_MATRIX * NORMAL);
	view_pos = (MODELVIEW_MATRIX * vec4(VERTEX, 1.0)).xyz;
}

vec3 apply_wow_fog(vec3 color) {
	float dist = length(view_pos);
	float range_fog = smoothstep(wow_fog_range.x, wow_fog_range.y, dist);
	float fog_amount = range_fog * clamp(wow_fog_density, 0.0, 0.55);
	return mix(color, wow_fog_color.rgb, fog_amount);
}

void fragment() {
	vec2 tiled_uv = UV * uv_scale;

	vec4 c0 = texture(tex0, tiled_uv, mip_bias);
	vec4 c1 = texture(tex1, tiled_uv, mip_bias);
	vec4 c2 = texture(tex2, tiled_uv, mip_bias);
	vec4 c3 = texture(tex3, tiled_uv, mip_bias);

	float w1 = layer_count > 1 ? texture(alpha1, UV).r : 0.0;
	float w2 = layer_count > 2 ? texture(alpha2, UV).r : 0.0;
	float w3 = layer_count > 3 ? texture(alpha3, UV).r : 0.0;
	float w0 = max(0.0, 1.0 - w1 - w2 - w3);

	float sum = w0;
	if (layer_count > 1) { sum += w1; }
	if (layer_count > 2) { sum += w2; }
	if (layer_count > 3) { sum += w3; }
	if (sum > 0.0) {
		w0 /= sum;
		w1 /= sum;
		w2 /= sum;
		w3 /= sum;
	}

	vec4 albedo = c0 * w0;
	if (layer_count > 1) { albedo += c1 * w1; }
	if (layer_count > 2) { albedo += c2 * w2; }
	if (layer_count > 3) { albedo += c3 * w3; }

	vec3 n = normalize(world_normal);
	if (!FRONT_FACING) {
		n = -n;
	}
	vec3 l = normalize(wow_light_dir);
	float ndl = max(dot(n, l), 0.0);
	float hemi_term = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
	vec3 ambient_tint = mix(vec3(0.74), max(wow_ambient_color.rgb, vec3(0.08)), 0.22);
	vec3 light_tint = mix(vec3(1.0), wow_light_color.rgb, 0.12);
	vec3 light = ambient_tint * ambient + light_tint * diffuse * ndl + vec3(hemi * hemi_term);
	light = clamp(light, vec3(0.48), vec3(1.08));
	ALBEDO = apply_wow_fog(albedo.rgb * light);
}
"""
	return _terrain_shader


static func _get_single_texture_shader() -> Shader:
	if _single_texture_shader:
		return _single_texture_shader

	_ensure_wow_shader_globals()
	_single_texture_shader = Shader.new()
	_single_texture_shader.code = """
shader_type spatial;
render_mode unshaded, cull_disabled, depth_draw_opaque;

	uniform sampler2D tex0 : source_color, filter_linear_mipmap_anisotropic, repeat_enable;
uniform float uv_scale = 8.0;
uniform float mip_bias = -0.75;
uniform float sharpen_strength = 0.0;
uniform vec3 light_dir = vec3(-0.35, 0.82, -0.45);
uniform float ambient = 0.62;
uniform float diffuse = 0.38;
uniform float hemi = 0.12;

global uniform vec4 wow_ambient_color;
global uniform vec4 wow_light_color;
global uniform vec3 wow_light_dir;
global uniform vec4 wow_fog_color;
global uniform vec2 wow_fog_range;
global uniform float wow_fog_density;

varying vec3 world_pos;
varying vec3 world_normal;
varying vec3 view_pos;

void vertex() {
	world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
	world_normal = normalize(MODEL_NORMAL_MATRIX * NORMAL);
	view_pos = (MODELVIEW_MATRIX * vec4(VERTEX, 1.0)).xyz;
}

vec3 sample_sharp(sampler2D tex, vec2 uv) {
	vec3 center = texture(tex, uv, mip_bias).rgb;
	if (sharpen_strength <= 0.001) {
		return center;
	}
	vec2 texel = 1.0 / vec2(textureSize(tex, 0));
	vec2 min_uv = vec2(0.001);
	vec2 max_uv = vec2(0.999);
	vec3 side =
		texture(tex, clamp(uv + vec2(texel.x, 0.0), min_uv, max_uv), mip_bias).rgb +
		texture(tex, clamp(uv - vec2(texel.x, 0.0), min_uv, max_uv), mip_bias).rgb +
		texture(tex, clamp(uv + vec2(0.0, texel.y), min_uv, max_uv), mip_bias).rgb +
		texture(tex, clamp(uv - vec2(0.0, texel.y), min_uv, max_uv), mip_bias).rgb;
	return clamp(center * (1.0 + 4.0 * sharpen_strength) - side * sharpen_strength, vec3(0.0), vec3(1.0));
}

vec3 apply_wow_fog(vec3 color) {
	float dist = length(view_pos);
	float range_fog = smoothstep(wow_fog_range.x, wow_fog_range.y, dist);
	float fog_amount = range_fog * clamp(wow_fog_density, 0.0, 0.55);
	return mix(color, wow_fog_color.rgb, fog_amount);
}

void fragment() {
	vec3 n = normalize(world_normal);
	if (!FRONT_FACING) {
		n = -n;
	}
	vec3 l = normalize(wow_light_dir);
	float ndl = max(dot(n, l), 0.0);
	float hemi_term = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
	vec3 ambient_tint = mix(vec3(0.74), max(wow_ambient_color.rgb, vec3(0.08)), 0.22);
	vec3 light_tint = mix(vec3(1.0), wow_light_color.rgb, 0.12);
	vec3 light = ambient_tint * ambient + light_tint * diffuse * ndl + vec3(hemi * hemi_term);
	light = clamp(light, vec3(0.48), vec3(1.08));
	ALBEDO = apply_wow_fog(sample_sharp(tex0, UV * uv_scale) * light);
}
"""
	return _single_texture_shader


static func _load_texture(
		blp_path: String,
		cache: Dictionary,
		extracted_dir: String) -> Texture2D:

	if cache.has(blp_path):
		return cache[blp_path]

	if not ClassDB.class_exists("BLPLoader"):
		return null

	var abs_path := extracted_dir.path_join(blp_path.replace("\\", "/"))
	if not FileAccess.file_exists(abs_path):
		cache[blp_path] = null
		return null

	var img: Image = ClassDB.instantiate("BLPLoader").call("load_image", abs_path)
	if not img:
		cache[blp_path] = null
		return null

	img.generate_mipmaps()
	var tex := ImageTexture.create_from_image(img)
	cache[blp_path] = tex
	return tex


static func _load_image(
		blp_path: String,
		cache: Dictionary,
		extracted_dir: String) -> Image:

	if cache.has(blp_path):
		return cache[blp_path]

	if not ClassDB.class_exists("BLPLoader"):
		return null

	var abs_path := extracted_dir.path_join(blp_path.replace("\\", "/"))
	if not FileAccess.file_exists(abs_path):
		cache[blp_path] = null
		return null

	var img: Image = ClassDB.instantiate("BLPLoader").call("load_image", abs_path)
	if not img:
		cache[blp_path] = null
		return null

	cache[blp_path] = img
	return img
