From 83d2cb6c526a7d5b8668378a832c24df42525f35 Mon Sep 17 00:00:00 2001 From: Samuele Lorefice Date: Mon, 20 Jul 2026 18:27:33 +0200 Subject: [PATCH] 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 --- .gitignore | 8 + AGENTS.md | 82 ++++ GENERATE_PATCHES_EXPLAINED.md | 351 +++++++++++++++ REFERENCE.md | 181 ++++++++ ammo.sqf | 120 +++++ armor.sqf | 123 ++++++ magazines.sqf | 70 +++ scripts/extract_csvs.py | 191 ++++++++ scripts/generate_patches.py | 799 ++++++++++++++++++++++++++++++++++ scripts/split_by_mod.py | 186 ++++++++ weapons.sqf | 83 ++++ 11 files changed, 2194 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 GENERATE_PATCHES_EXPLAINED.md create mode 100644 REFERENCE.md create mode 100644 ammo.sqf create mode 100644 armor.sqf create mode 100644 magazines.sqf create mode 100644 scripts/extract_csvs.py create mode 100644 scripts/generate_patches.py create mode 100644 scripts/split_by_mod.py create mode 100644 weapons.sqf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d20c2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Raw CSV exports from Arma .rpt +*_raw.csv + +# Generated output +output/ + +# Per-mod split data +data/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..88b3923 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,82 @@ +# ArmADump + +Arma 3 config export and rebalance tool. Exports ammo/armor/weapons/magazines to CSV, then generates a CfgPatches mod to normalize non-RHS values to RHS baseline. + +## What this repo is + +### Extraction scripts (run in Arma 3) +- **`ammo.sqf`** — CfgAmmo export with vanilla + ACE3 ballistics properties. Uses BFS with `sleep 0` batching. +- **`armor.sqf`** — Vest + HeadGear export with per-body-part armor/passthrough values. +- **`weapons.sqf`** — Weapon export with ACE barrel properties (`ACE_barrelLength`, `ACE_barrelTwist`). +- **`magazines.sqf`** — Magazine export linking to ammo classes with `initSpeed`. + +### Legacy scripts (kept for reference) +- **`sheet1.sqf`** — Weapons + magazines (old format). +- **`sheet2.sqf`** — Items/gear (old format). +- **`sheet3_ammo.sqf`** — Ammo (old format, no ACE properties). +- **`classesExport.sqf`** — Original monolithic script. + +### Processing pipeline (Python) +- **`scripts/split_by_mod.py`** — Reads raw CSVs from `.rpt`, splits into per-mod folders under `data/`. +- **`scripts/generate_patches.py`** — Compares all mods to RHS baseline, generates `output/config.cpp`. + +### Reference +- **`REFERENCE.md`** — Quick reference for all config values (vanilla + ACE3). + +## Workflow + +### Step 1: Run extraction scripts in Arma 3 +Run each script in debug console (or `execVM`). Each outputs CSV via `diag_log` to `.rpt`: +``` +execVM "ammo.sqf" +execVM "armor.sqf" +execVM "weapons.sqf" +execVM "magazines.sqf" +``` + +### Step 2: Extract CSVs from .rpt +```bash +python scripts/extract_csvs.py "path/to/arma3.rpt" +``` +Parses the `.rpt` log and extracts CSVs between `--- ... START/END ---` markers into `ammo_raw.csv`, `armor_raw.csv`, `weapons_raw.csv`, `magazines_raw.csv`. + +### Step 3: Split by mod +```bash +python scripts/split_by_mod.py +``` +Creates `data/[mod_name]/` folders with per-mod CSVs. Baseline mods (`_rhs`, `_vanilla`, `_ace`) sort first. + +### Step 4: Generate rebalance mod +```bash +python scripts/generate_patches.py +``` +Produces: +- `output/config.cpp` — CfgPatches config with overrides +- `output/unmatched.csv` — Items with no RHS equivalent (review manually) + +### Step 5: Load in Arma +Drop `output/config.cpp` into an `@mod/addons/config.cpp` structure and load it. + +## How the scripts work + +- Each SQF script takes optional arguments: `[name, CfgPatches name]` for a mod, `[name]` for vanilla BIS. +- Uses `diag_log text(...)` for CSV output — extract from `.rpt` log. +- Separator is `;` (semicolon). No quoting. +- `configSourceAddonList` tags each entry with its source CfgPatches class. +- `ammo.sqf` uses iterative BFS with combined traversal+extraction and `sleep 0` every 200 entries. + +## Conventions + +- SQF files use 8-space indentation (tabs). +- CSVs use `;` delimiter. +- Python scripts use UTF-8 encoding. +- Per-mod folders use the CfgPatches class name as folder name. + +## Gotchas + +- Scripts output to Arma's `.rpt` log, not directly to a file. +- `ammo.sqf` uses `sleep` which requires scheduled environment (`execVM` or `spawn`). +- CfgAmmo BFS depth limit of 8 levels prevents exponential blowup. +- `continue` keyword avoided for pre-2.14 Arma compatibility. +- `generate_patches.py` classifies ammo by (caliber_tier, subtype). Items with no RHS tier match appear in `unmatched.csv`. +- Armor matching uses (type, mass_tier). Soft armor items (chest_armor=0) are in their own tier. diff --git a/GENERATE_PATCHES_EXPLAINED.md b/GENERATE_PATCHES_EXPLAINED.md new file mode 100644 index 0000000..822f0db --- /dev/null +++ b/GENERATE_PATCHES_EXPLAINED.md @@ -0,0 +1,351 @@ +# Generate Patches — How It Works + +This document explains the logic behind `generate_patches.py`, the script that produces the rebalance `config.cpp`. + +--- + +## Purpose + +Many Arma 3 mods define ammo and armor with values that don't match RHS standards. This script: + +1. Reads the extracted CSV data for every installed mod +2. Compares each mod's values against RHS as the baseline +3. Generates a `config.cpp` mod that overrides non-RHS values to match RHS equivalents + +The result is a single drop-in mod that normalizes your entire mod list to RHS balance. + +--- + +## Overview of the Process + +``` +Per-mod CSV files (data/) + | + v + +-------------------+ + | Load baseline | Reads RHS + vanilla + RHS sub-mods + | (RHS / vanilla) | as the "correct" reference values + +-------------------+ + | + v + +-------------------+ + | Classify every | Each ammo/armor item is sorted into a + | entry into tiers | "tier" group based on caliber or protection level + +-------------------+ + | + v + +-------------------+ + | Build baseline | For each tier, pick one representative RHS entry + | lookup table | to use as the reference values + +-------------------+ + | + v + +-------------------+ + | Compare each mod | For every non-RHS entry, look up its tier's + | against baseline | RHS baseline and record any differences + +-------------------+ + | + v + +-------------------+ + | Generate | Write a config.cpp that overrides every + | config.cpp | differing value to match the RHS baseline + +-------------------+ + | + v + output/config.cpp (the rebalance mod) + output/unmatched.csv (items with no RHS equivalent) +``` + +--- + +## Step 1: Loading Baseline Data + +The script loads CSVs from these folders as the baseline (the "correct" values): + +| Folder | Source | +|---|---| +| `_rhs` | RHS AFRF (Russian equipment) | +| `_rhsusf` | RHS USMC (American equipment) | +| `_rhsgref` | RHS GREF (generic/other factions) | +| `_rhssaf` | RHS SAF (Serbian equipment) | +| `_vanilla` | Arma 3 base game (BI) | +| `_ace` | ACE3 mod (ballistic overrides) | + +All entries from these mods are pooled together. When two entries exist in the same tier, RHS entries are preferred over vanilla. + +--- + +## Step 2: Classification — Putting Items Into Tiers + +Every ammo and armor item is classified into a **tier**. Items in the same tier are considered equivalent — a 5.56mm round from mod A should have similar stats to a 5.56mm round from RHS. + +### Ammo Classification + +Ammo is classified by **two properties**: a **caliber tier** (the type of round) and a **subtype** (ball, AP, tracer, etc.). + +#### How Caliber Tier Is Determined + +The script checks the ammo class name against a list of patterns. The first pattern that matches wins. This is done by name, not by the numeric `caliber` value, because Arma's `caliber` property is a penetration coefficient, not the actual bullet caliber. + +| Pattern in class name | Tier | Typical RHS values (hit / caliber / speed) | +|---|---|---| +| `9x19`, `9mm`, `9x18` | `pistol_9mm` | 5 / 0.4 / 370 | +| `45acp`, `.45` | `pistol_45acp` | 6 / 0.5 / 260 | +| `57x28`, `5.7` | `smg_57x28` | 4 / 0.3 / 390 | +| `46x30`, `4.6` | `smg_46x30` | 4 / 0.3 / 375 | +| `545x39`, `5.45` | `rifle_545x39` | 9 / 0.65 / 900 | +| `556x45`, `5.56` | `rifle_556x45` | 9 / 0.87 / 920 | +| `762x39` | `rifle_762x39` | 11 / 1.2 / 730 | +| `762x51`, `.308` | `rifle_762x51` | 11.6 / 1.6 / 800 | +| `762x54` | `rifle_762x54` | 11.6 / 1.8 / 830 | +| `300blk` | `rifle_300blk` | (no RHS baseline) | +| `338`, `338lapua` | `sniper_338` | (no RHS baseline) | +| `408`, `cheytac` | `sniper_408` | (no RHS baseline) | +| `127x99`, `50bmg`, `12.7` | `hmg_50` | 22 / 2.8 / 900 | +| `145x114`, `14.5` | `hmg_145` | 28 / 3.5 / 950 | +| `20mm` | `cannon_20` | 30 / 1.2 / 1000 | +| `23mm` | `cannon_23` | 40 / 1.5 / 950 | +| `25mm` | `cannon_25` | 60 / 2.0 / 900 | +| `30mm` | `cannon_30` | 80 / 2.5 / 850 | +| `35mm` | `cannon_35` | 85 / 2.5 / 850 | +| `40mm`, `mk19` | `cannon_40` | 100 / 2.5 / 800 | +| `mortar`, `82mm`, `81mm` | `mortar_82` | varies | +| `100mm` and above | `gun_heavy` | varies | +| `12gauge`, `buckshot` | `shotgun_12g` | varies | +| `rocket`, `pg7`, `rpg` | `rocket` | varies | +| `missile`, `titan`, `hellfire` | `missile` | varies | +| `grenade`, `40mm_he` | `grenade_40mm` | varies | +| `mine`, `ied`, `satchel` | `explosive` | varies | +| `penetrator`, `heat` | `penetrator` | varies | + +If no name pattern matches, the script falls back to the `simulation` property: + +| Simulation | Tier | +|---|---| +| `shotBullet` (speed < 350) | `bullet_subsonic` | +| `shotBullet` (other) | `bullet_unknown` | +| `shotShell` + explosive > 0.5 | `shell_he` | +| `shotShell` (other) | `shell_ap` | +| `shotMissile` | `missile` | +| `shotRocket` | `rocket` | +| `shotGrenade` | `grenade_40mm` | +| `shotIlluminating`, `shotSmokeX` | `utility` | + +#### How Subtype Is Determined + +Within each caliber tier, items are further sorted into subtypes by checking the class name for keywords: + +| Condition | Subtype | +|---|---| +| Speed > 0 and < 350 m/s | `subsonic` | +| Name contains "ap" | `ap` | +| Name contains "tracer" | `ball_tracer` | +| Name contains "match", "sniper", "otm", "mk262", "mk316" | `match` | +| Name contains "incendiary" or "incen" | `incendiary` | +| Name contains "hedp" | `hedp` | +| Name contains "he" (but not "hedp") | `he` | +| Everything else | `ball` | + +### Armor Classification + +Armor is classified by **item type** (Vest or HeadGear) and a **protection tier**. + +#### Vest Tiers + +Based on the `armor_chest` value (front plate protection): + +| Chest Armor Value | Tier | Examples | +|---|---|---| +| 0 (no plate) | `soft` | Cloth vests, chest rigs | +| 1 - 16 | `light` | Plate carrier lite, PASGT vest | +| 17 - 31 | `medium` | IOTV, SPC, 6B13 | +| 32 - 50 | `heavy` | Full battle carrier | +| 50+ | `eod` | EOD suits, GL carriers | + +#### Helmet Tiers + +Based on the `armor_head` value: + +| Head Armor Value | Tier | Examples | +|---|---|---| +| 0 (no armor) | `cap` | Berets, caps | +| 1 - 6 | `light` | Cloth helmets, bump helmets | +| 7 - 15 | `medium` | PASGT, MICH | +| 16 - 25 | `heavy` | ACH, ECH, 6B47 | +| 25+ | `special` | Specialized heavy helmets | + +--- + +## Step 3: Building the Baseline Lookup Table + +After all entries are loaded and classified, the script builds a lookup table. For each tier, it picks one representative RHS entry to use as the reference. + +**Example:** For the `rifle_556x45` / `ball` tier, the baseline might be RHS's `B_556x45_Ball` with: +- hit = 9 +- caliber = 0.87 +- typicalSpeed = 920 +- airFriction = -0.001033 +- deflecting = 21 + +Any 5.56mm ball round from any other mod will be compared against these values. + +--- + +## Step 4: Finding Overrides + +For each non-RHS mod entry, the script: + +1. Looks up the entry's tier in the baseline table +2. If the tier exists in the baseline, compares every field +3. If a field differs and the mod's value is not zero, records a change to the baseline value + +### What Gets Compared + +**Ammo fields (14 values):** + +| Field | Arma Config Name | What It Controls | +|---|---|---| +| `hit` | `hit` | Direct damage on impact | +| `indirect_hit` | `indirectHit` | Splash damage | +| `indirect_hit_range` | `indirectHitRange` | Splash radius (meters) | +| `caliber` | `caliber` | Penetration coefficient | +| `typical_speed` | `typicalSpeed` | Muzzle velocity (m/s) | +| `air_friction` | `airFriction` | Drag coefficient | +| `deflecting` | `deflecting` | Min ricochet angle (degrees) | +| `explosive` | `explosive` | 0 = kinetic, 1 = explosive | +| `ace_caliber` | `ACE_caliber` | Actual bullet diameter (mm) | +| `ace_bullet_length` | `ACE_bulletLength` | Bullet length (mm) | +| `ace_bullet_mass` | `ACE_bulletMass` | Bullet mass (grams) | +| `ace_drag_model` | `ACE_dragModel` | Drag curve (1=G1, 7=G7, etc.) | +| `ace_transonic` | `ACE_transonicStabilityCoef` | Transonic stability (0-1) | +| `ace_mv_var` | `ACE_muzzleVelocityVariationSD` | Velocity spread (%) | + +Plus 4 ACE array fields compared as text: +- `ACE_ballisticCoefficients[]` — drag coefficient at different velocities +- `ACE_velocityBoundaries[]` — velocity thresholds for BC changes +- `ACE_muzzleVelocities[]` — muzzle velocity per barrel length +- `ACE_barrelLengths[]` — barrel lengths for velocity interpolation + +**Armor fields (16 values):** + +For each body part (Head, Neck, Chest, Diaphragm, Abdomen, Body, Arms, Legs): +- `armor` — additional armor points +- `passThrough` — 0 = full block, 1 = no protection + +### What Does NOT Get Overridden + +- Fields where the mod's value is zero (treated as "not defined, keep it") +- `passThrough` values where both the mod and RHS baseline are 1.0 (no change for unarmored areas) + +--- + +## Step 5: Generating config.cpp + +The output is a standard Arma 3 `config.cpp` with three sections: + +### CfgPatches (Dependency Declaration) + +Each mod that has overrides gets a dependency entry: + +```cpp +class CfgPatches { + class armadump_afou { + units[] = {}; + weapons[] = {}; + requiredAddons[] = {"afou"}; + }; +}; +``` + +This tells Arma to load our overrides after the original mod. + +### CfgAmmo (Ammo Overrides) + +```cpp +class CfgAmmo { + class B_afou_556x45_Ball { + airFriction = -0.001033; + caliber = 0.87; + deflecting = 21; + hit = 9; + typicalSpeed = 920; + }; +}; +``` + +Only fields that differ from RHS are listed. Fields not listed keep their original mod values. + +### CfgWeapons (Armor Overrides) + +```cpp +class CfgWeapons { + class MyMod_Vest { + class ItemInfo { + class HitpointsProtectionInfo { + class Chest { + armor = 24; + passThrough = 0.5; + }; + class Abdomen { + armor = 16; + passThrough = 0.6; + }; + }; + }; + }; +}; +``` + +Only body parts with differing armor values are listed. + +--- + +## Step 6: Unmatched Items + +Items that are classified into a tier with no RHS baseline equivalent are written to `output/unmatched.csv` for manual review. + +**Common reasons an item is unmatched:** + +| Reason | Example | +|---|---| +| Caliber has no RHS equivalent | `.300 Blackout`, `.57x28mm`, `.338 Lapua` | +| Subtype has no RHS equivalent | Tracer variants when only ball exists in RHS | +| Simulation-based fallback tier | Unknown simulation type | + +These items are **not** included in the generated config.cpp. They retain their original mod values. + +--- + +## What You End Up With + +After running the script: + +1. **`output/config.cpp`** — Drop this into an `@mod/addons/` folder and load it in Arma. It silently overrides every non-RHS ammo and armor value to match RHS balance. + +2. **`output/unmatched.csv`** — A spreadsheet of items that couldn't be matched. Review these to decide if they need manual adjustment. + +3. **No changes to original mods** — The script only reads data. Original mod files are untouched. + +--- + +## Example: Tracing One Entry + +Taking the AFou mod's 5.56mm ball round as an example: + +1. **Classification**: Class name `B_afou_556x45_Ball` matches pattern `556x45` -> tier `rifle_556x45`. No "tracer", "ap", etc. in name -> subtype `ball`. + +2. **Baseline lookup**: Tier `rifle_556x45` / `ball` maps to RHS's `B_556x45_Ball`: + - hit = 9, caliber = 0.87, typicalSpeed = 920, airFriction = -0.001033, deflecting = 21 + +3. **Comparison**: AFou's values differ on airFriction, caliber, deflecting, hit, typicalSpeed. + +5. **Override generated**: The config.cpp sets those 5 fields to RHS values. All other fields (ACE properties, cost, etc.) keep AFou's original values. + +--- + +## Limitations + +- **Tier-based, not per-class matching**: All 5.56mm ball rounds are normalized to the same RHS reference. If a mod intentionally makes a round weaker or stronger (e.g., training ammo), it will be overwritten. +- **No threshold filtering**: Even a difference of 0.001 generates an override. There is no minimum difference filter. +- **First RHS entry wins**: If multiple RHS entries exist in the same tier, the first one encountered is used. This is usually fine since RHS values within a tier are consistent. +- **Unmatched items skipped**: Calibers without an RHS equivalent (like .300 BLK) are left untouched. diff --git a/REFERENCE.md b/REFERENCE.md new file mode 100644 index 0000000..546130b --- /dev/null +++ b/REFERENCE.md @@ -0,0 +1,181 @@ +# Arma 3 / ACE3 Config Values Reference + +Quick reference for all config properties relevant to ammo ballistics and armor protection. + +--- + +## CfgAmmo — Vanilla Properties + +| Property | Type | Description | +|---|---|---| +| `simulation` | String | Physics model: `shotBullet`, `shotShell`, `shotMissile`, `shotRocket`, `shotGrenade`, `shotSubmunitions`, `shotSpread`, `shotIlluminating`, `shotSmokeX`, `shotTimeBomb`, `shotMine`, `shotLaser`, `shotDeploy` | +| `hit` | Number | Direct damage on impact. Bullets: 5-25, Shells: 50-200, Missiles: 100-10000 | +| `indirectHit` | Number | Splash damage at impact point. 0 for bullets, high for HE. | +| `indirectHitRange` | Number | Splash radius in meters. 0 for bullets, 3-15 for HE/shells. | +| `caliber` | Number | Penetration coefficient. <1 = weak, 1 = standard, >1 = armor-piercing. Formula: effective_armor = armor / caliber | +| `typicalSpeed` | Number | Muzzle velocity in m/s for vanilla drag model. | +| `airFriction` | Number | Vanilla drag coefficient (always negative). Higher magnitude = more drag. | +| `visibleFire` | Number | How much firing reveals position. 2-3 for small arms, 32 for cannon. | +| `audibleFire` | Number | How loud the shot is. 0.25 suppressed, 16-40 unsuppressed, 200-250 cannon. | +| `cost` | Number | AI ammo expenditure priority. 0.7 for bullets, 100-500 for missiles. | +| `explosive` | Number | 0 = kinetic penetrator, 1 = HE/explosive. | +| `deflecting` | Number | Minimum angle (degrees) for ricochet. 0 = none, 5 = base, 60 = very high. | +| `airLock` | Number | 0 = none, 1 = lock-on capable. | +| `cartridge` | String | Visual cartridge effect class (e.g., `FxCartridge_556`). | + +### Simulation Types + +| Simulation | Used For | Key Behavior | +|---|---|---| +| `shotBullet` | Rifle/pistol rounds, submunitions | Traced, ricochets, penetration | +| `shotShell` | Cannon shells, grenades, penetrators | Explosive, splash damage | +| `shotMissile` | Guided missiles, rockets | Lock-on, tracking, airFriction > 0 | +| `shotRocket` | Unguided rockets | Untracked flight, airFriction > 0 | +| `shotGrenade` | Hand/UGL grenades | Arched trajectory | +| `shotSubmunitions` | Cluster submunitions | Deploys children | +| `shotSpread` | Shotgun pellets | Spread pattern | +| `shotIlluminating` | Flares | Light emission | +| `shotSmokeX` | Smoke grenades | Particle effects | + +--- + +## CfgAmmo — ACE3 Properties + +All ACE3 properties are placed **directly on the ammo class** (no sub-class nesting). + +| Property | Type | Unit | Description | +|---|---|---|---| +| `ACE_caliber` | Number | mm | Actual bullet diameter (e.g., 5.69 for 5.56 NATO) | +| `ACE_bulletLength` | Number | mm | Bullet length from base to tip (e.g., 23.012 for M855) | +| `ACE_bulletMass` | Number | grams | Bullet mass (e.g., 4.0176 for M855 5.56mm) | +| `ACE_ballisticCoefficients[]` | Array | — | BC value(s). Single value or velocity-dependent array. Higher BC = less drag. | +| `ACE_velocityBoundaries[]` | Array | m/s | Velocity thresholds where BC changes. Empty `{}` for single BC. | +| `ACE_dragModel` | Number | — | 1=G1, 2=G2, 5=G5, 6=G6, 7=G7 (boat tail), 8=G8. Most rifle ammo = G7. | +| `ACE_standardAtmosphere` | String | — | `"ICAO"` (standard) or `"ASM"` (Army Standard Meteorological) | +| `ACE_muzzleVelocities[]` | Array | m/s | Muzzle velocities at corresponding barrel lengths. | +| `ACE_barrelLengths[]` | Array | mm | Barrel lengths for velocity interpolation. Same size as muzzleVelocities. | +| `ACE_transonicStabilityCoef` | Number | 0-1 | Transonic stability. Default 0.5. | +| `ACE_muzzleVelocityVariationSD` | Number | % | Velocity spread standard deviation. 2 = 2%. | + +### ACE Drag Models + +| Value | Model | Used For | +|---|---|---| +| 1 | G1 | Flat-base bullets (pistol, some SMG) | +| 2 | G2 | Ogive projectiles | +| 5 | G5 | Boat-tail, low L/D | +| 6 | G6 | Boat-tail, high L/D | +| 7 | G7 | Long-range match (most 7.62mm, .338, .50 BMG) | +| 8 | G8 | Very low drag bullets | + +--- + +## CfgWeapons — Armor Properties (HitpointsProtectionInfo) + +**Path:** `configFile >> "CfgWeapons" >> "Item" >> "ItemInfo" >> "HitpointsProtectionInfo" >> "BodyPart"` + +### Body Parts + +| Class | Hitpoint | Coverage | +|---|---|---| +| `Head` | HitHead | Helmet | +| `Neck` | HitNeck | Collar protection | +| `Face` | HitFace | Face shield | +| `Chest` | HitChest | Front plate / upper torso | +| `Diaphragm` | HitDiaphragm | Upper chest, behind sternum | +| `Abdomen` | HitAbdomen | Lower torso | +| `Arms` | HitArms | Arm protection | +| `Legs` | HitLegs | Leg protection | +| `Body` | HitBody | Full body | + +### Per-Body-Part Properties + +| Property | Type | Description | +|---|---|---| +| `hitpointName` | String | References the hitpoint class (e.g., `"HitChest"`) | +| `armor` | Number | Additional armor points. 0-80 typical range. | +| `passThrough` | Number | 0-1. 0 = full block, 1 = no protection. | + +### Armor Value Tiers (RHS baseline) + +| Tier | Chest Armor | Example | +|---|---|---| +| Soft / No plate | 0-6 | Caps, berets, light vests | +| Light plate | 8-16 | Plate carrier lite, PASGT | +| Medium plate | 20-31 | IOTV, SPC, 6B13 | +| Heavy plate | 32-50 | Full EOD, heavy carrier | +| EOD / Special | 50-78 | EOD suits, GL carriers | + +--- + +## CfgWeapons — ACE3 Weapon Properties + +| Property | Type | Unit | Description | +|---|---|---|---| +| `ACE_barrelLength` | Number | mm | Physical barrel length. Used with ammo's `ACE_muzzleVelocities[]` / `ACE_barrelLengths[]` for velocity interpolation. | +| `ACE_barrelTwist` | Number | mm | Rifling twist rate. 304.8mm = 1:12", 228.6mm = 1:9". | +| `ACE_twistDirection` | Number | — | 1 = right twist (default), -1 = left, 0 = none. | + +ACE interpolates muzzle velocity based on weapon barrel length vs ammo barrel length table: + +``` +Weapon ACE_barrelLength = 368mm +Ammo ACE_barrelLengths[] = {210, 250, 300, 370, 450} +Ammo ACE_muzzleVelocities[] = {723, 764, 796, 843, 900} +Result = interpolated ~844 m/s +``` + +--- + +## Caliber Tiers — RHS Baseline + +### Small Arms + +| Caliber | hit | caliber | speed | ACE_BC (G7) | ACE_mass (g) | +|---|---|---|---|---|---| +| 9x19mm | 5-6 | 0.4 | 360-400 | 0.05-0.08 | 7.5-8.0 | +| .45 ACP | 6-7 | 0.5 | 260 | 0.08-0.12 | 14-15 | +| 5.45x39mm | 9-10 | 0.65 | 900 | 0.12-0.16 | 3.0-3.5 | +| 5.56x45mm | 9 | 0.87 | 920 | 0.12-0.20 | 3.5-4.5 | +| 7.62x39mm | 11 | 1.2 | 730 | 0.12-0.15 | 7.5-8.0 | +| 7.62x51mm | 11.6 | 1.6 | 800 | 0.18-0.30 | 9.0-11.0 | +| 7.62x54mmR | 11.6 | 1.8 | 800-860 | 0.18-0.30 | 9.0-12.0 | +| .338 Lapua | 14-16 | 2.0 | 800-900 | 0.28-0.40 | 16-19 | +| .408 CheyTac | 16-18 | 2.2 | 870-910 | 0.35-0.45 | 19-22 | +| 12.7mm (.50 BMG) | 20-25 | 2.5-3.0 | 900 | 0.35-0.60 | 42-46 | +| 14.5mm | 25-30 | 3.0-4.0 | 950 | 0.40-0.60 | 55-65 | + +### Subsonic vs Supersonic + +Subsonic ammo (below ~340 m/s) has significantly lower hit and speed values: + +| Type | hit | speed | Typical caliber | +|---|---|---|---| +| 9mm Subsonic | 3-4 | 290-310 | 9x19mm | +| 5.56mm Subsonic | 4-5 | 310-330 | 5.56x45mm | +| 7.62mm Subsonic | 6-8 | 300-340 | 7.62x51mm | +| .300 BLK Sub | 5-7 | 300-330 | .300 Blackout | + +### AP vs Ball vs Tracer + +| Type | caliber modifier | Typical behavior | +|---|---|---| +| Ball (standard) | Baseline | Normal penetration and damage | +| AP (armor-piercing) | +30-50% higher | Higher caliber, lower hit sometimes | +| Tracer | Same as ball | Adds visible trail | +| Incendiary | Same hit, +explosive | Sets fires | +| Subsonic | -40-60% hit, -60% speed | Quieter, less damage | +| HE (high-explosive) | 0 hit, high indirectHit | Splash damage only | + +--- + +## Config Source Identification + +To determine which mod defined a class, use: + +```sqf +private _sources = configSourceAddonList (configFile >> "CfgAmmo" >> "B_556x45_Ball"); +// Returns array of CfgPatches class names, e.g. ["A3_Weapons_F", "ace_ballistics"] +``` + +This is how we tag each extracted entry with its source mod for per-mod CSV splitting. diff --git a/ammo.sqf b/ammo.sqf new file mode 100644 index 0000000..e63e004 --- /dev/null +++ b/ammo.sqf @@ -0,0 +1,120 @@ +///////// ARMA3 Config CSV EXPORT — Ammo (CfgAmmo) with ACE3 properties +///////// Usage: execVM "ammo.sqf" or paste execVM in debug console +///////// Arguments: [name, CfgPatches name] for a mod, [name] for vanilla BIS +///////// Outputs: CSV via diag_log to .rpt — copy from log after run + +private ["_CfgPatches","_configPath","_getClass","_out","_count","_maxDepth"]; + +_CfgPatches = false; +_configPath = ""; +if(count _this > 1) then +{ + _configPath = _configPath + (_this select 1); + _CfgPatches = true; +}; + +_getClass = { + _out = ""; + if(typename _this == "STRING") then + { + _out = _this; + } + else + { + _out = configName _this; + }; + _out; +}; + +_maxDepth = 8; +_count = 0; + +diag_log "--- AMMO EXPORT CSV START ---"; +diag_log text("source_mod;name;simulation;class_name;parent_class;hit;indirectHit;indirectHitRange;caliber;typicalSpeed;airFriction;visibleFire;audibleFire;cost;explosive;deflecting;airLock;cartridge;ACE_caliber;ACE_bulletLength;ACE_bulletMass;ACE_ballisticCoefficients;ACE_velocityBoundaries;ACE_dragModel;ACE_standardAtmosphere;ACE_muzzleVelocities;ACE_barrelLengths;ACE_transonicStabilityCoef;ACE_muzzleVelocityVariationSD"); + +{ + private _queue = [[_x, 0]]; + while {count _queue > 0} do { + private _pair = _queue deleteAt 0; + private _current = _pair select 0; + private _depth = _pair select 1; + + { + private _sim = getText(configFile >> "CfgAmmo" >> (configName _x) >> "simulation"); + if (_sim != "") then { + private _cn = configName _x; + + // Source mod identification + private _sources = configSourceAddonList (configFile >> "CfgAmmo" >> _cn); + private _sourceMod = "unknown"; + if (count _sources > 0) then { + _sourceMod = _sources select 0; + }; + + private _displayName = getText(configFile >> "CfgAmmo" >> _cn >> "displayName"); + private _name = if (_displayName != "") then { _displayName } else { _cn }; + + private _parentClass = configName inheritsFrom (configFile >> "CfgAmmo" >> _cn); + if (_parentClass == "Default") then { _parentClass = ""; }; + + // Vanilla properties + private _hit = getNumber(configFile >> "CfgAmmo" >> _cn >> "hit"); + private _indirectHit = getNumber(configFile >> "CfgAmmo" >> _cn >> "indirectHit"); + private _indirectHitRange = getNumber(configFile >> "CfgAmmo" >> _cn >> "indirectHitRange"); + private _caliber = getNumber(configFile >> "CfgAmmo" >> _cn >> "caliber"); + private _typicalSpeed = getNumber(configFile >> "CfgAmmo" >> _cn >> "typicalSpeed"); + private _airFriction = getNumber(configFile >> "CfgAmmo" >> _cn >> "airFriction"); + private _visibleFire = getNumber(configFile >> "CfgAmmo" >> _cn >> "visibleFire"); + private _audibleFire = getNumber(configFile >> "CfgAmmo" >> _cn >> "audibleFire"); + private _cost = getNumber(configFile >> "CfgAmmo" >> _cn >> "cost"); + private _explosive = getNumber(configFile >> "CfgAmmo" >> _cn >> "explosive"); + private _deflecting = getNumber(configFile >> "CfgAmmo" >> _cn >> "deflecting"); + private _airLock = getNumber(configFile >> "CfgAmmo" >> _cn >> "airLock"); + private _cartridge = getText(configFile >> "CfgAmmo" >> _cn >> "cartridge"); + + // ACE3 properties + private _aceCaliber = getNumber(configFile >> "CfgAmmo" >> _cn >> "ACE_caliber"); + private _aceBulletLength = getNumber(configFile >> "CfgAmmo" >> _cn >> "ACE_bulletLength"); + private _aceBulletMass = getNumber(configFile >> "CfgAmmo" >> _cn >> "ACE_bulletMass"); + private _aceBCs = getArray(configFile >> "CfgAmmo" >> _cn >> "ACE_ballisticCoefficients"); + private _aceVBs = getArray(configFile >> "CfgAmmo" >> _cn >> "ACE_velocityBoundaries"); + private _aceDragModel = getNumber(configFile >> "CfgAmmo" >> _cn >> "ACE_dragModel"); + private _aceAtmo = getText(configFile >> "CfgAmmo" >> _cn >> "ACE_standardAtmosphere"); + private _aceMVs = getArray(configFile >> "CfgAmmo" >> _cn >> "ACE_muzzleVelocities"); + private _aceBLs = getArray(configFile >> "CfgAmmo" >> _cn >> "ACE_barrelLengths"); + private _aceTransonic = getNumber(configFile >> "CfgAmmo" >> _cn >> "ACE_transonicStabilityCoef"); + private _aceMVVar = getNumber(configFile >> "CfgAmmo" >> _cn >> "ACE_muzzleVelocityVariationSD"); + + // Format arrays as strings + private _bcStr = str _aceBCs; + private _vbStr = str _aceVBs; + private _mvStr = str _aceMVs; + private _blStr = str _aceBLs; + + diag_log text(format["%1;%2;%3;%4;%5;%6;%7;%8;%9;%10;%11;%12;%13;%14;%15;%16;%17;%18;%19;%20;%21;%22;%23;%24;%25;%26;%27;%28;%29", + _sourceMod, _name, _sim, _cn, _parentClass, + _hit, _indirectHit, _indirectHitRange, + _caliber, _typicalSpeed, _airFriction, + _visibleFire, _audibleFire, _cost, _explosive, + _deflecting, _airLock, _cartridge, + _aceCaliber, _aceBulletLength, _aceBulletMass, + _bcStr, _vbStr, _aceDragModel, _aceAtmo, + _mvStr, _blStr, _aceTransonic, _aceMVVar]); + + _count = _count + 1; + if (_count % 200 == 0) then { sleep 0; }; + }; + + if (_depth < _maxDepth) then { + { + _queue pushBack [_x, _depth + 1]; + } forEach ("true" configClasses _x); + }; + + } forEach ("true" configClasses _current); + }; +} forEach [configFile >> "CfgAmmo"]; + +diag_log "--- AMMO EXPORT CSV END ---"; + +systemchat format["Ammo export done — %1 entries", _count]; diff --git a/armor.sqf b/armor.sqf new file mode 100644 index 0000000..16eaed1 --- /dev/null +++ b/armor.sqf @@ -0,0 +1,123 @@ +///////// ARMA3 Config CSV EXPORT — Armor (Vests + Headgear) +///////// Usage: execVM "armor.sqf" or paste execVM in debug console +///////// Arguments: [name, CfgPatches name] for a mod, [name] for vanilla BIS +///////// Outputs: CSV via diag_log to .rpt + +private ["_CfgPatches","_configPath","_getClass","_out","_items","_count"]; + +_CfgPatches = false; +_configPath = ""; +if(count _this > 1) then +{ + _configPath = _configPath + (_this select 1); + _CfgPatches = true; +}; + +_getClass = { + _out = ""; + if(typename _this == "STRING") then + { + _out = _this; + } + else + { + _out = configName _this; + }; + _out; +}; + +_count = 0; +_items = []; + +if(_CfgPatches) then +{ + _items = _items + getArray(configfile >> "CfgPatches" >> _configPath >> "weapons"); +} +else +{ + _items = [configFile >> "CfgWeapons"] call BIS_fnc_returnChildren; +}; + +diag_log "--- ARMOR EXPORT CSV START ---"; +diag_log text("source_mod;name;type;class_name;mass;armor_Head;armor_Neck;armor_Chest;armor_Diaphragm;armor_Abdomen;armor_Body;armor_Arms;armor_Legs;passthrough_Head;passthrough_Neck;passthrough_Chest;passthrough_Diaphragm;passthrough_Abdomen;passthrough_Body;passthrough_Arms;passthrough_Legs"); + +{ + private _configName = _x call _getClass; + private _name = getText(configFile >> "CfgWeapons" >> _configName >> "displayname"); + + if (_name != "") then { + private _parents = [_x, true] call BIS_fnc_returnParents; + private _type = ""; + + // Only process Vest and HeadGear + if ("HelmetBase" in _parents || "H_HelmetB" in _parents) then { _type = "HeadGear"; }; + if ("Vest_Camo_Base" in _parents || "Vest_NoCamo_Base" in _parents) then { _type = "Vest"; }; + + if (_type != "") then { + // Source mod + private _sources = configSourceAddonList (configFile >> "CfgWeapons" >> _configName); + private _sourceMod = "unknown"; + if (count _sources > 0) then { _sourceMod = _sources select 0; }; + + private _mass = getNumber(configFile >> "CfgWeapons" >> _configName >> "ItemInfo" >> "mass"); + + // Extract per-body-part armor and passthrough + // Defaults: armor=0, passthrough=1 (no protection) + private _aH = 0; private _pH = 1; + private _aN = 0; private _pN = 1; + private _aC = 0; private _pC = 1; + private _aD = 0; private _pD = 1; + private _aAb = 0; private _pAb = 1; + private _aBo = 0; private _pBo = 1; + private _aAr = 0; private _pAr = 1; + private _aL = 0; private _pL = 1; + + private _hppBase = configFile >> "CfgWeapons" >> _configName >> "ItemInfo" >> "HitpointsProtectionInfo"; + + if (isClass (_hppBase >> "Head")) then { + _aH = getNumber(_hppBase >> "Head" >> "armor"); + _pH = getNumber(_hppBase >> "Head" >> "passThrough"); + }; + if (isClass (_hppBase >> "Neck")) then { + _aN = getNumber(_hppBase >> "Neck" >> "armor"); + _pN = getNumber(_hppBase >> "Neck" >> "passThrough"); + }; + if (isClass (_hppBase >> "Chest")) then { + _aC = getNumber(_hppBase >> "Chest" >> "armor"); + _pC = getNumber(_hppBase >> "Chest" >> "passThrough"); + }; + if (isClass (_hppBase >> "Diaphragm")) then { + _aD = getNumber(_hppBase >> "Diaphragm" >> "armor"); + _pD = getNumber(_hppBase >> "Diaphragm" >> "passThrough"); + }; + if (isClass (_hppBase >> "Abdomen")) then { + _aAb = getNumber(_hppBase >> "Abdomen" >> "armor"); + _pAb = getNumber(_hppBase >> "Abdomen" >> "passThrough"); + }; + if (isClass (_hppBase >> "Body")) then { + _aBo = getNumber(_hppBase >> "Body" >> "armor"); + _pBo = getNumber(_hppBase >> "Body" >> "passThrough"); + }; + if (isClass (_hppBase >> "Arms")) then { + _aAr = getNumber(_hppBase >> "Arms" >> "armor"); + _pAr = getNumber(_hppBase >> "Arms" >> "passThrough"); + }; + if (isClass (_hppBase >> "Legs")) then { + _aL = getNumber(_hppBase >> "Legs" >> "armor"); + _pL = getNumber(_hppBase >> "Legs" >> "passThrough"); + }; + + diag_log text(format["%1;%2;%3;%4;%5;%6;%7;%8;%9;%10;%11;%12;%13;%14;%15;%16;%17;%18;%19;%20;%21", + _sourceMod, _name, _type, _configName, _mass, + _aH, _aN, _aC, _aD, _aAb, _aBo, _aAr, _aL, + _pH, _pN, _pC, _pD, _pAb, _pBo, _pAr, _pL]); + + _count = _count + 1; + if (_count % 100 == 0) then { sleep 0; }; + }; + }; +} forEach _items; + +diag_log "--- ARMOR EXPORT CSV END ---"; + +systemchat format["Armor export done — %1 items", _count]; diff --git a/magazines.sqf b/magazines.sqf new file mode 100644 index 0000000..21b0ee4 --- /dev/null +++ b/magazines.sqf @@ -0,0 +1,70 @@ +///////// ARMA3 Config CSV EXPORT — Magazines (links ammo classes) +///////// Usage: execVM "magazines.sqf" or paste execVM in debug console +///////// Arguments: [name, CfgPatches name] for a mod, [name] for vanilla BIS +///////// Outputs: CSV via diag_log to .rpt + +private ["_CfgPatches","_configPath","_getClass","_out","_items","_count"]; + +_CfgPatches = false; +_configPath = ""; +if(count _this > 1) then +{ + _configPath = _configPath + (_this select 1); + _CfgPatches = true; +}; + +_getClass = { + _out = ""; + if(typename _this == "STRING") then + { + _out = _this; + } + else + { + _out = configName _this; + }; + _out; +}; + +_count = 0; +_items = []; + +if(_CfgPatches) then +{ + _items = _items + getArray(configfile >> "CfgPatches" >> _configPath >> "weapons"); +} +else +{ + _items = [configFile >> "CfgMagazines"] call BIS_fnc_returnChildren; +}; + +diag_log "--- MAGAZINES EXPORT CSV START ---"; +diag_log text("source_mod;name;class_name;ammo;count;initSpeed;mass"); + +{ + private _configName = _x call _getClass; + private _displayName = getText(configFile >> "CfgMagazines" >> _configName >> "displayName"); + private _picture = getText(configFile >> "CfgMagazines" >> _configName >> "picture"); + + if (_displayName != "" && _picture != "") then { + // Source mod + private _sources = configSourceAddonList (configFile >> "CfgMagazines" >> _configName); + private _sourceMod = "unknown"; + if (count _sources > 0) then { _sourceMod = _sources select 0; }; + + private _ammo = getText(configFile >> "CfgMagazines" >> _configName >> "ammo"); + private _ammoCount = getNumber(configFile >> "CfgMagazines" >> _configName >> "count"); + private _initSpd = getNumber(configFile >> "CfgMagazines" >> _configName >> "initSpeed"); + private _mass = getNumber(configFile >> "CfgMagazines" >> _configName >> "mass"); + + diag_log text(format["%1;%2;%3;%4;%5;%6;%7", + _sourceMod, _displayName, _configName, _ammo, _ammoCount, _initSpd, _mass]); + + _count = _count + 1; + if (_count % 200 == 0) then { sleep 0; }; + }; +} forEach _items; + +diag_log "--- MAGAZINES EXPORT CSV END ---"; + +systemchat format["Magazines export done — %1 mags", _count]; diff --git a/scripts/extract_csvs.py b/scripts/extract_csvs.py new file mode 100644 index 0000000..bcb68ab --- /dev/null +++ b/scripts/extract_csvs.py @@ -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() diff --git a/scripts/generate_patches.py b/scripts/generate_patches.py new file mode 100644 index 0000000..e893d1e --- /dev/null +++ b/scripts/generate_patches.py @@ -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() diff --git a/scripts/split_by_mod.py b/scripts/split_by_mod.py new file mode 100644 index 0000000..003979e --- /dev/null +++ b/scripts/split_by_mod.py @@ -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() diff --git a/weapons.sqf b/weapons.sqf new file mode 100644 index 0000000..9fd3733 --- /dev/null +++ b/weapons.sqf @@ -0,0 +1,83 @@ +///////// ARMA3 Config CSV EXPORT — Weapons (with ACE barrel properties) +///////// Usage: execVM "weapons.sqf" or paste execVM in debug console +///////// Arguments: [name, CfgPatches name] for a mod, [name] for vanilla BIS +///////// Outputs: CSV via diag_log to .rpt + +private ["_CfgPatches","_configPath","_getClass","_out","_items","_count"]; + +_CfgPatches = false; +_configPath = ""; +if(count _this > 1) then +{ + _configPath = _configPath + (_this select 1); + _CfgPatches = true; +}; + +_getClass = { + _out = ""; + if(typename _this == "STRING") then + { + _out = _this; + } + else + { + _out = configName _this; + }; + _out; +}; + +_count = 0; +_items = []; + +if(_CfgPatches) then +{ + _items = _items + getArray(configfile >> "CfgPatches" >> _configPath >> "weapons"); +} +else +{ + _items = [configFile >> "CfgWeapons"] call BIS_fnc_returnChildren; +}; + +diag_log "--- WEAPONS EXPORT CSV START ---"; +diag_log text("source_mod;name;type;class_name;mass;mags;ACE_barrelLength;ACE_barrelTwist"); + +{ + private _configName = _x call _getClass; + private _name = getText(configFile >> "CfgWeapons" >> _configName >> "displayname"); + private _picture = getText(configFile >> "CfgWeapons" >> _configName >> "picture"); + + if (_name != "" && _picture != "") then { + private _parents = [_x, true] call BIS_fnc_returnParents; + private _type = switch true do { + case ("Rifle_Base_F" in _parents): {"PrimaryWeapon"}; + case ("Pistol" in _parents): {"SecondaryWeapon"}; + case ("Launcher" in _parents): {"Launcher"}; + default {"unknown"}; + }; + + if (_type != "unknown") then { + // Source mod + private _sources = configSourceAddonList (configFile >> "CfgWeapons" >> _configName); + private _sourceMod = "unknown"; + if (count _sources > 0) then { _sourceMod = _sources select 0; }; + + private _mass = getNumber(configFile >> "CfgWeapons" >> _configName >> "WeaponSlotsInfo" >> "mass"); + private _mags = getArray(configFile >> "CfgWeapons" >> _configName >> "magazines"); + + // ACE3 weapon properties + private _aceBarrelLength = getNumber(configFile >> "CfgWeapons" >> _configName >> "ACE_barrelLength"); + private _aceBarrelTwist = getNumber(configFile >> "CfgWeapons" >> _configName >> "ACE_barrelTwist"); + + diag_log text(format["%1;%2;%3;%4;%5;%6;%7;%8", + _sourceMod, _name, _type, _configName, _mass, str _mags, + _aceBarrelLength, _aceBarrelTwist]); + + _count = _count + 1; + if (_count % 100 == 0) then { sleep 0; }; + }; + }; +} forEach _items; + +diag_log "--- WEAPONS EXPORT CSV END ---"; + +systemchat format["Weapons export done — %1 weapons", _count];