""" generate_patches.py — Generate CfgPatches config.cpp for ammo/armor rebalance. Reads per-mod CSVs from data/, compares to RHS baseline, and generates a config.cpp that overrides non-RHS values to match RHS equivalents. Usage: python scripts/generate_patches.py Output: output/config.cpp — The rebalance mod output/unmatched.csv — Items with no RHS equivalent (for manual review) """ import csv import os import re import sys from collections import defaultdict from dataclasses import dataclass, field from pathlib import Path from typing import Optional REPO_ROOT = Path(__file__).resolve().parent.parent DATA_DIR = REPO_ROOT / "data" OUTPUT_DIR = REPO_ROOT / "output" # ============================================================================ # Caliber tier classification # ============================================================================ # Classify ammo by class name patterns (not caliber value, which is penetration coefficient) # Order matters: first match wins CALIBER_NAME_PATTERNS = [ # Pistol calibers (r'(?:9x19|9mm|9x21|9x18|009|acp_9)', "pistol_9mm"), (r'(?:45acp|\.45|45_auto|00045)', "pistol_45acp"), (r'(?:57x28|5\.7)', "smg_57x28"), (r'(?:46x30|4\.6)', "smg_46x30"), (r'(?:380|8mm|62x25|762x25|1143)', "pistol_other"), # 5.45mm (r'(?:545x39|5\.45|545)', "rifle_545x39"), # 5.56mm (r'(?:556x45|5\.56|556)', "rifle_556x45"), # 6.5mm (r'(?:65 Creedmoor|65x39|6\.5)', "rifle_65"), # 7.62x39mm (r'(?:762x39|7\.62x39|762_39)', "rifle_762x39"), # 7.62x51mm / 7.62x54mmR (r'(?:762x51|7\.62x51|762_51)', "rifle_762x51"), (r'(?:762x54|7\.62x54|762_54)', "rifle_762x54"), # .300 BLK (r'(?:300blk|300_blk|300blackout)', "rifle_300blk"), # .338 Lapua (r'(?:338lapua|338_lapua|338lm|338)', "sniper_338"), # .408 CheyTac (r'(?:408|cheytac)', "sniper_408"), # .50 BMG / 12.7mm (r'(?:127x99|12\.7|50bmg|50cal|12_7)', "hmg_50"), # 14.5mm (r'(?:145x114|14\.5|145)', "hmg_145"), # 20mm cannon (r'(?:20mm|20x)', "cannon_20"), # 23mm cannon (r'(?:23mm|23x)', "cannon_23"), # 25mm cannon (r'(?:25mm|25x)', "cannon_25"), # 30mm cannon (r'(?:30mm|30x|3ubr|3uof)', "cannon_30"), # 35mm cannon (r'(?:35mm|35x)', "cannon_35"), # 40mm cannon (r'(?:40mm|40x|mk19|自动)', "cannon_40"), # 57mm (r'(?:57mm|57x)', "cannon_57"), # 73mm / 76mm (r'(?:73mm|76mm|73x)', "gun_73"), # 82mm / 81mm mortars (r'(?:82mm|81mm|mortar)', "mortar_82"), # 100mm+ (r'(?:100mm|105mm|120mm|122mm|125mm|152mm|155mm)', "gun_heavy"), # Shotgun (r'(?:12gauge|12g|buckshot|shotgun|pellet)', "shotgun_12g"), # Rockets (non-guided) (r'(?:rocket|pg7|og7|rpg)', "rocket"), # Missiles (guided) (r'(?:missile|titan|scalpel|javelin|nlaw|stinger|igla|hellfire|kornet|metis|fagot|tow)', "missile"), # Grenades (r'(?:grenade|40mm_he|40mm_hedp|hedge)', "grenade_40mm"), # Submunitions / cluster (r'(?:submunition|cluster|dpicm)', "submunition"), # Flares / smoke / illumination (r'(?:flare|smoke|illuminat|chem)', "utility"), # Mines / IEDs (r'(?:mine|ied|satchel|demo|timed)', "explosive"), # Penetrators (HEAT warheads) (r'(?:penetrator|heat|tandem)', "penetrator"), ] def classify_ammo(simulation: str, classname: str, parent_class: str, hit: float, caliber: float, speed: float, indirect_hit: float, explosive: float) -> str: """Classify an ammo class into a caliber tier for matching. Uses class name patterns first, falls back to simulation-based classification. """ cn = classname.lower() pc = parent_class.lower() if parent_class else "" # Try name-based classification first for pattern, tier in CALIBER_NAME_PATTERNS: if re.search(pattern, cn) or re.search(pattern, pc): return tier # Fallback: simulation-based classification if simulation == "shotBullet": if speed > 0 and speed < 350: return "bullet_subsonic" return "bullet_unknown" elif simulation == "shotShell": if explosive > 0.5: return "shell_he" return "shell_ap" elif simulation == "shotMissile": return "missile" elif simulation == "shotRocket": return "rocket" elif simulation == "shotGrenade": return "grenade_40mm" elif simulation in ("shotIlluminating", "shotSmokeX"): return "utility" return f"unknown_{simulation}" def detect_ammo_subtype(classname: str, hit: float, speed: float, caliber: float) -> str: """Detect ammo subtype within a caliber tier (AP, subsonic, tracer, etc.).""" cn = classname.lower() if speed > 0 and speed < 350: return "subsonic" if "ap" in cn and ("fsds" in cn or "penetrat" in cn): return "ap" if "ap" in cn and "fsds" not in cn: return "ap" if "tracer" in cn: return "ball_tracer" if "subsonic" in cn or "sub" in cn: return "subsonic" if "match" in cn or "sniper" in cn or "otm" in cn or "mk262" in cn or "mk316" in cn: return "match" if "incendiary" in cn or "incen" in cn: return "incendiary" if "he" in cn and "hedp" not in cn: return "he" if "hedp" in cn: return "hedp" return "ball" # ============================================================================ # Armor tier classification # ============================================================================ def classify_armor_vest(mass: float, chest_armor: float) -> str: """Classify a vest into an armor tier for matching.""" if chest_armor <= 0: return "soft" if chest_armor <= 16: return "light" if chest_armor <= 31: return "medium" if chest_armor <= 50: return "heavy" return "eod" def classify_armor_helmet(mass: float, head_armor: float) -> str: """Classify a helmet into an armor tier for matching.""" if head_armor <= 0: return "cap" if head_armor <= 6: return "light" if head_armor <= 15: return "medium" if head_armor <= 25: return "heavy" return "special" # ============================================================================ # Data loading # ============================================================================ @dataclass class AmmoEntry: source_mod: str name: str simulation: str classname: str parent_class: str hit: float indirect_hit: float indirect_hit_range: float caliber: float typical_speed: float air_friction: float visible_fire: float audible_fire: float cost: float explosive: float deflecting: float air_lock: float cartridge: str ace_caliber: float = 0 ace_bullet_length: float = 0 ace_bullet_mass: float = 0 ace_bc: str = "[]" ace_vb: str = "[]" ace_drag_model: int = 0 ace_atmo: str = "" ace_mv: str = "[]" ace_bl: str = "[]" ace_transonic: float = 0 ace_mv_var: float = 0 tier: str = "" subtype: str = "" source_folder: str = "" @dataclass class ArmorEntry: source_mod: str name: str item_type: str # Vest or HeadGear classname: str mass: float source_folder: str = "" armor_head: float = 0 armor_neck: float = 0 armor_chest: float = 0 armor_diaphragm: float = 0 armor_abdomen: float = 0 armor_body: float = 0 armor_arms: float = 0 armor_legs: float = 0 pt_head: float = 1 pt_neck: float = 1 pt_chest: float = 1 pt_diaphragm: float = 1 pt_abdomen: float = 1 pt_body: float = 1 pt_arms: float = 1 pt_legs: float = 1 tier: str = "" def parse_float(s: str) -> float: try: return float(s.strip().strip('"')) except (ValueError, AttributeError): return 0.0 def parse_int(s: str) -> int: try: return int(float(s.strip().strip('"'))) except (ValueError, AttributeError): return 0 def load_ammo_csv(filepath: Path, folder: str = "") -> list[AmmoEntry]: """Load ammo CSV into AmmoEntry list.""" entries = [] if not filepath.exists(): return entries with open(filepath, "r", encoding="utf-8", errors="replace") as f: reader = csv.reader(f, delimiter=";") header = next(reader, None) if not header: return entries for row in reader: if len(row) < 19: continue e = AmmoEntry( source_mod=row[0].strip().strip('"'), name=row[1].strip().strip('"'), simulation=row[2].strip().strip('"'), classname=row[3].strip().strip('"'), parent_class=row[4].strip().strip('"'), hit=parse_float(row[5]), indirect_hit=parse_float(row[6]), indirect_hit_range=parse_float(row[7]), caliber=parse_float(row[8]), typical_speed=parse_float(row[9]), air_friction=parse_float(row[10]), visible_fire=parse_float(row[11]), audible_fire=parse_float(row[12]), cost=parse_float(row[13]), explosive=parse_float(row[14]), deflecting=parse_float(row[15]), air_lock=parse_float(row[16]), cartridge=row[17].strip().strip('"') if len(row) > 17 else "", ace_caliber=parse_float(row[18]) if len(row) > 18 else 0, ace_bullet_length=parse_float(row[19]) if len(row) > 19 else 0, ace_bullet_mass=parse_float(row[20]) if len(row) > 20 else 0, ace_bc=row[21].strip() if len(row) > 21 else "[]", ace_vb=row[22].strip() if len(row) > 22 else "[]", ace_drag_model=parse_int(row[23]) if len(row) > 23 else 0, ace_atmo=row[24].strip().strip('"') if len(row) > 24 else "", ace_mv=row[25].strip() if len(row) > 25 else "[]", ace_bl=row[26].strip() if len(row) > 26 else "[]", ace_transonic=parse_float(row[27]) if len(row) > 27 else 0, ace_mv_var=parse_float(row[28]) if len(row) > 28 else 0, source_folder=folder, ) e.tier = classify_ammo(e.simulation, e.classname, e.parent_class, e.hit, e.caliber, e.typical_speed, e.indirect_hit, e.explosive) e.subtype = detect_ammo_subtype(e.classname, e.hit, e.typical_speed, e.caliber) entries.append(e) return entries def load_armor_csv(filepath: Path, folder: str = "") -> list[ArmorEntry]: """Load armor CSV into ArmorEntry list.""" entries = [] if not filepath.exists(): return entries with open(filepath, "r", encoding="utf-8", errors="replace") as f: reader = csv.reader(f, delimiter=";") header = next(reader, None) if not header: return entries for row in reader: if len(row) < 14: continue e = ArmorEntry( source_mod=row[0].strip().strip('"'), name=row[1].strip().strip('"'), item_type=row[2].strip().strip('"'), classname=row[3].strip().strip('"'), mass=parse_float(row[4]), armor_head=parse_float(row[5]), armor_neck=parse_float(row[6]), armor_chest=parse_float(row[7]), armor_diaphragm=parse_float(row[8]), armor_abdomen=parse_float(row[9]), armor_body=parse_float(row[10]), armor_arms=parse_float(row[11]) if len(row) > 11 else 0, armor_legs=parse_float(row[12]) if len(row) > 12 else 0, pt_head=parse_float(row[13]) if len(row) > 13 else 1, pt_neck=parse_float(row[14]) if len(row) > 14 else 1, pt_chest=parse_float(row[15]) if len(row) > 15 else 1, pt_diaphragm=parse_float(row[16]) if len(row) > 16 else 1, pt_abdomen=parse_float(row[17]) if len(row) > 17 else 1, pt_body=parse_float(row[18]) if len(row) > 18 else 1, pt_arms=parse_float(row[19]) if len(row) > 19 else 1, pt_legs=parse_float(row[20]) if len(row) > 20 else 1, source_folder=folder, ) if e.item_type == "Vest": e.tier = classify_armor_vest(e.mass, e.armor_chest) elif e.item_type == "HeadGear": e.tier = classify_armor_helmet(e.mass, e.armor_head) entries.append(e) return entries # ============================================================================ # Baseline building # ============================================================================ def build_ammo_baseline(entries: list[AmmoEntry]) -> dict: """Build RHS baseline lookup: (tier, subtype) -> representative entry. Uses the first RHS entry found for each tier+subtype combination. Prefers entries from _rhs over _vanilla. """ baseline = {} # Group by (tier, subtype) groups = defaultdict(list) for e in entries: if e.source_folder in ("_rhs", "_rhsusf", "_rhsgref", "_rhssaf", "_vanilla", "_ace"): groups[(e.tier, e.subtype)].append(e) for key, group in groups.items(): # Prefer RHS entries rhs_entries = [e for e in group if e.source_folder.startswith("_rhs")] if rhs_entries: baseline[key] = rhs_entries[0] else: baseline[key] = group[0] return baseline def build_armor_baseline(entries: list[ArmorEntry]) -> dict: """Build RHS baseline lookup: (item_type, tier) -> representative entry.""" baseline = {} groups = defaultdict(list) for e in entries: if e.source_folder in ("_rhs", "_rhsusf", "_rhsgref", "_rhssaf", "_vanilla", "_ace"): groups[(e.item_type, e.tier)].append(e) for key, group in groups.items(): rhs_entries = [e for e in group if e.source_folder.startswith("_rhs")] if rhs_entries: baseline[key] = rhs_entries[0] else: baseline[key] = group[0] return baseline # ============================================================================ # Matching and delta computation # ============================================================================ # Ammo fields to compare and potentially override AMMO_FIELDS = [ "hit", "indirect_hit", "indirect_hit_range", "caliber", "typical_speed", "air_friction", "deflecting", "explosive", "ace_caliber", "ace_bullet_length", "ace_bullet_mass", "ace_drag_model", "ace_transonic", "ace_mv_var", ] # Armor fields per body part ARMOR_FIELDS = [ "armor_head", "armor_neck", "armor_chest", "armor_diaphragm", "armor_abdomen", "armor_body", "armor_arms", "armor_legs", "pt_head", "pt_neck", "pt_chest", "pt_diaphragm", "pt_abdomen", "pt_body", "pt_arms", "pt_legs", ] @dataclass class AmmoOverride: classname: str source_mod: str changes: dict = field(default_factory=dict) @dataclass class ArmorOverride: classname: str source_mod: str item_type: str changes: dict = field(default_factory=dict) def find_ammo_overrides( mod_entries: list[AmmoEntry], baseline: dict, ) -> list[AmmoOverride]: """Find ammo entries that differ from RHS baseline.""" overrides = [] for e in mod_entries: key = (e.tier, e.subtype) if key not in baseline: continue base = baseline[key] changes = {} for field_name in AMMO_FIELDS: mod_val = getattr(e, field_name) base_val = getattr(base, field_name) if mod_val != base_val and mod_val != 0: changes[field_name] = base_val # Check ACE array fields (compare as strings) for array_field in ["ace_bc", "ace_mv", "ace_bl", "ace_vb"]: mod_val = getattr(e, array_field) base_val = getattr(base, array_field) if mod_val != base_val and mod_val not in ("[]", ""): changes[array_field] = base_val if changes: overrides.append(AmmoOverride( classname=e.classname, source_mod=e.source_mod, changes=changes, )) return overrides def find_armor_overrides( mod_entries: list[ArmorEntry], baseline: dict, ) -> list[ArmorOverride]: """Find armor entries that differ from RHS baseline.""" overrides = [] for e in mod_entries: key = (e.item_type, e.tier) if key not in baseline: continue base = baseline[key] changes = {} for field_name in ARMOR_FIELDS: mod_val = getattr(e, field_name) base_val = getattr(base, field_name) # Don't override passThrough for soft items (keep their defaults) if "pt_" in field_name and base_val >= 1.0 and mod_val >= 1.0: continue if mod_val != base_val: changes[field_name] = base_val if changes: overrides.append(AmmoOverride( classname=e.classname, source_mod=e.source_mod, item_type=e.item_type, changes=changes, ) if hasattr(AmmoOverride, 'item_type') else ArmorOverride( classname=e.classname, source_mod=e.source_mod, item_type=e.item_type, changes=changes, )) return overrides # ============================================================================ # Config.cpp generation # ============================================================================ FIELD_TO_CONFIG = { # Ammo fields "hit": "hit", "indirect_hit": "indirectHit", "indirect_hit_range": "indirectHitRange", "caliber": "caliber", "typical_speed": "typicalSpeed", "air_friction": "airFriction", "deflecting": "deflecting", "explosive": "explosive", "ace_caliber": "ACE_caliber", "ace_bullet_length": "ACE_bulletLength", "ace_bullet_mass": "ACE_bulletMass", "ace_drag_model": "ACE_dragModel", "ace_transonic": "ACE_transonicStabilityCoef", "ace_mv_var": "ACE_muzzleVelocityVariationSD", "ace_bc": "ACE_ballisticCoefficients", "ace_vb": "ACE_velocityBoundaries", "ace_mv": "ACE_muzzleVelocities", "ace_bl": "ACE_barrelLengths", } # Armor field -> (hpp_section, property_name) ARMOR_FIELD_MAP = { "armor_head": ("Head", "armor"), "armor_neck": ("Neck", "armor"), "armor_chest": ("Chest", "armor"), "armor_diaphragm": ("Diaphragm", "armor"), "armor_abdomen": ("Abdomen", "armor"), "armor_body": ("Body", "armor"), "armor_arms": ("Arms", "armor"), "armor_legs": ("Legs", "armor"), "pt_head": ("Head", "passThrough"), "pt_neck": ("Neck", "passThrough"), "pt_chest": ("Chest", "passThrough"), "pt_diaphragm": ("Diaphragm", "passThrough"), "pt_abdomen": ("Abdomen", "passThrough"), "pt_body": ("Body", "passThrough"), "pt_arms": ("Arms", "passThrough"), "pt_legs": ("Legs", "passThrough"), } def format_number(val) -> str: """Format a number for SQF config output.""" if isinstance(val, float): if val == int(val) and abs(val) < 1e10: return str(int(val)) return f"{val:.6g}" return str(val) def generate_config_cpp( ammo_overrides: dict[str, list[AmmoOverride]], armor_overrides: dict[str, list[ArmorOverride]], source_mods: set[str], ) -> str: """Generate the config.cpp content.""" lines = [] lines.append('// ArmADump - Rebalance Config') lines.append('// Auto-generated by generate_patches.py') lines.append('// Compare to RHS baseline values') lines.append('//') lines.append('// WARNING: Review output/unmatched.csv for items without RHS equivalents') lines.append('') # CfgPatches lines.append('class CfgPatches {') for mod in sorted(source_mods): safe_name = re.sub(r'[^a-zA-Z0-9_]', '_', mod) lines.append(f' class armadump_{safe_name} {{') lines.append(f' units[] = {{}};') lines.append(f' weapons[] = {{}};') lines.append(f' requiredAddons[] = {{"{mod}"}};') lines.append(f' }};') lines.append('};') lines.append('') # CfgAmmo overrides has_ammo = any(v for v in ammo_overrides.values()) if has_ammo: lines.append('class CfgAmmo {') for mod in sorted(ammo_overrides.keys()): overrides = ammo_overrides[mod] if not overrides: continue for o in sorted(overrides, key=lambda x: x.classname): lines.append(f' class {o.classname} {{') for field_name, val in sorted(o.changes.items()): config_name = FIELD_TO_CONFIG.get(field_name, field_name) if isinstance(val, str) and val.startswith("["): # Array value — write as SQF array lines.append(f' {config_name}[] = {val};') else: lines.append(f' {config_name} = {format_number(val)};') lines.append(' };') lines.append('};') lines.append('') # CfgWeapons overrides (armor) has_armor = any(v for v in armor_overrides.values()) if has_armor: lines.append('class CfgWeapons {') for mod in sorted(armor_overrides.keys()): overrides = armor_overrides[mod] if not overrides: continue for o in sorted(overrides, key=lambda x: x.classname): lines.append(f' class {o.classname} {{') lines.append(f' class ItemInfo {{') lines.append(f' class HitpointsProtectionInfo {{') # Group changes by body part part_changes = defaultdict(dict) for field_name, val in sorted(o.changes.items()): section, prop = ARMOR_FIELD_MAP[field_name] part_changes[section][prop] = val for part in ["Head", "Neck", "Chest", "Diaphragm", "Abdomen", "Body", "Arms", "Legs"]: if part in part_changes: lines.append(f' class {part} {{') for prop, val in sorted(part_changes[part].items()): lines.append( f' {prop} = {format_number(val)};') lines.append(f' }};') lines.append(f' }};') lines.append(f' }};') lines.append(f' }};') lines.append('};') lines.append('') return '\n'.join(lines) def write_unmatched( unmatched_ammo: list, unmatched_armor: list, ): """Write unmatched items to CSV for manual review.""" out_path = OUTPUT_DIR / "unmatched.csv" with open(out_path, "w", encoding="utf-8", newline="") as f: writer = csv.writer(f, delimiter=";") writer.writerow(["type", "source_mod", "classname", "name", "tier", "reason"]) for e in unmatched_ammo: writer.writerow(["ammo", e.source_mod, e.classname, e.name, e.tier, "no_rhs_tier_match"]) for e in unmatched_armor: writer.writerow(["armor", e.source_mod, e.classname, e.name, e.tier, "no_rhs_tier_match"]) print(f" Wrote {len(unmatched_ammo)} unmatched ammo, " f"{len(unmatched_armor)} unmatched armor to {out_path}") # ============================================================================ # Main # ============================================================================ def main(): print("=" * 60) print("ArmADump - Generate CfgPatches Rebalance Mod") print("=" * 60) # Load baseline (RHS + vanilla) print("\nLoading RHS baseline...") rhs_ammo = load_ammo_csv(DATA_DIR / "_rhs" / "ammo.csv", "_rhs") rhs_ammo += load_ammo_csv(DATA_DIR / "_vanilla" / "ammo.csv", "_vanilla") rhs_ammo += load_ammo_csv(DATA_DIR / "_rhsusf" / "ammo.csv", "_rhsusf") rhs_ammo += load_ammo_csv(DATA_DIR / "_rhsgref" / "ammo.csv", "_rhsgref") rhs_ammo += load_ammo_csv(DATA_DIR / "_rhssaf" / "ammo.csv", "_rhssaf") rhs_armor = load_armor_csv(DATA_DIR / "_rhs" / "armor.csv", "_rhs") rhs_armor += load_armor_csv(DATA_DIR / "_vanilla" / "armor.csv", "_vanilla") rhs_armor += load_armor_csv(DATA_DIR / "_rhsusf" / "armor.csv", "_rhsusf") rhs_armor += load_armor_csv(DATA_DIR / "_rhsgref" / "armor.csv", "_rhsgref") rhs_armor += load_armor_csv(DATA_DIR / "_rhssaf" / "armor.csv", "_rhssaf") print(f" RHS ammo: {len(rhs_ammo)} entries") print(f" RHS armor: {len(rhs_armor)} entries") ammo_baseline = build_ammo_baseline(rhs_ammo) armor_baseline = build_armor_baseline(rhs_armor) print(f" Ammo baseline tiers: {sorted(ammo_baseline.keys())}") print(f" Armor baseline tiers: {sorted(armor_baseline.keys())}") # Scan all mods print("\nScanning non-baseline mods...") all_ammo_overrides = {} all_armor_overrides = {} all_source_mods = set() unmatched_ammo = [] unmatched_armor = [] for mod_dir in sorted(DATA_DIR.iterdir()): if not mod_dir.is_dir(): continue if mod_dir.name.startswith("_"): continue # Skip baseline mods mod_ammo = load_ammo_csv(mod_dir / "ammo.csv", mod_dir.name) mod_armor = load_armor_csv(mod_dir / "armor.csv", mod_dir.name) if not mod_ammo and not mod_armor: continue print(f"\n {mod_dir.name}: {len(mod_ammo)} ammo, {len(mod_armor)} armor") # Find ammo overrides ammo_ovs = find_ammo_overrides(mod_ammo, ammo_baseline) if ammo_ovs: all_ammo_overrides[mod_dir.name] = ammo_ovs all_source_mods.add(mod_dir.name) print(f" Ammo overrides: {len(ammo_ovs)}") # Track unmatched for e in mod_ammo: key = (e.tier, e.subtype) if key not in ammo_baseline: unmatched_ammo.append(e) # Find armor overrides armor_ovs = find_armor_overrides(mod_armor, armor_baseline) if armor_ovs: all_armor_overrides[mod_dir.name] = armor_ovs all_source_mods.add(mod_dir.name) print(f" Armor overrides: {len(armor_ovs)}") for e in mod_armor: key = (e.item_type, e.tier) if key not in armor_baseline: unmatched_armor.append(e) # Generate output print("\nGenerating config.cpp...") OUTPUT_DIR.mkdir(parents=True, exist_ok=True) config_cpp = generate_config_cpp( all_ammo_overrides, all_armor_overrides, all_source_mods) config_path = OUTPUT_DIR / "config.cpp" with open(config_path, "w", encoding="utf-8") as f: f.write(config_cpp) print(f" Wrote {config_path}") print("\nWriting unmatched items...") write_unmatched(unmatched_ammo, unmatched_armor) # Summary total_ammo = sum(len(v) for v in all_ammo_overrides.values()) total_armor = sum(len(v) for v in all_armor_overrides.values()) print(f"\n{'=' * 60}") print(f"DONE - {total_ammo} ammo overrides, {total_armor} armor overrides") print(f" across {len(all_source_mods)} mods") print(f" Config: {config_path.relative_to(REPO_ROOT)}") print(f" Unmatched: {REPO_ROOT / 'output' / 'unmatched.csv'}") print(f"{'=' * 60}") if __name__ == "__main__": main()