## DBC-driven outdoor sky controller for WoW 3.3.5a data. extends Node const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd") const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd") const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd") const WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT := preload("res://src/render/environment/world_environment_snapshot.gd") const LIGHT_COORD_SCALE := 36.0 const HALF_MINUTES_PER_DAY := 2880 const CH_AMBIENT := 0 const CH_DIFFUSE := 1 const CH_SKY_TOP := 2 const CH_SKY_MIDDLE := 3 const CH_SKY_BAND_1 := 4 const CH_SKY_BAND_2 := 5 const CH_FOG := 6 const FOG_END := 0 const FOG_START_SCALAR := 1 const CLOUD_DENSITY := 2 const FOG_DENSITY := 3 const AREA_LIGHT_PARAMS_BY_ZONE := { # Eastern Kingdoms outdoor fallback profiles for places not covered by Light.dbc volumes. 1: 28, # Dun Morogh 3: 40, # Badlands 4: 40, # Blasted Lands 8: 40, # Swamp of Sorrows 10: 92, # Duskwood 11: 34, # Wetlands 12: 28, # Elwynn Forest 28: 92, # Western Plaguelands 33: 118, # Stranglethorn Vale 36: 34, # Alterac Mountains 38: 28, # Loch Modan 40: 28, # Westfall 41: 92, # Deadwind Pass 44: 28, # Redridge Mountains 45: 28, # Arathi Highlands 46: 40, # Burning Steppes 47: 118, # Hinterlands 51: 40, # Searing Gorge 85: 92, # Tirisfal Glades 130: 92, # Silverpine Forest 139: 92, # Eastern Plaguelands 267: 28, # Hillsbrad Foothills 1519: 28, # Stormwind 1537: 28, # Ironforge 1497: 92, # Undercity 2365: 12, # Great Sea } @export var extracted_dir: String = "res://data/extracted" @export var map_name: String = "Azeroth" @export var map_id: int = 0 @export var target_path: NodePath @export var world_environment_path: NodePath @export var sun_path: NodePath @export var update_interval: float = 0.2 @export var use_system_time: bool = false @export_range(0.0, 24.0, 0.1) var fixed_time_hours: float = 13.0 @export var time_speed: float = 0.0 @export var smooth_speed: float = 6.0 @export var debug_log_enabled: bool = true @export var debug_log_interval: float = 5.0 @export var skybox_models_enabled: bool = true @export var skybox_model_scale: float = 1.0 @export var skybox_model_height_offset: float = 0.0 ## Non-zero forces a LightSkybox.dbc ID for visual testing on maps that do not use outdoor skybox models. @export var debug_force_skybox_id: int = 0 var _world_environment: WorldEnvironment var _environment: Environment var _sky_material: ProceduralSkyMaterial var _sun: DirectionalLight3D var _target: Node3D var _light_volumes_by_map: Dictionary = {} var _default_light_params_by_map: Dictionary = {} var _profiles: Dictionary = {} var _light_skyboxes: Dictionary = {} var _missing_skyboxes: Dictionary = {} var _area_table: Dictionary = {} var _adt_area_cache: Dictionary = {} var _loaded := false var _elapsed := 0.0 var _debug_elapsed := 0.0 var _current: Dictionary = {} var _skybox_root: Node3D var _active_skybox_id := -1 var _active_skybox_node: Node3D var _last_logged_area_id := -1 var _last_logged_zone_id := -1 var _last_logged_profile_signature := "" var _last_global_light_signature := "" func _ready() -> void: _resolve_nodes() _prepare_environment() _ensure_wow_shader_globals() _loaded = _load_lighting_dbcs() if _loaded: print("WowSkyController: loaded %d LightParams profiles, %d area records and %d %s light volumes" % [ _profiles.size(), _area_table.size(), _get_light_volume_count(map_id), map_name]) else: push_warning("WowSkyController: DBC lighting not loaded, using fallback sky") _apply_sky(1.0) _update_skybox_model() _update_skybox_transform() func _process(delta: float) -> void: if not _environment or not _sky_material: return _elapsed += delta _debug_elapsed += delta fixed_time_hours = fposmod(fixed_time_hours + delta * time_speed, 24.0) if _elapsed < update_interval: _apply_sky(delta) _update_skybox_transform() return _elapsed = 0.0 _sample_current_params() _apply_sky(delta) _update_skybox_model() _update_skybox_transform() ## Accepts an authoritative time snapshot without moving DBC visual selection out ## of this controller. Existing fixed-clock behavior is preserved and frozen until ## another snapshot or explicit local clock configuration is supplied. func apply_environment_snapshot(world_environment_snapshot: RefCounted) -> bool: if ( world_environment_snapshot == null or world_environment_snapshot.get_script() != WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT or not bool(world_environment_snapshot.get("is_valid")) ): return false use_system_time = false time_speed = 0.0 fixed_time_hours = float(world_environment_snapshot.get("time_of_day_hours")) return true func _resolve_nodes() -> void: if not world_environment_path.is_empty(): _world_environment = get_node_or_null(world_environment_path) as WorldEnvironment else: _world_environment = get_parent().get_node_or_null("WorldEnvironment") as WorldEnvironment if not sun_path.is_empty(): _sun = get_node_or_null(sun_path) as DirectionalLight3D else: _sun = get_parent().get_node_or_null("Sun") as DirectionalLight3D if not target_path.is_empty(): _target = get_node_or_null(target_path) as Node3D _skybox_root = Node3D.new() _skybox_root.name = "SkyboxModelRoot" _skybox_root.top_level = true add_child(_skybox_root) func _prepare_environment() -> void: if not _world_environment: push_warning("WowSkyController: WorldEnvironment not found") return _environment = _world_environment.environment if not _environment: _environment = Environment.new() else: _environment = _environment.duplicate(true) _world_environment.environment = _environment _environment.background_mode = Environment.BG_SKY _environment.ambient_light_source = Environment.AMBIENT_SOURCE_SKY _environment.reflected_light_source = Environment.REFLECTION_SOURCE_SKY _environment.fog_enabled = true _environment.fog_mode = Environment.FOG_MODE_EXPONENTIAL var sky := _environment.sky if not sky: sky = Sky.new() _environment.sky = sky _sky_material = sky.sky_material as ProceduralSkyMaterial if not _sky_material: _sky_material = ProceduralSkyMaterial.new() sky.sky_material = _sky_material _sky_material.use_debanding = true sky.radiance_size = Sky.RADIANCE_SIZE_256 func _sample_current_params() -> void: var time_hours := _get_time_hours() var time_half := int(floor(fposmod(time_hours, 24.0) * 120.0)) % HALF_MINUTES_PER_DAY var wow_pos := _get_target_wow_position() var area_id := _get_target_area_id() var zone_id := _get_zone_area_id(area_id) var selected := _select_light_params(wow_pos, area_id, zone_id) var params := _sample_blended_params(selected, time_half) params["time_hours"] = time_hours params["light_params"] = selected params["skybox_id"] = _select_skybox_id(selected) params["area_id"] = area_id params["zone_id"] = zone_id _current = params var profile_signature := str(selected) if debug_log_enabled and ( _debug_elapsed >= debug_log_interval or area_id != _last_logged_area_id or zone_id != _last_logged_zone_id or profile_signature != _last_logged_profile_signature ): _debug_elapsed = 0.0 _last_logged_area_id = area_id _last_logged_zone_id = zone_id _last_logged_profile_signature = profile_signature print("SKY_LIGHT time=%.2f map=%d area=%d:%s zone=%d:%s wow=(%.1f,%.1f,%.1f) profiles=%s skybox=%s fog=%.0f..%.0f density=%.3f" % [ time_hours, map_id, area_id, _get_area_name(area_id), zone_id, _get_area_name(zone_id), wow_pos.x, wow_pos.y, wow_pos.z, str(selected), str(params["skybox_id"]), float(params.get("fog_start", 0.0)), float(params.get("fog_end", 0.0)), float(params.get("fog_density", 0.0))]) func _apply_sky(delta: float) -> void: if _current.is_empty(): _sample_current_params() var blend := 1.0 if delta > 0.0 and smooth_speed > 0.0: blend = clamp(delta * smooth_speed, 0.0, 1.0) var sky_top: Color = _current.get("sky_top", Color(0.13, 0.32, 0.62)) var sky_mid: Color = _current.get("sky_middle", Color(0.36, 0.58, 0.86)) var sky_horizon: Color = _current.get("sky_band_1", Color(0.62, 0.76, 0.9)) var sky_low: Color = _current.get("sky_band_2", sky_horizon) var fog_color: Color = _current.get("fog_color", Color(0.55, 0.66, 0.72)) var ambient: Color = _current.get("ambient", Color(0.72, 0.8, 0.88)) var diffuse: Color = _current.get("diffuse", Color(1.0, 0.91, 0.78)) var time_hours: float = float(_current.get("time_hours", _get_time_hours())) var sun_elevation := _sun_elevation01(time_hours) _sky_material.sky_top_color = _sky_material.sky_top_color.lerp(sky_top, blend) _sky_material.sky_horizon_color = _sky_material.sky_horizon_color.lerp(sky_horizon, blend) _sky_material.ground_horizon_color = _sky_material.ground_horizon_color.lerp(sky_low, blend) _sky_material.ground_bottom_color = _sky_material.ground_bottom_color.lerp(fog_color.darkened(0.55), blend) _sky_material.sky_energy_multiplier = lerpf(_sky_material.sky_energy_multiplier, lerpf(0.28, 1.18, sun_elevation), blend) _sky_material.ground_energy_multiplier = lerpf(_sky_material.ground_energy_multiplier, 0.45, blend) _sky_material.sun_angle_max = 10.0 _sky_material.sun_curve = 0.08 _environment.ambient_light_color = _environment.ambient_light_color.lerp(ambient, blend) _environment.ambient_light_energy = lerpf(_environment.ambient_light_energy, clamp(_color_luma(ambient) * 1.45, 0.18, 1.05), blend) _environment.ambient_light_sky_contribution = lerpf(_environment.ambient_light_sky_contribution, 0.65, blend) _environment.background_energy_multiplier = lerpf(_environment.background_energy_multiplier, lerpf(0.35, 0.95, sun_elevation), blend) _environment.fog_light_color = _environment.fog_light_color.lerp(fog_color, blend) _environment.fog_light_energy = lerpf(_environment.fog_light_energy, 0.55, blend) _environment.fog_sun_scatter = lerpf(_environment.fog_sun_scatter, 0.08, blend) _environment.fog_sky_affect = lerpf(_environment.fog_sky_affect, 0.35, blend) var fog_end: float = maxf(800.0, float(_current.get("fog_end", 5200.0))) var fog_start: float = clamp(float(_current.get("fog_start", fog_end * 0.25)), 0.0, fog_end - 50.0) var dbc_fog_density: float = clamp(float(_current.get("fog_density", 0.5)), 0.0, 1.0) _environment.fog_depth_begin = lerpf(_environment.fog_depth_begin, fog_start, blend) _environment.fog_depth_end = lerpf(_environment.fog_depth_end, fog_end, blend) _environment.fog_density = lerpf(_environment.fog_density, clamp(dbc_fog_density * 0.0002, 0.00002, 0.00022), blend) if _sun: var sun_color := diffuse.lerp(Color(0.42, 0.46, 0.7), 1.0 - sun_elevation) _sun.light_color = _sun.light_color.lerp(sun_color, blend) _sun.light_energy = lerpf(_sun.light_energy, clamp(_color_luma(diffuse) * lerpf(0.22, 1.45, sun_elevation), 0.05, 1.6), blend) _apply_sun_direction(time_hours) _update_wow_shader_globals(sun_elevation) func _ensure_wow_shader_globals() -> void: pass func _update_wow_shader_globals(sun_elevation: float) -> void: if not _environment: return var light_dir := Vector3(-0.35, 0.82, -0.45).normalized() if _sun: light_dir = -_sun.global_transform.basis.z.normalized() var fog_range := Vector2(_environment.fog_depth_begin, _environment.fog_depth_end) var raw_light: Color = _sun.light_color if _sun else Color(1.0, 0.91, 0.78, 1.0) var shader_ambient := _world_shader_color(_environment.ambient_light_color, 0.65, Color(0.72, 0.70, 0.64, 1.0), 0.22) var shader_light := _world_shader_color(raw_light, 0.45, Color(1.0, 0.92, 0.78, 1.0), 0.16) var shader_fog := _world_shader_color(_environment.fog_light_color, 0.75, Color(0.62, 0.66, 0.66, 1.0), 0.38) var density: float = clampf(_environment.fog_density * 2200.0, 0.0, 0.42) var signature := "%s|%s|%s|%s|%.4f|%.3f" % [ str(shader_ambient), str(shader_light), str(light_dir.snapped(Vector3(0.001, 0.001, 0.001))), str(fog_range.snapped(Vector2(1.0, 1.0))), density, sun_elevation] if signature == _last_global_light_signature: return _last_global_light_signature = signature RenderingServer.global_shader_parameter_set(&"wow_ambient_color", shader_ambient) RenderingServer.global_shader_parameter_set(&"wow_light_color", shader_light) RenderingServer.global_shader_parameter_set(&"wow_light_dir", light_dir) RenderingServer.global_shader_parameter_set(&"wow_fog_color", shader_fog) RenderingServer.global_shader_parameter_set(&"wow_fog_range", fog_range) RenderingServer.global_shader_parameter_set(&"wow_fog_density", density) RenderingServer.global_shader_parameter_set(&"wow_sun_elevation", sun_elevation) func _load_lighting_dbcs() -> bool: var base := _res_path(extracted_dir).path_join("DBFilesClient") var light := _load_wdbc(base.path_join("Light.dbc")) var light_params := _load_wdbc(base.path_join("LightParams.dbc")) var light_skybox := _load_wdbc(base.path_join("LightSkybox.dbc")) var int_band := _load_wdbc(base.path_join("LightIntBand.dbc")) var float_band := _load_wdbc(base.path_join("LightFloatBand.dbc")) var area_table := _load_wdbc(base.path_join("AreaTable.dbc")) if light.is_empty() or int_band.is_empty() or float_band.is_empty(): return false _load_light_volumes(light) if not light_params.is_empty(): _load_light_params(light_params) if not light_skybox.is_empty(): _load_light_skyboxes(light_skybox) if not area_table.is_empty(): _load_area_table(area_table) _load_int_bands(int_band) _load_float_bands(float_band) return not _profiles.is_empty() func _load_light_volumes(dbc: Dictionary) -> void: _light_volumes_by_map.clear() _default_light_params_by_map.clear() for i in int(dbc["records"]): var record_map := _dbc_u32(dbc, i, 1) var wx := _dbc_float(dbc, i, 2) / LIGHT_COORD_SCALE var wz := _dbc_float(dbc, i, 3) / LIGHT_COORD_SCALE var wy := _dbc_float(dbc, i, 4) / LIGHT_COORD_SCALE var inner := maxf(0.0, _dbc_float(dbc, i, 5) / LIGHT_COORD_SCALE) var outer := maxf(inner, _dbc_float(dbc, i, 6) / LIGHT_COORD_SCALE) var normal := int(_dbc_u32(dbc, i, 7)) var rain := int(_dbc_u32(dbc, i, 8)) var underwater := int(_dbc_u32(dbc, i, 9)) if normal <= 0: continue if outer <= 0.001: if not _default_light_params_by_map.has(record_map): _default_light_params_by_map[record_map] = normal continue if not _light_volumes_by_map.has(record_map): _light_volumes_by_map[record_map] = [] _light_volumes_by_map[record_map].append({ "pos": Vector3(wx, wy, wz), "inner": inner, "outer": outer, "normal": normal, "rain": rain, "underwater": underwater, }) func _load_int_bands(dbc: Dictionary) -> void: for i in int(dbc["records"]): var band_id := int(_dbc_u32(dbc, i, 0)) if band_id <= 0: continue var param_id := int((band_id - 1) / 18) + 1 var channel := (band_id - 1) % 18 var profile := _get_or_create_profile(param_id) var colors: Array = profile["colors"] colors[channel] = _read_color_band(dbc, i) func _load_float_bands(dbc: Dictionary) -> void: for i in int(dbc["records"]): var band_id := int(_dbc_u32(dbc, i, 0)) if band_id <= 0: continue var param_id := int((band_id - 1) / 6) + 1 var channel := (band_id - 1) % 6 var profile := _get_or_create_profile(param_id) var floats: Array = profile["floats"] floats[channel] = _read_float_band(dbc, i) func _load_light_params(dbc: Dictionary) -> void: for i in int(dbc["records"]): var param_id := int(_dbc_u32(dbc, i, 0)) if param_id <= 0: continue var profile := _get_or_create_profile(param_id) profile["highlight_sky"] = int(_dbc_u32(dbc, i, 1)) profile["skybox_id"] = int(_dbc_u32(dbc, i, 2)) func _load_light_skyboxes(dbc: Dictionary) -> void: _light_skyboxes.clear() for i in int(dbc["records"]): var skybox_id := int(_dbc_u32(dbc, i, 0)) if skybox_id <= 0: continue var path := _dbc_string(dbc, i, 1).replace("\\", "/") if path.is_empty(): continue _light_skyboxes[skybox_id] = { "path": path, "flags": int(_dbc_u32(dbc, i, 2)), } func _load_area_table(dbc: Dictionary) -> void: _area_table.clear() for i in int(dbc["records"]): var area_id := int(_dbc_u32(dbc, i, 0)) if area_id <= 0: continue _area_table[area_id] = { "map": int(_dbc_u32(dbc, i, 1)), "parent": int(_dbc_u32(dbc, i, 2)), "name": _dbc_string(dbc, i, 19), } func _get_or_create_profile(param_id: int) -> Dictionary: if _profiles.has(param_id): return _profiles[param_id] var colors: Array = [] var floats: Array = [] colors.resize(18) floats.resize(6) var profile := {"colors": colors, "floats": floats} _profiles[param_id] = profile return profile func _select_light_params(wow_pos: Vector3, area_id: int, zone_id: int) -> Array: var volumes: Array = _light_volumes_by_map.get(map_id, []) var weighted: Array = [] for volume in volumes: var pos: Vector3 = volume["pos"] var d := Vector2(wow_pos.x - pos.x, wow_pos.y - pos.y).length() var outer: float = volume["outer"] if d > outer: continue var inner: float = volume["inner"] var weight := 1.0 if outer > inner: weight = clamp((outer - d) / (outer - inner), 0.0, 1.0) if weight > 0.0: weighted.append({"id": int(volume["normal"]), "weight": weight, "source": "volume"}) weighted.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return float(a["weight"]) > float(b["weight"])) if weighted.size() > 4: weighted.resize(4) var total := 0.0 for item in weighted: total += float(item["weight"]) if total > 0.0: for item in weighted: item["weight"] = float(item["weight"]) / total return weighted var area_profile := _select_area_light_param(area_id, zone_id) if area_profile > 0: return [{"id": area_profile, "weight": 1.0, "source": "area", "area": area_id, "zone": zone_id}] var fallback := int(_default_light_params_by_map.get(map_id, _default_light_params_by_map.get(0, 12))) return [{"id": fallback, "weight": 1.0, "source": "default"}] func _select_area_light_param(area_id: int, zone_id: int) -> int: if area_id > 0 and AREA_LIGHT_PARAMS_BY_ZONE.has(area_id): return int(AREA_LIGHT_PARAMS_BY_ZONE[area_id]) if zone_id > 0 and AREA_LIGHT_PARAMS_BY_ZONE.has(zone_id): return int(AREA_LIGHT_PARAMS_BY_ZONE[zone_id]) return 0 func _select_skybox_id(selected: Array) -> int: if debug_force_skybox_id > 0: return debug_force_skybox_id if _light_skyboxes.has(debug_force_skybox_id) else 0 for item in selected: var id := int(item.get("id", 0)) if not _profiles.has(id): continue var skybox_id := int((_profiles[id] as Dictionary).get("skybox_id", 0)) if skybox_id > 0 and _light_skyboxes.has(skybox_id): return skybox_id return 0 func _sample_blended_params(selected: Array, time_half: int) -> Dictionary: var out := { "ambient": Color(0.72, 0.80, 0.88), "diffuse": Color(1.0, 0.91, 0.78), "fog_color": Color(0.55, 0.66, 0.72), "sky_top": Color(0.13, 0.32, 0.62), "sky_middle": Color(0.36, 0.58, 0.86), "sky_band_1": Color(0.62, 0.76, 0.90), "sky_band_2": Color(0.50, 0.62, 0.72), "fog_end": 5200.0, "fog_start": 1200.0, "fog_density": 0.6, "cloud_density": 0.0, } var accum := {} for key in out.keys(): accum[key] = Color(0, 0, 0) if out[key] is Color else 0.0 var total := 0.0 for item in selected: var id := int(item["id"]) var weight := float(item["weight"]) if not _profiles.has(id) or weight <= 0.0: continue var sampled := _sample_profile(_profiles[id], time_half, out) for key in sampled.keys(): accum[key] = accum[key] + sampled[key] * weight total += weight if total <= 0.0: return out for key in out.keys(): out[key] = accum[key] if total == 1.0 else accum[key] / total return out func _sample_profile(profile: Dictionary, time_half: int, fallback: Dictionary) -> Dictionary: var colors: Array = profile["colors"] var floats: Array = profile["floats"] var fog_end := _sample_float_band(floats[FOG_END], time_half, float(fallback["fog_end"])) var fog_scalar := _sample_float_band(floats[FOG_START_SCALAR], time_half, 0.25) return { "ambient": _sample_color_band(colors[CH_AMBIENT], time_half, fallback["ambient"]), "diffuse": _sample_color_band(colors[CH_DIFFUSE], time_half, fallback["diffuse"]), "fog_color": _sample_color_band(colors[CH_FOG], time_half, fallback["fog_color"]), "sky_top": _sample_color_band(colors[CH_SKY_TOP], time_half, fallback["sky_top"]), "sky_middle": _sample_color_band(colors[CH_SKY_MIDDLE], time_half, fallback["sky_middle"]), "sky_band_1": _sample_color_band(colors[CH_SKY_BAND_1], time_half, fallback["sky_band_1"]), "sky_band_2": _sample_color_band(colors[CH_SKY_BAND_2], time_half, fallback["sky_band_2"]), "fog_end": fog_end, "fog_start": maxf(0.0, fog_end * fog_scalar), "fog_density": _sample_float_band(floats[FOG_DENSITY], time_half, float(fallback["fog_density"])), "cloud_density": _sample_float_band(floats[CLOUD_DENSITY], time_half, float(fallback["cloud_density"])), } func _read_color_band(dbc: Dictionary, record: int) -> Dictionary: var count := mini(int(_dbc_u32(dbc, record, 1)), 16) var times := PackedInt32Array() var values: Array[Color] = [] for i in count: times.append(int(_dbc_u32(dbc, record, 2 + i)) % HALF_MINUTES_PER_DAY) values.append(_dbc_color(_dbc_u32(dbc, record, 18 + i))) return {"times": times, "values": values} func _read_float_band(dbc: Dictionary, record: int) -> Dictionary: var count := mini(int(_dbc_u32(dbc, record, 1)), 16) var times := PackedInt32Array() var values := PackedFloat32Array() for i in count: times.append(int(_dbc_u32(dbc, record, 2 + i)) % HALF_MINUTES_PER_DAY) values.append(_dbc_float(dbc, record, 18 + i)) return {"times": times, "values": values} func _sample_color_band(band_variant: Variant, time_half: int, fallback: Color) -> Color: if not (band_variant is Dictionary): return fallback var band: Dictionary = band_variant var times: PackedInt32Array = band.get("times", PackedInt32Array()) var values: Array = band.get("values", []) if times.is_empty() or values.is_empty(): return fallback if times.size() == 1: return values[0] var pair := _find_time_pair(times, time_half) var t := pair.z return (values[pair.x] as Color).lerp(values[pair.y] as Color, t) func _sample_float_band(band_variant: Variant, time_half: int, fallback: float) -> float: if not (band_variant is Dictionary): return fallback var band: Dictionary = band_variant var times: PackedInt32Array = band.get("times", PackedInt32Array()) var values: PackedFloat32Array = band.get("values", PackedFloat32Array()) if times.is_empty() or values.is_empty(): return fallback if times.size() == 1: return values[0] var pair := _find_time_pair(times, time_half) return lerpf(values[pair.x], values[pair.y], pair.z) func _find_time_pair(times: PackedInt32Array, time_half: int) -> Vector3: var idx1 := times.size() - 1 var idx2 := 0 for i in times.size(): if time_half < times[i]: idx2 = i idx1 = i - 1 if i > 0 else times.size() - 1 break var t1 := times[idx1] var t2 := times[idx2] var span := t2 - t1 if t2 > t1 else HALF_MINUTES_PER_DAY - t1 + t2 var elapsed := time_half - t1 if time_half >= t1 else HALF_MINUTES_PER_DAY - t1 + time_half var alpha: float = clamp(float(elapsed) / maxf(1.0, float(span)), 0.0, 1.0) return Vector3(idx1, idx2, alpha) func _load_wdbc(path: String) -> Dictionary: var abs_path := ProjectSettings.globalize_path(path) if not FileAccess.file_exists(abs_path): return {} var file := FileAccess.open(abs_path, FileAccess.READ) if not file: return {} var bytes := file.get_buffer(file.get_length()) if bytes.size() < 20 or bytes[0] != 0x57 or bytes[1] != 0x44 or bytes[2] != 0x42 or bytes[3] != 0x43: return {} var records := int(bytes.decode_u32(4)) var fields := int(bytes.decode_u32(8)) var record_size := int(bytes.decode_u32(12)) var string_size := int(bytes.decode_u32(16)) var required := 20 + records * record_size + string_size if records < 0 or fields <= 0 or record_size <= 0 or required > bytes.size(): return {} return { "bytes": bytes, "records": records, "fields": fields, "record_size": record_size, "records_offset": 20, "strings_offset": 20 + records * record_size, "string_size": string_size, } func _dbc_u32(dbc: Dictionary, record: int, field: int) -> int: if record < 0 or record >= int(dbc["records"]) or field < 0: return 0 var record_size := int(dbc["record_size"]) var field_offset := field * 4 if field_offset + 4 > record_size: return 0 var bytes: PackedByteArray = dbc["bytes"] return int(bytes.decode_u32(int(dbc["records_offset"]) + record * record_size + field_offset)) func _dbc_float(dbc: Dictionary, record: int, field: int) -> float: if record < 0 or record >= int(dbc["records"]) or field < 0: return 0.0 var record_size := int(dbc["record_size"]) var field_offset := field * 4 if field_offset + 4 > record_size: return 0.0 var bytes: PackedByteArray = dbc["bytes"] return bytes.decode_float(int(dbc["records_offset"]) + record * record_size + field_offset) func _dbc_string(dbc: Dictionary, record: int, field: int) -> String: var offset := _dbc_u32(dbc, record, field) var string_size := int(dbc.get("string_size", 0)) if offset <= 0 or offset >= string_size: return "" var bytes: PackedByteArray = dbc["bytes"] var pos := int(dbc["strings_offset"]) + offset var end := pos var max_end := int(dbc["strings_offset"]) + string_size while end < max_end and bytes[end] != 0: end += 1 if end <= pos: return "" return bytes.slice(pos, end).get_string_from_utf8() func _dbc_color(value: int) -> Color: var r := float(value & 0xFF) / 255.0 var g := float((value >> 8) & 0xFF) / 255.0 var b := float((value >> 16) & 0xFF) / 255.0 return Color(r, g, b, 1.0) func _get_time_hours() -> float: if use_system_time: var now := Time.get_datetime_dict_from_system() return float(now.hour) + float(now.minute) / 60.0 + float(now.second) / 3600.0 return fixed_time_hours func _get_target_wow_position() -> Vector3: var world_pos := Vector3.ZERO if _target: world_pos = _target.global_position var canonical_position = COORDINATE_MAPPER_SCRIPT.godot_to_canonical(_typed_godot_position(world_pos)) return Vector3(canonical_position.x_yards, canonical_position.y_yards, canonical_position.z_yards) func _get_target_area_id() -> int: if not _target or map_name.is_empty(): return 0 var world_pos := _target.global_position var typed_world_position = _typed_godot_position(world_pos) var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(typed_world_position) var local_position = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile_local(typed_world_position) var chunk_coordinate = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_chunk(tile_coordinate, local_position) var areas := _load_adt_area_grid(tile_coordinate.tile_x, tile_coordinate.tile_y) if areas.is_empty(): return 0 var chunk_x := clampi(chunk_coordinate.chunk_x, 0, 15) var chunk_y := clampi(chunk_coordinate.chunk_y, 0, 15) return int(areas[chunk_y * 16 + chunk_x]) func _typed_godot_position(world_position: Vector3): return GODOT_WORLD_POSITION_SCRIPT.new(world_position.x, world_position.y, world_position.z) func _load_adt_area_grid(tile_x: int, tile_y: int) -> PackedInt32Array: var key := "%d_%d" % [tile_x, tile_y] if _adt_area_cache.has(key): var cached: PackedInt32Array = _adt_area_cache[key] return cached var areas := PackedInt32Array() areas.resize(256) var rel_path := _res_path(extracted_dir).path_join("World/Maps").path_join(map_name).path_join("%s_%d_%d.adt" % [map_name, tile_x, tile_y]) var abs_path := ProjectSettings.globalize_path(rel_path) if not FileAccess.file_exists(abs_path): _adt_area_cache[key] = areas return areas var file := FileAccess.open(abs_path, FileAccess.READ) if not file: _adt_area_cache[key] = areas return areas var bytes := file.get_buffer(file.get_length()) var offset := 0 while offset + 8 <= bytes.size(): var payload := offset + 8 var size := int(bytes.decode_u32(offset + 4)) if size < 0 or payload + size > bytes.size(): offset += 1 continue if _is_mcnk_magic(bytes, offset) and size >= 56: var index_x := int(bytes.decode_u32(payload + 4)) var index_y := int(bytes.decode_u32(payload + 8)) var area_id := int(bytes.decode_u32(payload + 52)) if index_x >= 0 and index_x < 16 and index_y >= 0 and index_y < 16: areas[index_y * 16 + index_x] = area_id offset = payload + size _adt_area_cache[key] = areas return areas func _is_mcnk_magic(bytes: PackedByteArray, offset: int) -> bool: if offset + 4 > bytes.size(): return false return ( (bytes[offset] == 0x4B and bytes[offset + 1] == 0x4E and bytes[offset + 2] == 0x43 and bytes[offset + 3] == 0x4D) or (bytes[offset] == 0x4D and bytes[offset + 1] == 0x43 and bytes[offset + 2] == 0x4E and bytes[offset + 3] == 0x4B) ) func _get_zone_area_id(area_id: int) -> int: if area_id <= 0: return 0 var current := area_id var visited := {} for i in 32: if not _area_table.has(current): return current var area: Dictionary = _area_table[current] var parent := int(area.get("parent", 0)) if parent <= 0: return current if visited.has(current): return current visited[current] = true current = parent return current func _get_area_name(area_id: int) -> String: if area_id <= 0 or not _area_table.has(area_id): return "-" var area: Dictionary = _area_table[area_id] var name := str(area.get("name", "")) return name if not name.is_empty() else "-" func _update_skybox_model() -> void: if not skybox_models_enabled or not _skybox_root: return var skybox_id := int(_current.get("skybox_id", 0)) if skybox_id == _active_skybox_id: return _active_skybox_id = skybox_id if _active_skybox_node: _active_skybox_node.queue_free() _active_skybox_node = null if skybox_id <= 0: return var rel_path := _get_skybox_m2_path(skybox_id) if rel_path.is_empty(): return var node := _load_skybox_m2(rel_path) if not node: return node.name = "Skybox_%d" % skybox_id node.scale = Vector3.ONE * maxf(0.001, skybox_model_scale) _disable_skybox_shadows(node) _skybox_root.add_child(node) _active_skybox_node = node print("SKYBOX_MODEL id=%d path=%s" % [skybox_id, rel_path]) func _update_skybox_transform() -> void: if not _skybox_root: return var pos := Vector3.ZERO if _target: pos = _target.global_position pos.y += skybox_model_height_offset _skybox_root.global_position = pos func _get_skybox_m2_path(skybox_id: int) -> String: if not _light_skyboxes.has(skybox_id): return "" var rel_path := str((_light_skyboxes[skybox_id] as Dictionary).get("path", "")).replace("\\", "/") if rel_path.is_empty(): return "" if rel_path.get_extension().to_lower() == "mdx": rel_path = rel_path.get_basename() + ".m2" return rel_path func _load_skybox_m2(rel_path: String) -> Node3D: if _missing_skyboxes.has(rel_path): return null if not ClassDB.class_exists("M2Loader"): return null var abs_path := ProjectSettings.globalize_path(_res_path(extracted_dir).path_join(rel_path)) if not FileAccess.file_exists(abs_path): _missing_skyboxes[rel_path] = true push_warning("WowSkyController: missing skybox model: %s" % rel_path) return null var loader = ClassDB.instantiate("M2Loader") if loader == null: return null var data: Dictionary = loader.call("load_m2", abs_path) if data.is_empty(): _missing_skyboxes[rel_path] = true return null return M2_BUILDER_SCRIPT.build(data, extracted_dir) func _disable_skybox_shadows(node: Node) -> void: if node is GeometryInstance3D: (node as GeometryInstance3D).cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF for child in node.get_children(): _disable_skybox_shadows(child) func _apply_sun_direction(time_hours: float) -> void: var day_phase := fposmod(time_hours - 6.0, 24.0) / 24.0 var azimuth := day_phase * TAU var elevation := sin(clamp((time_hours - 6.0) / 12.0, 0.0, 1.0) * PI) if time_hours < 6.0 or time_hours > 18.0: elevation = -0.25 var horizontal := sqrt(maxf(0.0, 1.0 - elevation * elevation)) var sun_dir := Vector3(cos(azimuth) * horizontal, elevation, sin(azimuth) * horizontal).normalized() var light_dir := -sun_dir if abs(light_dir.dot(Vector3.UP)) > 0.98: _sun.look_at(_sun.global_position + light_dir, Vector3.FORWARD) else: _sun.look_at(_sun.global_position + light_dir, Vector3.UP) func _sun_elevation01(time_hours: float) -> float: if time_hours < 6.0 or time_hours > 18.0: return 0.0 return clamp(sin(((time_hours - 6.0) / 12.0) * PI), 0.0, 1.0) func _color_luma(color: Color) -> float: return color.r * 0.2126 + color.g * 0.7152 + color.b * 0.0722 func _world_shader_color(color: Color, desaturate: float, neutral: Color, neutral_mix: float) -> Color: var luma := clampf(_color_luma(color), 0.0, 1.25) var gray := Color(luma, luma, luma, color.a) var result := color.lerp(gray, clampf(desaturate, 0.0, 1.0)) result = result.lerp(neutral, clampf(neutral_mix, 0.0, 1.0)) return Color( clampf(result.r, 0.0, 1.25), clampf(result.g, 0.0, 1.25), clampf(result.b, 0.0, 1.25), color.a) func _get_light_volume_count(id: int) -> int: return int((_light_volumes_by_map.get(id, []) as Array).size()) func _res_path(path: String) -> String: return path.trim_suffix("/")