## CharacterGeosetController – WoW 3.3.5a (build 12340) character geoset manager. ## ## Attach to the root of an imported character GLB scene. ## ## Correct geoset ID ranges (from M2SkinMeshPartID, WotLK 3.3.5): ## 0 skin – base body, always visible ## 1 – 34 hair – hair style variants ## 101 – 124 facial1 – beard ## 201 – 219 facial2 – mustache ## 301 – 319 facial3 – sideburns ## 401 – 405 gloves ## 501 – 510 boots ## 601 – 614 shirt ## 701 – 711 ears ## 801 – 804 wristbands (801 = bare wrists) ## 901 – 905 kneepads ## 1001– 1004 chest ## 1101– 1105 pants ## 1201– 1204 tabard ## 1301– 1303 legs ## 1401– 1414 shirt_doublet ## 1501– 1524 cape ## 1601– 1614 facial_jewelry ## 1701– 1705 eye_effects ## 1801– 1804 belt ## 1901– 1914 trail ## 2001– 2008 feet @tool extends Node3D const EYE_GLOW_SHADER := preload("res://scenes/eye_glow.gdshader") ## Customisation (0 = auto-select first available on _ready) @export var hair_style: int = 0 : set = _set_hair @export var facial1: int = 0 : set = _set_facial1 @export var facial2: int = 0 : set = _set_facial2 @export var facial3: int = 0 : set = _set_facial3 @export var ears: int = 0 : set = _set_ears @export var eye_effects: int = 0 : set = _set_eye_effects @export var eye_glow_intensity: float = 1.3 ## Skin / face customisation – forwarded to CharacterTextureCompositor sibling/child. @export var skin_color: int = 0 : set = _set_skin_color @export var face_style: int = 0 : set = _set_face_style ## Internal: available geoset IDs per category (populated in _ready from actual model). var _available: Dictionary = {} # category_name -> Array[int] (sorted) ## Internal: valid ranges for texture-based customisation (from compositor). var _skin_color_count: int = 0 var _face_style_count: int = 0 ## Equipment toggles (off by default = naked character) @export var show_gloves: bool = false : set = _set_gloves @export var show_boots: bool = false : set = _set_boots @export var show_shirt: bool = false : set = _set_shirt ## Wristband geoset ID: 801 = bare wrists (no bracer), 802+ = equipped bracers. ## 0 = auto (picks first available in model). @export var wristband_style: int = 0 : set = _set_wristbands @export var show_kneepads: bool = false : set = _set_kneepads @export var show_chest: bool = false : set = _set_chest @export var show_pants: bool = false : set = _set_pants @export var show_tabard: bool = false : set = _set_tabard @export var show_legs: bool = false : set = _set_legs @export var show_cape: bool = false : set = _set_cape @export var show_belt: bool = false : set = _set_belt @export var show_feet: bool = false : set = _set_feet # ── WoW 3.3.5 geoset ID ranges ─────────────────────────────────────────────── # Each entry: [start, end_exclusive, category_name] const RANGES: Array = [ [0, 1, "skin"], [1, 35, "hair"], [101, 125, "facial1"], [201, 220, "facial2"], [301, 320, "facial3"], [401, 406, "gloves"], [501, 511, "boots"], [601, 615, "shirt"], [701, 712, "ears"], [801, 805, "wristbands"], [901, 906, "kneepads"], [1001, 1005, "chest"], [1101, 1106, "pants"], [1201, 1205, "tabard"], [1301, 1304, "legs"], [1401, 1415, "shirt_doublet"], [1501, 1525, "cape"], [1601, 1615, "facial_jewelry"], [1701, 1706, "eye_effects"], [1801, 1805, "belt"], [1901, 1915, "trail"], [2001, 2009, "feet"], ] func _ready() -> void: var nodes := _geoset_nodes() if nodes.is_empty(): return # Build _available: actual geoset IDs present in the model, grouped by category. _available.clear() for child in nodes: var gid := _geoset_id(child.name) var cat := _category(gid) if not _available.has(cat): _available[cat] = [] _available[cat].append(gid) for cat in _available: (_available[cat] as Array).sort() # Query compositor for skin/face counts (it knows what PNGs are on disk). var comp := _get_compositor() if comp: _skin_color_count = comp.get_skin_color_count() _face_style_count = comp.get_face_style_count() # Clamp each export to the nearest valid value for this model. hair_style = _clamp_geoset("hair", hair_style) facial1 = _clamp_geoset("facial1", facial1) facial2 = _clamp_geoset("facial2", facial2) facial3 = _clamp_geoset("facial3", facial3) ears = _clamp_geoset("ears", ears) eye_effects = _clamp_geoset("eye_effects", eye_effects) wristband_style = _clamp_geoset("wristbands", wristband_style) skin_color = clampi(skin_color, 0, maxi(_skin_color_count - 1, 0)) face_style = clampi(face_style, 0, maxi(_face_style_count - 1, 0)) _apply_all(nodes) _apply_eye_glow_shader(nodes) # ── setters ─────────────────────────────────────────────────────────────────── func _set_hair(v): hair_style = _clamp_geoset("hair", v); _apply_variant("hair", hair_style) func _set_facial1(v): facial1 = _clamp_geoset("facial1", v); _apply_variant("facial1", facial1) func _set_facial2(v): facial2 = _clamp_geoset("facial2", v); _apply_variant("facial2", facial2) func _set_facial3(v): facial3 = _clamp_geoset("facial3", v); _apply_variant("facial3", facial3) func _set_ears(v): ears = _clamp_geoset("ears", v); _apply_variant("ears", ears) func _set_eye_effects(v): eye_effects = _clamp_geoset("eye_effects", v); _apply_variant("eye_effects", eye_effects) func _set_skin_color(v: int) -> void: skin_color = clampi(v, 0, maxi(_skin_color_count - 1, 0)) var c := _get_compositor() if c: c.skin_color = skin_color func _set_face_style(v: int) -> void: face_style = clampi(v, 0, maxi(_face_style_count - 1, 0)) var c := _get_compositor() if c: c.face_style = face_style func _get_compositor() -> Node: # Look for CharacterTextureCompositor as a direct child first, # then as a sibling (child of our parent). for child in get_children(): if child.get_script() and child.has_method("refresh"): return child var p := get_parent() if p: for sibling in p.get_children(): if sibling != self and sibling.get_script() and sibling.has_method("refresh"): return sibling return null func _set_gloves(v): show_gloves = v; _apply_toggle("gloves", v) func _set_boots(v): show_boots = v; _apply_toggle("boots", v) func _set_shirt(v): show_shirt = v; _apply_toggle("shirt", v) func _set_wristbands(v): wristband_style = v; _apply_variant("wristbands", v) func _set_kneepads(v): show_kneepads = v; _apply_toggle("kneepads", v) func _set_chest(v): show_chest = v; _apply_toggle("chest", v) func _set_pants(v): show_pants = v; _apply_toggle("pants", v) func _set_tabard(v): show_tabard = v; _apply_toggle("tabard", v) func _set_legs(v): show_legs = v; _apply_toggle("legs", v) func _set_cape(v): show_cape = v; _apply_toggle("cape", v) func _set_belt(v): show_belt = v; _apply_toggle("belt", v) func _set_feet(v): show_feet = v; _apply_toggle("feet", v) # ── visibility ──────────────────────────────────────────────────────────────── ## Show the geoset matching exact ID `wanted`; hide all others in the category. func _apply_variant(cat: String, wanted: int) -> void: for child in _geoset_nodes(): var gid := _geoset_id(child.name) if _category(gid) == cat: child.visible = (gid == wanted) ## Show or hide all geosets in a category. func _apply_toggle(cat: String, on: bool) -> void: for child in _geoset_nodes(): if _category(_geoset_id(child.name)) == cat: child.visible = on func _apply_all(nodes: Array[Node] = []) -> void: if nodes.is_empty(): nodes = _geoset_nodes() for child in nodes: var gid := _geoset_id(child.name) var cat := _category(gid) match cat: "skin": child.visible = true "hair": child.visible = (gid == hair_style) "facial1": child.visible = (gid == facial1) "facial2": child.visible = (gid == facial2) "facial3": child.visible = (gid == facial3) "ears": child.visible = (gid == ears) "eye_effects": child.visible = (gid == eye_effects) "gloves": child.visible = show_gloves "boots": child.visible = show_boots "shirt": child.visible = show_shirt "wristbands": child.visible = (gid == wristband_style) "kneepads": child.visible = show_kneepads "chest": child.visible = show_chest "pants": child.visible = show_pants "tabard": child.visible = show_tabard "legs": child.visible = show_legs "cape": child.visible = show_cape "belt": child.visible = show_belt "feet": child.visible = show_feet # shirt_doublet, facial_jewelry, trail: follow shirt / facial / trail "shirt_doublet": child.visible = show_shirt "facial_jewelry":child.visible = (gid == facial1 + 1500) # rough guess "trail": child.visible = true _: child.visible = true # unknown – leave visible # ── helpers ─────────────────────────────────────────────────────────────────── func _geoset_nodes() -> Array[Node]: var result: Array[Node] = [] _collect(self, result) return result func _collect(node: Node, result: Array[Node]) -> void: for child in node.get_children(): if child.name.begins_with("geoset_") and child is MeshInstance3D: result.append(child) else: _collect(child, result) ## Extract geoset_id from node name "geoset_NNNN_category_vNN" func _geoset_id(node_name: String) -> int: var parts := node_name.split("_") if parts.size() >= 2: return parts[1].to_int() return -1 ## Map a geoset ID to its category name using the WoW 3.3.5 ranges. func _category(gid: int) -> String: for r in RANGES: if gid >= r[0] and gid < r[1]: return r[2] return "unknown" ## Replace materials on eye-glow geosets with the multiplicative glow shader. ## Detects eye-glow blend in two ways (in priority order): ## 1. GLTF node extras["blend_mode"] == 4 (Mod) or 3 (Add) from M2 material data ## 2. Fallback: geoset ID in the eye_effects range 1701-1705 ## Call once after the scene loads. func _apply_eye_glow_shader(nodes: Array[Node] = []) -> void: if nodes.is_empty(): nodes = _geoset_nodes() # Per-race glow colour defaults (Mod blending: tints the eye behind it). # With blend_mul: result = ALBEDO * background. # Use saturated hues so the tint is strong; 1.0 on the dominant channel # keeps that channel of the background intact while suppressing others. var race_colors := { "bloodelf": Color(0.1, 1.0, 0.2), # green "nightelf": Color(0.6, 0.9, 1.0), # silver-blue "deathknight": Color(0.1, 0.4, 1.0), # icy blue "scourge": Color(0.1, 0.4, 1.0), # icy blue (undead DKs) } # Guess race from scene/model name var scene_name := get_name().to_lower() var glow_color := Color(0.2, 1.0, 0.3) # default green for race in race_colors: if race in scene_name: glow_color = race_colors[race] break for child in nodes: var mesh_inst := child as MeshInstance3D if not mesh_inst: continue # Determine whether this geoset uses WoW eye-glow blending. # Primary: check blend_mode from M2 material stored in GLTF extras. # blend_mode=4 → Mod (DST_COLOR * src) — most races (Blood Elf, Night Elf, etc.) # blend_mode=3 → Add (SRC_ALPHA + ONE) — pure additive (rare; DK particles etc.) # Fallback: geoset ID in the eye_effects range (1701-1705) for older GLBs. var is_eye_glow := false if mesh_inst.has_meta("extras"): var extras = mesh_inst.get_meta("extras") if extras is Dictionary and extras.has("blend_mode"): var bm: int = extras["blend_mode"] is_eye_glow = (bm == 4 or bm == 3) if not is_eye_glow: is_eye_glow = (_category(_geoset_id(child.name)) == "eye_effects") if not is_eye_glow: continue # Replace every surface material with the glow shader for si in range(mesh_inst.get_surface_override_material_count()): var orig_mat := mesh_inst.mesh.surface_get_material(si) var shader_mat := ShaderMaterial.new() shader_mat.shader = EYE_GLOW_SHADER shader_mat.set_shader_parameter("glow_color", Vector3(glow_color.r, glow_color.g, glow_color.b)) shader_mat.set_shader_parameter("glow_intensity", eye_glow_intensity) # Carry over the original albedo texture if it was a StandardMaterial3D if orig_mat is StandardMaterial3D: var std := orig_mat as StandardMaterial3D if std.albedo_texture: shader_mat.set_shader_parameter("albedo_texture", std.albedo_texture) mesh_inst.set_surface_override_material(si, shader_mat) ## Returns sorted list of geoset IDs available for a given category. ## After _ready() this uses the cached _available dict; before _ready() it scans nodes. func get_ids(cat: String) -> Array[int]: if not _available.is_empty(): var ids: Array = _available.get(cat, []) var result: Array[int] = [] for id in ids: result.append(id) return result # Fallback: scan live (called before _ready in edge cases) var result: Array[int] = [] for child in _geoset_nodes(): var gid := _geoset_id(child.name) if _category(gid) == cat and not gid in result: result.append(gid) result.sort() return result ## Returns valid skin colour count for this model (0 if compositor not found yet). func get_skin_color_count() -> int: return _skin_color_count ## Returns valid face style count for this model (0 if compositor not found yet). func get_face_style_count() -> int: return _face_style_count ## Clamp/snap v to the nearest available geoset ID in cat. ## Returns v unchanged if _available is not populated yet or cat is missing. func _clamp_geoset(cat: String, v: int) -> int: if not _available.has(cat): return v var ids: Array = _available[cat] # already sorted if ids.is_empty(): return v if v in ids: return v # Pick the nearest available ID var best: int = ids[0] var best_dist: int = abs(v - best) for id in ids: var d: int = abs(v - id) if d < best_dist: best_dist = d best = id return best