Last active
July 6, 2024 20:39
-
-
Save srhinos/fcbf48a8031c5e61fd2cd29e9a154200 to your computer and use it in GitHub Desktop.
Fallout 76 IconSortingTags Strings Mod Merger Script
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# ############### HOW TO USE THIS FILE ################ | |
# | |
# NOTE: Requires Python 3.9+ and the package `xmltodict` installed. | |
# | |
# 1. Drop this file in the base level directory of the "Tagged Plans - XML for xTranslator" mod | |
# 2. Open xTranslator, load 76's ESP/ESM, and process the IconSortingTag's header rules | |
# - import the fonts to your ba2s too | |
# 3. Export IconSortingTag's changes as an XML translation | |
# - File -> Export Translation -> XML files | |
# - Name file `full_icon_dump.xml` and place in directory of the "Tagged Plans" mod | |
# 4. Run this Python script. | |
# - Script merges IconSortingTag's changes with the Tagged Plans changes as: | |
# `🔫 [Ops] Cool Gun From Daily Ops` | |
# 5. Import merged XML as a translation | |
# - File -> Import Translation -> XML Files (xTranslator) | |
# - Select `out.xml` | |
# - Overwrite should be set to "Everything" | |
# - Mode should be set to "Use FormID References" | |
# 6. Save Strings | |
# - File -> Finalize STRINGS | |
# - Overwrite any existing strings files | |
# 7. Close xTranslator and start game! | |
# | |
# NOTE: This file also supports merging other mods as well: | |
# | |
# --- Tagged Rare Plans and Apparel - https://www.nexusmods.com/fallout76/mods/1409 --- | |
# 1. Download the two optional files called `Apparel Tags` and `Plan Tags by Source` | |
# 2. Extract the "BottomSorted" tagged files from these zip files to the base level directory of the | |
# "Tagged Plans - XML for xTranslator" mod | |
# 3. Follow all original instructions above! | |
# - Beyond merging with the `IconSortingTag` mod, it will also merge tags with the | |
# `Tagged Plans` mod | |
# - This mod will be prioritized over the `Tagged Plans` mod's tags | |
# | |
# --- Quizzless Apalachia - https://www.nexusmods.com/fallout76/mods/305 --- | |
# 0. THIS SHOULD BE DONE LAST | |
# 1. Once you have finalized all other string changes, import the STT file. | |
# - I went thru and validated this doesn't impact anything any of the other strings modify. | |
# - File -> Import Translation -> STT Dictionary | |
# - Overwrite should be set to "Everything" | |
# - Mode should be set to "Use FormID References" | |
# 2. Follow Steps 6 and onward in the topmost set of instructions! | |
# ##################################################### | |
# ############# CONSTANTS - CHANGE THESE ############## | |
# ------------------ IconSortingTag ------------------- | |
XTRANSLATE_ICONS_MOD_DUMP = "full_icon_dump.xml" | |
# -------- Tagged Plans - XML for xTranslator --------- | |
ALL_CATEGORIES_FILE_NAME = "SeventySix_en_en.xml" | |
EXTENDED_TAGS_FILE_NAME = "ExtendedTag_en.xml" | |
SILO_HOLO_NAME = "SiloHolotape_en.xml" | |
# ----------- Tagged Rare Plans and Apparel ----------- | |
RARE_PLANS_FILE_NAME = "Plans_by_Source_BottomSorted.xml" | |
RARE_APPAREL_FILE_NAME = "Apparel_BottomSorted.xml" | |
RARE_META_FILE_NAME = "Meta_BottomSorted.xml" | |
# ##################################################### | |
import logging # noqa | |
import re # noqa | |
from functools import reduce # noqa | |
from pathlib import Path # noqa | |
import xmltodict # noqa | |
logging.basicConfig( | |
level=logging.INFO, | |
format="{asctime} [{levelname}] {message}", | |
style="{", | |
datefmt="%Y-%m-%d %H:%M:%S", | |
) | |
log = logging.getLogger() | |
plan_file = {} | |
recipe_file = {} | |
icon_file = {} | |
renamed_file = {} | |
tagged_replacements = [ | |
# BREWING.xml | |
("#BREWING#", ""), | |
# EVENT.xml | |
("#FASNACHT#", "[Fas]"), | |
("#FASNACHT-24#", "[Fas]"), | |
("#INVADERS#", "[Inv]"), | |
("#GIFTS-19#", "[G19]"), | |
("#GIFTS-20#", "[G20]"), | |
("#GIFTS-21#", "[G21]"), | |
("#GIFTS-22#", "[G22]"), | |
("#GIFTS-23#", "[G23]"), | |
("#GIFTS-24#", "[G24]"), | |
("#HALLOWEEN#", "[HW]"), | |
("#HALLOWEEN-23#", "[HW23]"), | |
("#MOTHMAN#", "[MM]"), | |
("#MW#", ""), | |
("#PAILS#", "[P]"), | |
("#PAILS-20#", "[P20]"), | |
("#PAILS-21#", "[P21]"), | |
("#PAILS-22#", "[P22]"), | |
("#PAILS-23#", "[P23]"), | |
# FREERANGE.xml | |
("#FREE RANGE#", "[FR]"), | |
# GRAHM.xml | |
("#GRAHM#", "[Gr]"), | |
# LEGACY.xml | |
("#LEGACY#", ""), | |
# NW.xml | |
("#NW#", "[NW]"), | |
("#MUT#", "[Mut]"), | |
# RARE.xml | |
("#RARE#", "[$]"), | |
# SCOUTS.xml | |
("#SCOUTS#", "[Scout]"), | |
# UNCOMMON.xml | |
("#UNCOMMON#", ""), | |
# OPS.xml | |
("#OPS#", "[Ops]"), | |
# ExtendedTag_en.xml | |
("#RARE_ITEM#", "[Rare]"), | |
] | |
ph_enabled_tagged_replacements = [ | |
"#INVADERS#", | |
"#GIFTS-19#", | |
"#GIFTS-20#", | |
"#GIFTS-21#", | |
"#GIFTS-23#", | |
"#GIFTS-24#", | |
"#HALLOWEEN#", | |
"#HALLOWEEN-23#", | |
"#MOTHMAN#", | |
"#MW#", | |
"#NW#", | |
"#PAILS#", | |
"#PAILS-20#", | |
"#PAILS-21#", | |
"#PAILS-23#", | |
"#FREE RANGE#", | |
"#GRAHM#", | |
"#OPS#", | |
] | |
plan_hunter_ascii = ["¬", "±", "¤", "¢", "¶", "·"] | |
plan_hunter_replacements = [ | |
# Apparel Tags - Ultra Rare.xml | |
("¬¬¬", "[!!!]"), | |
("¬¬", "[!!]"), | |
# Apparel Tags - Rare.xml | |
("¬", "[$$$]"), | |
# Plan Tags - Colossal Problem.xml | |
# -No replacements- | |
# ("[CP]", "[CP]"), | |
# Plan Tags - Daily Ops.xml | |
("[DOps]", "[Ops]"), | |
# Plan Tags - Encryptid.xml | |
("[ENC]", "[Cryp]"), | |
# Plan Tags - Fasnacht.xml | |
("[FASN]", "[Fas]"), | |
# Plan Tags - Free Range.xml | |
# -No replacements- | |
# ("[FR]", "[FR]"), | |
# Plan Tags - General Rare.xml | |
# -No replacements - handled by Apparel tag above- | |
# ("¬", "[$$$]"), | |
# Plan Tags - General Uncommon Valueable.xml | |
("±", "[$]"), | |
# Plan Tags - Grahm Unique Plans.xml | |
("[GRAHM]", "[Gr]"), | |
# Plan Tags - Holiday Gifts.xml | |
("[GIFTS]", "[G]"), | |
# Plan Tags - Illegal or Unobtainable.xml | |
("¤", "[Hack]"), | |
# Plan Tags - Invaders from Beyond.xml | |
("[ALIENS]", "[Alien]"), | |
# Plan Tags - Meat Week.xml | |
("[MEAT]", "[Meat]"), | |
# Plan Tags - Moleminer Pails.xml | |
("[PAILS]", "[Pail]"), | |
# Plan Tags - Moonshine Jamboree.xml | |
# -No replacements- | |
# ("[MJ]", "[MJ]"), | |
# Plan Tags - Most Wanted.xml | |
("[MW]", "[Wanted]"), | |
# Plan Tags - Mothman Equinox Plans.xml | |
("[MOTH]", "[Moth]"), | |
# Plan Tags - Nuka World on Tour.xml | |
# -No replacements- | |
# ("[NWOT]", "[NWOT]"), | |
# Plan Tags - Pitt Expeditions.xml | |
("[PITT]", "[Pitt]"), | |
# Plan Tags - Project Paradise.xml | |
# -No replacements- | |
# ("[PP]", "[PP]"), | |
# Plan Tags - Radiation Rumble.xml | |
# -No replacements- | |
# ("[RR]", "[RR]"), | |
# Plan Tags - Seismic Activity Plans.xml | |
# -No replacements- | |
# ("[SA]", "[SA]"), | |
# Plan Tags - Spin the Wheel.xml | |
# -No replacements- | |
# ("[StW]", "[StW]"), | |
# Plan Tags - Spooky Treat Bags.xml | |
("[BAGS]", "[HWN]"), | |
# Plan Tags - Test your Metal.xml | |
("[METAL]", "[Metal]"), | |
# Plan Tags - Tunnel of Love.xml | |
# -No replacements- | |
# ("[ToL]", "[ToL]"), | |
# Plan Tags - Untradable - Just Read Them.xml | |
("¢", "[X]"), | |
("¶", ""), | |
("·", "[!]"), | |
] | |
preferred_overrides = [ | |
("[!!!]", "[Rare]"), | |
("[$$$]", "[Rare]"), | |
("[$$$]", "[$]"), | |
] | |
value_strings = ["[$$$]", "[$]", "[Rare]", "[!]", "[!!]", "[!!!]"] | |
if not Path(XTRANSLATE_ICONS_MOD_DUMP).is_file(): | |
log.error("Missing IconSortingTag's Dumped XML") | |
exit() | |
def clean_malformed_xml(malformed_xml): | |
malformed_xml = "\n".join( | |
[line for line in malformed_xml.split("\r\n") if line.strip() != ""] | |
) | |
malformed_xml = re.sub(r"\n([^ <])", r"\\n\g<1>", malformed_xml) | |
malformed_xml = re.sub(r"\n([ ])(?! )", r"\\n\g<1>", malformed_xml) | |
malformed_xml = re.sub(r"\n([ ]{2,2})(?!<| )", r"\\n\g<1>", malformed_xml) | |
malformed_xml = re.sub(r"\n(</Source>)", r"\g<1>", malformed_xml) | |
malformed_xml = re.sub(r"\n( </Source>)", r"\g<1>", malformed_xml) | |
malformed_xml = re.sub(r"\n(</Dest>)", r"\g<1>", malformed_xml) | |
malformed_xml = re.sub(r"\n( </Dest>)", r"\g<1>", malformed_xml) | |
malformed_xml = malformed_xml.replace("\xBE\b", "") | |
return malformed_xml | |
def merge_dictionaries(*dicts): | |
""" | |
Merges multiple dictionaries with lists as values. In case of key collision, merges the lists. | |
Parameters: | |
*dicts (dict): The dictionaries to merge. | |
Returns: | |
dict: The merged dictionary. | |
""" | |
merged_dict = {} | |
# Helper function to add items to the merged dictionary | |
def add_items(source_dict): | |
for key, value in source_dict.items(): | |
if key in merged_dict: | |
merged_dict[key].extend(value) | |
else: | |
merged_dict[key] = value.copy() | |
# Add items from all dictionaries | |
for dictionary in dicts: | |
add_items(dictionary) | |
return merged_dict | |
with open(XTRANSLATE_ICONS_MOD_DUMP, "rb") as r: | |
log.info("Parsing Icons File...") | |
string_file = clean_malformed_xml(r.read().decode("utf-8")) | |
icon_file = xmltodict.parse(string_file, encoding="utf-8") | |
log.info("Done!") | |
with open(SILO_HOLO_NAME, "rb") as r: | |
log.info("Parsing Silo Holo File...") | |
string_file = clean_malformed_xml(r.read().decode("utf-8")) | |
holo_file = xmltodict.parse(string_file, encoding="utf-8")["SSTXMLRessources"][ | |
"Content" | |
] | |
log.info("Done!") | |
if not Path(f"./{ALL_CATEGORIES_FILE_NAME}").is_file(): | |
log.error("ERROR: Missing all categories 'Tagged Plans' file.") | |
exit() | |
with open(ALL_CATEGORIES_FILE_NAME, "rb") as r: | |
log.info("Parsing Tagged Plans' all categories file...") | |
renamed_file = xmltodict.parse(r.read()) | |
log.info("Done!") | |
valid_renames = { | |
item_blob["@sID"]: [re.sub(r"# (.*)", "#", item_blob["Dest"])] | |
for item_blob in renamed_file["SSTXMLRessources"]["Content"]["String"] | |
if item_blob["Dest"] | |
} | |
extended_valid_renames = {} | |
if Path(f"./{EXTENDED_TAGS_FILE_NAME}").is_file(): | |
log.info("Found extended tags file! Parsing file...") | |
with open(EXTENDED_TAGS_FILE_NAME, "rb") as r: | |
extended_renamed_file = xmltodict.parse(r.read()) | |
extended_valid_renames = { | |
item_blob["@sID"]: [re.sub(r"# (.*)", "#RARE_ITEM#", item_blob["Dest"])] | |
for item_blob in extended_renamed_file["SSTXMLRessources"]["Content"]["String"] | |
if item_blob["Dest"] | |
} | |
log.info("Done!") | |
cleaned_renames = merge_dictionaries(valid_renames, extended_valid_renames) | |
def rfind_by_list(query_string, search_list): | |
for search_string in search_list: | |
if query_string.rfind(search_string) > -1: | |
return query_string.rfind(search_string) | |
return -1 | |
plan_hunter_valid_renames = {} | |
if ( | |
Path(f"./{RARE_PLANS_FILE_NAME}").is_file() | |
and Path(f"./{RARE_APPAREL_FILE_NAME}").is_file() | |
and Path(f"./{RARE_META_FILE_NAME}").is_file() | |
): | |
log.info("Found Plan Collector Files! Parsing files...") | |
tagged_replacements = [ | |
( | |
(blob[0], blob[1]) | |
if blob[0] not in ph_enabled_tagged_replacements | |
else (blob[0], "") | |
) | |
for blob in tagged_replacements | |
] | |
plan_hunter_merged_xml = {} | |
with open(RARE_PLANS_FILE_NAME, "rb") as r: | |
plan_hunter_merged_xml = xmltodict.parse(r.read()) | |
with open(RARE_APPAREL_FILE_NAME, "rb") as r: | |
string_list = xmltodict.parse(r.read())["SSTXMLRessources"]["Content"]["String"] | |
string_list = [string_list] if isinstance(string_list, dict) else string_list | |
plan_hunter_merged_xml["SSTXMLRessources"]["Content"]["String"].extend( | |
string_list | |
) | |
with open(RARE_META_FILE_NAME, "rb") as r: | |
string_list = xmltodict.parse(r.read())["SSTXMLRessources"]["Content"]["String"] | |
string_list = [string_list] if isinstance(string_list, dict) else string_list | |
plan_hunter_merged_xml["SSTXMLRessources"]["Content"]["String"].extend( | |
string_list | |
) | |
for item_blob in plan_hunter_merged_xml["SSTXMLRessources"]["Content"]["String"]: | |
if not item_blob["Dest"]: | |
continue | |
if item_blob["@sID"] not in plan_hunter_valid_renames: | |
plan_hunter_valid_renames[item_blob["@sID"]] = [] | |
chunk = ( | |
item_blob["Dest"][: item_blob["Dest"].rfind("]") + 1] | |
if item_blob["Dest"].rfind("]") > 0 | |
else item_blob["Dest"][ | |
: rfind_by_list(item_blob["Dest"], plan_hunter_ascii) + 1 | |
] | |
) | |
specific_sequences = ["¬¬¬", "¬¬"] | |
specific_pattern = "|".join(re.escape(seq) for seq in specific_sequences) | |
ascii_pattern = "|".join( | |
re.escape(char) for char in plan_hunter_ascii if char != "¬" | |
) | |
ascii_pattern = f"{ascii_pattern}|¬" | |
bracket_pattern = r"\[[^\]]*\]" | |
combined_pattern = f"({specific_pattern}|{ascii_pattern}|{bracket_pattern})" | |
plan_hunter_valid_renames[item_blob["@sID"]] += re.findall( | |
combined_pattern, chunk | |
) | |
log.info("Done!") | |
log.info("Cleaning data to be merged...") | |
replaced_renames = { | |
item_id: [ | |
reduce( | |
lambda string_name, replacement_blob: string_name.replace( | |
*replacement_blob | |
), | |
tagged_replacements, | |
string_name, | |
) | |
for string_name in string_list | |
] | |
for item_id, string_list in cleaned_renames.items() | |
} | |
replaced_renames = { | |
item_id: [string_name for string_name in string_list if string_name] | |
for item_id, string_list in replaced_renames.items() | |
if len([string_name for string_name in string_list if string_name]) > 0 | |
} | |
plan_hunter_replaced_renames = { | |
item_id: [ | |
reduce( | |
lambda string_name, replacement_blob: string_name.replace( | |
*replacement_blob | |
), | |
plan_hunter_replacements, | |
string_name, | |
) | |
for string_name in string_list | |
] | |
for item_id, string_list in plan_hunter_valid_renames.items() | |
} | |
plan_hunter_replaced_renames = { | |
item_id: [string_name for string_name in string_list if string_name] | |
for item_id, string_list in plan_hunter_replaced_renames.items() | |
if len([string_name for string_name in string_list if string_name]) > 0 | |
} | |
reduced_renames = merge_dictionaries(replaced_renames, plan_hunter_replaced_renames) | |
reduced_renames = { | |
key: list(set(value_list)) for key, value_list in reduced_renames.items() | |
} | |
for key, value_list in reduced_renames.items(): | |
for override in preferred_overrides: | |
if override[0] in value_list and override[1] in value_list: | |
value_list.remove(override[1]) | |
# Step 2: Transform the list into another dictionary | |
finalized_renames = {} | |
for key, value_list in reduced_renames.items(): | |
value_dict = { | |
"value_strings": [v for v in value_list if v in value_strings], | |
"source_strings": [v for v in value_list if v not in value_strings], | |
} | |
finalized_renames[key] = value_dict | |
reduced_icon_file = [ | |
item | |
for item in icon_file["SSTXMLRessources"]["Content"]["String"] | |
if item["@sID"] in reduced_renames | |
] | |
icon_file["SSTXMLRessources"]["Content"]["String"] = reduced_icon_file | |
log.info("Done!") | |
log.info("Replacing strings in data block...") | |
for item in icon_file["SSTXMLRessources"]["Content"]["String"]: | |
if item["@sID"] in finalized_renames: | |
item["Source"] = item["Dest"] | |
value_string = "".join(finalized_renames[item["@sID"]]["value_strings"]) | |
value_string = f" {value_string}" if value_string else "" | |
source_string = "".join(finalized_renames[item["@sID"]]["source_strings"]) | |
item["Dest"] = re.sub( | |
r"([^\x00-\x1F\x21-\x7F]+)(.*)", | |
rf"{value_string}\1{source_string} \2", | |
item["Dest"], | |
1, | |
) | |
log.info("Done!") | |
log.info("Writing output XML...") | |
output = xmltodict.unparse(icon_file, pretty=True) | |
with open("out.xml", "w", encoding="utf-8") as r: | |
r.write(output) | |
log.info("Done!") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment