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
This commit is contained in:
@@ -0,0 +1,799 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user