omw_shuffle_ingredients.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599
  1. #!/usr/bin/env python3
  2. from struct import pack, unpack
  3. from datetime import date
  4. from pathlib import Path
  5. import os.path
  6. import argparse
  7. import sys
  8. import re
  9. configFilename = 'openmw.cfg'
  10. configPaths = { 'linux': '~/.config/openmw',
  11. 'freebsd': '~/.config/openmw',
  12. 'darwin': '~/Library/Preferences/openmw' }
  13. modPaths = { 'linux': '~/.local/share/openmw/data',
  14. 'freebsd': '~/.local/share/openmw/data',
  15. 'darwin': '~/Library/Application Support/openmw/data' }
  16. def packLong(i):
  17. # little-endian, "standard" 4-bytes (old 32-bit systems)
  18. return pack('<l', i)
  19. def packString(s):
  20. return bytes(s, 'ascii')
  21. def packPaddedString(s, l):
  22. bs = bytes(s, 'ascii')
  23. if len(bs) > l:
  24. # still need to null-terminate
  25. return bs[:(l-1)] + bytes(1)
  26. else:
  27. return bs + bytes(l - len(bs))
  28. def parseString(ba):
  29. i = ba.find(0)
  30. return ba[:i].decode(encoding='ascii', errors='ignore')
  31. def parseNum(ba):
  32. return int.from_bytes(ba, 'little', signed=True)
  33. def parseFloat(ba):
  34. return unpack('f', ba)[0]
  35. def parseTES3(rec):
  36. tesrec = {}
  37. sr = rec['subrecords']
  38. tesrec['version'] = parseFloat(sr[0]['data'][0:4])
  39. tesrec['filetype'] = parseNum(sr[0]['data'][4:8])
  40. tesrec['author'] = parseString(sr[0]['data'][8:40])
  41. tesrec['desc'] = parseString(sr[0]['data'][40:296])
  42. tesrec['numrecords'] = parseNum(sr[0]['data'][296:300])
  43. masters = []
  44. for i in range(1, len(sr), 2):
  45. mastfile = parseString(sr[i]['data'])
  46. mastsize = parseNum(sr[i+1]['data'])
  47. masters.append((mastfile, mastsize))
  48. tesrec['masters'] = masters
  49. return tesrec
  50. def parseINGR(rec):
  51. ingrrec = {}
  52. sr = rec['subrecords']
  53. ingrrec['id'] = parseString(sr[0]['data'])
  54. ingrrec['model'] = parseString(sr[1]['data'])
  55. ingrrec['name'] = parseString(sr[2]['data'])
  56. ingrrec['weight'] = parseFloat(sr[3]['data'][0:4])
  57. ingrrec['value'] = parseNum(sr[3]['data'][4:8])
  58. effect_ids = []
  59. skill_ids = []
  60. attr_ids = []
  61. for ind in range(8, 21, 4):
  62. effect_ids.append(parseNum(sr[3]['data'][ind:ind+4]))
  63. for ind in range(24, 37, 4):
  64. skill_ids.append(parseNum(sr[3]['data'][ind:ind+4]))
  65. for ind in range(40, 53, 4):
  66. attr_ids.append(parseNum(sr[3]['data'][ind:ind+4]))
  67. ingrrec['effect_ids'] = effect_ids
  68. ingrrec['skill_ids'] = skill_ids
  69. ingrrec['attr_ids'] = attr_ids
  70. ingrrec['icon'] = parseString(sr[4]['data'])
  71. if len(sr) > 5:
  72. ingrrec['script'] = parseString(sr[5]['data'])
  73. return ingrrec
  74. def pullSubs(rec, subtype):
  75. return [ s for s in rec['subrecords'] if s['type'] == subtype ]
  76. def readHeader(ba):
  77. header = {}
  78. header['type'] = ba[0:4].decode()
  79. header['length'] = int.from_bytes(ba[4:8], 'little')
  80. return header
  81. def readSubRecord(ba):
  82. sr = {}
  83. sr['type'] = ba[0:4].decode()
  84. sr['length'] = int.from_bytes(ba[4:8], 'little')
  85. endbyte = 8 + sr['length']
  86. sr['data'] = ba[8:endbyte]
  87. return (sr, ba[endbyte:])
  88. def readRecords(filename):
  89. fh = open(filename, 'rb')
  90. while True:
  91. headerba = fh.read(16)
  92. if headerba is None or len(headerba) < 16:
  93. return None
  94. record = {}
  95. header = readHeader(headerba)
  96. record['type'] = header['type']
  97. record['length'] = header['length']
  98. record['subrecords'] = []
  99. # stash the filename here (a bit hacky, but useful)
  100. record['fullpath'] = filename
  101. remains = fh.read(header['length'])
  102. while len(remains) > 0:
  103. (subrecord, restofbytes) = readSubRecord(remains)
  104. record['subrecords'].append(subrecord)
  105. remains = restofbytes
  106. yield record
  107. def oldGetRecords(filename, rectype):
  108. return ( r for r in readRecords(filename) if r['type'] == rectype )
  109. def getRecords(filename, rectypes):
  110. numtypes = len(rectypes)
  111. retval = [ [] for x in range(numtypes) ]
  112. for r in readRecords(filename):
  113. if r['type'] in rectypes:
  114. for i in range(numtypes):
  115. if r['type'] == rectypes[i]:
  116. retval[i].append(r)
  117. return retval
  118. def packStringSubRecord(lbl, strval):
  119. str_bs = packString(strval) + bytes(1)
  120. l = packLong(len(str_bs))
  121. return packString(lbl) + l + str_bs
  122. def packIntSubRecord(lbl, num, numsize=4):
  123. # This is interesting. The 'pack' function from struct works fine like this:
  124. #
  125. # >>> pack('<l', 200)
  126. # b'\xc8\x00\x00\x00'
  127. #
  128. # but breaks if you make that format string a non-literal:
  129. #
  130. # >>> fs = '<l'
  131. # >>> pack(fs, 200)
  132. # Traceback (most recent call last):
  133. # File "<stdin>", line 1, in <module>
  134. # struct.error: repeat count given without format specifier
  135. #
  136. # This is as of Python 3.5.2
  137. num_bs = b''
  138. if numsize == 4:
  139. # "standard" 4-byte longs, little-endian
  140. num_bs = pack('<l', num)
  141. elif numsize == 2:
  142. num_bs = pack('<h', num)
  143. elif numsize == 1:
  144. # don't think endian-ness matters for bytes, but consistency
  145. num_bs = pack('<b', num)
  146. elif numsize == 8:
  147. num_bs = pack('<q', num)
  148. return packString(lbl) + packLong(numsize) + num_bs
  149. def packLEV(rec):
  150. start_bs = b''
  151. id_bs = b''
  152. if rec['type'] == 'LEVC':
  153. start_bs += b'LEVC'
  154. id_bs = 'CNAM'
  155. else:
  156. start_bs += b'LEVI'
  157. id_bs = 'INAM'
  158. headerflags_bs = bytes(8)
  159. name_bs = packStringSubRecord('NAME', rec['name'])
  160. calcfrom_bs = packIntSubRecord('DATA', rec['calcfrom'])
  161. chance_bs = packIntSubRecord('NNAM', rec['chancenone'], 1)
  162. subrec_bs = packIntSubRecord('INDX', len(rec['items']))
  163. for (lvl, lid) in rec['items']:
  164. subrec_bs += packStringSubRecord(id_bs, lid)
  165. subrec_bs += packIntSubRecord('INTV', lvl, 2)
  166. reclen = len(name_bs) + len(calcfrom_bs) + len(chance_bs) + len(subrec_bs)
  167. reclen_bs = packLong(reclen)
  168. return start_bs + reclen_bs + headerflags_bs + \
  169. name_bs + calcfrom_bs + chance_bs + subrec_bs
  170. def packTES3(desc, numrecs, masters):
  171. start_bs = b'TES3'
  172. headerflags_bs = bytes(8)
  173. hedr_bs = b'HEDR' + packLong(300)
  174. version_bs = pack('<f', 1.0)
  175. # .esp == 0, .esm == 1, .ess == 32
  176. # suprisingly, .omwaddon == 0, also -- figured it would have its own
  177. ftype_bs = bytes(4)
  178. author_bs = packPaddedString('omwllf, copyright 2017, jmelesky', 32)
  179. desc_bs = packPaddedString(desc, 256)
  180. numrecs_bs = packLong(numrecs)
  181. masters_bs = b''
  182. for (m, s) in masters:
  183. masters_bs += packStringSubRecord('MAST', m)
  184. masters_bs += packIntSubRecord('DATA', s, 8)
  185. reclen = len(hedr_bs) + len(version_bs) + len(ftype_bs) + len(author_bs) +\
  186. len(desc_bs) + len(numrecs_bs) + len(masters_bs)
  187. reclen_bs = packLong(reclen)
  188. return start_bs + reclen_bs + headerflags_bs + \
  189. hedr_bs + version_bs + ftype_bs + author_bs + \
  190. desc_bs + numrecs_bs + masters_bs
  191. def ppSubRecord(sr):
  192. if sr['type'] in ['NAME', 'INAM', 'CNAM', 'FNAM', 'MODL', 'TEXT', 'SCRI']:
  193. print(" %s, length %d, value '%s'" % (sr['type'], sr['length'], parseString(sr['data'])))
  194. elif sr['type'] in ['DATA', 'NNAM', 'INDX', 'INTV']:
  195. print(" %s, length %d, value '%s'" % (sr['type'], sr['length'], parseNum(sr['data'])))
  196. else:
  197. print(" %s, length %d" % (sr['type'], sr['length']))
  198. def ppRecord(rec):
  199. print("%s, length %d" % (rec['type'], rec['length']))
  200. for sr in rec['subrecords']:
  201. ppSubRecord(sr)
  202. def ppLEV(rec):
  203. if rec['type'] == 'LEVC':
  204. print("Creature list '%s' from '%s':" % (rec['name'], rec['file']))
  205. else:
  206. print("Item list '%s' from '%s':" % (rec['name'], rec['file']))
  207. print("flags: %d, chance of none: %d" % (rec['calcfrom'], rec['chancenone']))
  208. for (lvl, lid) in rec['items']:
  209. print(" %2d - %s" % (lvl, lid))
  210. def ppINGR(rec):
  211. print("Ingredient name: '%s'" % (rec['name']))
  212. print(" ID: '%s'" % rec['id'])
  213. print(" Model: '%s', Icon: '%s'" % (rec['model'], rec['icon']))
  214. if 'script' in rec:
  215. print(" Script: '%s'" % (rec['script']))
  216. print(" %10s%10s%10s" % ("effect", "skill", "attribute"))
  217. for i in range(0,4):
  218. print(" %10d%10d%10d" % (rec['effect_ids'][i], rec['skill_ids'][i], rec['attr_ids'][i]))
  219. def ppTES3(rec):
  220. print("TES3 record, type %d, version %f" % (rec['filetype'], rec['version']))
  221. print("author: %s" % rec['author'])
  222. print("description: %s" % rec['desc'])
  223. for (mfile, msize) in rec['masters']:
  224. print(" master %s, size %d" % (mfile, msize))
  225. print()
  226. def mergeableLists(alllists):
  227. candidates = {}
  228. for l in alllists:
  229. lid = l['name']
  230. if lid in candidates:
  231. candidates[lid].append(l)
  232. else:
  233. candidates[lid] = [l]
  234. mergeables = {}
  235. for k in candidates:
  236. if len(candidates[k]) > 1:
  237. mergeables[k] = candidates[k]
  238. return mergeables
  239. def mergeLists(lls):
  240. # last one gets priority for list-level attributes
  241. last = lls[-1]
  242. newLev = { 'type': last['type'],
  243. 'name': last['name'],
  244. 'calcfrom': last['calcfrom'],
  245. 'chancenone': last['chancenone'] }
  246. allItems = []
  247. for l in lls:
  248. allItems += l['items']
  249. newLev['files'] = [ x['file'] for x in lls ]
  250. newLev['file'] = ', '.join(newLev['files'])
  251. # This ends up being a bit tricky, but it prevents us
  252. # from overloading lists with the same stuff.
  253. #
  254. # This is needed, because the original leveled lists
  255. # contain multiple entries for some creatures/items, and
  256. # that gets reproduced in many plugins.
  257. #
  258. # If we just added and sorted, then the more plugins you
  259. # have, the less often you'd see plugin content. This
  260. # method prevents the core game content from overwhelming
  261. # plugin contents.
  262. allUniques = [ x for x in set(allItems) ]
  263. allUniques.sort()
  264. newList = []
  265. for i in allUniques:
  266. newCount = max([ x['items'].count(i) for x in lls ])
  267. newList += [i] * newCount
  268. newLev['items'] = newList
  269. return newLev
  270. def mergeAllLists(alllists):
  271. mergeables = mergeableLists(alllists)
  272. merged = []
  273. for k in mergeables:
  274. merged.append(mergeLists(mergeables[k]))
  275. return merged
  276. def readCfg(cfg):
  277. # first, open the file and pull all 'data' and 'content' lines, in order
  278. data_dirs = []
  279. mods = []
  280. with open(cfg, 'r') as f:
  281. for l in f.readlines():
  282. # match of form "blah=blahblah"
  283. m = re.search(r'^(.*)=(.*)$', l)
  284. if m:
  285. varname = m.group(1).strip()
  286. # get rid of not only whitespace, but also surrounding quotes
  287. varvalue = m.group(2).strip().strip('\'"')
  288. if varname == 'data':
  289. data_dirs.append(varvalue)
  290. elif varname == 'content':
  291. mods.append(varvalue)
  292. # we've got the basenames of the mods, but not the full paths
  293. # and we have to search through the data_dirs to find them
  294. fp_mods = []
  295. for m in mods:
  296. for p in data_dirs:
  297. full_path = os.path.join(p, m)
  298. if os.path.exists(full_path):
  299. fp_mods.append(full_path)
  300. break
  301. print("Config file parsed...")
  302. return fp_mods
  303. def dumplists(cfg):
  304. llists = []
  305. fp_mods = readCfg(cfg)
  306. for f in fp_mods:
  307. [ ppTES3(parseTES3(x)) for x in oldGetRecords(f, 'TES3') ]
  308. for f in fp_mods:
  309. llists += [ parseLEV(x) for x in oldGetRecords(f, 'LEVI') ]
  310. for f in fp_mods:
  311. llists += [ parseLEV(x) for x in oldGetRecords(f, 'LEVC') ]
  312. for l in llists:
  313. ppLEV(l)
  314. def dumpalchs(cfg):
  315. alchs = []
  316. fp_mods = readCfg(cfg)
  317. for f in fp_mods:
  318. [ ppTES3(parseTES3(x)) for x in oldGetRecords(f, 'TES3') ]
  319. for f in fp_mods:
  320. [ ppINGR(parseINGR(x)) for x in oldGetRecords(f, 'INGR') ]
  321. def main(cfg, outmoddir, outmod):
  322. fp_mods = readCfg(cfg)
  323. # first, let's grab the "raw" records from the files
  324. (rtes3, rlevi, rlevc) = ([], [], [])
  325. for f in fp_mods:
  326. print("Parsing '%s' for relevant records" % f)
  327. (rtes3t, rlevit, rlevct) = getRecords(f, ('TES3', 'LEVI', 'LEVC'))
  328. rtes3 += rtes3t
  329. rlevi += rlevit
  330. rlevc += rlevct
  331. # next, parse the tes3 records so we can get a list
  332. # of master files required by all our mods
  333. tes3list = [ parseTES3(x) for x in rtes3 ]
  334. masters = {}
  335. for t in tes3list:
  336. for m in t['masters']:
  337. masters[m[0]] = m[1]
  338. master_list = [ (k,v) for (k,v) in masters.items() ]
  339. # now, let's parse the levi and levc records into
  340. # mergeable lists, then merge them
  341. # creature lists
  342. clist = [ parseLEV(x) for x in rlevc ]
  343. levc = mergeAllLists(clist)
  344. # item lists
  345. ilist = [ parseLEV(x) for x in rlevi ]
  346. levi = mergeAllLists(ilist)
  347. # now build the binary representation of
  348. # the merged lists.
  349. # along the way, build up the module
  350. # description for the new merged mod, out
  351. # of the names of mods that had lists
  352. llist_bc = b''
  353. pluginlist = []
  354. for x in levi + levc:
  355. # ppLEV(x)
  356. llist_bc += packLEV(x)
  357. pluginlist += x['files']
  358. plugins = set(pluginlist)
  359. moddesc = "Merged leveled lists from: %s" % ', '.join(plugins)
  360. # finally, build the binary form of the
  361. # TES3 record, and write the whole thing
  362. # out to disk
  363. if not os.path.exists(outmoddir):
  364. p = Path(outmoddir)
  365. p.mkdir(parents=True)
  366. with open(outmod, 'wb') as f:
  367. f.write(packTES3(moddesc, len(levi + levc), master_list))
  368. f.write(llist_bc)
  369. # And give some hopefully-useful instructions
  370. modShortName = os.path.basename(outmod)
  371. print("\n\n****************************************")
  372. 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)
  373. 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)
  374. print(" 2. %s is checked (enabled)" % modShortName)
  375. print(" 3. Any other OMWLLF mods are *un*checked. Loading them might not cause problems, but probably will")
  376. print("\n")
  377. print(" Then, go ahead and start the game! Your leveled lists should include adjustmemts from all relevants enabled mods")
  378. print("\n")
  379. if __name__ == '__main__':
  380. parser = argparse.ArgumentParser()
  381. parser.add_argument('-c', '--conffile', type = str, default = None,
  382. action = 'store', required = False,
  383. help = 'Conf file to use. Optional. By default, attempts to use the default conf file location.')
  384. parser.add_argument('-d', '--moddir', type = str, default = None,
  385. action = 'store', required = False,
  386. help = 'Directory to store the new module in. By default, attempts to use the default work directory for OpenMW-CS')
  387. parser.add_argument('-m', '--modname', type = str, default = None,
  388. action = 'store', required = False,
  389. help = 'Name of the new module to create. By default, this is "OMWLLF Mod - <today\'s date>.omwaddon.')
  390. parser.add_argument('--dumpalchs', default = True,
  391. action = 'store_true', required = False,
  392. help = 'Instead of generating merged lists, dump all leveled lists in the conf mods. Used for debugging')
  393. p = parser.parse_args()
  394. # determine the conf file to use
  395. confFile = ''
  396. if p.conffile:
  397. confFile = p.conffile
  398. else:
  399. pl = sys.platform
  400. if pl in configPaths:
  401. baseDir = os.path.expanduser(configPaths[pl])
  402. confFile = os.path.join(baseDir, configFilename)
  403. elif pl == 'win32':
  404. # this is ugly. first, imports that only work properly on windows
  405. from ctypes import *
  406. import ctypes.wintypes
  407. buf = create_unicode_buffer(ctypes.wintypes.MAX_PATH)
  408. # opaque arguments. they are, roughly, for our purposes:
  409. # - an indicator of folder owner (0 == current user)
  410. # - an id for the type of folder (5 == 'My Documents')
  411. # - an indicator for user to call from (0 same as above)
  412. # - a bunch of flags for different things
  413. # (if you want, for example, to get the default path
  414. # instead of the actual path, or whatnot)
  415. # 0 == current stuff
  416. # - the variable to hold the return value
  417. windll.shell32.SHGetFolderPathW(0, 5, 0, 0, buf)
  418. # pull out the return value and construct the rest
  419. baseDir = os.path.join(buf.value, 'My Games', 'OpenMW')
  420. confFile = os.path.join(baseDir, configFilename)
  421. else:
  422. print("Sorry, I don't recognize the platform '%s'. You can try specifying the conf file using the '-c' flag." % p)
  423. sys.exit(1)
  424. baseModDir = ''
  425. if p.moddir:
  426. baseModDir = p.moddir
  427. else:
  428. pl = sys.platform
  429. if pl in configPaths:
  430. baseModDir = os.path.expanduser(modPaths[pl])
  431. elif pl == 'win32':
  432. # this is ugly in exactly the same ways as above.
  433. # see there for more information
  434. from ctypes import *
  435. import ctypes.wintypes
  436. buf = create_unicode_buffer(ctypes.wintypes.MAX_PATH)
  437. windll.shell32.SHGetFolderPathW(0, 5, 0, 0, buf)
  438. baseDir = os.path.join(buf.value, 'My Games', 'OpenMW')
  439. baseModDir = os.path.join(baseDir, 'data')
  440. else:
  441. print("Sorry, I don't recognize the platform '%s'. You can try specifying the conf file using the '-c' flag." % p)
  442. sys.exit(1)
  443. if not os.path.exists(confFile):
  444. print("Sorry, the conf file '%s' doesn't seem to exist." % confFile)
  445. sys.exit(1)
  446. modName = ''
  447. if p.modname:
  448. modName = p.modname
  449. else:
  450. modName = 'OMWLLF Mod - %s.omwaddon' % date.today().strftime('%Y-%m-%d')
  451. modFullPath = os.path.join(baseModDir, modName)
  452. if p.dumpalchs:
  453. dumpalchs(confFile)
  454. else:
  455. main(confFile, baseModDir, modFullPath)
  456. # regarding the windows path detection:
  457. #
  458. # "SHGetFolderPath" is deprecated in favor of "SHGetKnownFolderPath", but
  459. # >>> windll.shell32.SHGetKnownFolderPath('{FDD39AD0-238F-46AF-ADB4-6C85480369C7}', 0, 0, buf2)
  460. # -2147024894