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:
Samuele Lorefice
2026-07-20 18:27:33 +02:00
commit 83d2cb6c52
11 changed files with 2194 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
"""
extract_csvs.py — Extract CSV data from Arma 3 .rpt log file.
Parses the .rpt log and extracts CSV blocks delimited by
--- ... START --- / --- ... END --- markers.
Usage:
python scripts/extract_csvs.py path/to/arma3.rpt
python scripts/extract_csvs.py path/to/arma3.rpt --output-dir ./extracted
Output files (in repo root by default):
ammo_raw.csv
armor_raw.csv
weapons_raw.csv
magazines_raw.csv
"""
import argparse
import re
import sys
from pathlib import Path
# Arma 3 .rpt lines start with "HH:MM:SS " timestamp prefix
RPT_TIMESTAMP_RE = re.compile(r'^\d{2}:\d{2}:\d{2}\s+')
# Marker patterns -> output filename
MARKERS = {
"AMMO EXPORT CSV": "ammo_raw.csv",
"ARMOR EXPORT CSV": "armor_raw.csv",
"WEAPONS EXPORT CSV": "weapons_raw.csv",
"MAGAZINES EXPORT CSV": "magazines_raw.csv",
}
# Also detect the old sheet-style markers for backwards compat
LEGACY_MARKERS = {
"CONFIG DUMP CSV SHEET 3 (AMMO)": "ammo_raw.csv",
"CONFIG DUMP SHEET 1": "weapons_raw.csv",
"CONFIG DUMP SHEET 2": "armor_raw.csv",
}
# Header line prefix to skip (the diag_log text("name;...") header)
HEADER_PREFIXES = ["name;", "name ", "source_mod;"]
def extract_from_rpt(rpt_path: Path, output_dir: Path) -> dict[str, int]:
"""Parse .rpt file and extract CSV blocks.
Returns dict of filename -> number of data rows extracted.
"""
if not rpt_path.exists():
print(f"Error: {rpt_path} not found")
sys.exit(1)
print(f"Reading: {rpt_path}")
print(f"File size: {rpt_path.stat().st_size / 1024 / 1024:.1f} MB")
# Read the full file
with open(rpt_path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
print(f"Total lines: {len(lines)}")
# Find all START/END marker positions
# Format: --- AMMO EXPORT CSV START ---
# --- AMMO EXPORT CSV END ---
start_pattern = re.compile(r'^\s*---\s+(.+?)\s+START\s+---\s*$')
end_pattern = re.compile(r'^\s*---\s+(.+?)\s+END\s+---\s*$')
# Build list of (start_line, end_line, marker_name)
blocks = []
active_block = None
for i, line in enumerate(lines):
# Strip RPT timestamp prefix (e.g. "17:55:18 ")
line_clean = RPT_TIMESTAMP_RE.sub('', line).strip()
# Strip surrounding quotes (.rpt wraps diag_log strings in "...")
if line_clean.startswith('"') and line_clean.endswith('"'):
line_clean = line_clean[1:-1].strip()
start_match = start_pattern.match(line_clean)
if start_match:
marker = start_match.group(1).strip()
active_block = {"marker": marker, "start": i + 1, "end": None}
continue
end_match = end_pattern.match(line_clean)
if end_match:
marker = end_match.group(1).strip()
if active_block and active_block["marker"] == marker:
active_block["end"] = i # end line (exclusive)
blocks.append(active_block)
active_block = None
print(f"Found {len(blocks)} CSV blocks in .rpt")
# Map marker names to output filenames
marker_to_file = {}
for marker_name, filename in {**MARKERS, **LEGACY_MARKERS}.items():
marker_to_file[marker_name] = filename
# Extract each block
results = {}
output_dir.mkdir(parents=True, exist_ok=True)
for block in blocks:
marker = block["marker"]
filename = marker_to_file.get(marker)
if not filename:
# Try partial match
for key, fname in marker_to_file.items():
if key in marker:
filename = fname
break
if not filename:
print(f" Skipping unrecognized marker: {marker}")
continue
# Extract lines between START and END (exclusive of marker lines)
data_lines = []
header_found = False
for line in lines[block["start"]:block["end"]]:
# Strip RPT timestamp prefix
stripped = RPT_TIMESTAMP_RE.sub('', line).strip()
# Strip surrounding quotes
if stripped.startswith('"') and stripped.endswith('"'):
stripped = stripped[1:-1].strip()
# Skip empty lines
if not stripped:
continue
# Remove diag_log wrapper if present: text("...") -> ...
cleaned = stripped
if cleaned.startswith('text("') and cleaned.endswith('")'):
cleaned = cleaned[6:-2]
elif cleaned.startswith("text('") and cleaned.endswith("')"):
cleaned = cleaned[6:-2]
# Keep only the first header row (diag_log text("name;..."))
if any(cleaned.startswith(p) for p in HEADER_PREFIXES):
if not header_found:
data_lines.append(cleaned)
header_found = True
continue
data_lines.append(cleaned)
out_path = output_dir / filename
with open(out_path, "w", encoding="utf-8", newline="") as f:
for line in data_lines:
f.write(line + "\n")
results[filename] = len(data_lines)
print(f" {filename:25s} -> {len(data_lines):6d} rows ({out_path})")
return results
def main():
parser = argparse.ArgumentParser(
description="Extract CSV data from Arma 3 .rpt log file")
parser.add_argument(
"rpt_file",
help="Path to Arma 3 .rpt log file")
parser.add_argument(
"--output-dir", "-o",
default=".",
help="Output directory for CSV files (default: repo root)")
args = parser.parse_args()
rpt_path = Path(args.rpt_file).resolve()
output_dir = Path(args.output_dir).resolve()
print("=" * 60)
print("ArmADump - Extract CSVs from .rpt log")
print("=" * 60)
results = extract_from_rpt(rpt_path, output_dir)
print(f"\n{'=' * 60}")
if results:
print(f"Extracted {len(results)} CSV files to {output_dir}")
for fname, count in sorted(results.items()):
print(f" {fname}: {count} rows")
print("\nNext step: python scripts/split_by_mod.py")
else:
print("No CSV blocks found in .rpt file!")
print("Make sure the extraction scripts were run in Arma first.")
print("=" * 60)
if __name__ == "__main__":
main()
+799
View File
@@ -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()
+186
View File
@@ -0,0 +1,186 @@
"""
split_by_mod.py — Split raw Arma 3 CSV exports into per-mod folders.
Reads raw CSV files (extracted from .rpt log) and organizes them by source mod.
Each raw CSV has a 'source_mod' column (first column) that identifies which
CfgPatches class defined that entry.
Usage:
python scripts/split_by_mod.py
Input files (in repo root):
ammo_raw.csv — from ammo.sqf
armor_raw.csv — from armor.sqf
weapons_raw.csv — from weapons.sqf
magazines_raw.csv — from magazines.sqf
Output structure:
data/
├── _rhs/
│ ├── ammo.csv
│ ├── armor.csv
│ ├── weapons.csv
│ ├── magazines.csv
├── _vanilla/
│ ├── ammo.csv
│ ├── ...
├── [mod_name]/
│ ├── ammo.csv
│ ├── ...
"""
import csv
import os
import re
import sys
from collections import defaultdict
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
DATA_DIR = REPO_ROOT / "data"
# Map (input_filename, output_filename)
CSV_FILES = [
("ammo_raw.csv", "ammo.csv"),
("armor_raw.csv", "armor.csv"),
("weapons_raw.csv", "weapons.csv"),
("magazines_raw.csv", "magazines.csv"),
]
# Known CfgPatches -> folder name mappings
# Prefix with _ for baseline mods (rhs, vanilla) so they sort first
# Order matters: more specific prefixes first
MOD_FOLDER_PREFIXES = [
# RHS family
("rhs_", "_rhs"),
("rhsusf_", "_rhsusf"),
("rhsgref_", "_rhsgref"),
("rhssaf_", "_rhssaf"),
# Vanilla
("A3_", "_vanilla"),
("A3a3_", "_vanilla"),
# ACE
("ace_", "_ace"),
# CBA
("cba_", "_cba"),
]
# Exact matches for edge cases
MOD_FOLDER_EXACT = {
"ace_main": "_ace",
"ace_ballistics": "_ace",
"cba_common": "_cba",
"cba_xeh": "_cba",
}
def sanitize_folder_name(mod_name: str) -> str:
"""Convert a CfgPatches class name to a safe folder name.
Uses prefix matching for known mod families, then falls back to
extracting the first meaningful segment.
"""
# Exact match first
if mod_name in MOD_FOLDER_EXACT:
return MOD_FOLDER_EXACT[mod_name]
# Prefix matching for known families
for prefix, folder in MOD_FOLDER_PREFIXES:
if mod_name.startswith(prefix):
return folder
# Heuristic: take the first segment before underscore,
# stripping common suffixes
parts = mod_name.lower().split("_")
suffixes = {"main", "weapons", "data", "config", "core", "compat",
"f", "e", "c", "add", "addons"}
clean = [p for p in parts if p not in suffixes and p]
if clean:
return clean[0]
return mod_name.lower()
def parse_rpt_csv(filepath: Path) -> list[list[str]]:
"""Parse a semicolon-delimited CSV file extracted from .rpt log.
Handles lines that may have been wrapped or have trailing semicolons.
"""
rows = []
if not filepath.exists():
print(f" Warning: {filepath} not found, skipping")
return rows
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if not line:
continue
# Split on semicolons, strip whitespace
parts = [p.strip() for p in line.split(";")]
# Remove trailing empty fields (from trailing semicolons)
while parts and parts[-1] == "":
parts.pop()
if parts:
rows.append(parts)
return rows
def split_csv(input_name: str, output_name: str):
"""Split a single raw CSV file into per-mod files."""
input_path = REPO_ROOT / input_name
print(f"\nProcessing {input_name}...")
rows = parse_rpt_csv(input_path)
if not rows:
return
# First row is header
header = rows[0]
data_rows = rows[1:]
# source_mod is always the first column
mod_groups = defaultdict(list)
for row in data_rows:
if len(row) < 2:
continue
mod_name = row[0].strip().strip('"')
folder = sanitize_folder_name(mod_name)
mod_groups[folder].append(row)
print(f" Found {len(mod_groups)} unique mods, {len(data_rows)} total entries")
# Write per-mod files
for folder, group_rows in sorted(mod_groups.items()):
mod_dir = DATA_DIR / folder
mod_dir.mkdir(parents=True, exist_ok=True)
out_path = mod_dir / output_name
with open(out_path, "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f, delimiter=";")
writer.writerow(header)
writer.writerows(group_rows)
print(f" {folder:30s} -> {len(group_rows):5d} entries ({out_path.relative_to(REPO_ROOT)})")
def main():
print("=" * 60)
print("ArmADump - Split CSVs by Mod")
print("=" * 60)
print(f"Repository root: {REPO_ROOT}")
print(f"Output directory: {DATA_DIR}")
DATA_DIR.mkdir(parents=True, exist_ok=True)
for input_name, output_name in CSV_FILES:
split_csv(input_name, output_name)
print("\n" + "=" * 60)
print("Done! Per-mod CSVs written to data/")
print("=" * 60)
if __name__ == "__main__":
main()