Created
February 7, 2026 19:53
-
-
Save bearlikelion/4e0393ed575df27c41a9116181b490a4 to your computer and use it in GitHub Desktop.
Organize Extracted Everquest 1 Characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """Organize EverQuest character exports into Race/Gender folders and rename animations.""" | |
| import struct | |
| import json | |
| import os | |
| import shutil | |
| BASE = os.path.dirname(os.path.abspath(__file__)) | |
| TEXTURES_DIR = os.path.join(BASE, "Textures") | |
| # --- Race code mapping --- | |
| # prefix -> (Race, Gender) | |
| RACE_MAP = { | |
| "baf": ("Barbarian", "Female"), "bam": ("Barbarian", "Male"), | |
| "daf": ("DarkElf", "Female"), "dam": ("DarkElf", "Male"), | |
| "dwf": ("Dwarf", "Female"), "dwm": ("Dwarf", "Male"), | |
| "elf": ("WoodElf", "Female"), "elm": ("WoodElf", "Male"), | |
| "erf": ("Erudite", "Female"), "erm": ("Erudite", "Male"), | |
| "gnf": ("Gnome", "Female"), "gnm": ("Gnome", "Male"), | |
| "haf": ("HalfElf", "Female"), "ham": ("HalfElf", "Male"), | |
| "hif": ("HighElf", "Female"), "him": ("HighElf", "Male"), | |
| "hof": ("Halfling", "Female"), "hom": ("Halfling", "Male"), | |
| "huf": ("Human", "Female"), "hum": ("Human", "Male"), | |
| "ogf": ("Ogre", "Female"), "ogm": ("Ogre", "Male"), | |
| "trf": ("Troll", "Female"), "trm": ("Troll", "Male"), | |
| } | |
| # Special models -> Other/{Name} | |
| SPECIAL_MAP = { | |
| "ske": "Skeleton", | |
| "wer": "Werewolf", | |
| "ele": "Erudite_Old", | |
| "boat": "Boat", | |
| "eye": "Eye", | |
| "ivm": "InvisibleMan", | |
| "woe": "Wolf", | |
| } | |
| # Texture prefixes that go to special model folders | |
| SPECIAL_TEX_PREFIXES = ["ske", "wer", "ele", "woe", "eye", "ivm", "boat", "binside"] | |
| # Shared texture prefixes (checked AFTER race-specific cloak check) | |
| SHARED_TEX_PREFIXES = ["clk", "chain", "helm", "magecap"] | |
| # --- Animation name mapping --- | |
| ANIM_MAP = { | |
| "c01": "Kick", "c02": "Primary Jab", "c03": "2H Swipe", | |
| "c04": "2H Jab", "c05": "Primary Swipe", "c06": "Secondary Swipe", | |
| "c07": "Secondary Bash", "c08": "Primary Upper Jab", "c09": "Shoot Arrow", | |
| "c10": "Swim Attack", "c11": "Round Kick", | |
| "d01": "Take Damage", "d02": "Take Damage 2", | |
| "d03": "Swim Take Damage", "d04": "Drowning", "d05": "Death", | |
| "l01": "Walk", "l02": "Run", "l03": "Jump Across", "l04": "Jump", | |
| "l05": "Free Fall", "l06": "Crouch Walk", "l07": "Climb", | |
| "l08": "Crouch", "l09": "Swim Idle", | |
| "o01": "Idle 1", "o02": "Idle 3", "o03": "Sit Idle", | |
| "p01": "Stand", "p02": "Sit", "p03": "Turn Right", | |
| "p04": "Ice Strafe", "p05": "Kneel", "p06": "Swim Forward", | |
| "p07": "Unknown P07", "p08": "Idle 2", "pos": "Default", "drf": "Pose", | |
| "s01": "Cheer", "s02": "Cry", "s03": "Wave", "s04": "Rude", | |
| "s05": "Yawn", "s06": "Nod", "s07": "Amaze", "s08": "Plead", | |
| "s09": "Clap", "s10": "Hungry", "s11": "Blush", "s12": "Chuckle", | |
| "s13": "Cough", "s14": "Duck", "s15": "Puzzle", "s16": "Dance", | |
| "s17": "Blink", "s18": "Glare", "s19": "Drool", "s20": "Kneel Emote", | |
| "s21": "Laugh", "s22": "Point", "s23": "Shrug", "s24": "Ready", | |
| "s25": "Salute", "s26": "Shiver", "s27": "Tap", "s28": "Bow", | |
| "t01": "Instrument Percussion", "t02": "Instrument Strings", | |
| "t03": "Instrument Brass", "t04": "Spell Defense", "t05": "Spell General", | |
| "t06": "Spell Offense", "t07": "Flying Kick", | |
| "t08": "Combo Swipes", "t09": "Kung Fu Jab", | |
| } | |
| def rename_animations_in_glb(filepath): | |
| """Rewrite a GLB file with renamed animations. Returns count of renamed animations.""" | |
| with open(filepath, "rb") as f: | |
| data = f.read() | |
| # GLB header: magic(4) + version(4) + length(4) | |
| if len(data) < 12: | |
| return 0 | |
| magic, version, total_length = struct.unpack_from("<III", data, 0) | |
| if magic != 0x46546C67: # 'glTF' | |
| print(f" WARNING: {filepath} is not a valid GLB, skipping") | |
| return 0 | |
| # JSON chunk header: length(4) + type(4) | |
| json_chunk_length, json_chunk_type = struct.unpack_from("<II", data, 12) | |
| if json_chunk_type != 0x4E4F534A: # 'JSON' | |
| print(f" WARNING: {filepath} first chunk is not JSON, skipping") | |
| return 0 | |
| json_bytes = data[20 : 20 + json_chunk_length] | |
| gltf = json.loads(json_bytes) | |
| # Rename animations | |
| renamed = 0 | |
| for anim in gltf.get("animations", []): | |
| old_name = anim.get("name", "") | |
| if old_name in ANIM_MAP: | |
| anim["name"] = ANIM_MAP[old_name] | |
| renamed += 1 | |
| if renamed == 0: | |
| return 0 | |
| # Serialize new JSON | |
| new_json = json.dumps(gltf, separators=(",", ":")).encode("utf-8") | |
| # Pad to 4-byte boundary with spaces (glTF spec) | |
| padding = (4 - len(new_json) % 4) % 4 | |
| new_json_padded = new_json + b" " * padding | |
| new_json_chunk_length = len(new_json_padded) | |
| # Binary chunk (everything after the original JSON chunk) | |
| bin_chunk_start = 20 + json_chunk_length | |
| bin_chunk_data = data[bin_chunk_start:] | |
| # New total length | |
| new_total_length = 12 + 8 + new_json_chunk_length + len(bin_chunk_data) | |
| # Write new GLB | |
| with open(filepath, "wb") as f: | |
| f.write(struct.pack("<III", magic, version, new_total_length)) | |
| f.write(struct.pack("<II", new_json_chunk_length, json_chunk_type)) | |
| f.write(new_json_padded) | |
| f.write(bin_chunk_data) | |
| return renamed | |
| def get_glb_dest(filename): | |
| """Determine destination folder for a GLB file. Returns (dest_dir, is_special).""" | |
| stem = filename.replace(".glb", "") | |
| # Strip variant suffix like _00, _01 | |
| prefix = stem.split("_")[0] | |
| if prefix in RACE_MAP: | |
| race, gender = RACE_MAP[prefix] | |
| return os.path.join(BASE, race, gender), False | |
| if prefix in SPECIAL_MAP: | |
| return os.path.join(BASE, "Other", SPECIAL_MAP[prefix]), True | |
| print(f" WARNING: Unknown GLB prefix '{prefix}' for {filename}, skipping") | |
| return None, False | |
| def get_texture_dest(filename): | |
| """Determine destination folder for a texture file. Returns dest_dir or None.""" | |
| lower = filename.lower() | |
| # Race-specific cloak textures: clkerf*.png, clkerm*.png | |
| if lower.startswith("clkerf"): | |
| return os.path.join(BASE, "Erudite", "Female", "Textures") | |
| if lower.startswith("clkerm"): | |
| return os.path.join(BASE, "Erudite", "Male", "Textures") | |
| # Special model textures | |
| for sp in SPECIAL_TEX_PREFIXES: | |
| if lower.startswith(sp): | |
| if sp == "binside": | |
| return os.path.join(BASE, "Other", "Boat", "Textures") | |
| if sp == "boat": | |
| return os.path.join(BASE, "Other", "Boat", "Textures") | |
| if sp in SPECIAL_MAP: | |
| return os.path.join(BASE, "Other", SPECIAL_MAP[sp], "Textures") | |
| return None | |
| # Shared textures | |
| for sp in SHARED_TEX_PREFIXES: | |
| if lower.startswith(sp): | |
| return os.path.join(BASE, "Shared", "Textures") | |
| # Race textures: first 3 chars are the race code | |
| prefix = lower[:3] | |
| if prefix in RACE_MAP: | |
| race, gender = RACE_MAP[prefix] | |
| return os.path.join(BASE, race, gender, "Textures") | |
| print(f" WARNING: Unknown texture prefix for {filename}, skipping") | |
| return None | |
| def main(): | |
| print("=== Organizing EverQuest Character Exports ===\n") | |
| # --- Step 1: Move GLB files --- | |
| print("--- Moving GLB files ---") | |
| glb_files = sorted(f for f in os.listdir(BASE) if f.endswith(".glb")) | |
| glb_moved = 0 | |
| for glb in glb_files: | |
| dest_dir, _ = get_glb_dest(glb) | |
| if dest_dir is None: | |
| continue | |
| os.makedirs(dest_dir, exist_ok=True) | |
| src = os.path.join(BASE, glb) | |
| dst = os.path.join(dest_dir, glb) | |
| shutil.move(src, dst) | |
| glb_moved += 1 | |
| print(f" Moved {glb_moved} GLB files\n") | |
| # --- Step 2: Move textures --- | |
| print("--- Moving texture files ---") | |
| tex_moved = 0 | |
| if os.path.isdir(TEXTURES_DIR): | |
| tex_files = sorted(os.listdir(TEXTURES_DIR)) | |
| for tex in tex_files: | |
| if not tex.endswith(".png"): | |
| continue | |
| dest_dir = get_texture_dest(tex) | |
| if dest_dir is None: | |
| continue | |
| os.makedirs(dest_dir, exist_ok=True) | |
| src = os.path.join(TEXTURES_DIR, tex) | |
| dst = os.path.join(dest_dir, tex) | |
| shutil.move(src, dst) | |
| tex_moved += 1 | |
| print(f" Moved {tex_moved} texture files\n") | |
| # Clean up empty Textures directory | |
| remaining = os.listdir(TEXTURES_DIR) | |
| if not remaining: | |
| os.rmdir(TEXTURES_DIR) | |
| print(" Removed empty Textures/ directory\n") | |
| else: | |
| print(f" WARNING: {len(remaining)} files remain in Textures/: {remaining[:5]}...\n") | |
| else: | |
| print(" No Textures/ directory found\n") | |
| # --- Step 3: Rename animations in all GLB files --- | |
| print("--- Renaming animations in GLB files ---") | |
| total_renamed = 0 | |
| glb_count = 0 | |
| for root, dirs, files in os.walk(BASE): | |
| for f in sorted(files): | |
| if not f.endswith(".glb"): | |
| continue | |
| path = os.path.join(root, f) | |
| renamed = rename_animations_in_glb(path) | |
| if renamed > 0: | |
| rel = os.path.relpath(path, BASE) | |
| print(f" {rel}: {renamed} animations renamed") | |
| total_renamed += renamed | |
| glb_count += 1 | |
| print(f"\n Total: {total_renamed} animations renamed across {glb_count} GLB files\n") | |
| # --- Step 4: Clean up script --- | |
| script_path = os.path.join(BASE, "organize_characters.py") | |
| if os.path.exists(script_path): | |
| os.remove(script_path) | |
| print("--- Cleaned up: removed organize_characters.py ---\n") | |
| print("=== Done! ===") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment