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,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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user