├── Interface └── Translations │ ├── SLAnimLoader_CHINESE.TXT │ ├── SLAnimLoader_CZECH.TXT │ ├── SLAnimLoader_DANISH.TXT │ ├── SLAnimLoader_ENGLISH.txt │ ├── SLAnimLoader_FINNISH.TXT │ ├── SLAnimLoader_FRENCH.TXT │ ├── SLAnimLoader_GERMAN.TXT │ ├── SLAnimLoader_GREEK.TXT │ ├── SLAnimLoader_ITALIAN.TXT │ ├── SLAnimLoader_JAPANESE.TXT │ ├── SLAnimLoader_NORWEGIAN.TXT │ ├── SLAnimLoader_POLISH.TXT │ ├── SLAnimLoader_PORTUGUESE.TXT │ ├── SLAnimLoader_RUSSIAN.TXT │ ├── SLAnimLoader_SPANISH.TXT │ ├── SLAnimLoader_SWEDISH.TXT │ └── SLAnimLoader_TURKISH.TXT ├── README.md ├── SLAnimLoader.esp ├── SLAnims ├── SLAnimGenerate.pyw └── source │ └── Example.txt ├── Scripts ├── Source │ ├── slalAnimationFactory.psc │ ├── slalData.psc │ ├── slalLoader.psc │ ├── slalMCM.psc │ └── slalOnLoad.psc ├── slalAnimationFactory.pex ├── slalData.pex ├── slalLoader.pex ├── slalMCM.pex └── slalOnLoad.pex └── export.py /Interface/Translations/SLAnimLoader_CHINESE.TXT: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orxx/SLAnimLoader/a5d079b8c736620c2ba5c4c1e0f9bd1b5bdcf821/Interface/Translations/SLAnimLoader_CHINESE.TXT -------------------------------------------------------------------------------- /Interface/Translations/SLAnimLoader_CZECH.TXT: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orxx/SLAnimLoader/a5d079b8c736620c2ba5c4c1e0f9bd1b5bdcf821/Interface/Translations/SLAnimLoader_CZECH.TXT -------------------------------------------------------------------------------- /Interface/Translations/SLAnimLoader_DANISH.TXT: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orxx/SLAnimLoader/a5d079b8c736620c2ba5c4c1e0f9bd1b5bdcf821/Interface/Translations/SLAnimLoader_DANISH.TXT -------------------------------------------------------------------------------- /Interface/Translations/SLAnimLoader_ENGLISH.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orxx/SLAnimLoader/a5d079b8c736620c2ba5c4c1e0f9bd1b5bdcf821/Interface/Translations/SLAnimLoader_ENGLISH.txt -------------------------------------------------------------------------------- /Interface/Translations/SLAnimLoader_FINNISH.TXT: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orxx/SLAnimLoader/a5d079b8c736620c2ba5c4c1e0f9bd1b5bdcf821/Interface/Translations/SLAnimLoader_FINNISH.TXT -------------------------------------------------------------------------------- /Interface/Translations/SLAnimLoader_FRENCH.TXT: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orxx/SLAnimLoader/a5d079b8c736620c2ba5c4c1e0f9bd1b5bdcf821/Interface/Translations/SLAnimLoader_FRENCH.TXT -------------------------------------------------------------------------------- /Interface/Translations/SLAnimLoader_GERMAN.TXT: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orxx/SLAnimLoader/a5d079b8c736620c2ba5c4c1e0f9bd1b5bdcf821/Interface/Translations/SLAnimLoader_GERMAN.TXT -------------------------------------------------------------------------------- /Interface/Translations/SLAnimLoader_GREEK.TXT: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orxx/SLAnimLoader/a5d079b8c736620c2ba5c4c1e0f9bd1b5bdcf821/Interface/Translations/SLAnimLoader_GREEK.TXT -------------------------------------------------------------------------------- /Interface/Translations/SLAnimLoader_ITALIAN.TXT: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orxx/SLAnimLoader/a5d079b8c736620c2ba5c4c1e0f9bd1b5bdcf821/Interface/Translations/SLAnimLoader_ITALIAN.TXT -------------------------------------------------------------------------------- /Interface/Translations/SLAnimLoader_JAPANESE.TXT: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orxx/SLAnimLoader/a5d079b8c736620c2ba5c4c1e0f9bd1b5bdcf821/Interface/Translations/SLAnimLoader_JAPANESE.TXT -------------------------------------------------------------------------------- /Interface/Translations/SLAnimLoader_NORWEGIAN.TXT: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orxx/SLAnimLoader/a5d079b8c736620c2ba5c4c1e0f9bd1b5bdcf821/Interface/Translations/SLAnimLoader_NORWEGIAN.TXT -------------------------------------------------------------------------------- /Interface/Translations/SLAnimLoader_POLISH.TXT: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orxx/SLAnimLoader/a5d079b8c736620c2ba5c4c1e0f9bd1b5bdcf821/Interface/Translations/SLAnimLoader_POLISH.TXT -------------------------------------------------------------------------------- /Interface/Translations/SLAnimLoader_PORTUGUESE.TXT: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orxx/SLAnimLoader/a5d079b8c736620c2ba5c4c1e0f9bd1b5bdcf821/Interface/Translations/SLAnimLoader_PORTUGUESE.TXT -------------------------------------------------------------------------------- /Interface/Translations/SLAnimLoader_RUSSIAN.TXT: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orxx/SLAnimLoader/a5d079b8c736620c2ba5c4c1e0f9bd1b5bdcf821/Interface/Translations/SLAnimLoader_RUSSIAN.TXT -------------------------------------------------------------------------------- /Interface/Translations/SLAnimLoader_SPANISH.TXT: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orxx/SLAnimLoader/a5d079b8c736620c2ba5c4c1e0f9bd1b5bdcf821/Interface/Translations/SLAnimLoader_SPANISH.TXT -------------------------------------------------------------------------------- /Interface/Translations/SLAnimLoader_SWEDISH.TXT: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orxx/SLAnimLoader/a5d079b8c736620c2ba5c4c1e0f9bd1b5bdcf821/Interface/Translations/SLAnimLoader_SWEDISH.TXT -------------------------------------------------------------------------------- /Interface/Translations/SLAnimLoader_TURKISH.TXT: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orxx/SLAnimLoader/a5d079b8c736620c2ba5c4c1e0f9bd1b5bdcf821/Interface/Translations/SLAnimLoader_TURKISH.TXT -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | SexLab Animation Loader 2 | ======================= 3 | 4 | SLAnimLoader registers custom animations with SexLab. It reads information 5 | about the animations from Data\\SLAnims\\json\\ 6 | 7 | This makes it possible to add new animations without needing to edit any mods 8 | or do any scripting. This also makes it easy to change animation tags, actor 9 | positions, sounds, mouth positions, etc. 10 | 11 | 12 | Source Files vs JSON 13 | -------------------- 14 | 15 | While you can edit the JSON files manually in your favorite text editor, this 16 | isn't recommended. Hand editing these fields is tedious, and it's easy to get 17 | the syntax slightly wrong. If you have a syntax error, Skyrim will fail to 18 | load the file and won't give you any error information about what line of your 19 | file was wrong. 20 | 21 | Therefore SLAnimLoader supports building the JSON data from source files in 22 | Data\\SLAnims\\source\\. 23 | 24 | Data\\SLAnims\\source\\Example.txt contains a brief overview of the syntax of 25 | the source files. The Example.txt file itself will be automatically skipped by 26 | SLAnimGenerate, since it contains the line "is\_example = True". You can 27 | remove this line if you want to play around with it in SLAnimGenerate, but 28 | there aren't any actual animation files associated with it, so no animation 29 | stages will be found. If you copy Example.txt to start building your own 30 | animation pack, be sure to remove the "is\_example = True" setting. 31 | 32 | 33 | Setting up your Source file and Animation files 34 | ----------------------------------------------- 35 | 36 | You will generally want to group all of your animations into a single category. 37 | Pick a name for your category, and create a source file with that name. For 38 | example, Data\\SLAnims\\source\\YourCategory.txt 39 | 40 | Now put your \*.hkx animation files into into the directory 41 | meshes\\actors\\characters\\animations\\YourCategory\\. Animations for 42 | creatures should go into the appropriate creature directory (e.g. 43 | meshes\\actors\\draugr instead of meshes\\actors\\characters). 44 | 45 | You will have one \*.hkx for each stage of each actor. Your files should be 46 | named AnimName_A1_S1.hkx for the 1st actors 1st stage, AnimName_A2_S3.hkx for 47 | the 2nd actor's 3rd stage, etc. 48 | 49 | In the YourCategory.txt source file, add a new Animation() statement for your 50 | animation. The "id" field must match the name of your animation files. e.g., 51 | put id="Foo" if your files are Foo_A1_S1.hkx, Foo_A1_S2.hkx, etc. 52 | 53 | 54 | Building the JSON Data 55 | ---------------------- 56 | 57 | Run Data\\SLAnims\\SLAnimGenerate.pyw to process your source file. This will 58 | generate a corresponding JSON file under Data\\SLAnims\\json, and will also 59 | generate FNIS lists in each of your animation directories (one for each race). 60 | 61 | Any time a FNIS list file is updated, you need to re-run 62 | GenerateFNISforModders.exe to process the list file. After you have 63 | processed all of the FNIS lists, then re-run GenerateFNISforUsers.exe. 64 | 65 | Once this is done everything should be ready to start Skyrim and register your 66 | animations. 67 | 68 | 69 | Tweaking Parameters 70 | ------------------- 71 | 72 | Once you have loaded your animation into Skyrim, you may notice that the actor 73 | positions aren't quite right, the sound is wrong, or some other minor issue. 74 | You can tweak most simple parameters like this without having to quit skyrim. 75 | 76 | Simply modify your category source file, and then build it with 77 | SLAnimGenerate.pyw. You can do this while Skyrim is still running. 78 | 79 | Next, go into the SLAnimLoader MCM menu, and in the "General Options" section, 80 | click "Reapply JSON Settings". This will update the settings for all 81 | SLAnimLoader animations that were already registered with Skyrim. 82 | 83 | 84 | Rebuilding the SexLab Animation Registry 85 | ---------------------------------------- 86 | 87 | Whenever the SexLab animation registry is rebuilt, SLAnimLoader will 88 | re-register it's enabled animations. To unregister all SLAnimLoader 89 | animations, click the "Disable All" button in the "General Options" page of the 90 | MCM, then rebuild the SexLab animation registry. 91 | -------------------------------------------------------------------------------- /SLAnimLoader.esp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orxx/SLAnimLoader/a5d079b8c736620c2ba5c4c1e0f9bd1b5bdcf821/SLAnimLoader.esp -------------------------------------------------------------------------------- /SLAnims/SLAnimGenerate.pyw: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | """ 3 | This script reads the files in SLAnims\source, and generates JSON output in 4 | SLAnims\json which can be read by SLAnimLoader in Skyrim. 5 | 6 | While you could manually edit the JSON data by hand, JSON is not very friendly 7 | to hand-edit. (It is easy to break if you don't get the syntax exactly right, 8 | and you won't get any feedback in Skyrim if you break anything.) The source 9 | scripts also allow animation info to be specified in a much less verbose 10 | format. 11 | """ 12 | import argparse 13 | import inspect 14 | import json 15 | import os 16 | import re 17 | import sys 18 | import traceback 19 | 20 | 21 | ################################# 22 | # Helpers for use in source files 23 | ################################# 24 | 25 | # Several functions available in scripts are actually methods of Category. 26 | # - Animation() is Category.add_anim() 27 | # - anim_id_prefix() is Category.set_anim_id_prefix() 28 | # - anim_name_prefix() is Category.set_anim_name_prefix() 29 | # - common_tags() is Category.set_common_tags() 30 | 31 | # SexLab currently supports up to 5 actors 32 | MAX_ACTORS = 5 33 | 34 | VALID_SOUNDS = [ 35 | "", 36 | "none", 37 | "Squishing", 38 | "Sucking", 39 | "SexMix", 40 | "Squirting", 41 | ] 42 | 43 | # These numerical values must match the settings defined 44 | # in the SexLab Framework's sslAnimationFactory script. 45 | CUM_TYPES = { 46 | "Vaginal": 1, 47 | "Oral": 2, 48 | "Anal": 3, 49 | "VaginalOral": 4, 50 | "VaginalAnal": 5, 51 | "OralAnal": 6, 52 | "VaginalOralAnal": 7, 53 | } 54 | CUM_TYPES_LOWER = dict((k.lower(), v) for k, v in CUM_TYPES.items()) 55 | 56 | KNOWN_RACES = { 57 | # 58 | # Races added by SexLab Framework 59 | # 60 | "Bears": "bear", 61 | "Chaurus": "chaurus", 62 | "Chickens": "ambient/chicken", 63 | "Dogs": "canine", 64 | "Dragons": "dragon", 65 | "Draugrs": "draugr", 66 | "Falmers": "falmer", 67 | "FlameAtronach": "atronachflame", 68 | "Gargoyles": "dlc01/vampirebrute", 69 | "Giants": "giant", 70 | "Horses": "horse", 71 | "LargeSpiders": "frostbitespider", 72 | "Lurkers": "dlc02/benthiclurker", 73 | "Rieklings": "dlc02/riekling", 74 | "SabreCats": "sabrecat", 75 | "Seekers": "dlc02/hmdaedra", 76 | "Skeevers": "skeever", 77 | "Spiders": "frostbitespider", 78 | "Spriggans": "spriggan", 79 | "Trolls": "troll", 80 | "VampireLords": "vampirelord", 81 | "Werewolves": "werewolfbeast", 82 | "Wolves": "canine", 83 | # 84 | # Races added by MoreNastyCritters 85 | # 86 | "Ashhoppers": "dlc02/scrib", 87 | "Boars": "dlc02/boarriekling", 88 | "ChaurusHunters": "dlc01/chaurusflyer", 89 | "ChaurusReapers": "chaurus", 90 | "Cows": "cow", 91 | "Deers": "deer", 92 | "DragonPriests": "dragonpriest", 93 | "DwarvenBallistas": "dlc02\dwarvenballistacenturion", 94 | "DwarvenCenturions": "DwarvenSteamCenturion", 95 | "DwarvenSpheres": "dwarvenspherecenturion", 96 | "DwarvenSpiders": "dwarvenspider", 97 | "FrostAtronach": "atronachfrost", 98 | "Goats": "goat", 99 | "Hagravens": "hagraven", 100 | "Horkers": "horker", 101 | "Mammoths": "mammoth", 102 | "Netches": "dlc02/netch", 103 | "Rabbits": "ambient/hare", 104 | "Slaughterfishes": "slaughterfish", 105 | "GiantSpiders": "frostbitespider", 106 | "Wispmothers": "wisp", 107 | } 108 | KNOWN_RACES_LOWER = dict((k.lower(), v) for k, v in KNOWN_RACES.items()) 109 | 110 | 111 | class Stage(object): 112 | def __init__(self, number, **kwargs): 113 | self.number = number 114 | self.kwargs = kwargs 115 | 116 | 117 | class Actor(object): 118 | def __init__(self, **kwargs): 119 | self.kwargs = kwargs 120 | 121 | 122 | class Male(Actor): 123 | SEXLAB_VALUE = 0 124 | ALLOW_CUM = False 125 | IS_CREATURE = False 126 | 127 | 128 | class Female(Actor): 129 | SEXLAB_VALUE = 1 130 | ALLOW_CUM = True 131 | IS_CREATURE = False 132 | 133 | 134 | class CreatureMale(Actor): 135 | SEXLAB_VALUE = 2 136 | ALLOW_CUM = False 137 | IS_CREATURE = True 138 | 139 | 140 | class CreatureFemale(Actor): 141 | SEXLAB_VALUE = 3 142 | ALLOW_CUM = True 143 | IS_CREATURE = True 144 | 145 | 146 | ################################################### 147 | # End Helpers 148 | # The remaining code is the rebuilding logic itself 149 | ################################################### 150 | 151 | __LOADER_CODE__ = None 152 | 153 | _ANIM_NAME_REGEX = re.compile(r"(?P.*)A(?P[0-9]+)" 154 | r"(?:_?S(?P[0-9]+))?$", 155 | re.IGNORECASE) 156 | 157 | 158 | class Category(object): 159 | def __init__(self, name, src_path, data_dir): 160 | self.name = name 161 | self.src_path = src_path 162 | self.data_dir = data_dir 163 | 164 | self.json_path = os.path.join(self.data_dir, "SLAnims", "json", 165 | self.name + ".json") 166 | 167 | self.mcm_name = name 168 | self.anim_dir = name 169 | 170 | # The Example.txt sample file sets is_example to true. 171 | # We skip any file with is_example set when processing files. 172 | self.is_example = False 173 | 174 | self.errors = [] 175 | self.anim_errors = 0 176 | self.anims = [] 177 | self.anims_by_id = {} 178 | self.anim_id_prefix = "" 179 | self.anim_name_prefix = "" 180 | self.common_tags = [] 181 | 182 | self.fnis_changed = {} 183 | 184 | @classmethod 185 | def load(cls, path): 186 | # Parse the path name to get the category name 187 | # and the main data directory. 188 | src_dir, base = os.path.split(path) 189 | cat_name, ext = os.path.splitext(base) 190 | parts = os.path.abspath(src_dir).split(os.path.sep) 191 | if len(parts) < 2 or parts[-2].lower() != "slanims": 192 | raise Exception(r"source files should be inside an " 193 | r"SLAnims\data directory") 194 | data_dir = os.path.sep.join(parts[:-2]) 195 | 196 | cat = cls(cat_name, path, data_dir) 197 | 198 | try: 199 | cls._load_impl(cat, path) 200 | except Exception as ex: 201 | err_lines = traceback.format_exception(*sys.exc_info()) 202 | cat.errors.extend(err_lines) 203 | return cat 204 | 205 | return cat 206 | 207 | def relpath(self, path): 208 | return os.path.relpath(path, self.data_dir) 209 | 210 | @classmethod 211 | def _load_impl(cls, cat, path): 212 | # Read the source file contents 213 | with open(path, "rb") as f: 214 | data = f.read() 215 | 216 | try: 217 | code = compile(data, path, "exec") 218 | except SyntaxError as ex: 219 | err_lines = traceback.format_exception_only(type(ex), ex) 220 | cat.errors.extend(err_lines) 221 | return 222 | 223 | local_vars = {} 224 | global_vars = { 225 | "Animation": cat.add_anim, 226 | "anim_dir": cat.set_anim_dir, 227 | "anim_id_prefix": cat.set_anim_id_prefix, 228 | "anim_name_prefix": cat.set_anim_name_prefix, 229 | "common_tags": cat.set_common_tags, 230 | "Stage": Stage, 231 | "Female": Female, 232 | "Male": Male, 233 | "CreatureFemale": CreatureFemale, 234 | "CreatureMale": CreatureMale, 235 | "__CATEGORY_FRAME__": None, 236 | } 237 | 238 | for sound in VALID_SOUNDS: 239 | if not sound: 240 | continue 241 | if sound == "none": 242 | global_vars["NoSound"] = sound 243 | else: 244 | global_vars[sound] = sound 245 | for cum_type in CUM_TYPES.keys(): 246 | global_vars[cum_type] = cum_type 247 | exec(code, global_vars, local_vars) 248 | 249 | mcm_name = local_vars.get("mcm_name") 250 | if mcm_name is not None: 251 | cat.mcm_name = mcm_name 252 | 253 | cat.is_example = local_vars.get("is_example", False) 254 | 255 | cat.load_stages() 256 | cat.anim_errors = sum(1 for a in cat.anims if a.errors) 257 | 258 | cat.gen_data() 259 | 260 | def gen_data(self): 261 | self.json = self.gen_json_dict() 262 | self.old_json = self._read_json() 263 | self.fnis_info = self.gen_fnis_lines() 264 | 265 | self._check_fnis_same() 266 | 267 | def _check_fnis_same(self): 268 | fnis_changed = {} 269 | for path, new_lines in self.fnis_info.items(): 270 | try: 271 | with open(path, "r") as f: 272 | old_data = f.read() 273 | except OSError: 274 | old_data = "" 275 | 276 | old_info = self._parse_fnis_lines(old_data.splitlines()) 277 | new_info = self._parse_fnis_lines(new_lines) 278 | if old_info == new_info: 279 | continue 280 | 281 | fnis_changed[path] = (old_info, new_info) 282 | 283 | self.fnis_changed = fnis_changed 284 | 285 | def _parse_fnis_lines(self, lines): 286 | stages = {} 287 | untitled = set() 288 | title = None 289 | cur_stage = [] 290 | 291 | def stage_finished(): 292 | if not cur_stage: 293 | return 294 | if title: 295 | stages[title] = cur_stage 296 | else: 297 | untitled.add(tuple(cur_stage)) 298 | 299 | for line in lines: 300 | l = line.strip() 301 | if not l: 302 | stage_finished() 303 | title = None 304 | continue 305 | if l.startswith("'"): 306 | title = l[1:].strip() 307 | continue 308 | 309 | if l.startswith("s"): 310 | stage_finished() 311 | cur_stage = [l] 312 | if l.startswith("+"): 313 | assert(cur_stage) 314 | cur_stage.append(l) 315 | 316 | stage_finished() 317 | return stages, untitled 318 | 319 | def save_all(self): 320 | self.save_json() 321 | self.save_all_fnis() 322 | 323 | def save_json(self): 324 | try: 325 | os.makedirs(os.path.dirname(self.json_path)) 326 | except OSError as ex: 327 | pass 328 | 329 | with open(self.json_path, "w") as f: 330 | json.dump(self.json, f, indent=2, sort_keys=True) 331 | self.old_json = self.json 332 | 333 | def save_all_fnis(self): 334 | for path, lines in self.fnis_info.items(): 335 | data = "\n".join(lines) + "\n" 336 | with open(path, "w") as f: 337 | f.write(data) 338 | 339 | self.fnis_changed.clear() 340 | 341 | def save_fnis(self, path): 342 | lines = self.fnis_info[path] 343 | data = "\n".join(lines) + "\n" 344 | with open(path, "w") as f: 345 | f.write(data) 346 | 347 | self.fnis_changed.pop(path, None) 348 | 349 | def set_anim_dir(self, path): 350 | self.anim_dir = path 351 | 352 | def add_anim(self, id, name, **kwargs): 353 | full_id = self.anim_id_prefix + id 354 | name = self.anim_name_prefix + name 355 | 356 | anim = AnimInfo(self, full_id, name, **kwargs) 357 | anim.bare_id = id 358 | self.anims.append(anim) 359 | 360 | if anim.id in self.anims_by_id: 361 | anim.error("duplicate animation ID {}", anim.id) 362 | else: 363 | self.anims_by_id[anim.id] = anim 364 | 365 | def set_anim_id_prefix(self, prefix): 366 | self.anim_id_prefix = prefix 367 | 368 | def set_anim_name_prefix(self, prefix): 369 | self.anim_name_prefix = prefix 370 | 371 | def set_common_tags(self, tags): 372 | if isinstance(tags, str): 373 | tags = [t.strip() for t in tags.split(",")] 374 | self.common_tags = tags 375 | 376 | def _read_json(self): 377 | try: 378 | with open(self.json_path, "r") as f: 379 | return json.load(f) 380 | except: 381 | return {} 382 | 383 | def load_stages(self): 384 | dir_caches = {} 385 | for anim in self.anims: 386 | anim.load_stages(dir_caches) 387 | 388 | def gen_json_dict(self): 389 | anims = [] 390 | for anim in self.anims: 391 | anims.append(anim.gen_json_dict()) 392 | 393 | d = { 394 | "name": self.mcm_name, 395 | "animations": anims, 396 | } 397 | return d 398 | 399 | def gen_fnis_lines(self): 400 | lines_by_path = {} 401 | for anim in self.anims: 402 | anim_lines_by_path = anim.gen_fnis_lines() 403 | for path, anim_lines in anim_lines_by_path.items(): 404 | if path not in lines_by_path: 405 | lines_by_path[path] = ["Version V1.0"] 406 | lines = lines_by_path[path] 407 | 408 | lines.append("") 409 | lines.extend(anim_lines) 410 | 411 | return lines_by_path 412 | 413 | def anim_error(self, anim, msg, *args, **kwargs): 414 | if args or kwargs: 415 | msg = msg.format(*args, **kwargs) 416 | 417 | err_lines = [msg + ":"] + self._get_source_stack_info() 418 | anim.errors.extend(err_lines) 419 | 420 | def _get_source_stack_info(self): 421 | # Return the stack frames that are part of the config code rather 422 | # than our code. 423 | f = inspect.currentframe() 424 | stack_info = [] 425 | while f: 426 | if "__LOADER_CODE__" in f.f_globals: 427 | break 428 | stack_info.extend(traceback.extract_stack(f, 1)) 429 | f = f.f_back 430 | 431 | return [l.rstrip() for l in traceback.format_list(stack_info)] 432 | 433 | 434 | class AnimStageFile(object): 435 | def __init__(self, path, anim_id, actor, stage): 436 | self.path = path 437 | self.anim_id = anim_id 438 | self.actor = actor 439 | self.stage = stage 440 | self.used = False 441 | 442 | 443 | class AnimDirCache(object): 444 | """ 445 | AnimDirCache finds all hkx files in a given directory, and parses 446 | name, actor, and stage information out of them. 447 | """ 448 | def __init__(self, path): 449 | self.path = path 450 | 451 | self._by_name = {} 452 | self._load() 453 | 454 | def get_anims(self, *names): 455 | results = [] 456 | for n in names: 457 | anims = self._by_name.get(n.lower()) 458 | if not anims: 459 | continue 460 | results.extend(anims) 461 | return results 462 | 463 | def _load(self): 464 | try: 465 | dir_entries = os.listdir(self.path) 466 | except OSError: 467 | # The directory doesn't exist, or we didn't have permission to read 468 | # it, or some other similar error. 469 | return 470 | 471 | for entry in dir_entries: 472 | base, ext = os.path.splitext(entry) 473 | if ext.lower() != ".hkx": 474 | continue 475 | 476 | m = _ANIM_NAME_REGEX.match(base) 477 | if not m: 478 | continue 479 | 480 | name = m.group("name").lower() 481 | name = name.rstrip("_") 482 | 483 | actor_num = int(m.group("actor")) 484 | 485 | stage_str = m.group("stage") 486 | if stage_str is None: 487 | stage_num = 1 488 | else: 489 | stage_num = int(stage_str) 490 | 491 | name_info = self._by_name.get(name) 492 | if name_info is None: 493 | name_info = [] 494 | self._by_name[name] = name_info 495 | 496 | entry_path = os.path.join(self.path, entry) 497 | anim_info = AnimStageFile(entry_path, name, actor_num, stage_num) 498 | name_info.append(anim_info) 499 | 500 | 501 | class AnimInfo(object): 502 | def __init__(self, cat, id, name, **kwargs): 503 | self.category = cat 504 | self.errors = [] 505 | 506 | self.id = id 507 | self.name = name 508 | self.anim_dir = cat.anim_dir 509 | self.sound = kwargs.pop("sound", None) 510 | if self.sound is None: 511 | # TODO: This is okay if a sound is explicitly specified 512 | # for each stage 513 | self.error("no animation sound specified") 514 | elif self.sound not in VALID_SOUNDS: 515 | self.error("invalid sound {!r}: must be one of {}", 516 | self.sound, ", ".join(VALID_SOUNDS)) 517 | 518 | # Parse tags 519 | tags_arg = kwargs.pop("tags", None) 520 | self.tags = cat.common_tags[:] 521 | if tags_arg is None: 522 | if not cat.common_tags: 523 | self.error("no animation tags specified") 524 | else: 525 | self.tags.extend(self._parse_tags(tags_arg)) 526 | 527 | # Parse actors 528 | self.creature_race = None 529 | self.actors = [] 530 | missing_actors = [] 531 | for n in range(1, MAX_ACTORS + 1): 532 | arg_name = "actor{}".format(n) 533 | stage_arg_name = "a{}_stage_params".format(n) 534 | info = kwargs.pop(arg_name, None) 535 | stage_params = kwargs.pop(stage_arg_name, None) 536 | if info is None: 537 | missing_actors.append(arg_name) 538 | if stage_params is not None: 539 | self.error("cannot specify {} without {}", 540 | stage_arg_name, arg_name) 541 | continue 542 | if missing_actors: 543 | # This happens if there is a missing actor. 544 | # e.g., actor1 and actor3 were specified, but not actor2 545 | self.error("cannot specify {} without {}", 546 | arg_name, ", ".join(missing_actors)) 547 | continue 548 | 549 | actor_info = ActorInfo(self, n, info, stage_params) 550 | if actor_info.creature_race is not None: 551 | self.creature_race = actor_info.creature_race 552 | self.actors.append(actor_info) 553 | 554 | if not self.actors: 555 | self.error("must include at least one actor") 556 | 557 | # Parse stage_params 558 | stage_params_arg = kwargs.pop("stage_params", None) 559 | self.stage_params = self._parse_stage_params(stage_params_arg) 560 | 561 | if kwargs: 562 | self.error("unsupported arguments: {}", "," .join(kwargs.keys())) 563 | 564 | def error(self, msg, *args, **kwargs): 565 | if args or kwargs: 566 | msg = msg.format(*args, **kwargs) 567 | 568 | stack_info = self.category._get_source_stack_info() 569 | if stack_info: 570 | self.errors.append(msg + ":") 571 | self.errors.extend(" " + line for line in stack_info) 572 | else: 573 | self.errors.append(msg) 574 | 575 | def _parse_tags(self, tags): 576 | if isinstance(tags, (list, tuple)): 577 | return list(tags) 578 | if isinstance(tags, str): 579 | return [t.strip() for t in tags.split(",")] 580 | 581 | raise Exception("bad tags value: must be a list or string") 582 | 583 | def _parse_stage_params(self, stage_params): 584 | VALID_ARGS = { 585 | "sound": str, 586 | "timer": float, 587 | } 588 | parsed = _parse_stage_params(stage_params, VALID_ARGS, 589 | on_error=self.error) 590 | # Validate the sound types 591 | for sp in parsed.values(): 592 | sp_sound = sp.get("sound") 593 | if sp_sound is not None and sp_sound not in VALID_SOUNDS: 594 | self.error("invalid sound {!r}: must be one of {}", 595 | self.sound, ", ".join(VALID_SOUNDS)) 596 | 597 | return parsed 598 | 599 | def load_stages(self, dir_caches): 600 | if not self.actors: 601 | return 602 | 603 | for actor in self.actors: 604 | actor.find_anim_files(dir_caches) 605 | 606 | num_stages = len(self.actors[0].stage_anims) 607 | bad = False 608 | stage_by_actor = [("A1", num_stages)] 609 | for idx, actor in enumerate(self.actors[1:]): 610 | n = len(actor.stage_anims) 611 | stage_by_actor.append(("A{}".format(idx + 2), n)) 612 | if n != num_stages: 613 | bad = True 614 | num_stages = max(n, num_stages) 615 | if bad: 616 | self.error("all actors must have the same number of " 617 | "animation stages: {}", stage_by_actor) 618 | 619 | for stage_num in self.stage_params.keys(): 620 | if stage_num <= 0 or stage_num > num_stages: 621 | self.error("invalid stage number {} in stage_params", 622 | stage_num) 623 | 624 | def gen_json_dict(self): 625 | actor_data = [] 626 | for actor in self.actors: 627 | actor_data.append(actor.gen_json_dict()) 628 | 629 | d = { 630 | "id": self.id, 631 | "name": self.name, 632 | } 633 | if self.errors: 634 | d["error"] = "\n".join(self.errors) 635 | return d 636 | 637 | d.update(tags=",".join(self.tags), 638 | sound=self.sound, 639 | actors=actor_data) 640 | if self.creature_race: 641 | d["creature_race"] = self.creature_race 642 | if self.stage_params: 643 | sp = [] 644 | for stage_num, info in self.stage_params.items(): 645 | json_info = info.copy() 646 | json_info['number'] = stage_num 647 | sp.append(json_info) 648 | d["stages"] = sp 649 | return d 650 | 651 | def gen_fnis_lines(self): 652 | lines_by_path = {} 653 | for actor in self.actors: 654 | fnis_path = actor.get_fnis_list_path() 655 | if fnis_path not in lines_by_path: 656 | lines = ["' {}".format(self.id)] 657 | lines_by_path[fnis_path] = lines 658 | else: 659 | lines = lines_by_path[fnis_path] 660 | 661 | lines.extend(actor.gen_fnis_lines()) 662 | 663 | return lines_by_path 664 | 665 | 666 | class ActorInfo(object): 667 | ACTOR_STAGE_ARGS = { 668 | "forward": float, 669 | "up": float, 670 | "side": float, 671 | "rotate": float, 672 | "silent": bool, 673 | "open_mouth": bool, 674 | "strap_on": bool, 675 | "sos": int, 676 | } 677 | 678 | def __init__(self, anim, number, info, stage_params): 679 | self.anim = anim 680 | self.number = number 681 | 682 | # This will eventually be set to an array containing the animation path 683 | # for each animation stage. 684 | self.stage_anims = None 685 | 686 | self.creature_race = None 687 | self.anim_race_dir = None 688 | 689 | # Check to make sure the argument is a valid Actor type 690 | if not hasattr(info, "ALLOW_CUM"): 691 | self.error("invalid actor type") 692 | # We shouldn't try generating JSON for this animation 693 | # due to the error, but set a default type just in case. 694 | self.type = Male 695 | return 696 | self.type = type(info).__name__ 697 | 698 | # Allow users to pass in an Actor class instead of an instance object. 699 | # This makes it okay to use "Male" instead of "Male()" when there 700 | # aren't any extra arguments that need to be specified. 701 | kwargs = getattr(info, "kwargs", {}) 702 | 703 | if getattr(info, "IS_CREATURE", False): 704 | self.creature_race = kwargs.pop("race", None) 705 | if self.creature_race is None: 706 | self.error("race argument must be given for " 707 | "creature actor types") 708 | elif self.creature_race.lower() not in KNOWN_RACES_LOWER.keys(): 709 | # This is not necessarily an error, since other mods 710 | # can register creature race IDs. However, it does 711 | # mean we probably won't know where to look for the 712 | # animation files. 713 | self.warning("unknown creature race {!r}: " 714 | "did you mean one of {}", 715 | self.creature_race, ", ".join(KNOWN_RACES)) 716 | 717 | self.anim_race_dir = kwargs.pop("anim_race_dir", None) 718 | 719 | self.cum = None 720 | cum_type = kwargs.pop("add_cum", None) 721 | if cum_type is not None: 722 | self.cum = CUM_TYPES_LOWER.get(cum_type.lower()) 723 | if self.cum is None: 724 | self.error("invalid cum type {!r}: must be one of {}", 725 | cum_type, ", ".join(CUM_TYPES.keys())) 726 | 727 | self.object_name = kwargs.pop("object", None) 728 | 729 | self.stage_defaults = _parse_stage_args(kwargs, self.ACTOR_STAGE_ARGS, 730 | on_error=self.error) 731 | if kwargs: 732 | self.error("unsupported arguments: {}", ", ".join(kwargs.keys())) 733 | 734 | self.stage_params = self._parse_stage_params(stage_params) 735 | 736 | def _parse_stage_params(self, stage_params): 737 | def on_error(msg, *args, **kwargs): 738 | if args or kwargs: 739 | msg = msg.format(*args, **kwargs) 740 | self.error("a{}_stage_params: {}", self.number, msg) 741 | 742 | parsed = _parse_stage_params(stage_params, self.ACTOR_STAGE_ARGS, 743 | on_error=on_error) 744 | return parsed 745 | 746 | def get_anim_dir(self): 747 | data_dir = self.anim.category.data_dir 748 | race_dir = self._get_race_dir() 749 | return os.path.join(data_dir, "meshes", "actors", race_dir, 750 | "animations", self.anim.anim_dir) 751 | 752 | def find_anim_files(self, dir_caches): 753 | anim_dir = self.get_anim_dir() 754 | 755 | dir_cache = dir_caches.get(anim_dir.lower()) 756 | if dir_cache is None: 757 | dir_cache = AnimDirCache(anim_dir) 758 | dir_caches[anim_dir.lower()] = dir_cache 759 | 760 | # Since the animation directory already include mod information, 761 | # allow the animation files to not include the ID prefix. 762 | # 763 | # e.g. even though we may add an "FB_" prefix to all animation IDs 764 | # in the FunnyBizness category, allow 765 | # actors/character/animations/FunnyBizness/HardcoreDoggy_A1_S1.hkx 766 | # in addition to 767 | # actors/character/animations/FunnyBizness/FB_HardcoreDoggy_A1_S1.hkx 768 | by_stage = {} 769 | anims = dir_cache.get_anims(self.anim.id, self.anim.bare_id) 770 | for info in anims: 771 | if info.actor != self.number: 772 | continue 773 | 774 | if info.stage in by_stage: 775 | self.error("found multiple animations for stage {}:\n" 776 | " - {}\n" 777 | " - {}", 778 | info.stage, by_stage[info.stage], info.path) 779 | by_stage[info.stage] = info.path 780 | 781 | expected_next = 1 782 | stages = [] 783 | for n in sorted(by_stage.keys()): 784 | if n != expected_next: 785 | self.error("no animation found for stage {}", expected_next) 786 | self.stage_anims = [] 787 | return 788 | expected_next += 1 789 | stages.append(by_stage[n]) 790 | 791 | if not stages: 792 | self.error("no animations found: expected animations " 793 | "at {}\\{}_A{}_S1.hkx", 794 | anim_dir, self.anim.id, self.number) 795 | 796 | self.stage_anims = stages 797 | 798 | def get_fnis_list_path(self): 799 | fnis_dir = self.get_anim_dir() 800 | race_name = fnis_dir.split(os.path.sep)[-3] 801 | if race_name.lower() == "character": 802 | entry = "FNIS_{}_List.txt".format(self.anim.anim_dir) 803 | else: 804 | entry = "FNIS_{}_{}_List.txt".format(self.anim.anim_dir, race_name) 805 | return os.path.join(fnis_dir, entry) 806 | 807 | def _get_race_dir(self): 808 | if self.anim_race_dir is not None: 809 | return self.anim_race_dir 810 | 811 | if self.creature_race is None: 812 | return "character" 813 | 814 | race_dir = KNOWN_RACES_LOWER.get(self.creature_race.lower()) 815 | if race_dir is None: 816 | self.error("unable to find animation race directory for unknown " 817 | "race {}. You can add an anim_race_dir parameter to " 818 | "{}.actor{} to tell us where to find it for now", 819 | self.creature_race, self.anim.id, self.number) 820 | 821 | return race_dir.replace("/", os.path.sep) 822 | 823 | def gen_json_dict(self): 824 | stages = [] 825 | for idx, sanim in enumerate(self.stage_anims): 826 | stage_num = idx + 1 827 | anim_id = "{}_A{}_S{}".format(self.anim.id, self.number, stage_num) 828 | s = {"id": anim_id} 829 | s.update(self.stage_defaults) 830 | if stage_num in self.stage_params: 831 | s.update(self.stage_params[stage_num]) 832 | 833 | stages.append(s) 834 | 835 | d = { 836 | "type": self.type, 837 | "stages": stages, 838 | } 839 | if self.cum is not None: 840 | d["add_cum"] = self.cum 841 | if self.creature_race is not None: 842 | d["race"] = self.creature_race 843 | return d 844 | 845 | def gen_fnis_lines(self): 846 | object_arg = "" 847 | object_suffix = "" 848 | if self.object_name: 849 | object_arg = " -o" 850 | object_suffix = " " + self.object_name 851 | 852 | lines = [] 853 | for idx, sanim in enumerate(self.stage_anims): 854 | stage_num = idx + 1 855 | if idx == 0: 856 | prefix = "s" 857 | else: 858 | prefix = "+" 859 | 860 | filename = os.path.basename(sanim) 861 | line = "{}{} {}_A{}_S{} {}{}".format( 862 | prefix, object_arg, self.anim.id, self.number, stage_num, 863 | filename, object_suffix) 864 | lines.append(line) 865 | 866 | return lines 867 | 868 | def error(self, msg, *args, **kwargs): 869 | if args or kwargs: 870 | msg = msg.format(*args, **kwargs) 871 | self.anim.error("actor {}: {}", self.number, msg) 872 | 873 | def warning(self, msg, *args, **kwargs): 874 | if args or kwargs: 875 | msg = msg.format(*args, **kwargs) 876 | print("warning: actor {}: {}", self.number, msg) 877 | 878 | 879 | def _parse_stage_params(stage_params, valid_args, on_error): 880 | if not stage_params: 881 | return {} 882 | 883 | parsed = {} 884 | for sp in stage_params: 885 | if not isinstance(sp, Stage): 886 | on_error("expected a Stage() object") 887 | continue 888 | stage_info = _parse_stage_args(sp.kwargs, valid_args, on_error) 889 | if not stage_info: 890 | # This is just a sanity check. There's no point specifying 891 | # Stage() with no arguments other than a number. 892 | on_error("empty stage parameters for stage {}", sp.number) 893 | parsed[sp.number] = stage_info 894 | if sp.kwargs: 895 | on_error("unsupported arguments: {}", ", ".join(kwargs.keys())) 896 | 897 | return parsed 898 | 899 | 900 | def _parse_stage_args(kwargs, valid_args, on_error): 901 | d = {} 902 | for name, type in valid_args.items(): 903 | if name not in kwargs: 904 | continue 905 | value = kwargs.pop(name) 906 | if type == float: 907 | # Also allow integers in float fields 908 | allowed_types = (float, int) 909 | else: 910 | allowed_types = type 911 | if not isinstance(value, allowed_types): 912 | on_error("invalid value for stage param {!r}: " 913 | "got {!r}, expected a {}", 914 | name, value, type.__name__) 915 | d[name] = value 916 | return d 917 | 918 | 919 | def _preformat_json_for_diff(data): 920 | # Replace the animation list with a map of anim_id --> anim_info. 921 | # This makes the JSON diff output much nicer to read when an animation is 922 | # added or removed. Rather than showing diff info for everything being 923 | # shifted up or down, this causes us to show info only for the 924 | # added/removed animations. 925 | result = data.copy() 926 | anim_map = {} 927 | for anim in data["animations"]: 928 | anim_map[anim["id"]] = anim 929 | result["animations"] = anim_map 930 | return result 931 | 932 | 933 | def _format_json_diff(old, new): 934 | lines = [] 935 | 936 | def do_diff(ov, nv, path=""): 937 | if ov == nv: 938 | return 939 | if isinstance(ov, dict) and isinstance(nv, dict): 940 | print_dict_diff(ov, nv, path) 941 | elif isinstance(ov, list) and isinstance(nv, list): 942 | print_list_diff(ov, nv, path) 943 | else: 944 | lines.append("{}: {!r} vs {!r}".format(path, ov, nv)) 945 | 946 | import itertools 947 | def print_list_diff(od, nd, path=""): 948 | for idx, (ov, nv) in enumerate(itertools.zip_longest(od, nd)): 949 | v_path = "{}[{}]".format(path, idx) 950 | do_diff(ov, nv, v_path) 951 | 952 | def print_dict_diff(od, nd, path=""): 953 | for k, ov in od.items(): 954 | nv = nd.get(k) 955 | if path: 956 | v_path = "{}.{}".format(path, k) 957 | else: 958 | v_path = k 959 | do_diff(ov, nv, v_path) 960 | 961 | for k, nv in nd.items(): 962 | if k in od: 963 | continue 964 | if path: 965 | v_path = "{}.{}".format(path, k) 966 | else: 967 | v_path = k 968 | do_diff(None, nv, v_path) 969 | 970 | do_diff(old, new) 971 | return lines 972 | 973 | 974 | class NoDataDirError(Exception): 975 | def __init__(self, path): 976 | msg = "cannot find data directory from {}".format(path) 977 | super().__init__(msg) 978 | self.path = path 979 | 980 | 981 | def is_data_dir(path): 982 | src_dir = os.path.join(path, "SLAnims", "source") 983 | if os.path.isdir(src_dir): 984 | return True 985 | 986 | 987 | def get_data_dir(path): 988 | cur_path = os.path.abspath(path) 989 | while True: 990 | if is_data_dir(cur_path): 991 | return cur_path 992 | parent = os.path.dirname(cur_path) 993 | if cur_path == parent: 994 | raise NoDataDirError(path) 995 | cur_path = parent 996 | 997 | 998 | def find_data_dir(): 999 | # try in the current directory first 1000 | search_list = [ 1001 | os.getcwd(), 1002 | os.path.dirname(__file__), 1003 | ] 1004 | for path in search_list: 1005 | try: 1006 | return get_data_dir(path) 1007 | except NoDataDirError: 1008 | continue 1009 | 1010 | raise Exception("unable to find Skyrim Data/ directory") 1011 | 1012 | 1013 | # 1014 | # GUI handling 1015 | # 1016 | # We use tkinter simply because it's shipped with Python by default. 1017 | # It looks like crap, but users won't need to install any additional packages. 1018 | # 1019 | 1020 | import tkinter 1021 | import tkinter.filedialog 1022 | import tkinter.messagebox 1023 | import tkinter.scrolledtext 1024 | 1025 | 1026 | class GUI(object): 1027 | def __init__(self, master, prefs_path): 1028 | self.master = master 1029 | self.prefs_path = prefs_path 1030 | 1031 | self.first_focus = True 1032 | self.master.bind("", self.on_focus) 1033 | 1034 | # TODO: It would be nice to save the window size in the prefs, 1035 | # and start with the size loaded from prefs. 1036 | self.prefs = self._load_prefs() 1037 | 1038 | self.data_dir = tkinter.StringVar() 1039 | dd = self.prefs.get("data_dir") 1040 | if not dd: 1041 | dd = find_data_dir() 1042 | self.data_dir.set(dd) 1043 | 1044 | self.categories = [] 1045 | self._init_window() 1046 | self._load_categories() 1047 | 1048 | def _init_window(self): 1049 | self.master.title("SexLab Animation Loader") 1050 | 1051 | frame = tkinter.Frame(self.master) 1052 | frame.pack(side=tkinter.TOP, fill=tkinter.BOTH) 1053 | label = tkinter.Label(frame, text="Data directory:", 1054 | justify=tkinter.LEFT) 1055 | label.pack(side=tkinter.LEFT, fill=tkinter.X) 1056 | data_dir_entry = tkinter.Entry(frame, textvariable=self.data_dir) 1057 | data_dir_entry.pack(side=tkinter.LEFT, fill=tkinter.BOTH, expand=True) 1058 | self.browse = tkinter.Button(frame, text="Browse", 1059 | command=self.on_browse) 1060 | self.browse.pack(side=tkinter.LEFT) 1061 | 1062 | # The category frame 1063 | frame = tkinter.Frame(self.master) 1064 | frame.pack(side=tkinter.TOP, fill=tkinter.X) 1065 | label = tkinter.Label(frame, text="Categories:", justify=tkinter.LEFT) 1066 | label.pack(side=tkinter.LEFT, fill=tkinter.X) 1067 | frame = tkinter.Frame() 1068 | frame.pack(side=tkinter.TOP, fill=tkinter.BOTH, expand=True) 1069 | scroll = tkinter.Scrollbar(frame, orient=tkinter.VERTICAL) 1070 | self.cat_list = tkinter.Listbox(frame, height=5, 1071 | exportselection=0, 1072 | yscrollcommand=scroll.set) 1073 | self.cat_list.bind("<>", self.on_cat_select) 1074 | self.cat_list.pack(side=tkinter.LEFT, fill=tkinter.BOTH, expand=True) 1075 | scroll.pack(side=tkinter.RIGHT, fill=tkinter.Y) 1076 | scroll.config(command=self.cat_list.yview) 1077 | 1078 | # The animation frame 1079 | frame = tkinter.Frame(self.master) 1080 | frame.pack(side=tkinter.TOP, fill=tkinter.X) 1081 | label = tkinter.Label(frame, text="Animations:", justify=tkinter.LEFT) 1082 | label.pack(side=tkinter.LEFT, fill=tkinter.X) 1083 | frame = tkinter.Frame() 1084 | frame.pack(side=tkinter.TOP, fill=tkinter.BOTH, expand=True) 1085 | scroll = tkinter.Scrollbar(frame, orient=tkinter.VERTICAL) 1086 | self.anim_list = tkinter.Listbox(frame, height=10, 1087 | exportselection=0, 1088 | yscrollcommand=scroll.set) 1089 | self.anim_list.bind("<>", self.on_anim_select) 1090 | self.anim_list.pack(side=tkinter.LEFT, fill=tkinter.BOTH, expand=True) 1091 | scroll.pack(side=tkinter.RIGHT, fill=tkinter.Y) 1092 | scroll.config(command=self.anim_list.yview) 1093 | 1094 | # The log frame 1095 | frame = tkinter.Frame(self.master) 1096 | frame.pack(side=tkinter.TOP, fill=tkinter.X) 1097 | label = tkinter.Label(frame, text="Log:", justify=tkinter.LEFT) 1098 | label.pack(side=tkinter.LEFT, fill=tkinter.X) 1099 | frame = tkinter.Frame() 1100 | frame.pack(side=tkinter.TOP, fill=tkinter.BOTH, expand=True) 1101 | scroll = tkinter.Scrollbar(frame, orient=tkinter.VERTICAL) 1102 | scrollx = tkinter.Scrollbar(frame, orient=tkinter.HORIZONTAL) 1103 | self.log = tkinter.Listbox(frame, width=80, height=10, 1104 | xscrollcommand=scrollx.set, 1105 | yscrollcommand=scroll.set) 1106 | self.log.config(font="Courier 10") 1107 | scrollx.pack(side=tkinter.BOTTOM, fill=tkinter.X) 1108 | self.log.pack(side=tkinter.LEFT, fill=tkinter.BOTH, expand=True) 1109 | scroll.pack(side=tkinter.RIGHT, fill=tkinter.Y) 1110 | scroll.config(command=self.log.yview) 1111 | scrollx.config(command=self.log.xview) 1112 | 1113 | frame = tkinter.Frame(self.master) 1114 | frame.pack(side=tkinter.TOP, fill=tkinter.X, pady=5) 1115 | pad = tkinter.Frame(frame) 1116 | pad.pack(side=tkinter.LEFT, fill=tkinter.X, expand=True) 1117 | self.reload = tkinter.Button(frame, text="Reload", 1118 | command=self.on_reload) 1119 | self.reload.pack(side=tkinter.LEFT, padx=5) 1120 | self.build_all = tkinter.Button(frame, text="Build All Categories", 1121 | command=self.on_build_all) 1122 | self.build_all.pack(side=tkinter.LEFT, padx=5) 1123 | self.build_one = tkinter.Button(frame, text="Build Category", 1124 | command=self.on_build_one, 1125 | state=tkinter.DISABLED) 1126 | self.build_one.pack(side=tkinter.LEFT, padx=5) 1127 | self.quit = tkinter.Button(frame, text="Exit", command=frame.quit) 1128 | self.quit.pack(side=tkinter.LEFT, padx=5) 1129 | pad = tkinter.Frame(frame) 1130 | pad.pack(side=tkinter.LEFT, fill=tkinter.X, expand=True) 1131 | 1132 | def on_focus(self, event): 1133 | if event.widget != self.master: 1134 | return 1135 | # on_focus() will be called once when the window is initially drawn. 1136 | # Don't bother reloading the data then. 1137 | if self.first_focus: 1138 | self.first_focus = False 1139 | return 1140 | 1141 | # Reload the JSON data whenever the main window gets focus again. 1142 | # This just makes things easier when switching back and forth 1143 | # between a text editor working on the sources and the generator: the 1144 | # generator will automatically reload the most recent changes from the 1145 | # editor. 1146 | self._load_categories() 1147 | 1148 | def on_browse(self): 1149 | dd = self.data_dir.get() 1150 | result = tkinter.filedialog.askdirectory(initialdir=dd) 1151 | if not result: 1152 | return 1153 | 1154 | # tkinter appears to always return POSIX style paths, even on Windows. 1155 | # Convert it back to to the native path format. 1156 | result = os.path.sep.join(result.split("/")) 1157 | 1158 | self.data_dir.set(result) 1159 | self._load_categories() 1160 | if not self.categories: 1161 | msg = "No categories found in {}".format(dd) 1162 | tkinter.messagebox.showwarning(title="Warning", message=msg) 1163 | else: 1164 | # Update the preferences whenever a valid-looking directory 1165 | # is selected. 1166 | self.prefs["data_dir"] = result 1167 | self._save_prefs() 1168 | 1169 | def _selection_info(self): 1170 | # Selection is a tuple of selected elements, 1171 | # which in our case should always be just 1, or possibly 0 1172 | cat_sel = self.cat_list.curselection() 1173 | if not cat_sel: 1174 | return None, None 1175 | cat = self.categories[cat_sel[0]] 1176 | 1177 | anim_sel = self.anim_list.curselection() 1178 | if not anim_sel: 1179 | return cat, None 1180 | anim = cat.anims[anim_sel[0]] 1181 | return cat, anim 1182 | 1183 | def on_cat_select(self, event): 1184 | self.anim_list.selection_clear(0, tkinter.END) 1185 | self._clear_log() 1186 | cat, anim = self._selection_info() 1187 | if cat is None: 1188 | self.build_one.config(state=tkinter.DISABLED) 1189 | return 1190 | 1191 | self._select_cat(cat) 1192 | 1193 | def _select_cat(self, cat): 1194 | self._log("=== Category Info: {} ===", cat.name) 1195 | for error in cat.errors: 1196 | self._log(str(error)) 1197 | for anim in cat.anims: 1198 | if anim.errors: 1199 | self._log("Errors in \"{}\"", anim.name) 1200 | 1201 | self.anim_list.delete(0, tkinter.END) 1202 | for anim in cat.anims: 1203 | msg = anim.name 1204 | if anim.errors: 1205 | msg += " (HAS ERRORS)" 1206 | self.anim_list.insert(tkinter.END, msg) 1207 | 1208 | if cat.errors or cat.anim_errors: 1209 | self.build_one.config(state=tkinter.DISABLED) 1210 | return 1211 | 1212 | self.build_one.config(state=tkinter.NORMAL) 1213 | 1214 | if cat.json == cat.old_json: 1215 | self._log("JSON output up-to-date: {}", cat.relpath(cat.json_path)) 1216 | elif not cat.old_json: 1217 | self._log("JSON needs to be generated: {}", 1218 | cat.relpath(cat.json_path)) 1219 | else: 1220 | self._log("JSON needs to be regenerated: {}", 1221 | cat.relpath(cat.json_path)) 1222 | 1223 | # Munge the JSON so the diff is easier to read. 1224 | # Replace animation indices with animation IDs 1225 | old_json = _preformat_json_for_diff(cat.old_json) 1226 | new_json = _preformat_json_for_diff(cat.json) 1227 | 1228 | lines = _format_json_diff(old_json, new_json) 1229 | for l in lines: 1230 | self._log(" " + l) 1231 | 1232 | if not cat.fnis_changed: 1233 | self._log("All FNIS lists up-to-date:") 1234 | else: 1235 | self._log("{} FNIS list(s) need rebuilding", len(cat.fnis_changed)) 1236 | for path in sorted(cat.fnis_info): 1237 | if path in cat.fnis_changed: 1238 | self._log("- Needs update: {}", cat.relpath(path)) 1239 | else: 1240 | self._log("- Up-to-date: {}", cat.relpath(path)) 1241 | 1242 | def on_anim_select(self, event): 1243 | self._clear_log() 1244 | cat, anim = self._selection_info() 1245 | if cat is None: 1246 | return 1247 | 1248 | if cat.errors or anim.errors: 1249 | self._log("=== Errors ===") 1250 | 1251 | for error in cat.errors: 1252 | self._log(str(error)) 1253 | for error in anim.errors: 1254 | self._log(str(error)) 1255 | 1256 | if cat.errors or anim.errors: 1257 | return 1258 | 1259 | self._log("=== Animation Status: {} ===", anim.name) 1260 | 1261 | fnis_status = None 1262 | for actor in anim.actors: 1263 | fnis_path = actor.get_fnis_list_path() 1264 | fnis_mod_info = cat.fnis_changed.get(fnis_path) 1265 | if fnis_mod_info is None: 1266 | # This FNIS file had no modifications 1267 | continue 1268 | old_info, new_info = fnis_mod_info 1269 | if anim.id not in old_info[0]: 1270 | fnis_status = "new" 1271 | break 1272 | if old_info[0][anim.id] != new_info[0][anim.id]: 1273 | fnis_status = "modified" 1274 | break 1275 | 1276 | self._add_anim_json_status_log(cat, anim) 1277 | 1278 | if fnis_status == "new": 1279 | self._log("Not yet present in FNIS list, needs rebuild") 1280 | elif fnis_status == "modified": 1281 | self._log("FNIS list info has changed and needs to be rebuilt") 1282 | else: 1283 | self._log("FNIS list info for this animation is up-to-date") 1284 | 1285 | def _add_anim_json_status_log(self, cat, anim): 1286 | if not cat.old_json or not cat.old_json.get("animations"): 1287 | self._log("Not yet present in JSON file, needs rebuild") 1288 | return 1289 | 1290 | old_json_anim = None 1291 | for a in cat.old_json["animations"]: 1292 | if a.get("id") == anim.id: 1293 | old_json_anim = a 1294 | break 1295 | if old_json_anim is None: 1296 | self._log("Not yet present in JSON file, needs rebuild") 1297 | return 1298 | 1299 | new_json_anim = None 1300 | for a in cat.json["animations"]: 1301 | if a.get("id") == anim.id: 1302 | new_json_anim = a 1303 | break 1304 | 1305 | if new_json_anim != old_json_anim: 1306 | self._log("Animation modified and JSON data needs rebuild") 1307 | else: 1308 | self._log("JSON data for this animation is up-to-date") 1309 | 1310 | def on_reload(self): 1311 | self._load_categories() 1312 | 1313 | def on_build_all(self): 1314 | cat, anim = self._selection_info() 1315 | 1316 | # Reload categories before doing anything, just in case the 1317 | # source files have changed. 1318 | self._load_categories() 1319 | 1320 | modified_files = [] 1321 | self._clear_log() 1322 | self._log("=== Build Logs ===") 1323 | try: 1324 | for cat in self.categories: 1325 | cat_modified = self._build_category(cat) 1326 | modified_files.extend(cat_modified) 1327 | except: 1328 | self._log_exc() 1329 | 1330 | self._check_fnis_changed(modified_files) 1331 | 1332 | # Redisplay the categories 1333 | self._redisplay_categories(clear_log=False) 1334 | 1335 | def on_build_one(self): 1336 | cat, anim = self._selection_info() 1337 | if cat is None: 1338 | # This shouldn't happen since we have the button disabled 1339 | msg = "No category selected" 1340 | tkinter.messagebox.showwarning(title="Warning", message=msg) 1341 | return 1342 | 1343 | # Reload the category, just in case the source has changed 1344 | cat_idx = self.cat_list.curselection()[0] 1345 | cat = Category.load(cat.src_path) 1346 | self.categories[cat_idx] = cat 1347 | 1348 | if cat.errors or cat.anim_errors: 1349 | self._redisplay_categories(cat.name) 1350 | msg = "Errors found in updated source" 1351 | tkinter.messagebox.showerror(title="Error", message=msg) 1352 | return 1353 | 1354 | self._clear_log() 1355 | self._log("=== Build Logs ===") 1356 | # Save the new build data 1357 | try: 1358 | modified_files = self._build_category(cat) 1359 | except: 1360 | self._log_exc() 1361 | 1362 | self._check_fnis_changed(modified_files) 1363 | self._redisplay_categories(cat.name, clear_log=False) 1364 | 1365 | def _check_fnis_changed(self, modified_files): 1366 | fnis_changed = False 1367 | for path in modified_files: 1368 | if path.lower().endswith('.txt'): 1369 | fnis_changed = True 1370 | break 1371 | if fnis_changed: 1372 | self._log("!! Remember to re-run GenerateFNISforModders.exe !!") 1373 | 1374 | def _log_exc(self): 1375 | for line in traceback.format_exception(*sys.exc_info()): 1376 | self._log(line.rstrip()) 1377 | 1378 | def _log(self, msg, *args, **kwargs): 1379 | if args or kwargs: 1380 | msg = msg.format(*args, **kwargs) 1381 | for line in msg.splitlines(): 1382 | self.log.insert(tkinter.END, line) 1383 | 1384 | def _clear_log(self): 1385 | self.log.delete(0, tkinter.END) 1386 | 1387 | def _build_category(self, cat): 1388 | if cat.errors or cat.anim_errors: 1389 | self._log("{}: skipping due to source errors", cat.name) 1390 | return [] 1391 | 1392 | modified_files = [] 1393 | if cat.json == cat.old_json: 1394 | self._log("{}: JSON already up-to-date", cat.name) 1395 | else: 1396 | cat.save_json() 1397 | modified_files.append(cat.json_path) 1398 | self._log("{}: updated JSON file {}", cat.name, cat.json_path) 1399 | 1400 | if not cat.fnis_changed: 1401 | self._log("{}: FNIS lists already up-to-date", cat.name) 1402 | for path in sorted(cat.fnis_changed.keys()): 1403 | cat.save_fnis(path) 1404 | modified_files.append(path) 1405 | self._log("{}: updated FNIS list {}", cat.name, path) 1406 | 1407 | return modified_files 1408 | 1409 | def _load_categories(self): 1410 | old_cat, old_anim = self._selection_info() 1411 | old_cat_name = old_cat.name if old_cat else None 1412 | 1413 | self.cat_list.delete(0, tkinter.END) 1414 | self.anim_list.delete(0, tkinter.END) 1415 | self._clear_log() 1416 | 1417 | src_dir = os.path.join(self.data_dir.get(), "SLAnims", "source") 1418 | self.categories = [] 1419 | try: 1420 | dir_entries = os.listdir(src_dir) 1421 | except OSError: 1422 | return 1423 | 1424 | new_cat_idx = None 1425 | for entry in dir_entries: 1426 | if not _is_source_file(entry): 1427 | continue 1428 | 1429 | entry_path = os.path.join(src_dir, entry) 1430 | cat = Category.load(entry_path) 1431 | if cat.is_example: 1432 | continue 1433 | if cat.name == old_cat_name: 1434 | new_cat_idx = len(self.categories) 1435 | self.categories.append(cat) 1436 | self._display_cat(cat) 1437 | 1438 | # If there is only one category, select it. 1439 | if new_cat_idx is None and len(self.categories) == 1: 1440 | new_cat_idx = 0 1441 | 1442 | if new_cat_idx != None: 1443 | self.cat_list.selection_set(new_cat_idx) 1444 | self._select_cat(self.categories[new_cat_idx]) 1445 | 1446 | def _redisplay_categories(self, old_cat_name=None, clear_log=True): 1447 | self.cat_list.delete(0, tkinter.END) 1448 | self.anim_list.delete(0, tkinter.END) 1449 | if clear_log: 1450 | self._clear_log() 1451 | 1452 | new_cat_idx = None 1453 | for idx, cat in enumerate(self.categories): 1454 | self._display_cat(cat) 1455 | if cat.name == old_cat_name: 1456 | new_cat_idx = idx 1457 | 1458 | # If there is only one category, select it. 1459 | if new_cat_idx is None and len(self.categories) == 1: 1460 | new_cat_idx = 0 1461 | 1462 | if new_cat_idx != None: 1463 | self.cat_list.selection_set(new_cat_idx) 1464 | self._select_cat(self.categories[new_cat_idx]) 1465 | 1466 | def _display_cat(self, cat): 1467 | entry = cat.name 1468 | if cat.errors: 1469 | entry += " (HAS ERRORS)" 1470 | if cat.anim_errors: 1471 | s = "" if cat.anim_errors == 1 else "S" 1472 | entry += " ({} ANIMATION ERROR{})".format(cat.anim_errors, s) 1473 | 1474 | if not cat.errors and not cat.anim_errors: 1475 | if cat.json != cat.old_json or cat.fnis_changed: 1476 | entry += " (NEEDS BUILD)" 1477 | self.cat_list.insert(tkinter.END, entry) 1478 | 1479 | def _load_prefs(self): 1480 | try: 1481 | with open(self.prefs_path, "r") as f: 1482 | return json.load(f) 1483 | except: 1484 | return {} 1485 | 1486 | def _save_prefs(self): 1487 | d = json.dumps(self.prefs, indent=True, sort_keys=True, 1488 | ensure_ascii=False) 1489 | with open(self.prefs_path, "w") as f: 1490 | f.write(d) 1491 | 1492 | 1493 | def _is_source_file(entry): 1494 | if entry.endswith(".py"): 1495 | return True 1496 | 1497 | # Allow files ending in ".txt" too. 1498 | # This is to avoid confusion if users have configured ".py" files to be 1499 | # executed with python by default. Users will generally want to edit these 1500 | # files, not run them directly with python. 1501 | if entry.endswith(".txt"): 1502 | return True 1503 | return False 1504 | 1505 | 1506 | def process_dir(path): 1507 | path = get_data_dir(path) 1508 | src_dir = os.path.join(path, "SLAnims", "source") 1509 | for entry in os.listdir(src_dir): 1510 | if not _is_source_file(entry): 1511 | continue 1512 | 1513 | entry_path = os.path.join(src_dir, entry) 1514 | print("Processing {}".format(entry)) 1515 | cat = Category.load(entry_path) 1516 | if cat.is_example: 1517 | print("skipping example entry") 1518 | continue 1519 | cat.save_all() 1520 | 1521 | 1522 | def process_path(path): 1523 | cat = Category.load(path) 1524 | cat.save_all() 1525 | 1526 | 1527 | def main(): 1528 | ap = argparse.ArgumentParser() 1529 | ap.add_argument("-d", "--data-dir", 1530 | help="The path to the Skyrim Data/ directory, " 1531 | "or to a mod directory") 1532 | ap.add_argument("-p", "--preferences", 1533 | help="The path to the preferences file") 1534 | ap.add_argument("paths", nargs="*", 1535 | help="Specific source files to process") 1536 | args = ap.parse_args() 1537 | 1538 | prefs_path = args.preferences 1539 | if prefs_path is None: 1540 | prefs_path = os.path.expanduser(r"~\AppData\Local\Skyrim" 1541 | r"\SLAnimLoader.json") 1542 | 1543 | if args.paths: 1544 | for p in args.paths: 1545 | if os.path.isdir(p): 1546 | process_dir(p) 1547 | else: 1548 | process_file(p) 1549 | return 1550 | 1551 | root = tkinter.Tk() 1552 | GUI(root, prefs_path) 1553 | root.mainloop() 1554 | 1555 | 1556 | if __name__ == "__main__": 1557 | main() 1558 | -------------------------------------------------------------------------------- /SLAnims/source/Example.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This directory should contain the *.txt files describing your animation 3 | # metadata. 4 | # 5 | # Only files ending in *.txt or *.py will be processed by SLAnimGenerate.pyw 6 | # This file serves to document the file format. The following line tells 7 | # SLAnimGenerate.pyw to ignore this file (since it is just an example and 8 | # there aren't any real animation files for it). 9 | # !! If you copy Example.txt to start your own animation pack, make sure to 10 | # remove the following line, or SLAnimGenerate.pyw will ignore your new file !! 11 | is_example = True 12 | 13 | # 14 | # Lines starting with "#" are comments 15 | # 16 | 17 | # The name to use when displaying this animation category in the 18 | # mod configuration menu (MCM). 19 | # 20 | # If not specified, it will be taken from the source file name 21 | # (e.g. "YourAnimations.py" --> "YourAnimations") 22 | mcm_name = "Super Cool" 23 | 24 | # This optional setting specifies what subdirectory the animations 25 | # can be found in. Animations should be located at 26 | # Data\meshes\actors\character\animations\\ 27 | # 28 | # If not specified, it will be taken from the source file name 29 | # (e.g. "YourAnimations.py" --> "YourAnimations") 30 | anim_dir("SuperCool") 31 | 32 | # Prepend "SC_" to all animation IDs in this file. 33 | # This helps make sure animation IDs from this file won't conflict 34 | # with IDs from other categories. 35 | # 36 | # Note that anim_dir(), anim_id_prefix(), anim_name_prefix(), and common_tags() 37 | # only affects Animations defined below them in the file. 38 | # (You can call them again later if you want to change the settings for 39 | # subsequent animations defined after the new calls.) 40 | anim_id_prefix("SC_") 41 | 42 | # Prepend "Super Cool " to all the animations defined after this point 43 | anim_name_prefix("Super Cool ") 44 | 45 | # Add "SuperCool" to the tags for all animations defined after this point 46 | common_tags("SuperCool") 47 | 48 | # Animation fields 49 | # - id: 50 | # The internal ID to use for registering the animation with SexLab. 51 | # This ID must match the animation file names in the 52 | # meshes/actors/.../animations directories. 53 | # For example, if the id is "MyCoolAnimation", the animation file names 54 | # should be MyCoolAnimation_A1_S1.hkx, MyCoolAnimation_A1_S2.hkx, etc. 55 | # 56 | # - name 57 | # The animation name that will be displayed to users in SexLab and MCM menus 58 | # 59 | # - tags 60 | # Tags defined for this animation. 61 | # 62 | # - sound 63 | # The sound effect to use for this animation: 64 | # Allowed values: 65 | # - Squishing 66 | # - Squirting 67 | # - Sucking 68 | # - SexMix 69 | # - NoSound 70 | # 71 | # - actor1, actor2, actor3, actor4, actor5 72 | # Actor definitions. Possible values: 73 | # - Male, Female, CreatureMale, CreatureFemale 74 | # 75 | # Female and CreatureFemale actors accept an add_cum argument describing what 76 | # cum settings should be applied for this animation. Allowed add_cum values: 77 | # - Vaginal, Oral, Anal, VaginalOral, VaginalAnal, OralAnal, VaginalOralAnal 78 | # 79 | # CreatureMale and CreatureFemale actors require a "race" argument specifying 80 | # the creature race. Examples of valid races include Draugrs, Horses, 81 | # SabreCats, Trolls, etc. 82 | # 83 | # Actor objects can also accept any stage parameter applicable to the 84 | # a1_stage_params arguments below. Stage parameters specified in the actor 85 | # object will be applied to all of that actor's stages, unless overridden by 86 | # an specific stage parameter in aX_stage_params. 87 | # 88 | # - a1_stage_params, a2_stage_params, etc. 89 | # 90 | # Parameters for specific stage animations for the specified actor. 91 | # For example, a1_stage_params=[Stage(2, silent=true)] 92 | # specifies that actor1 should be silent during stage 2. 93 | # 94 | # If you want a setting to apply to all stages for an actor, you can also 95 | # pass the setting as an argument to the Actor object. For instance, 96 | # Male(silent=True) will make that actor silent for all stages (except stages 97 | # that are explicitly overridden with a Stage parameter setting silent=False 98 | # again for that stage). 99 | # 100 | # Stage parameters: 101 | # - forward : Move the actor forward by the specified amount 102 | # - side : Move the actor to the side by the specified amount 103 | # - up : Move the actor up by the specified amount 104 | # - rotate : Rotate the actor by the given number of degrees 105 | # - silent : True / False 106 | # - open_mouth : True / False 107 | # - sos : integer setting 108 | # 109 | 110 | Animation( 111 | id="SuckAndFuck", 112 | name="Suck and Fuck", 113 | tags="Dirty,Sex,Oral,Vaginal,MF", 114 | sound=Squishing, 115 | actor1=Female(add_cum=VaginalOral), 116 | actor2=Male(), 117 | a1_stage_params = [ 118 | Stage(1, silent=True, open_mouth=True), 119 | Stage(2, silent=True, open_mouth=True), 120 | ], 121 | stage_params = [ 122 | # Stage 1 should be 15 seconds long, and use the "Sucking" sound 123 | Stage(1, timer=15.0, sound=Sucking), 124 | # Stage 2 should be 10 seconds long 125 | Stage(2, timer=10.0), 126 | ] 127 | ) 128 | 129 | Animation( 130 | id="PilloryDoggy", 131 | name="Pillory Doggy", 132 | tags="Sex,Dirty,Furniture,AnimObject,Vaginal,Doggy,Doggystyle,MF", 133 | sound=Squishing, 134 | actor1=Female(add_cum=Vaginal), 135 | actor2=Male(object="AOZaZPunishmentPillory"), 136 | ) 137 | 138 | Animation( 139 | id="DraugrSex", 140 | name="Draugr Sex", 141 | tags="Necro,Forced,Creature,Draugr,Bestiality,Dirty,Aggressive,AggressiveDefault,Rough,Vaginal,Doggy", 142 | sound="Squishing", 143 | actor1=Female(add_cum=Vaginal), 144 | actor2=CreatureMale(race="Draugrs"), 145 | ) 146 | -------------------------------------------------------------------------------- /Scripts/Source/slalAnimationFactory.psc: -------------------------------------------------------------------------------- 1 | Scriptname slalAnimationFactory extends sslAnimationFactory 2 | ; 3 | ; This module provides functionality very similar to sslAnimationFactory, but 4 | ; tweaked to support the needs of SLAnimLoader. In particular, it supports: 5 | ; - Registering multiple animations with a single callback, whose name does 6 | ; not need to match the animation ID. 7 | ; - Registering both character and creature animations in a single script. 8 | ; 9 | 10 | sslCreatureAnimationSlots property CreatureSlots auto hidden 11 | 12 | ; Prepare the factory 13 | function PrepareFactory() 14 | parent.PrepareFactory() 15 | if !CreatureSlots 16 | CreatureSlots = Game.GetFormFromFile(0x664FB, "SexLab.esm") as sslCreatureAnimationSlots 17 | endIf 18 | endFunction 19 | 20 | ; Similar to sslAnimationFactory.RegisterAnimation(), but accepts an extra 21 | ; parameter indicating the name of a callback function to invoke. If 22 | ; specified, this callback will be invoked with the Registrar argument. 23 | ; This makes it possible to use a single callback function with many different 24 | ; animations. 25 | ; 26 | ; It also accepts the slots to use as a parameter as well (normal or creature slots). 27 | bool function RegisterAnimationCB(sslAnimationSlots animSlots, string Registrar, string Callback = "") 28 | ; Get free Animation slot 29 | int id = animSlots.Register(Registrar) 30 | if id != -1 31 | InitAnimSlot(animSlots, id, Registrar, Callback) 32 | endIf 33 | return (id != -1) 34 | endFunction 35 | 36 | function InitAnimSlot(sslAnimationSlots animSlots, int id, string Registrar, string Callback) 37 | ; Init slot 38 | sslBaseAnimation Slot = animSlots.GetBySlot(id) 39 | Slot.Initialize() 40 | Slot.Registry = Registrar 41 | Slot.Enabled = true 42 | ; Send load event 43 | int eid = ModEvent.Create(Registrar) 44 | ModEvent.PushInt(eid, id) 45 | if Callback 46 | RegisterForModEvent(Registrar, Callback) 47 | ModEvent.PushString(eid, Registrar) 48 | else 49 | RegisterForModEvent(Registrar, Registrar) 50 | endIf 51 | ModEvent.Send(eid) 52 | endFunction 53 | 54 | sslBaseAnimation function CreateInSlots(sslAnimationSlots animSlots, int id) 55 | sslBaseAnimation Slot = animSlots.GetbySlot(id) 56 | UnregisterForModEvent(Slot.Registry) 57 | return Slot 58 | endFunction 59 | -------------------------------------------------------------------------------- /Scripts/Source/slalData.psc: -------------------------------------------------------------------------------- 1 | Scriptname slalData Hidden 2 | 3 | function debugMsg(string msg) global 4 | Debug.Trace("SLAL: " + msg) 5 | endFunction 6 | 7 | function warningMsg(string msg) global 8 | Debug.Trace("SLAL warning: " + msg) 9 | endFunction 10 | 11 | function errorMsg(string msg) global 12 | Debug.Trace("SLAL error: " + msg) 13 | Debug.Notification("SLAL error: " + msg) 14 | endFunction 15 | 16 | ; Returns a JMap of {Category Name -> JArray of animID strings} 17 | int function getCategories() global 18 | int categories = JDB.solveObj(".SLAL.categories") 19 | if categories == 0 20 | reloadData() 21 | categories = JDB.solveObj(".SLAL.categories") 22 | endIf 23 | 24 | return categories 25 | endFunction 26 | 27 | ; Returns a JArray of animID strings for the specified category 28 | int function getCategoryAnims(string cat) global 29 | return JMap.getObj(getCategories(), cat) 30 | endFunction 31 | 32 | ; Returns a JMap of {AnimID -> Animation Info} 33 | int function getAnimations() global 34 | int anims = JDB.solveObj(".SLAL.animations") 35 | if anims == 0 36 | reloadData() 37 | anims = JDB.solveObj(".SLAL.animations") 38 | endIf 39 | 40 | return anims 41 | endFunction 42 | 43 | ; Get the JMap containing the animation info for a given animation ID 44 | int function getAnimInfo(string animID) global 45 | int anims = getAnimations() 46 | return JMap.getObj(anims, animID) 47 | endFunction 48 | 49 | ; Returns a JMap of {animID --> bool} 50 | int function getEnableState() global 51 | int enableState = JDB.solveObj(".SLAL.enableState") 52 | if enableState == 0 53 | enableState = JMap.object() 54 | JDB.solveObjSetter(".SLAL.enableState", enableState, true) 55 | endIf 56 | 57 | return enableState 58 | endFunction 59 | 60 | ; Unload the JSON data 61 | ; This makes sure that it will be reloaded the next time is is needed 62 | function unloadData() global 63 | ; Reset the category and animation data 64 | ; We intentionally leave enableState as-is, since this is not 65 | ; loaded from a file on disk. 66 | JDB.solveObjSetter(".SLAL.categories", 0, true) 67 | JDB.solveObjSetter(".SLAL.animations", 0, true) 68 | endFunction 69 | 70 | int function reloadData() global 71 | debugMsg("reloading data") 72 | int data = JValue.readFromDirectory("Data/SLAnims/json", ".json") 73 | JValue.retain(data) 74 | 75 | int categories = JValue.retain(JMap.object()) 76 | int anims = JValue.retain(JMap.object()) 77 | 78 | int catInfo 79 | int numErrors = 0 80 | string catName 81 | string path = JMap.nextKey(data) 82 | while path 83 | catInfo = JMap.getObj(data, path) 84 | numErrors += loadCategory(path, catInfo, categories, anims) 85 | path = JMap.nextKey(data, path) 86 | endwhile 87 | 88 | JDB.solveObjSetter(".SLAL.categories", categories, true) 89 | JDB.solveObjSetter(".SLAL.animations", anims, true) 90 | 91 | ; Release the json objects we retained above. 92 | ; (We retain them just in case loading the data somehow takes a very long 93 | ; time, and JContainers expires them before we can put them in the JDB.) 94 | JValue.release(categories) 95 | JValue.release(anims) 96 | JValue.release(data) 97 | 98 | debugMsg("loaded " + JMap.count(categories) + " JSON files") 99 | debugMsg("found " + numErrors + " animations with errors in the JSON data") 100 | return numErrors 101 | endFunction 102 | 103 | int function loadCategory(string path, int catInfo, int categories, int allAnims) global 104 | string catName = JMap.getStr(catInfo, "name") 105 | if catName == "" 106 | errorMsg("unable to load " + path + ": no name field") 107 | return 1 108 | endIf 109 | 110 | int catAnimIDs = JValue.retain(JArray.object()) 111 | int catAnims = JMap.getObj(catInfo, "animations") 112 | int numAnims = JArray.count(catAnims) 113 | int n = 0 114 | int numErrors = 0 115 | while n < numAnims 116 | int animInfo = JArray.getObj(catAnims, n) 117 | if loadAnimation(path, n, animInfo, allAnims) 118 | string animID = JMap.getStr(animInfo, "id") 119 | JArray.addStr(catAnimIDs, animID) 120 | 121 | if JMap.getStr(animInfo, "error") != "" 122 | numErrors += 1 123 | endIf 124 | else 125 | ; Missing ID or name, so we have to completely ignore this animation 126 | numErrors += 1 127 | endIf 128 | 129 | n += 1 130 | endWhile 131 | 132 | JMap.setObj(categories, catName, catAnimIDs) 133 | JValue.release(catAnimIDs) 134 | debugMsg("loaded category " + catName + ": " + numAnims + " animations") 135 | return numErrors 136 | endFunction 137 | 138 | bool function loadAnimation(string path, int animIndex, int animInfo, int allAnims) global 139 | string animID = JMap.getStr(animInfo, "id") 140 | if !animID 141 | errorMsg(path + " animation " + animIndex + ": missing id") 142 | return false 143 | endIf 144 | 145 | string animName = JMap.getStr(animInfo, "name") 146 | if !animName 147 | errorMsg(path + " animation " + animIndex + ": missing name") 148 | return false 149 | endIf 150 | 151 | ; The generator script may already include error text 152 | string error = JMap.getStr(animInfo, "error") 153 | if !animName 154 | return true 155 | endIf 156 | 157 | error = processAnimation(path, animID, animInfo) 158 | ; As long as we have the name and ID, we still add the animation info 159 | ; to allAnims, even if an error occurred. It will still show up in the 160 | ; configuration menu, but it will be disabled and have error info 161 | ; attached to it. 162 | JMap.setStr(animInfo, "error", error) 163 | JMap.setObj(allAnims, animID, animInfo) 164 | return true 165 | endFunction 166 | 167 | string function addWarning(int animInfo, string warning) global 168 | string allWarn = JMap.getStr(animInfo, "warning") 169 | if allWarn 170 | allWarn += "; " + warning 171 | else 172 | allWarn = warning 173 | endIf 174 | JMap.setStr(animInfo, "warning", allWarn) 175 | endFunction 176 | 177 | string function processAnimation(string path, string animID, int animInfo) global 178 | int actors = JMap.getObj(animInfo, "actors") 179 | if actors == 0 180 | return "missing actors" 181 | endIf 182 | 183 | int numActors = JArray.count(actors) 184 | if numActors < 1 185 | return "must have at least 1 actor" 186 | endIf 187 | 188 | ; TODO: make sure all actors have the same number of stages 189 | 190 | ; TODO: Make sure creature_race is set (and is valid) 191 | ; if any of the actors are a creature type 192 | 193 | ; TODO: check to see if the sound is valid 194 | ; TODO: make sure other fields have valid values 195 | 196 | return "" 197 | endFunction 198 | -------------------------------------------------------------------------------- /Scripts/Source/slalLoader.psc: -------------------------------------------------------------------------------- 1 | Scriptname slalLoader extends slalAnimationFactory 2 | 3 | slalMCM Property Config Auto 4 | 5 | function verboseMsg(string msg) 6 | if Config.verboseLogs 7 | slalData.debugMsg(msg) 8 | endIf 9 | endFunction 10 | 11 | function debugMsg(string msg) 12 | slalData.debugMsg(msg) 13 | endFunction 14 | 15 | function warningMsg(string msg) 16 | slalData.warningMsg(msg) 17 | endFunction 18 | 19 | function OnLoad() 20 | debugMsg("SLAL: OnLoad") 21 | RegisterForModEvent("SexLabSlotAnimations", "registerAnimations") 22 | 23 | ; After any game load, make sure we re-read JSON data the next time it is 24 | ; needed. (Don't bother re-reading it now, since we won't actually need it 25 | ; unless the MCM menu is opened.) 26 | slalData.unloadData() 27 | endFunction 28 | 29 | ; Register all enabled animations 30 | int function registerAnimations() 31 | debugMsg("SLAL: registering animations") 32 | PrepareFactory() 33 | 34 | int enableState = slalData.getEnableState() 35 | int anims = slalData.getAnimations() 36 | string animID = JMap.nextKey(anims) 37 | int numRegistered = 0 38 | while animID 39 | if registerAnimIfEnabled(animID, anims, enableState) 40 | numRegistered += 1 41 | endIf 42 | 43 | animID = JMap.nextKey(anims, animID) 44 | endWhile 45 | 46 | debugMsg("SLAL: finished registering " + numRegistered + " animations") 47 | return numRegistered 48 | endFunction 49 | 50 | ; Register the enabled animations from a specific category 51 | ; Other categories are ignored, even if they have enabled but not yet 52 | ; registered animations. 53 | int function registerCategoryAnimations(string catName) 54 | debugMsg("SLAL: registering " + catName + " animations") 55 | PrepareFactory() 56 | 57 | int enableState = slalData.getEnableState() 58 | int anims = slalData.getAnimations() 59 | int catAnims = slalData.getCategoryAnims(catName) 60 | int numAnims = JArray.count(catAnims) 61 | 62 | int n = 0 63 | int numRegistered = 0 64 | while n < numAnims 65 | string animID = JArray.getStr(catAnims, n) 66 | if registerAnimIfEnabled(animID, anims, enableState) 67 | numRegistered += 1 68 | endIf 69 | 70 | n += 1 71 | endWhile 72 | 73 | debugMsg("SLAL: finished registering " + numRegistered + " animations") 74 | return numRegistered 75 | endFunction 76 | 77 | function updateJsonSettings() 78 | int anims = slalData.getAnimations() 79 | string animID = JMap.nextKey(anims) 80 | while animID 81 | sslAnimationSlots animSlots = getSlotsByAnimID(animID) 82 | int sexlabID = animSlots.FindByRegistrar(animID) 83 | if sexlabID != -1 84 | sslBaseAnimation anim = animSlots.GetBySlot(sexlabID) 85 | anim.Initialize() 86 | anim.Registry = animId 87 | anim.Enabled = true 88 | 89 | InitAnimSlot(animSlots, sexlabID, animId, "OnRegisterAnim") 90 | endIf 91 | 92 | animID = JMap.nextKey(anims, animID) 93 | endWhile 94 | endFunction 95 | 96 | bool function registerAnimIfEnabled(string animID, int anims, int enableState) 97 | bool enabled = JMap.getInt(enableState, animID) as bool 98 | if !enabled 99 | return false 100 | endIf 101 | 102 | int animInfo = JMap.getObj(anims, animID) 103 | sslAnimationSlots animSlots = getSlots(animInfo) 104 | return RegisterAnimationCB(animSlots, animID, "OnRegisterAnim") 105 | endFunction 106 | 107 | ; Get the correct sslAnimationSlots to use for the specified animation 108 | sslAnimationSlots function getSlots(int animInfo) 109 | bool isCreature = JMap.hasKey(animInfo, "creature_race") 110 | if isCreature 111 | return CreatureSlots 112 | endIf 113 | return Slots 114 | endFunction 115 | 116 | sslAnimationSlots function getSlotsByAnimID(string animID) 117 | int animInfo = slalData.getAnimInfo(animID) 118 | return getSlots(animInfo) 119 | endFunction 120 | 121 | bool function isRegistered(string animID) 122 | sslAnimationSlots animSlots = getSlotsByAnimID(animID) 123 | return animSlots.IsRegistered(animID) 124 | endFunction 125 | 126 | function OnRegisterAnim(int id, string animID) 127 | verboseMsg("registering animation: " + animID) 128 | 129 | int animInfo = slalData.getAnimInfo(animID) 130 | 131 | sslBaseAnimation anim = CreateInSlots(getSlots(animInfo), id) 132 | anim.Name = JMap.getStr(animInfo, "name") 133 | anim.SoundFX = getSound(animInfo) 134 | verboseMsg(" anim = " + anim) 135 | verboseMsg(" Name = " + anim.Name) 136 | verboseMsg(" SoundFX = " + anim.SoundFX) 137 | 138 | int actors = JMap.getObj(animInfo, "actors") 139 | int numActors = JArray.count(actors) 140 | int n = 0 141 | while n < numActors 142 | int actorInfo = JArray.getObj(actors, n) 143 | addActorInfo(anim, animInfo, actorInfo) 144 | n += 1 145 | endWhile 146 | 147 | int stages = JMap.getObj(animInfo, "stages") 148 | int numStages = JArray.count(stages) 149 | n = 0 150 | while n < numStages 151 | int stageInfo = JArray.getObj(stages, n) 152 | addStageInfo(anim, stageInfo) 153 | 154 | n += 1 155 | endWhile 156 | 157 | ; TODO: SetBedOffsets(float forward, float sideward, float upward, float rotate) 158 | 159 | string tags = JMap.getStr(animInfo, "tags") 160 | verboseMsg(" Tags = " + anim.SoundFX) 161 | anim.SetTags(tags) 162 | 163 | anim.Save(id) 164 | endFunction 165 | 166 | function addActorInfo(sslBaseAnimation anim, int animInfo, int actorInfo) 167 | int actorID = addActorPosition(anim, animInfo, actorInfo) 168 | 169 | int stages = JMap.getObj(actorInfo, "stages") 170 | int numStages = JArray.count(stages) 171 | int n = 0 172 | while n < numStages 173 | int stageInfo = JArray.getObj(stages, n) 174 | addActorStage(anim, actorID, stageInfo) 175 | n += 1 176 | endWhile 177 | endFunction 178 | 179 | function addActorStage(sslBaseAnimation anim, int actorID, int stageInfo) 180 | string eventID = JMap.getStr(stageInfo, "id") 181 | float forward = JMap.getFlt(stageInfo, "forward") 182 | float side = JMap.getFlt(stageInfo, "side") 183 | float up = JMap.getFlt(stageInfo, "up") 184 | float rotate = JMap.getFlt(stageInfo, "rotate") 185 | bool silent = JMap.getInt(stageInfo, "silent") as bool 186 | bool openMouth = JMap.getInt(stageInfo, "open_mouth") as bool 187 | bool strapOn = JMap.getInt(stageInfo, "strap_on") as bool 188 | int sos = JMap.getInt(stageInfo, "sos") 189 | 190 | verboseMsg(" + " + anim + ".AddPositionStage(" + actorID + ", " + eventID + ",") 191 | verboseMsg(" forward=" + forward + ", side=" + side + ", up=" + up + ", rotate=" + rotate + ",") 192 | verboseMsg(" silent=" + silent + ", openmouth=" + openmouth + ", strapon=" + strapon + ", sos=" + sos + ")") 193 | anim.AddPositionStage(actorID, eventID, forward=forward, side=side, up=up, rotate=rotate, silent=silent, openmouth=openmouth, strapon=strapOn, sos=sos) 194 | 195 | ; TODO: SetStageCumID(int Position, int Stage, int CumID, int CumSource = -1) 196 | endFunction 197 | 198 | function addStageInfo(sslBaseAnimation anim, int stageInfo) 199 | int stageNum = JMap.getInt(stageInfo, "number") 200 | 201 | if JMap.hasKey(stageInfo, "sound") 202 | string soundName = JMap.getStr(stageInfo, "sound") 203 | verboseMsg(" SetStageSoundFX(" + stageNum + ", " + soundName + ")") 204 | anim.SetStageSoundFX(stageNum, getSoundByName(soundName)) 205 | endIf 206 | if JMap.hasKey(stageInfo, "timer") 207 | float timer = JMap.getFlt(stageInfo, "timer") 208 | verboseMsg(" SetStageTimer(" + stageNum + ", " + timer + ")") 209 | anim.SetStageTimer(stageNum, timer) 210 | endIf 211 | endFunction 212 | 213 | Sound function getSound(int animInfo) 214 | string soundName = JMap.getStr(animInfo, "sound") 215 | return getSoundByName(soundName) 216 | endFunction 217 | 218 | Sound function getSoundByName(string soundName) 219 | if !soundName || soundName == "none" 220 | return none 221 | elseIf soundName == "Squishing" 222 | return Squishing 223 | elseIf soundName == "Sucking" 224 | return Sucking 225 | elseIf soundName == "SexMix" 226 | return SexMix 227 | elseIf soundName == "Squirting" 228 | return Squirting 229 | endIf 230 | 231 | warningMsg("unrecognized sound '" + soundName + "'") 232 | return none 233 | endFunction 234 | 235 | int function addActorPosition(sslBaseAnimation anim, int animInfo, int actorInfo) 236 | string type = JMap.getStr(actorInfo, "type") 237 | string creatureRace 238 | int cum 239 | 240 | if type == "Male" 241 | verboseMsg(" AddPosition(Male)") 242 | return anim.AddPosition(Male) 243 | elseIf type == "Female" 244 | cum = getActorCum(actorInfo) 245 | verboseMsg(" AddPosition(Female, addCum=" + cum + ")") 246 | return anim.AddPosition(Female, addCum=cum) 247 | elseIf type == "Creature" 248 | creatureRace = JMap.getStr(actorInfo, "race") 249 | verboseMsg(" AddCreaturePosition(" + creatureRace + ", Creature)") 250 | return anim.AddCreaturePosition(creatureRace, Creature) 251 | elseIf type == "CreatureMale" 252 | creatureRace = JMap.getStr(actorInfo, "race") 253 | anim.GenderedCreatures = true 254 | verboseMsg(" AddCreaturePosition(" + creatureRace + ", CreatureMale)") 255 | return anim.AddCreaturePosition(creatureRace, CreatureMale) 256 | elseIf type == "CreatureFemale" 257 | creatureRace = JMap.getStr(actorInfo, "race") 258 | anim.GenderedCreatures = true 259 | cum = getActorCum(actorInfo) 260 | verboseMsg(" AddCreaturePosition(" + creatureRace + ", CreatureFemale, addCum=" + cum + ")") 261 | return anim.AddCreaturePosition(creatureRace, CreatureFemale, AddCum=cum) 262 | endIf 263 | 264 | warningMsg("unrecognized actor type '" + type + "'") 265 | return anim.AddPosition(Male) 266 | endFunction 267 | 268 | int function getActorCum(int actorInfo) 269 | ; Try the field as an integer first 270 | int cum = JMap.getInt(actorInfo, "add_cum", -99) 271 | if cum != -99 272 | return cum 273 | endIf 274 | 275 | string name = JMap.getStr(actorInfo, "add_cum") 276 | if name == "" || name == "none" 277 | return -1 278 | elseIf name == "Vaginal" 279 | return Vaginal 280 | elseIf name == "Oral" 281 | return Oral 282 | elseIf name == "Anal" 283 | return Anal 284 | elseIf name == "VaginalOral" 285 | return VaginalOral 286 | elseIf name == "VaginalAnal" 287 | return VaginalAnal 288 | elseIf name == "OralAnal" 289 | return OralAnal 290 | elseIf name == "VaginalOralAnal" 291 | return VaginalOralAnal 292 | endIf 293 | 294 | warningMsg("unrecognized add_cum value '" + name + "'") 295 | return -1 296 | endFunction 297 | -------------------------------------------------------------------------------- /Scripts/Source/slalMCM.psc: -------------------------------------------------------------------------------- 1 | Scriptname slalMCM extends SKI_ConfigBase 2 | {MCM Menu for SLAnimLoader} 3 | 4 | slalLoader Property Loader Auto 5 | SexLabFramework Property SexLab Auto 6 | 7 | bool Property verboseLogs = false Auto 8 | 9 | ; A JMap of {MCM option ID -> Anim ID string} 10 | ; This is only valid within a single animation page. We rebuild it each 11 | ; time an animation page is opened. 12 | int optionIDs = 0 13 | 14 | function debugMsg(string msg) 15 | slalData.debugMsg(msg) 16 | endFunction 17 | 18 | int function GetVersion() 19 | return 1 20 | endFunction 21 | 22 | event OnConfigOpen() 23 | ; Reload the JSON data automatically each time the MCM is opened 24 | slalData.reloadData() 25 | 26 | Pages = getPageNames() 27 | optionIDs = JValue.retain(JMap.object()) 28 | endEvent 29 | 30 | event OnConfigClose() 31 | JValue.release(optionIDs) 32 | endEvent 33 | 34 | event OnPageReset(string page) 35 | SetCursorFillMode(LEFT_TO_RIGHT) 36 | if page == "" 37 | ; Note that one call to OnPageReset("") is made before OnConfigOpen() 38 | ; runs. Therefore this page shouldn't do anything that needs data set 39 | ; up by OnConfigOpen(). 40 | AddHeaderOption("$SLAL_ModName") 41 | return 42 | endIf 43 | 44 | if page == Pages[0] 45 | AddHeaderOption("$SLAL_GeneralOptions") 46 | AddHeaderOption("") 47 | 48 | AddTextOptionST("EnableAll", "$SLAL_EnableAll", "$SLAL_ClickHere") 49 | AddTextOptionST("DisableAll", "$SLAL_DisableAll", "$SLAL_ClickHere") 50 | AddTextOptionST("RegisterAnims", "$SLAL_RegisterAnimations", "$SLAL_ClickHere") 51 | AddTextOptionST("ReloadJSON", "$SLAL_ReloadJSON", "$SLAL_ClickHere") 52 | AddTextOptionST("RebuildAnimRegistry", "$SLAL_ResetAnimationRegistry", "$SLAL_ClickHere") 53 | AddTextOptionST("ReapplyJSON", "$SLAL_ReapplyJSON", "$SLAL_ClickHere") 54 | AddTextOptionST("AnimationCount", "$SLAL_CountAnimations", "$SLAL_ClickHere") 55 | AddToggleOptionST("VerboseLogs", "$SLAL_VerboseLogs", verboseLogs) 56 | return 57 | endIf 58 | 59 | AddTextOptionST("EnableAll", "$SLAL_EnableAll", "$SLAL_ClickHere") 60 | AddTextOptionST("DisableAll", "$SLAL_DisableAll", "$SLAL_ClickHere") 61 | AddTextOptionST("RegisterAnims", "$SLAL_RegisterAnimations", "$SLAL_ClickHere") 62 | AddEmptyOption() 63 | AddHeaderOption("$SLAL_Animations") 64 | AddHeaderOption("") 65 | 66 | int enableState = slalData.getEnableState() 67 | 68 | ; Must call Loader.PrepareFactory() before checking if animations are 69 | ; registered or not. 70 | Loader.PrepareFactory() 71 | 72 | JMap.clear(optionIDs) 73 | int anims = slalData.getAnimations() 74 | int catAnims = slalData.getCategoryAnims(page) 75 | int numAnims = JArray.count(catAnims) 76 | int n = 0 77 | while n < numAnims 78 | string animID = JArray.getStr(catAnims, n) 79 | int animInfo = JMap.getObj(anims, animID) 80 | addAnimationToggle(animInfo, enableState) 81 | n += 1 82 | endWhile 83 | endEvent 84 | 85 | string[] function getPageNames() 86 | int cats = slalData.getCategories() 87 | int catNames = JArray.sort(JMap.allKeys(cats)) 88 | 89 | int numCats = JArray.count(catNames); 90 | string[] pageNames = PapyrusUtil.StringArray(numCats + 1) 91 | pageNames[0] = "$SLAL_GeneralOptions" 92 | int n = 0; 93 | while n < numCats 94 | pageNames[n + 1] = JArray.getStr(catNames, n) 95 | n += 1 96 | endWhile 97 | return pageNames 98 | endFunction 99 | 100 | function addAnimationToggle(int animInfo, int enableState) 101 | string animName = JMap.getStr(animInfo, "name") 102 | string animID = JMap.getStr(animInfo, "id") 103 | bool enabled = JMap.getInt(enableState, animID, 0) 104 | 105 | int optID 106 | int flags = OPTION_FLAG_NONE 107 | string error = JMap.getStr(animInfo, "error") 108 | if error != "" 109 | ; If we add a disabled toggle option then OnOptionHighlight() never 110 | ; gets called, and we can't show error info in the bottom option text. 111 | ; Threfore use a text option instead. 112 | optID = AddTextOption(animName, "X") 113 | else 114 | optID = AddToggleOption(animName, enabled) 115 | endIf 116 | 117 | JMap.setStr(optionIDs, optID, animID) 118 | endFunction 119 | 120 | int function getAnimInfoFromOptionID(int mcmOptionID) 121 | ; Look up the animID string in the optionIDs map, then look up the animInfo 122 | ; from that. 123 | ; (We could directly store animInfo ID in the optionIDs map, but it seems 124 | ; like then it would be easier to accidentally have subtle bugs if the JSON data 125 | ; is ever reloaded after building the optionIDs map.) 126 | string animID = JMap.getStr(optionIDs, mcmOptionID) 127 | return slalData.getAnimInfo(animID) 128 | endFunction 129 | 130 | event OnOptionSelect(int optID) 131 | int enableState = slalData.getEnableState() 132 | string animID = JMap.getStr(optionIDs, optID) 133 | if !animID 134 | return 135 | endIf 136 | 137 | bool enabled = JMap.getInt(enableState, animID, 0) as bool 138 | 139 | enabled = !enabled 140 | JMap.setInt(enableState, animID, enabled as int) 141 | 142 | SetToggleOptionValue(optID, enabled) 143 | endEvent 144 | 145 | event OnOptionHighlight(int optID) 146 | string animID = JMap.getStr(optionIDs, optID) 147 | if !animID 148 | return 149 | endIf 150 | 151 | int animInfo = slalData.getAnimInfo(animID) 152 | string error = JMap.getStr(animInfo, "error") 153 | if error != "" 154 | SetInfoText("Error" + " " + error) 155 | return 156 | endIf 157 | 158 | bool registered = Loader.isRegistered(animID) 159 | string animTags = JMap.getStr(animInfo, "tags") 160 | 161 | string msg = "Registered: " + registered 162 | msg += "\nTags: " + animTags 163 | SetInfoText(msg) 164 | endEvent 165 | 166 | state RegisterAnims 167 | event OnSelectST() 168 | SetOptionFlagsST(OPTION_FLAG_DISABLED) 169 | SetTextOptionValueST("$SLAL_Registering") 170 | 171 | int numRegistered 172 | if CurrentPage == Pages[0] 173 | numRegistered = Loader.registerAnimations() 174 | else 175 | numRegistered = Loader.registerCategoryAnimations(CurrentPage) 176 | ; Redraw the page, so the toggles will correctly reflect the registration state 177 | ForcePageReset() 178 | endIf 179 | 180 | SetTextOptionValueST("$SLAL_ClickHere") 181 | SetOptionFlagsST(OPTION_FLAG_NONE) 182 | ShowMessage("Registered " + numRegistered + " new animations", false) 183 | endEvent 184 | 185 | event OnHighlightST() 186 | SetInfoText("$SLAL_RegisterAnimationsInfo") 187 | endEvent 188 | endState 189 | 190 | state AnimationCount 191 | event OnSelectST() 192 | int enableState = slalData.getEnableState() 193 | int anims = slalData.getAnimations() 194 | string animID = JMap.nextKey(anims) 195 | int humanToRegister = 0 196 | int humanToUnregister = 0 197 | int creatureToRegister = 0 198 | int creatureToUnregister = 0 199 | while animID 200 | int animInfo = JMap.getObj(anims, animID) 201 | bool isCreature = JMap.hasKey(animInfo, "creature_race") 202 | bool enabled = JMap.getInt(enableState, animID) as bool 203 | bool registered = Loader.isRegistered(animID) 204 | if enabled 205 | if !registered 206 | if isCreature 207 | creatureToRegister += 1 208 | else 209 | humanToRegister += 1 210 | endIf 211 | endIf 212 | else 213 | if registered 214 | if isCreature 215 | creatureToUnregister += 1 216 | else 217 | humanToUnregister += 1 218 | endIf 219 | endIf 220 | endIf 221 | 222 | animID = JMap.nextKey(anims, animID) 223 | endWhile 224 | 225 | Loader.PrepareFactory() 226 | int humanCount = Loader.Slots.GetCount(false) 227 | int creatureCount = Loader.CreatureSlots.GetCount(false) 228 | debugMsg("SLAL: current creature count: " + creatureCount) 229 | 230 | int humanTotal = humanCount + humanToRegister - humanToUnregister; 231 | int creatureTotal = creatureCount + creatureToRegister - creatureToUnregister; 232 | ShowMessage("Human Animations: " + humanCount + " currently registered; " + \ 233 | humanToRegister + " to register, " + humanToUnregister + \ 234 | " to unregister; new total: " + humanTotal + \ 235 | "\nCreature Animations: " + creatureCount + " currently registered; " + \ 236 | creatureToRegister + " to register, " + creatureToUnregister + \ 237 | " to unregister; new total: " + creatureTotal, false) 238 | endEvent 239 | endState 240 | 241 | state ReloadJSON 242 | event OnSelectST() 243 | string[] oldPages = getPageNames() 244 | int numErrors = slalData.reloadData() 245 | string[] newPages = getPageNames() 246 | 247 | if numErrors == 0 248 | ShowMessage("$SLAL_ReloadJSONSuccess", false) 249 | else 250 | ShowMessage("$SLAL_ReloadJSONErrors", false) 251 | endIf 252 | 253 | if oldPages != newPages 254 | ; Changing page names requires the user to close and re-open the MCM menu 255 | ; This has to be in a separate message box for translations to work properly. 256 | ; (Unfortunately you cannot concatenate separate translated strings.) 257 | ShowMessage("$SLAL_AnimCategoriesChanged", false) 258 | endIf 259 | endEvent 260 | 261 | event OnHighlightST() 262 | SetInfoText("$SLAL_ReloadJSONInfo") 263 | endEvent 264 | endState 265 | 266 | state RebuildAnimRegistry 267 | event OnSelectST() 268 | SetOptionFlagsST(OPTION_FLAG_DISABLED) 269 | SetTextOptionValueST("$SLAL_Resetting") 270 | SexLab.ThreadSlots.StopAll() 271 | SexLab.AnimSlots.Setup() 272 | SexLab.CreatureSlots.Setup() 273 | ShowMessage("$SLAL_ResetRegistryDone", false) 274 | Debug.Notification("$SLAL_ResetRegistryDone") 275 | SetTextOptionValueST("$SLAL_ClickHere") 276 | SetOptionFlagsST(OPTION_FLAG_NONE) 277 | endEvent 278 | 279 | event OnHighlightST() 280 | SetInfoText("$SLAL_ResetRegistryInfo") 281 | endEvent 282 | endState 283 | 284 | state ReapplyJSON 285 | event OnSelectST() 286 | SetOptionFlagsST(OPTION_FLAG_DISABLED) 287 | SetTextOptionValueST("$SLAL_Updating") 288 | 289 | ; Reload the JSON data before applying changes 290 | string[] oldPages = getPageNames() 291 | int numErrors = slalData.reloadData() 292 | string[] newPages = getPageNames() 293 | 294 | ; Update the settings in already registered animations 295 | Loader.updateJsonSettings() 296 | 297 | SetTextOptionValueST("$SLAL_ClickHere") 298 | SetOptionFlagsST(OPTION_FLAG_NONE) 299 | endEvent 300 | 301 | event OnHighlightST() 302 | SetInfoText("$SLAL_ReapplyJSONInfo") 303 | endEvent 304 | endState 305 | 306 | state EnableAll 307 | event OnSelectST() 308 | if CurrentPage == Pages[0] 309 | toggleAllAnims(true) 310 | ShowMessage("$SLAL_EnableAllDone", false) 311 | else 312 | toggleAllPageAnims(true) 313 | endIf 314 | endEvent 315 | 316 | event OnHighlightST() 317 | SetInfoText("$SLAL_EnableAllInfo") 318 | endEvent 319 | endState 320 | 321 | state DisableAll 322 | event OnSelectST() 323 | if CurrentPage == Pages[0] 324 | toggleAllAnims(false) 325 | ShowMessage("$SLAL_DisableAllDone", false) 326 | else 327 | toggleAllPageAnims(false) 328 | endIf 329 | endEvent 330 | 331 | event OnHighlightST() 332 | SetInfoText("$SLAL_DisableAllInfo") 333 | endEvent 334 | endState 335 | 336 | state VerboseLogs 337 | event OnSelectST() 338 | verboseLogs = !verboseLogs 339 | SetToggleOptionValueST(verboseLogs) 340 | endEvent 341 | 342 | event OnHighlightST() 343 | SetInfoText("$SLAL_VerboseLogsInfo") 344 | endEvent 345 | endState 346 | 347 | function toggleAllAnims(bool enable) 348 | int enableState = slalData.getEnableState() 349 | int anims = slalData.getAnimations() 350 | string animID = JMap.nextKey(anims) 351 | while animID 352 | JMap.setInt(enableState, animID, enable as int) 353 | animID = JMap.nextKey(anims, animID) 354 | endWhile 355 | endFunction 356 | 357 | function toggleAllPageAnims(bool enable) 358 | int enableState = slalData.getEnableState() 359 | int anims = slalData.getAnimations() 360 | int catAnims = slalData.getCategoryAnims(CurrentPage) 361 | int numAnims = JArray.count(catAnims) 362 | 363 | int n = 0 364 | int numRegistered = 0 365 | while n < numAnims 366 | string animID = JArray.getStr(catAnims, n) 367 | int animInfo = JMap.getObj(anims, animID) 368 | JMap.setInt(enableState, animID, enable as int) 369 | 370 | n += 1 371 | endWhile 372 | 373 | ForcePageReset() 374 | endFunction 375 | -------------------------------------------------------------------------------- /Scripts/Source/slalOnLoad.psc: -------------------------------------------------------------------------------- 1 | Scriptname slalOnLoad extends ReferenceAlias 2 | 3 | event OnInit() 4 | (GetOwningQuest() as slalLoader).OnLoad() 5 | endEvent 6 | 7 | event OnPlayerLoadGame() 8 | (GetOwningQuest() as slalLoader).OnLoad() 9 | endEvent 10 | -------------------------------------------------------------------------------- /Scripts/slalAnimationFactory.pex: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orxx/SLAnimLoader/a5d079b8c736620c2ba5c4c1e0f9bd1b5bdcf821/Scripts/slalAnimationFactory.pex -------------------------------------------------------------------------------- /Scripts/slalData.pex: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orxx/SLAnimLoader/a5d079b8c736620c2ba5c4c1e0f9bd1b5bdcf821/Scripts/slalData.pex -------------------------------------------------------------------------------- /Scripts/slalLoader.pex: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orxx/SLAnimLoader/a5d079b8c736620c2ba5c4c1e0f9bd1b5bdcf821/Scripts/slalLoader.pex -------------------------------------------------------------------------------- /Scripts/slalMCM.pex: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orxx/SLAnimLoader/a5d079b8c736620c2ba5c4c1e0f9bd1b5bdcf821/Scripts/slalMCM.pex -------------------------------------------------------------------------------- /Scripts/slalOnLoad.pex: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orxx/SLAnimLoader/a5d079b8c736620c2ba5c4c1e0f9bd1b5bdcf821/Scripts/slalOnLoad.pex -------------------------------------------------------------------------------- /export.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import re 4 | import shutil 5 | import subprocess 6 | import sys 7 | import tempfile 8 | 9 | ZIPPER = r"C:\Program Files\7-Zip\7z.exe" 10 | 11 | 12 | def get_dest_path(entry): 13 | if entry.startswith("."): 14 | return None 15 | if re.match(r"SLAnimLoader-.*\.7z", entry): 16 | return None 17 | 18 | DEST_LOCATIONS = { 19 | 'export.py': None, 20 | 'meta.ini': None, 21 | 'Interface': 'Interface', 22 | 'Scripts': 'Scripts', 23 | 'SLAnimLoader.esp': 'SLAnimLoader.esp', 24 | 'SLAnims': 'SLAnims', 25 | 'README.md': 'Readme - SLAnimLoader.txt', 26 | } 27 | return DEST_LOCATIONS[entry] 28 | 29 | 30 | def export_dir(src_path, dest_path): 31 | os.mkdir(dest_path) 32 | for entry in os.listdir(src_path): 33 | if entry.startswith("."): 34 | continue 35 | entry_src = os.path.join(src_path, entry) 36 | entry_dest = os.path.join(dest_path, entry) 37 | if os.path.isdir(entry_src): 38 | export_dir(entry_src, entry_dest) 39 | else: 40 | shutil.copy2(entry_src, entry_dest) 41 | 42 | 43 | def export(args): 44 | if not os.access(ZIPPER, os.X_OK): 45 | raise Exception("cannot find {}".format(ZIPPER)) 46 | 47 | src_dir = args.source_dir 48 | version = args.version 49 | 50 | with tempfile.TemporaryDirectory() as tmpdir: 51 | dest_dir = os.path.join(tmpdir, "SLAnimLoader-{}".format(version)) 52 | os.mkdir(dest_dir) 53 | dest_entries = [] 54 | for entry in os.listdir(src_dir): 55 | dest = get_dest_path(entry) 56 | if dest is None: 57 | continue 58 | 59 | src_path = os.path.join(src_dir, entry) 60 | dest_path = os.path.join(dest_dir, dest) 61 | dest_entries.append(dest) 62 | if os.path.isdir(src_path): 63 | export_dir(src_path, dest_path) 64 | else: 65 | shutil.copy2(src_path, dest_path) 66 | 67 | archive_name = "SLAnimLoader-{}.7z".format(version) 68 | zip_cmd = [ZIPPER, "a", os.path.join("..", archive_name)] 69 | zip_cmd.extend(dest_entries) 70 | 71 | p = subprocess.Popen(zip_cmd, cwd=dest_dir) 72 | p.wait() 73 | if p.returncode != 0: 74 | raise Exception("error creating archive") 75 | 76 | tmp_archive = os.path.join(tmpdir, archive_name) 77 | archive_path = os.path.join(args.output_dir, archive_name) 78 | shutil.move(tmp_archive, archive_path) 79 | 80 | return archive_path 81 | 82 | 83 | def main(): 84 | ap = argparse.ArgumentParser() 85 | ap.add_argument('-s', '--source-dir', 86 | help='The directory containing the mod sources') 87 | ap.add_argument('-o', '--output-dir', 88 | help='The output directory') 89 | ap.add_argument('-V', '--version', 90 | required=True, 91 | help='The version number to release') 92 | args = ap.parse_args() 93 | 94 | if args.source_dir is None: 95 | args.source_dir = os.path.dirname(sys.argv[0]) 96 | if args.output_dir is None: 97 | args.output_dir = args.source_dir 98 | 99 | print("Generating release archive for version {}".format(args.version)) 100 | archive_path = export(args) 101 | print("Successfully generated {}".format(archive_path)) 102 | 103 | 104 | if __name__ == '__main__': 105 | main() 106 | --------------------------------------------------------------------------------