My current problem. I started modifying the scrolls with little problem (added to the text a list of required spell schools skills to learn it) and to progress i now need to create script records.
However, https://en.uesp.net/morrow/tech/mw_esm.txt says for the 'SCPT' (script) record that the script text is *compiled*. I'm pretty sure that compiling code is beyond my wheelhouse.
Should i just use the openmw support for python or lua?
Some questions if 'yes'.
Are those interpreters built in openmw or do they require system tools installed (in linux python always is, but assuming a windows user). If it requires a system interpreter does openmw at least tell what went wrong?
If i use python for instance, can i install my script 'out' of the omwaddon file, that is not as a record, but as a free-standing 'py' file installed like textures for example? (this feature might make me use it even if the 'compilation' is not a problem).
My current code if you're curious and if i die tomorrow:
- Spoiler: Show
Code: Select all
#!/usr/bin/env python3 from struct import pack, unpack from datetime import date from pathlib import Path import os.path import argparse import sys import re configFilename = 'openmw.cfg' configPaths = { 'linux': '~/.config/openmw', 'freebsd': '~/.config/openmw', 'darwin': '~/Library/Preferences/openmw' } modPaths = { 'linux': '~/.local/share/openmw/data', 'freebsd': '~/.local/share/openmw/data', 'darwin': '~/Library/Application Support/openmw/data' } def packLong(i): # little-endian, "standard" 4-bytes (old 32-bit systems) return pack('<l', i) def packString(s): return bytes(s, 'ascii') def packPaddedString(s, l): bs = bytes(s, 'ascii') if len(bs) > l: # still need to null-terminate return bs[:(l-1)] + bytes(1) else: return bs + bytes(l - len(bs)) def parseString(ba): i = ba.find(0) return ba[:i].decode(encoding='ascii', errors='ignore') def parseNum(ba): return int.from_bytes(ba, 'little') def parseFloat(ba): return unpack('f', ba)[0] def parseTES3(rec): tesrec = {} sr = rec['subrecords'] tesrec['version'] = parseFloat(sr[0]['data'][0:4]) tesrec['filetype'] = parseNum(sr[0]['data'][4:8]) tesrec['author'] = parseString(sr[0]['data'][8:40]) tesrec['desc'] = parseString(sr[0]['data'][40:296]) tesrec['numrecords'] = parseNum(sr[0]['data'][296:300]) masters = [] for i in range(1, len(sr), 2): mastfile = parseString(sr[i]['data']) mastsize = parseNum(sr[i+1]['data']) masters.append((mastfile, mastsize)) tesrec['masters'] = masters return tesrec def pullSubs(rec, subtype): return [ s for s in rec['subrecords'] if s['type'] == subtype ] def readHeader(ba): header = {} header['type'] = ba[0:4].decode() header['length'] = int.from_bytes(ba[4:8], 'little') return header def readSubRecord(ba): sr = {} sr['type'] = ba[0:4].decode() sr['length'] = int.from_bytes(ba[4:8], 'little') endbyte = 8 + sr['length'] sr['data'] = ba[8:endbyte] return (sr, ba[endbyte:]) def readRecords(filename): fh = open(filename, 'rb') while True: headerba = fh.read(16) if headerba is None or len(headerba) < 16: return None record = {} header = readHeader(headerba) record['type'] = header['type'] record['length'] = header['length'] record['subrecords'] = [] # stash the filename here (a bit hacky, but useful) record['fullpath'] = filename remains = fh.read(header['length']) while len(remains) > 0: (subrecord, restofbytes) = readSubRecord(remains) record['subrecords'].append(subrecord) remains = restofbytes yield record def oldGetRecords(filename, rectype): return ( r for r in readRecords(filename) if r['type'] == rectype ) def getRecords(filename, rectypes): numtypes = len(rectypes) retval = [ [] for x in range(numtypes) ] for r in readRecords(filename): if r['type'] in rectypes: for i in range(numtypes): if r['type'] == rectypes[i]: retval[i].append(r) return retval def packStringSubRecord(lbl, strval): str_bs = packString(strval) + bytes(1) l = packLong(len(str_bs)) return packString(lbl) + l + str_bs def packIntSubRecord(lbl, num, numsize=4): # This is interesting. The 'pack' function from struct works fine like this: # # >>> pack('<l', 200) # b'\xc8\x00\x00\x00' # # but breaks if you make that format string a non-literal: # # >>> fs = '<l' # >>> pack(fs, 200) # Traceback (most recent call last): # File "<stdin>", line 1, in <module> # struct.error: repeat count given without format specifier # # This is as of Python 3.5.2 num_bs = b'' if numsize == 4: # "standard" 4-byte longs, little-endian num_bs = pack('<l', num) elif numsize == 2: num_bs = pack('<h', num) elif numsize == 1: # don't think endian-ness matters for bytes, but consistency num_bs = pack('<b', num) elif numsize == 8: num_bs = pack('<q', num) return packString(lbl) + packLong(numsize) + num_bs def packTES3(desc, numrecs, masters): start_bs = b'TES3' headerflags_bs = bytes(8) hedr_bs = b'HEDR' + packLong(300) version_bs = pack('<f', 1.0) # .esp == 0, .esm == 1, .ess == 32 # suprisingly, .omwaddon == 0, also -- figured it would have its own ftype_bs = bytes(4) author = 'i30817, copyright 2018' author_bs = packPaddedString(author, 32) desc_bs = packPaddedString(desc, 256) numrecs_bs = packLong(numrecs) masters_bs = b'' for (m, s) in masters: masters_bs += packStringSubRecord('MAST', m) masters_bs += packIntSubRecord('DATA', s, 8) reclen = len(hedr_bs) + len(version_bs) + len(ftype_bs) + len(author_bs) +\ len(desc_bs) + len(numrecs_bs) + len(masters_bs) reclen_bs = packLong(reclen) return start_bs + reclen_bs + headerflags_bs + \ hedr_bs + version_bs + ftype_bs + author_bs + \ desc_bs + numrecs_bs + masters_bs def ppSubRecord(sr): if sr['type'] in ['NAME', 'INAM', 'CNAM']: print(" %s, length %d, value '%s'" % (sr['type'], sr['length'], parseString(sr['data']))) elif sr['type'] in ['DATA', 'NNAM', 'INDX', 'INTV']: print(" %s, length %d, value '%s'" % (sr['type'], sr['length'], parseNum(sr['data']))) else: print(" %s, length %d" % (sr['type'], sr['length'])) def ppRecord(rec): print("%s, length %d" % (rec['type'], rec['length'])) for sr in rec['subrecords']: ppSubRecord(sr) def ppTES3(rec): print("TES3 record, type %d, version %f" % (rec['filetype'], rec['version'])) print("author: %s" % rec['author']) print("description: %s" % rec['desc']) for (mfile, msize) in rec['masters']: print(" master %s, size %d" % (mfile, msize)) print() def readCfg(cfg): # first, open the file and pull all 'data' and 'content' lines, in order data_dirs = [] mods = [] with open(cfg, 'r') as f: for l in f.readlines(): # match of form "blah=blahblah" m = re.search(r'^(.*)=(.*)$', l) if m: varname = m.group(1).strip() # get rid of not only whitespace, but also surrounding quotes varvalue = m.group(2).strip().strip('\'"') if varname == 'data': data_dirs.append(varvalue) elif varname == 'content': mods.append(varvalue) # we've got the basenames of the mods, but not the full paths # and we have to search through the data_dirs to find them fp_mods = [] for m in mods: for p in data_dirs: full_path = os.path.join(p, m) if os.path.exists(full_path): fp_mods.append(full_path) break print("Config file parsed...") return fp_mods def packSpell(rec): spellname = renameScroll(x) def packScript(rec, script_name, spell_name, reqAlt=0,reqConj=0,reqDest=0,reqIllus=0,reqMyst=0,reqRest=0): content=\ ''' begin {} short OnPCEquip short PCSkipEquip short learn if (MenuMode == 0) return endif PCSkipEquip = 1 if ( learn == 1 ) set learn to 0 if (getbuttonpressed == 0) if (Player->GetAlteration >= {}) if (Player->GetConjuration >= {}) if (Player->GetDestruction >= {}) if (Player->GetIllusion >= {}) if (Player->GetMysticism >= {}) if (Player->GetRestoration >= {}) player->AddSpell "{}" MessageBox "You have learned the spell '{}'!" PlaySound "skillraise" disable setdelete 1 return endif endif endif endif endif endif Activate Messagebox "More study is required to inscribe this scroll." endif return endif if (OnPCEquip == 1) if ( player->getspell "{}" == 0 ) messagebox "Do you wish to attempt to learn this spell?" "Learn Now" "Learn Later" set learn to 1 return elseif Activate endif endif end {} '''.format(script_name, reqAlt,reqConj,reqDest,reqIllus,reqMyst,reqRest, spell_name, spell_name, spell_name, script_name) def packBook(rec): start_bs = b'BOOK' headerflags_bs = bytes(8) name_bs = packStringSubRecord('NAME', rec['name']) model_bs = packStringSubRecord('MODL', rec['modl']) fname_bs = packStringSubRecord('FNAM', rec['fnam']) #compared with vbindiff to a original record #20 is the length of this subrecord bkdt_bs = b'BKDT' + packLong(20) + rec['bkdt'] #we only pack back 'books' (scrolls) with enchantments and scripts, but just to be safe possible_script = rec.get('scri', None) if possible_script: scri_bs = packStringSubRecord('SCRI', possible_script) else: scri_bs = bytes(0) itex_bs = packStringSubRecord('ITEX', rec['itex']) text_bs = packStringSubRecord('TEXT', rec.get('text', "")) possible_enchant = rec.get('enam', None) if possible_enchant: enam_bs = packStringSubRecord('ENAM', possible_enchant) else: enam_bs = bytes(0) reclen = len(name_bs) + len(model_bs) + len(fname_bs) + len(bkdt_bs) + len(scri_bs) + len(itex_bs) + len(text_bs) + len(enam_bs) reclen_bs = packLong(reclen) return start_bs + reclen_bs + headerflags_bs + name_bs + model_bs + fname_bs + bkdt_bs + scri_bs + itex_bs + text_bs + enam_bs def toSigned32(n): n = n & 0xffffffff return (n ^ 0x80000000) - 0x80000000 def parseBook(rec): bkrec = {} sr = rec['subrecords'] bkrec['type'] = rec['type'] for r in sr: if r['type'] == 'NAME': bkrec['name'] = parseString(r['data']) elif r['type'] == 'MODL': bkrec['modl'] = parseString(r['data']) elif r['type'] == 'FNAM': bkrec['fnam'] = parseString(r['data']) elif r['type'] == 'BKDT': bkrec['bkdt'] = r['data'] elif r['type'] == 'ITEX': bkrec['itex'] = parseString(r['data']) elif r['type'] == 'ENAM': bkrec['enam'] = parseString(r['data']) elif r['type'] == 'TEXT': bkrec['text'] = parseString(r['data']) elif r['type'] == 'SCRI': bkrec['scri'] = parseString(r['data']) else: assert False, r return bkrec def parseEnchantment(rec): bkrec = {} sr = rec['subrecords'] bkrec['type'] = rec['type'] enams = [] bkrec['enam'] = enams for r in sr: if r['type'] == 'NAME': bkrec['name'] = parseString(r['data']) elif r['type'] == 'ENDT': bkrec['endt'] = r['data'] #16 bytes elif r['type'] == 'ENAM': enams += [r['data']] else: assert False, r return bkrec def renameScroll(rec): #some 'normal' spells with only one effect with non-standard names, which might be changed by patches if rec['name'] == 'sc_messengerscroll': return 'Scribed Summon Scamp' elif rec['name'] == 'sc_summondaedroth_hto': return 'Scribed Summon Daedroth' elif rec['name'].startswith('sc_radrenesspellbreaker'): return 'Scribed Radrene\'s Spell Breaker' elif rec['name'].startswith('sc_recall'): #this is not the 'recall' scroll that appears in game, but may as well fix the case here return 'Scribed Recall' elif rec['fnam'].startswith('Scroll of The '): #some return 'Scribed ' + rec['fnam'][len('Scroll of The '):] elif rec['fnam'].startswith('Scroll of the '): #some return 'Scribed ' + rec['fnam'][len('Scroll of the '):] elif rec['fnam'].startswith('Scroll of '): #many return 'Scribed ' + rec['fnam'][len('Scroll of '):] elif rec['fnam'].startswith(('L1', 'L2', 'L3', 'L4', 'L5')): #many in uvirith's legacy return 'Scribed ' + rec['fnam'][0:3] + rec['fnam'][13:] #removed 'Scroll of ' else: #fallback, might look weird but hopefully never happens return 'Scribed '+ rec['fnam'] def main(cfg, outmoddir, outmod): fp_mods = readCfg(cfg) # first, let's grab the "raw" records from the files (rtes3, rbook, rench) = ([], [], []) for f in fp_mods: print(f) (rtes3t, rbookt, encht) = getRecords(f, ('TES3', 'BOOK', 'ENCH')) rtes3 += [ parseTES3(x) for x in rtes3t ] rbook += [ parseBook(x) for x in rbookt ] rench += [ parseEnchantment(x) for x in encht ] master_list = [ (esp, timestamp) for x in rtes3 for (esp, timestamp) in x['masters'] ] # before filtering we only want to overload the last version of the entity across all mods # we need to remove the previous because a new version could cause it to be # disqualified and not removing would cause the (wrong) previous one to be picked up. id_map = {} for x in rbook: #is scroll without script and with enchantment if x.get('bkdt',None) and int(x['bkdt'][8]) == 1 and not x.get('scri', None) and x.get('enam', None): id_map[x['name']] = x #override previous if any elif x.get('bkdt',None) and int(x['bkdt'][8]) == 1 and x.get('scri', None) and x.get('enam', None): print("spell scroll with a script skipped: "+x['name']) else: id_map.pop(x['name'], None) #delete previous if any scrolls = b'' scripts = b'' spells = b'' for x in id_map.values(): #script_name = "lrn_" + x['name'] #x['scri'] = script_name enchantment = next( e for e in rench if x['enam'] == e['name'] ) assert enchantment #https://en.uesp.net/morrow/hints/mweffects.shtml myst_tb = [53,57,58,59,60,61,62,63,64,65,66,67,68,85,86,87,88,89] illu_tb = [39,40,41,42,43,44,45,46,47,48,49,50,51,52,54,55,56] conj_tb = [101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,118,119,120,121,122,123,124,125,126,127,128,129,130,131,134] alte_tb = [0,1,2,3,4,5,6,7,8,9,10,11,12,13] dest_tb = [14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,132,133,135,136] rest_tb = [69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,90,91,92,93,94,95,96,97,98,99,100,117] #highest difficulty of enchantments, duration multiplier, color and id table schools = [ [0, '5A2458', 0.15, alte_tb, 'Alteration' ],[0, '642D00', 0.25, conj_tb, 'Conjuration'], \ [0, '9B0000', 0.08, dest_tb, 'Destruction' ],[0, '113D25', 0.2 , illu_tb, 'Illusion'], \ [0, '383C9C', 0.2 , myst_tb, 'Mysticism' ],[0, '001BB9', 0.2 , rest_tb, 'Restoration']] import random #for each school only count the highest difficulty enchantment for effect in enchantment['enam']: effect_id = parseNum(effect[0:2]) for s in schools: if effect_id in s[3]: dur_mag = parseNum(effect[12:16]) if dur_mag <= 1: #instant, probably on self, deserves bump dur_mag = 40 #this is so non-spread out #(even with the duration and duration mult) #that i prefer to introduce some randomness min_mag = parseNum(effect[16:20]) max_mag = parseNum(effect[20:]) mag = (min_mag + max_mag) / 2 + s[2]*dur_mag mag = min(98, mag) if mag > s[0]: if mag <= 15: mag += random.randint(0, 10) elif mag <= 40: mag += random.randint(-3, 7) elif mag <= 85: mag += random.randint(-3, 3) else: mag += random.randint(-6, 1) s[0] = int(mag) break x['text'] += '<FONT><DIV ALIGN="LEFT"><BR><BR>I require these skills to learn from the scroll<BR><BR></FONT>' for s in schools: x['text'] += '<FONT COLOR="{}"><DIV ALIGN="LEFT">{} {}<BR></FONT>'.format(s[1],s[0],s[4]) scrolls += packBook(x) # scripts += packScript(x, script_name) # spells += packSpell(x) moddesc = "Merged scripted scrolls" if not os.path.exists(outmoddir): p = Path(outmoddir) p.mkdir(parents=True) with open(outmod, 'wb') as f: f.write(packTES3(moddesc, len(id_map.values()), master_list)) f.write(scrolls) # f.write(scripts) # f.write(spells) def create_spells( scroll_records ): nop # # And give some hopefully-useful instructions # modShortName = os.path.basename(outmod) # print("\n\n****************************************") # print(" Great! I think that worked. When you next start the OpenMW Launcher, look for a module named %s. Make sure of the following things:" % modShortName) # print(" 1. %s is at the bottom of the list. Drag it to the bottom if it's not. It needs to load last." % modShortName) # print(" 2. %s is checked (enabled)" % modShortName) # print(" 3. Any other OMWLLF mods are *un*checked. Loading them might not cause problems, but probably will") # print("\n") # print(" Then, go ahead and start the game! Your leveled lists should include adjustmemts from all relevants enabled mods") # print("\n") if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('-c', '--conffile', type = str, default = None, action = 'store', required = False, help = 'Conf file to use. Optional. By default, attempts to use the default conf file location.') parser.add_argument('-d', '--moddir', type = str, default = None, action = 'store', required = False, help = 'Directory to store the new module in. By default, attempts to use the default work directory for OpenMW-CS') parser.add_argument('-m', '--modname', type = str, default = None, action = 'store', required = False, help = 'Name of the new module to create. By default, this is "OMWLLF Mod - <today\'s date>.omwaddon.') p = parser.parse_args() # determine the conf file to use confFile = '' if p.conffile: confFile = p.conffile else: pl = sys.platform if pl in configPaths: baseDir = os.path.expanduser(configPaths[pl]) confFile = os.path.join(baseDir, configFilename) elif pl == 'win32': # this is ugly. first, imports that only work properly on windows from ctypes import * import ctypes.wintypes buf = create_unicode_buffer(ctypes.wintypes.MAX_PATH) # opaque arguments. they are, roughly, for our purposes: # - an indicator of folder owner (0 == current user) # - an id for the type of folder (5 == 'My Documents') # - an indicator for user to call from (0 same as above) # - a bunch of flags for different things # (if you want, for example, to get the default path # instead of the actual path, or whatnot) # 0 == current stuff # - the variable to hold the return value windll.shell32.SHGetFolderPathW(0, 5, 0, 0, buf) # pull out the return value and construct the rest baseDir = os.path.join(buf.value, 'My Games', 'OpenMW') confFile = os.path.join(baseDir, configFilename) else: print("Sorry, I don't recognize the platform '%s'. You can try specifying the conf file using the '-c' flag." % p) sys.exit(1) baseModDir = '' if p.moddir: baseModDir = p.moddir else: pl = sys.platform if pl in configPaths: baseModDir = os.path.expanduser(modPaths[pl]) elif pl == 'win32': # this is ugly in exactly the same ways as above. # see there for more information from ctypes import * import ctypes.wintypes buf = create_unicode_buffer(ctypes.wintypes.MAX_PATH) windll.shell32.SHGetFolderPathW(0, 5, 0, 0, buf) baseDir = os.path.join(buf.value, 'My Games', 'OpenMW') baseModDir = os.path.join(baseDir, 'data') else: print("Sorry, I don't recognize the platform '%s'. You can try specifying the conf file using the '-c' flag." % p) sys.exit(1) if not os.path.exists(confFile): print("Sorry, the conf file '%s' doesn't seem to exist." % confFile) sys.exit(1) modName = '' if p.modname: modName = p.modname else: modName = 'Merged scripted scrolls mod - %s.omwaddon' % date.today().strftime('%Y-%m-%d') modFullPath = os.path.join(baseModDir, modName) main(confFile, baseModDir, modFullPath) # regarding the windows path detection: # # "SHGetFolderPath" is deprecated in favor of "SHGetKnownFolderPath", but # >>> windll.shell32.SHGetKnownFolderPath('{FDD39AD0-238F-46AF-ADB4-6C85480369C7}', 0, 0, buf2) # -2147024894