├── wwiser ├── generator │ ├── __init__.py │ ├── txtp │ │ ├── __init__.py │ │ ├── hnode_misc.py │ │ ├── wtxtp_renamer.py │ │ ├── wtxtp_debug.py │ │ ├── hnode_envelope.py │ │ └── wtxtp_tree.py │ ├── registry │ │ ├── __init__.py │ │ ├── wstingers.py │ │ ├── wtransitions.py │ │ ├── wgamevars.py │ │ └── wparams.py │ ├── render │ │ ├── __init__.py │ │ ├── bnode_stinger.py │ │ ├── bnode_auxs.py │ │ ├── wglobalsettings.py │ │ ├── bnode_markers.py │ │ ├── wrenderer_util.py │ │ ├── bnode_automation.py │ │ ├── wmediaindex.py │ │ ├── bnode_fxs.py │ │ ├── rnode_base.py │ │ ├── bnode_rules.py │ │ ├── bnode_statechunk.py │ │ ├── wbuilder_util.py │ │ ├── wstate.py │ │ ├── bnode_base.py │ │ └── bnode_tree.py │ ├── wexternals.py │ ├── wstats.py │ ├── wtxtp_cache.py │ ├── wreport.py │ ├── wmover.py │ ├── wtags.py │ └── wlang.py ├── names │ ├── __init__.py │ ├── wnamerow.py │ ├── wnconfig.py │ └── wsqlite.py ├── parser │ ├── __init__.py │ ├── wfmt.py │ ├── wfinder.py │ └── wio.py ├── tools │ ├── __init__.py │ ├── wcleaner.py │ ├── wconfigini.py │ └── wcleaner_unwanted.py ├── viewer │ ├── __init__.py │ ├── resources │ │ ├── stylesheet.2.xsl │ │ ├── templates │ │ │ ├── unknown.tpl │ │ │ ├── skip.tpl │ │ │ ├── error.tpl │ │ │ ├── object-CAkSound.tpl │ │ │ ├── test.tpl │ │ │ ├── list.tpl │ │ │ ├── root.tpl │ │ │ ├── object.tpl │ │ │ └── field.tpl │ │ ├── favicon.ico │ │ ├── wwiser.ico │ │ ├── viewer.html │ │ ├── viewer.css │ │ └── viewer.js │ ├── wloader.py │ ├── wmarkdown.py │ └── wtemplate.py ├── wversion.py ├── __init__.py ├── wlogs.py ├── wfnv.py └── wtests.py ├── .gitignore ├── wwiser.py ├── .github └── workflows │ ├── nightly.yml │ └── stable.yml ├── doc ├── DEV.md └── TODO.md └── README.md /wwiser/generator/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wwiser/names/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wwiser/parser/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wwiser/tools/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wwiser/viewer/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wwiser/generator/txtp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wwiser/generator/registry/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wwiser/generator/render/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /wwiser/viewer/resources/stylesheet.2.xsl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /wwiser/viewer/resources/templates/unknown.tpl: -------------------------------------------------------------------------------- 1 |
error
-------------------------------------------------------------------------------- /wwiser/wversion.py: -------------------------------------------------------------------------------- 1 | # autogenerated on build 2 | WWISER_VERSION = "v20250928" 3 | -------------------------------------------------------------------------------- /wwiser/__init__.py: -------------------------------------------------------------------------------- 1 | #from __future__ import absolute_import 2 | #from . import wcli 3 | 4 | #__all__ = ["wcli"] #? 5 | -------------------------------------------------------------------------------- /wwiser/viewer/resources/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bnnm/wwiser/HEAD/wwiser/viewer/resources/favicon.ico -------------------------------------------------------------------------------- /wwiser/viewer/resources/wwiser.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bnnm/wwiser/HEAD/wwiser/viewer/resources/wwiser.ico -------------------------------------------------------------------------------- /wwiser/viewer/resources/templates/skip.tpl: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /wwiser/viewer/resources/templates/error.tpl: -------------------------------------------------------------------------------- 1 |
2 | error: {attrs[message]} 3 | {body} 4 |
5 | -------------------------------------------------------------------------------- /wwiser/viewer/resources/templates/object-CAkSound.tpl: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /wwiser/viewer/resources/templates/test.tpl: -------------------------------------------------------------------------------- 1 | TEMPLATE: 2 | ${ if _exists('none'):} 3 | none 4 | ${: else: } 5 | user: ${user} 6 | ${:} 7 | map: ${testmap['key']} 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | !/.gitignore 3 | !.gitattributes 4 | !.github/ 5 | *.user 6 | *.o 7 | *.a 8 | 9 | # Byte-compiled / optimized / DLL files 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | bin 14 | 15 | # extra for tests 16 | wwnames.db3 17 | wwiser.log 18 | wwnames* 19 | *.ini -------------------------------------------------------------------------------- /wwiser/viewer/resources/templates/list.tpl: -------------------------------------------------------------------------------- 1 |
2 |
3 | list 4 | ${attrs['name']} 5 | ${attrs['count']} 6 |
7 |
8 | ${body} 9 |
10 |
11 | -------------------------------------------------------------------------------- /wwiser/viewer/resources/templates/root.tpl: -------------------------------------------------------------------------------- 1 |
2 |
3 | bank 4 | v${attrs['version']} 5 | ${attrs['filename']} 6 |
7 |
8 | ${body} 9 |
10 |
11 | -------------------------------------------------------------------------------- /wwiser/viewer/resources/templates/object.tpl: -------------------------------------------------------------------------------- 1 |
2 |
3 | obj 4 | 5 | ${attrs['name']}${if 'index' in attrs:}[${attrs['index']}]${:} 6 | 7 |
8 |
9 | ${body} 10 |
11 |
12 | -------------------------------------------------------------------------------- /wwiser/tools/wcleaner.py: -------------------------------------------------------------------------------- 1 | import re 2 | from . import wcleaner_unwanted, wcleaner_unused 3 | 4 | 5 | class Cleaner(object): 6 | def __init__(self, locator, banks): 7 | self._locator = locator 8 | self._banks = banks 9 | 10 | def process(self): 11 | cleaner = wcleaner_unused.CleanerUnused(self._locator, self._banks) 12 | cleaner.process() 13 | 14 | cleaner = wcleaner_unwanted.CleanerUnwanted(self._locator) 15 | cleaner.process() 16 | -------------------------------------------------------------------------------- /wwiser/viewer/wloader.py: -------------------------------------------------------------------------------- 1 | import pkgutil 2 | 3 | class _Loader(object): 4 | def __init__(self): 5 | pass 6 | 7 | def get_resource_text(self, path): 8 | try: 9 | return pkgutil.get_data(__name__, path).decode() 10 | except (FileNotFoundError, OSError): # as e 11 | return None 12 | 13 | def get_resource(self, path): 14 | try: 15 | return pkgutil.get_data(__name__, path) 16 | except (FileNotFoundError, OSError): # as e 17 | return None 18 | 19 | Loader = _Loader() 20 | -------------------------------------------------------------------------------- /wwiser/generator/registry/wstingers.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | # stingers are special musicsegments, registered to generate at the end 4 | 5 | class Stingers(object): 6 | def __init__(self): 7 | self._items = [] 8 | self._done = OrderedDict() 9 | 10 | 11 | def get_items(self): 12 | return self._items 13 | 14 | #-------------------------------------------------------------------------- 15 | 16 | def add(self, stingerlist): 17 | for bstinger in stingerlist.stingers: 18 | 19 | if bstinger in self._done: 20 | continue 21 | self._done[bstinger] = True 22 | self._items.append(bstinger) 23 | -------------------------------------------------------------------------------- /wwiser/generator/registry/wtransitions.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | # transition musicsegments in switches/playlists don't get used, registered to generate at the end 4 | 5 | class Transitions(object): 6 | def __init__(self): 7 | self._items = [] 8 | self._done = OrderedDict() 9 | 10 | def get_items(self): 11 | return self._items 12 | 13 | #-------------------------------------------------------------------------- 14 | 15 | def add(self, rules): 16 | for btrn in rules.ntrns: 17 | 18 | if btrn.tid in self._done: 19 | continue 20 | self._done[btrn.tid] = True 21 | self._items.append(btrn) 22 | -------------------------------------------------------------------------------- /wwiser.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # (should work for python2 and 3) 3 | # 4 | # Wwiser bnk parser by bnnm 5 | 6 | import sys 7 | from wwiser import wcli 8 | from wwiser import wgui 9 | 10 | _PROFILE = False 11 | 12 | 13 | def main(): 14 | if len(sys.argv) > 1: 15 | wcli.Cli().start() 16 | else: 17 | wgui.Gui().start() 18 | 19 | 20 | def profile_simple(): 21 | try: 22 | import cProfile as profile 23 | except: 24 | import profile 25 | profile.run('wcli.Cli().start()') 26 | 27 | 28 | def profile_complex(): 29 | import pstats 30 | try: 31 | import cProfile as profile 32 | except: 33 | import profile 34 | #profile.run('wcli.Cli().start()') 35 | profiler = profile.Profile() 36 | profiler.enable() 37 | main() 38 | profiler.disable() 39 | sort = 'cumtime' #tottime ncalls 40 | stats = pstats.Stats(profiler).sort_stats(sort) 41 | stats.print_stats() 42 | 43 | 44 | if __name__ == "__main__": 45 | if _PROFILE: 46 | profile_complex() 47 | else: 48 | main() 49 | -------------------------------------------------------------------------------- /wwiser/generator/render/bnode_stinger.py: -------------------------------------------------------------------------------- 1 | # STINGERS/TRIGGERS 2 | # 3 | # Some objects can contain a reference to another object, that plays when the game posts 4 | # a "trigger" (via API or calling a CAkTrigger from an event) 5 | 6 | class CAkStinger(object): 7 | def __init__(self, node): 8 | self.ntrigger = None 9 | self.ntid = None 10 | self.tid = None 11 | self._build(node) 12 | 13 | def _build(self, node): 14 | self.ntrigger = node.find1(name='TriggerID') #idExt called from trigger action 15 | self.ntid = node.find1(name='SegmentID') #musicsegment to play (may be 0) 16 | if self.ntid: 17 | self.tid = self.ntid.value() 18 | 19 | class CAkStingerList(object): 20 | def __init__(self, node): 21 | self.stingers = [] 22 | self._build(node) 23 | 24 | def _build(self, node): 25 | nstingers = node.finds(name='CAkStinger') 26 | if not nstingers: 27 | return 28 | 29 | for nstinger in nstingers: 30 | stinger = CAkStinger(nstinger) 31 | if stinger.tid: 32 | self.stingers.append(stinger) 33 | -------------------------------------------------------------------------------- /wwiser/tools/wconfigini.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import configparser 3 | 4 | _CONFIG_NAME = 'wwiser.ini' 5 | _DEFAULT_SECTION = 'wwiser' 6 | 7 | class ConfigIni(object): 8 | def __init__(self): 9 | self._config = configparser.ConfigParser() 10 | 11 | # ignored if file doesn't exist 12 | self._config.read('wwiser.ini') 13 | 14 | # inis need to put vars in sections 15 | try: 16 | self._config.add_section(_DEFAULT_SECTION) 17 | except: 18 | pass #already exists 19 | 20 | def get(self, key): 21 | try: 22 | # also: .getboolean, .getint, .getfloat 23 | return self._config.get(_DEFAULT_SECTION, key) 24 | except: 25 | return None #doesn't exists 26 | 27 | def set(self, key, val): 28 | self._config.set(_DEFAULT_SECTION, key, val) 29 | 30 | def update(self): 31 | try: 32 | with open(_CONFIG_NAME, 'w') as f: 33 | self._config.write(f) 34 | except PermissionError: 35 | logging.info("config: can't save .ini (read only dir?)") 36 | return # may happen in read-only dirs 37 | -------------------------------------------------------------------------------- /wwiser/generator/render/bnode_auxs.py: -------------------------------------------------------------------------------- 1 | 2 | class AkAuxList(object): 3 | def __init__(self, node, bparent, hircs): 4 | self.init = False 5 | self._auxs = [] #exact max is 4 6 | self._build(node, bparent, hircs) 7 | 8 | def _build(self, node, bparent, hircs): 9 | if not node: 10 | return 11 | 12 | # flag is implicit and always set, but just in case 13 | nhas = node.find1(name='bHasAux') 14 | if not nhas or nhas.value() <= 0: 15 | return 16 | 17 | # object may have defined auxs, but not override them = not used, however flag is only set in childs 18 | nover = node.find1(name='bOverrideUserAuxSends') 19 | if not nover or bparent and nover.value() <= 0: 20 | return 21 | 22 | # current list overwrites parent, even if empty 23 | self.init = True 24 | 25 | # always 4 entries, ID 0 if not used 26 | nauxids = node.finds(name='auxID') #should exist 27 | for nauxid in nauxids: 28 | baux = hircs._read_bus(nauxid) #parent bus of this bus 29 | if not baux: 30 | continue 31 | self._auxs.append(baux) 32 | 33 | def get_bauxs(self): 34 | return self._auxs 35 | -------------------------------------------------------------------------------- /wwiser/viewer/resources/templates/field.tpl: -------------------------------------------------------------------------------- 1 |
2 | ${if 'offset' in attrs: } 3 |
4 | ${"%08x" % attrs['offset']} 5 |
6 | ${:} 7 |
8 | ${attrs['type']} 9 | ${attrs['name']} 10 | ${ 11 | if 'valuefmt' in attrs: 12 | val = attrs['valuefmt'] 13 | else: 14 | val = attrs['value'] 15 | } 16 | ${val} 17 | 18 | ${ #clickable links need text nodes, but not anchors } 19 | ${if attrs['type'] == 'tid' and attrs['value'] > 0: } 20 | target 21 | ${:} 22 | ${if attrs['type'] == 'sid': } 23 | anchor 24 | ${:} 25 | 26 | ${if 'hashname' in attrs: }(${attrs['hashname']})${:} 27 | ${if 'guidname' in attrs: }{${attrs['guidname']}}${:} 28 | ${if 'objpath' in attrs: }${attrs['objpath']}${:} 29 | ${if 'path' in attrs: }${attrs['path']}${:} 30 |
31 |
32 | ${body} 33 |
34 |
35 | -------------------------------------------------------------------------------- /wwiser/generator/render/wglobalsettings.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | # default Wwise config in init.bnk 5 | 6 | class GlobalSettings(object): 7 | def __init__(self): 8 | self._rtpc_default = {} 9 | 10 | #-------------------------------------------------------------------------- 11 | 12 | def load(self, nchunk): 13 | # load another chunk (may be N) 14 | chunkname = nchunk.get_name() 15 | if chunkname != 'GlobalSettingsChunk': 16 | return 17 | 18 | for nitem in nchunk.get_children(): 19 | itemname = nitem.get_name() 20 | 21 | # StateGroups: list of states (not always found) 22 | 23 | # pItems > SwitchGroups: same 24 | 25 | if itemname == 'pRTPCMgr': #RTPC info 26 | nrtpcrampings = nitem.finds(name='RTPCRamping') 27 | if not nrtpcrampings: 28 | continue 29 | 30 | for nrtpcramping in nrtpcrampings: 31 | nid = nrtpcramping.find1(name='RTPC_ID') 32 | nvalue = nrtpcramping.find1(name='fValue') 33 | if nid and nvalue: 34 | id = nid.value() 35 | value = nvalue.value() 36 | self._rtpc_default[id] = value 37 | continue 38 | 39 | #acousticTextures: texture modifiers for game 40 | return 41 | 42 | def get_rtpc_default(self, id): 43 | return self._rtpc_default.get(id) 44 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: build-wwiser-nightly 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: # Run on any code change 8 | - "wwiser/**/*.py" 9 | - "wwiser.py" 10 | 11 | # Allows you to run this workflow manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | jobs: 15 | release-new-nightly: 16 | runs-on: windows-2022 17 | 18 | steps: 19 | # Checkout your code 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | 23 | # Setup env 24 | - name: Set up Python 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: '3.8' 28 | 29 | # Make release version tag 30 | - name: Get Release Version 31 | id: release_version 32 | run: | 33 | $DateString = Get-Date -Format "yyyyMMdd" 34 | echo "wwiser_version=$DateString" >> $env:GITHUB_OUTPUT 35 | 36 | # Build app 37 | - name: Build WWiser 38 | run: python build.py ${{ steps.release_version.outputs.wwiser_version }} 39 | 40 | # Release nightly 41 | - name: Create GitHub Release 42 | id: create_release 43 | uses: softprops/action-gh-release@v2 44 | with: 45 | files: ./bin/wwiser.pyz 46 | name: ${{ env.WWISER_VERSION}}-nightly 47 | tag_name: latest-nightly 48 | prerelease: true 49 | env: 50 | WWISER_VERSION: v${{ steps.release_version.outputs.wwiser_version }} 51 | -------------------------------------------------------------------------------- /.github/workflows/stable.yml: -------------------------------------------------------------------------------- 1 | name: build-wwiser-stable 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: # Only run on new version 8 | - "wwiser/wversion.py" 9 | 10 | # Allows you to run this workflow manually from the Actions tab 11 | workflow_dispatch: 12 | 13 | jobs: 14 | release-new-version: 15 | runs-on: windows-2022 16 | 17 | steps: 18 | # Checkout your code 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | 22 | # Setup env 23 | - name: Set up Python 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: '3.8' 27 | 28 | # Make release version tag 29 | - name: Get Release Version 30 | id: release_version 31 | run: | 32 | $WwiserVersion = $(python -c "from wwiser.wversion import WWISER_VERSION; print(WWISER_VERSION)") 33 | echo "WWISER_VERSION=$WwiserVersion" >> $env:GITHUB_OUTPUT 34 | 35 | # Build app 36 | - name: Build WWiser 37 | run: python build.py ${{ steps.release_version.outputs.wwiser_version }} 38 | 39 | # Create stable release 40 | - name: Create GitHub Release 41 | id: create_release 42 | uses: softprops/action-gh-release@v2 43 | with: 44 | files: ./bin/wwiser.pyz 45 | name: ${{ env.WWISER_VERSION}} 46 | tag_name: ${{env.WWISER_VERSION}} 47 | # body_path: /path/to/CHANGELOG.TXT # If not present then it's latest commit message 48 | env: 49 | WWISER_VERSION: ${{ steps.release_version.outputs.wwiser_version }} 50 | -------------------------------------------------------------------------------- /wwiser/generator/txtp/hnode_misc.py: -------------------------------------------------------------------------------- 1 | # Misc helper nodes, for rendering 2 | 3 | # common config from all nodes to pass around 4 | class NodeConfig(object): 5 | def __init__(self): 6 | # loop_flag = 0 in Wwise means "full loop or use loop points of file has (if file is sfx)", 7 | # and 1 means "don't loop even if the file has loop points" (like xma/dsp) 8 | # (>N also means "loop N times", but sounds shouldn't use this, only groups) 9 | self.loop = None 10 | 11 | self.gain = 0 #combination of all wwise's volume stuff (though technically still volume) 12 | self.delay = 0 13 | 14 | # marks 15 | self.crossfaded = False #RTPC/statechunks controlled silence 16 | self.silenced = False #low volume 17 | self.silenced_default = False #default silence (without applying RTPC/statechunks) 18 | 19 | self.playevent = False 20 | self.rules = None 21 | self.duration = None 22 | self.entry = None 23 | self.exit = None 24 | 25 | # common audio object with config 26 | class NodeSound(object): 27 | def __init__(self): 28 | self.source = None #original source info (may not exist for silence) 29 | self.nsrc = None #to get root bank 30 | self.silent = False 31 | self.automations = None 32 | self.unreachable = False 33 | 34 | #clips can be modded by the engine (regular sounds are pre-changed when saved to .wem) 35 | self.clip = False 36 | self.fpa = 0 #moves track from start (>0=right, <0=left) 37 | self.fbt = 0 #mods beginning (>0=trim, <0=add begin repeat) 38 | self.fet = 0 #mods end (<0=trim, >0=add end repeat) 39 | self.fsd = 0 #original file duration (for calcs) 40 | -------------------------------------------------------------------------------- /wwiser/wlogs.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | def setup_clean_logging(): 5 | # removes old handlers in case we call one setup after other setups 6 | for handler in logging.root.handlers[:]: 7 | logging.root.removeHandler(handler) 8 | 9 | #to-do: handlers is python3.3+? 10 | def setup_cli_logging(): 11 | setup_clean_logging() 12 | #handlers = [logging.StreamHandler(sys.stdout)] 13 | logging.basicConfig( 14 | level=logging.INFO, 15 | format='%(message)s', 16 | #format='[%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s', 17 | #handlers=handlers 18 | ) 19 | 20 | def setup_gui_logging(txt): 21 | setup_clean_logging() 22 | handlers = [_GuiLogHandler(txt)] 23 | logging.basicConfig( 24 | level=logging.INFO, 25 | format='%(message)s', 26 | #format='[%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s', 27 | handlers=handlers 28 | ) 29 | 30 | def setup_file_logging(): 31 | setup_clean_logging() 32 | #handlers = [logging.FileHandler('wwiser.log')] 33 | logging.basicConfig( 34 | #allow DEBUG for extra info 35 | level=logging.DEBUG, 36 | format='%(message)s', 37 | #format='[%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s', 38 | filename='wwiser.log' 39 | ) 40 | 41 | class _GuiLogHandler(logging.Handler): 42 | def __init__(self, txt): 43 | logging.Handler.__init__(self) 44 | self._txt = txt 45 | 46 | def emit(self, message): 47 | msg = self.format(message) 48 | txt = self._txt 49 | txt.config(state='normal') 50 | txt.insert('end', msg + '\n') 51 | txt.see('end') 52 | txt.config(state='disabled') 53 | -------------------------------------------------------------------------------- /wwiser/generator/render/bnode_markers.py: -------------------------------------------------------------------------------- 1 | 2 | # "entry" and "exit" markers (fixed IDs) are ordered in time and may go in any position 3 | _MARKER_ID_ENTRY = 43573010 4 | _MARKER_ID_EXIT = 1539036744 5 | # older versions (v62<=) use simpler IDs 0/1 for entry/exit, while other cues do use tids 6 | _MARKER_ID_ENTRY_OLD = 0 7 | _MARKER_ID_EXIT_OLD = 1 8 | 9 | class _AkMarker(object): 10 | def __init__(self, node): 11 | self.id = None 12 | self.pos = None 13 | self.node = None 14 | self.npos = None 15 | self._build(node) 16 | 17 | def _build(self, node): 18 | self.node = node 19 | self.id = node.find(name='id').value() 20 | self.npos = node.find(name='fPosition') 21 | self.pos = self.npos.value() 22 | #pMarkerName: optional, found in later versions 23 | 24 | 25 | class AkMarkerList(object): 26 | def __init__(self, node): 27 | self._markers = [] 28 | self.fields = [] 29 | self._build(node) 30 | 31 | def _build(self, node): 32 | nbase = node.find(name='pArrayMarkers') 33 | if not nbase: 34 | return 35 | 36 | nmarkers = nbase.finds(name='AkMusicMarkerWwise') 37 | for nmarker in nmarkers: 38 | marker = _AkMarker(nmarker) 39 | self._markers.append(marker) 40 | 41 | def _get_marker(self, ids, must_exist=False): 42 | for marker in self._markers: 43 | if marker.id in ids: 44 | return marker 45 | if must_exist: 46 | raise ValueError("can't find marker") 47 | return None 48 | 49 | def get_entry(self): 50 | return self._get_marker([_MARKER_ID_ENTRY, _MARKER_ID_ENTRY_OLD], must_exist=True) 51 | 52 | def get_exit(self): 53 | return self._get_marker([_MARKER_ID_EXIT, _MARKER_ID_EXIT_OLD], must_exist=True) 54 | -------------------------------------------------------------------------------- /wwiser/generator/wexternals.py: -------------------------------------------------------------------------------- 1 | import logging, os 2 | 3 | # Reads a externals.txt list to get "externals", a type of .wem definition. 4 | # Devs can manually set a "id <> filename" relation, and this simulates it. 5 | 6 | class Externals(object): 7 | def __init__(self): 8 | self.active = False 9 | self._items = {} 10 | self._locator = None 11 | 12 | def set_locator(self, locator): 13 | self._locator = locator 14 | 15 | def get(self, item): 16 | return self._items.get(item) 17 | 18 | def load(self): 19 | if not self._locator: 20 | return 21 | 22 | files = self._locator.find_externals() 23 | if not files: 24 | return 25 | for file in files: 26 | self._parse_externals(file) 27 | 28 | def _parse_externals(self, file): 29 | logging.info("generator: loading externals in %s", file) 30 | 31 | with open(file, 'r') as in_file: 32 | current_tid = None 33 | current_list = None 34 | for line in in_file: 35 | line = line.strip() 36 | 37 | if not line or line.startswith('#'): 38 | continue 39 | 40 | # new "cookie" ID 41 | if line.isnumeric(): 42 | current_tid = int(line) 43 | if current_tid not in self._items: 44 | self._items[current_tid] = [] 45 | current_list = self._items[current_tid] 46 | continue 47 | 48 | # must have one 49 | if not current_tid: 50 | logging.info("generator: WARNING, ignored externals (must start with an ID)") 51 | return 52 | 53 | # add text under current ID 54 | current_list.append(line) 55 | 56 | self.active = bool(self._items) 57 | -------------------------------------------------------------------------------- /wwiser/generator/render/wrenderer_util.py: -------------------------------------------------------------------------------- 1 | from .rnode_hircs import * 2 | 3 | 4 | # HIRC classes that should be used to generate .txtp 5 | GENERATED_BASE_HIRCS = [ 6 | 'CAkEvent', 7 | 'CAkDialogueEvent', 8 | ] 9 | 10 | # default for non-useful HIRC classes 11 | _DEFAULT_RENDERER_NODE = RN_CAkNone 12 | 13 | # HIRC classes capable of making a TXTP part. 14 | # Each should also have a bnode equivalent 15 | _HIRC_RENDERER_NODES = { 16 | # actions 17 | 'CAkEvent': RN_CAkEvent, 18 | 'CAkDialogueEvent': RN_CAkDialogueEvent, 19 | 'CAkActionPlay': RN_CAkActionPlay, 20 | 'CAkActionTrigger': RN_CAkActionTrigger, 21 | # not found, may need to do something with them 22 | 'CAkActionPlayAndContinue': RN_CAkActionPlayAndContinue, 23 | 'CAkActionPlayEvent': RN_CAkActionPlayEvent, 24 | 25 | # sound engine 26 | 'CAkLayerCntr': RN_CAkLayerCntr, 27 | 'CAkSwitchCntr': RN_CAkSwitchCntr, 28 | 'CAkRanSeqCntr': RN_CAkRanSeqCntr, 29 | 'CAkSound': RN_CAkSound, 30 | 31 | # music engine 32 | 'CAkMusicSwitchCntr': RN_CAkMusicSwitchCntr, 33 | 'CAkMusicRanSeqCntr': RN_CAkMusicRanSeqCntr, 34 | 'CAkMusicSegment': RN_CAkMusicSegment, 35 | 'CAkMusicTrack': RN_CAkMusicTrack, 36 | 37 | # info only, not renderable 38 | #CAkState 39 | #CAkFxCustom 40 | #CAkActorMixer 41 | #CAkActionSetState 42 | #CAkAction* 43 | #CAkBus 44 | #CAkAuxBus 45 | #CAkFeedbackBus: accepts audio from regular sounds + creates rumble 46 | #CAkFeedbackNode: played like audio (play action) and has source ID, but it's simply a rumble generator 47 | #CAkAttenuation 48 | #CAkAudioDevice 49 | #CAkFxShareSet 50 | #CAkLFOModulator 51 | #CAkEnvelopeModulator 52 | #CAkTimeModulator 53 | } 54 | 55 | def get_renderer_hirc(hircname): 56 | return _HIRC_RENDERER_NODES.get(hircname, _DEFAULT_RENDERER_NODE) 57 | -------------------------------------------------------------------------------- /wwiser/generator/wstats.py: -------------------------------------------------------------------------------- 1 | 2 | class Stats(object): 3 | def __init__(self): 4 | # process info 5 | self.created = 0 6 | self.duplicates = 0 7 | self.unused = 0 8 | self.multitrack = 0 9 | self.trims = 0 10 | self.streams = 0 11 | self.internals = 0 12 | self.names = 0 13 | 14 | self._txtp_hashes = {} #hash 15 | self._namenode_hashes = {} 16 | self._name_hashes = {} 17 | self._banks = {} 18 | 19 | # process flag #TODO: improve 20 | self.unused_mark = False 21 | 22 | 23 | def register_txtp(self, texthash, printer): 24 | if texthash in self._txtp_hashes: 25 | self.duplicates += 1 26 | return False 27 | 28 | self._txtp_hashes[texthash] = True 29 | self.created += 1 30 | if self.unused_mark: 31 | self.unused += 1 32 | 33 | if printer.has_internals: 34 | self.internals += 1 35 | if printer.has_streams: 36 | self.streams += 1 37 | return True 38 | 39 | def unregister_dupe(self, texthash): 40 | if texthash in self._txtp_hashes: 41 | self.duplicates -= 1 42 | return 43 | 44 | def register_namenode(self, name, node): 45 | hashname = hash(name) 46 | hashnode = hash(node) #ok since different bank + cak object = different python hash 47 | key = (hashname, hashnode) 48 | 49 | self.names += 1 50 | if key in self._namenode_hashes: 51 | return False 52 | 53 | self._namenode_hashes[key] = True 54 | return True 55 | 56 | def register_namebase(self, name): 57 | # same as the above but without node/bank, to detect when it needs to rename 58 | hashname = hash(name) 59 | key = (hashname) 60 | 61 | if key in self._name_hashes: 62 | return False 63 | 64 | self._name_hashes[key] = True 65 | return True 66 | 67 | def current_name_count(self): 68 | return self.names 69 | 70 | def register_bank(self, bankname): 71 | self._banks[bankname] = True 72 | return 73 | 74 | def get_used_banks(self): 75 | return self._banks 76 | -------------------------------------------------------------------------------- /wwiser/viewer/resources/viewer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | wwiser viewer 4 | 5 | 6 | 7 | 8 |
9 |

wwiser viewer

10 |
11 | 19 | 20 |
21 | 22 | 23 |
24 | 25 |
26 | 27 | 28 | 29 | Hide: 30 | 31 | 32 |
33 | 34 |
35 | 36 |
37 |
38 | 39 | 40 |
41 | 42 |
43 | 44 |
45 |
46 | 47 |
48 |
49 | 50 | 51 |
52 |
53 |
54 |
55 | 56 |
57 |
58 |
59 |
60 |
61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /wwiser/names/wnamerow.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | # helper containing a single name 4 | 5 | class NameRow(object): 6 | __slots__ = ['id', 'name', 'type', 'hashname', 'hashnames', 'guidname', 'guidnames', 'objpath', 'path', 'hashname_used', 'multiple_marked', 'source', 'extended', 'hashtypes'] 7 | 8 | NAME_SOURCE_COMPANION = 0 #XML/TXT/H 9 | NAME_SOURCE_EXTRA = 1 #LST/DB 10 | 11 | def __init__(self, id, hashname=None): 12 | self.id = id 13 | 14 | self.hashname = hashname 15 | self.guidname = None 16 | self.hashnames = [] #for list generation (contains only extra names, main is in "hashname") 17 | self.guidnames = [] #possible but useful? 18 | self.path = None 19 | self.objpath = None 20 | self.hashname_used = False 21 | self.multiple_marked = False 22 | self.source = None 23 | self.extended = False 24 | self.hashtypes = None 25 | 26 | def _exists(self, name, list): 27 | if name.lower() in (listname.lower() for listname in list): 28 | return True 29 | return False 30 | 31 | def add_hashname(self, name, extended=False): 32 | if not name: 33 | return 34 | 35 | self.extended = extended 36 | 37 | if not self.hashname: #base 38 | self.hashname = name 39 | else: 40 | is_samecaps = name.lower() == self.hashname.lower() 41 | is_exists = name.lower() in (hashname.lower() for hashname in self.hashnames) 42 | if not is_samecaps and not is_exists: 43 | self.hashnames.append(name) #alts 44 | self.hashname = name #overwrite if reached this function (update caps) 45 | 46 | def add_guidname(self, name): 47 | if not name: 48 | return 49 | if not self.guidname: #base 50 | self.guidname = name 51 | else: 52 | if name.lower() == self.guidname.lower(): 53 | return 54 | if name.lower() in (guidname.lower() for guidname in self.guidnames): 55 | return 56 | self.guidnames.append(name) #alts 57 | 58 | def add_objpath(self, objpath): 59 | if not objpath: 60 | return 61 | self.objpath = objpath 62 | 63 | def add_path(self, path): 64 | if not path: 65 | return 66 | self.path = path 67 | -------------------------------------------------------------------------------- /wwiser/wfnv.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | class Fnv(object): 4 | FNV_DICT = '0123456789abcdefghijklmnopqrstuvwxyz_' 5 | FNV_FORMAT = re.compile(r"^[a-z_][a-z0-9\_]*$") 6 | FNV_FORMAT_EX = re.compile(r"^[a-z_0-9][a-z0-9_()\- ,]*$") 7 | 8 | def is_hashable(self, lowname): 9 | return self.FNV_FORMAT.match(lowname) 10 | 11 | def is_hashable_extended(self, lowname): 12 | return self.FNV_FORMAT_EX.match(lowname) 13 | 14 | 15 | # Find actual name from a close name (same up to last char) using some fuzzy searching 16 | # ('bgm0' and 'bgm9' IDs only differ in the last byte, so it calcs 'bgm' + '0', '1'...) 17 | def unfuzzy_hashname_lw(self, id, lowname, hashname): 18 | if not id or not hashname: 19 | return None 20 | 21 | namebytes = bytearray(lowname, 'UTF-8') 22 | basehash = self._get_hash(namebytes[:-1]) #up to last byte 23 | for c in self.FNV_DICT: #try each last char 24 | id_hash = self._get_partial_hash(basehash, ord(c)) 25 | 26 | if id_hash == id: 27 | c = c.upper() 28 | for cs in hashname: #upper only if all base name is all upper 29 | if cs.islower(): 30 | c = c.lower() 31 | break 32 | 33 | hashname = hashname[:-1] + c 34 | return hashname 35 | # it's possible to reach here with incorrect (manually input) ids, 36 | # since not all 255 values are in FNV_DICT 37 | return None 38 | 39 | def unfuzzy_hashname(self, id, hashname): 40 | return self.unfuzzy_hashname_lw(id, hashname.lower(), hashname) 41 | 42 | # Partial hashing for unfuzzy'ing. 43 | def _get_partial_hash(self, hash, value): 44 | hash = hash * 16777619 #FNV prime 45 | hash = hash ^ value #FNV xor 46 | hash = hash & 0xFFFFFFFF #python clamp 47 | return hash 48 | 49 | # Standard AK FNV-1 with 32-bit. 50 | def _get_hash(self, namebytes): 51 | hash = 2166136261 #FNV offset basis 52 | 53 | for namebyte in namebytes: #for i in range(len(namebytes)): 54 | hash = hash * 16777619 #FNV prime 55 | hash = hash ^ namebyte #FNV xor 56 | hash = hash & 0xFFFFFFFF #python clamp 57 | return hash 58 | 59 | def get_hash(self, name): 60 | return self.get_hash_lw(name.lower()) 61 | 62 | def get_hash_lw(self, lowname): 63 | namebytes = bytes(lowname, 'UTF-8') 64 | return self._get_hash(namebytes) 65 | -------------------------------------------------------------------------------- /wwiser/generator/render/bnode_automation.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class _AkGraphPoint(object): 4 | def __init__(self, npoint): 5 | self.time = npoint.find(name='From').value() #time from (relative to music track) 6 | self.value = npoint.find(name='To').value() #current altered value 7 | self.interp = npoint.find(name='Interp').value() #easing function between points 8 | 9 | 10 | class AkClipAutomation(object): 11 | def __init__(self, nclipam): 12 | self.index = None 13 | self.type = None 14 | self.points = [] 15 | 16 | self._build(nclipam) 17 | 18 | def _build(self, nclipam): 19 | # clips have associated 'automations', that define graph points (making envelopes) to alter sound 20 | self.index = nclipam.find(name='uClipIndex').value() # which clip is affected (index N in clip list) 21 | self.type = nclipam.find(name='eAutoType').value() # type of alteration 22 | 23 | # types: 24 | # - fade-out: alters volume from A to B (in dB, so 0.0=100%, -96=0%) 25 | # - fade-in: same but in reverse 26 | # - LPF/HPF: low/high pass filter 27 | # - volume: similar but allows more points 28 | 29 | npoints = nclipam.finds(name='AkRTPCGraphPoint') 30 | for npoint in npoints: 31 | p = _AkGraphPoint(npoint) 32 | self.points.append(p) 33 | # each point is discrete yet connected to next point via easing function 34 | # ex. point1: from=0.0, to=0.0, interp=sine 35 | # point2: from=1.0, to=1.0, interp=constant 36 | # with both you have a fade in from 0.0..1.0, changing volume from silence to full in a sine curve 37 | 38 | 39 | class AkClipAutomationList(object): 40 | def __init__(self, node): 41 | self._cas = {} #may have N clips per track 42 | self.empty = True 43 | self.nclipams = None 44 | self._build(node) 45 | 46 | def _build(self, node): 47 | # parse clip modifiers 48 | nclipams = node.finds(name='AkClipAutomation') 49 | for nclipam in nclipams: 50 | ca = AkClipAutomation(nclipam) 51 | 52 | # each automation is associated to some musictrack's track index, so trackN = M automations 53 | # (where each track has different clips) 54 | if not ca.index in self._cas: 55 | self._cas[ca.index] = [] 56 | self._cas[ca.index].append(ca) 57 | self.empty = False 58 | self.nclipams = nclipams 59 | 60 | def get(self, track_index): 61 | return self._cas.get(track_index) 62 | -------------------------------------------------------------------------------- /wwiser/generator/render/wmediaindex.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | # preloaded list of internal/memory .wem in bnk, as txtp will need to find them 5 | 6 | class MediaIndex(object): 7 | def __init__(self): 8 | self._media_banks = {} # bank + sid > internal wem index 9 | self._media_sids = {} # sid > bank + internal wem index 10 | self._missing_media = {} # media (wem) objects missing in some bank 11 | self._event_based_packaging = False 12 | 13 | def set_event_based_packaging(self, flag): 14 | self._event_based_packaging = flag 15 | 16 | def get_missing_media(self): 17 | return self._missing_media 18 | 19 | def get_event_based_packaging(self): 20 | return self._event_based_packaging 21 | 22 | #-------------------------------------------------------------------------- 23 | 24 | def load(self, nchunk): 25 | # load another chunk (may be N) 26 | chunkname = nchunk.get_name() 27 | if chunkname != 'MediaIndex': 28 | return 29 | 30 | # preload indexes for internal wems 31 | bankname = nchunk.get_root().get_filename() 32 | nsids = nchunk.finds(type='sid') 33 | for nsid in nsids: 34 | sid = nsid.value() 35 | attrs = nsid.get_parent().get_attrs() 36 | index = attrs.get('index') 37 | if index is not None: 38 | self._add_media_index(bankname, sid, index) 39 | return 40 | 41 | # A game could load bgm.bnk + media1.bnk, and bgm.bnk point to sid=123 in media1.bnk. 42 | # But if user loads bgm1.bnk + media1.bnk + media2.bnk both media banks may contain sid=123, 43 | # so media_banks is used to find the index inside a certain bank (sid repeats allowed) first, 44 | # while media_sids is used to find any bank+index that contains that sid (repeats ignored). 45 | def _add_media_index(self, bankname, sid, index): 46 | self._media_banks[(bankname, sid)] = index 47 | if sid not in self._media_sids: 48 | self._media_sids[sid] = (bankname, index) 49 | 50 | def get_media_index(self, bankname, sid): 51 | #seen 0 in v112 test banks 52 | if not sid: 53 | return None 54 | 55 | # try in current bank 56 | index = self._media_banks.get((bankname, sid)) 57 | if index is not None: 58 | return (bankname, index) 59 | 60 | # try any bank 61 | media = self._media_sids.get(sid) 62 | if media is not None: 63 | return media 64 | 65 | logging.debug("generator: missing memory wem %s", sid) 66 | self._missing_media[sid] = True 67 | return None 68 | -------------------------------------------------------------------------------- /wwiser/generator/render/bnode_fxs.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class AkFxChunk(object): 4 | def __init__(self, nfx): 5 | self.index = None 6 | self.nid = None 7 | self.is_inline = False 8 | self.is_shareset = False 9 | self.is_rendered = False 10 | self.bfx = None 11 | self._build(nfx) 12 | 13 | def _build(self, nfx): 14 | nindex = nfx.find(name='uFXIndex') #not in older versions 15 | nid = nfx.find(name='fxID') #plugin ID in older versions 16 | nshareset = nfx.find(name='bIsShareSet') #not in older versions (inline plugins) 17 | nrendered = nfx.find(name='bIsRendered') #_bIsRendered in bus, never set 18 | 19 | if nindex: 20 | self.index = nindex.value() 21 | self.is_inline = nshareset is None 22 | 23 | if not self.is_inline: 24 | self.nid = nid 25 | self.is_shareset = nshareset.value() 26 | 27 | if nrendered: 28 | self.is_rendered = nrendered.value() 29 | #TODO load inline 30 | 31 | 32 | class AkFxChunkList(object): 33 | def __init__(self, node, builder): 34 | self.init = False 35 | self._fxcs = [] #exact max is 4 36 | self._flags = 0 37 | self._build(node, builder) 38 | 39 | def _build(self, node, builder): 40 | if not node: 41 | return 42 | 43 | nfxchunk = node.find(name='pFXChunk') 44 | if not nfxchunk: 45 | return 46 | 47 | # current list overwrites parent, even if empty 48 | self.init = True 49 | 50 | # & 0x1/0x2/0x4/0x8 = bypass index 0/1/2/3, 0x10 = bypass all 51 | flags = node.find1(name='bitsFXBypass') 52 | if flags is not None: 53 | self._flags = flags.value() 54 | else: 55 | flags = node.find1(name='bBypassAll') 56 | if flags and flags.value(): 57 | self._flags = 0x10 58 | 59 | nfxs = nfxchunk.finds(name='FXChunk') 60 | for nfx in nfxs: 61 | fxc = AkFxChunk(nfx) 62 | if fxc.is_rendered: #baked in 63 | continue 64 | 65 | if fxc.is_shareset: 66 | bfx = builder._get_bnode_link_shareset(fxc.nid) 67 | else: #AkFxCustom, regular audio node 68 | bfx = builder._get_bnode_link(fxc.nid) 69 | 70 | if not bfx: 71 | continue 72 | 73 | fxc.bfx = bfx 74 | if fxc.index is None: 75 | self._fxcs.append(fxc) 76 | else: 77 | if not self._fxcs: 78 | self._fxcs = [None] * 4 79 | self._fxcs[fxc.index] = fxc 80 | 81 | def get_gain(self): 82 | #TODO: read flags 83 | gain = 0 84 | for fxc in self._fxcs: 85 | if not fxc: 86 | continue 87 | gain += fxc.bfx.fx.gain 88 | return gain 89 | -------------------------------------------------------------------------------- /wwiser/generator/registry/wgamevars.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections import OrderedDict 3 | from ... import wfnv 4 | 5 | 6 | # GAMESYNCS' GAME PARAMETERS (gamevars) 7 | # Sets config used for RTPCs (Real Time Parameter Control), like battle_rank or 8 | # player_distance, typically to control volumes/pitches/etc via in-game values. 9 | 10 | class GamevarItem(object): 11 | def __init__(self, key, val, keyname=None): 12 | self.ok = False 13 | self.key = None 14 | self.value = None 15 | self.keyname = keyname 16 | 17 | # special key that sets all gamevars to this 18 | if key == '*': 19 | self.key = 0 20 | else: 21 | try: 22 | self.key = int(key) 23 | except: 24 | return 25 | 26 | # allowed special values 27 | self.is_min = val == 'min' 28 | self.is_max = val == 'max' 29 | self.is_default = val == '-' 30 | 31 | if self.is_min or self.is_max or self.is_default: 32 | pass #val = 0 #don't change to detect dupes 33 | else: 34 | try: 35 | val = float(val) 36 | except: 37 | return #invalid 38 | self.value = val 39 | 40 | self.ok = True 41 | 42 | # --------------------------------------------------------- 43 | 44 | # stores gamevars (rtpc) config 45 | class GamevarsParams(object): 46 | def __init__(self): 47 | self._items = OrderedDict() 48 | 49 | def add(self, item): 50 | if not item: 51 | return 52 | self._items[item.key] = item 53 | 54 | def get_item(self, id): 55 | id = int(id) 56 | return self._items.get(id) 57 | 58 | def get_items(self): 59 | return self._items.values() 60 | 61 | # --------------------------------------------------------- 62 | 63 | # stores combos of gamevars (rtpc) config 64 | class GamevarsPaths(object): 65 | def __init__(self): 66 | self._combos = [] 67 | self._fnv = wfnv.Fnv() 68 | 69 | # no registers needed 70 | 71 | def combos(self): 72 | return self._combos 73 | 74 | # --- 75 | 76 | def add_params(self, params): 77 | for combo in params.combos(): 78 | gparams = GamevarsParams() 79 | for item in combo: 80 | gitem = self._make_gitem(item) 81 | gparams.add(gitem) 82 | self._combos.append(gparams) 83 | 84 | def _make_gitem(self, item): 85 | key = item.key 86 | val = item.val 87 | 88 | if key == '*': #special 89 | keyname = None 90 | elif not key.isnumeric(): 91 | keyname = key 92 | key = self._fnv.get_hash(key) 93 | else: 94 | keyname = None 95 | 96 | gitem = GamevarItem(key, val, keyname) 97 | if not gitem.ok: 98 | logging.info('parser: ignored incorrect gamevar %s', item.elem) 99 | return None 100 | 101 | return gitem 102 | -------------------------------------------------------------------------------- /wwiser/generator/txtp/wtxtp_renamer.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | # Renames .txtp to other names. 4 | # Used to simplify repetitive prefixes like "play_bgm (BGM_TYPE_MUSIC=BGM_TYPE_M01)" > play_bgm (MUSIC=M01) 5 | # Contains a list loaded of rename stems, when applies to final txtp. 6 | # Also deletes unwanted .txtp by using the flag. 7 | # examples: 8 | # "(BGM_:(": changes "play_BGM (BGM_MUSIC=M01)" to "play_BGM (MUSIC=M01)" 9 | # "\\((.+?)=\\1_:(\\1=": changes "play_BGM (BGM_MUSIC=BGM_MUSIC_M01)" to "play_BGM (BGM_MUSIC=M01)" 10 | 11 | 12 | class TxtpRenamer(object): 13 | SKIP_FLAG = '' 14 | 15 | def __init__(self): 16 | self._items = [] 17 | self._has_skips = False 18 | self.skip = False 19 | 20 | def add(self, items): 21 | if not items: 22 | return 23 | for item in items: 24 | parts = item.split(":") 25 | if len(parts) != 2: 26 | continue 27 | 28 | text_in = parts[0] 29 | text_out = parts[1] 30 | regex = None 31 | if any(item in text_in for item in ['*','+','^','$', '.']): 32 | # full regex 33 | regex_in = text_in 34 | 35 | # not sure how to guess if regex was properly parsed or not 36 | #replaces = { '(':'\(', ')':'\)', '[':'\[', ']':'\]', '.':'\.', '*':'.*?' } 37 | #for key, val in replaces.items(): 38 | # regex_in = regex_in.replace(key, val) 39 | regex = re.compile(regex_in, re.IGNORECASE) 40 | else: 41 | # simple text 42 | regex = re.compile(re.escape(text_in), re.IGNORECASE) 43 | 44 | skip = text_out == self.SKIP_FLAG 45 | if skip: 46 | self._has_skips = True 47 | 48 | item = (text_in, text_out, skip, regex) 49 | self._items.append(item) 50 | return 51 | 52 | def apply_renames(self, name): 53 | if not self._items: 54 | return name 55 | 56 | # special "skip this txtp if rename matches" flag (for variables), lasts until next call 57 | # repeated at the end b/c it should go after cleanup (extra spaces) and final name 58 | self.skip = False 59 | 60 | 61 | # base renames 62 | for text_in, text_out, skip, regex in self._items: 63 | if skip and (regex and regex.match(name) or text_in in name): 64 | self.skip = True 65 | return name 66 | 67 | if regex: 68 | name = regex.sub(text_out, name) 69 | else: 70 | # not used at the moment (uses regex to handle case insensitiveness) 71 | name = name.replace(text_in, text_out) 72 | 73 | # clean extra stuff after cleanup 74 | replaces = { '(=':'(', '[=':'[', '=)':')', '=]':']', '()':'', '[]':'' } 75 | for key, val in replaces.items(): 76 | name = name.replace(key, val) 77 | while ' ' in name: 78 | name = name.replace(" ", " ") 79 | 80 | name.strip() 81 | 82 | # skips after cleaning up 83 | if self._has_skips: 84 | for text_in, text_out, skip, regex in self._items: 85 | if skip and (regex and regex.match(name) or text_in in name): 86 | self.skip = True 87 | return name 88 | 89 | return name 90 | -------------------------------------------------------------------------------- /wwiser/parser/wfmt.py: -------------------------------------------------------------------------------- 1 | 2 | #class FormatterStandard(object): 3 | # def __init__(self): 4 | # pass 5 | # 6 | # def format(self, type=None, value=None): 7 | # if self.value is None: 8 | # raise ValueError("formatter: value not set") 9 | # return str(self.value) 10 | 11 | HEX_FORMATS = { 12 | 'u64': "0x%16X", 13 | 'u32': "0x%08X", 14 | 'u16': "0x%04X", 15 | 'u8': "0x%02X", 16 | 'var': "0x%02X", 17 | 'gap': "0x%02X", 18 | } 19 | 20 | class FormatterHex(object): 21 | def __init__(self, fixed=False, zeropad=None): 22 | self.fixed = fixed 23 | self.zeropad = zeropad 24 | 25 | def format(self, type=None, value=None): 26 | if value is None: 27 | raise ValueError("formatter: value not set") 28 | if type is None: 29 | raise ValueError("formatter: type not set") 30 | 31 | format = HEX_FORMATS.get(type, None) #doubles as a "is int" check 32 | if format is None: 33 | return str(value) 34 | 35 | if value < 0: 36 | return "%i" % (value) 37 | 38 | if not self.fixed and not self.zeropad: 39 | return "0x%02X" % (value) 40 | 41 | if self.zeropad: 42 | format = "0x%0" + str(self.zeropad) + "X" 43 | 44 | return format % (value) 45 | 46 | class FormatterLUT(object): 47 | def __init__(self, enum, zeropad=None): 48 | self.enum = enum 49 | self.fmt = FormatterHex(zeropad=zeropad) 50 | 51 | 52 | def format(self, type=None, value=None): 53 | if value is None: 54 | raise ValueError("formatter: value not set") 55 | if type is None: 56 | raise ValueError("formatter: type not set") 57 | 58 | description = " [%s]" % self.enum.get(value, "?") 59 | 60 | return self.fmt.format(type, value) + description 61 | 62 | def get(self, val): 63 | return self.enum.get(val) 64 | 65 | CHANNEL_FORMATS = { 66 | (1 << 0): "FL", # front left 67 | (1 << 1): "FR", # front right 68 | (1 << 2): "FC", # front center 69 | (1 << 3): "LFE", # low frequency effects 70 | (1 << 4): "BL", # back left 71 | (1 << 5): "BR", # back right 72 | (1 << 6): "FLC", # front left center 73 | (1 << 7): "FRC", # front right center 74 | (1 << 8): "BC", # back center 75 | (1 << 9): "SL", # side left 76 | (1 << 10): "SR", # side right 77 | 78 | (1 << 11): "TC", # top center 79 | (1 << 12): "TFL", # top front left 80 | (1 << 13): "TFC", # top front center 81 | (1 << 14): "TFR", # top front right 82 | (1 << 15): "TBL", # top back left 83 | (1 << 16): "TBC", # top back center 84 | (1 << 17): "TBR", # top back left 85 | } 86 | 87 | class FormatterChannelConfig(object): 88 | def __init__(self): 89 | self.fmt = FormatterHex() 90 | 91 | def format(self, type=None, value=None): 92 | if value is None: 93 | raise ValueError("formatter: value not set") 94 | #if type is None: 95 | # raise ValueError("formatter: type not set") 96 | 97 | mapping = "" 98 | for i in range(0, 32): 99 | bitmask = (1< "*+3.0db" 68 | try: 69 | # use dB for easier mixing with Wwise's values 70 | for text in ['*','auto']: 71 | if volume.startswith(text): 72 | master_db = 0.0 73 | auto = True 74 | # allow *+3.0db = auto volume then adds 3.0db 75 | volume = volume[len(text):] 76 | break 77 | 78 | if not volume: #in case of auto volume 79 | pass 80 | 81 | elif volume.lower().endswith('db'): 82 | master_db = float(volume[:-2]) 83 | 84 | else: 85 | if volume.lower().endswith('%'): 86 | master_db = float(volume[:-1]) / 100.0 87 | else: 88 | master_db = float(volume) 89 | if master_db <= 0: #fails next formula, maybe should print something? 90 | return 91 | master_db = VOLUME_PERCENT_TO_DB.get(master_db, math.log10(master_db) * 20.0) 92 | 93 | self.volume_master = master_db 94 | self.volume_master_auto = auto 95 | except ValueError: #not a float 96 | pass 97 | 98 | if volume and not self.volume_master and not auto: 99 | logging.info("parser: ignored incorrect master volume %s", volume) 100 | -------------------------------------------------------------------------------- /wwiser/generator/txtp/wtxtp_debug.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | # Utils 4 | 5 | class TxtpDebug(object): 6 | def __init__(self): 7 | pass 8 | 9 | #-------------------------------------------------------------------------- 10 | 11 | # simplifies tree to simulate some Wwise features with TXTP 12 | def print(self, tree, pre, post): 13 | if pre and not post: 14 | logging.info("*** tree pre:") 15 | if not pre and post: 16 | logging.info("*** tree post:") 17 | if pre and post: 18 | logging.info("*** tree:") 19 | 20 | self._mdepth = 0 21 | self._print_tree(tree, pre, post) 22 | 23 | logging.info("") 24 | 25 | return 26 | 27 | 28 | def _print_tree(self, tnode, pre, post): 29 | line1 = '' 30 | line2 = '' 31 | config1 = '' 32 | config2 = '' 33 | 34 | if post: 35 | if tnode.loop is not None: config1 += " lpn=%s" % (tnode.loop) 36 | if tnode.volume: config1 += " vol=%s" % (tnode.volume) 37 | if tnode.envelopelist: config1 += " (env)" 38 | if tnode.fake_entry: config1 += " (fke)" 39 | if tnode.ignorable(): config1 += " [i]" 40 | 41 | if tnode.body_time: config2 += ' bt={0:.5f}'.format(tnode.body_time) 42 | if tnode.pad_begin: config2 += ' pb={0:.5f}'.format(tnode.pad_begin) 43 | if tnode.trim_begin: config2 += ' tb={0:.5f}'.format(tnode.trim_begin) 44 | if tnode.trim_end: config2 += ' te={0:.5f}'.format(tnode.trim_end) 45 | if tnode.pad_end: config2 += ' pb={0:.5f}'.format(tnode.pad_end) 46 | 47 | if pre: 48 | if tnode.config.loop is not None: config1 += " lpc=%s" % (tnode.config.loop) 49 | if tnode.config.delay: config1 += " dly=%s" % (tnode.config.delay) 50 | if tnode.config.gain: config1 += " cgn=%s" % (tnode.config.gain) 51 | if tnode.config.playevent: config1 += " (pev)" 52 | if tnode.config.rules: config1 += " (rules)" 53 | #if tnode.envelopelist: config1 += " (env)" 54 | 55 | if tnode.config.entry or tnode.config.exit: 56 | dur = '{0:.5f}'.format(tnode.config.duration) 57 | ent = '{0:.5f}'.format(tnode.config.entry) 58 | exi = '{0:.5f}'.format(tnode.config.exit) 59 | config2 += " (dur=%s, entry=%s, exit=%s)" % (dur, ent, exi) 60 | 61 | if tnode.sound and tnode.sound.clip: 62 | fsd = '{0:.5f}'.format(tnode.sound.fsd) 63 | fpa = '{0:.5f}'.format(tnode.sound.fpa) 64 | fbt = '{0:.5f}'.format(tnode.sound.fbt) 65 | fet = '{0:.5f}'.format(tnode.sound.fet) 66 | config2 += " (fsd=%s, fpa=%s, fbt=%s, fet=%s)" % (fsd, fpa, fbt, fet) 67 | 68 | if tnode.is_sound(): 69 | tid = None 70 | if tnode.sound.source: 71 | tid = tnode.sound.source.tid 72 | line1 += "%s %s" % (tnode.type, tid) 73 | line1 += config1 74 | line2 += config2 75 | else: 76 | line1 += "%s%i" % (tnode.type, len(tnode.children)) 77 | line1 += config1 78 | line2 += config2 79 | 80 | logging.info("%s%s", ' ' * self._mdepth, line1) 81 | if line2: 82 | logging.info("%s%s", ' ' * self._mdepth, line2) 83 | 84 | 85 | self._mdepth += 1 86 | for subtnode in tnode.children: 87 | self._print_tree(subtnode, pre, post) 88 | self._mdepth -= 1 89 | -------------------------------------------------------------------------------- /wwiser/generator/render/rnode_base.py: -------------------------------------------------------------------------------- 1 | from . import wbuilder_util, wproperties 2 | 3 | 4 | # common for all renderer nodes (rnode) 5 | class RN_CAkHircNode(object): 6 | def __init__(self): 7 | #no params since changing constructors is a pain, uses init_x below 8 | pass 9 | 10 | def init_renderer(self, renderer): 11 | self._renderer = renderer 12 | self._builder = renderer._builder 13 | self._filter = renderer._filter 14 | self._ws = renderer._ws 15 | 16 | #-------------------------------------------------------------------------- 17 | 18 | def _register_transitions(self, rules): 19 | # could do only default/everything path, but may be needed when passing manual combos 20 | #if self._ws.gs_registrable(): 21 | # return 22 | 23 | self._ws.transitions.add(rules) 24 | return 25 | 26 | def _register_stingers(self, stingerlist): 27 | # could do only default/everything path, but may be needed when passing manual combos 28 | if self._ws.gs_registrable(): 29 | return 30 | 31 | self._ws.stingers.add(stingerlist) 32 | return 33 | 34 | #-------------------------------------------------------------------------- 35 | 36 | # Get final prop values, that depend on wwise's state. Some object shouldn't have them (events) 37 | # or limited types (actions), but can be used as a generic container. 38 | 39 | def _calculate_config(self, bnode, txtp): 40 | 41 | # will also detect and register RTPCs and statechunks 42 | calculator = wproperties.PropertyCalculator(self._ws, bnode, txtp) 43 | config = calculator.get_properties() 44 | 45 | return config 46 | 47 | #-------------------------------------------------------------------------- 48 | 49 | def _barf(self, text="not implemented"): 50 | raise ValueError("%s - %s %s" % (text, self.name, self.sid)) 51 | 52 | def _render_base(self, bnode, txtp): 53 | try: 54 | txtp.info.next(bnode.node, bnode.fields, nsid=bnode.nsid) 55 | self._render_txtp(bnode, txtp) 56 | txtp.info.done() 57 | except Exception: #as e #autochained 58 | raise ValueError("Error processing TXTP for node %i" % (bnode.sid)) #from e 59 | 60 | def _render_txtp(self, bnode, txtp): 61 | self._barf("must implement") 62 | 63 | def _render_next_event(self, ntid, txtp): 64 | self._render_next(ntid, txtp, idtype=wbuilder_util.IDTYPE_EVENT, nbankid=None) 65 | 66 | def _render_next(self, ntid, txtp, idtype=None, nbankid=None): 67 | tid = ntid.value() 68 | if tid == 0: 69 | #this is fairly common in switches, that may define all combos but some nodes don't point to anything 70 | return 71 | 72 | if nbankid: 73 | # play actions reference bank by id (plus may save bankname in STID) 74 | bank_id = nbankid.value() 75 | else: 76 | # try same bank as node 77 | bank_id = ntid.get_root().get_id() 78 | 79 | builder = self._builder 80 | bnode = builder._get_bnode(bank_id, tid, idtype, nbankid_target=nbankid) 81 | if not bnode: 82 | return 83 | 84 | # filter HIRC nodes (for example drop unwanted calls to layered ActionPlay) 85 | filter = self._filter 86 | if filter and filter.active: 87 | generate = filter.allow_inner(bnode.node, bnode.nsid, bnode=bnode) 88 | if not generate: 89 | return 90 | 91 | #logging.debug("next: %s %s > %s", self.node.get_name(), self.sid, tid) 92 | rnode = self._renderer._get_rnode(bnode) 93 | rnode._render_base(bnode, txtp) 94 | return 95 | -------------------------------------------------------------------------------- /wwiser/viewer/wmarkdown.py: -------------------------------------------------------------------------------- 1 | 2 | #ugly markdown-to-html converter, don't do this at home 3 | # doesn't support urls, lists-in-lists or more complex stuff 4 | 5 | class Markdown(object): 6 | 7 | def convert(self, text): 8 | mlines = text.splitlines() 9 | lines = [] 10 | 11 | 12 | is_p = False 13 | is_ul = False 14 | is_li = False 15 | is_pre = False 16 | is_pre_first = False 17 | 18 | lines.append('
') 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, 'd', 8) 78 | 79 | def d64(self, offset = None): 80 | if self.be: 81 | return self.d64be(offset) 82 | else: 83 | return self.d64le(offset) 84 | 85 | def f32le(self, offset = None): 86 | return self.__read(offset, 'f', 4) 90 | 91 | def f32(self, offset = None): 92 | if self.be: 93 | return self.f32be(offset) 94 | else: 95 | return self.f32le(offset) 96 | 97 | def s64le(self, offset = None): 98 | return self.__read(offset, 'q', 8) 102 | 103 | def u64le(self, offset = None): 104 | return self.__read(offset, 'Q', 8) 108 | 109 | def s64(self, offset = None): 110 | if self.be: 111 | return self.s64be(offset) 112 | else: 113 | return self.s64le(offset) 114 | 115 | def u64(self, offset = None): 116 | if self.be: 117 | return self.u64be(offset) 118 | else: 119 | return self.u64le(offset) 120 | 121 | def s32le(self, offset = None): 122 | return self.__read(offset, 'i', 4) 126 | 127 | def u32le(self, offset = None): 128 | return self.__read(offset, 'I', 4) 132 | 133 | def s32(self, offset = None): 134 | if self.be: 135 | return self.s32be(offset) 136 | else: 137 | return self.s32le(offset) 138 | 139 | def u32(self, offset = None): 140 | if self.be: 141 | return self.u32be(offset) 142 | else: 143 | return self.u32le(offset) 144 | 145 | def s16le(self, offset = None): 146 | return self.__read(offset, 'h', 2) 150 | 151 | def s16(self, offset = None): 152 | if self.be: 153 | return self.s16be(offset) 154 | else: 155 | return self.s16le(offset) 156 | 157 | def u16le(self, offset = None): 158 | return self.__read(offset, 'H', 2) 162 | 163 | def u16(self, offset = None): 164 | if self.be: 165 | return self.u16be(offset) 166 | else: 167 | return self.u16le(offset) 168 | 169 | def s8(self, offset = None): 170 | return self.__read(offset, 'b', 1) 171 | 172 | def u8(self, offset = None): 173 | return self.__read(offset, 'B', 1) 174 | 175 | def str(self, size, offset = None): 176 | return self.__read_string(offset, size) 177 | 178 | def fourcc(self, offset = None): 179 | #as bytes rather than string to avoid failures on bad data 180 | return self.__bytes(offset, 4) 181 | 182 | def gap(self, bytes): 183 | offset_before = self.current() 184 | self.skip(bytes) 185 | offset_after = self.current() 186 | if offset_before + bytes != offset_after or offset_after > self.size: 187 | raise ReaderError("can't skip requested 0x%x bytes at 0x%x" % (bytes, self.current())) 188 | 189 | def seek(self, offset): 190 | self.file.seek(offset, os.SEEK_SET) 191 | 192 | def skip(self, bytes): 193 | self.file.seek(bytes, os.SEEK_CUR) 194 | 195 | def current(self): 196 | return self.file.tell() 197 | 198 | def get_size(self): 199 | return self.size 200 | 201 | def guess_endian32(self, offset): 202 | current = self.file.tell() 203 | var_le = self.u32le(offset) 204 | var_be = self.u32be(offset) 205 | 206 | if var_le > var_be: 207 | self.be = True 208 | else: 209 | self.be = False 210 | self.file.seek(current, os.SEEK_SET) 211 | 212 | def get_endian_big(self): 213 | return self.be 214 | 215 | def set_endian(self, big_endian): 216 | self.be = big_endian 217 | 218 | def is_eof(self): 219 | return self.current() >= self.size 220 | 221 | def get_path(self): 222 | return os.path.dirname(self.file.name) 223 | 224 | def get_filename(self): 225 | return os.path.basename(self.file.name) 226 | 227 | def set_xorpad(self, xorpad): 228 | self._xorpad = xorpad 229 | 230 | class ReaderError(Exception): 231 | def __init__(self, msg): 232 | super(ReaderError, self).__init__(msg) 233 | -------------------------------------------------------------------------------- /wwiser/generator/wlang.py: -------------------------------------------------------------------------------- 1 | import logging, os 2 | 3 | 4 | # bank lang info 5 | 6 | _LANG_IDS_OLD = 122 #<= 7 | 8 | _LANGUAGE_IDS = { 9 | 0x00: "SFX", 10 | 0x01: "Arabic", 11 | 0x02: "Bulgarian", 12 | 0x03: "Chinese(HK)", 13 | 0x04: "Chinese(PRC)", 14 | 0x05: "Chinese(Taiwan)", 15 | 0x06: "Czech", 16 | 0x07: "Danish", 17 | 0x08: "Dutch", 18 | 0x09: "English(Australia)", 19 | 0x0A: "English(India)", 20 | 0x0B: "English(UK)", 21 | 0x0C: "English(US)", 22 | 0x0D: "Finnish", 23 | 0x0E: "French(Canada)", 24 | 0x0F: "French(France)", 25 | 0x10: "German", 26 | 0x11: "Greek", 27 | 0x12: "Hebrew", 28 | 0x13: "Hungarian", 29 | 0x14: "Indonesian", 30 | 0x15: "Italian", 31 | 0x16: "Japanese", 32 | 0x17: "Korean", 33 | 0x18: "Latin", 34 | 0x19: "Norwegian", 35 | 0x1A: "Polish", 36 | 0x1B: "Portuguese(Brazil)", 37 | 0x1C: "Portuguese(Portugal)", 38 | 0x1D: "Romanian", 39 | 0x1E: "Russian", 40 | 0x1F: "Slovenian", 41 | 0x20: "Spanish(Mexico)", 42 | 0x21: "Spanish(Spain)", 43 | 0x22: "Spanish(US)", 44 | 0x23: "Swedish", 45 | 0x24: "Turkish", 46 | 0x25: "Ukrainian", 47 | 0x26: "Vietnamese", 48 | } 49 | 50 | _LANGUAGE_HASHNAMES = { 51 | 393239870: "SFX", 52 | 3254137205: "Arabic", 53 | 4238406668: "Bulgarian", 54 | 218471146: "Chinese(HK)", 55 | 3948448560: "Chinese(PRC)", 56 | 2983963595: "Chinese(Taiwan)", 57 | 877555794: "Czech", 58 | 4072223638: "Danish", 59 | 353026313: "Dutch", 60 | 144167294: "English(Australia)", 61 | 1103735775: "English(India)", 62 | 550298558: "English(UK)", 63 | 684519430: "English(US)", 64 | 50748638: "Finnish", 65 | 1024389618: "French(Canada)", 66 | 323458483: "French(France)", 67 | 4290373403: "German", 68 | 4147287991: "Greek", 69 | 919142012: "Hebrew", 70 | 370126848: "Hungarian", 71 | 1076167009: "Indonesian", 72 | 1238911111: "Italian", 73 | 2008704848: "Japanese", 74 | 4224429355: "Japanese(JP)", 75 | 3391026937: "Korean", 76 | 3647200089: "Latin", 77 | 701323259: "Norwegian", 78 | 559547786: "Polish", 79 | 960403217: "Portuguese(Brazil)", 80 | 3928554441: "Portuguese(Portugal)", 81 | 4111048996: "Romanian", 82 | 2577776572: "Russian", 83 | 3484397090: "Slovenian", 84 | 3671217401: "Spanish(Mexico)", 85 | 235381821: "Spanish(Spain)", 86 | 4148950150: "Spanish(US)", 87 | 771234336: "Swedish", 88 | 4036333791: "Turkish", 89 | 4065424201: "Ukrainian", 90 | 2847887552: "Vietnamese", 91 | 92 | # derived just in case, some seen in games 93 | 3383237639: "English", 94 | 3133094709: "French", 95 | 4039628935: "Spanish", 96 | 577468018: "Portuguese", 97 | 1016554174: "Chinese", 98 | } 99 | 100 | # common alt names to simplify usage 101 | _LANGUAGE_ALTS = { 102 | 'us': 'en', 103 | 'jp': 'ja', 104 | } 105 | 106 | # list also used to sort names in printed info, defaults to most common ones 107 | _LANGUAGE_SHORTNAMES = { 108 | "SFX": 'sfx', 109 | 110 | "English": 'en', 111 | "English(US)": 'en', #en-us 112 | "English(UK)": 'uk', #en-gb 113 | "Japanese": 'ja', 114 | "Japanese(JP)": 'ja', 115 | 116 | "Arabic": 'ar', 117 | "Bulgarian": 'bg', 118 | "Chinese": 'zh', 119 | "Chinese(HK)": 'zh-hk', 120 | "Chinese(PRC)": 'zh-cn', 121 | "Chinese(Taiwan)": 'zh-tw', 122 | "Czech": 'cs', 123 | "Danish": 'da', 124 | "Dutch": 'nl', 125 | "English(Australia)": 'en-au', 126 | "English(India)": 'en-in', #? 127 | "Finnish": 'fi', 128 | "French": 'fr', 129 | "French(Canada)": 'fr-ca', 130 | "French(France)": 'fr', 131 | "German": 'de', 132 | "Greek": 'el', 133 | "Hebrew": 'he', 134 | "Hungarian": 'hu', 135 | "Indonesian": 'id', 136 | "Italian": 'it', 137 | "Korean": 'ko', 138 | "Norwegian": 'no', 139 | "Polish": 'pl', 140 | "Portuguese": 'pt', 141 | "Portuguese(Brazil)": 'pt-br', 142 | "Portuguese(Portugal)": 'pt', 143 | "Romanian": 'ro', 144 | "Russian": 'ru', 145 | "Slovenian": 'sl', 146 | "Spanish": 'es', 147 | "Spanish(Mexico)": 'es-mx', 148 | "Spanish(Spain)": 'es', 149 | "Spanish(US)": 'es-us', 150 | "Swedish": 'sv', 151 | "Turkish": 'tr', 152 | "Ukrainian": 'uk', 153 | "Vietnamese": 'vi', 154 | 155 | "Latin": 'la', #what (used in SM:WoS for placeholder voices, that are reversed audio of misc voices) 156 | } 157 | 158 | _LANGUAGES_ORDER = list(_LANGUAGE_SHORTNAMES.keys()) 159 | 160 | class Lang(object): 161 | def __init__(self, node): 162 | self._node = node 163 | 164 | self.shortname = None 165 | self.fullname = None 166 | self._load() 167 | 168 | 169 | def _load(self): 170 | nroot = self._node.get_root() 171 | nlangid = nroot.find1(name='BankHeader').find1(name='dwLanguageID') 172 | version = nroot.get_version() 173 | 174 | lang_value = nlangid.value() 175 | if version <= _LANG_IDS_OLD: #set of values 176 | lang_name = _LANGUAGE_IDS.get(lang_value) 177 | else: #set of hashed names 178 | # typical values but languages can be anything (redefined in project options) 179 | lang_name = _LANGUAGE_HASHNAMES.get(lang_value) 180 | if not lang_name: #try loaded names (ex. Xenoblade DE uses "en" and "jp") 181 | lang_name = nlangid.get_attr('hashname') 182 | 183 | if not lang_name: 184 | lang_name = "%s" % (lang_value) 185 | 186 | lang_short = _LANGUAGE_SHORTNAMES.get(lang_name, lang_name) 187 | #if lang_short == 'sfx': 188 | # lang_short = '' 189 | self.shortname = lang_short 190 | 191 | #if lang_name == 'SFX': 192 | # lang_name = '' 193 | self.fullname = lang_name 194 | 195 | # checks if current bank's lang matches expected bank (ex. "en" only matches if current bnk is the English(US) lang) 196 | # SFX/default languages always match (always allowed) 197 | def matches(self, lang): 198 | if not lang: 199 | return True 200 | if not self.fullname or self.fullname.lower() == 'sfx': #sfx 201 | return True 202 | # can pass allowed lang "sfx", meaning any localized banks are ignored 203 | lang = lang.lower() 204 | #if lang == 'sfx': 205 | # lang = '' 206 | if lang in _LANGUAGE_ALTS: #simplify... 207 | lang = _LANGUAGE_ALTS[lang] 208 | 209 | # allow full name "English(US)" or "en" 210 | return self.fullname.lower() == lang or self.shortname.lower() == lang 211 | 212 | 213 | def _sorter(elem): 214 | try: 215 | fullname = elem[0] 216 | return _LANGUAGES_ORDER.index(fullname) 217 | except: 218 | return 999 219 | 220 | # makes a lang list 221 | class Langs(object): 222 | def __init__(self, banks, localized_only=False): 223 | self.items = [] 224 | self._localized_only = localized_only 225 | self._load(banks) 226 | 227 | def _load(self, banks): 228 | items = [] 229 | for bank in banks: 230 | lang = Lang(bank) 231 | 232 | # todo improve 233 | if self._localized_only and lang.fullname == 'SFX': 234 | continue 235 | 236 | key = (lang.fullname, lang.shortname) 237 | if key not in items: 238 | items.append(key) 239 | 240 | items.sort(key=_sorter) 241 | self.items = items 242 | -------------------------------------------------------------------------------- /wwiser/generator/render/bnode_tree.py: -------------------------------------------------------------------------------- 1 | from ..registry import wparams 2 | 3 | 4 | # Handles tree with multi gamesync (order of gamesyncs is branch order in tree) 5 | # 6 | # tree's args (gamesync key) are given in Arguments, and possible values in AkDecisionTree, that contains 7 | # 'pNodes' with 'Node', that have keys (gamesync value) and children or audioNodeId: 8 | # Arguments 9 | # bgm 10 | # scene 11 | # 12 | # AkDecisionTree 13 | # key=* 14 | # key=bgm001 15 | # key=scene001 16 | # audioNodeId=123456789 17 | # key=* 18 | # key=* 19 | # audioNodeId=234567890 20 | # 21 | # Thus: (-=*, bgm=bgm001, scene=scene001 > 123456789) or (-=*, bgm=*, scene=* > 234567890). 22 | # Paths must be unique (can't point to different IDs). 23 | # 24 | # Wwise picks paths depending on mode: 25 | # - "best match": (default) selects "paths with least amount of wildcards" (meaning favors matching values) 26 | # - "weighted": random based on based on "weight" (0=never, 100=always) 27 | # For example: 28 | # (bgm=bgm001, scene=*, subscene=001) vs (bgm=*, scene=scene001, subscene=*) picks the later (less *) 29 | # 30 | # This behaves like "best match", but saves GS values as "*" (that shouldn't be possible) 31 | # 32 | # Trees always start with a implicit "*" key that matches anything, so it's possible 33 | # to have trees with no arguments that point to an audioNodeId = non-switch tree 34 | 35 | 36 | class AkDecisionTree(object): 37 | def __init__(self, node): 38 | self.init = False 39 | self.args = [] 40 | self.paths = [] 41 | self.tree = {} 42 | 43 | self._build(node) 44 | 45 | def _build(self, node): 46 | ntree = node.find(name='AkDecisionTree') 47 | if not ntree: 48 | return 49 | self.init = True 50 | 51 | # args has gamesync type+names, and tree "key" is value (where 0=any) 52 | depth = node.find1(name='uTreeDepth').value() 53 | nargs = node.finds(name='AkGameSync') 54 | if depth != len(nargs): #not possible? 55 | self._barf(text="tree depth and args don't match") 56 | 57 | self.args = [] 58 | for narg in nargs: 59 | ngtype = narg.find(name='eGroupType') 60 | ngname = narg.find(name='ulGroup') 61 | if ngtype: 62 | gtype = ngtype.value() 63 | else: #DialogueEvent in older versions, assumed default 64 | gtype = wparams.TYPE_STATE 65 | self.args.append( (gtype, ngname) ) 66 | 67 | # make a tree for access, plus a path list (similar to how the editor shows them) for GS combos 68 | # - [val1] = { 69 | # [*] = { 12345 }, 70 | # [val2] = { 71 | # [val3] = { ... } 72 | # } 73 | # } 74 | # - [(gtype1, ngname1, ngvalue1), (gtype2, ngname2, ngvalue2), ...] > ntid (xN) 75 | gamesyncs = [None] * len(nargs) #temp list 76 | 77 | nnodes = ntree.find1(name='pNodes') #always 78 | nnode = nnodes.find1(name='Node') #always 79 | npnodes = nnode.find1(name='pNodes') #may be empty 80 | if npnodes: 81 | self._build_tree_nodes(self.tree, 0, npnodes, gamesyncs) 82 | elif nnode: 83 | # In rare cases may only contain one node for key 0, no depth (NMH3). This can be added 84 | # as a "generic path" with no vars selected, meaning ignores vars and matches 1 object. 85 | self.ntid = nnode.find1(name='audioNodeId') 86 | 87 | 88 | def _build_tree_nodes(self, tree, depth, npnodes, gamesyncs): 89 | if depth >= len(self.args): 90 | self._barf(text="wrong depth") #shouldn't happen 91 | 92 | if not npnodes: #short branch? 93 | return 94 | nchildren = npnodes.get_children() #parser shouldn't make empty pnodes 95 | if not nchildren: 96 | return 97 | 98 | gtype, ngname = self.args[depth] 99 | 100 | for nnode in nchildren: 101 | ngvalue = nnode.find1(name='key') 102 | npnodes = nnode.find1(name='pNodes') 103 | gamesyncs[depth] = (gtype, ngname, ngvalue) #overwrite per node, will be copied 104 | 105 | key = ngvalue.value() 106 | 107 | if not npnodes: #depth + 1 == len(self.args): #not always correct 108 | ntid = nnode.find1(name='audioNodeId') 109 | tree[key] = (ngvalue, ntid, None) 110 | self._build_tree_leaf(ntid, ngvalue, gamesyncs) 111 | 112 | else: 113 | subtree = {} 114 | tree[key] = (ngvalue, None, subtree) 115 | self._build_tree_nodes(subtree, depth + 1, npnodes, gamesyncs) 116 | return 117 | 118 | def _build_tree_leaf(self, ntid, ngvalue, gamesyncs): 119 | # clone list of gamesyncs and final ntid (both lists as an optimization for huge trees) 120 | path = [] 121 | for gamesync in gamesyncs: 122 | if gamesync is None: #smaller path, rare 123 | break 124 | gtype, ngname, ngvalue = gamesync 125 | path.append( (gtype, ngname.value(), ngvalue.value()) ) 126 | self.paths.append( (path, ntid) ) 127 | 128 | return 129 | 130 | # find gamesyncs matches in path, ex. 131 | # - defined: 132 | # bgm=m01,bgm_vo=on > 123 133 | # bgm=m02,bgm_vo=on > 345 134 | # bgm=* ,bgm_vo=off > 789 135 | # - paths 136 | # - (bgm=m01,bgm_vo=on ): gets 123 (direct match) 137 | # - (bgm=m01,bgm_vo=off): gets 789 (tries bgm=m01 but can't find bgm_vo=off, then tries bgm=* and matches bgm_vo=off) 138 | # Each part (bgm > bgm_vo) is defined in "args" at index N 139 | def get_npath(self, params): 140 | # Follow tree up to some match (recursive since it may need to re-try using other branches) 141 | # Result is [paths..] and should fill ntid, or set None 142 | 143 | self._leaf_ntid = None #meh 144 | npath = self._get_npath_sub(params, self.tree, 0) 145 | if not npath or not self._leaf_ntid: 146 | return None 147 | return (npath, self._leaf_ntid) 148 | 149 | # tree should be well formed and stop at some point when no match is found 150 | def _get_npath_sub(self, params, tree, args_index): 151 | if not tree: #args_index >= len(self.args): 152 | return None 153 | 154 | # current arg + params must be defined to some value 155 | gtype, ngname = self.args[args_index] 156 | 157 | gvalue = params.current(gtype, ngname.value()) 158 | if gvalue is None: #not found in params = can't match 159 | return None 160 | 161 | npath = self._get_npath_submatch(params, tree, args_index, gvalue) # exact match 162 | if not npath: 163 | npath = self._get_npath_submatch(params, tree, args_index, 0) # * match 164 | return npath 165 | 166 | def _get_npath_submatch(self, params, tree, args_index, gvalue): 167 | gtype, ngname = self.args[args_index] 168 | 169 | match = tree.get(gvalue) 170 | if not match: 171 | return None 172 | ngvalue, ntid, subtree = match 173 | 174 | npath = [(gtype, ngname, ngvalue)] #note paths are a list, combined on return 175 | if ntid: #leaf 176 | self._leaf_ntid = ntid 177 | return npath 178 | 179 | # next depth 180 | subnpath = self._get_npath_sub(params, subtree, args_index + 1) 181 | if subnpath: 182 | return npath + subnpath 183 | else: 184 | return None 185 | -------------------------------------------------------------------------------- /wwiser/tools/wcleaner_unwanted.py: -------------------------------------------------------------------------------- 1 | import os, logging, re, glob 2 | 3 | 4 | # output folder is the same as original but using a extra mark 5 | # /blah/blah > /blah/blah[unwanted] 6 | # it's done that way since a subdir (/blah/blah/[unwanted]) would be re-included when loading *.bnk in subdirs 7 | # in the rare case of moving in root, would probably throw an error (whatevs) 8 | _OUTPUT_FOLDER_MARK = '[wwiser-unwanted]' 9 | _IS_TEST = False 10 | _IS_TEST_DIR = False 11 | 12 | # moves .wem/bnk to extra folders 13 | # - load all existing .wem/bnk from root-path 14 | # - load .txtp in txtp-path 15 | # - remove used files from existing 16 | # - move remaining files from (root)/(path) to (root-new)/(path) 17 | # 18 | # (maybe should have a .zip option but that doesn't let you check bgm files not in txtp) 19 | 20 | # catch folder-like parts followed by name + extension 21 | _FILE_PATTERN = re.compile(r"^[ ]*[?]*[ ]*([0-9a-zA-Z()\[\]_\- \\/\.]*[0-9a-zA-Z_]+\.[0-9a-zA-Z_]+).*$") 22 | # catch comment with .bnk used to generate current .txtp 23 | _BANK_PATTERN = re.compile(r"^#[ ]*-[ ]*([0-9a-zA-Z()\[\]_\- \\/\.]*[0-9a-zA-Z_]+\.bnk).*$") 24 | _UNREACHABLE_PATTERN = re.compile(r"^[ ]*[?]*[ ]*#[ ]*([0-9a-zA-Z()\[\]_\- \\/\.]*[0-9a-zA-Z_]+\.[0-9a-zA-Z_]+).*$") 25 | _VALID_EXTS = ['.wem', '.bnk', '.xma', '.ogg', '.wav', '.logg', '.lwav'] 26 | 27 | class CleanerUnwanted(object): 28 | def __init__(self, locator): 29 | self._locator = locator 30 | self._files_used = set() 31 | self._files_move = set() 32 | self._moved = 0 33 | self._errors = 0 34 | self._root_orig = None 35 | self._root_move = None 36 | self._dirs_moved = set() 37 | 38 | def process(self): 39 | self._prepare() 40 | logging.info("cleaner: moving unwanted files to %s", self._root_move) 41 | 42 | self._parse_txtps() 43 | if not self._files_used: 44 | logging.info("cleaner: no txtp or referenced files found") 45 | return 46 | 47 | self._parse_files() 48 | if not self._files_move: 49 | logging.info("cleaner: no unwanted files to move") 50 | return 51 | 52 | # avoid odd cases of moving things outside root to root 53 | for file in self._files_move: 54 | if not file.startswith(self._root_orig): 55 | logging.info("cleaner: referenced files outside base folder, make sure base folder is loaded first") 56 | return 57 | 58 | 59 | self._move_files() 60 | self._clean_dirs() 61 | 62 | logging.info("cleaner: moved %s files (%s errors)", self._moved, self._errors) 63 | if self._moved: 64 | logging.info(" * make sure files are really unwanted before removing them") 65 | 66 | def _prepare(self): 67 | root = self._locator.get_root_fullpath() 68 | outpath = root[0:-1] + _OUTPUT_FOLDER_MARK + '/' 69 | root = os.path.abspath(root) 70 | self._root_orig = root 71 | self._root_move = outpath 72 | 73 | 74 | def _parse_txtps(self): 75 | base_root = self._locator.get_root_fullpath() 76 | txtp_root = self._locator.get_txtp_rootpath() 77 | 78 | try: 79 | #for char, repl in [('[','\['), ('[','\[')]: 80 | txtp_root = glob.escape(txtp_root) 81 | subpath = os.path.join(txtp_root, '**/*.txtp') 82 | filenames = glob.glob(subpath, recursive=True) 83 | except: 84 | return 85 | 86 | logging.info(" * reading from to %s txtp", len(filenames)) 87 | for filename in filenames: 88 | with open(filename, 'r', encoding='utf-8-sig') as infile: 89 | for line in infile: 90 | extra_bank = False 91 | if '#unreachable' in line: 92 | match = _UNREACHABLE_PATTERN.match(line) 93 | elif line.startswith('#'): 94 | match = _BANK_PATTERN.match(line) 95 | extra_bank = True 96 | else: 97 | match = _FILE_PATTERN.match(line) 98 | 99 | if not match: 100 | continue 101 | name, = match.groups() 102 | vals = os.path.splitext(name) 103 | if len(vals) != 2 or vals[1].lower() not in _VALID_EXTS: 104 | continue 105 | 106 | file = name.replace('\\', '/') 107 | 108 | txtp_subdir = os.path.dirname(filename) 109 | #file = os.path.normpath(name) 110 | #file = os.path.normcase(file) 111 | #path = os.path.dirname(file) 112 | if extra_bank: 113 | filepath = os.path.join(base_root, file) #relative to root dir 114 | else: 115 | filepath = os.path.join(txtp_subdir, file) #relative to current txtp 116 | filepath = os.path.abspath(filepath) 117 | self._files_used.add(filepath) 118 | #self._folders_used.add(path) 119 | 120 | #if extra_bank: 121 | # print(filepath) 122 | 123 | def _parse_files(self): 124 | root = self._locator.get_root_fullpath() 125 | files = self._locator.get_files() #.wem and .bnk only 126 | for file in files: 127 | filepath = file 128 | 129 | if not file.startswith(root): #for GUI 130 | filepath = os.path.join(root, filepath) 131 | filepath = os.path.abspath(filepath) 132 | 133 | if filepath in self._files_used: 134 | continue 135 | 136 | self._files_move.add(filepath) 137 | 138 | def _move_files(self): 139 | for file in self._files_move: 140 | bn = os.path.basename(file) 141 | _, ext = os.path.splitext(bn) 142 | 143 | ignore = False 144 | for bnk in ['init.bnk', '1355168291.bnk']: 145 | if bnk in bn.lower(): 146 | ignore = True 147 | break 148 | if ignore: 149 | continue 150 | 151 | if ext == '.bnk' or ext == '.BNK': 152 | move_exts = ['.bnk', '.txt', '.xml','.json'] 153 | if ext == '.BNK': #probably unneeded but... 154 | move_exts = [ item.upper() for item in move_exts] 155 | 156 | # move .bnk + companion files if any 157 | for move_ext in move_exts: 158 | file_tmp = file.replace(ext, move_ext) 159 | self._move_file(file_tmp) 160 | else: 161 | self._move_file(file) 162 | 163 | 164 | def _move_file(self, file): 165 | if not os.path.isfile(file): 166 | return 167 | 168 | #file = os.path.normpath(name) 169 | 170 | root = self._root_orig 171 | outpath = self._root_move 172 | if not file.startswith(root): 173 | raise ValueError("unexpected path", file) 174 | 175 | file_move = outpath + file[len(root) :] 176 | file_move = os.path.normpath(file_move) 177 | 178 | dir_move = os.path.dirname(file) 179 | self._dirs_moved.add(dir_move) 180 | 181 | if _IS_TEST: 182 | print("move: ", file) 183 | print(" ", file_move) 184 | self._moved += 1 185 | return 186 | 187 | try: 188 | os.makedirs(os.path.dirname(file_move), exist_ok=True) 189 | os.rename(file, file_move) 190 | self._moved += 1 191 | except: 192 | self._errors += 1 193 | 194 | def _clean_dirs(self): 195 | 196 | dirs = list(self._dirs_moved) 197 | dirs.reverse() #in case of subdirs this (probably) should remove them correctly 198 | 199 | for dir in dirs: 200 | if not os.path.isdir(dir): 201 | logging.warning("cleaner: not a dir? %s", dir) 202 | continue 203 | items = os.listdir(dir) 204 | if items: 205 | continue 206 | 207 | if _IS_TEST_DIR: 208 | print("remove dir:", dir) 209 | continue 210 | 211 | try: 212 | os.rmdir(dir) 213 | except: 214 | logging.warning("cleaner: dir error? %s", dir) 215 | --------------------------------------------------------------------------------