omw_shuffle_ingredients.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651
  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 parseLEVC(rec):
  75. # we're not writing these back out, and we're only
  76. # interested in the ID and the list of items, so
  77. # disregard the other info
  78. levrec = {}
  79. sr = rec['subrecords']
  80. levrec['name'] = parseString(sr[0]['data'])
  81. if len(sr) > 3:
  82. listcount = parseNum(sr[3]['data'])
  83. listitems = []
  84. for i in range(0,listcount*2,2):
  85. itemid = parseString(sr[4+i]['data'])
  86. listitems.append(itemid)
  87. levrec['items'] = listitems
  88. else:
  89. levrec['items'] = []
  90. return levrec
  91. def pullSubs(rec, subtype):
  92. return [ s for s in rec['subrecords'] if s['type'] == subtype ]
  93. def readHeader(ba):
  94. header = {}
  95. header['type'] = ba[0:4].decode()
  96. header['length'] = int.from_bytes(ba[4:8], 'little')
  97. return header
  98. def readSubRecord(ba):
  99. sr = {}
  100. sr['type'] = ba[0:4].decode()
  101. sr['length'] = int.from_bytes(ba[4:8], 'little')
  102. endbyte = 8 + sr['length']
  103. sr['data'] = ba[8:endbyte]
  104. return (sr, ba[endbyte:])
  105. def readRecords(filename):
  106. fh = open(filename, 'rb')
  107. while True:
  108. headerba = fh.read(16)
  109. if headerba is None or len(headerba) < 16:
  110. return None
  111. record = {}
  112. header = readHeader(headerba)
  113. record['type'] = header['type']
  114. record['length'] = header['length']
  115. record['subrecords'] = []
  116. # stash the filename here (a bit hacky, but useful)
  117. record['fullpath'] = filename
  118. remains = fh.read(header['length'])
  119. while len(remains) > 0:
  120. (subrecord, restofbytes) = readSubRecord(remains)
  121. record['subrecords'].append(subrecord)
  122. remains = restofbytes
  123. yield record
  124. def oldGetRecords(filename, rectype):
  125. return ( r for r in readRecords(filename) if r['type'] == rectype )
  126. def getRecords(filename, rectypes):
  127. numtypes = len(rectypes)
  128. retval = [ [] for x in range(numtypes) ]
  129. for r in readRecords(filename):
  130. if r['type'] in rectypes:
  131. for i in range(numtypes):
  132. if r['type'] == rectypes[i]:
  133. retval[i].append(r)
  134. return retval
  135. def packStringSubRecord(lbl, strval):
  136. str_bs = packString(strval) + bytes(1)
  137. l = packLong(len(str_bs))
  138. return packString(lbl) + l + str_bs
  139. def packIntSubRecord(lbl, num, numsize=4):
  140. # This is interesting. The 'pack' function from struct works fine like this:
  141. #
  142. # >>> pack('<l', 200)
  143. # b'\xc8\x00\x00\x00'
  144. #
  145. # but breaks if you make that format string a non-literal:
  146. #
  147. # >>> fs = '<l'
  148. # >>> pack(fs, 200)
  149. # Traceback (most recent call last):
  150. # File "<stdin>", line 1, in <module>
  151. # struct.error: repeat count given without format specifier
  152. #
  153. # This is as of Python 3.5.2
  154. num_bs = b''
  155. if numsize == 4:
  156. # "standard" 4-byte longs, little-endian
  157. num_bs = pack('<l', num)
  158. elif numsize == 2:
  159. num_bs = pack('<h', num)
  160. elif numsize == 1:
  161. # don't think endian-ness matters for bytes, but consistency
  162. num_bs = pack('<b', num)
  163. elif numsize == 8:
  164. num_bs = pack('<q', num)
  165. return packString(lbl) + packLong(numsize) + num_bs
  166. def packTES3(desc, numrecs, masters):
  167. start_bs = b'TES3'
  168. headerflags_bs = bytes(8)
  169. hedr_bs = b'HEDR' + packLong(300)
  170. version_bs = pack('<f', 1.0)
  171. # .esp == 0, .esm == 1, .ess == 32
  172. # suprisingly, .omwaddon == 0, also -- figured it would have its own
  173. ftype_bs = bytes(4)
  174. author_bs = packPaddedString('code copyright 2020, jmelesky', 32)
  175. desc_bs = packPaddedString(desc, 256)
  176. numrecs_bs = packLong(numrecs)
  177. masters_bs = b''
  178. for (m, s) in masters:
  179. masters_bs += packStringSubRecord('MAST', m)
  180. masters_bs += packIntSubRecord('DATA', s, 8)
  181. reclen = len(hedr_bs) + len(version_bs) + len(ftype_bs) + len(author_bs) +\
  182. len(desc_bs) + len(numrecs_bs) + len(masters_bs)
  183. reclen_bs = packLong(reclen)
  184. return start_bs + reclen_bs + headerflags_bs + \
  185. hedr_bs + version_bs + ftype_bs + author_bs + \
  186. desc_bs + numrecs_bs + masters_bs
  187. def packINGR(rec):
  188. start_bs = b'INGR'
  189. headerflags_bs = bytes(8)
  190. id_bs = packStringSubRecord('NAME', rec['id'])
  191. modl_bs = packStringSubRecord('MODL', rec['model'])
  192. name_bs = packStringSubRecord('FNAM', rec['name'])
  193. irdt_bs = b'IRDT'
  194. irdt_bs += packLong(56) # this subrecord is always length 56
  195. irdt_bs += packFloat(rec['weight'])
  196. irdt_bs += packLong(rec['value'])
  197. for i in range(0,4):
  198. irdt_bs += packLong(rec['effects'][i][0])
  199. for i in range(0,4):
  200. irdt_bs += packLong(rec['effects'][i][1])
  201. for i in range(0,4):
  202. irdt_bs += packLong(rec['effects'][i][2])
  203. icon_bs = packStringSubRecord('ITEX', rec['icon'])
  204. script_bs = b''
  205. if 'script' in rec:
  206. script_bs = packStringSubRecord('SCRI', rec['script'])
  207. reclen = len(id_bs) + len(modl_bs) + len(name_bs) + \
  208. len(irdt_bs) + len(icon_bs) + len(script_bs)
  209. reclen_bs = packLong(reclen)
  210. return start_bs + reclen_bs + headerflags_bs + id_bs + \
  211. modl_bs + name_bs + irdt_bs + icon_bs + script_bs
  212. def ppSubRecord(sr):
  213. if sr['type'] in ['NAME', 'INAM', 'CNAM', 'FNAM', 'MODL', 'TEXT', 'SCRI']:
  214. print(" %s, length %d, value '%s'" % (sr['type'], sr['length'], parseString(sr['data'])))
  215. elif sr['type'] in ['DATA', 'NNAM', 'INDX', 'INTV']:
  216. print(" %s, length %d, value '%s'" % (sr['type'], sr['length'], parseNum(sr['data'])))
  217. else:
  218. print(" %s, length %d" % (sr['type'], sr['length']))
  219. def ppRecord(rec):
  220. print("%s, length %d" % (rec['type'], rec['length']))
  221. for sr in rec['subrecords']:
  222. ppSubRecord(sr)
  223. def ppINGR(rec):
  224. print("Ingredient name: '%s'" % (rec['name']))
  225. print(" ID: '%s', file: '%s'" % (rec['id'], rec['file']))
  226. print(" Model: '%s', Icon: '%s'" % (rec['model'], rec['icon']))
  227. if 'script' in rec:
  228. print(" Script: '%s'" % (rec['script']))
  229. print(" %10s%10s%10s" % ("effect", "skill", "attribute"))
  230. for i in range(0,4):
  231. print(" %10d%10d%10d" % rec['effects'][i])
  232. def ppTES3(rec):
  233. print("TES3 record, type %d, version %f" % (rec['filetype'], rec['version']))
  234. print("author: %s" % rec['author'])
  235. print("description: %s" % rec['desc'])
  236. for (mfile, msize) in rec['masters']:
  237. print(" master %s, size %d" % (mfile, msize))
  238. print()
  239. def readCfg(cfg):
  240. # first, open the file and pull all 'data' and 'content' lines, in order
  241. data_dirs = []
  242. mods = []
  243. with open(cfg, 'r') as f:
  244. for l in f.readlines():
  245. # match of form "blah=blahblah"
  246. m = re.search(r'^(.*)=(.*)$', l)
  247. if m:
  248. varname = m.group(1).strip()
  249. # get rid of not only whitespace, but also surrounding quotes
  250. varvalue = m.group(2).strip().strip('\'"')
  251. if varname == 'data':
  252. data_dirs.append(varvalue)
  253. elif varname == 'content':
  254. mods.append(varvalue)
  255. # we've got the basenames of the mods, but not the full paths
  256. # and we have to search through the data_dirs to find them
  257. fp_mods = []
  258. for m in mods:
  259. for p in data_dirs:
  260. full_path = os.path.join(p, m)
  261. if os.path.exists(full_path):
  262. fp_mods.append(full_path)
  263. break
  264. print("Config file parsed...")
  265. return fp_mods
  266. def dumpalchs(cfg):
  267. alchs = []
  268. fp_mods = readCfg(cfg)
  269. for f in fp_mods:
  270. [ ppTES3(parseTES3(x)) for x in oldGetRecords(f, 'TES3') ]
  271. for f in fp_mods:
  272. ingrs = [ parseINGR(x) for x in oldGetRecords(f, 'INGR') ]
  273. [ ppINGR(x) for x in ingrs ]
  274. def shuffle_ingredients(ingredients):
  275. # Okay, here's what we're doing.
  276. #
  277. # First, let's take out all the ingredients that
  278. # don't have any effects. They're likely unused
  279. # or singular quest items.
  280. final_ingredients = []
  281. for ingr in ingredients:
  282. if ingr['effects'][0][0] < 0 \
  283. and ingr['effects'][1][0] < 0 \
  284. and ingr['effects'][2][0] < 0 \
  285. and ingr['effects'][3][0] < 0:
  286. final_ingredients.append(ingr)
  287. for ingr in final_ingredients:
  288. ingredients.remove(ingr)
  289. # Next, we're going to build four lists, one
  290. # each for the first, second, third, and fourth
  291. # effects.
  292. #
  293. # Why?
  294. #
  295. # We want to maintain proportions of different
  296. # effects. For example, in Vanilla, Restore
  297. # Fatigue is common as a first effect, and only
  298. # shows up once as a second effect. Likewise,
  299. # in Vanilla, some effects only appear in one
  300. # ingredient. We want to keep those
  301. # characteristics
  302. effect_lists = [[],[],[],[]]
  303. for i in range(0,4):
  304. for ingr in ingredients:
  305. if ingr['effects'][i][0] > 0:
  306. effect_lists[i].append(ingr['effects'][i])
  307. # Next, we shuffle the ingredients, then go
  308. # through each effect, assigning it to an
  309. # ingredient. At the end, move any remaining
  310. # ingredients to the final list. Repeat
  311. # until we assign all four levels of effect
  312. for i in range(0,4):
  313. shuffle(ingredients)
  314. total_effects = len(effect_lists[i])
  315. for j in range(0,total_effects):
  316. ingredients[j]['effects'][i] = effect_lists[i][j]
  317. if len(ingredients) > total_effects:
  318. final_ingredients += ingredients[total_effects:]
  319. del ingredients[total_effects:]
  320. # and then slap the rest in
  321. final_ingredients += ingredients
  322. print("first effects: %s" % len(effect_lists[0]))
  323. print("second effects: %s" % len(effect_lists[1]))
  324. print("third effects: %s" % len(effect_lists[2]))
  325. print("fourth effects: %s" % len(effect_lists[3]))
  326. print("total ingredients shuffled: %s" % len(final_ingredients))
  327. return final_ingredients
  328. def main(cfg, outmoddir, outmod):
  329. fp_mods = readCfg(cfg)
  330. # first, let's grab the "raw" records from the files
  331. (rtes3, rlevc, ringr) = ([], [], [])
  332. for f in fp_mods:
  333. print("Parsing '%s' for relevant records" % f)
  334. (rtes3t, rlevct, ringrt) = getRecords(f, ('TES3', 'LEVC', 'INGR'))
  335. rtes3 += rtes3t
  336. rlevc += rlevct
  337. ringr += ringrt
  338. # next, parse the tes3 records so we can get a list
  339. # of master files required by all our mods
  340. tes3list = [ parseTES3(x) for x in rtes3 ]
  341. masters = {}
  342. for t in tes3list:
  343. for m in t['masters']:
  344. masters[m[0]] = m[1]
  345. master_list = [ (k,v) for (k,v) in masters.items() ]
  346. # parse the levc records -- we want to sort things
  347. # as food, if the appear in a food leveled list
  348. levclist = [ parseLEVC(x) for x in rlevc ]
  349. # get a list of items that appear in lists of "food"
  350. foodset = set()
  351. for ll in levclist:
  352. if 'food' in ll['name'] or 'Food' in ll['name']:
  353. for ingr in ll['items']:
  354. foodset.add(ingr)
  355. # now parse the ingredients entries.
  356. ilist = [ parseINGR(x) for x in ringr ]
  357. # we need to uniquify the list -- mods may alter
  358. # Vanilla ingredients by replacing them
  359. idict = {}
  360. for ingr in ilist:
  361. idict[ingr['id']] = ingr
  362. new_ilist = [ x for x in idict.values() ]
  363. # now sort the ingredients into food and non-food
  364. food_ilist = []
  365. nonfood_ilist = []
  366. for ingr in new_ilist:
  367. if ingr['id'] in foodset or 'food' in ingr['id'] \
  368. or 'Food' in ingr['id']:
  369. food_ilist.append(ingr)
  370. else:
  371. nonfood_ilist.append(ingr)
  372. print("total ingredient records: %s" % (len(ilist)))
  373. print("total ingredients: %s" % (len(new_ilist)))
  374. print("total food: %s, total nonfood: %s" % (len(food_ilist), len(nonfood_ilist)))
  375. # now we build new lists with shuffled ingredient effects
  376. shuffled_foods = shuffle_ingredients(food_ilist)
  377. shuffled_nonfoods = shuffle_ingredients(nonfood_ilist)
  378. # now turn those ingredients back into INGR records
  379. #
  380. # along the way, build up the module
  381. # description for the new merged mod, out
  382. # of the names of mods that had ingredients
  383. ilist_bin = b''
  384. plugins = set()
  385. for x in shuffled_foods:
  386. ilist_bin += packINGR(x)
  387. plugins.add(x['file'])
  388. for x in shuffled_nonfoods:
  389. ilist_bin += packINGR(x)
  390. plugins.add(x['file'])
  391. moddesc = "Shuffled ingredients from: %s" % ', '.join(plugins)
  392. # finally, build the binary form of the
  393. # TES3 record, and write the whole thing
  394. # out to disk
  395. if not os.path.exists(outmoddir):
  396. p = Path(outmoddir)
  397. p.mkdir(parents=True)
  398. with open(outmod, 'wb') as f:
  399. f.write(packTES3(moddesc, len(shuffled_foods) +
  400. len(shuffled_nonfoods), master_list))
  401. f.write(ilist_bin)
  402. # And give some hopefully-useful instructions
  403. modShortName = os.path.basename(outmod)
  404. print("\n\n****************************************")
  405. 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)
  406. 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)
  407. print(" 2. %s is checked (enabled)" % modShortName)
  408. print(" 3. Any other OMW ingredient shuffler mods are *un*checked. Loading them might not cause problems, but probably will")
  409. print("\n")
  410. print(" Then, go ahead and start the game! All alchemy ingredients from all your mods should now have shuffled effects.")
  411. print("\n")
  412. if __name__ == '__main__':
  413. parser = argparse.ArgumentParser()
  414. parser.add_argument('-c', '--conffile', type = str, default = None,
  415. action = 'store', required = False,
  416. help = 'Conf file to use. Optional. By default, attempts to use the default conf file location.')
  417. parser.add_argument('-d', '--moddir', type = str, default = None,
  418. action = 'store', required = False,
  419. help = 'Directory to store the new module in. By default, attempts to use the default work directory for OpenMW-CS')
  420. parser.add_argument('-m', '--modname', type = str, default = None,
  421. action = 'store', required = False,
  422. help = 'Name of the new module to create. By default, this is "Shuffled Ingredients - <today\'s date>.omwaddon.')
  423. parser.add_argument('--dumpalchs', default = False,
  424. action = 'store_true', required = False,
  425. help = 'Instead of generating merged lists, dump all alchemy ingredients in the conf mods. Used for debugging')
  426. p = parser.parse_args()
  427. # determine the conf file to use
  428. confFile = ''
  429. if p.conffile:
  430. confFile = p.conffile
  431. else:
  432. pl = sys.platform
  433. if pl in configPaths:
  434. baseDir = os.path.expanduser(configPaths[pl])
  435. confFile = os.path.join(baseDir, configFilename)
  436. elif pl == 'win32':
  437. # this is ugly. first, imports that only work properly on windows
  438. from ctypes import *
  439. import ctypes.wintypes
  440. buf = create_unicode_buffer(ctypes.wintypes.MAX_PATH)
  441. # opaque arguments. they are, roughly, for our purposes:
  442. # - an indicator of folder owner (0 == current user)
  443. # - an id for the type of folder (5 == 'My Documents')
  444. # - an indicator for user to call from (0 same as above)
  445. # - a bunch of flags for different things
  446. # (if you want, for example, to get the default path
  447. # instead of the actual path, or whatnot)
  448. # 0 == current stuff
  449. # - the variable to hold the return value
  450. windll.shell32.SHGetFolderPathW(0, 5, 0, 0, buf)
  451. # pull out the return value and construct the rest
  452. baseDir = os.path.join(buf.value, 'My Games', 'OpenMW')
  453. confFile = os.path.join(baseDir, configFilename)
  454. else:
  455. print("Sorry, I don't recognize the platform '%s'. You can try specifying the conf file using the '-c' flag." % p)
  456. sys.exit(1)
  457. baseModDir = ''
  458. if p.moddir:
  459. baseModDir = p.moddir
  460. else:
  461. pl = sys.platform
  462. if pl in configPaths:
  463. baseModDir = os.path.expanduser(modPaths[pl])
  464. elif pl == 'win32':
  465. # this is ugly in exactly the same ways as above.
  466. # see there for more information
  467. from ctypes import *
  468. import ctypes.wintypes
  469. buf = create_unicode_buffer(ctypes.wintypes.MAX_PATH)
  470. windll.shell32.SHGetFolderPathW(0, 5, 0, 0, buf)
  471. baseDir = os.path.join(buf.value, 'My Games', 'OpenMW')
  472. baseModDir = os.path.join(baseDir, 'data')
  473. else:
  474. print("Sorry, I don't recognize the platform '%s'. You can try specifying the conf file using the '-c' flag." % p)
  475. sys.exit(1)
  476. if not os.path.exists(confFile):
  477. print("Sorry, the conf file '%s' doesn't seem to exist." % confFile)
  478. sys.exit(1)
  479. modName = ''
  480. if p.modname:
  481. modName = p.modname
  482. else:
  483. modName = 'Shuffled Ingredients - %s.omwaddon' % date.today().strftime('%Y-%m-%d')
  484. modFullPath = os.path.join(baseModDir, modName)
  485. if p.dumpalchs:
  486. dumpalchs(confFile)
  487. else:
  488. main(confFile, baseModDir, modFullPath)
  489. # regarding the windows path detection:
  490. #
  491. # "SHGetFolderPath" is deprecated in favor of "SHGetKnownFolderPath", but
  492. # >>> windll.shell32.SHGetKnownFolderPath('{FDD39AD0-238F-46AF-ADB4-6C85480369C7}', 0, 0, buf2)
  493. # -2147024894