forked from wrye-bash/wrye-bash
/
__init__.py
3466 lines (3143 loc) · 149 KB
/
__init__.py
1
2
3
4
5
6
7
8
9
10
11
12
13
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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# -*- coding: utf-8 -*-
#
# GPL License and Copyright Notice ============================================
# This file is part of Wrye Bash.
#
# Wrye Bash is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# Wrye Bash is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Wrye Bash; if not, write to the Free Software Foundation,
# Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
# Wrye Bash copyright (C) 2005-2009 Wrye, 2010-2015 Wrye Bash Team
# https://github.com/wrye-bash
#
# =============================================================================
"""This module defines objects and functions for working with Oblivion
files and environment. It does not provide interface functions which are instead
provided by separate modules: bish for CLI and bash/basher for GUI."""
############# bush.game must be set by the time you import bosh ! #############
# Imports ---------------------------------------------------------------------
#--Python
import cPickle
import collections
import errno
import os
import re
import string
import struct
import sys
import time
import traceback
from collections import OrderedDict
from functools import wraps, partial
from itertools import imap
#--Local
from ._mergeability import isPBashMergeable, isCBashMergeable
from .mods_metadata import ConfigHelpers
from .. import bass, bolt, balt, bush, env, load_order, archives
from .. import patcher # for configIsCBash()
from ..archives import readExts
from ..bass import dirs, inisettings, tooldirs
from ..bolt import GPath, DataDict, cstrip, deprint, sio, Path, decode, \
unpack_many, unpack_byte, struct_pack, struct_unpack
from ..brec import MreRecord, ModReader
from ..cint import CBashApi
from ..exception import AbstractError, ArgumentError, BoltError, BSAError, \
CancelError, FileError, ModError, NonExistentDriveError, PermissionError, \
PluginsFullError, SaveFileError, SaveHeaderError, SkipError, StateError
from ..parsers import ModFile
#--Settings
settings = None
try:
allTags = bush.game.allTags
allTagsSet = set(allTags)
except AttributeError: # 'NoneType' object has no attribute 'allTags'
pass
oldTags = sorted((u'Merge',))
oldTagsSet = set(oldTags)
reOblivion = re.compile(
u'^(Oblivion|Nehrim)(|_SI|_1.1|_1.1b|_1.5.0.8|_GOTY non-SI).esm$', re.U)
undefinedPath = GPath(u'C:\\not\\a\\valid\\path.exe')
empty_path = GPath(u'')
undefinedPaths = {GPath(u'C:\\Path\\exe.exe'), undefinedPath}
# Singletons, Constants -------------------------------------------------------
#--Constants
#..Bit-and this with the fid to get the objectindex.
oiMask = 0xFFFFFFL
#--Singletons
gameInis = None # type: tuple[OblivionIni]
oblivionIni = None # type: OblivionIni
modInfos = None # type: ModInfos
saveInfos = None # type: SaveInfos
iniInfos = None # type: INIInfos
bsaInfos = None # type: BSAInfos
screensData = None # type: ScreensData
#--Config Helper files (LOOT Master List, etc.)
configHelpers = None # type: mods_metadata.ConfigHelpers
#--Header tags
reVersion = re.compile(
ur'^(version[:.]*|ver[:.]*|rev[:.]*|r[:.\s]+|v[:.\s]+) *([-0-9a-zA-Z.]*\+?)',
re.M | re.I | re.U)
#--Mod Extensions
__exts = ur'((\.(' + ur'|'.join(ext[1:] for ext in readExts) + ur'))|)$'
reTesNexus = re.compile(ur'(.*?)(?:-(\d{1,6})(?:\.tessource)?(?:-bain)'
ur'?(?:-\d{0,6})?(?:-\d{0,6})?(?:-\d{0,6})?(?:-\w{0,16})?(?:\w)?)?'
+ __exts, re.I | re.U)
reTESA = re.compile(ur'(.*?)(?:-(\d{1,6})(?:\.tessource)?(?:-bain)?)?'
+ __exts, re.I | re.U)
del __exts
imageExts = {u'.gif', u'.jpg', u'.png', u'.jpeg', u'.bmp', u'.tif'}
#------------------------------------------------------------------------------
class CoSaves:
"""Handles co-files (.pluggy, .obse, .skse) for saves."""
try:
reSave = re.compile(ur'\.' + bush.game.ess.ext[1:] + '(f?)$',
re.I | re.U)
except AttributeError: # 'NoneType' object has no attribute 'ess'
pass
@staticmethod
def getPaths(savePath):
"""Returns cofile paths."""
maSave = CoSaves.reSave.search(savePath.s)
if maSave: savePath = savePath.root
first = maSave and maSave.group(1) or u''
return tuple(savePath + ext + first for ext in
(u'.pluggy', u'.' + bush.game.se.shortName.lower()))
def __init__(self,savePath,saveName=None):
"""Initialize with savePath."""
if saveName: savePath = savePath.join(saveName)
self.savePath = savePath
self.paths = CoSaves.getPaths(savePath)
def _recopy(self, savePath, saveName, pathFunc):
"""Renames/copies cofiles depending on supplied pathFunc."""
if saveName: savePath = savePath.join(saveName)
newPaths = CoSaves.getPaths(savePath)
for oldPath,newPath in zip(self.paths,newPaths):
if newPath.exists(): newPath.remove() ##: dont like it, investigate
if oldPath.exists(): pathFunc(oldPath,newPath)
def copy(self,savePath,saveName=None):
"""Copies cofiles."""
self._recopy(savePath, saveName, bolt.Path.copyTo)
def move(self,savePath,saveName=None):
"""Renames cofiles."""
self._recopy(savePath, saveName, bolt.Path.moveTo)
@staticmethod
def get_new_paths(old_path, new_path):
return zip(CoSaves.getPaths(old_path), CoSaves.getPaths(new_path))
def getTags(self):
"""Returns tags expressing whether cosaves exist and are correct."""
cPluggy,cObse = (u'',u'')
save = self.savePath
pluggy,obse = self.paths
if pluggy.exists():
cPluggy = u'XP'[abs(pluggy.mtime - save.mtime) < 10]
if obse.exists():
cObse = u'XO'[abs(obse.mtime - save.mtime) < 10]
return cObse,cPluggy
# File System -----------------------------------------------------------------
#------------------------------------------------------------------------------
class BsaFile:
"""Represents a BSA archive file."""
@staticmethod
def getHash(fileName):
"""Returns tes4's two hash values for filename.
Based on Timeslips code with cleanup and pythonization."""
#--NOTE: fileName is NOT a Path object!
root,ext = os.path.splitext(fileName.lower())
#--Hash1
chars = map(ord,root)
hash1 = chars[-1] | ((len(chars)>2 and chars[-2]) or 0)<<8 | len(chars)<<16 | chars[0]<<24
if ext == u'.kf': hash1 |= 0x80
elif ext == u'.nif': hash1 |= 0x8000
elif ext == u'.dds': hash1 |= 0x8080
elif ext == u'.wav': hash1 |= 0x80000000
#--Hash2
uintMask, hash2, hash3 = 0xFFFFFFFF, 0, 0
for char in chars[1:-2]:
hash2 = ((hash2 * 0x1003F) + char ) & uintMask
for char in map(ord,ext):
hash3 = ((hash3 * 0x1003F) + char ) & uintMask
hash2 = (hash2 + hash3) & uintMask
#--Done
return (hash2<<32) + hash1
#--Instance Methods ------------------------------------------------------
def __init__(self,path):
self.path = path
self.folderInfos = None
def scan(self):
"""Reports on contents."""
with open(self.path.s,'rb') as ins:
#--Header
ins.seek(4*4)
(self.folderCount,self.fileCount,lenFolderNames,lenFileNames,fileFlags) = unpack_many(ins, '5I')
#--FolderInfos (Initial)
folderInfos = self.folderInfos = []
for index in range(self.folderCount):
hash,subFileCount,offset = unpack_many(ins, 'Q2I')
folderInfos.append([hash,subFileCount,offset])
#--Update folderInfos
for index,folderInfo in enumerate(folderInfos):
fileInfos = []
folderName = cstrip(ins.read(unpack_byte(ins)))
folderInfos[index].extend((folderName,fileInfos))
for index in range(folderInfo[1]):
filePos = ins.tell()
hash,size,offset = unpack_many(ins, 'Q2I')
fileInfos.append([hash,size,offset,u'',filePos])
#--File Names
fileNames = [decode(x) for x in ins.read(lenFileNames).split('\x00')[:-1]]
namesIter = iter(fileNames)
for folderInfo in folderInfos:
fileInfos = folderInfo[-1]
for index,fileInfo in enumerate(fileInfos):
fileInfo[3] = namesIter.next()
#--Done
def report(self,printAll=False):
"""Report on contents."""
folderInfos = self.folderInfos
getHash = BsaFile.getHash
print self.folderCount,self.fileCount,sum(len(info[-1]) for info in folderInfos)
for folderInfo in folderInfos:
printOnce = folderInfo[-2]
for fileInfo in folderInfo[-1]:
hash,fileName = fileInfo[0],fileInfo[3]
trueHash = getHash(fileName)
@staticmethod
def updateAIText(files=None):
"""Update aiText with specified files (or remove, if files == None)."""
aiPath = dirs['app'].join(u'ArchiveInvalidation.txt')
if not files:
aiPath.remove()
return
#--Archive invalidation
aiText = re.sub(ur'\\',u'/',u'\n'.join(files))
with aiPath.open('w') as f:
f.write(aiText)
@staticmethod
def resetOblivionBSAMTimes():
"""Reset dates of bsa files to 'correct' values."""
#--Fix the data of a few archive files
bsaTimes = (
(u'Oblivion - Meshes.bsa',1138575220),
(u'Oblivion - Misc.bsa',1139433736),
(u'Oblivion - Sounds.bsa',1138660560),
(inisettings['OblivionTexturesBSAName'].stail, 1138162634),
(u'Oblivion - Voices1.bsa',1138162934),
(u'Oblivion - Voices2.bsa',1138166742),
)
for bsaFile,mtime in bsaTimes:
dirs['mods'].join(bsaFile).mtime = mtime
def reset(self):
"""Resets BSA archive hashes to correct values."""
with open(self.path.s,'r+b') as ios:
#--Rehash
resetCount = 0
folderInfos = self.folderInfos
getHash = BsaFile.getHash
for folderInfo in folderInfos:
for fileInfo in folderInfo[-1]:
hash,size,offset,fileName,filePos = fileInfo
trueHash = getHash(fileName)
if hash != trueHash:
#print ' ',fileName,'\t',hex(hash-trueHash),hex(hash),hex(trueHash)
ios.seek(filePos)
ios.write(struct_pack('Q',trueHash))
resetCount += 1
#--Done
self.resetOblivionBSAMTimes()
self.updateAIText()
return resetCount
#------------------------------------------------------------------------------
class AFile(object):
"""Abstract file, supports caching - alpha."""
_null_stat = (-1, None)
def _stat_tuple(self): return self.abs_path.size_mtime()
def __init__(self, abs_path, load_cache=False):
self._abs_path = GPath(abs_path)
#Set cache info (mtime, size[, ctime]) and reload if load_cache is True
try:
self._reset_cache(self._stat_tuple(), load_cache)
except OSError:
self._reset_cache(self._null_stat, load_cache=False)
@property
def abs_path(self): return self._abs_path
@abs_path.setter
def abs_path(self, val): self._abs_path = val
def do_update(self):
"""Check cache, reset it if needed. Return True if reset else False."""
try:
stat_tuple = self._stat_tuple()
except OSError:
self._reset_cache(self._null_stat, load_cache=False)
return False # we should not call do_update on deleted files
if self._file_changed(stat_tuple):
self._reset_cache(stat_tuple, load_cache=True)
return True
return False
def _file_changed(self, stat_tuple):
return (self._file_size, self._file_mod_time) != stat_tuple
def _reset_cache(self, stat_tuple, load_cache):
"""Reset cache flags (size, mtime,...) and possibly reload the cache.
:param load_cache: if True either load the cache (header in Mod and
SaveInfo) or reset it so it gets reloaded later
"""
self._file_size, self._file_mod_time = stat_tuple
def __repr__(self): return self.__class__.__name__ + u"<" + repr(
self.abs_path.stail) + u">"
#------------------------------------------------------------------------------
class MasterInfo:
def _init_master_info(self):
if self.modInfo:
self.mtime = self.modInfo.mtime
self.masterNames = self.modInfo.masterNames
else:
self.mtime = 0
self.masterNames = tuple()
def __init__(self, name):
self.oldName = self.name = GPath(name)
self.modInfo = modInfos.get(self.name,None)
self.isGhost = self.modInfo and self.modInfo.isGhost
self._init_master_info()
def setName(self,name):
self.name = GPath(name)
self.modInfo = modInfos.get(self.name,None)
self._init_master_info()
def hasChanged(self):
return self.name != self.oldName
def isEsm(self):
if self.modInfo:
return self.modInfo.isEsm()
else:
return self.name.cext == u'.esm'
def is_esml(self):
if self.modInfo:
return self.modInfo.is_esml()
else:
return self.name.cext == u'.esm' or self.name.cext == u'.esl'
def hasTimeConflict(self):
"""True if has an mtime conflict with another mod."""
if self.modInfo:
return self.modInfo.hasTimeConflict()
else:
return False
def hasActiveTimeConflict(self):
"""True if has an active mtime conflict with another mod."""
if self.modInfo:
return self.modInfo.hasActiveTimeConflict()
else:
return False
def getBashTags(self):
"""Retrieve bash tags for master info if it's present in Data."""
if self.modInfo:
return self.modInfo.getBashTags()
else:
return set()
def getStatus(self):
if not self.modInfo:
return 30
else:
return 0
def __repr__(self):
return self.__class__.__name__ + u"<" + repr(self.name) + u">"
#------------------------------------------------------------------------------
class FileInfo(AFile):
"""Abstract Mod, Save or BSA File. Features a half baked Backup API."""
_null_stat = (-1, None, None)
def _stat_tuple(self): return self.abs_path.size_mtime_ctime()
def __init__(self, parent_dir, name, load_cache=False):
self.dir = GPath(parent_dir)
self.name = GPath(name) # ghost must be lopped off
self.header = None
self.masterNames = tuple()
self.masterOrder = tuple()
self.madeBackup = False
#--Ancillary storage
self.extras = {}
super(FileInfo, self).__init__(self.dir.join(name), load_cache)
def _reset_masters(self):
#--Master Names/Order
self.masterNames = tuple(self.header.masters)
self.masterOrder = tuple() #--Reset to empty for now
def _file_changed(self, stat_tuple):
return (self._file_size, self._file_mod_time, self.ctime) != stat_tuple
def _reset_cache(self, stat_tuple, load_cache):
self._file_size, self._file_mod_time, self.ctime = stat_tuple
if load_cache: self.readHeader()
def mark_unchanged(self):
self._reset_cache(self._stat_tuple(), load_cache=False)
##: DEPRECATED-------------------------------------------------------------
def getPath(self): return self.abs_path
@property
def mtime(self): return self._file_mod_time
@property
def size(self): return self._file_size
#--------------------------------------------------------------------------
#--File type tests ##: Belong to ModInfo!
#--Note that these tests only test extension, not the file data.
def isMod(self):
return ModInfos.rightFileType(self.name)
def setmtime(self, set_time=0, crc_changed=False):
"""Sets mtime. Defaults to current value (i.e. reset)."""
set_time = int(set_time or self.mtime)
self.abs_path.mtime = set_time
self._file_mod_time = set_time
return set_time
def readHeader(self):
"""Read header from file and set self.header attribute."""
pass
def copy_header(self, original_info):
self.header = original_info.header
self._reset_masters()
def getStatus(self):
"""Returns status of this file -- which depends on status of masters.
0: Good
20, 22: Out of order master
21, 22: Loads after its masters
30: Missing master(s)."""
#--Worst status from masters
status = 30 if any( # if self.masterNames is empty returns False
(m not in modInfos) for m in self.masterNames) else 0
#--Missing files?
if status == 30:
return status
#--Misordered?
self.masterOrder = tuple(load_order.get_ordered(self.masterNames))
loads_before_its_masters = self.isMod() and self.masterOrder and \
load_order.cached_lo_index(
self.masterOrder[-1]) > load_order.cached_lo_index(self.name)
if self.masterOrder != self.masterNames and loads_before_its_masters:
return 22
elif loads_before_its_masters:
return 21
elif self.masterOrder != self.masterNames:
return 20
else:
return status
# Backup stuff - beta, see #292 -------------------------------------------
def getFileInfos(self):
"""Return one of the FileInfos singletons depending on fileInfo type.
:rtype: FileInfos"""
raise AbstractError
def _doBackup(self,backupDir,forceBackup=False):
"""Creates backup(s) of file, places in backupDir."""
#--Skip backup?
if not self in self.getFileInfos().values(): return
if self.madeBackup and not forceBackup: return
#--Backup
self.getFileInfos().copy_info(self.name, backupDir)
#--First backup
firstBackup = backupDir.join(self.name) + u'f'
if not firstBackup.exists():
self.getFileInfos().copy_info(self.name, backupDir,
firstBackup.tail)
def tempBackup(self, forceBackup=True):
"""Creates backup(s) of file. Uses temporary directory to avoid UAC issues."""
self._doBackup(Path.baseTempDir().join(u'WryeBash_temp_backup'),forceBackup)
def makeBackup(self, forceBackup=False):
"""Creates backup(s) of file."""
backupDir = self.backup_dir
self._doBackup(backupDir,forceBackup)
#--Done
self.madeBackup = True
def backup_paths(self, first=False):
"""Return a list of tuples with backup paths and their restore
destinations
:rtype: list[tuple]""" ##: drop tuples use lists !
return [(self.backup_dir.join(self.name) + (u'f' if first else u''),
self.getPath())]
def revert_backup(self, first=False):
backup_paths = self.backup_paths(first)
for tup in backup_paths[1:]: # if cosaves do not exist shellMove fails!
if not tup[0].exists(): backup_paths.remove(tup)
env.shellCopy(*zip(*backup_paths))
# do not change load order for timestamp games - rest works ok
self.setmtime(self._file_mod_time, crc_changed=True)
self.getFileInfos().refreshFile(self.name)
def getNextSnapshot(self):
"""Returns parameters for next snapshot."""
destDir = self.snapshot_dir
destDir.makedirs()
(root,ext) = self.name.root, self.name.ext
separator = u'-'
snapLast = [u'00']
#--Look for old snapshots.
reSnap = re.compile(u'^'+root.s+u'[ -]([0-9.]*[0-9]+)'+ext+u'$',re.U)
for fileName in destDir.list():
maSnap = reSnap.match(fileName.s)
if not maSnap: continue
snapNew = maSnap.group(1).split(u'.')
#--Compare shared version numbers
sharedNums = min(len(snapNew),len(snapLast))
for index in range(sharedNums):
(numNew,numLast) = (int(snapNew[index]),int(snapLast[index]))
if numNew > numLast:
snapLast = snapNew
continue
#--Compare length of numbers
if len(snapNew) > len(snapLast):
snapLast = snapNew
continue
#--New
snapLast[-1] = (u'%0'+unicode(len(snapLast[-1]))+u'd') % (int(snapLast[-1])+1,)
destName = root+separator+(u'.'.join(snapLast))+ext
return destDir,destName,(root+u'*'+ext).s
@property
def backup_dir(self):
return self.getFileInfos().bash_dir.join(u'Backups')
@property
def snapshot_dir(self):
return self.getFileInfos().bash_dir.join(u'Snapshots')
#------------------------------------------------------------------------------
reBashTags = re.compile(ur'{{ *BASH *:[^}]*}}\s*\n?',re.U)
class ModInfo(FileInfo):
"""An esp/m/l file."""
def __init__(self, parent_dir, name, load_cache=False):
self.isGhost = endsInGhost = (name.cs[-6:] == u'.ghost')
if endsInGhost: name = GPath(name.s[:-6])
else: # refreshFile() path
absPath = GPath(parent_dir).join(name)
self.isGhost = \
not absPath.exists() and (absPath + u'.ghost').exists()
super(ModInfo, self).__init__(parent_dir, name, load_cache)
def _reset_cache(self, stat_tuple, load_cache):
super(ModInfo, self)._reset_cache(stat_tuple, load_cache)
# check if we have a cached crc for this file, use fresh mtime and size
if load_cache: self.calculate_crc() # for added and hopefully updated
def getFileInfos(self): return modInfos
def isEsm(self):
"""Check if the mod info is a master file based on master flag -
header must be set"""
return int(self.header.flags1) & 1 == 1
def is_esl(self):
# game seems not to care about the flag, at least for load order
return self.name.cext == u'.esl'
def is_esml(self):
return self.is_esl() or self.isEsm()
def isInvertedMod(self):
"""Extension indicates esp/esm, but byte setting indicates opposite."""
if self.name.cext not in (u'.esm', u'.esp'): # don't use for esls
raise ArgumentError(
u'isInvertedMod: %s - only esm/esp allowed' % self.name.ext)
return (self.header and
self.name.cext != (u'.esp', u'.esm')[int(self.header.flags1) & 1])
def setType(self, esm_or_esp):
"""Sets the file's internal type."""
if esm_or_esp not in (u'esm', u'esp'): # don't use for esls
raise ArgumentError(
u'setType: %s - only esm/esp allowed' % esm_or_esp)
with self.getPath().open('r+b') as modFile:
modFile.seek(8)
flags1 = MreRecord.flags1_(struct_unpack('I', modFile.read(4))[0])
flags1.esm = (esm_or_esp == u'esm')
modFile.seek(8)
modFile.write(struct_pack('=I', int(flags1)))
self.header.flags1 = flags1
self.setmtime(crc_changed=True)
def calculate_crc(self, recalculate=False):
cached_crc = modInfos.table.getItem(self.name, 'crc')
if not recalculate:
cached_mtime = modInfos.table.getItem(self.name, 'crc_mtime')
cached_size = modInfos.table.getItem(self.name, 'crc_size')
recalculate = cached_crc is None \
or self._file_mod_time != cached_mtime \
or self._file_size != cached_size
path_crc = cached_crc
if recalculate:
path_crc = self.abs_path.crc
if path_crc != cached_crc:
modInfos.table.setItem(self.name,'crc',path_crc)
modInfos.table.setItem(self.name,'ignoreDirty',False)
modInfos.table.setItem(self.name, 'crc_mtime', self._file_mod_time)
modInfos.table.setItem(self.name, 'crc_size', self._file_size)
return path_crc, cached_crc
def cached_mod_crc(self): # be sure it's valid before using it!
return modInfos.table.getItem(self.name, 'crc')
def crc_string(self):
try:
return u'%08X' % modInfos.table.getItem(self.name, 'crc')
except TypeError: # None, should not happen so let it show
return u'UNKNOWN!'
def setmtime(self, set_time=0, crc_changed=False):
"""Set mtime and if crc_changed is True recalculate the crc."""
set_time = FileInfo.setmtime(self, set_time)
# Prevent re-calculating the File CRC
if not crc_changed:
modInfos.table.setItem(self.name,'crc_mtime', set_time)
else:
self.calculate_crc(recalculate=True)
# Ghosting and ghosting related overrides ---------------------------------
def do_update(self):
self.isGhost, old_ghost = not self._abs_path.exists() and (
self._abs_path + u'.ghost').exists(), self.isGhost
# mark updated if ghost state changed but only reread header if needed
changed = super(ModInfo, self).do_update()
return changed or self.isGhost != old_ghost
@FileInfo.abs_path.getter
def abs_path(self):
"""Return joined dir and name, adding .ghost if the file is ghosted."""
return (self._abs_path + u'.ghost') if self.isGhost else self._abs_path
def setGhost(self,isGhost):
"""Sets file to/from ghost mode. Returns ghost status at end."""
normal = self.dir.join(self.name)
ghost = normal + u'.ghost'
# Refresh current status - it may have changed due to things like
# libloadorder automatically unghosting plugins when activating them.
# Libloadorder only un-ghosts automatically, so if both the normal
# and ghosted version exist, treat the normal as the real one.
# Both should never exist simultaneously, Bash will warn in BashBugDump
if normal.exists(): self.isGhost = False
elif ghost.exists(): self.isGhost = True
# Current status == what we want it?
if isGhost == self.isGhost: return isGhost
# Current status != what we want, so change it
try:
if not normal.editable() or not ghost.editable():
return self.isGhost
if isGhost: normal.moveTo(ghost)
else: ghost.moveTo(normal)
self.isGhost = isGhost
# reset cache info as un/ghosting should not make do_update return True
self.mark_unchanged()
except:
deprint(u'Failed to %sghost file %s' % ((u'un', u'')[isGhost],
(ghost.s, normal.s)[isGhost]), traceback=True)
return self.isGhost
#--Bash Tags --------------------------------------------------------------
def setBashTags(self,keys):
"""Sets bash keys as specified."""
modInfos.table.setItem(self.name,'bashTags',keys)
def setBashTagsDesc(self,keys):
"""Sets bash keys as specified."""
keys = set(keys) #--Make sure it's a set.
if keys == self.getBashTagsDesc(): return
if keys:
strKeys = u'{{BASH:'+(u','.join(sorted(keys)))+u'}}\n'
else:
strKeys = u''
description = self.header.description or ''
if reBashTags.search(description):
description = reBashTags.sub(strKeys,description)
else:
description = description + u'\n' + strKeys
if len(description) > 511: return False
self.writeDescription(description)
return True
def getBashTags(self):
"""Returns any Bash flag keys."""
return modInfos.table.getItem(self.name, 'bashTags', set())
def getBashTagsDesc(self):
"""Returns any Bash flag keys."""
description = self.header.description or u''
maBashKeys = re.search(u'{{ *BASH *:([^}]+)}}',description,flags=re.U)
if not maBashKeys:
return set()
else:
bashTags = maBashKeys.group(1).split(u',')
return set([str.strip() for str in bashTags]) & allTagsSet - oldTagsSet
def reloadBashTags(self):
"""Reloads bash tags from mod description and LOOT"""
tags, removed, _userlist = configHelpers.getTagsInfoCache(self.name)
tags |= self.getBashTagsDesc()
tags -= removed
# Filter and remove old tags
tags &= allTagsSet
if tags & oldTagsSet:
tags -= oldTagsSet
self.setBashTagsDesc(tags)
self.setBashTags(tags)
#--Header Editing ---------------------------------------------------------
def readHeader(self):
"""Read header from file and set self.header attribute."""
with ModReader(self.name,self.getPath().open('rb')) as ins:
try:
recHeader = ins.unpackRecHeader()
if recHeader.recType != bush.game.MreHeader.classType:
raise ModError(self.name,u'Expected %s, but got %s'
% (bush.game.MreHeader.classType,recHeader.recType))
self.header = bush.game.MreHeader(recHeader,ins,True)
except struct.error as rex:
raise ModError(self.name,u'Struct.error: %s' % rex)
self._reset_masters()
def writeHeader(self):
"""Write Header. Actually have to rewrite entire file."""
filePath = self.getPath()
with filePath.open('rb') as ins:
with filePath.temp.open('wb') as out:
try:
#--Open original and skip over header
reader = ModReader(self.name,ins)
recHeader = reader.unpackRecHeader()
if recHeader.recType != bush.game.MreHeader.classType:
raise ModError(self.name,u'Expected %s, but got %s'
% (bush.game.MreHeader.classType,recHeader.recType))
reader.seek(recHeader.size,1)
#--Write new header
self.header.getSize()
self.header.dump(out)
#--Write remainder
outWrite = out.write
for block in iter(partial(ins.read, 0x5000000), ''):
outWrite(block)
except struct.error as rex:
raise ModError(self.name,u'Struct.error: %s' % rex)
#--Remove original and replace with temp
filePath.untemp()
self.setmtime(crc_changed=True)
#--Merge info
size,canMerge = modInfos.table.getItem(self.name,'mergeInfo',(None,None))
if size is not None:
modInfos.table.setItem(self.name,'mergeInfo',(filePath.size,canMerge))
def writeDescription(self,description):
"""Sets description to specified text and then writes hedr."""
description = description[:min(511,len(description))] # 511 + 1 for null = 512
self.header.description = description
self.header.setChanged()
self.writeHeader()
#--Helpers ----------------------------------------------------------------
def isBP(self, __bp_authors={u'BASHED PATCH', u'BASHED LISTS'}):
return self.header.author in __bp_authors ##: drop BASHED LISTS
def txt_status(self):
if load_order.cached_is_active(self.name): return _(u'Active')
elif self.name in modInfos.merged: return _(u'Merged')
elif self.name in modInfos.imported: return _(u'Imported')
else: return _(u'Non-Active')
def hasTimeConflict(self):
"""True if there is another mod with the same mtime."""
return load_order.has_load_order_conflict(self.name)
def hasActiveTimeConflict(self):
"""True if has an active mtime conflict with another mod."""
return load_order.has_load_order_conflict_active(self.name)
def hasBadMasterNames(self):
"""True if has a master with un unencodable name in cp1252."""
try:
for x in self.header.masters: x.s.encode('cp1252')
return False
except UnicodeEncodeError:
return True
@property
def _modname(self):
return modInfos.file_pattern.sub(u'', self.name.s)
def mod_bsas(self, bsa_infos=None):
"""Return bsas from bsaInfos, that match plugin's name."""
pattern = re.escape(self._modname)
# games other than skyrim accept more general bsa names
if bush.game.fsName != u'Skyrim': pattern += u'.*'
reg = re.compile(pattern, re.I | re.U)
# bsaInfos must be updated and contain all existing bsas
if bsa_infos is None: bsa_infos = bsaInfos
return [inf for bsa, inf in bsa_infos.iteritems() if reg.match(bsa.s)]
def hasBsa(self):
"""Returns True if plugin has an associated BSA."""
return bool(self.mod_bsas())
def getIniPath(self):
"""Returns path to plugin's INI, if it were to exists."""
return self.getPath().root.root + u'.ini' # chops off ghost if ghosted
def _string_files_paths(self, lang):
sbody, ext = self.name.sbody, self.name.ext
for join, format_str in bush.game.esp.stringsFiles:
fname = format_str % {'body': sbody, 'ext': ext, 'language': lang}
assetPath = empty_path.join(*join).join(fname)
yield assetPath
def getStringsPaths(self, lang=u'English'):
"""If Strings Files are available as loose files, just point to
those, otherwise extract needed files from BSA if needed."""
baseDirJoin = self.getPath().head.join
extract = set()
paths = set()
#--Check for Loose Files first
for filepath in self._string_files_paths(lang):
loose = baseDirJoin(filepath)
if not loose.exists():
extract.add(filepath)
else:
paths.add(loose)
#--If there were some missing Loose Files
if extract:
bsa_assets = OrderedDict()
for bsa_info in self._extra_bsas():
try:
found_assets = bsa_info.has_assets(extract)
except (BSAError, OverflowError):
deprint(u'Failed to parse %s' % bsa_info.name,
traceback=True)
continue
if not found_assets: continue
bsa_assets[bsa_info] = found_assets
#extract contains Paths that compare equal to lowercase strings
extract -= set(imap(unicode.lower, found_assets))
if not extract:
break
else: raise ModError(self.name, u"Could not locate Strings Files")
for bsa, assets in bsa_assets.iteritems():
out_path = dirs['bsaCache'].join(bsa.name)
try:
bsa.extract_assets(assets, out_path.s)
except BSAError as e:
raise ModError(self.name,
u"Could not extract Strings File from "
u"'%s': %s" % (bsa.name, e))
paths.update(imap(out_path.join, assets))
return paths
def _extra_bsas(self):
"""Return a list of bsas to get assets from.
:rtype: list[BSAInfo]
"""
if self.name.cs in bush.game.vanilla_string_bsas: # lowercase !
bsa_infos = [bsaInfos[b] for b in map(GPath,
bush.game.vanilla_string_bsas[self.name.cs]) if b in bsaInfos]
else:
bsa_infos = self.mod_bsas() # first check bsa with same name
for iniFile in modInfos.ini_files():
for key in bush.game.resource_archives_keys:
extraBsas = map(GPath, (x.strip() for x in (
iniFile.getSetting(u'Archive', key, u'').split(u','))))
bsa_infos.extend(
bsaInfos[bsa] for bsa in extraBsas if bsa in bsaInfos)
return bsa_infos
def isMissingStrings(self, __debug=0):
"""True if the mod says it has .STRINGS files, but the files are
missing."""
if not self.header.flags1.hasStrings: return False
lang = oblivionIni.get_ini_language()
bsa_infos = self._extra_bsas()
for assetPath in self._string_files_paths(lang):
# Check loose files first
if self.dir.join(assetPath).exists():
continue
# Check in BSA's next
if __debug == 1:
deprint(u'Scanning BSAs for string files for %s' % self.name)
__debug = 2
for bsa_info in bsa_infos:
try:
if bsa_info.has_assets({assetPath}):
break # found
except (BSAError, OverflowError):
print u'Failed to parse %s:\n%s' % (
bsa_info.name, traceback.format_exc())
continue
if __debug == 2:
deprint(u'Asset %s not in %s' % (assetPath, bsa_info.name))
else: # not found
return True
return False
def hasResources(self):
"""Returns (hasBsa,hasVoices) booleans according to presence of
corresponding resources."""
voicesPath = self.dir.join(u'Sound',u'Voice',self.name)
return [self.hasBsa(),voicesPath.exists()]
#------------------------------------------------------------------------------
from .ini_files import IniFile, OBSEIniFile, DefaultIniFile, OblivionIni
def get_game_ini(ini_path, is_abs=True):
""":rtype: OblivionIni | None"""
for game_ini in gameInis:
game_ini_path = game_ini.abs_path
if ini_path == ((is_abs and game_ini_path) or game_ini_path.stail):
return game_ini
return None
def BestIniFile(abs_ini_path):
""":rtype: IniFile"""
game_ini = get_game_ini(abs_ini_path)
if game_ini:
return game_ini
INICount = IniFile.formatMatch(abs_ini_path)
OBSECount = OBSEIniFile.formatMatch(abs_ini_path)
if INICount >= OBSECount:
return IniFile(abs_ini_path)
else:
return OBSEIniFile(abs_ini_path)
#------------------------------------------------------------------------------
class INIInfo(IniFile):
"""Ini info, adding cached status and functionality to the ini files."""
_status = None
def _reset_cache(self, stat_tuple, load_cache):
super(INIInfo, self)._reset_cache(stat_tuple, load_cache)
if load_cache: self._status = None
@property
def tweak_status(self):
if self._status is None: self.getStatus()
return self._status
@property
def is_default_tweak(self): return False
def _incompatible(self, other):
if not isinstance(self, OBSEIniFile):
return isinstance(other, OBSEIniFile)
return not isinstance(other, OBSEIniFile)
def is_applicable(self, stat=None):
stat = stat or self.tweak_status
return stat != -20 and (
bass.settings['bash.ini.allowNewLines'] or stat != -10)
def getStatus(self, target_ini=None):
"""Returns status of the ini tweak:
20: installed (green with check)
15: mismatches (green with dot) - mismatches are with another tweak from same installer that is applied
10: mismatches (yellow)
0: not installed (green)