diff --git a/antora.yml b/antora.yml index f1568a0e..233b1ff3 100644 --- a/antora.yml +++ b/antora.yml @@ -8,9 +8,13 @@ asciidoc: # Custom text replacements. Built-in ones can be found here: # https://github.com/asciidoctor/asciidoctor/blob/917d3800a08a8f283a8d05beb08bb75af1673de5/lib/asciidoctor.rb#L391 attributes: + # Increase max table of content nesting levels from 2 to 3 https://docs.antora.org/antora/latest/page/page-attributes/#access-attributes-from-ui-template + page-toclevels: 3@ + # Zero-width joiner (U+200D) to avoid being split across lines + zero-width-join: ‍ # Uses zero-width joiner (U+200D) to avoid being split across lines cpp: C‍+‍+ # Use backslash without it escaping things in other situations literal-backslash: \ # To keep paths from breaking across lines - NoBreakBackslash: ‍\‍ \ No newline at end of file + NoBreakBackslash: ‍\‍ diff --git a/cspell.json b/cspell.json index 11dcb265..1dc77caa 100644 --- a/cspell.json +++ b/cspell.json @@ -109,6 +109,7 @@ "structs", "subfolder", "subfolders", + "toclevels", "Tolgee", "Treelo", "uasset", diff --git a/modules/ROOT/attachments/Development/SatisfactoryAudioRenamer/convert.py b/modules/ROOT/attachments/Development/SatisfactoryAudioRenamer/convert.py index 3164a3f6..ff4b5d38 100644 --- a/modules/ROOT/attachments/Development/SatisfactoryAudioRenamer/convert.py +++ b/modules/ROOT/attachments/Development/SatisfactoryAudioRenamer/convert.py @@ -1,31 +1,242 @@ +# pylint: disable=logging-fstring-interpolation, global-statement, missing-function-docstring, missing-module-docstring, broad-exception-caught import os +import subprocess from pathlib import Path +import shutil +import logging +import sys -def convert(filename): +# Full path to vgmstream-cli.exe, usually in your FModel's Output Directory +# Example Path +VGMSTREAM = Path(r"C:/FModel/Output/.data/vgmstream-cli.exe") - my_file = open("./txtp/" + filename, "r") - data = my_file.read() +# ================================================================================================== - data_into_list = data.replace('\n', ' ').split(" ") +# Logs +MAIN_LOG = "conversion_main.log" +FAILED_LOG = "conversion_errors.log" +# Setup main logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[ + logging.FileHandler(MAIN_LOG, mode="w", encoding="utf-8"), + logging.StreamHandler(), + ], +) - for i in range(len(data_into_list)): - if data_into_list[i].startswith('wem'): - wavname = "./txtp/" + data_into_list[i].split('.')[0] + '.wav' +# Set up subfolders +currentDirectory = Path(os.path.dirname(os.path.abspath(__file__))) +(currentDirectory / "out").mkdir(exist_ok=True) +(currentDirectory / "txtp" / "wem").mkdir(parents=True, exist_ok=True) - if os.path.isfile(wavname): - os.rename(wavname, "./out/" + filename.split('.')[0] + '_' + str(i) + '.wav') +# Check if vgmstream exists +if not VGMSTREAM.exists(): + logging.critical( + f"vgmstream-cli.exe not found at {VGMSTREAM}. It should have been downloaded by FModel. Edit the script if necessary to change the path." + ) + sys.exit(1) +else: + logging.info(f"Using vgmstream-cli.exe at {VGMSTREAM}") + +# Setup failed conversion logging (Will overwrite each run, could be replaced with RotatingFileHandler but needs script changes) +failed_logger = logging.getLogger("failed") +failed_handler = logging.FileHandler(FAILED_LOG, mode="w", encoding="utf-8") +failed_handler.setLevel(logging.ERROR) +failed_logger.addHandler(failed_handler) +failed_logger.propagate = False + +# Counters for summary +total_wems = 0 +converted_count = 0 +skipped_count = 0 +failed_count = 0 + + +# Step 1: Convert all .wem files into ./out_temp/wem/ (flat), mapping to digit folders +def wem_to_wav(input_root, temp_root): + global converted_count, skipped_count, failed_count + input_root = Path(input_root) + temp_wem_root = Path(temp_root) / "wem" + + # CLEAN temp folder + if temp_wem_root.exists(): + shutil.rmtree(temp_wem_root) + temp_wem_root.mkdir(parents=True, exist_ok=True) + + wav_filename_to_digit_folder = {} + + for folder, _, files in os.walk(input_root): + folder_path = Path(folder) + + # If we are in root (txtp/wem) use "root" as folder name + digit_folder = "root" if folder_path == input_root else folder_path.name + + for file in files: + ext = Path(file).suffix.lower() + base_name = Path(file).stem + wav_name = base_name + ".wav" + + wem_path = folder_path / file + wav_path = temp_wem_root / wav_name + wav_filename_to_digit_folder[wav_name] = digit_folder + + final_out_path = Path("out") / digit_folder / wav_name + if wav_path.exists() or final_out_path.exists(): + skipped_count += 1 + logging.info(f"Skipping existing WAV: {wav_path} or {final_out_path}") + continue + + if ext == ".wem": + # Convert wem → wav + logging.info(f"Converting: {wem_path} → {wav_path}") + result = subprocess.run( + [str(VGMSTREAM), "-o", str(wav_path), str(wem_path)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0 or not wav_path.exists(): + failed_count += 1 + logging.error(f"Conversion failed for {wem_path}: {result.stderr}") + failed_logger.error(str(wem_path)) + else: + converted_count += 1 + logging.info(f"Converted {wem_path} successfully") + + elif ext == ".wav": + # Copy pre-existing wav into temp for rename step + try: + shutil.copy2(wem_path, wav_path) + skipped_count += 1 + logging.info( + f"Using existing WAV instead of converting: {wem_path} → {wav_path}" + ) + except Exception as e: + failed_count += 1 + logging.error(f"Failed to copy existing WAV {wem_path}: {e}") + failed_logger.error(str(wem_path)) + return wav_filename_to_digit_folder + + +# Step 2: Rename .wav files based on .txtp references +def convert(filename, wav_root, out_root, mapping): + wav_root = Path(wav_root) + out_root = Path(out_root) + txtp_path = Path("txtp") / filename + + try: + with open(txtp_path, "r", encoding="utf-8") as my_file: + data = my_file.read() + except Exception as e: + logging.error(f"Failed to read {txtp_path}: {e}") + return + + tokens = data.replace("\n", " ").split(" ") + + for i, token in enumerate(tokens): + if token.startswith("wem"): + wav_file_only = Path(token).stem + ".wav" + wavname = wav_root / wav_file_only + digit_folder = mapping.get(wavname.name, "unknown") + out_folder = out_root / digit_folder + out_folder.mkdir(parents=True, exist_ok=True) + new_name = out_folder / f"{filename.split('.')[0]}_{i}.wav" + + if new_name.exists(): + logging.info(f"Skipping already renamed WAV: {new_name}") + continue + + if wavname.exists(): + try: + shutil.move(str(wavname), str(new_name)) + logging.info(f"Renamed {wavname} → {new_name}") + except Exception as e: + logging.error(f"Failed to rename {wavname}: {e}") else: - print(wavname + " not found.") + logging.warning(f"{wavname} not found.") + + +# Step 3: Retry failed conversions +def retry_failed_conversions(temp_wav_root): + global converted_count, failed_count + failed_path = Path(FAILED_LOG) + if not failed_path.exists(): + logging.info("No failed conversions to retry.") + return + + logging.info("Retrying failed conversions...") + + # Read and truncate the failed log for this retry + with open(failed_path, "r+", encoding="utf-8") as f: + failed_files = [line.strip() for line in f.readlines() if line.strip()] + f.seek(0) + f.truncate(0) + + new_failures = 0 # counter for files that fail again + + for wem_path_str in failed_files: + wem_path = Path(wem_path_str) + wav_name = wem_path.stem + ".wav" + wav_path = temp_wav_root / wav_name + + if wav_path.exists(): + logging.info(f"Skipping existing WAV: {wav_path}") + continue + + logging.info(f"Retrying conversion: {wem_path} → {wav_path}") + result = subprocess.run( + [str(VGMSTREAM), "-o", str(wav_path), str(wem_path)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0 or not wav_path.exists(): + new_failures += 1 + logging.error( + f"Conversion failed a 2nd time: {wem_path}. " + "Either the .wem file is corrupt, broken, or there is no .txtp path for that file. " + "Consider a manual approach or ask for help in the Discord." + ) + failed_logger.error(str(wem_path)) + else: + # Count as converted only if it actually succeeds now + converted_count += 1 + logging.info(f"Successfully converted on retry: {wem_path}") + + # Update failed_count to reflect files that truly failed after retry + failed_count = new_failures + + +# Main driver +if __name__ == "__main__": + wem_root = Path("txtp/wem") + wav_temp_root = Path("out_temp") / "wem" + out_root = Path("out") + + logging.info("Starting .wem → .wav conversion") + mapping = wem_to_wav(wem_root, Path("out_temp")) - my_file.close() + logging.info("Starting .wav renaming based on .txtp files") + txtp_files = [f for f in Path("txtp").glob("*.txtp")] + for file_path in txtp_files: + convert(file_path.name, wav_temp_root, out_root, mapping) -relevant_path = "./txtp/" -included_extensions = ['txtp'] -file_names = [fn for fn in os.listdir(relevant_path) - if any(fn.endswith(ext) for ext in included_extensions)] + # Retry any failed conversions + retry_failed_conversions(wav_temp_root) + # Clean up temp folder + if wav_temp_root.parent.exists(): + shutil.rmtree(wav_temp_root.parent) + logging.info(f"Temporary folder {wav_temp_root.parent} deleted") -for file_name in file_names: - convert(file_name) \ No newline at end of file + # Final summary + logging.info("===================================") + logging.info(f"Total .wem files found: {total_wems}") + logging.info(f"Successfully converted: {converted_count}") + logging.info(f"Skipped (already exists): {skipped_count}") + logging.info(f"Failed conversions: {failed_count}") + logging.info("Conversion and renaming complete") + logging.info("===================================") diff --git a/modules/ROOT/pages/Development/ExtractGameFiles.adoc b/modules/ROOT/pages/Development/ExtractGameFiles.adoc index 6e60f495..db6c0e3e 100644 --- a/modules/ROOT/pages/Development/ExtractGameFiles.adoc +++ b/modules/ROOT/pages/Development/ExtractGameFiles.adoc @@ -104,7 +104,7 @@ Press `OK` to save your changes. image:ExtractingGameFiles/FModelModelSettings.png[FModel Model Export Settings] -[WARNING] +[WARNING] ==== Any other changes made are at user discretion, and dependent on level of knowledge. ==== @@ -204,64 +204,84 @@ You may still find the `Events` folder useful for leaning the context of sound e For example, `/Events/World_Events_FilatovD/Environment/Caves/` presumably contains environmental sounds that would play while in caves. +[id="bnk_single"] +==== Extracting a Specific `.bnk` + FModel's link:#_searching_for_files[Package Search functionality] is beneficial for finding specific sounds. Events that begin sound playback follow the naming scheme `Play_something.bnk`, so you can search for `Play something bnk` to find a list of valid sound bnk files containing `something`. Once you have found the bnk you want to extract (for example, `FactoryGame/Content/WwiseAudio/Event/19/Play_EQ_JetPack_Activate.bnk`) -right click on it in FModel's "Packages" tab list and select `Export Raw Data (.uasset)`, -which will export the bnk file despite the tooltip option claiming it will be a uasset. +right click on it in FModel's "Packages" tab list and select `Export Raw Data`. + +Click on the text in the FModel log (ex. `Play_EQ_JetPack_Activate.bnk`) to open your system's file browser to the folder that contains the exported bnk. -Alternatively, you can extract the entire `Event` folder at once -by right clicking on it in the "Folders" tab and selecting `Export Folder's Packages Raw Data (.uasset)`. +[id="bnk_all"] +==== Extracting All Sound `.bnk`{zero-width-join}s -Click on the text -(ex. `Play_EQ_JetPack_Activate.bnk`) -in the FModel log to open your system's file browser to the folder that contains the exported bnk, -or the top-level export folder if you exported the entire `Event` folder. +You can extract all bnk files from the `Event` folder +by right clicking on it in the "Folders" tab and selecting `Export Folder's Packages Raw Data`. + +The directory folder structure will be preserved. +Clicking the text in FModel's log will take you to your FModel Output folder. === Extracting sourceIDs with wwiser +// cspell:ignore txtp +SourceIDs are the unique numbers that associate a bnk file with the sound files it uses. +The tool wwiser can extract metadata, in the form of a `.txtp` file, from a bnk file. + You will need Python installed to utilize wwiser. Python 3.8.10 is known to work, and https://github.com/pyenv-win/pyenv-win[pyenv] is the suggested method of install. Installing python is out of the scope of this guide. -Download the latest copy of wwiser from its https://github.com/bnnm/wwiser/releases[releases page]. // cspell:ignore wwnames -You'll want both the `wwiser.pyz` and `wwnames.db3` files from the release. - -Use a zip extracting program of your choice to extract the files from `wwiser.pyz` -and place the `wwnames.db3` file in the same directory as the extracted files. - -To open the wwiser interface, run `python .\wwiser.py` in a terminal in that folder. -Next, select `Load banks...` and select the bnk file you extracted earlier. - -Check wwiser's log panel before continuing. -If it contains the message `names: couldn't find .\wwnames.db3 name file`, -go back to grab the `wwnames.db3` from the GitHub release and put it in the same folder as the bnk file you opened. - -// cspell:ignore txtp -Next, select `Generate TXTP` which will create a folder in the same directory as the bnk file -containing a txtp file for the event. -// Need the + symbols to make sure Asciidoc doesn't see them as attributes -(ex. `+Play_EQ_JetPack_Activate {s} {m}.txtp+`) - -Open the txtp file in a text editor of your choice. -If the bnk is linked to any sound files -their sourceID numbers will be displayed at the top of the file -(ex. `wem/633850317.wem` has the sourceID number `633850317`) -along with additional audio information. +1. Download the latest copy of wwiser from its https://github.com/bnnm/wwiser/releases[releases page]. + You'll want both the `wwiser.pyz` and `wwnames.db3` files from the release. + If the release doesn't have a `wwnames.db3` file, grab one from a previous release. +2. Use a zip extracting program of your choice to extract the files from `wwiser.pyz` + and place the `wwnames.db3` file in the same directory as the extracted files. +3. Open the wwiser interface by running `python .\wwiser.py` in a terminal in that folder. + +[id="txtp_single"] +==== Generating a Specific `.txtp`{zero-width-join} + +1. In wwiser, click `Load banks...` and select the bnk file you extracted earlier. +2. Check wwiser's log panel before continuing. + If it contains the message `names: couldn't find .\wwnames.db3 name file`, + go back to grab the `wwnames.db3` from the GitHub release and put it in the same folder as the bnk file you opened. +3. Click `Generate TXTP` which will create a `txtp` subfolder in the same directory as the bnk file + containing a txtp file for the event +// Need the + symbols to make sure Asciidoc doesn't see them as attributes + (ex. `+Play_EQ_JetPack_Activate {s} {m}.txtp+`). +4. Open the txtp file in a text editor of your choice. + If the bnk is linked to any sound files + their _sourceID numbers_ will be displayed at the top of the file + (ex. `wem/633850317.wem` has the sourceID number `633850317`) + along with additional audio information. Sound events typically consistent of multiple sounds played at different volumes. Take note of all the sourceIDs of the event as you will likely need to review a few raw sounds to find the exact one you're looking for. +[id="txtp_all"] +==== Generating `.txtp`{zero-width-join}s for All Sounds + +If you extracted the entire Event folder earlier, +use wwiser's `Load dirs...` button instead of `Load banks...` button. +Select the `Event` folder in the FModel export hierarchy. + +The `txtp/` folder will be created inside the `Event` folder. + === Extracting Sound Files Now that we have sourceIDs we can use FModel to locate and extract their corresponding sound files. +[id="wem_single"] +==== Extracting a Specific `.wem` + First, locate the sound file in FModel via its sourceID, which will be its package file name. Using FModel's link:#_searching_for_files[Package Search functionality] is beneficial here. All game audio can be found in subfolders of the pak's (note - NOT the utoc!) `FactoryGame/Content/WwiseAudio/Media/` folder. @@ -287,20 +307,39 @@ https://discord.com/channels/555424930502541343/1036634533077979146/128694206712 if that didn't work (mod developer discord role required to view). ==== +[id="wem_all"] +==== Extracting All Sound `.wem`{zero-width-join}s + +You can also select the entire `Media` folder to extract all sound files to .`wem` files at once. +Right click on it and select `Save Folder's Packages Audio`. + +Be aware though that this method will extract ALL sounds and can consume more than 5 GB of disk space. +If you only need specific sounds, it's recommended to extract them individually. + +[id="BulkAudioRenamer"] === Bulk Audio Renamer -Community member MrCheese has created a python script that enables mass renaming of exported wem files to their associated named bnk files. +Community members MrCheese and Rovetown have created a python script that enables mass renaming of exported wem files to their associated named bnk files. If you decide to extract a large number of sounds, this script can save you a lot of time. -To use it: - -1. Create a folder somewhere named `SatisfactoryAudioRenamer`. -2. Create a subfolder named `out` -3. Create a subfolder named `txtp` -4. link:{attachmentsdir}/Development/SatisfactoryAudioRenamer/convert.py[Download this python file (convert.py)] - and place it in the SatisfactoryAudioRenamer folder -5. Move all the txtp files that wwiser generated earlier to the txtp subfolder - and run `python .\convert.py` from a terminal in that SatisfactoryAudioRenamer folder. +1. Follow the directions in the headings above to obtain the required `.txtp` files from wwiser and `.wem` files from FModel. +2. Create a folder somewhere named `SatisfactoryAudioRenamer`. +3. link:{attachmentsdir}/Development/SatisfactoryAudioRenamer/convert.py[Download this python file (convert.py)] + and place it in the folder. +4. Run the script once (`python .\convert.py`) to verify it can find your vgmstream-cli.exe and create the required subfolders. + Edit the script's definition for `VGMSTREAM` if required to point to your vgmstream-cli.exe location. +5. Move all the txtp files that wwiser generated to the `SatisfactoryAudioRenamer/txtp` subfolder +6. Place the extracted `.wem` files (including their parent `Media` folder if you decided to extract the full folder structure) + into the `SatisfactoryAudioRenamer/txtp/wem` folder. +7. Run `python .\convert.py` from a terminal in the SatisfactoryAudioRenamer folder. +8. You now have the renamed `.wav` files in the `out` subfolder (and in their original folder structure). + +[NOTE] +==== +The script will try and rerun if it encounters any errors. +However, it may not always succeed in properly renaming all files. +This can be due to several reasons, so if you encounter this, feel free to ask for help in the Discord. +==== == Generating a Complete Starter Project