omw_shuffle_ingredients.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595
  1. #!/usr/bin/env python3
  2. from struct import pack, unpack
  3. from datetime import date
  4. from pathlib import Path
  5. from random import shuffle
  6. import os.path
  7. import argparse
  8. import sys
  9. import re
  10. configFilename = 'openmw.cfg'
  11. configPaths = { 'linux': '~/.config/openmw',
  12. 'freebsd': '~/.config/openmw',
  13. 'darwin': '~/Library/Preferences/openmw' }
  14. modPaths = { 'linux': '~/.local/share/openmw/data',
  15. 'freebsd': '~/.local/share/openmw/data',
  16. 'darwin': '~/Library/Application Support/openmw/data' }
  17. def packLong(i):
  18. # little-endian, "standard" 4-bytes (old 32-bit systems)
  19. return pack('<l', i)
  20. def packFloat(i):
  21. return pack('<f', i)
  22. def packString(s):
  23. return bytes(s, 'ascii')
  24. def packPaddedString(s, l):
  25. bs = bytes(s, 'ascii')
  26. if len(bs) > l:
  27. # still need to null-terminate
  28. return bs[:(l-1)] + bytes(1)
  29. else:
  30. return bs + bytes(l - len(bs))
  31. def parseString(ba):
  32. i = ba.find(0)
  33. return ba[:i].decode(encoding='ascii', errors='ignore')
  34. def parseNum(ba):
  35. return int.from_bytes(ba, 'little', signed=True)
  36. def parseFloat(ba):
  37. return unpack('f', ba)[0]
  38. def parseTES3(rec):
  39. tesrec = {}
  40. sr = rec['subrecords']
  41. tesrec['version'] = parseFloat(sr[0]['data'][0:4])
  42. tesrec['filetype'] = parseNum(sr[0]['data'][4:8])
  43. tesrec['author'] = parseString(sr[0]['data'][8:40])
  44. tesrec['desc'] = parseString(sr[0]['data'][40:296])
  45. tesrec['numrecords'] = parseNum(sr[0]['data'][296:300])
  46. masters = []
  47. for i in range(1, len(sr), 2):
  48. mastfile = parseString(sr[i]['data'])
  49. mastsize = parseNum(sr[i+1]['data'])
  50. masters.append((mastfile, mastsize))
  51. tesrec['masters'] = masters
  52. return tesrec
  53. def parseINGR(rec):
  54. ingrrec = {}
  55. sr = rec['subrecords']
  56. ingrrec['id'] = parseString(sr[0]['data'])
  57. ingrrec['model'] = parseString(sr[1]['data'])
  58. ingrrec['name'] = parseString(sr[2]['data'])
  59. attr_struct = sr[3]['data']
  60. ingrrec['weight'] = parseFloat(attr_struct[0:4])
  61. ingrrec['value'] = parseNum(attr_struct[4:8])
  62. effect_tuples = []
  63. for i in range(0,4):
  64. effect = parseNum(attr_struct[(8+i*4):(12+i*4)])
  65. skill = parseNum(attr_struct[(24+i*4):(28+i*4)])
  66. attribute = parseNum(attr_struct[(40+i*4):(44+i*4)])
  67. effect_tuples.append((effect, skill, attribute))
  68. ingrrec['effects'] = effect_tuples
  69. ingrrec['icon'] = parseString(sr[4]['data'])
  70. if len(sr) > 5:
  71. ingrrec['script'] = parseString(sr[5]['data'])
  72. ingrrec['file'] = os.path.basename(rec['fullpath'])
  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 packTES3(desc, numrecs, masters):
  150. start_bs = b'TES3'
  151. headerflags_bs = bytes(8)
  152. hedr_bs = b'HEDR' + packLong(300)
  153. version_bs = pack('<f', 1.0)
  154. # .esp == 0, .esm == 1, .ess == 32
  155. # suprisingly, .omwaddon == 0, also -- figured it would have its own
  156. ftype_bs = bytes(4)
  157. author_bs = packPaddedString('code copyright 2020, jmelesky', 32)
  158. desc_bs = packPaddedString(desc, 256)
  159. numrecs_bs = packLong(numrecs)
  160. masters_bs = b''
  161. for (m, s) in masters:
  162. masters_bs += packStringSubRecord('MAST', m)
  163. masters_bs += packIntSubRecord('DATA', s, 8)
  164. reclen = len(hedr_bs) + len(version_bs) + len(ftype_bs) + len(author_bs) +\
  165. len(desc_bs) + len(numrecs_bs) + len(masters_bs)
  166. reclen_bs = packLong(reclen)
  167. return start_bs + reclen_bs + headerflags_bs + \
  168. hedr_bs + version_bs + ftype_bs + author_bs + \
  169. desc_bs + numrecs_bs + masters_bs
  170. def packINGR(rec):
  171. start_bs = b'INGR'
  172. headerflags_bs = bytes(8)
  173. id_bs = packStringSubRecord('NAME', rec['id'])
  174. modl_bs = packStringSubRecord('MODL', rec['model'])
  175. name_bs = packStringSubRecord('FNAM', rec['name'])
  176. irdt_bs = b'IRDT'
  177. irdt_bs += packLong(56) # this subrecord is always length 56
  178. irdt_bs += packFloat(rec['weight'])
  179. irdt_bs += packLong(rec['value'])
  180. for i in range(0,4):
  181. irdt_bs += packLong(rec['effects'][i][0])
  182. for i in range(0,4):
  183. irdt_bs += packLong(rec['effects'][i][1])
  184. for i in range(0,4):
  185. irdt_bs += packLong(rec['effects'][i][2])
  186. icon_bs = packStringSubRecord('ITEX', rec['icon'])
  187. script_bs = b''
  188. if 'script' in rec:
  189. script_bs = packStringSubRecord('SCRI', rec['script'])
  190. reclen = len(id_bs) + len(modl_bs) + len(name_bs) + \
  191. len(irdt_bs) + len(icon_bs) + len(script_bs)
  192. reclen_bs = packLong(reclen)
  193. return start_bs + reclen_bs + headerflags_bs + id_bs + \
  194. modl_bs + name_bs + irdt_bs + icon_bs + script_bs
  195. def ppSubRecord(sr):
  196. if sr['type'] in ['NAME', 'INAM', 'CNAM', 'FNAM', 'MODL', 'TEXT', 'SCRI']:
  197. print(" %s, length %d, value '%s'" % (sr['type'], sr['length'], parseString(sr['data'])))
  198. elif sr['type'] in ['DATA', 'NNAM', 'INDX', 'INTV']:
  199. print(" %s, length %d, value '%s'" % (sr['type'], sr['length'], parseNum(sr['data'])))
  200. else:
  201. print(" %s, length %d" % (sr['type'], sr['length']))
  202. def ppRecord(rec):
  203. print("%s, length %d" % (rec['type'], rec['length']))
  204. for sr in rec['subrecords']:
  205. ppSubRecord(sr)
  206. def ppINGR(rec):
  207. print("Ingredient name: '%s'" % (rec['name']))
  208. print(" ID: '%s', file: '%s'" % (rec['id'], rec['file']))
  209. print(" Model: '%s', Icon: '%s'" % (rec['model'], rec['icon']))
  210. if 'script' in rec:
  211. print(" Script: '%s'" % (rec['script']))
  212. print(" %10s%10s%10s" % ("effect", "skill", "attribute"))
  213. for i in range(0,4):
  214. print(" %10d%10d%10d" % rec['effects'][i])
  215. def ppTES3(rec):
  216. print("TES3 record, type %d, version %f" % (rec['filetype'], rec['version']))
  217. print("author: %s" % rec['author'])
  218. print("description: %s" % rec['desc'])
  219. for (mfile, msize) in rec['masters']:
  220. print(" master %s, size %d" % (mfile, msize))
  221. print()
  222. def readCfg(cfg):
  223. # first, open the file and pull all 'data' and 'content' lines, in order
  224. data_dirs = []
  225. mods = []
  226. with open(cfg, 'r') as f:
  227. for l in f.readlines():
  228. # match of form "blah=blahblah"
  229. m = re.search(r'^(.*)=(.*)$', l)
  230. if m:
  231. varname = m.group(1).strip()
  232. # get rid of not only whitespace, but also surrounding quotes
  233. varvalue = m.group(2).strip().strip('\'"')
  234. if varname == 'data':
  235. data_dirs.append(varvalue)
  236. elif varname == 'content':
  237. mods.append(varvalue)
  238. # we've got the basenames of the mods, but not the full paths
  239. # and we have to search through the data_dirs to find them
  240. fp_mods = []
  241. for m in mods:
  242. for p in data_dirs:
  243. full_path = os.path.join(p, m)
  244. if os.path.exists(full_path):
  245. fp_mods.append(full_path)
  246. break
  247. print("Config file parsed...")
  248. return fp_mods
  249. def dumpalchs(cfg):
  250. alchs = []
  251. fp_mods = readCfg(cfg)
  252. for f in fp_mods:
  253. [ ppTES3(parseTES3(x)) for x in oldGetRecords(f, 'TES3') ]
  254. for f in fp_mods:
  255. ingrs = [ parseINGR(x) for x in oldGetRecords(f, 'INGR') ]
  256. [ ppINGR(x) for x in ingrs ]
  257. def shuffle_ingredients(ingredients):
  258. # Okay, here's what we're doing.
  259. #
  260. # First, let's take out all the ingredients that
  261. # don't have any effects. They're likely unused
  262. # or singular quest items.
  263. final_ingredients = []
  264. for ingr in ingredients:
  265. if ingr['effects'][0][0] < 0 \
  266. and ingr['effects'][1][0] < 0 \
  267. and ingr['effects'][2][0] < 0 \
  268. and ingr['effects'][3][0] < 0:
  269. final_ingredients.append(ingr)
  270. for ingr in final_ingredients:
  271. ingredients.remove(ingr)
  272. # Next, we're going to build four lists, one
  273. # each for the first, second, third, and fourth
  274. # effects.
  275. #
  276. # Why?
  277. #
  278. # We want to maintain proportions of different
  279. # effects. For example, in Vanilla, Restore
  280. # Fatigue is common as a first effect, and only
  281. # shows up once as a second effect. Likewise,
  282. # in Vanilla, some effects only appear in one
  283. # ingredient. We want to keep those
  284. # characteristics
  285. effect_lists = [[],[],[],[]]
  286. for i in range(0,4):
  287. for ingr in ingredients:
  288. if ingr['effects'][i][0] > 0:
  289. effect_lists[i].append(ingr['effects'][i])
  290. # Next, we shuffle the ingredients, then go
  291. # through each effect, assigning it to an
  292. # ingredient. At the end, move any remaining
  293. # ingredients to the final list. Repeat
  294. # until we assign all four levels of effect
  295. for i in range(0,4):
  296. shuffle(ingredients)
  297. total_effects = len(effect_lists[i])
  298. for j in range(0,total_effects):
  299. ingredients[j]['effects'][i] = effect_lists[i][j]
  300. if len(ingredients) > total_effects:
  301. final_ingredients += ingredients[total_effects:]
  302. del ingredients[total_effects:]
  303. # and then slap the rest in
  304. final_ingredients += ingredients
  305. print("first effects: %s" % len(effect_lists[0]))
  306. print("second effects: %s" % len(effect_lists[1]))
  307. print("third effects: %s" % len(effect_lists[2]))
  308. print("fourth effects: %s" % len(effect_lists[3]))
  309. print("total ingredients shuffled: %s" % len(final_ingredients))
  310. return final_ingredients
  311. def main(cfg, outmoddir, outmod):
  312. fp_mods = readCfg(cfg)
  313. # first, let's grab the "raw" records from the files
  314. (rtes3, ringr) = ([], [])
  315. for f in fp_mods:
  316. print("Parsing '%s' for relevant records" % f)
  317. (rtes3t, ringrt) = getRecords(f, ('TES3', 'INGR'))
  318. rtes3 += rtes3t
  319. ringr += ringrt
  320. # next, parse the tes3 records so we can get a list
  321. # of master files required by all our mods
  322. tes3list = [ parseTES3(x) for x in rtes3 ]
  323. masters = {}
  324. for t in tes3list:
  325. for m in t['masters']:
  326. masters[m[0]] = m[1]
  327. master_list = [ (k,v) for (k,v) in masters.items() ]
  328. # now parse the ingredients entries.
  329. ilist = [ parseINGR(x) for x in ringr ]
  330. # we need to uniquify the list -- mods may alter
  331. # Vanilla ingredients by replacing them
  332. idict = {}
  333. for ingr in ilist:
  334. idict[ingr['id']] = ingr
  335. print("total ingredient records: %s" % (len(ilist)))
  336. print("total ingredients: %s" % (len(idict)))
  337. new_ilist = [ x for x in idict.values() ]
  338. # now we build a list with shuffled ingredient effects
  339. shuffled_ilist = shuffle_ingredients(new_ilist)
  340. # now turn those ingredients back into INGR records
  341. #
  342. # along the way, build up the module
  343. # description for the new merged mod, out
  344. # of the names of mods that had ingredients
  345. ilist_bin = b''
  346. pluginlist = []
  347. for x in shuffled_ilist:
  348. ilist_bin += packINGR(x)
  349. pluginlist += x['file']
  350. plugins = set(pluginlist)
  351. moddesc = "Shuffled ingredients from: %s" % ', '.join(plugins)
  352. # finally, build the binary form of the
  353. # TES3 record, and write the whole thing
  354. # out to disk
  355. if not os.path.exists(outmoddir):
  356. p = Path(outmoddir)
  357. p.mkdir(parents=True)
  358. with open(outmod, 'wb') as f:
  359. f.write(packTES3(moddesc, len(shuffled_ilist), master_list))
  360. f.write(ilist_bin)
  361. # And give some hopefully-useful instructions
  362. modShortName = os.path.basename(outmod)
  363. print("\n\n****************************************")
  364. 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)
  365. 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)
  366. print(" 2. %s is checked (enabled)" % modShortName)
  367. print(" 3. Any other OMW ingredient shuffler mods are *un*checked. Loading them might not cause problems, but probably will")
  368. print("\n")
  369. print(" Then, go ahead and start the game! All alchemy ingredients from all your mods should now have shuffled effects.")
  370. print("\n")
  371. if __name__ == '__main__':
  372. parser = argparse.ArgumentParser()
  373. parser.add_argument('-c', '--conffile', type = str, default = None,
  374. action = 'store', required = False,
  375. help = 'Conf file to use. Optional. By default, attempts to use the default conf file location.')
  376. parser.add_argument('-d', '--moddir', type = str, default = None,
  377. action = 'store', required = False,
  378. help = 'Directory to store the new module in. By default, attempts to use the default work directory for OpenMW-CS')
  379. parser.add_argument('-m', '--modname', type = str, default = None,
  380. action = 'store', required = False,
  381. help = 'Name of the new module to create. By default, this is "Shuffled Ingredients - <today\'s date>.omwaddon.')
  382. parser.add_argument('--dumpalchs', default = False,
  383. action = 'store_true', required = False,
  384. help = 'Instead of generating merged lists, dump all alchemy ingredients in the conf mods. Used for debugging')
  385. p = parser.parse_args()
  386. # determine the conf file to use
  387. confFile = ''
  388. if p.conffile:
  389. confFile = p.conffile
  390. else:
  391. pl = sys.platform
  392. if pl in configPaths:
  393. baseDir = os.path.expanduser(configPaths[pl])
  394. confFile = os.path.join(baseDir, configFilename)
  395. elif pl == 'win32':
  396. # this is ugly. first, imports that only work properly on windows
  397. from ctypes import *
  398. import ctypes.wintypes
  399. buf = create_unicode_buffer(ctypes.wintypes.MAX_PATH)
  400. # opaque arguments. they are, roughly, for our purposes:
  401. # - an indicator of folder owner (0 == current user)
  402. # - an id for the type of folder (5 == 'My Documents')
  403. # - an indicator for user to call from (0 same as above)
  404. # - a bunch of flags for different things
  405. # (if you want, for example, to get the default path
  406. # instead of the actual path, or whatnot)
  407. # 0 == current stuff
  408. # - the variable to hold the return value
  409. windll.shell32.SHGetFolderPathW(0, 5, 0, 0, buf)
  410. # pull out the return value and construct the rest
  411. baseDir = os.path.join(buf.value, 'My Games', 'OpenMW')
  412. confFile = os.path.join(baseDir, configFilename)
  413. else:
  414. print("Sorry, I don't recognize the platform '%s'. You can try specifying the conf file using the '-c' flag." % p)
  415. sys.exit(1)
  416. baseModDir = ''
  417. if p.moddir:
  418. baseModDir = p.moddir
  419. else:
  420. pl = sys.platform
  421. if pl in configPaths:
  422. baseModDir = os.path.expanduser(modPaths[pl])
  423. elif pl == 'win32':
  424. # this is ugly in exactly the same ways as above.
  425. # see there for more information
  426. from ctypes import *
  427. import ctypes.wintypes
  428. buf = create_unicode_buffer(ctypes.wintypes.MAX_PATH)
  429. windll.shell32.SHGetFolderPathW(0, 5, 0, 0, buf)
  430. baseDir = os.path.join(buf.value, 'My Games', 'OpenMW')
  431. baseModDir = os.path.join(baseDir, 'data')
  432. else:
  433. print("Sorry, I don't recognize the platform '%s'. You can try specifying the conf file using the '-c' flag." % p)
  434. sys.exit(1)
  435. if not os.path.exists(confFile):
  436. print("Sorry, the conf file '%s' doesn't seem to exist." % confFile)
  437. sys.exit(1)
  438. modName = ''
  439. if p.modname:
  440. modName = p.modname
  441. else:
  442. modName = 'Shuffled Ingredients - %s.omwaddon' % date.today().strftime('%Y-%m-%d')
  443. modFullPath = os.path.join(baseModDir, modName)
  444. if p.dumpalchs:
  445. dumpalchs(confFile)
  446. else:
  447. main(confFile, baseModDir, modFullPath)
  448. # regarding the windows path detection:
  449. #
  450. # "SHGetFolderPath" is deprecated in favor of "SHGetKnownFolderPath", but
  451. # >>> windll.shell32.SHGetKnownFolderPath('{FDD39AD0-238F-46AF-ADB4-6C85480369C7}', 0, 0, buf2)
  452. # -2147024894