Skip to content

Instantly share code, notes, and snippets.

@srhinos
Last active July 6, 2024 20:39
Show Gist options
  • Save srhinos/fcbf48a8031c5e61fd2cd29e9a154200 to your computer and use it in GitHub Desktop.
Save srhinos/fcbf48a8031c5e61fd2cd29e9a154200 to your computer and use it in GitHub Desktop.
Fallout 76 IconSortingTags Strings Mod Merger Script
# ############### 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