Files
A3Rebalancer/scripts/generate_patches.py
T
Samuele Lorefice 83d2cb6c52 Initial commit: ArmADump ammo/armor rebalance tool
- SQF extraction scripts (ammo, armor, weapons, magazines) with ACE3 properties
- Python pipeline: extract_csvs, split_by_mod, generate_patches
- Generates CfgPatches config.cpp normalizing non-RHS values to RHS baseline
- Documentation: REFERENCE.md, GENERATE_PATCHES_EXPLAINED.md, AGENTS.md
2026-07-20 18:27:33 +02:00

800 lines
28 KiB
Python

"""
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()