')
19 | for line in mlines:
20 | #header
21 | if not is_pre:
22 | if line.startswith('#'):
23 | if line.startswith('###'):
24 | lines.append('
%s
' % (line[3:]))
25 | elif line.startswith('##'):
26 | lines.append('
%s' % (line[2:]))
27 | elif line.startswith('#'):
28 | lines.append('%s
' % (line[1:]))
29 | continue
30 |
31 |
32 | #start paragraph or more text
33 | if line:
34 | if line.startswith('```'):
35 | if not is_pre:
36 | is_pre = True
37 | is_pre_first = True
38 | lines.append('')#
39 | line = line[3:]
40 | else:
41 | is_pre = False
42 | lines.append('
')#
43 | line = line[3:]
44 | elif is_pre:
45 | pass
46 |
47 | #list start
48 | elif line.startswith('-'):
49 | line = line[1:]
50 | if not is_ul:
51 | is_ul = True
52 | lines.append('')
53 | if is_li:
54 | lines.append('')
55 | lines.append('- ')
56 | is_li = True
57 |
58 | #list continue
59 | elif line.startswith(' ') and is_li:
60 | pass
61 | #paragraph and/or list end
62 | elif is_p:
63 | if not line.startswith(' '):
64 | lines.append(' ') #extra word after line breaks
65 | elif not is_p:
66 | if is_li:
67 | is_li = False
68 | is_ul = False
69 | lines.append('
')
70 | is_p = True
71 | lines.append('
')
72 |
73 | #end paragraph
74 | elif not line:
75 | if is_p:
76 | is_p = False
77 | lines.append('
')
78 | if is_li:
79 | is_li = False
80 | is_ul = False
81 | lines.append('')
82 |
83 | #modifiers
84 | if not is_pre:
85 | line = self.replacer(line, '**', '
', '')
86 | line = self.replacer(line, '*', '
', '')
87 | line = self.replacer(line, '`', '
', '')
88 |
89 | lines.append(line)
90 | if is_pre:
91 | if not is_pre_first:
92 | lines.append('
')
93 | is_pre_first = False
94 |
95 | lines.append('
')
96 |
97 | msg = ''.join(lines)
98 | return msg
99 |
100 |
101 | def replacer(self, line, find, repl1, repl2):
102 | if not line:
103 | return line
104 |
105 | if line.count(find) % 2 != 0: #only in pairs
106 | return line
107 |
108 | #maybe some regex but multiple are possible
109 | is_open = False
110 | while find in line:
111 | if not is_open:
112 | repl = repl1
113 | else:
114 | repl = repl2
115 | line = line.replace(find, repl, 1)
116 | is_open = not is_open
117 |
118 | return line
119 |
--------------------------------------------------------------------------------
/wwiser/wtests.py:
--------------------------------------------------------------------------------
1 | from .generator.render import bnode_rtpc
2 |
3 |
4 | class Tests(object):
5 | def __init__(self):
6 | pass
7 |
8 | def main(self):
9 | print("tests")
10 |
11 | GraphTests().start()
12 | pass
13 |
14 | def _info(self):
15 | #try:
16 | #import objgraph
17 | #objgraph.show_most_common_types()
18 |
19 | #from guppy import hpy; h=hpy()
20 | #h.heap()
21 |
22 | #from . import wmodel
23 | #import sys
24 | #print("NodeElement: %x" % sys.getsizeof(wmodel.NodeElement(None, 'test')))
25 | #getsizeof(wmodel.NodeElement()), getsizeof(Wrong())
26 | #except:
27 | #pass
28 |
29 | pass
30 |
31 | class GraphTests(object):
32 | def __init__(self):
33 | self.tests = [
34 | GraphTest('ACB whispers - GP_MP_DISTANCE_CHASER_FROM_PLAYER', 40, 2, [
35 | (0.0, 19.806190490722656, 9),
36 | (18.694889068603516, 19.806190490722656, 6),
37 | (25.472000122070312, -96.30000305175781, 9),
38 | (32.0, -96.30000305175781, 9),
39 | (50.0, -96.30000305175781, 9),
40 | ],
41 | [-1.0, 0.0, 10.0, 19.0, 20.0, 25.0, 30.0, 55.0]
42 | ),
43 | GraphTest('ACB track - GP_MP_DISTANCE_PLAYER_FROM_TARGET', 40, 2, [
44 | (0.0, 50.86879348754883, 5),
45 | (4.351779937744141, 15.675174713134766, 7),
46 | (9.646302223205566, -0.47163546085357666, 4),
47 | (50.0, -0.26096510887145996, 4),
48 | ],
49 | [-1.0, 0.0, 2.0, 5.0, 10.0, 40.0, 55.0]
50 | ),
51 | GraphTest('DMC5 nero - bgm_srank_param 375889460', 160, 2, [
52 | (0.0, -1.0, 9),
53 | (4.2288498878479, -1.0, 4),
54 | (4.599999904632568, 0.0, 9),
55 | (5.0, 0.0, 9),
56 | (7.0, 0.0, 4),
57 | ],
58 | [-1.0, 1.0, 4.25, 4.60, 10.0]
59 | ),
60 | GraphTest('DMC5 nero - bgm_srank_param 525203653', 160, 2, [
61 | (0.0, -1.0, 9),
62 | (3.755729913711548, -1.0, 4),
63 | (4.0, 0.0, 4),
64 | (4.193260192871094, -0.12219105660915375, 4),
65 | (4.515379905700684, -0.9930070042610168, 4),
66 | (7.0, -1.0, 4),
67 | ],
68 | [-1.0, 1.0, 3.85, 4.05, 4.70, 6.0, 10.0]
69 | ),
70 | GraphTest('DMC5 nero - bgm_srank_param 385813821', 160, 2, [
71 | (0.0, 0.0, 9),
72 | (4.0, 0.0, 4),
73 | (4.462500095367432, -0.1541711390018463, 4),
74 | (4.599999904632568, -0.6837722063064575, 4),
75 | (5.0, -1.0, 9),
76 | (7.0, -1.0, 4),
77 | ],
78 | [-1.0, 1.0, 4.25, 4.50, 4.80, 10.0]
79 | ),
80 | ]
81 |
82 | def start(self):
83 | for t in self.tests:
84 | self._test(t)
85 | return
86 |
87 | def _test(self, t):
88 | graph = bnode_rtpc._AkGraph(None, 0)
89 | graph.version = t.version
90 | graph.scaling = t.scaling
91 |
92 | for x, y, i in t.points:
93 | p = bnode_rtpc._AkGraphPoint(None)
94 | p.x = x
95 | p.y = y
96 | p.i = i
97 | graph.points.append(p)
98 |
99 | print("- %s" % (t.name))
100 | for x in t.values:
101 | y = graph.get(x)
102 | print(" x=%s, y=%s" % (x, y))
103 | print("")
104 |
105 | class GraphTest(object):
106 | def __init__(self, name, version, scaling, points, values):
107 | self.name = name
108 | self.version = version
109 | self.scaling = scaling
110 | self.points = points
111 | self.values = values
112 |
--------------------------------------------------------------------------------
/wwiser/generator/wreport.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 |
4 | class Report(object):
5 |
6 | def __init__(self, generator):
7 | self._generator = generator
8 |
9 | def report(self):
10 | gen = self._generator
11 | reb = gen._builder
12 | txc = gen._txtpcache
13 | mdi = gen._txtpcache.mediaindex
14 | stats = gen._txtpcache.stats
15 |
16 | if reb.has_unused() and not gen._generate_unused:
17 | logging.info("generator: NOTICE! possibly unused audio? (find+load more banks?)")
18 | logging.info("*** set 'generate unused' option to include, may not create anything")
19 |
20 | if mdi.get_missing_media():
21 | missing = len(mdi.get_missing_media())
22 | if mdi.get_event_based_packaging():
23 | # usually not an error but can't detect
24 | logging.debug("generator: some .txtp use packaged memory audio from .wem/bnk")
25 | else:
26 | logging.info("generator: WARNING! missing %s memory audio (load more banks?)", missing)
27 |
28 | # probably same as missing audio nodes
29 | #if reb.get_missing_nodes_loaded():
30 | # missing = len(reb.get_missing_nodes_loaded())
31 | # logging.info("generator: WARNING! missing %s Wwise objects in loaded banks (ignore?)", missing)
32 |
33 | if reb.get_missing_nodes_others():
34 | missing = len(reb.get_missing_nodes_others())
35 | logging.info("generator: WARNING! missing %s Wwise objects in other banks (load?)", missing)
36 | for bankinfo in reb.get_missing_banks():
37 | logging.info("- %s.bnk" % (bankinfo))
38 |
39 | # usually missing audio nodes that were removed but some refs are left in the bank
40 | #if reb.get_missing_nodes_unknown():
41 | # missing = len(reb.get_missing_nodes_unknown())
42 | # logging.info("generator: WARNING! missing %s Wwise objects in unknown banks (load/ignore?)", missing)
43 |
44 | if reb.get_missing_nodes_buses():
45 | missing = len(reb.get_missing_nodes_buses())
46 | logging.info("generator: WARNING! missing %s Wwise buses (load init.bnk/1355168291.bnk?)", missing)
47 |
48 | if reb.get_multiple_nodes():
49 | missing = len(reb.get_multiple_nodes())
50 | logging.info("generator: WARNING! repeated %s Wwise objects in multiple banks (load less?)", missing)
51 |
52 | if not stats.created:
53 | logging.info("generator: WARNING! no .txtp were created (find+load banks with events?)")
54 |
55 | if reb.get_transition_objects():
56 | logging.info("generator: REPORT! transition object in playlists")
57 | if reb.get_unknown_props():
58 | logging.info("generator: REPORT! unknown properties in some objects")
59 | for prop in reb.get_unknown_props():
60 | logging.info("- %s" % (prop))
61 |
62 | if stats.trims:
63 | logging.info("generator: NOTICE! trimmed %s long filenames (use shorter dirs?)", stats.trims)
64 | logging.info("*** set 'tags.m3u' option for shorter names + tag file with full names")
65 |
66 | if stats.multitrack:
67 | logging.info("generator: multitracks detected (ignore, may generate in future versions)")
68 |
69 | auto_find = txc.locator.is_auto_find()
70 | move_info = ''
71 | if not auto_find and not gen._move:
72 | move_info = ' (move to wem folder)'
73 |
74 | if stats.streams:
75 | logging.debug("generator: some .txtp (%s) use streams%s", stats.streams, move_info)
76 | if stats.internals and not txc.bnkskip:
77 | logging.debug("generator: some .txtp (%s) use .bnk%s", stats.internals, move_info)
78 | for bankname in txc.stats.get_used_banks():
79 | logging.debug("- %s", bankname)
80 |
81 | #logging.info("generator: done")
82 | line = "created %i" % stats.created
83 | if stats.duplicates:
84 | line += ", %i duplicates" % stats.duplicates
85 | if gen._generate_unused:
86 | line += ", unused %i" % stats.unused
87 | logging.info("generator: done (%s)", line)
88 |
--------------------------------------------------------------------------------
/doc/DEV.md:
--------------------------------------------------------------------------------
1 | # WWISER DEV INFO
2 |
3 |
4 | ## OVERVIEW
5 | Wwiser is roughly divided in "components" that do certain jobs. Most are somewhat separate, but
6 | a full course of action would be:
7 | - open .bnk file
8 | - make *IO reader*: pass file
9 | - make *parser*: pass *io*, create *model* tree
10 | - open *names*: read companion name files
11 | - pass *names* to *parser*
12 | - make *printer*: pass *parser*, write *model* tree
13 | - make *viewer*: pass *parser*, print tree nodes on demand
14 | - make *generator*: pass *parser*, analyze and generate *.txtp*
15 |
16 |
17 | ## COMPONENTS
18 | A general rundown of wwiser internals:
19 |
20 | ### CLI
21 | Simple client interfase. Opens and manages other components depending on to CLI commands.
22 |
23 | ### GUI
24 | Simple GUI, mostly the same as CLI but simplified (less options).
25 |
26 | ### IO
27 | Simple file reading encapsulation.
28 |
29 | ### MODEL
30 | A generic a tree/xml-like structure of nodes ("objects", "lists", "fields", etc), tuned to read the bank
31 | file with the IO reader.
32 |
33 | ### PARSER
34 | Reads a .bnk and creates a bank tree. This generic structure then can be used for other components to do their thing.
35 |
36 | This was developed by analyzing SDK decompilations and somewhat trying to follow original code along. It was simply too time consuming to analyze, understand and reinterpret every single thing. As a result, I don't actually know every field or anything, but I can see if parser is correct by checking that it handles the same things as the SDK, in the correct order. This is also why it uses a generic tree rather than proper classes (much faster to create, test and modify).
37 |
38 | ### DEFS/CLS
39 | Companion info/constants for the parser.
40 |
41 | ### PRINTER
42 | Takes the Parser and writes current banks as a xml/text/etc file. Basically follows the generic bank tree along and prints nodes as found, nothing fancy. Mostly to debugging and test banks, as the viewer is meant to print nodes in different ways.
43 |
44 | ### NAMES
45 | Reads companion files/databases and creates a list of possible names to be used by the parser (ID=name). This is then injected to the parser, so it automatically shows names as attributes in the tree nodes that have an ID.
46 |
47 | ### VIEWER
48 | Web server that shows the Parser node tree as HTML. Base .html interacts with the crude server requesting printed nodes via AJAX, that are created with a dumb templating engine (home-baked rather than a known engine for simplicity, to minimize dependencies, and for fun too).
49 |
50 | A pure python GUI was ultimate dismissed, as using CSS+HTML+JS was much more flexible (plus ubiquitous) and having to learn a new, probably limited GUI would end up being time consuming and unmaintainable. The html design was also repurposed from the Printer's test XML output to save time.
51 |
52 | ### GENERATOR
53 | Takes the Parser tree and tries to build .txtp files to play with vgmstream. Because how Wwise internally works (objects point to objects based on config) there is quite a bit of jumping around. A bunch of helper classes are used to create simplified .txtp trees from the parser tree, that ultimately makes text files.
54 |
55 | Base generator populates a "rebuilder", where parser nodes are read and simplified a bit with direct field access to ease handling. Then those rebuilt nodes start adding parts to a TXTP helper, calling their descendants and sound nodes until the whole path is parsed. The helper in turn makes a tree of simpler nodes, that are ultimately post-processed to make a final .txtp (some extra info is gathered and printer
56 | to the .txtp too.
57 |
58 | Because the huge amount of features Wwise has that vgmstream lacks, this component is most incomplete, unlike others. Particularly, some things are ignored and others are simplified to be more suitable with vgmstream's features. Since it was also the hardest part, code is kind of chaotic as I was randomly tumbling around.
59 |
60 |
61 | ## OTHER NOTES
62 |
63 | Random thoughts:
64 | - I (bnnm) barely know/knew python, so code shouldn't be taken as a good example of the language or software engineering practices, as I was often trying stuff along
65 | - python was mainly chosen for its good prototyping/quick iteration powerz, and being common enough for users
66 | - key milestones are/were: parser > names > visualizer > generator, and were developed as such
67 | - code was written prioritizing dev speed over orderly design, stuff came along as needed. That is to say, it was more important to churn out something viable, usable and testable (as the whole thing is no easy task) than slowly making the best thing ever. As a result lots of code isn't too great, but good enough and done is better than perfect but pending.
68 | - packages aren't very organized until I figure out that mess
69 | - some parts were mix and matched from small .py tests around, not too consistent
70 | - codebase isn't considered stable and may change anytime on a whim
71 |
--------------------------------------------------------------------------------
/wwiser/names/wnconfig.py:
--------------------------------------------------------------------------------
1 | import logging, re, os, os.path, sys
2 |
3 |
4 | class Config(object):
5 |
6 | def __init__(self):
7 | #self._names = names
8 | self._hashtypes_missing = None # print only certain hashtypes
9 |
10 | self.disable_fuzzy = False
11 | self.classify_bank = True
12 | self.bank_paths = False
13 | self.save_missing = True
14 | self.save_companion = True
15 | self.save_all = False
16 | self.repeats_update_caps = False
17 |
18 | self.sort_always = False
19 | self._default_weight = 100
20 | self._sort_weights = []
21 | self._config_lines = []
22 |
23 | def add_config(self, line):
24 | ok = False
25 |
26 | if line.startswith('#@no-fuzzy') or line.startswith('#@nofuzzy'):
27 | self.disable_fuzzy = True
28 | ok = True
29 |
30 | if line.startswith('#@no-save-missing'):
31 | self.save_missing = False
32 | ok = True
33 |
34 | if line.startswith('#@no-save-companion'):
35 | self.save_companion = False
36 | ok = True
37 |
38 | if line.startswith('#@no-save-all'):
39 | self.save_all = True
40 | ok = True
41 |
42 | if line.startswith('#@no-classify'):
43 | self.classify_bank = False
44 | ok = True
45 |
46 | if line.startswith('#@classify-path'):
47 | self.bank_paths = True
48 | ok = True
49 |
50 | if line.startswith('#@hashtypes-missing'):
51 | line = line.replace('#@hashtypes-missing', '')
52 | self._hashtypes_missing = [item.lower().strip() for item in line.split()]
53 | ok = True
54 |
55 | if line.startswith('#@sort-always'):
56 | self.sort_always = True
57 | ok = True
58 |
59 | if line.startswith('#@sort-weight'): #or line.startswith('#@sw'): #clases with strings2 wwnames
60 | self._add_sort_weight(line)
61 | ok = True
62 |
63 | if line.startswith('#@repeats-update-caps'):
64 | self.repeats_update_caps = True
65 | ok = True
66 |
67 | if ok:
68 | self._config_lines.append(line)
69 |
70 |
71 | def get_config_lines(self):
72 | return self._config_lines
73 |
74 | def skip_hashtype(self, hashtype):
75 | return self._hashtypes_missing and hashtype not in self._hashtypes_missing
76 |
77 |
78 | # TODO maybe generate again when cleaning wwnames
79 |
80 | # defined sorting weight, where higher = lower priority (0=top, 100=default, 999=lowest). examples:
81 | # group=value 10 # exact match
82 | # group*=value* 20 # partial match
83 | # group=- 999 # by default "any" has highest
84 | # value 20 # same as *=value
85 | #
86 | def _add_sort_weight(self, line):
87 | line = line.strip()
88 | elems = line.split(" ")
89 | if len(elems) != 3:
90 | logging.info("names: ignored weight %s", line )
91 | return
92 | match = elems[1]
93 | weight = elems[2]
94 | if not weight.isnumeric():
95 | logging.info("names: ignored weight %s", line )
96 | return
97 |
98 | if '*' == match:
99 | self._default_weight = weight
100 | else:
101 | if '=' in match:
102 | gv = match.split("=")
103 | g_wr = self._get_weight_regex(gv[0])
104 | v_wr = self._get_weight_regex(gv[1])
105 | item = (g_wr, v_wr, weight)
106 | else:
107 | v_wr = self._get_weight_regex(match)
108 | item = (None, v_wr, weight)
109 | self._sort_weights.append(item)
110 |
111 | def _get_weight_regex(self, text_in):
112 | if '*' in text_in:
113 | replaces = { '(':'\\(', ')':'\\)', '[':'\\[', ']':'\\]', '.':'\\.', '*':'.*?' }
114 | regex_in = text_in
115 | for key, val in replaces.items():
116 | regex_in = regex_in.replace(key, val)
117 | regex = re.compile(regex_in, re.IGNORECASE)
118 | else:
119 | regex = re.compile(re.escape(text_in), re.IGNORECASE)
120 | return regex
121 |
122 | def get_weight(self, groupname, valuename):
123 | for g_wr, v_wr, weight in self._sort_weights:
124 | if not g_wr:
125 | if v_wr.match(valuename):
126 | return weight
127 | else:
128 | if g_wr.match(groupname) and v_wr.match(valuename):
129 | return weight
130 |
131 | if valuename == '-': #any usually goes first, unless overwritten
132 | return 0
133 | return self._default_weight
134 |
--------------------------------------------------------------------------------
/wwiser/viewer/wtemplate.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | # dumb templater for quick output, don't try this at home. repurposed from:
4 | # http://code.activestate.com/recipes/496702/
5 | # https://git.joonis.de/snippets/4
6 |
7 | class Template(object):
8 | """ Compiles a template to python code. Lines are print'd as-is while blocks are executed.
9 | Format:
10 | (printed text)
11 | ${ code: } #python code, has access to args passed to render()
12 | (prints text depending on the code)
13 | ${ : } #block end signaled by ":"
14 |
15 | ${ if exists('var'): } #special function, same as: if 'var ' in locals/globals():
16 | ...
17 | ${: else x > 5: }
18 | ${ : }
19 | ${ #comment }
20 | $\\{:} escaped
21 | ${_write(x)} or ${x} or ${'%s' % x} or ${"", x} #x must exist
22 | ${:if:}{{test}}${:} #also ok
23 | ${
24 | if:
25 | } #also ok
26 | """
27 |
28 | # block patterns (DOTALL: . matches LF, *? non-greedy match)
29 | # matches "...${...}..."
30 | DELIMITER = re.compile(r"\$\{(.*?)\}", re.DOTALL)
31 | BLOCK_END = ':'
32 | ESCAPES = [('\\{','{')]
33 | AUTOWRITE = re.compile(r"(^[\'\"])|(^[a-zA-Z0-9_\[\]\'\"]+$)")
34 | FN_WRITE = '_write'
35 | FN_INCLUDE = '_include'
36 |
37 | def __init__(self, text=None):
38 | if text is None:
39 | raise ValueError('text required')
40 | self._file = 'template.py'
41 | self._code = self._compile(text)
42 |
43 | def _compile(self, template):
44 | indent = 0 # indented code
45 | spaces = 4
46 |
47 | # creates a final text 'program' made of parts = lines
48 | parts = []
49 |
50 | # may need this thing in first token/line for python2?
51 | #encoding_hack = '# -*- coding: utf-8 -*-'
52 |
53 | # split template into chunks of regular text and ${..} commands
54 | for i, part in enumerate(self.DELIMITER.split(template)):
55 | for base, change in self.ESCAPES:
56 | part = part.replace(base, change)
57 |
58 | if i % 2 == 0: # "even" parts = output
59 | if not part:
60 | continue
61 |
62 | # regular output is created by calling: 'write("""thing""")'
63 | part = part.replace('\\', '\\\\').replace('"', '\\"')
64 | part = '%s%s("""%s""")' % (' ' * indent, self.FN_WRITE, part)
65 |
66 | else: # "odd" parts = commands
67 | part = part.rstrip()
68 | if not part:
69 | continue
70 |
71 | #commands may be ":" (block end), "(:) ...:" (python code) or "name" (autowritten var 'name')
72 | command = part.strip()
73 | if command.startswith(':'): #block end
74 | if not indent:
75 | raise SyntaxError('no block statement to terminate: ${%s}$' % part)
76 | indent -= spaces
77 | part = command[1:]
78 |
79 | #subblock must be (...):
80 | if not part.endswith(':'):
81 | continue
82 |
83 | elif self.AUTOWRITE.match(command):
84 | part = '%s(%s)' % (self.FN_WRITE, command) #output var
85 |
86 | # in case of multiline command, and some cleanup
87 | lines = part.splitlines()
88 | margin = min(len(l) - len(l.lstrip()) for l in lines if l.strip())
89 | part = '\n'.join(' ' * indent + l[margin:] for l in lines)
90 |
91 | if part.endswith(':'):
92 | indent += spaces
93 |
94 | # next program chunk
95 | parts.append(part)
96 |
97 | if indent:
98 | raise SyntaxError('block statement not terminated (%i)' % indent)
99 |
100 | # finished program lines
101 | program = '\n'.join(parts)
102 | return compile(program, self._file, 'exec') #resulting 'code' can be called with exec(code, args)
103 |
104 | def render(self, **code_globals):
105 | text = []
106 |
107 | # 'write' will be called to output plain text, adds text to outer list
108 | def _write(*args):
109 | for value in args:
110 | text.append(str(value))
111 |
112 | # 'exists' may be called to check for var existence, since vars must exist in code_globals
113 | def _exists(arg):
114 | return arg in code_globals
115 |
116 | #'include'
117 | #def _include(file):
118 | # pass
119 |
120 |
121 | # contains passed render(key=value), that will become code's globals
122 | code_globals['__file__'] = self._file
123 | code_globals[self.FN_WRITE] = _write
124 | #code_globals[self.FN_INCLUDE] = _include
125 | code_globals['_exists'] = _exists
126 |
127 | # execute template code (loads 'text')
128 | exec(self._code, code_globals) #, code_locals
129 |
130 | # create final text output
131 | return ''.join(text)
132 |
--------------------------------------------------------------------------------
/wwiser/viewer/resources/viewer.css:
--------------------------------------------------------------------------------
1 | html { overflow-y: scroll; }
2 | body { font-family: monospace; font-size: 16px; white-space: nowrap; }
3 |
4 | /* fields */
5 | .object, .list, .field, .skip, .error { margin-left: 45px; }
6 |
7 | .head { display: flex; align-items: center; }
8 | .head > .attr { _margin-right:10px; _display:inline-block; margin: 0; padding: 0; _outline: 1px solid red;}
9 | .head > .attr.type { min-width:45px; color:#0074D9; }
10 | .head > .attr.name { min-width:350px; color:#FF4136; }
11 | .head > .attr.value { color:#3D9970; }
12 | .head > .attr.hashname,
13 | .head > .attr.guidname,
14 | .head > .attr.objpath,
15 | .head > .attr.path { color:#800080; _color:#9932CC; margin-left:10px; }
16 |
17 | .list > .head > .attr { color:#B10DC9; }
18 | .object > .head > .attr.name { width:auto; }
19 | .object > .head > .attr.type,
20 | .object > .head > .attr.name { color:#85144b; }
21 |
22 | .index { color:#777; font-size:12px; }
23 | .skip { color: #777; }
24 | .error { color:red; font-weight: bold; }
25 | .error:before { content: '**'; }
26 |
27 | .offset { color: #aaa; position: absolute; left: 10px; }
28 | .content { margin-left:80px; }
29 |
30 | /* simple */
31 | .simple .head > .attr.type { display:none; }
32 | .simple .offset { display:none; }
33 |
34 | /* links */
35 | .target {
36 | display:inline-block; width:0px; height:0px; min-width:0px;max-width:0px; min-height:0px;max-height:0px; vertical-align: center;
37 | margin-left:6px;text-indent:16px; overflow: hidden;
38 | background-color: transparent; border-radius:1px; width: 0; height: 0; border-left: 6px solid transparent; border-right: 6px solid transparent; border-bottom: 10px solid cadetblue;
39 | }
40 | .anchor {
41 | display:inline-block; width:10px; height:10px; min-width:10px;max-width:10px; min-height:10px;max-height:10px; vertical-align: center;
42 | margin-left:6px; text-indent:16px; overflow: hidden;
43 | background-color: #87b6b8; border-radius:6px;
44 | }
45 |
46 | /* toggler */
47 | .closable > .head { cursor:pointer; margin-left: -25px; padding-left: 25px; }
48 | .closable > .head:before {
49 | content:'-'; position:absolute; font-weight: bold; margin-top:2px; margin-left: -25px; color:666;
50 | width:20px; line-height:12px; text-align:center; display:inline-block; background-color:#eee; border-radius:3px; vertical-align: bottom;
51 | }
52 | .closable.hidden > .head:before { content:'+'; }
53 | .closable > .head > .attr { cursor:auto; }
54 | .closable.hidden > .body { display:none; }
55 |
56 | /* tooltips */
57 | .tooltip {
58 | position: relative;
59 | display: inline-block;
60 | background-color:#800080;
61 | width: 10px; height: 10px; margin-left:2px;
62 | }
63 | .tooltip.objpath { }
64 | .tooltip.path { border-radius: 6px; }
65 |
66 | .tooltip > .attr {
67 | display:none; position: absolute; top: -5px; right: 100%;
68 | border-radius: 5px; padding: 1px; background-color: #eee; color:#800080
69 | }
70 | .tooltip:hover .attr {
71 | display:block;
72 | }
73 |
74 | /* page */
75 | .tools {
76 | border: 1px solid #ccc;
77 | border-radius: 5px;
78 | padding: 10px;
79 | margin-bottom: 30px;
80 | }
81 | .view.hide-offset .offset {
82 | display:none;
83 | }
84 | .view.hide-type .attr.type {
85 | display:none;
86 | }
87 |
88 | /* above is common to XSL */
89 |
90 |
91 | .load-type {
92 | width:200px;
93 | }
94 |
95 | .tabs {
96 | _display:flex;
97 | _position:relative;
98 | }
99 |
100 | header { display:flex; }
101 |
102 | .logo { margin: 0; padding: 0; margin-right: 100px; }
103 | .tabs-panel { /*position:sticky;*/ top:0; float:right; margin-top: -30px; } /* todo flexstuff */
104 | .tabs-panel > label { font-size:20px; cursor:pointer; background-color:#ffffff; margin-left: 10px; border: 1px solid #ccc; border-radius:5pX; text-align: center; min-width:100px; display: inline-block; }
105 |
106 | .tab-radio { display: none; }
107 | .tab-radio:checked + .view { display:block; }
108 | .tab-radio + .view { display:none; margin-top: 10px; }
109 |
110 | .tab-button.selected { background-color:#666; color:#ffffff; }
111 | .doc {
112 | margin:0 auto;
113 | border:3px solid #eee; border-radius:3px; padding:10px; max-width: 900px;
114 | }
115 |
116 | /* adapted from github's */
117 | .markdown {
118 | font-family: Segoe UI, Helvetica, Arial, sans-serif; white-space: normal;
119 | font-size: 16px; line-height: 1.5; word-wrap: break-word;
120 | }
121 | .markdown h1 { font-size: 2em; }
122 | .markdown h1, .markdown h2 { padding-bottom: .3em; border-bottom: 1px solid #eaecef; }
123 | .markdown h1, .markdown h2, .markdown h3 {
124 | margin-top: 24px; margin-bottom: 16px; font-weight: 600; line-height: 1.25;
125 | }
126 | .markdown code, .markdown pre, .markdown tt { font-family: Consolas,Liberation Mono, Menlo, monospace; font-size: 12px; }
127 | .markdown code, .markdown tt {
128 | padding: .2em .4em; margin: 0; font-size: 85%; background-color: rgba(27,31,35,.05); border-radius: 6px;
129 | }
130 | .markdown pre {
131 | padding: 16px; overflow: auto; font-size: 85%; line-height: 1.45; background-color: #f6f8fa; border-radius: 6px;
132 | }
133 |
134 | .markdown b, .markdown strong { /*font-weight: 600;*/ }
135 | .markdown i, .markdown em { }
136 |
--------------------------------------------------------------------------------
/wwiser/generator/render/bnode_rules.py:
--------------------------------------------------------------------------------
1 | # TRANSITION RULES
2 | #
3 | # When a musicswitch or musicranseq changes between objects, wwise can set config
4 | # to tweaks how the audio transitions. Mostly "post-exit" and "pre-entry" info
5 | # (play overlapped audio) and a optional transition object
6 |
7 |
8 | class AkFade(object):
9 | def __init__(self, node):
10 | self.time = 0
11 | self.offset = 0
12 | self.curve = None
13 |
14 | self._build(node)
15 |
16 | def _build(self, node):
17 | # some early versions have garbage in some cases
18 | if not node:
19 | return
20 | ntt = node.find1(name='transitionTime')
21 | nfo = node.find1(name='iFadeOffset')
22 | nfc = node.find1(name='eFadeCurve')
23 |
24 | if ntt:
25 | self.time = ntt.value()
26 | if nfo:
27 | self.offset = nfo.value()
28 | if nfc:
29 | self.curve = nfc.value()
30 |
31 |
32 | class AkMusicTransSrcRule(object):
33 | def __init__(self, node):
34 | self.fade = None
35 | self.type = None
36 | self.play = False #post exit
37 | self._build(node)
38 |
39 | def _build(self, node):
40 | self.fade = AkFade(node)
41 | self.type = node.find1(name='eSyncType').value()
42 | self.play = node.find1(name='bPlayPostExit').value() != 0
43 |
44 | #ncue = node.find(name='uCueFilterHash')
45 | #if ncue:
46 | # self.cue = ncue.value()
47 |
48 |
49 | class AkMusicTransDstRule(object):
50 | def __init__(self, node):
51 | self.fade = None
52 | self.type = None
53 | self.play = False #pre extry
54 | self._build(node)
55 |
56 | def _build(self, node):
57 | self.fade = AkFade(node)
58 | self.type = node.find1(name='eEntryType').value()
59 | self.play = node.find1(name='bPlayPreEntry').value() != 0
60 |
61 | # varies with version
62 | #uMarkerID
63 | #uCueFilterHash
64 | #ncue = node.find(name='uCueFilterHash')
65 | #if ncue:
66 | # self.link_cue = ncue.value()
67 | #nmrk = node.find(name='uMarkerID')
68 | #if nmrk:
69 | # self.link_id = nmrk.value()
70 |
71 | #uJumpToID
72 | #eJumpToType
73 | #bDestMatchSourceCueName
74 |
75 | class AkMusicTransitionObject(object):
76 | def __init__(self, node):
77 | self.ntid = None
78 | self.tid = None
79 | self.fin = None
80 | self.fout = None
81 | self.pre = False
82 | self.post = False
83 |
84 | self._build(node)
85 |
86 | def _build(self, node):
87 | self.ntid = node.find1(name='segmentID')
88 | self.tid = self.ntid.value()
89 |
90 | nfin = node.find1(name='fadeInParams')
91 | self.fin = AkFade(nfin)
92 | nfout = node.find1(name='fadeOutParams')
93 | self.fin = AkFade(nfout)
94 |
95 | self.pre = node.find1(name='bPlayPreEntry').value() != 0
96 | self.post = node.find1(name='bPlayPostExit').value() != 0
97 |
98 |
99 | class AkTransitionRule(object):
100 | def __init__(self, node):
101 | self.src_ids = []
102 | self.dst_ids = []
103 | self.rsrc = None
104 | self.rdst = None
105 | self.rtrn = None
106 |
107 | self._build(node)
108 |
109 | def _build(self, node):
110 | # id=object, -1=any, 0=nothing
111 |
112 | # v088+ allows N but not sure how it's used, editor only sets one by one (seen in Detroit)
113 | nsrc_ids = node.finds(name='srcID')
114 | for nsrc_id in nsrc_ids:
115 | self.src_ids.append(nsrc_id.value())
116 |
117 | ndst_ids = node.finds(name='dstID')
118 | for ndst_id in ndst_ids:
119 | self.dst_ids.append(ndst_id.value())
120 |
121 | nrsrc = node.find(name='AkMusicTransSrcRule')
122 | self.rsrc = AkMusicTransSrcRule(nrsrc)
123 |
124 | nrdst = node.find(name='AkMusicTransDstRule')
125 | self.rdst = AkMusicTransDstRule(nrdst)
126 |
127 | # older versions use bIsTransObjectEnabled to signal use, but segmentID is 0 if false anyway
128 | nrtrn = node.find(name='AkMusicTransitionObject')
129 | if nrtrn:
130 | self.rtrn = AkMusicTransitionObject(nrtrn)
131 |
132 |
133 | class AkTransitionRules(object):
134 | def __init__(self, node):
135 | self._rules = []
136 | self.ntrns = []
137 |
138 | self._build(node)
139 |
140 | def _build(self, node):
141 | nrules = node.finds(name='AkMusicTransitionRule')
142 | for nrule in nrules:
143 | rule = AkTransitionRule(nrule)
144 | self._rules.append(rule)
145 | if rule.rtrn and rule.rtrn.tid: #segment 0 = useless
146 | self.ntrns.append(rule.rtrn)
147 |
148 | def get_rule(self, src_id, dst_id):
149 | #TODO implement (detect -1/0 too)
150 | #for ...
151 | return None
152 |
153 | def get_rules(self):
154 | return self._rules
155 |
--------------------------------------------------------------------------------
/wwiser/generator/render/bnode_statechunk.py:
--------------------------------------------------------------------------------
1 | from . import bnode_props
2 |
3 | # STATECHUNK
4 | #
5 | # Most Wwise objects can define properties + values to be used when a state in set ("states" tab).
6 | # For example could change object's volume=-96 when state bgm_type=silent. This info is saved into
7 | # a "StateChunk". StateChunks only define info for active states, so "none" is not allowed.
8 | #
9 | # These are often used to silence tracks using states (ex. MGR, DMC)
10 | # In rare cases also used to slightly increase volume from one track (Monster Hunter World's 3221323256.bnk).
11 |
12 | class _AkStateInfo(object):
13 | def __init__(self):
14 | self.nstategroupid = None
15 | self.nstatevalueid = None
16 | self.group = None
17 | self.value = None
18 | #self.ntid = None
19 | self.props = None
20 |
21 | class AkStateChunk(object):
22 | def __init__(self, node, builder):
23 | self.valid = False
24 | self._states = []
25 | self._usables = None
26 | self._build(node, builder)
27 |
28 | def _build(self, node, builder):
29 | nstatechunk = node.find1(name='StateChunk')
30 | if not nstatechunk:
31 | return
32 | self.valid = True
33 |
34 | # StateChunk has 2 sections:
35 | # - stateProps: list of AkStatePropertyInfo with possible modified properties (RTPC IDs).
36 | # It's the defined columns in the "states" tab, even if not used (defaulting to 0).
37 | # * AkStatePropertyInfo has ID, "inDb" (volume flag) and "accumType" (2=additive, but not
38 | # actually always true), but most of that info is implicit when applied later, thus ignored
39 | # - pStateChunks: list of AkStateGroupChunk, that defines which state=value will modify what.
40 | # Typically only one but may define N for state-group1=state-value1, state-group2=state-value2, ....
41 | #
42 | # Each state-value doesn't have the list of modified properties, but rather an ID of a CAkState HIRC,
43 | # that has those in their AkPropBundle props
44 |
45 | bank_id = nstatechunk.get_root().get_id()
46 |
47 | nstategroups = nstatechunk.finds(name='AkStateGroupChunk')
48 | for nstategroup in nstategroups:
49 | nstategroupid = nstategroup.find1(name='ulStateGroupID')
50 | #eStateSyncType #when to apply props when state changes, not needed since we don't to dynamic changes
51 |
52 | nstates = nstategroup.finds(name='AkState')
53 | if not nstates: #possible to have groupchunks without pStates when leaving all values default (Xcom2's 820279197)
54 | continue
55 |
56 | for nstate in nstates:
57 | nstatevalueid = nstate.find1(name='ulStateID')
58 | if not nstatevalueid or not nstatevalueid.value():
59 | continue #not possible to set "none" as value
60 |
61 |
62 | nprops = nstate.find1(name='AkPropBundle')
63 | if nprops:
64 | # state props are included directly (ex. Aster Tatariqus v150 58709424)
65 | props = bnode_props.CAkProps(nstate)
66 | if not props.valid:
67 | continue
68 |
69 | self.include_statechunk(nstategroupid, nstatevalueid, props)
70 |
71 | else:
72 | # uses a reference to a state object
73 | nstateinstanceid = nstate.find1(name='ulStateInstanceID') #each
74 | if not nstateinstanceid or not nstateinstanceid.value():
75 | continue
76 |
77 | # state should exist as a node and have properties
78 | tid = nstateinstanceid.value()
79 | bstate = builder._get_bnode(bank_id, tid)
80 | if not bstate or not bstate.props:
81 | continue
82 |
83 | self.include_statechunk(nstategroupid, nstatevalueid, bstate.props)
84 |
85 | def include_statechunk(self, nstategroupid, nstatevalueid, props):
86 | bsi = _AkStateInfo()
87 | bsi.nstategroupid = nstategroupid
88 | bsi.nstatevalueid = nstatevalueid
89 | bsi.group = nstategroupid.value()
90 | bsi.value = nstatevalueid.value()
91 | #bsi.ntid = nstateinstanceid
92 | bsi.props = props
93 |
94 | #TODO filter repeats
95 | self._states.append(bsi)
96 |
97 |
98 | def get_bsi(self, group, value):
99 | for bsi in self._states:
100 | if bsi.group == group and bsi.value == value:
101 | return bsi
102 | return None
103 |
104 | def get_states(self):
105 | return self._states
106 |
107 | # states with properties that wwiser/vgmstream can handle (ignores stuff like auxs)
108 | def get_usable_states(self, apply_bus):
109 | if self._usables is None:
110 | items = []
111 | for bsi in self._states:
112 | if bsi.props.is_usable(apply_bus):
113 | items.append(bsi)
114 | self._usables = items
115 | return self._usables
116 |
--------------------------------------------------------------------------------
/wwiser/generator/render/wbuilder_util.py:
--------------------------------------------------------------------------------
1 | from .bnode_hircs import *
2 |
3 |
4 | # default for non-useful HIRC classes
5 | _DEFAULT_BUILDER_NODE = CAkNone
6 |
7 | # HIRC classes and their rebuilt equivalent.
8 | # Internal classes (like AkTrackSrcInfo) are handled separately per class since they
9 | # tend to need custom behavior
10 | _HIRC_BUILDER_NODES = {
11 | # actions
12 | 'CAkEvent': CAkEvent,
13 | 'CAkDialogueEvent': CAkDialogueEvent,
14 | 'CAkActionPlay': CAkActionPlay,
15 | 'CAkActionTrigger': CAkActionTrigger,
16 |
17 | # not found, may need to do something with them
18 | 'CAkActionPlayAndContinue': CAkActionPlayAndContinue,
19 | 'CAkActionPlayEvent': CAkActionPlayEvent,
20 |
21 | # sound hierarchy
22 | 'CAkActorMixer': CAkActorMixer,
23 | 'CAkLayerCntr': CAkLayerCntr,
24 | 'CAkSwitchCntr': CAkSwitchCntr,
25 | 'CAkRanSeqCntr': CAkRanSeqCntr,
26 | 'CAkSound': CAkSound,
27 |
28 | # music hierarchy
29 | 'CAkMusicSwitchCntr': CAkMusicSwitchCntr,
30 | 'CAkMusicRanSeqCntr': CAkMusicRanSeqCntr,
31 | 'CAkMusicSegment': CAkMusicSegment,
32 | 'CAkMusicTrack': CAkMusicTrack,
33 |
34 | # others
35 | 'CAkState': CAkState,
36 | 'CAkFxCustom': CAkFxCustom,
37 | 'CAkFxShareSet': CAkFxShareSet,
38 | 'CAkBus': CAkBus,
39 | 'CAkAuxBus': CAkAuxBus,
40 | 'CAkAudioDevice': CAkAudioDevice,
41 |
42 | #not useful
43 | #CAkActionSetState
44 | #CAkAction*
45 | #CAkAuxBus
46 | #CAkFeedbackBus: accepts audio from regular sounds + creates rumble
47 | #CAkFeedbackNode: played like audio (play action) and has source ID, but it's simply a rumble generator
48 | #CAkAttenuation
49 | #CAkFxShareSet
50 | #CAkLFOModulator
51 | #CAkEnvelopeModulator
52 | #CAkTimeModulator
53 | }
54 |
55 | def get_builder_hirc_class(hircname):
56 | return _HIRC_BUILDER_NODES.get(hircname, _DEFAULT_BUILDER_NODE)
57 |
58 |
59 | # Classes that may generate unused audio, ordered by priority (musicsegment may contain unused musictrack)
60 | UNUSED_HIRCS = [
61 | #'CAkEvent',
62 | #'CAkDialogueEvent',
63 | 'CAkActionPlay',
64 | 'CAkActionTrigger',
65 |
66 | 'CAkActionPlayAndContinue',
67 | 'CAkActionPlayEvent',
68 |
69 | 'CAkLayerCntr',
70 | 'CAkSwitchCntr',
71 | 'CAkRanSeqCntr',
72 | 'CAkSound',
73 |
74 | 'CAkMusicSwitchCntr',
75 | 'CAkMusicRanSeqCntr',
76 | 'CAkMusicSegment',
77 | 'CAkMusicTrack',
78 | ]
79 |
80 |
81 | # Wwise separates shortIDs into:
82 | # - explicit: internal use only (guidnames for most objects)
83 | # - implicit: external use (hashnames for events, switches, states, rtpcs, textures)
84 | # - media: .wem, same as explicit.
85 | #
86 | # ShortIDs can be reused between types, so it's possible to have an event, dialogueevent, bus and
87 | # aksound with the same ID (not that common though), while you can't have 2 buses named the same
88 | # (even aux-bus + bus).
89 | #
90 | # Explicit objects won't repeat IDs *in the same bank* (no aksound + akswitch with same ID) but
91 | # hashnames can match explicit IDs. Devs could also make one aksound in one bank, compile, change the
92 | # aksound's source and make another bank, in effect 2 different audio objects with the same ID,
93 | # so bank is also taken into account (seen in Detroit).
94 | #
95 | # This means when loading/reading another object we need to know the type, to allow certain repeated
96 | # IDs (though it's rare). Not all named objects in the editor have an hashname though.
97 | #
98 | # implicits
99 | IDTYPE_EVENT = 'ev'
100 | IDTYPE_DIALOGUEEVENT = 'de'
101 | # not used directly in hircs (kind of implicit)
102 | IDTYPE_STATE_GROUP = 'stgr'
103 | IDTYPE_STATE_VALUE = 'stvl'
104 | IDTYPE_SWITCH_GROUP = 'swgr'
105 | IDTYPE_SWITCH_VALUE = 'swvl'
106 | IDTYPE_GAMEPARAMETER = 'gp'
107 | IDTYPE_TRIGGER = 'tr'
108 | IDTYPE_ARGUMENTS = 'ar' #old dialogueevent args, uses switches or states in later versions
109 |
110 | # actor-mixer / interactive music (guidnames)
111 | IDTYPE_AUDIO = 'au'
112 | # master-mixer (usually a hashname)
113 | IDTYPE_BUS = 'bu'
114 | # share sets > effects
115 | IDTYPE_SHARESET = 'ef'
116 | # from tests
117 | IDTYPE_AUDIODEVICE = 'ad'
118 |
119 |
120 | _IDTYPE_HIRCS = {
121 | # event/implicit
122 | 'CAkEvent': IDTYPE_EVENT,
123 | 'CAkDialogueEvent': IDTYPE_DIALOGUEEVENT,
124 |
125 | # buses
126 | 'CAkBus': IDTYPE_BUS,
127 | 'CAkAuxBus': IDTYPE_BUS,
128 | 'CAkFeedbackBus': IDTYPE_BUS, #untested
129 |
130 | 'CAkFxShareSet': IDTYPE_SHARESET,
131 |
132 | 'CAkAudioDevice': IDTYPE_AUDIODEVICE,
133 |
134 | # audio (default since most objects are this)
135 | #CAkAction*
136 |
137 | #CAkActorMixer
138 | #CAkLayerCntr
139 | #CAkSwitchCntr
140 | #CAkRanSeqCntr
141 | #CAkSound
142 |
143 | #CAkMusicSwitchCntr
144 | #CAkMusicRanSeqCntr
145 | #CAkMusicSegment
146 | #CAkMusicTrack
147 |
148 | #CAkState #audio objects, despite the name
149 | #CAkFeedbackNode #assumed, played like audio
150 |
151 | #CAkLFOModulator #can be named in editor, but internally uses guidnames
152 | #CAkEnvelopeModulator #same
153 | #CAkTimeModulator #same
154 | #CAkAttenuation #assumed to be the same
155 | #CAkFxCustom: #assumed, not using hashnames
156 | }
157 |
158 |
159 | def get_builder_hirc_idtype(hircname):
160 | return _IDTYPE_HIRCS.get(hircname, IDTYPE_AUDIO)
161 |
--------------------------------------------------------------------------------
/wwiser/generator/wmover.py:
--------------------------------------------------------------------------------
1 | import logging, os
2 | from .render import bnode_source
3 |
4 | # Moves 123.wem to /txtp/wem/123.wem, or 123.ogg/logg to /txtp/wem/123.logg if alt_exts is set
5 |
6 | _OBJECT_SOURCES = {
7 | 'CAkSound': 'AkBankSourceData',
8 | 'CAkMusicTrack': 'AkBankSourceData',
9 | }
10 |
11 |
12 | class Mover(object):
13 | def __init__(self, txtpcache):
14 | self._txtpcache = txtpcache
15 | self._nodes = []
16 | self._moved_sources = {}
17 | # conserve case stuff
18 | self._dirs = {}
19 |
20 | def add_node(self, node):
21 | hircname = node.get_name()
22 | node_name = _OBJECT_SOURCES.get(hircname)
23 | if node_name:
24 | nsources = node.finds(name=node_name)
25 | self._nodes.extend(nsources)
26 |
27 | def move_wems(self):
28 | if self._txtpcache.locator.is_auto_find():
29 | logging.info("mover: can't move wems when using autofind")
30 | return
31 |
32 | if not self._nodes:
33 | return
34 | for node in self._nodes:
35 | self._move_wem(node)
36 |
37 | def _move_wem(self, node):
38 | if not node:
39 | return
40 |
41 | source = bnode_source.AkBankSourceData(node, None)
42 | if not source or not source.tid: #?
43 | return
44 | if source.tid in self._moved_sources:
45 | return
46 | if source.plugin_external or source.plugin_id: #not audio:
47 | return
48 | if source.internal and not self._txtpcache.bnkskip:
49 | return
50 |
51 | self._moved_sources[source.tid] = True #skip dupes
52 |
53 | nroot = node.get_root()
54 | in_dir = nroot.get_path()
55 | out_dir = self._txtpcache.locator.get_wem_fullpath()
56 |
57 | if in_dir == out_dir:
58 | return
59 |
60 | in_name, out_name = self._get_names(source, in_dir, out_dir)
61 |
62 | if os.path.exists(out_name):
63 | if os.path.exists(in_name):
64 | logging.info("generator: cannot move %s (exists on output folder)", in_name)
65 | return
66 |
67 | bank = nroot.get_filename()
68 | wem_exists = os.path.exists(in_name)
69 | if not wem_exists:
70 | if self._txtpcache.alt_exts:
71 | # try as .logg
72 | in_name = "%s.%s" % (source.tid, source.extension_alt)
73 | in_name = os.path.join(in_dir, in_name)
74 | in_name = os.path.normpath(in_name)
75 | wem_exists = os.path.exists(in_name)
76 |
77 | elif in_dir != out_dir:
78 | # by default it tries in the bank's dir, but in case of lang banks may need to try in other banks' folder
79 | in_dir = self._txtpcache.locator.get_root_fullpath()
80 | in_name, out_name = self._get_names(source, in_dir, out_dir)
81 | wem_exists = os.path.exists(in_name)
82 |
83 | if not wem_exists:
84 | logging.info("generator: cannot move %s (file not found) / %s", in_name, bank)
85 | return
86 |
87 | # it's nice to keep original extension case (also for case-sensitive OSs)
88 | in_name, out_name = self.fix_case(in_name, out_name)
89 |
90 | os.rename(in_name, out_name)
91 | logging.debug("generator: moved %s / %s", in_name, bank)
92 |
93 | return
94 |
95 | def fix_case(self, in_name, out_name):
96 | dir = os.path.dirname(in_name)
97 | name = os.path.basename(in_name)
98 | if not dir:
99 | dir = '.'
100 |
101 | if dir not in self._dirs:
102 | self._dirs[dir] = os.listdir(dir)
103 | items = self._dirs[dir]
104 |
105 | # find OS's file as see if it's named differently
106 | name_lw = name.lower()
107 |
108 | for item in items:
109 | if name_lw.endswith(item.lower()):
110 | if name != item:
111 | _, item_in_ext = os.path.splitext(item)
112 | item_out_ext = item_in_ext
113 |
114 | in_base, _ = os.path.splitext(in_name)
115 | _, in_ext = os.path.splitext(in_name)
116 |
117 | out_base, _ = os.path.splitext(out_name)
118 | _, out_ext = os.path.splitext(out_name)
119 |
120 | if in_ext != out_ext and out_ext.lower().startswith('.l'): #localized
121 | item_out_ext = '.L' + item_out_ext[1:]
122 |
123 | in_name = in_base + item_in_ext
124 | out_name = out_base + item_out_ext
125 | break
126 |
127 | return (in_name, out_name)
128 |
129 | def _get_names(self, source, in_dir, out_dir):
130 | os.makedirs(out_dir, exist_ok=True)
131 |
132 | in_extension = source.extension
133 | out_extension = source.extension
134 | if self._txtpcache.alt_exts:
135 | #in_extension = source.extension_alt #handled below
136 | out_extension = source.extension_alt
137 |
138 | in_name = "%s.%s" % (source.tid, in_extension)
139 | in_name = os.path.join(in_dir, in_name)
140 | in_name = os.path.normpath(in_name)
141 | out_name = "%s.%s" % (source.tid, out_extension)
142 | out_name = os.path.join(out_dir, out_name)
143 | out_name = os.path.normpath(out_name)
144 | return (in_name, out_name)
145 |
--------------------------------------------------------------------------------
/wwiser/names/wsqlite.py:
--------------------------------------------------------------------------------
1 | import logging, os, os.path, sys, sqlite3
2 | from .wnamerow import NameRow
3 |
4 | # wwnames.db3 database handler
5 | # This is meant to be a common names database (like a default wwnames.txt)
6 | # However since wwise's hash is too simple and has many collisions, this isn't that useful.
7 |
8 | class SqliteHandler(object):
9 | BATCH_COUNT = 50000 #more=higher memory, but much faster for huge (500000+) sets
10 |
11 | def __init__(self):
12 | self._cx = None
13 |
14 | def is_open(self):
15 | return self._cx
16 |
17 | def open(self, filename, preinit=False):
18 | if not filename:
19 | filename = 'wwnames.db3' #in work dir
20 | #if not filename:
21 | # raise ValueError("filename not provided")
22 |
23 | basepath = filename
24 | workpath = os.path.dirname(sys.argv[0])
25 | workpath = os.path.join(workpath, filename)
26 | if os.path.isfile(basepath):
27 | path = basepath
28 | elif os.path.isfile(workpath):
29 | path = workpath
30 | else:
31 | path = None #no existing db3
32 |
33 | # connect() creates DB if file doesn't exists, allow only if flag is set
34 | if not path:
35 | if not preinit:
36 | logging.info("names: couldn't find %s name file", workpath)
37 | return
38 | path = filename
39 | logging.info("names: loading %s", filename)
40 |
41 | #by default each thread needs its own cx (ex. viewer/server thread vs dumper/main thread),
42 | #but we don't really care since it's mostly read-only (could use some kinf od threadlocal?)
43 | self._cx = sqlite3.connect(path, check_same_thread=False)
44 | self._setup()
45 |
46 | def close(self):
47 | if not self._cx:
48 | return
49 | self._cx.close()
50 |
51 | def save(self, names, hashonly=False, save_all=False, save_companion=False):
52 | if not self._cx:
53 | return
54 | if not names:
55 | return
56 |
57 | cx = self._cx
58 | cur = cx.cursor()
59 |
60 | total = 0
61 | count = 0
62 | for row in names:
63 | #save hashnames only, as they can be safely shared between games
64 | if not row.hashname:
65 | continue
66 | #save used names only, unless set to save all
67 | if not save_all and not row.hashname_used:
68 | continue
69 | #save names not in xml/h/etc only, unless set to save extra
70 | if row.source != NameRow.NAME_SOURCE_EXTRA and not save_companion:
71 | continue
72 |
73 |
74 | params = (row.id, row.hashname)
75 | cur.execute("INSERT OR IGNORE INTO names(id, name) VALUES(?, ?)", params)
76 | #logging.debug("names: insert %s (%i)", row.hashname, cur.rowcount)
77 |
78 | count += cur.rowcount
79 | if count == self.BATCH_COUNT:
80 | total += count
81 | logging.info("names: %i saved...", total)
82 | cx.commit()
83 | count = 0
84 |
85 | total += count
86 | logging.info("names: total %i saved", total)
87 | cx.commit()
88 |
89 |
90 | def _to_namerow(self, row):
91 | #id = row['id']
92 | #name = row['name']
93 | id, name = row
94 | return NameRow(id, hashname=name)
95 |
96 | def select_by_id(self, id):
97 | if not self._cx:
98 | return
99 | #with closing(db.cursor()) as cursor: ???
100 | cur = self._cx.cursor()
101 |
102 | params = (id,)
103 | cur.execute("SELECT id, name FROM names WHERE id = ?", params)
104 | rows = cur.fetchall()
105 | for row in rows:
106 | return self._to_namerow(row)
107 | return None
108 |
109 | def select_by_id_fuzzy(self, id):
110 | if not self._cx:
111 | return
112 | cur = self._cx.cursor()
113 |
114 | #FNV hashes only change last byte when last char changes. We can use this property to get
115 | # close names (like "bgm1"=1189781958 / 0x46eaa1c6 and "bgm2"=1189781957 / 0x46eaa1c5)
116 | id = id & 0xFFFFFF00
117 | params = (id + 0, id + 256)
118 | cur.execute("SELECT id, name FROM names WHERE id >= ? AND id < ?", params)
119 | rows = cur.fetchall()
120 | for row in rows:
121 | return self._to_namerow(row)
122 | return None
123 |
124 | def _setup(self):
125 | cx = self._cx
126 | #init main table is not existing
127 | cur = cx.cursor()
128 | cur.execute("SELECT * FROM sqlite_master WHERE type='table' AND name='%s'" % 'names')
129 | rows = cur.fetchall() #cur.rowcount is for updates
130 | if len(rows) >= 1:
131 | #migrate/etc
132 | return
133 |
134 | try:
135 | cur.execute("CREATE TABLE names(oid integer PRIMARY KEY, id integer, name text)") #, origin text, added date
136 | #cur.execute("CREATE INDEX index_names_id ON names(id)")
137 | cur.execute("CREATE UNIQUE INDEX index_names_id ON names(id)")
138 |
139 | #maybe should create a version table to handle schema changes
140 | cx.commit()
141 | finally:
142 | cx.rollback()
143 |
--------------------------------------------------------------------------------
/wwiser/parser/wfinder.py:
--------------------------------------------------------------------------------
1 |
2 | # finds nodes in a node's tree based on config, external to simplify but could be optimized
3 | # if added to model to avoid generating attrs dicts
4 | class NodeFinder(object):
5 | def __init__(self, name=None, type=None, names=None, types=None, value=None, values=None, contains=None):
6 | if name:
7 | names = [name]
8 | if type:
9 | types = [type]
10 | if value is not None: #may be 0
11 | values = [value]
12 | if names is None:
13 | names = []
14 | if types is None:
15 | types = []
16 | if values is None:
17 | values = []
18 | self.names = names
19 | self.types = types
20 | self.values = values
21 | self.results = []
22 | self.first = False
23 | self.base = False
24 | self.contains = contains
25 | self.empty = not names and not types and not values and not contains
26 |
27 |
28 | def find1(self, node):
29 | return self.find(node, first=True)
30 |
31 | def find(self, node, first=False):
32 | if not node:
33 | return None
34 | self.first = first
35 | self.depth = 0
36 |
37 | # aim for outer nodes first as it's slightly faster in some cases
38 | #self._find_inner(node)
39 | self._find_outer([node])
40 |
41 | if self.results:
42 | if len(self.results) > 1:
43 | raise ValueError("more than 1 result found")
44 | return self.results[0]
45 | else:
46 | return None
47 |
48 | def finds(self, node):
49 | if not node:
50 | return []
51 | self.depth = 0
52 |
53 | # aim for outer nodes first as it's slightly faster in some cases
54 | #self._find_inner(node)
55 | self._find_outer([node])
56 |
57 | return self.results
58 |
59 | # find query in tree, going in depth first:
60 | # A > B > C
61 | # > D
62 | # > E
63 | # > F
64 | # > G
65 | # order: A B C D E F G (finds lower nodes faster)
66 | def _find_inner(self, node):
67 | if self.empty:
68 | return
69 | if self.first and self.results:
70 | return
71 |
72 | self._query(node)
73 |
74 | # target exists and only need one result: stop
75 | if self.first and self.results:
76 | return
77 |
78 | # keep finding results in children
79 | children = node.get_children()
80 | if not children:
81 | return
82 | for subnode in children:
83 | self.depth += 1
84 | self._find_inner(subnode)
85 | self.depth -= 1
86 |
87 | # find query in tree, going same-level first:
88 | # A > B > C
89 | # > D
90 | # > E
91 | # > F
92 | # > G
93 | # order: A B G C E D F (finds upper nodes faster)
94 | def _find_outer(self, nodes):
95 | if self.empty:
96 | return
97 | if self.first and self.results:
98 | return
99 |
100 | if not nodes:
101 | return
102 |
103 | # find query on this level
104 | for node in nodes:
105 | self._query(node)
106 | # target exists and only need one result: stop
107 | if self.first and self.results:
108 | return
109 |
110 | # find query on children level
111 | for node in nodes:
112 | self.depth += 1
113 | self._find_outer(node.get_children())
114 | self.depth -= 1
115 | # target exists and only need one result: stop
116 | if self.first and self.results:
117 | return
118 |
119 |
120 | def _query(self, node):
121 | # may simplify with a list of find key + value (like contains)?
122 | # find target values in attrs
123 | #attrs = node.get_attrs()
124 | valid = self.depth > 0 or self.depth == 0 and self.base #first
125 | if not valid:
126 | return
127 |
128 | attr = node.get_attr('name')
129 | if attr:
130 | for target in self.names:
131 | if attr == target:
132 | self.results.append(node)
133 |
134 | attr = node.get_attr('type')
135 | if attr:
136 | for target in self.types:
137 | if attr == target:
138 | self.results.append(node)
139 |
140 | attr = node.get_attr('value')
141 | if attr is not None:
142 | for target in self.values:
143 | if attr == target:
144 | self.results.append(node)
145 |
146 | if self.contains:
147 | key, val = self.contains
148 | attr = node.get_attr(key)
149 | if attr and val in attr:
150 | self.results.append(node)
151 |
152 | return
153 |
154 |
155 | def node_find(node, name=None, type=None, names=None, types=None, value=None, values=None):
156 | finder = NodeFinder(name=name, type=type, names=names, types=types)
157 | return finder.find(node)
158 |
159 | def node_finds(node, name=None, type=None, names=None, types=None, first=False, value=None, values=None):
160 | finder = NodeFinder(name=name, type=type, names=names, types=types, value=value, values=values)
161 | return finder.finds(node)
162 |
--------------------------------------------------------------------------------
/wwiser/generator/render/wstate.py:
--------------------------------------------------------------------------------
1 | from ..registry import wgamesync, wstatechunks, wtransitions, wstingers, wgamevars, wparams
2 |
3 | # Simple container/simulation of internal wwise state, (currently set or newly registered paths).
4 |
5 | class WwiseState(object):
6 | def __init__(self, txtpcache):
7 | self._txtpcache = txtpcache
8 |
9 | # combos found during process
10 | self.gspaths = None #gamesyncs: states/switches for dynamic object paths
11 | self.scpaths = None #statechunks: states for dynamic property changes
12 | self.gvpaths = None #gamevars: variables for dynamic property changes
13 |
14 | # currently set values (when None renderer will try to find possible combos)
15 | self.gsparams = None
16 | self.scparams = None
17 | self.gvparams = None
18 |
19 | # special values, not reset
20 | self.transitions = None # sub-objects when changing from one GS path to other
21 | self.stingers = None # sub-objects triggered with another plays
22 |
23 | self._default_gspaths = None
24 | self._default_gsparams = None
25 | self._default_scpaths = None
26 | self._default_scparams = None
27 | self._default_gvpaths = None
28 | self._default_gvparams = None
29 |
30 | self.reset()
31 |
32 | def reset(self):
33 | # resets should make new lists and not just .clear() in case list is copied?
34 | self.reset_gs()
35 | self.reset_sc()
36 | self.reset_gv()
37 | self.transitions = wtransitions.Transitions()
38 | self.stingers = wstingers.Stingers()
39 |
40 | def reset_gs(self):
41 | self.gspaths = self._default_gspaths
42 | self.gsparams = self._default_gsparams
43 | if not self.gspaths:
44 | self.gspaths = wgamesync.GamesyncPaths(self._txtpcache)
45 |
46 | def reset_sc(self):
47 | self.scpaths = self._default_scpaths
48 | self.scparams = self._default_scparams
49 | if not self.scpaths:
50 | self.scpaths = wstatechunks.StateChunkPaths(self._txtpcache.wwnames)
51 |
52 | def reset_gv(self):
53 | self.gvpaths = self._default_gvpaths
54 | self.gvparams = self._default_gvparams
55 |
56 | # ---
57 |
58 | def gs_registrable(self):
59 | return self.gsparams is None
60 |
61 | def get_gscombos(self):
62 | if self.gsparams or self.gspaths.is_empty():
63 | return None
64 | return self.gspaths.combos()
65 |
66 | def set_gs(self, gsparams):
67 | if self._default_gsparams:
68 | return
69 | self.gsparams = gsparams
70 |
71 | # ---
72 |
73 | def sc_registrable(self):
74 | return self.scparams is None
75 |
76 | def get_sccombos(self):
77 | if self.scparams or self.scpaths.is_empty():
78 | return None
79 | return self.scpaths.combos()
80 |
81 | def set_sc(self, scparams):
82 | self.scparams = scparams
83 |
84 | # ---
85 |
86 | def gv_registrable(self):
87 | return self.gvparams is not None
88 |
89 | def get_gvcombos(self):
90 | if self.gvparams or not self.gvpaths:
91 | return None
92 | return self.gvpaths.combos()
93 |
94 | def set_gv(self, gvparams):
95 | self.gvparams = gvparams
96 |
97 | # ---
98 |
99 | # Handle param defaults, that work mostly the same.
100 | #
101 | # Param list can be N combos of params, so make a default GS/SC/GV path handler, and pass the pre-parsed list
102 | # that is converted to internal stuff. To simplify
103 | # If there is only one 1 path set it default to optimize calls (otherwise would try to get combos for 1 type).
104 |
105 | def set_gsdefaults(self, items):
106 | if items is None: #allow []
107 | return
108 | params = wparams.Params(allow_st=True, allow_sw=True)
109 | params.adds(items)
110 |
111 | gspaths = wgamesync.GamesyncPaths(self._txtpcache)
112 | gspaths.add_params(params)
113 |
114 | gscombos = gspaths.combos()
115 | if len(gscombos) == 1:
116 | self._default_gspaths = None
117 | self._default_gsparams = gscombos[0]
118 | elif len(gscombos) > 1:
119 | self._default_gspaths = gspaths
120 | self._default_gsparams = None
121 |
122 | def set_scdefaults(self, items):
123 | if items is None: #allow []
124 | return
125 | params = wparams.Params(allow_st=True)
126 | params.adds(items)
127 |
128 | scpaths = wstatechunks.StateChunkPaths(self._txtpcache.wwnames)
129 | scpaths.add_params(params)
130 |
131 | sccombos = scpaths.combos()
132 | if len(sccombos) == 1:
133 | self._default_scpaths = None
134 | self._default_scparams = sccombos[0]
135 | elif len(sccombos) > 1:
136 | self._default_scpaths = scpaths
137 | self._default_scparams = None
138 |
139 | def set_gvdefaults(self, items):
140 | if items is None: #allow []
141 | return
142 | params = wparams.Params(allow_gp=True)
143 | params.adds(items)
144 |
145 | gvpaths = wgamevars.GamevarsPaths()
146 | gvpaths.add_params(params)
147 |
148 | gvcombos = gvpaths.combos()
149 | if len(gvcombos) == 1:
150 | self._default_gvpaths = None
151 | self._default_gvparams = gvcombos[0]
152 | elif len(gvcombos) > 1:
153 | self._default_gvpaths = gvpaths
154 | self._default_gvparams = None
155 |
--------------------------------------------------------------------------------
/wwiser/generator/render/bnode_base.py:
--------------------------------------------------------------------------------
1 | from . import bnode_automation, bnode_props, bnode_rtpc, bnode_rules, bnode_source, bnode_tree, bnode_stinger, bnode_statechunk, bnode_fxs, bnode_auxs
2 | from ..txtp import wtxtp_fields
3 |
4 |
5 | #beware circular refs
6 | #class CAkNode(object):
7 | # def __init__(self):
8 | # pass #no params since changing constructors is a pain
9 |
10 | # common for all builder nodes (bnodes)
11 | class CAkHircNode(object):
12 | def __init__(self):
13 | pass #no params since inheriting and changing python constructors is a pain
14 |
15 | def init_builder(self, builder):
16 | self._builder = builder
17 |
18 | def init_node(self, node):
19 | self._build_defaults(node)
20 |
21 | self.fields = wtxtp_fields.TxtpFields() #main node fields, for printing
22 |
23 | # loaded during process, if object has them (different classes have more or less)
24 | self.props = None
25 | self.statechunk = None
26 | self.rtpclist = None
27 | self.stingerlist = None
28 | self.fxlist = None
29 |
30 | self.bbus = None
31 | self.bparent = None
32 |
33 | self._build(node)
34 |
35 | #--------------------------------------------------------------------------
36 |
37 | def _barf(self, text="not implemented"):
38 | raise ValueError("%s - %s %s" % (text, self.name, self.sid))
39 |
40 |
41 | def _build_defaults(self, node):
42 | # common to all HIRC nodes
43 | self.node = node
44 | self.name = node.get_name()
45 | self.nsid = node.find1(type='sid')
46 | self.sid = None
47 | if self.nsid:
48 | self.sid = self.nsid.value()
49 |
50 | def _build(self, node):
51 | self._barf()
52 |
53 | #--------------------------------------------------------------------------
54 |
55 | def _read_device(self, ntid):
56 | return self._builder._get_bnode_link_device(ntid)
57 |
58 | def _read_bus(self, ntid):
59 | return self._builder._get_bnode_link_bus(ntid)
60 |
61 | def _read_parent(self, ntid):
62 | return self._builder._get_bnode_link(ntid)
63 |
64 | def _make_props(self, nbase):
65 | if not nbase:
66 | return None
67 | props = bnode_props.CAkProps(nbase)
68 | if not props.valid:
69 | return None
70 | self._builder.report_unknown_props(props.unknowns)
71 |
72 | # add only behavior props (relative props include parents+buses, which aren't always part of path tree)
73 | self.fields.props(props.fields_bfld)
74 | self.fields.keyvals(props.fields_bstd)
75 | self.fields.keyminmaxs(props.fields_brng)
76 |
77 | return props
78 |
79 | def _make_statechunk(self, nbase):
80 | if not nbase:
81 | return None
82 |
83 | statechunk = bnode_statechunk.AkStateChunk(nbase, self._builder)
84 | if not statechunk.valid:
85 | return None
86 |
87 | # during during calculations to make a final list
88 | #for bsi in statechunk.get_states():
89 | # self.fields.statechunk(bsi.nstategroupid, bsi.nstatevalueid, bsi.props)
90 |
91 | return statechunk
92 |
93 | def _make_rtpclist(self, nbase):
94 | if not nbase:
95 | return None
96 |
97 | # RTPC linked to volume (ex. DMC5 battle rank layers, ACB whispers)
98 | globalsettings = self._builder._globalsettings
99 | rtpclist = bnode_rtpc.AkRtpcList(nbase, globalsettings)
100 | if not rtpclist.valid:
101 | return None
102 |
103 | # during during calculations to make a final list
104 | #for brtpc in rtpclist.get_rtpcs():
105 | # self.fields.rtpc(brtpc.nid, brtpc.nparam, brtpc.values_x(), brtpc.values_y())
106 | return rtpclist
107 |
108 | def _make_transition_rules(self, node, is_switch):
109 | rules = bnode_rules.AkTransitionRules(node)
110 | if not is_switch and rules.ntrns:
111 | # rare in playlists (Polyball, Spiderman)
112 | self._builder.report_transition_object()
113 | return rules
114 |
115 | def _make_tree(self, node):
116 | tree = bnode_tree.AkDecisionTree(node)
117 | if not tree.init:
118 | return None
119 | return tree
120 |
121 | def _make_fxlist(self, node):
122 | fxlist = bnode_fxs.AkFxChunkList(node, self._builder)
123 | if not fxlist.init:
124 | return None
125 | return fxlist
126 |
127 | def _make_auxlist(self, node, bparent):
128 | auxlist = bnode_auxs.AkAuxList(node, bparent, self)
129 | if not auxlist.init:
130 | return None
131 | return auxlist
132 |
133 | def _make_automationlist(self, node):
134 | return bnode_automation.AkClipAutomationList(node)
135 |
136 | def _make_stingerlist(self, node):
137 | return bnode_stinger.CAkStingerList(node)
138 |
139 | def _make_source(self, nbnksrc):
140 | source = bnode_source.AkBankSourceData(nbnksrc, self.sid)
141 |
142 | # sources may be:
143 | # - standard .wem
144 | # - Wwise Audio Input (audio capturing)
145 | # - Wwise Silence
146 | # - Wwise Sine (configurable secs, simple sine)
147 | # - Wwise Synth One (infinite duration, kind of midi-controlled sine?)
148 | # - Wwise Tone Generator (~1sec, selectable tone like sine, triangle, noise, etc)
149 | # - Wwise External Source (handled separately)
150 |
151 | if source.is_plugin_silence:
152 | if source.plugin_size:
153 | # older games have inline plugin info
154 | source.plugin_fx = self._make_sfx(nbnksrc, source.plugin_id)
155 | else:
156 | # newer games use another CAkFxCustom (though in theory could inline)
157 | bank_id = source.nsrc.get_root().get_id()
158 | tid = source.tid
159 | bfxcustom = self._builder._get_bnode(bank_id, tid)
160 | if bfxcustom:
161 | source.plugin_fx = bfxcustom.fx
162 |
163 | return source
164 |
165 | def _make_sfx(self, node, plugin_id):
166 | return bnode_source.CAkFx(node, plugin_id)
167 |
--------------------------------------------------------------------------------
/wwiser/generator/wtags.py:
--------------------------------------------------------------------------------
1 | import logging, os
2 |
3 |
4 | VALID_EXTENSIONS = ['.wem', ".wav", ".lwav", ".xma", ".ogg", ".logg"]
5 |
6 | # !tags.m3u helper
7 |
8 | #******************************************************************************
9 |
10 | class Tags(object):
11 | def __init__(self, banks, locator=None, names=None):
12 | self._banks = banks
13 | self._names = names
14 | self._locator = locator
15 |
16 | self.make_event = False
17 | self.make_wem = False
18 | self.shortevent = False
19 | self.add = False
20 | self.limit = None
21 |
22 | self._tag_names = {}
23 |
24 | #--------------------------------------------------------------------------
25 |
26 | def add_tag_names(self, shortname, longname):
27 | self._tag_names[shortname] = longname
28 |
29 | def set_locator(self, locator):
30 | self._locator = locator
31 |
32 | def set_make_event(self, flag):
33 | self.make_event = flag
34 | self.shortevent = flag
35 |
36 | def set_make_wem(self, flag):
37 | self.make_wem = flag
38 |
39 | def set_add(self, flag):
40 | self.add = flag
41 |
42 | def set_limit(self, value):
43 | self.limit = value
44 |
45 | def get_limit(self):
46 | return self._limit
47 |
48 | #--------------------------------------------------------------------------
49 |
50 |
51 | def make(self):
52 | try:
53 | self._write_event()
54 | self._write_wem()
55 |
56 | except Exception: # as e
57 | logging.warn("tags: PROCESS ERROR! (report)")
58 | logging.exception("")
59 | raise
60 | return
61 |
62 |
63 | def _write_event(self):
64 | if not self.make_event:
65 | return
66 | if not self._tag_names: #no names registered
67 | return
68 |
69 | logging.info("tags: start making tags for events")
70 |
71 | basepath = self.__get_basepath() #TODO
72 |
73 | tags = self._tag_names
74 | files = list(tags.keys())
75 | files.sort()
76 | if not files:
77 | return
78 |
79 | outdir = self._locator.get_txtp_rootpath()
80 | if outdir:
81 | outdir = os.path.join(basepath, outdir)
82 | os.makedirs(outdir, exist_ok=True)
83 |
84 | outname = os.path.join(outdir, "!tags.m3u")
85 |
86 | mode = 'w'
87 | if self.add and os.path.exists(outname):
88 | mode = 'a'
89 |
90 | with open(outname, mode, newline="\r\n") as outfile:
91 | if not mode == 'a':
92 | outfile.write("## @ALBUM \n")
93 | outfile.write("## $AUTOALBUM\n")
94 | outfile.write("## $AUTOTRACK\n")
95 | outfile.write("# AUTOGENERATED BY WWISER\n")
96 | outfile.write("\n")
97 |
98 | for file in files:
99 | longname = tags[file]
100 |
101 | outfile.write("# %%TITLE %s\n" %(longname))
102 | outfile.write('%s\n' % (file))
103 |
104 | return
105 |
106 |
107 | def _write_wem(self):
108 | if not self.make_wem:
109 | return
110 | if not self._names:
111 | return
112 |
113 | logging.info("tags: start making tags for wem")
114 |
115 | # try in current dir
116 | root_path = self._locator.get_root_fullpath()
117 |
118 | done = 0
119 | for root, _, files in os.walk(root_path):
120 | items = []
121 |
122 | has_info = False
123 | for file in files:
124 | basename = os.path.basename(file)
125 | name, ext = os.path.splitext(basename)
126 |
127 | if ext not in VALID_EXTENSIONS:
128 | continue
129 | if not name.isnumeric():
130 | continue
131 |
132 | # only make tags.m3u if at least one when some of the items have info
133 | # (that is, if 10 wems have info and 1 doesn't, should make tags for all wems)
134 | id = int(name)
135 | row = self._names.get_namerow(id)
136 | if row and (row.guidname or row.path or row.objpath):
137 | has_info = True
138 | items.append((file, row))
139 |
140 | if not items or not has_info:
141 | continue
142 |
143 | items.sort()
144 | self._write_wem_tags(root, items)
145 | done += 1
146 |
147 | if not done:
148 | logging.info("tags: couldn't generate tags (no .wem found or no .wem names in companion files)")
149 | return
150 |
151 | def _write_wem_tags(self, basepath, items):
152 | outname = os.path.join(basepath, "!tags.m3u")
153 |
154 | mode = 'w'
155 | if self.add and os.path.exists(outname):
156 | mode = 'a'
157 |
158 | with open(outname, mode, newline="\r\n") as outfile:
159 | if not mode == 'a':
160 | outfile.write("## @ALBUM \n")
161 | outfile.write("## $AUTOALBUM\n")
162 | outfile.write("## $AUTOTRACK\n")
163 | outfile.write("# AUTOGENERATED BY WWISER\n")
164 | outfile.write("\n")
165 |
166 | for file, row in items:
167 | if row:
168 | if row.guidname:
169 | outfile.write("# %%TITLE %s\n" %(row.guidname))
170 | if row.path:
171 | outfile.write("# %%PATH %s\n" %(row.path))
172 | if row.objpath:
173 | outfile.write("# %%OBJPATH %s\n" %(row.objpath))
174 | outfile.write('%s\n' % (file))
175 |
176 | logging.info("tags: wrote %s", outname)
177 | return
178 |
179 | def __get_basepath(self):
180 | # take first bank as base folder
181 | if self._banks:
182 | basepath = self._banks[0].get_root().get_path()
183 | else:
184 | basepath = os.getcwd() #self._txtpcache.basedir
185 | if not basepath:
186 | basepath = '.'
187 |
188 | return basepath
189 |
--------------------------------------------------------------------------------
/wwiser/generator/registry/wparams.py:
--------------------------------------------------------------------------------
1 | import logging, itertools
2 | import itertools
3 | from collections import OrderedDict
4 |
5 |
6 | TYPE_SWITCH = 0 #official Wwise
7 | TYPE_STATE = 1 #official Wwise
8 | TYPE_GAMEPARAMETER = 2 #made up
9 | TYPE_NAMES = {
10 | TYPE_SWITCH: 'SW',
11 | TYPE_STATE: 'ST',
12 | TYPE_GAMEPARAMETER: 'GP',
13 | }
14 |
15 | # Stores a simple list of params (gamesyncs/gamevars/statechunks) with extra config,
16 | # that will be passed later to each final list. Allow combos in the form of:
17 | # "(bgm=m01)[bgm=m01] {bgm=1.0}" > [ST bgm=m01, SW bgm=m01, GP hp=1.0]
18 | # "bgm=m01 sfx=s01" > [ST bgm=m01, ST sfx=s01] (when defaulting to ST)
19 | # "bgm=m01 / bgm m02" > [ST bgm=m01] / [ST bgm=m01]
20 | # "bgm=m01,m02" > [ST bgm=m01] / [ST bgm=m01]
21 | # "bgm=m01,m02 sfx=s01,s02" > [bgm=m01 sfx=s01], [bgm=m01 sfx=s02], [bgm=m02 sfx=s01], [bgm=m02 sfx=s02]
22 | # "bgm=m01,m02 sfx=s01 / sfx=s02" > [bgm=m01 sfx=s01], [bgm=m02 sfx=s01], [sfx=s02]
23 |
24 | class ParamItem(object):
25 | def __init__(self, type, key, val, elem):
26 | self.type = type
27 | self.key = key
28 | self.val = val
29 | self.elem = elem #original
30 |
31 | def __repr__(self):
32 | return "%s:%s=%s" % (TYPE_NAMES.get(self.type), self.key, self.val)
33 |
34 | # ---------------------------------------------------------
35 |
36 | class Params(object):
37 | def __init__(self, allow_st=False, allow_sw=False, allow_gp=False):
38 | self._allow_st = allow_st
39 | self._allow_sw = allow_sw
40 | self._allow_gp = allow_gp
41 |
42 | self._items = OrderedDict() #current type/key > items
43 | self._combos = [] #N lists of items
44 | # example: "bgm=m01,m02 / sfx=s01 / bgm=m01,m02 sfx=s01" = 5 combos
45 | # [
46 | # (bgm=m01),
47 | # (bgm=m02),
48 | # (sfx=s01),
49 | # (bgm=m01, sfx=s01),
50 | # (bgm=m02, sfx=s01),
51 | # ]
52 |
53 | def _add_item(self, item, subval=False):
54 | # handle subvals (bgm=val1,val2) in a list of [bgm=val1, bgm=val2], while regular
55 | # repeats (bgm=val1 bgm=val2) just overwrite and have a single-item list of [bgm=val2]
56 | index = (item.type, item.key)
57 |
58 | # maybe should compare key fnv (taking into account special var *), unlikely to mix hashname and sid though
59 | exists = index in self._items
60 | if not exists:
61 | self._items[index] = []
62 | items = self._items[index]
63 |
64 | # no repeats in
65 | if not subval and exists:
66 | items.clear()
67 |
68 | # allow repeats but of different value
69 | if subval:
70 | for old_item in items:
71 | if old_item.val == item.val:
72 | return
73 |
74 | items.append(item)
75 |
76 |
77 | def _add_param(self, elem):
78 | if not elem:
79 | return False
80 |
81 | sted = (elem[0], elem[-1])
82 | if sted == ('(', ')'):
83 | type = TYPE_STATE
84 | elif sted == ('[', ']'):
85 | type = TYPE_SWITCH
86 | elif sted == ('{', '}'):
87 | type = TYPE_GAMEPARAMETER
88 | else:
89 | type = None
90 |
91 | if type is not None:
92 | keyval = elem[1:-1]
93 | else:
94 | keyval = elem
95 | # default with 1 allowed type
96 | if self._allow_st and not self._allow_sw and not self._allow_gp:
97 | type = TYPE_STATE
98 | elif not self._allow_st and self._allow_sw and not self._allow_gp:
99 | type = TYPE_SWITCH
100 | elif not self._allow_st and not self._allow_sw and self._allow_gp:
101 | type = TYPE_GAMEPARAMETER
102 |
103 | if type is None:
104 | return
105 |
106 | parts = keyval.split('=')
107 | if len(parts) != 2:
108 | return False
109 | key = parts[0]
110 | val = parts[1]
111 |
112 | if ',' in val:
113 | item = None
114 | subvals = val.split(",")
115 | for subval in subvals:
116 | item = ParamItem(type, key, subval, elem)
117 | self._add_item(item, subval=True)
118 | else:
119 | item = ParamItem(type, key, val, elem)
120 | self._add_item(item)
121 |
122 | return True
123 |
124 |
125 | def adds(self, elems):
126 | if not elems:
127 | return
128 |
129 | # separate vars, could be improved
130 | replaces = {
131 | ')(':'):(', ')[':'):[', '){':'):{',
132 | '](':']:(', '][':']:[', ']{':']:{',
133 | '}(':'}:(', '}[':'}:[', '}{':'}:{',
134 | }
135 |
136 | for elem in elems:
137 | if elem == '/': # new sublist bgm=a1 / bgm=a2
138 | self._add_combos()
139 | continue
140 |
141 | is_split = False
142 | for repl_key, repl_val in replaces.items():
143 | if repl_key in elem:
144 | elem = elem.replace(repl_key, repl_val)
145 | is_split = True
146 | if is_split:
147 | splits = elem.split(':')
148 | for split in splits:
149 | ok = self._add_param(split)
150 | if not ok:
151 | logging.info('parser: ignored incorrect param %s', split)
152 |
153 | else:
154 | ok = self._add_param(elem)
155 | if not ok:
156 | logging.info('parser: ignored incorrect param %s', elem)
157 |
158 | self._add_combos()
159 |
160 | def _add_combos(self):
161 | # make possible combos of current items
162 | itemproducts = list(itertools.product(*self._items.values()))
163 | for itemproduct in itemproducts:
164 | #itemproduct = list(itemproduct) #these are tuples but no matter
165 | self._combos.append(itemproduct)
166 | self._items.clear() #in case of more lists
167 |
168 | def combos(self):
169 | return self._combos
170 |
--------------------------------------------------------------------------------
/doc/TODO.md:
--------------------------------------------------------------------------------
1 | # TODO
2 | Low priority TODOs
3 |
4 | ## general
5 | - fix todo-s
6 |
7 | ## parser
8 | - support Shadowrun, Too Human txtp
9 | - clean bitflags in parser (some change between versions)
10 | - make as list: uNumSrc, srcID/etc, Children, etc
11 | - v36<= eTransitionMode/_bIsUsingWeight etc recheck
12 | - akprops per version have a "max", and after it other props are "custom" per game: define of custom prop start
13 |
14 | ## model
15 | - 'var' type may go over omax, should adjust max loops
16 | - attr list may return non-ordered to speed-up find()?
17 | - improve printing of floats (problem: hard to detect accuracy)
18 | - `-4.76837158203125e-07` > `0.000000476837158203125`
19 |
20 | ## gui
21 | - show ico https://stackoverflow.com/questions/18537918/why-isnt-ico-file-defined-when-setting-windows-icon
22 | - show viewer open status (change text to "reopen?" "running")
23 | - viewer on another thread?
24 | - allow loading wwconfig?
25 | - just call GUI with this file
26 | - don't load log
27 | - some flag to reuse loaded banks/wwnames?
28 | - on opening close viewer?
29 | - viewer on close web tab notify server
30 | - option to transfer helper filenames + bks and make a base rip automagically?
31 |
32 | ## view
33 | - check if port is open (may open 2 instances and only 1 server works, but there is no port error)
34 | - improve threading/pool? (not too useful as doesn't have that many resources)
35 | - maybe move preloads to globals, auto init, recreate node printer every time
36 | - button to hide generic/useless stuff? (PositioningParams, AdvSettingsParams, etc)
37 | - add combo with all common HIRC types
38 | - links: if not found/loaded call bank and load sid id
39 | - simple JS query:
40 | - `open = bank > HircChunk > listLoadedItem > *` #opens all items that match that tree
41 | - `close = NodeBaseParams` #closes all node params
42 | - `find = ...`
43 |
44 | ## names
45 | - add key per game in DB so wwnames can contain everything, register "default" common names
46 |
47 | ## txtp
48 | - txtp should round up numbers? 1/48000 ~= 0.0000208333 * 48000 = 0.999999 > 1 or 0?
49 | - could round values that don't make samples, `#r 1.5000000000000002`
50 | - makes it easier to compare vs tree and most times can't be rounded
51 | - problem when rounding certain values depending if floor or ceil is used:
52 | - 6.0000007 * 48000 = 288000.0336 ~= 6.0 * 48000 = 288000
53 | - 0.9999999 * 48000 = 47999.9952 != 1.0 * 48000 = 48000
54 | - may be useful to find dupes with simpler flag: body like 0.399999999 vs 0.4 (uncommon?)
55 | - overlapped transitions
56 | - needs fades (games use fading transition to smooth out loops)
57 | - next segment and looping to itself may have different transitions
58 | - randoms also need transitions from A to B/C/D/E/F to G
59 | - transition objects in mranseq (Polyball)
60 | - resampler for demo music, pokemon, sor4
61 | - fix multiloops
62 | - DelayTime/InitialDelay may not work correctly with loops + groups (ex. John Wick Hex 2932040671)
63 | - mark loop inside inner group (double loop) as multiloop (ex. DmC last boss)
64 | - mark dialogueevents somehow as they can have the same name as events (unlikely though)
65 | - check how argument is used in older wwise dialogue events
66 | - builder: find0() / optional=True, return emptyNode where value() is None
67 | - add get_info() in model that converts tid to hashname and common props
68 |
69 | - layers of blend RTPCs (hard to understand and not very used)
70 | - some way to set GS/SC/GV lists + defaults (auto generated list)
71 | - `* / (bgm_layer=on,off)` = one pass with auto vars, other exact vars [Astral Chain]
72 | - complex due to render setup
73 | - may not be useful b/c default may need certain flags > use wwconfig
74 | - AC:BF BNK_SP_MU_Global_Naval_MUSIC has unused AkMediaInformation in 551617484
75 | - detect + register somewhere?
76 | - filter localized .bnk options (to load all without looking)
77 |
78 | ## txtp fade-in
79 | - apply transition delays: fTransitionTime in all ranseqs, TransitionTime, etc
80 | - TTime in earlier games (ex. Trine 2)
81 | - fadein on actions (LoopCrossfadeDuration? and such props?)
82 | - problems with loops when fadein + automation? (would need to move down curves)
83 | - check weirdprops
84 | - "[FadeInCurve]", "[FadeOutCurve]", #seen in CAkState, used in StateChunks (ex. NSR)
85 | - "[TrimInTime]", "[TrimOutTime]", #seen in CAkState (ex. DMC5)
86 | - some rtpc params like Transition are for internal use only?
87 |
88 | ## txtp misc cleanup
89 | - txtpcache > txtpstate (ts) + make wconfig/wsettings
90 | - improve passing of txtpcache stuff
91 | - don't use txtpcache from stats, pass stats directly, or read later
92 | - wstatechunks improve generation code with "default" case
93 | - detect if default is unreachable fails
94 | - print default first?
95 | - print unrechable default? =~
96 | - unused mark pass to Txtp() from Renderer?
97 | - when applying volumes from bottom>top, hoist volumes (like layered #v3 can be moved to group)
98 | - don't ignore sound volumes in simpler txtp? (uses auto normalize)
99 |
100 | ## txtp properties
101 | - filter properties that can't combine: pitch<>music objects, etc
102 | - AkProps.filter()?
103 | - simplify+unify RTPC and AkProp usage > AkPropertyInfo?
104 | - prop calculator: could cache simple properties (default+parents)
105 | - prop calculator: profile slowness when reading params
106 | - preload load parent bus my default? (rather than looking for it every time)
107 | - improve wwise gain effect (Tetris Effect, DMC5)
108 | - check bypass effects flag
109 | - apply rtpcs on the sfx + base node bypasses
110 |
111 | # txtp transitions
112 | - on make_txtp read correct transitions depending on src<>dst
113 | - write jumps (beta, format to be determined)
114 | - problem: each .wem potentially has N rules
115 | - from nothing to wem (when starting to play, usually plays entry)
116 | - from wem to itself (loops, infinite but also N=3)
117 | - from wem to others: usually only "next wem" but in randoms may be N
118 | - from any to wem: generic values
119 | - from wem to any: generic values
120 | - jumps ideas:
121 | - in/out + target: - (nothing), * (any) / self / position N or file.wem or ?
122 | - type: on beat N, on time N, next cue, ... needed?
123 | ```
124 | # without fades
125 | bgm01.adx #jo 25s #ji 5s
126 | bgm02.adx
127 | # with fades
128 | bgm01.adx #jo 25s P / 2s 0s #ji 5s P / 2s 0s
129 | bgm02.adx
130 | ```
131 | - jumps ideas2:
132 | - print entry/exit as is like `#j 10.0 50.0` (entry/exit)
133 | - define rules like wwise (#@rule 1 to bgm01.adx play exit, play entry, fade in, fade out), transition
134 | - by default if no rules: play entry, play exit
135 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # WWISER
2 | A Wwise `.bnk` parser, to assist in handling audio from games using the Wwise engine.
3 |
4 | Simply open `wwiser` without arguments to start the GUI. From there you can load
5 | and view banks, dump contents or make TXTP (used to simulate audio). It reads
6 | and shows all `.bnk` chunks, including *HIRC* (audio scripting) data, and properly
7 | identifies all fields. *wwiser* *can't* modify banks.
8 |
9 |
10 | ## MINI GUIDE
11 | How to play audio simulating the Wwise engine:
12 | - open *wwiser.pyz*
13 | - press *Load dirs...* and select a *base folder* with `.bnk` and `.wem` inside
14 | - if you have `.pck`, use *Quickbms* + [this script](https://github.com/bnnm/wwiser-utils/blob/master/scripts/wwise_pck_extractor.bms) to extract `.wem`/`.bnk` first
15 | - if game has `SoundbanksInfo.xml`, `Wwise_IDs.h`, `(bankname).txt` or similar files you will have (some) names
16 | - or make/download a [name list](https://github.com/bnnm/wwiser-utils/tree/master/wwnames) put it as `wwnames.txt` in the *base folder*
17 | - press *Generate TXTP* to make `.txtp` in the *base folder*
18 | - may need to fiddle with some options, such as setting language to `SFX` to skip voice lines
19 | - open those `.txtp` with some player like *foobar2000/winamp/audacious* with the *vgmstream* plugin installed (or use the CLI tool)
20 | - this plays `.wem` closer to how they sound in-game (such as having multiple `.wem` at once if needed)
21 | - it may be more usable to set *TXTP subdir* to *empty* so that `.txtp` are generated in the *base folder* instead of a subfolder
22 | - note that *TXTP* currently can't simulate all Wwise features
23 |
24 | You can also press *View banks* to explore, or *Dump banks* to save (somewhat) readable info, but isn't important to generate *TXTP*.
25 |
26 | Wwise is very complex so this program can only help so much, you may need to read and understand *doc/WWISER.md* to get the full picture.
27 |
28 |
29 | ## OTHER INFO
30 | All actions (and more) can be done from the command line as well: `wwiser [options] (files)`
31 | - `wwiser bgm.bnk`
32 | - (dumps `bgm.bnk` info to `bgm.bnk.xml`)
33 | - `wwiser -d txt init.bnk bgm.bnk -dn banks.txt`
34 | - (loads multiple `.bnk`, like Wwise does, and dumps to banks.txt)
35 | - `wwiser *.bnk -r -v`
36 | - (loads all .bnk in current folder and subfolders recursively and starts the viewer)
37 | - `wwiser -g bgm.bnk`
38 | - (generates TXTP files from banks to use with vgmstream)
39 | - `wwiser wwconfig.txt`
40 | - (loads a text list of any CLI commands, to simplify complex usage)
41 | - `wwiser -h`
42 | - (shows all available actions)
43 |
44 | Loaded banks can be explored using the *viewer*, a web browser-based tool (if you'd
45 | prefer a native GUI, please understand there are multiple reasons behind this),
46 | or dumped to a file. For best results make sure to load `init.bnk` then one or more
47 | related banks, since banks can point to data in other banks.
48 |
49 | If companion files like *SoundbankInfo.xml* or *(bank name).txt* are found in the
50 | `.bnk` dir they'll automatically be used to show names. *wwiser* also supports some
51 | artificial ways to add reversed names (`wwnames.txt` and `wwnames.db3`).
52 |
53 | Be aware that depending on the bank size loading may be slow, memory usage high,
54 | dump output big, and *txtp* generation slow. Dumped `.xml` can be opened with a
55 | browser to see contents (like a simple html/GUI) but since they can be huge
56 | browsers may run out of memory; the *viewer* is a safer bet (looks the same).
57 |
58 | *wwiser* requires *Python 3* installed. The viewer also needs a modern-ish browser.
59 |
60 | Extra info:
61 | - *doc/WWISER.md*: detailed Wwise info and usage explanations
62 | - wwiser-utils (https://github.com/bnnm/wwiser-utils): info and helpers for audio rips
63 | - For further help try asking on hcs64.com forums or discord
64 |
65 |
66 | ## WWISE ENGINE AND WWISER USAGE
67 | **TL;DR**: Wwise never plays `.wem` directly and always uses *events* inside `.bnk`
68 | that play one or many `.wem` indirectly. You want to open main `.bnk` (leaving
69 | companion `xml` and `txt` files together to get names) with *wwiser*, maybe explore
70 | a bit, and automatically generate *TXTP* for *events*, to use with *vgmstream* to
71 | play music (https://github.com/vgmstream/vgmstream).
72 |
73 | Wwise has two "modes", a sound module that plays single sfx or tracks capable of
74 | simple looping (some games loop like this), and a music module that dynamically
75 | plays multiple audio stems mixed in realtime (other games loop by using multiple
76 | separate files). You want *TXTP* to handle the later, but they also give consistency
77 | and (sometimes) original names to the former. In short, for games using Wwise audio
78 | don't play `.wem` directly but use *wwiser*'s generated `.txtp`.
79 |
80 |
81 | ## WWISER OUTPUT
82 | Web view or dumped `.xml` shows what is stored in `.bnk`, trying to follow original
83 | (Audiokinetic's) names. Since Wwise is a complex engine it can be a bit hard to
84 | understand at first. Most concepts are explained in *doc/WWISER.md*, though to get
85 | most of it install Wwise (free) and play around with the editor to get a better feel
86 | of how Wwise deals with audio.
87 |
88 |
89 | ## TXTP GENERATION
90 | You can generate `.txtp` files from `.bnk` that allow *vgmstream* to (mostly) play audio
91 | simulating Wwise. Make sure `.wem` go to the `/txtp/wem` folder and open `.txtp` with a
92 | player like *foobar2000* or *Winamp* with the *vgmstream* plugin installed (or use
93 | vgmstream's *CLI* tools).
94 |
95 | This function tries its best to make good, usable `.txtp` to improve the listening
96 | experience of Wwise audio rips (like giving names when possible, or handling
97 | complex loops). However Wwise is a very complex, dynamic audio engine, so you may
98 | need to tweak various options to improve output. *vgmstream* also has some limitations
99 | and audio simulation is not always perfect.
100 |
101 | See *WWISER* doc for detailed explanations.
102 |
103 |
104 | ## LIMITATIONS
105 | This tool is not, and will never be, a `.bnk` editor (can't replace files). It's only
106 | meant to show bank data and generate TXTP. But feel free to use info here to make
107 | other programs.
108 |
109 | Almost all `.bnk` versions should work, except the first two, used in *Shadowrun (X360)*
110 | (unsupported) and *Too Human (X360)* (mostly supported but can't make .txtp). Report if
111 | you get errors or incorrect behavior. All fields should be correctly identified and named,
112 | save a few bit flags in some versions and some lesser objects like plugins.
113 |
114 |
115 | ## LEGAL STUFF
116 | Everything from *wwiser* was researched and reverse-engineered by studying public
117 | SDKs, executables and files, by bnnm.
118 |
--------------------------------------------------------------------------------
/wwiser/generator/txtp/hnode_envelope.py:
--------------------------------------------------------------------------------
1 |
2 | # DmC (65): old volumes
3 | # MGR (72): new volumes (probably v70 too)
4 | _AUTOMATION_NEW_VOLUME_VERSION = 70
5 | #
6 | _AUTOMATION_NEW_TYPE_VERSION = 112
7 |
8 |
9 | # Wwise marks a type like "Exp1" when doing any fades, but due to how curves
10 | # are programmed, fade-ins are standard and fade-outs must use the inverse curve.
11 | # So fade-in Exp1 uses 'E' (lowered) while fade-out must be 'L' (raised).
12 | # TXTP's curves (not documented) are more limited, so fades aren't 100% exact:
13 | # - E: exponential (2.5)
14 | # - L: logaritmic (2.5)
15 | # - H: raised sine/cosine
16 | # - Q: quarter sine/cosine
17 | # - p: parabola
18 | # - P: inverted parabola
19 | # - T: triangular/linear
20 | # - {}: alias of one of the above
21 | # - (): alias of one of the above
22 | #
23 | # And Wwise's curves:
24 | # - 0: Log3 (raised high)
25 | # - 1: Sine (raised normal)
26 | # - 2: Log1 (raised low, almost linear)
27 | # - 3: InvSCurve (sharp S in the middle)
28 | # - 4: Linear (simple)
29 | # - 5: SCurve (shoft S in the middle)
30 | # - 6: Exp1 (lowered low, almost linear)
31 | # - 7: SineRecip (lowered mid)
32 | # - 8: Exp3 (lowered low)
33 | # - 9: Constant (fixed output)
34 | #
35 | # Where 'raised' = rises soon, and 'lowered' = rises later, roughly:
36 | # ..... ...
37 | # .. .
38 | # . .
39 | # . .
40 | # . ..
41 | # . ....
42 |
43 | _TXTP_INTERPOLATIONS_FADEIN = {
44 | 0:'L', #Log3
45 | 1:'P', #Sine
46 | 2:'P', #Log1
47 | 3:'H', #InvSCurve
48 | 4:'T', #Linear
49 | 5:'Q', #SCurve
50 | 6:'p', #Exp1
51 | 7:'p', #SineRecip
52 | 8:'E', #Exp3
53 | 9:'T', #Constant
54 | }
55 |
56 | _TXTP_INTERPOLATIONS_FADEOUT = {
57 | 0:'E', #Log3
58 | 1:'p', #Sine
59 | 2:'p', #Log1
60 | 3:'Q', #InvSCurve
61 | 4:'T', #Linear
62 | 5:'H', #SCurve
63 | 6:'P', #Exp1
64 | 7:'P', #SineRecip
65 | 8:'L', #Exp3
66 | 9:'T', #Constant
67 | }
68 |
69 | class NodeEnvelope(object):
70 | def __init__(self, automation, p1, p2, version=0):
71 | self.usable = False
72 | self.is_volume = False
73 | self.vol1 = None
74 | self.vol2 = None
75 | self.shape = None
76 | self.time1 = None
77 | self.time2 = None
78 | if not automation or not p1 or not p2:
79 | return
80 |
81 | # ignore unusable effects
82 | if version < _AUTOMATION_NEW_TYPE_VERSION:
83 | ignorable_types = [1] #0=volume 1=LPF 2=fadein 3=fadeout
84 | else:
85 | ignorable_types = [1,2] #0=volume 1=LPF 2=HDF 3=fadein 4=fadeout
86 |
87 | if automation.type in ignorable_types:
88 | return
89 | self.is_volume = True
90 |
91 | self.vol1 = p1.value
92 | self.vol2 = p2.value
93 |
94 | # constant is an special value that should ignore p2
95 | # in fades it's only used in p2 to indicate that volume lasts
96 | # (ex. fade-in: p1={0.0s, 0.0 vol, linear}, p2={4.0s, 1.0 vol, constant})
97 | if p1.interp == 9:
98 | self.vol2 = self.vol1
99 |
100 | # normalize volumes
101 | if version < _AUTOMATION_NEW_VOLUME_VERSION:
102 | # convert -96.0 .. 0.0 .. 96.0 dB to volume (scaling "dB_96_3")
103 | self.vol1 = pow(10.0, self.vol1 / 20.0)
104 | self.vol2 = pow(10.0, self.vol2 / 20.0)
105 | else:
106 | if automation.type == 0: # volume
107 | # convert -1.0 .. 0.0 .. 1.0 to 0.0 .. 1.0 .. 2.0 (scaling "dB")
108 | self.vol1 += 1.0
109 | self.vol2 += 1.0
110 | else: # fades
111 | # standard 0.0 .. 1.0
112 | pass
113 |
114 | # some points are just used to delay with no volume change
115 | # note that constant different volumes exists (ex. -0.24 .. -0.24 on doomy ternal)
116 | if self.vol1 == 1.0 and self.vol2 == 1.0:
117 | return
118 |
119 | # approximate curves
120 | if self.vol1 < self.vol2:
121 | interpolations = _TXTP_INTERPOLATIONS_FADEIN
122 | else:
123 | interpolations = _TXTP_INTERPOLATIONS_FADEOUT
124 | self.shape = interpolations.get(p1.interp, '{')
125 |
126 | self.time1 = p1.time
127 | self.time2 = p2.time - p1.time
128 |
129 | # clamp times (occasionally Wwise makes tiny negative values on what seems an editor hiccup, shouldn't matter)
130 | if self.time1 < 0:
131 | self.time1 = 0.0
132 | if self.time2 < 0:
133 | self.time2 = 0.0
134 |
135 | self.usable = True
136 |
137 | # Transform wwise automations to txtp envelopes.
138 | # Wwise defines points (A,B,C) and autocalcs combos like (A,B),(B,C),
139 | # but for txtp we need to pre-make combos in the format of:
140 | # - ch(type)(position)(time-start)+(time-length) [simpler fades]
141 | # - ch^(volume-start)~(volume-end)=(shape)@(time-pre)~(time-start)+(time-length)~(time-last) [complex volumes]
142 | class NodeEnvelopeList(object):
143 | def __init__(self, sound):
144 | self.empty = True
145 | self._envelopes = []
146 | self._build(sound)
147 |
148 | def _build(self, sound):
149 | if not sound:
150 | return
151 | if sound.automations and not sound.source:
152 | raise ValueError("unexpected automations without source")
153 | if not sound.source:
154 | return
155 | automations = sound.automations
156 | version = sound.source.version
157 | if not automations or not version:
158 | return
159 |
160 | for automation in automations:
161 | max = len(automation.points)
162 | for i in range(0, max):
163 | if (i + 1 >= max):
164 | continue
165 | # envelopes are made of N points, though in fade-in and fade-out types there are only 2 points (start>end)
166 | p1 = automation.points[i]
167 | p2 = automation.points[i+1]
168 |
169 | envelope = NodeEnvelope(automation, p1, p2, version)
170 | if not envelope.usable or not envelope.is_volume:
171 | continue
172 |
173 | self._envelopes.append(envelope)
174 | self.empty = len(self._envelopes) <= 0
175 |
176 | def items(self):
177 | return self._envelopes
178 |
179 | def pad(self, pad_time):
180 | for envelope in self._envelopes:
181 | envelope.time1 += pad_time
182 |
--------------------------------------------------------------------------------
/wwiser/viewer/resources/viewer.js:
--------------------------------------------------------------------------------
1 | // server interaction util
2 | function Viewer() {
3 | this.load_banks = load_banks;
4 | this.load_banks_all = load_banks_all;
5 | this.load_simple = load_simple;
6 | this.load_simple_all = load_simple_all;
7 | this.load_node = load_node;
8 | this.load_docs_readme = load_docs_readme;
9 | this.load_docs_wwiser = load_docs_wwiser;
10 |
11 |
12 | function load_banks(on_success) {
13 | get_ajax('/load-banks', on_success);
14 | }
15 | function load_banks_all(on_success) {
16 | get_ajax('/load-banks?all=true', on_success);
17 | }
18 | function load_simple(on_success) {
19 | get_ajax('/load-banks?simple=true', on_success);
20 | }
21 | function load_simple(on_success) {
22 | get_ajax('/load-banks?simple=true', on_success);
23 | }
24 | function load_simple_all(on_success) {
25 | get_ajax('/load-banks?all=true&simple=true', on_success);
26 | }
27 | function load_node(id, on_success) {
28 | get_ajax('/load-node?id='+id, on_success);
29 | }
30 | function load_docs_readme(on_success) {
31 | get_ajax('/load-docs?doc=readme', on_success);
32 | }
33 | function load_docs_wwiser(on_success) {
34 | get_ajax('/load-docs?doc=wwiser', on_success);
35 | }
36 |
37 | function get_ajax(url, on_success, on_error) {
38 | var xhr = new XMLHttpRequest();
39 | xhr.open('GET', url, true);
40 |
41 | xhr.onload = function() {
42 | if (this.status >= 200 && this.status < 400) {
43 | on_success(this.response);
44 | } else {
45 | do_error();
46 | }
47 | };
48 | xhr.onerror = do_error
49 | xhr.send();
50 |
51 | function do_error() {
52 | if (on_error) {
53 | on_error();
54 | } else {
55 | alert("Viewer stopped (restart wwiser's viewer)")
56 | }
57 | }
58 | }
59 | }
60 |
61 |
62 | // view namespace
63 | (function() {
64 | var NODE_WARNING_MAX = 300;
65 | var NODE_WARNING_MSG = "Warning! Preload size is big and may be slow/unresponsive!";
66 | var NODE_EMPTY_MSG = "No nodes found";
67 |
68 | var viewer = new Viewer();
69 |
70 | var $d = document;
71 | var $tabs_panel = $d.getElementById('tabs-panel');
72 | var $tabs = $d.getElementById('tabs');
73 | var vbank = load_view('tab-bank');
74 | var vsimple = load_view('tab-simple');
75 | var vdocs_readme = load_view('tab-docs-readme');
76 | var vdocs_wwiser = load_view('tab-docs-wwiser');
77 |
78 | setup();
79 | init();
80 | //todo capture clicked 'tid' and find object's sid in server if not open
81 |
82 | /* *************************** */
83 |
84 | function init() {
85 | vbank.tab.click()
86 | }
87 |
88 | function load_view(id_name) {
89 | var v = {};
90 | v.button = $tabs_panel.querySelector('[for='+id_name+']')
91 | v.tab = $d.getElementById(id_name);
92 | v.main = v.tab.nextElementSibling;
93 | v.tools = v.main.querySelector('.tools');
94 | v.content = v.main.querySelector('.content');
95 | v.loaded = false;
96 | return v;
97 | }
98 |
99 | function load_items(view, res) {
100 | view.content.innerHTML = res;
101 | view.loaded = true;
102 | }
103 | function set_active(view, loader) {
104 | //todo query selector etc
105 | var elems = $tabs_panel.querySelectorAll('.tab-button');
106 | elems.forEach(button => console.log(button));
107 | elems.forEach(button => button.classList.remove('selected'));
108 | view.button.classList.add('selected')
109 | if (view.loaded)
110 | return;
111 | loader(function(res) {
112 | load_items(view, res);
113 | });
114 | }
115 |
116 | function setup() {
117 | var items = [
118 | [vbank, viewer.load_banks],
119 | [vsimple, viewer.load_simple],
120 | [vdocs_readme, viewer.load_docs_readme],
121 | [vdocs_wwiser, viewer.load_docs_wwiser]
122 | ];
123 |
124 | // tab changes
125 | $tabs.addEventListener('click', function(e) {
126 | var tgt = e.target;
127 | if (!tgt)
128 | return;
129 |
130 | for (var i = 0; i < items.length; i++) {
131 | var obj = items[i][0];
132 | var fun = items[i][1];
133 |
134 | if (tgt == obj.tab) {
135 | set_active(obj, fun);
136 | return;
137 | }
138 | }
139 |
140 | }, false);
141 |
142 |
143 | // viewer main functions
144 | vbank.main.addEventListener('click', function(e) {
145 | var tgt = e.target;
146 | if (!tgt)
147 | return;
148 |
149 | if (tgt.matches('.hide')) {
150 | vbank.main.classList.toggle(tgt.value);
151 | }
152 |
153 | if (tgt.matches('.load-all')) {
154 | var filter = vbank.tools.querySelector('.load-type').value;
155 | var selector = '.js-load-node';
156 | if (filter)
157 | selector += '.'+filter;
158 |
159 | var elems = vbank.content.querySelectorAll(selector);
160 | if (elems.length > NODE_WARNING_MAX) {
161 | if (!window.confirm(NODE_WARNING_MSG))
162 | return;
163 | }
164 |
165 | if (elems.length == 0) {
166 | alert(NODE_EMPTY_MSG);
167 | return;
168 | }
169 |
170 | if (filter) {
171 | for (var i = 0; i < elems.length; i++) {
172 | elems[i].querySelector('.head').click();
173 | }
174 | }
175 | else {
176 | viewer.load_banks_all(function(res) {
177 | load_items(vbank, res);
178 | });
179 | }
180 |
181 | return;
182 | }
183 |
184 | if (tgt.matches('.closable > .head')) {
185 | var obj = tgt.parentNode;
186 | if (obj.matches('.js-load-node')) {
187 | id = obj.dataset.id;
188 | viewer.load_node(id, function(res) {
189 | obj.outerHTML = res;
190 | //obj.classList.toggle('hidden');
191 | //TODO: may need to evict DOM nodes if there are too many open to improve performance
192 | });
193 | } else {
194 | obj.classList.toggle('hidden');
195 | }
196 | return;
197 | }
198 |
199 | }, false);
200 |
201 | }
202 |
203 | })();
204 |
--------------------------------------------------------------------------------
/wwiser/generator/txtp/wtxtp_tree.py:
--------------------------------------------------------------------------------
1 | from . import hnode_envelope
2 |
3 | _DEBUG_PRINT_IGNORABLE = False
4 |
5 | TYPE_SOUND_LEAF = 'snd'
6 | TYPE_GROUP_ROOT = '.'
7 | TYPE_GROUP_SINGLE = 'N'
8 | TYPE_GROUP_SEQUENCE_CONTINUOUS = 'SC'
9 | TYPE_GROUP_SEQUENCE_STEP = 'SS'
10 | TYPE_GROUP_RANDOM_CONTINUOUS = 'RC'
11 | TYPE_GROUP_RANDOM_STEP = 'RS'
12 | TYPE_GROUP_LAYER = 'L'
13 | TYPE_GROUPS = {
14 | TYPE_GROUP_SINGLE,
15 | TYPE_GROUP_SEQUENCE_CONTINUOUS,
16 | TYPE_GROUP_SEQUENCE_STEP,
17 | TYPE_GROUP_RANDOM_CONTINUOUS,
18 | TYPE_GROUP_RANDOM_STEP,
19 | TYPE_GROUP_LAYER,
20 | }
21 | TYPE_GROUPS_CONTINUOUS = {
22 | TYPE_GROUP_SEQUENCE_CONTINUOUS,
23 | TYPE_GROUP_RANDOM_CONTINUOUS,
24 | }
25 | TYPE_GROUPS_STEPS = {
26 | TYPE_GROUP_SEQUENCE_STEP,
27 | TYPE_GROUP_RANDOM_STEP,
28 | }
29 | TYPE_GROUPS_LAYERS = {
30 | TYPE_GROUP_LAYER,
31 | }
32 | TYPE_SOUNDS = {
33 | TYPE_SOUND_LEAF,
34 | }
35 |
36 | _VOLUME_DB_MAX = 200.0 # 96.3 #wwise editor typical range is -96.0 to +12 but allowed editable max is +-200
37 |
38 | # Represents a TXTP tree node, that can be a "sound" (leaf file) or a "group" (includes files or groups).
39 | # The rough tree is created by the renderer, then simplified progressively to make a cleaner .txtp file,
40 | # transforming from Wwise concepts to TXTP commands.
41 | # (since Wwise object's meaning depends on modes and stuff, it's easier to make a crude tree first that
42 | # is mostly fixed, then tweak to get final tree, that may change as TXTP features are added)
43 |
44 | class TxtpNode(object):
45 | def __init__(self, parent, config, sound=None):
46 | self.parent = parent
47 | self.config = config #NodeConfig
48 | self.sound = sound #NodeSound
49 |
50 | self.type = TYPE_GROUP_ROOT
51 | if sound:
52 | self.type = TYPE_SOUND_LEAF
53 | self.children = []
54 |
55 | # calculated config
56 | self.pad_begin = None
57 | self.trim_begin = None
58 | self.body_time = None
59 | self.trim_end = None
60 | self.pad_end = None
61 |
62 | # copy value as may need to simplify tree config (ex. multiple objects can set infinite loop)
63 | self.volume = config.gain
64 | self.loop = config.loop
65 | self.delay = config.delay
66 |
67 | self.crossfaded = config.crossfaded
68 | self.silenced = config.silenced
69 | self.silenced_default = config.silenced_default
70 |
71 | self.envelopelist = None
72 | if sound:
73 | el = hnode_envelope.NodeEnvelopeList(sound)
74 | if not el.empty:
75 | self.envelopelist = el
76 |
77 | # allowed to separate "loop not set" and "loop set but not looping"
78 | #if self.loop == 1:
79 | # self.loop = None
80 | self.loop_anchor = False #flag to force anchors in sound
81 | self.loop_end = False #flag to force loop end anchors
82 | self.loop_killed = False #flag to show which nodes had loop killed due to trapping
83 |
84 | # clip loop meaning is a bit different and handled automatically
85 | if sound and sound.clip:
86 | self.loop = None
87 |
88 | # seen in Ghostwire: Tokyo amb_beds_daidara_Play, messes up vgmstream's calcs
89 | # (maybe should consider anything bigger than N an infinite loop)
90 | if self.loop and self.loop >= 32767:
91 | self.loop = 0
92 |
93 | self.fake_entry = False
94 | self.force_selectable = False
95 |
96 |
97 | def clamp_volume(self):
98 | if not self.volume:
99 | return
100 | if self.volume > _VOLUME_DB_MAX:
101 | self.volume = _VOLUME_DB_MAX
102 | elif self.volume < -_VOLUME_DB_MAX:
103 | self.volume = -_VOLUME_DB_MAX
104 |
105 | def insert_base(self, tnode):
106 | self.children.insert(0, tnode)
107 |
108 | def append(self, tnode): #TODO remove?
109 | self.children.append(tnode)
110 |
111 | def single(self):
112 | self.type = TYPE_GROUP_SINGLE
113 | return self
114 |
115 | def sequence_continuous(self):
116 | self.type = TYPE_GROUP_SEQUENCE_CONTINUOUS
117 | return self
118 |
119 | def sequence_step(self):
120 | self.type = TYPE_GROUP_SEQUENCE_STEP
121 | return self
122 |
123 | def random_continuous(self):
124 | self.type = TYPE_GROUP_RANDOM_CONTINUOUS
125 | return self
126 |
127 | def random_step(self):
128 | self.type = TYPE_GROUP_RANDOM_STEP
129 | return self
130 |
131 | def layer(self):
132 | self.type = TYPE_GROUP_LAYER
133 | return self
134 |
135 | #--------------------------------------------------------------------------
136 |
137 | def is_sound(self):
138 | return self.type in TYPE_SOUNDS
139 |
140 | def is_group(self):
141 | return self.type in TYPE_GROUPS
142 |
143 | def is_group_single(self):
144 | return self.type in TYPE_GROUP_SINGLE
145 |
146 | def is_group_steps(self):
147 | return self.type in TYPE_GROUPS_STEPS
148 |
149 | def is_group_layers(self):
150 | return self.type in TYPE_GROUPS_LAYERS
151 |
152 | def is_group_continuous(self):
153 | return self.type in TYPE_GROUPS_CONTINUOUS
154 |
155 | def is_group_sequence_step(self):
156 | return self.type in TYPE_GROUP_SEQUENCE_STEP
157 |
158 | def is_group_sequence_continuous(self):
159 | return self.type in TYPE_GROUP_SEQUENCE_CONTINUOUS
160 |
161 | def is_group_random(self):
162 | return self.is_group_random_step() or self.is_group_random_continuous()
163 |
164 | def is_group_random_step(self):
165 | return self.type in TYPE_GROUP_RANDOM_STEP
166 |
167 | def is_group_random_continuous(self):
168 | return self.type in TYPE_GROUP_RANDOM_CONTINUOUS
169 |
170 | #--------------------------------------------------------------------------
171 |
172 | def loops(self):
173 | return self.loop is not None and self.loop != 1
174 |
175 | def loops_inf(self):
176 | return self.loop is not None and self.loop == 0
177 |
178 | # nodes that don't contribute to final .txtp so they don't need to be written
179 | # also loads some values
180 | def ignorable(self, skiploop=False, simpler=False):
181 | if not skiploop: #sometimes gets in the way of calcs
182 | if self.loop == 0: #infinite loop
183 | return False
184 |
185 | if self.loop is not None and self.loop > 1: #finite loop
186 | return False
187 |
188 | if self.type in TYPE_SOUNDS:
189 | return False
190 |
191 | if len(self.children) > 1:
192 | return False
193 |
194 | if (self.delay or self.volume) and not simpler:
195 | return False
196 |
197 | #makeupgain, pitch: ignored
198 |
199 | if self.trim_begin or self.trim_end or self.pad_begin or self.pad_end or self.body_time:
200 | return False
201 |
202 | if _DEBUG_PRINT_IGNORABLE:
203 | return False
204 |
205 | return True
206 |
--------------------------------------------------------------------------------
/wwiser/parser/wio.py:
--------------------------------------------------------------------------------
1 | import os, struct
2 |
3 | class FileReader(object):
4 |
5 | def __init__(self, file):
6 | self.file = file
7 | self.be = False
8 | self._xorpad = None
9 |
10 | file.seek(0, os.SEEK_END)
11 | self.size = file.tell()
12 | file.seek(0, os.SEEK_SET)
13 |
14 | #def _read_buf(self, offset, type, size):
15 | # elem = self.buf[offset:offset+size]
16 | # return struct.unpack(type, elem)[0]
17 |
18 | def _check(self, elem, size):
19 | if not elem or len(elem) != size:
20 | raise ReaderError("can't read requested 0x%x bytes at 0x%x" % (size, self.current()))
21 |
22 | def __read(self, offset, type, size):
23 | if offset is not None:
24 | self.file.seek(offset, os.SEEK_SET)
25 | elem = self.file.read(size)
26 | if self._xorpad:
27 | elem = self.__unxor(elem, size)
28 |
29 | self._check(elem, size)
30 |
31 | return struct.unpack(type, elem)[0]
32 |
33 | def __read_string(self, offset, size):
34 | if offset is not None:
35 | self.file.seek(offset, os.SEEK_SET)
36 | if size == 0:
37 | return ""
38 | elem = self.file.read(size)
39 | self._check(elem, size)
40 |
41 | elem = bytes(elem) #force
42 | #remove c-string null terminator, .decode() retains it
43 | if elem[-1] == 0:
44 | elem = elem[:-1]
45 | text = elem.decode('UTF-8')
46 | return text
47 |
48 | def __bytes(self, offset, size):
49 | if offset is not None:
50 | self.file.seek(offset, os.SEEK_SET)
51 | elem = self.file.read(size)
52 | self._check(elem, size)
53 |
54 | elem = bytes(elem) #force
55 | return elem
56 |
57 | def __unxor(self, elem, size):
58 | offset = self.current() - size
59 | xorpad_len = len(self._xorpad)
60 | if offset >= xorpad_len:
61 | return elem
62 |
63 | max = offset + size
64 | if max > xorpad_len:
65 | max = xorpad_len
66 |
67 | elem = bytearray(elem)
68 | for i in range(offset, max):
69 | xor = self._xorpad[i]
70 | elem[i - offset] ^= xor
71 | return elem
72 |
73 | def d64le(self, offset = None):
74 | return self.__read(offset, '