Skip to content

Instantly share code, notes, and snippets.

@haifengkao
Created December 6, 2025 14:38
Show Gist options
  • Select an option

  • Save haifengkao/e449cf8459248a2abd8a3ec4bc17dab9 to your computer and use it in GitHub Desktop.

Select an option

Save haifengkao/e449cf8459248a2abd8a3ec4bc17dab9 to your computer and use it in GitHub Desktop.
Automatic Mod Patcher for VP 5.0
#!/usr/bin/env python3
"""
Batch rename helper for VP 5.0 alpha changes.
Defaults:
- Scans every mod folder under the default Civ5 MODS directory.
- Skips VP核心模組(但保留 Community Events),除非使用 --include-vp。
- Applies a set of string replacements to text files (xml, sql, lua, modinfo, civ5proj, txt).
This only rewrites file contents; it does not rename files.
"""
from __future__ import annotations
import argparse
from pathlib import Path
import re
from typing import Iterable
DEFAULT_ROOT = Path.cwd()
# Mods to skip by default (VP core pieces). Case-insensitive match.
DEFAULT_SKIP_NAMES = {
"(1) community patch",
"(2) vox populi",
"(3a) vp - eui compatibility files",
"(4a) squads for vp",
}
# Extensions we will process.
DEFAULT_EXTS = {".xml", ".sql", ".lua"}
# Replacement map: old -> new
REPLACEMENTS = {
# Font icons
# "ICON_RES_MARRIAGE": "ICON_MARRIAGE", # not used
"ICON_VP_SAPPED": "ICON_SAPPED",
"ICON_VP_WRITER": "ICON_WRITER",
"ICON_VP_ARTIST": "ICON_ARTIST",
"ICON_VP_MUSICIAN": "ICON_MUSICIAN",
"ICON_VP_SCIENTIST": "ICON_SCIENTIST",
"ICON_VP_MERCHANT": "ICON_MERCHANT",
"ICON_VP_ENGINEER": "ICON_ENGINEER",
"ICON_CSD_CIVIL_SERVANT": "ICON_CIVIL_SERVANT",
"ICON_VP_AUTOMATION": "ICON_AUTOMATON",
"ICON_VP_BORDER_OBSTRUCTION": "ICON_BORDER_OBSTRUCTION",
"ICON_VP_NOINFLUENCE": "ICON_NOINFLUENCE",
"ICON_VP_GREATWRITING": "ICON_GW_WRITING",
"ICON_VP_GREATART": "ICON_GW_ART",
"ICON_VP_GREATMUSIC": "ICON_GW_MUSIC",
"ICON_VP_ARTIFACT": "ICON_ARTIFACT",
"ICON_VP_SPY_POINTS": "ICON_SPY_POINT",
"ICON_VP_VISION": "ICON_VISION",
"ICON_VP_MONOPOLY": "ICON_MONOPOLY",
"ICON_VP_OFFICE": "ICON_OFFICE",
"ICON_VP_FRANCHISE": "ICON_FRANCHISE",
"ICON_ITP_HAPPINESS_CONTENT": "ICON_HAPPINESS_CONTENT",
"ICON_ITP_HAPPINESS_NEUTRAL": "ICON_HAPPINESS_NEUTRAL",
"ICON_ITP_PANTHEON": "ICON_PANTHEON_TP",
"ICON_ITP_RELIGION": "ICON_PROPHET_TP",
"ICON_ITP_RELIGIOUS_BUILDING": "ICON_RELIGIOUS_BUILDING",
"ICON_ITP_CITIZEN_CONTENT": "ICON_CITIZEN_CONTENT",
"ICON_ITP_CITIZEN_NEUTRAL": "ICON_CITIZEN_NEUTRAL",
"ICON_ITP_CITIZEN_UNHAPPY": "ICON_CITIZEN_UNHAPPY",
"ICON_ITP_CITIZEN_VERY_UNHAPPY": "ICON_CITIZEN_VERY_UNHAPPY",
"ICON_ITP_RELIGIOUS_UNIT": "ICON_STRENGTH_TP",
"ICON_ITP_RELIGIOUS_ENGINEER": "ICON_GREAT_ENGINEER_GRAY",
"ICON_ITP_RELIGIOUS_GENERAL": "ICON_GREAT_GENERAL_GRAY",
"ICON_ITP_RELIGIOUS_SCIENTIST": "ICON_GREAT_SCIENTIST_GRAY",
"ICON_ITP_RELIGIOUS_MERCHANT": "ICON_GREAT_MERCHANT_GRAY",
"ICON_ITP_RELIGIOUS_ARTIST": "ICON_GREAT_ARTIST_GRAY",
"ICON_ITP_RELIGIOUS_MUSICIAN": "ICON_GREAT_MUSICIAN_GRAY",
"ICON_ITP_RELIGIOUS_WRITER": "ICON_GREAT_WRITER_GRAY",
"ICON_ITP_RELIGIOUS_ADMIRAL": "ICON_GREAT_ADMIRAL_GRAY",
"ICON_ITP_RELIGIOUS_VENETIAN": "ICON_GREAT_MERCHANT_VENICE_GRAY",
"ICON_ITP_RELIGIOUS_DIPLOMAT": "ICON_DIPLOMAT_GRAY",
# Corporations
"CORPORATION_LANDSEA_EXTRACTORS": "CORPORATION_CENTAURUS_EXTRACTORS",
# Unit + art rename
"UNITCLASS_VP_SLINGER": "UNITCLASS_SLINGER",
"UNITCLASS_HORSE_ARCHER": "UNITCLASS_SKIRMISHER",
"UNITCLASS_MOUNTED_BOWMAN": "UNITCLASS_HEAVY_SKIRMISHER",
"UNITCLASS_MISSILE_DESTROYER": "UNITCLASS_SENSOR_COMBAT_SHIP",
"UNITCLASS_FCOMPANY": "UNITCLASS_FREE_COMPANY",
"UNITCLASS_GUERILLA": "UNITCLASS_MERCENARY",
"UNITCLASS_PANZER": "UNITCLASS_T34",
"UNITCLASS_ASSYRIAN_SIEGE_TOWER": "UNITCLASS_SIEGE_TOWER",
"UNITCLASS_BRAZILIAN_PRACINHA": "UNITCLASS_PRACINHA",
"UNITCLASS_SKI_INFANTRY": "UNITCLASS_NORWEGIAN_SKI_INFANTRY",
"UNITCLASS_COMPANIONCAVALRY": "UNITCLASS_COMPANION_CAVALRY",
"UNITCLASS_HUNNIC_BATTERING_RAM": "UNITCLASS_BATTERING_RAM",
"UNIT_MOUNTED_BOWMAN": "UNIT_HEAVY_SKIRMISHER",
"UNIT_MISSILE_DESTROYER": "UNIT_SENSOR_COMBAT_SHIP",
"UNIT_FCOMPANY": "UNIT_FREE_COMPANY",
"UNIT_GUERILLA": "UNIT_MERCENARY",
"UNIT_BANDEIRANTES": "UNIT_BANDEIRANTE",
"ART_DEF_UNIT_VP_SLINGER": "ART_DEF_UNIT_SLINGER",
"ART_DEF_UNIT_HEAVY_SKIRMISH": "ART_DEF_UNIT_HEAVY_SKIRMISHER",
"ART_DEF_UNIT_EXPLORER_CBP": "ART_DEF_UNIT_VP_EXPLORER",
"ART_DEF_UNIT_MISSILE_DESTROYER": "ART_DEF_UNIT_SENSOR_COMBAT_SHIP",
"ART_DEF_UNIT_VP_LIBURNA": "ART_DEF_UNIT_LIBURNA",
"ART_DEF_UNIT_BANDEIRANTES": "ART_DEF_UNIT_BANDEIRANTE",
"ART_DEF_UNIT_MEMBER_VP_SLINGER": "ART_DEF_UNIT_MEMBER_SLINGER",
"ART_DEF_UNIT_MEMBER_HEAVY_SKIRMISH": "ART_DEF_UNIT_MEMBER_HEAVY_SKIRMISHER",
"ART_DEF_UNIT_MEMBER_MISSILE_DESTROYER": "ART_DEF_UNIT_MEMBER_SENSOR_COMBAT_SHIP",
"ART_DEF_UNIT_MEMBER_VP_LIBURNA": "ART_DEF_UNIT_MEMBER_LIBURNA",
# UNIT_INCAN_SLINGER not changed in 4.22-> 5.0
# 22UNIT_INCAN_SLINGER: [(UnitFlavorSweeps.sql, UNIT_INCAN_SLINGER), (Inca.sql, UNIT_INCAN_SLINGER), (PreUnitChanges.sql, UNIT_INCAN_SLINGER), (CivilizationTextChanges.sql, TXT_KEY_UNIT_INCAN_SLINGER)]
# UNIT_INCAN_SLINGER: [(UnitFlavorSweeps.sql, UNIT_INCAN_SLINGER), (Inca.sql, UNIT_INCAN_SLINGER), (NewUnits.xml, TXT_KEY_UNIT_INCAN_SLINGER), (PreUnitChanges.sql, UNIT_INCAN_SLINGER), (UnitChanges.sql, UNIT_INCAN_SLINGER), (NewCivilizationText.xml, TXT_KEY_UNIT_INCAN_SLINGER)]
# 22UNIT_SLINGER: []
# UNIT_SLINGER: [(NewUnitArts.xml, ART_DEF_UNIT_SLINGER), (UnitArtChanges.sql, ART_DEF_UNIT_SLINGER), (UnitChanges.sql, ART_DEF_UNIT_SLINGER)]
# "UNIT_INCAN_SLINGER": "UNIT_SLINGER",
"UNIT_WARAKAQ": "UNIT_WARAKAQ", # listed as new; included for completeness
# Build
"BUILD_ENCAMPMENT_SHOSHONE": "BUILD_ENCAMPMENT",
"BUILD_SPAIN_HACIENDA": "BUILD_HACIENDA",
"BUILD_POLYNESIAN_BOATS": "BUILD_FISHING_BOATS_EMBARKED",
# Improvement
"IMPROVEMENT_JFD_MACHU_PICCHU": "IMPROVEMENT_MOUNTAIN_CITY",
"IMPROVEMENT_ENCAMPMENT_SHOSHONE": "IMPROVEMENT_VP_ENCAMPMENT",
"IMPROVEMENT_SPAIN_HACIENDA": "IMPROVEMENT_HACIENDA",
"IMPROVEMENT_MONGOLIA_ORDO": "IMPROVEMENT_ORDO",
"ART_DEF_IMPROVEMENT_JFD_MACHU": "ART_DEF_IMPROVEMENT_MOUNTAIN_CITY",
"ART_DEF_IMPROVEMENT_SPAIN_HACIENDA": "ART_DEF_IMPROVEMENT_HACIENDA",
"ART_DEF_IMPROVEMENT_MONGOLIA_ORDO": "ART_DEF_IMPROVEMENT_ORDO",
# Name standardization singular/plural tweaks
# "Mercenaries": "Mercenary", # I like Mercenary more
# 22Bandeirantes: [((2) Vox Populi (v 17).modinfo, bandeirantes_flagbearer), (flagbearer.fxsxml, bandeirantes_flagbearer), (UnitFlavorSweeps.sql, UNIT_BANDEIRANTES), (NewIcons.xml, BANDEIRANTES_FLAG_ATLAS), (NewUnitArts.xml, ART_DEF_UNIT_BANDEIRANTES), (UnitArtChanges.sql, ART_DEF_UNIT_BANDEIRANTES), (Brazil.sql, Bandeirantes), (NewPromotions.xml, TXT_KEY_PROMOTION_RECON_BANDEIRANTES_HELP), (PromotionChanges.sql, Bandeirantes), (PromotionDisplaySweeps.sql, PROMOTION_RECON_BANDEIRANTES)]
# Bandeirantes: [((2) Vox Populi (v 17).modinfo, bandeirantes_flagbearer), (flagbearer.fxsxml, bandeirantes_flagbearer), (NewUnitArts.xml, svbandeirantes), (NewCivilizationText.xml, Bandeirantes)]
# 22Bandeirante: [((2) Vox Populi (v 17).modinfo, bandeirantes_flagbearer), (bandeirante_1.fxsxml, bandeirante_1), (bandeirante_2.fxsxml, bandeirante_2), (bandeirante_3.fxsxml, bandeirante_3), (flagbearer.fxsxml, bandeirantes_flagbearer), (UnitFlavorSweeps.sql, UNIT_BANDEIRANTES), (NewIcons.xml, BANDEIRANTES_FLAG_ATLAS), (NewUnitArts.xml, bandeirante_3), (UnitArtChanges.sql, ART_DEF_UNIT_BANDEIRANTES), (Brazil.sql, Bandeirantes)]
# Bandeirante: [((2) Vox Populi (v 17).modinfo, Bandeirante), (bandeirante_1.fxsxml, bandeirante_1), (bandeirante_2.fxsxml, bandeirante_2), (bandeirante_3.fxsxml, bandeirante_3), (flagbearer.fxsxml, bandeirantes_flagbearer), (UnitFlavorSweeps.sql, UNIT_BANDEIRANTE), (NewUnitArts.xml, ART_DEF_UNIT_MEMBER_BANDEIRANTE_3), (UnitArtChanges.sql, ART_DEF_UNIT_BANDEIRANTE), (Brazil.sql, UNIT_BANDEIRANTE), (PromotionChanges.sql, Bandeirante)]
# "Bandeirantes": "Bandeirante", # cannot understand the replacement rule
"Comanche Riders": "Comanche Rider",
"Seir Morb": "Suea Mop",
"Bräuhaus": "Brewhouse",
"Waraq'ak": "Warak'aq",
"Baochuan": "Treasure Ship",
"E-temenanki": "Etemenanki",
"Smithsonian Institute": "Smithsonian Institution",
"Splash Damage I": "Splash I",
"Splash Damage II": "Splash II",
# "Noble": "Nobility", # I like Noble more
"Superior Flank Attack": "Heavy Flanking",
"Taikan Kyohosyugi": "Taikan Kyoho",
"Burger": "Burgher",
"Flag Bearers": "Flag Bearer",
"Grenadiers": "Grenadier",
"Siege Volleys": "Siege Volley",
"Concussive Hits": "Concussive Hit",
"White Walkers": "White Walker",
"Marsh Walkers": "Marsh Walker",
"Woods Walkers": "Woods Walker",
"Desert Walkers": "Desert Walker",
"Hill Walkers": "Hill Walker",
"Oceanic Perils": "Oceanic Peril",
"Highlanders": "Highlander",
"Lost Codices": "Lost Codex",
# Diplomacy responses
"RESPONSE_HOSTILE_REPEAT_SHARE_OPINION_NO": "RESPONSE_HOSTILE_REPEAT_SHARE_APPROACH_NO",
"RESPONSE_REPEAT_SHARE_OPINION_NO": "RESPONSE_REPEAT_SHARE_APPROACH_NO",
"RESPONSE_SHARE_OPINION_NO": "RESPONSE_SHARE_APPROACH_NO",
"RESPONSE_SHARE_OPINION_FRIENDLY": "RESPONSE_SHARE_APPROACH_FRIENDLY",
"RESPONSE_SHARE_OPINION_NEUTRAL": "RESPONSE_SHARE_APPROACH_NEUTRAL",
"RESPONSE_SHARE_OPINION_GUARDED": "RESPONSE_SHARE_APPROACH_GUARDED",
"RESPONSE_SHARE_OPINION_HOSTILE": "RESPONSE_SHARE_APPROACH_HOSTILE",
"RESPONSE_SHARE_OPINION_WAR": "RESPONSE_SHARE_APPROACH_WAR",
"RESPONSE_SHARE_OPINION_AFRAID": "RESPONSE_SHARE_APPROACH_AFRAID",
"RESPONSE_SHARE_OPINION_PLANNING_WAR": "RESPONSE_SHARE_APPROACH_PLANNING_WAR",
"RESPONSE_SHARE_OPINION_DECEPTIVE": "RESPONSE_SHARE_APPROACH_DECEPTIVE",
"RESPONSE_TOO_SOON_FOR_SHARE_OPINION": "RESPONSE_TOO_SOON_FOR_SHARE_APPROACH",
# CustomModOptions
"API_ACHIEVEMENTS": "ENABLE_ACHIEVEMENTS",
"CIVILIANS_RETREAT_WITH_MILITARY": "CORE_CIVILIANS_RETREAT_WITH_MILITARY",
"BALANCE_DEFENSIVE_PACTS_AGGRESSION_ONLY": "CORE_PERSISTENT_DEFENSIVE_PACTS",
"BALANCE_CORE_ENGINEER_HURRY": "CORE_ENGINEER_HURRY",
"NO_REPAIR_FOREIGN_LANDS": "CORE_NO_REPAIRING_FOREIGN_LANDS",
"NO_MAJORCIV_GIFTING": "CORE_NO_INTERMAJOR_UNIT_GIFTING",
"NO_HEALING_ON_MOUNTAINS": "CORE_NO_HEALING_ON_MOUNTAINS",
"NO_YIELD_ICE": "CORE_NO_YIELD_ICE",
"CORE_REDUCE_NOTIFICATIONS": "COREUI_REDUCE_NOTIFICATIONS",
"BALANCE_CORE_DIPLOMACY_ERA_INFLUENCE": "COREUI_DIPLOMACY_ERA_INFLUENCE",
"BALANCE_CORE_LUXURIES_TRAIT": "BALANCE_ALTERNATE_INDONESIA_TRAIT",
"BALANCE_CORE_MAYA_CHANGE": "BALANCE_ALTERNATE_MAYA_TRAIT",
"ALTERNATE_SIAM_TRAIT": "BALANCE_ALTERNATE_SIAM_TRAIT",
"BALANCE_CORE_UNIQUE_BELIEFS_ONLY_FOR_CIV": "BALANCE_UNIQUE_BELIEFS_ONLY_FOR_CIV",
"BALANCE_CORE_VICTORY_GAME_CHANGES": "BALANCE_CULTURE_VICTORY_CHANGES",
"BALANCE_CORE_BUILDING_INVESTMENTS": "BALANCE_BUILDING_INVESTMENTS",
"BALANCE_CORE_RESOURCE_MONOPOLIES": "BALANCE_RESOURCE_MONOPOLIES",
"BALANCE_CORE_RESOURCE_MONOPOLIES_STRATEGIC": "BALANCE_STRATEGIC_RESOURCE_MONOPOLIES",
"BALANCE_CORE_MINOR_VARIABLE_BULLYING": "BALANCE_HEAVY_TRIBUTE",
"BALANCE_CORE_MINOR_PTP_MINIMUM_VALUE": "BALANCE_MINOR_PROTECTION_REQUIREMENTS",
"BALANCE_CORE_CITY_DEFENSE_SWITCH": "BALANCE_CITY_STRENGTH_SWITCH",
"RIVER_CITY_CONNECTIONS": "BALANCE_RIVER_CITY_CONNECTIONS",
"BALANCE_CORE_SPIES": "BALANCE_SPY_POINTS",
"BALANCE_CORE_RANGED_ATTACK_PENALTY": "BALANCE_RANGED_DEFENSE_UNIT_HEALTH",
"ADJACENT_BLOCKADE": "BALANCE_LAND_UNITS_ADJACENT_BLOCKADE",
"BALANCE_CORE_GOODY_RECON_ONLY": "BALANCE_RECON_ONLY_ANCIENT_RUINS",
"BALANCE_CORE_INQUISITOR_TWEAKS": "BALANCE_INQUISITOR_NERF",
"BALANCE_CORE_MILITARY_RESOURCES": "BALANCE_RESOURCE_SHORTAGE_UNIT_HEALING",
"BALANCE_CORE_BUILDING_RESOURCE_MAINTENANCE": "BALANCE_RESOURCE_SHORTAGE_BUILDING_REFUND",
"BALANCE_RETROACTIVE_PROMOS": "BALANCE_RETROACTIVE_PROMOTIONS",
"BALANCE_CORE_PUPPET_CHANGES": "BALANCE_PUPPET_CHANGES",
"BALANCE_CORE_HALF_XP_PURCHASE": "BALANCE_HALF_XP_GOLD_PURCHASES",
"BALANCE_CORE_UNIT_CREATION_DAMAGED": "BALANCE_PURCHASED_UNIT_DAMAGE",
"BALANCE_CORE_SETTLERS_CONSUME_POP": "BALANCE_SETTLERS_CONSUME_POPULATION",
"BALANCE_CORE_BARBARIAN_THEFT": "BALANCE_BARBARIAN_THEFT",
"BALANCE_CORE_WONDER_COST_INCREASE": "BALANCE_WORLD_WONDER_COST_INCREASE",
"BALANCE_CORE_SCALING_TRADE_DISTANCE": "BALANCE_TRADE_ROUTE_PROXIMITY_PENALTY",
"BALANCE_CORE_WONDERS_VARIABLE_REWARD": "BALANCE_WONDERS_VARIABLE_CONSOLATION",
"BALANCE_FLIPPED_TOURISM_MODIFIER_OPEN_BORDERS": "BALANCE_FLIPPED_OPEN_BORDERS_TOURISM",
"BALANCE_CORE_PURCHASE_COST_INCREASE": "BALANCE_PURCHASE_COST_ADJUSTMENTS",
"BALANCE_CORE_QUEST_CHANGES": "BALANCE_QUEST_CHANGES",
"CORE_RESILIENT_PANTHEONS": "BALANCE_RESILIENT_PANTHEONS",
"ALTERNATE_ASSYRIA_TRAIT": "BALANCE_ALTERNATE_ASSYRIA_TRAIT",
"ALTERNATE_CELTS": "BALANCE_ALTERNATE_CELTS_TRAIT",
"ANY_PANTHEON": "BALANCE_ANY_PANTHEON",
"CITY_STATE_SCALE": "BALANCE_CITY_STATE_SCALE",
"ERA_RESTRICTED_GENERALS": "BALANCE_ERA_RESTRICTED_GENERALS",
"ERA_RESTRICTION": "BALANCE_ERA_RESTRICTION",
"GP_ERA_SCALING": "BALANCE_GREAT_PEOPLE_ERA_SCALING",
"UNIT_SUPPLY_MINORS_USE_HANDICAP": "BALANCE_MINOR_UNIT_SUPPLY_HANDICAP",
"NO_AUTO_SPAWN_PROPHET": "BALANCE_NO_AUTO_SPAWN_PROPHET",
"CORE_NO_RANGED_ATTACK_FROM_CITIES": "BALANCE_NO_CITY_RANGED_ATTACK",
"RELIGION_PASSIVE_SPREAD_WITH_CONNECTION_ONLY": "BALANCE_PASSIVE_SPREAD_BY_CONNECTION",
"RELIGION_PERMANENT_PANTHEON": "BALANCE_PERMANENT_PANTHEONS",
"PILLAGE_PERMANENT_IMPROVEMENTS": "BALANCE_PILLAGE_PERMANENT_IMPROVEMENTS",
"BALANCE_CORE_RANDOMIZED_GREAT_PROPHET_SPAWNS": "BALANCE_RANDOMIZED_GREAT_PROPHET_SPAWNS",
"CORE_RELAXED_BORDER_CHECK": "BALANCE_RELAXED_BORDER_CHECK",
"SANE_UNIT_MOVEMENT_COST": "BALANCE_SANE_UNIT_MOVEMENT_COST",
"BALANCE_CORE_SETTLER_RESET_FOOD": "BALANCE_SETTLERS_RESET_GROWTH",
"BALANCE_CORE_UNCAPPED_HAPPINESS": "BALANCE_UNCAPPED_UNHAPPINESS",
"BALANCE_CORE_UNIT_INVESTMENTS": "BALANCE_UNIT_INVESTMENTS",
"BALANCE_CORE_XP_ON_FIRST_ATTACK": "BALANCE_XP_ON_FIRST_ATTACK",
"NO_RANDOM_TEXT_CIVS": "UI_NO_RANDOM_CIV_DIALOGUE",
"CORE_TWO_PASS_DANGER": "COMBATAI_TWO_PASS_DANGER",
"CORE_AREA_EFFECT_PROMOTIONS": "API_AREA_EFFECT_PROMOTIONS",
"CORE_DISABLE_LUA_HOOKS": "API_DISABLE_LUA_HOOKS",
"CORE_HOVERING_UNITS": "API_HOVERING_UNITS",
"BALANCE_CORE_GOLD_INTERNAL_TRADE_ROUTES": "TRADE_INTERNAL_GOLD_ROUTES",
"BALANCE_CORE_BOMBARD_RANGE_BUILDINGS": "BALANCE_BOMBARD_RANGE_BUILDINGS",
"BALANCE_CORE_NEW_GP_ATTRIBUTES": "BALANCE_NEW_GREAT_PERSON_ATTRIBUTES",
# Defines
"BALANCE_MOD_POLICY_BRANCHES_NEEDED_IDEOLOGY": "IDEOLOGY_UNLOCK_NUM_POLICY_BRANCHES_NEEDED",
"BALANCE_MOD_POLICIES_NEEDED_IDEOLOGY": "IDEOLOGY_UNLOCK_NUM_POLICIES_NEEDED",
"BALANCE_CORE_WORLD_WONDER_SAME_ERA_COST_MODIFIER": "BALANCE_WORLD_WONDER_SAME_ERA_COST_MODIFIER",
"BALANCE_CORE_WORLD_WONDER_PREVIOUS_ERA_COST_MODIFIER": "BALANCE_WORLD_WONDER_PREVIOUS_ERA_COST_MODIFIER",
"BALANCE_CORE_WORLD_WONDER_SECOND_PREVIOUS_ERA_COST_MODIFIER": "BALANCE_WORLD_WONDER_SECOND_PREVIOUS_ERA_COST_MODIFIER",
"BALANCE_CORE_WORLD_WONDER_EARLIER_ERA_COST_MODIFIER": "BALANCE_WORLD_WONDER_EARLIER_ERA_COST_MODIFIER",
"BALANCE_CORE_CORP_OFFICE_FRANCHISE_CONVERSION": "BALANCE_CORP_OFFICE_FRANCHISE_CONVERSION",
"BALANCE_CORE_CORP_OFFICE_TR_CONVERSION": "BALANCE_CORP_OFFICE_TR_CONVERSION",
"BALANCE_CORE_PRODUCTION_DESERT_IMPROVEMENT": "BALANCE_PRODUCTION_DESERT_IMPROVEMENT",
"SHARE_OPINION_TURN_BUFFER": "SHARE_APPROACH_TURN_BUFFER",
# Unit columns
"NoMinorGifts": "NoMinorCivUU",
}
BUILDING_BASE_RENAMES = {
"GROVE": "COUNCIL",
"LODGE": "SMOKEHOUSE",
"FORTRESS": "BASTION_FORT",
"STOCKYARD": "AGRIBUSINESS",
"COAL_PLANT": "REFINERY",
"BASILICA": "TETRACONCH",
"JELLING_STONES": "RUNESTONE",
"ODEON": "GYMNASION",
"INDUS_CANAL": "HARAPPAN_RESERVOIR",
"YURT": "GER",
"SIEGE_WORKSHOP": "SIEGE_FOUNDRY",
"COURT_SCRIBE": "SCRIVENERS_OFFICE",
"FOREIGN_OFFICE": "FOREIGN_BUREAU",
"PALACE_SCIENCE_CULTURE": "PALACE_CULTURE_SCIENCE",
"FINANCE_CENTER": "INTERNATIONAL_FINANCE_CENTER",
"EHRENHALLE": "HALL_OF_HONOR",
"AMERICA_INDEPENDENCEHALL": "INDEPENDENCE_HALL",
"VENETIAN_ARSENALE": "ARSENALE_DI_VENEZIA",
"FORUM": "ROMAN_FORUM",
"MOTHERLAND_STATUE": "MOTHERLAND_CALLS",
"AMERICA_WESTPOINT": "WEST_POINT",
"AMERICA_SMITHSONIAN": "SMITHSONIAN_INSTITUTION",
"AMERICA_SLATERMILL": "SLATER_MILL",
"UN": "UNITED_NATIONS",
"PALACE_TREASURY": "STATE_TREASURY",
"CAPITAL_ENGINEER": "ROYAL_GUARDHOUSE",
"PALACE_COURT_CHAPEL": "COURT_CHAPEL",
"PALACE_ASTROLOGER": "ROYAL_ASTROLOGER",
"HEAVENLY_THRONE": "CELESTIAL_THRONE",
"RELIGIOUS_LIBRARY": "CHARTARIUM",
"LANDSEA_EXTRACTORS_HQ": "CENTAURUS_EXTRACTORS_HQ",
"LANDSEA_EXTRACTORS_FRANCHISE": "CENTAURUS_EXTRACTORS_FRANCHISE",
}
BUILDING_REPLACEMENT_KEYS = tuple(BUILDING_BASE_RENAMES.keys())
# Add prefixed Building/BuildingClass variants for every building rename entry.
for old, new in BUILDING_BASE_RENAMES.items():
REPLACEMENTS[f"BUILDING_{old}"] = f"BUILDING_{new}"
REPLACEMENTS[f"BUILDINGCLASS_{old}"] = f"BUILDINGCLASS_{new}"
PROMOTION_REPLACEMENTS = {
"WOODLAND_TRAILBLAZER": "TRAILBLAZER",
"ANTIAIR_LAND": "AIR_DEFENSE",
"SHOCK_4": "OVERRUN",
"DRILL_4": "STALWART",
"BARRAGE_4": "FIRING_DOCTRINE",
"ACCURACY_4": "INFILTRATORS",
"TARGETING_4": "INDOMITABLE",
"COASTAL_RAIDER_4": "BLOCKADE",
"BOARDING_PARTY_4": "PINCER",
"DAMAGE_REDUCTION": "DAUNTLESS",
"PRESS_GANGS": "COMMERCE_RAIDER",
"SKIRMISHER_MOBILITY": "PARTHIAN_TACTICS",
"SKIRMISHER_POWER": "COUP_DE_GRACE",
"BETTER_BOMBARDMENT": "SHRAPNEL_ROUNDS",
"COASTAL_TERROR": "VANGUARD",
"TRUE_WOLFPACK": "VP_WOLFPACK",
"KILL_HEAL": "ENDURANCE",
"HONOR_BONUS": "CONSCRIPTION",
"IMPERIALISM_OPENER": "IMPERIALISM",
"BETTER_LEADERSHIP": "REGIMENTAL_TRADITIONS",
"NAVAL_DEFENSE_BOOST": "BANZAI",
"LIGHTNING_WARFARE_GUN": "LIGHTNING_WARFARE_GUNPOWDER",
"LIGHTNING_WARFARE_ARMOR": "LIGHTNING_WARFARE_ARMORED",
"ICE_BREAKERS": "ENGINEERING_CORP",
"FALLOUT_REDUCTION": "FALLOUT_RESISTANCE",
"SIGNET": "ROYAL_SIGNET",
"EXPRESS": "WIRE_SERVICE",
"IMMUNITY": "DIPLOMATIC_IMMUNITY",
"PAX": "IMPERIAL_SEAL",
"ALHAMBRA": "JINETE",
"ARSENALE": "VENETIAN_CRAFTSMANSHIP",
"BARBARIAN_BONUS": "BRUTE_FORCE",
"NAVAL_MISFIRE": "NAVAL_TARGET_PENALTY",
"MECH": "TITANIC",
"ROUGH_TERRAIN_HALF_TURN": "BEAM_AXLE",
"RECON_EXPERIENCE": "RECONNAISSANCE",
"OCEAN_CROSSING": "OCEAN_EXPLORER",
"SCOUT_XP_PILLAGE": "SCAVENGER",
"OCEAN_HALF_MOVES": "SHALLOW_DRAFT",
"AIR_MISFIRE": "STRAFING_RUNS",
"RECON_RANGE": "AIR_RECON_RANGE",
"SUPPLY_BOOST": "MILITARY_TRADITION",
"NUCLEAR_SILO": "SHIELDED_SILO",
"FLANK_ATTACK_BONUS_STRONG": "HEAVY_FLANKING",
"ENSLAVEMENT": "GIFT_OF_THE_PHARAOH",
"RECON_BANDEIRANTES": "FLAG_BEARER",
"KNOCKOUT": "FASIMBA",
"ESPRIT_DE_CORPS": "WITHERING_FIRE",
"AOE_STRIKE_ON_KILL": "GRENADIER",
"AOE_STRIKE_FORTIFY": "PILUM",
"SLINGER": "CONCUSSIVE_HIT",
"REPEATER": "SIEGE_VOLLEY",
"HEAVY_SHIP": "HEAVY_ASSAULT",
"MANY_GOLDEN_AGE_POINTS": "PRIDE_OF_THE_NATION",
"BUSHIDO_HONOR": "HONOR",
"PRISONER_WAR": "PRISONERS_OF_WAR",
"MORALE_EVENT": "FERVOR",
}
for old, new in PROMOTION_REPLACEMENTS.items():
REPLACEMENTS[f"PROMOTION_{old}"] = f"PROMOTION_{new}"
ICON_FALLBACK_SIZES = (64, 48, 32, 24, 16)
# Scoped replacements (apply only in certain files)
TRAIT_POLICY_COLUMNS = {
"IgnoreTradeDistanceScaling": "NoTradeRouteProximityPenalty",
"BasicNeedsMedianModifierGlobal": "BasicNeedsMedianModifier",
"GoldMedianModifierGlobal": "GoldMedianModifier",
"ScienceMedianModifierGlobal": "ScienceMedianModifier",
"CultureMedianModifierGlobal": "CultureMedianModifier",
"ReligiousUnrestModifierGlobal": "ReligiousUnrestModifier",
"DistressFlatReductionGlobal": "DistressFlatReduction",
"PovertyFlatReductionGlobal": "PovertyFlatReduction",
"IlliteracyFlatReductionGlobal": "IlliteracyFlatReduction",
"BoredomFlatReductionGlobal": "BoredomFlatReduction",
"ReligiousUnrestFlatReductionGlobal": "ReligiousUnrestFlatReduction",
}
def should_skip_mod(mod_name: str, include_vp: bool) -> bool:
if include_vp:
return False
lowered = mod_name.lower()
# Always process Community Events even though it contains "vp".
if "community events" in lowered:
return False
if lowered in DEFAULT_SKIP_NAMES:
return True
# Generic skip: names that clearly are VP core (prefix vp/, or contain vox populi/community patch)
return lowered.startswith("vp ") or "vox populi" in lowered or "community patch" in lowered
def iter_mod_dirs(root: Path, include_vp: bool) -> Iterable[Path]:
for child in root.iterdir():
if not child.is_dir():
continue
if should_skip_mod(child.name, include_vp):
continue
yield child
def iter_target_files(mod_dir: Path, exts: set[str]) -> Iterable[Path]:
for path in mod_dir.rglob("*"):
if not path.is_file():
continue
if path.suffix.lower() in exts:
yield path
def compile_patterns(replacements: dict[str, str]) -> list[tuple[re.Pattern[str], str]]:
"""
Compile patterns to match tokens safely with case-aware boundaries:
- SCREAMING_SNAKE_CASE (all-caps/underscore/digit): match when followed by end or underscore.
- PascalCase: match when followed by end, an uppercase letter, or a digit.
- Other tokens: require non-word (no letter/digit/underscore) boundaries on both sides.
"""
compiled = []
for old, new in replacements.items():
escaped_old = re.escape(old)
is_screaming_snake = bool(re.fullmatch(r"[A-Z0-9_]+", old))
is_pascal_case = bool(
re.fullmatch(r"[A-Z][a-z0-9]*([A-Z][A-Za-z0-9]*)+", old)
) and not is_screaming_snake
if is_screaming_snake:
pattern = rf"(?<![A-Za-z0-9]){escaped_old}(?=$|_|[^A-Za-z0-9_])"
elif is_pascal_case:
pattern = rf"(?<![A-Za-z0-9_]){escaped_old}(?=$|[A-Z0-9])"
else:
pattern = rf"(?<![A-Za-z0-9_]){escaped_old}(?![A-Za-z0-9_])"
compiled.append((re.compile(pattern), new))
# Sort longest first so longer tokens win when overlaps exist.
compiled.sort(key=lambda x: len(x[0].pattern), reverse=True)
return compiled
def patch_ige_icon_lookup(root: Path) -> int:
"""
IGE calls IconLookup with size 45, but many atlases (Historical Religions) do not ship a 45px slice.
Inject a SafeIconLookup wrapper with fallbacks and rewrite IconLookup calls to use it.
Returns number of files patched.
"""
patched = 0
for path in root.rglob("IGE_API_Data.lua"):
try:
content = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
if "SafeIconLookup" in content:
continue
updated = re.sub(r"\bIconLookup\(", "SafeIconLookup(", content)
marker = "local largeSize = 64;\nlocal smallSize = 45;\n"
helper_block = (
"\n"
"local iconFallbackSizes = {"
+ ", ".join(str(size) for size in ICON_FALLBACK_SIZES)
+ "}\n\n"
"local function SafeIconLookup(index, size, atlas)\n"
" local offset, texture = IconLookup(index, size, atlas)\n"
" if offset and texture then\n"
" return offset, texture\n"
" end\n"
" for _, altSize in ipairs(iconFallbackSizes) do\n"
" if altSize ~= size then\n"
" offset, texture = IconLookup(index, altSize, atlas)\n"
" if offset and texture then\n"
" return offset, texture\n"
" end\n"
" end\n"
" end\n"
" return nil, nil\n"
"end\n"
"\n"
)
if marker not in updated:
continue
updated = updated.replace(marker, marker + helper_block, 1)
path.write_text(updated, encoding="utf-8")
print(f"[ICON_FIX] Added SafeIconLookup to {path}")
patched += 1
return patched
def apply_replacements(text: str, patterns: list[tuple[re.Pattern[str], str]]) -> tuple[str, int]:
"""Return (new_text, total_replacements)."""
total = 0
for pat, new in patterns:
text, count = pat.subn(new, text)
total += count
return text, total
def apply_scoped_replacements(text: str, path: Path) -> tuple[str, int]:
"""
Apply replacements that should only affect specific tables/files.
Currently scoped to trait/policy column renames.
"""
lowered = path.as_posix().lower()
is_trait_policy = any(key in lowered for key in ("trait", "policy"))
total = 0
if is_trait_policy:
for old, new in TRAIT_POLICY_COLUMNS.items():
pat = re.compile(rf"(?<![A-Za-z0-9_]){re.escape(old)}(?![A-Za-z0-9_])")
text, count = pat.subn(new, text)
total += count
return text, total
def run_tests() -> None:
"""Lightweight self-checks for boundary rules."""
def expect(src: str, expected: str, repl: dict[str, str]) -> None:
compiled = compile_patterns(repl)
out, _ = apply_replacements(src, compiled)
assert out == expected, f"Expected {expected!r}, got {out!r} for {src!r}"
# SCREAMING_SNAKE_CASE boundaries
expect(
"TXT_BUILDING_UN",
"TXT_BUILDING_UNITED_NATIONS",
{"BUILDING_UN": "BUILDING_UNITED_NATIONS"},
)
expect(
"TXT_BUILDING_UN_TYPE1",
"TXT_BUILDING_UNITED_NATIONS_TYPE1",
{"BUILDING_UN": "BUILDING_UNITED_NATIONS"},
)
expect("BUILDING_UN2", "BUILDING_UN2", {"BUILDING_UN": "BUILDING_UNITED_NATIONS"})
expect(
"BUILDINGCLASS_LANDSEA_EXTRACTORS_FRANCHISE</RequiredBuildingClass>",
"BUILDINGCLASS_CENTAURUS_EXTRACTORS_FRANCHISE</RequiredBuildingClass>",
{"BUILDINGCLASS_LANDSEA_EXTRACTORS_FRANCHISE": "BUILDINGCLASS_CENTAURUS_EXTRACTORS_FRANCHISE"},
)
expect(
"BUILDINGCLASS_LANDSEA_EXTRACTORS_FRANCHISE>/RequiredBuildingClass>",
"BUILDINGCLASS_CENTAURUS_EXTRACTORS_FRANCHISE>/RequiredBuildingClass>",
{"BUILDINGCLASS_LANDSEA_EXTRACTORS_FRANCHISE": "BUILDINGCLASS_CENTAURUS_EXTRACTORS_FRANCHISE"},
)
expect(
"BUILDINGCLASS_LANDSEA_EXTRACTORS_FRANCHISE /RequiredBuildingClass>",
"BUILDINGCLASS_CENTAURUS_EXTRACTORS_FRANCHISE /RequiredBuildingClass>",
{"BUILDINGCLASS_LANDSEA_EXTRACTORS_FRANCHISE": "BUILDINGCLASS_CENTAURUS_EXTRACTORS_FRANCHISE"},
)
# PascalCase boundaries
expect(
"BoredomFlatReductionGlobal123",
"Pascal123",
{"BoredomFlatReductionGlobal": "Pascal"},
)
expect(
"Foo BoredomFlatReductionGlobalApple",
"Foo PascalApple",
{"BoredomFlatReductionGlobal": "Pascal"},
)
expect(
"BoredomFlatReductionGlobales",
"BoredomFlatReductionGlobales",
{"BoredomFlatReductionGlobal": "Pascal"},
)
# Plain text boundaries (name standardization)
expect(
"Flag Bearers and Mercenaries.",
"Flag Bearer and Mercenary.",
{"Flag Bearers": "Flag Bearer", "Mercenaries": "Mercenary"},
)
expect(
"Flag Bearers123",
"Flag Bearers123",
{"Flag Bearers": "Flag Bearer"},
)
def main() -> None:
parser = argparse.ArgumentParser(description="VP 5.0 alpha rename helper")
parser.add_argument(
"--root",
type=Path,
default=DEFAULT_ROOT,
help="Mods root folder (default: Civ5 MODS path)",
)
parser.add_argument(
"--include-vp",
action="store_true",
default=False,
help="Also process VP core mods (default skips them, except Community Events).",
)
parser.add_argument(
"--patch-ige-icon-lookup",
action="store_true",
default=False,
help="Patch IGE IconLookup calls with fallbacks for missing 45px atlas slices.",
)
parser.add_argument(
"--dry-run", action="store_true", help="Show what would change without writing"
)
args = parser.parse_args()
root = args.root
if not root.exists():
raise SystemExit(f"Root does not exist: {root}")
patched_icons = 0
if args.patch_ige_icon_lookup:
patched_icons = patch_ige_icon_lookup(root)
patterns = compile_patterns(REPLACEMENTS)
modified_files = 0
total_hits = 0
for mod_dir in iter_mod_dirs(root, args.include_vp):
for file_path in iter_target_files(mod_dir, DEFAULT_EXTS):
read_encoding = "utf-8"
try:
content = file_path.read_text(encoding=read_encoding)
except UnicodeDecodeError:
read_encoding = "latin-1"
content = file_path.read_text(encoding=read_encoding)
new_content, hits = apply_replacements(content, patterns)
scoped_content, scoped_hits = apply_scoped_replacements(new_content, file_path)
if scoped_content != content:
modified_files += 1
total_hits += hits + scoped_hits
if not args.dry_run:
file_path.write_text(scoped_content, encoding=read_encoding)
print(f"[UPDATED] {file_path}")
print(f"\nDone. Files modified: {modified_files}, replacements applied: {total_hits}")
if patched_icons:
print(f"SafeIconLookup added to {patched_icons} IGE file(s) for icon fallbacks.")
if args.dry_run:
print("Dry-run mode: no files were written.")
if __name__ == "__main__":
run_tests()
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment