Initial commit: ArmADump ammo/armor rebalance tool

- SQF extraction scripts (ammo, armor, weapons, magazines) with ACE3 properties
- Python pipeline: extract_csvs, split_by_mod, generate_patches
- Generates CfgPatches config.cpp normalizing non-RHS values to RHS baseline
- Documentation: REFERENCE.md, GENERATE_PATCHES_EXPLAINED.md, AGENTS.md
This commit is contained in:
Samuele Lorefice
2026-07-20 18:27:33 +02:00
commit 83d2cb6c52
11 changed files with 2194 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# Raw CSV exports from Arma .rpt
*_raw.csv
# Generated output
output/
# Per-mod split data
data/
+82
View File
@@ -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.
+351
View File
@@ -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.
+181
View File
@@ -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.
+120
View File
@@ -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];
+123
View File
@@ -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];
+70
View File
@@ -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];
+191
View File
@@ -0,0 +1,191 @@
"""
extract_csvs.py — Extract CSV data from Arma 3 .rpt log file.
Parses the .rpt log and extracts CSV blocks delimited by
--- ... START --- / --- ... END --- markers.
Usage:
python scripts/extract_csvs.py path/to/arma3.rpt
python scripts/extract_csvs.py path/to/arma3.rpt --output-dir ./extracted
Output files (in repo root by default):
ammo_raw.csv
armor_raw.csv
weapons_raw.csv
magazines_raw.csv
"""
import argparse
import re
import sys
from pathlib import Path
# Arma 3 .rpt lines start with "HH:MM:SS " timestamp prefix
RPT_TIMESTAMP_RE = re.compile(r'^\d{2}:\d{2}:\d{2}\s+')
# Marker patterns -> output filename
MARKERS = {
"AMMO EXPORT CSV": "ammo_raw.csv",
"ARMOR EXPORT CSV": "armor_raw.csv",
"WEAPONS EXPORT CSV": "weapons_raw.csv",
"MAGAZINES EXPORT CSV": "magazines_raw.csv",
}
# Also detect the old sheet-style markers for backwards compat
LEGACY_MARKERS = {
"CONFIG DUMP CSV SHEET 3 (AMMO)": "ammo_raw.csv",
"CONFIG DUMP SHEET 1": "weapons_raw.csv",
"CONFIG DUMP SHEET 2": "armor_raw.csv",
}
# Header line prefix to skip (the diag_log text("name;...") header)
HEADER_PREFIXES = ["name;", "name ", "source_mod;"]
def extract_from_rpt(rpt_path: Path, output_dir: Path) -> dict[str, int]:
"""Parse .rpt file and extract CSV blocks.
Returns dict of filename -> number of data rows extracted.
"""
if not rpt_path.exists():
print(f"Error: {rpt_path} not found")
sys.exit(1)
print(f"Reading: {rpt_path}")
print(f"File size: {rpt_path.stat().st_size / 1024 / 1024:.1f} MB")
# Read the full file
with open(rpt_path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
print(f"Total lines: {len(lines)}")
# Find all START/END marker positions
# Format: --- AMMO EXPORT CSV START ---
# --- AMMO EXPORT CSV END ---
start_pattern = re.compile(r'^\s*---\s+(.+?)\s+START\s+---\s*$')
end_pattern = re.compile(r'^\s*---\s+(.+?)\s+END\s+---\s*$')
# Build list of (start_line, end_line, marker_name)
blocks = []
active_block = None
for i, line in enumerate(lines):
# Strip RPT timestamp prefix (e.g. "17:55:18 ")
line_clean = RPT_TIMESTAMP_RE.sub('', line).strip()
# Strip surrounding quotes (.rpt wraps diag_log strings in "...")
if line_clean.startswith('"') and line_clean.endswith('"'):
line_clean = line_clean[1:-1].strip()
start_match = start_pattern.match(line_clean)
if start_match:
marker = start_match.group(1).strip()
active_block = {"marker": marker, "start": i + 1, "end": None}
continue
end_match = end_pattern.match(line_clean)
if end_match:
marker = end_match.group(1).strip()
if active_block and active_block["marker"] == marker:
active_block["end"] = i # end line (exclusive)
blocks.append(active_block)
active_block = None
print(f"Found {len(blocks)} CSV blocks in .rpt")
# Map marker names to output filenames
marker_to_file = {}
for marker_name, filename in {**MARKERS, **LEGACY_MARKERS}.items():
marker_to_file[marker_name] = filename
# Extract each block
results = {}
output_dir.mkdir(parents=True, exist_ok=True)
for block in blocks:
marker = block["marker"]
filename = marker_to_file.get(marker)
if not filename:
# Try partial match
for key, fname in marker_to_file.items():
if key in marker:
filename = fname
break
if not filename:
print(f" Skipping unrecognized marker: {marker}")
continue
# Extract lines between START and END (exclusive of marker lines)
data_lines = []
header_found = False
for line in lines[block["start"]:block["end"]]:
# Strip RPT timestamp prefix
stripped = RPT_TIMESTAMP_RE.sub('', line).strip()
# Strip surrounding quotes
if stripped.startswith('"') and stripped.endswith('"'):
stripped = stripped[1:-1].strip()
# Skip empty lines
if not stripped:
continue
# Remove diag_log wrapper if present: text("...") -> ...
cleaned = stripped
if cleaned.startswith('text("') and cleaned.endswith('")'):
cleaned = cleaned[6:-2]
elif cleaned.startswith("text('") and cleaned.endswith("')"):
cleaned = cleaned[6:-2]
# Keep only the first header row (diag_log text("name;..."))
if any(cleaned.startswith(p) for p in HEADER_PREFIXES):
if not header_found:
data_lines.append(cleaned)
header_found = True
continue
data_lines.append(cleaned)
out_path = output_dir / filename
with open(out_path, "w", encoding="utf-8", newline="") as f:
for line in data_lines:
f.write(line + "\n")
results[filename] = len(data_lines)
print(f" {filename:25s} -> {len(data_lines):6d} rows ({out_path})")
return results
def main():
parser = argparse.ArgumentParser(
description="Extract CSV data from Arma 3 .rpt log file")
parser.add_argument(
"rpt_file",
help="Path to Arma 3 .rpt log file")
parser.add_argument(
"--output-dir", "-o",
default=".",
help="Output directory for CSV files (default: repo root)")
args = parser.parse_args()
rpt_path = Path(args.rpt_file).resolve()
output_dir = Path(args.output_dir).resolve()
print("=" * 60)
print("ArmADump - Extract CSVs from .rpt log")
print("=" * 60)
results = extract_from_rpt(rpt_path, output_dir)
print(f"\n{'=' * 60}")
if results:
print(f"Extracted {len(results)} CSV files to {output_dir}")
for fname, count in sorted(results.items()):
print(f" {fname}: {count} rows")
print("\nNext step: python scripts/split_by_mod.py")
else:
print("No CSV blocks found in .rpt file!")
print("Make sure the extraction scripts were run in Arma first.")
print("=" * 60)
if __name__ == "__main__":
main()
+799
View File
@@ -0,0 +1,799 @@
"""
generate_patches.py — Generate CfgPatches config.cpp for ammo/armor rebalance.
Reads per-mod CSVs from data/, compares to RHS baseline, and generates
a config.cpp that overrides non-RHS values to match RHS equivalents.
Usage:
python scripts/generate_patches.py
Output:
output/config.cpp — The rebalance mod
output/unmatched.csv — Items with no RHS equivalent (for manual review)
"""
import csv
import os
import re
import sys
from collections import defaultdict
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
REPO_ROOT = Path(__file__).resolve().parent.parent
DATA_DIR = REPO_ROOT / "data"
OUTPUT_DIR = REPO_ROOT / "output"
# ============================================================================
# Caliber tier classification
# ============================================================================
# Classify ammo by class name patterns (not caliber value, which is penetration coefficient)
# Order matters: first match wins
CALIBER_NAME_PATTERNS = [
# Pistol calibers
(r'(?:9x19|9mm|9x21|9x18|009|acp_9)', "pistol_9mm"),
(r'(?:45acp|\.45|45_auto|00045)', "pistol_45acp"),
(r'(?:57x28|5\.7)', "smg_57x28"),
(r'(?:46x30|4\.6)', "smg_46x30"),
(r'(?:380|8mm|62x25|762x25|1143)', "pistol_other"),
# 5.45mm
(r'(?:545x39|5\.45|545)', "rifle_545x39"),
# 5.56mm
(r'(?:556x45|5\.56|556)', "rifle_556x45"),
# 6.5mm
(r'(?:65 Creedmoor|65x39|6\.5)', "rifle_65"),
# 7.62x39mm
(r'(?:762x39|7\.62x39|762_39)', "rifle_762x39"),
# 7.62x51mm / 7.62x54mmR
(r'(?:762x51|7\.62x51|762_51)', "rifle_762x51"),
(r'(?:762x54|7\.62x54|762_54)', "rifle_762x54"),
# .300 BLK
(r'(?:300blk|300_blk|300blackout)', "rifle_300blk"),
# .338 Lapua
(r'(?:338lapua|338_lapua|338lm|338)', "sniper_338"),
# .408 CheyTac
(r'(?:408|cheytac)', "sniper_408"),
# .50 BMG / 12.7mm
(r'(?:127x99|12\.7|50bmg|50cal|12_7)', "hmg_50"),
# 14.5mm
(r'(?:145x114|14\.5|145)', "hmg_145"),
# 20mm cannon
(r'(?:20mm|20x)', "cannon_20"),
# 23mm cannon
(r'(?:23mm|23x)', "cannon_23"),
# 25mm cannon
(r'(?:25mm|25x)', "cannon_25"),
# 30mm cannon
(r'(?:30mm|30x|3ubr|3uof)', "cannon_30"),
# 35mm cannon
(r'(?:35mm|35x)', "cannon_35"),
# 40mm cannon
(r'(?:40mm|40x|mk19|自动)', "cannon_40"),
# 57mm
(r'(?:57mm|57x)', "cannon_57"),
# 73mm / 76mm
(r'(?:73mm|76mm|73x)', "gun_73"),
# 82mm / 81mm mortars
(r'(?:82mm|81mm|mortar)', "mortar_82"),
# 100mm+
(r'(?:100mm|105mm|120mm|122mm|125mm|152mm|155mm)', "gun_heavy"),
# Shotgun
(r'(?:12gauge|12g|buckshot|shotgun|pellet)', "shotgun_12g"),
# Rockets (non-guided)
(r'(?:rocket|pg7|og7|rpg)', "rocket"),
# Missiles (guided)
(r'(?:missile|titan|scalpel|javelin|nlaw|stinger|igla|hellfire|kornet|metis|fagot|tow)', "missile"),
# Grenades
(r'(?:grenade|40mm_he|40mm_hedp|hedge)', "grenade_40mm"),
# Submunitions / cluster
(r'(?:submunition|cluster|dpicm)', "submunition"),
# Flares / smoke / illumination
(r'(?:flare|smoke|illuminat|chem)', "utility"),
# Mines / IEDs
(r'(?:mine|ied|satchel|demo|timed)', "explosive"),
# Penetrators (HEAT warheads)
(r'(?:penetrator|heat|tandem)', "penetrator"),
]
def classify_ammo(simulation: str, classname: str, parent_class: str,
hit: float, caliber: float, speed: float,
indirect_hit: float, explosive: float) -> str:
"""Classify an ammo class into a caliber tier for matching.
Uses class name patterns first, falls back to simulation-based classification.
"""
cn = classname.lower()
pc = parent_class.lower() if parent_class else ""
# Try name-based classification first
for pattern, tier in CALIBER_NAME_PATTERNS:
if re.search(pattern, cn) or re.search(pattern, pc):
return tier
# Fallback: simulation-based classification
if simulation == "shotBullet":
if speed > 0 and speed < 350:
return "bullet_subsonic"
return "bullet_unknown"
elif simulation == "shotShell":
if explosive > 0.5:
return "shell_he"
return "shell_ap"
elif simulation == "shotMissile":
return "missile"
elif simulation == "shotRocket":
return "rocket"
elif simulation == "shotGrenade":
return "grenade_40mm"
elif simulation in ("shotIlluminating", "shotSmokeX"):
return "utility"
return f"unknown_{simulation}"
def detect_ammo_subtype(classname: str, hit: float, speed: float,
caliber: float) -> str:
"""Detect ammo subtype within a caliber tier (AP, subsonic, tracer, etc.)."""
cn = classname.lower()
if speed > 0 and speed < 350:
return "subsonic"
if "ap" in cn and ("fsds" in cn or "penetrat" in cn):
return "ap"
if "ap" in cn and "fsds" not in cn:
return "ap"
if "tracer" in cn:
return "ball_tracer"
if "subsonic" in cn or "sub" in cn:
return "subsonic"
if "match" in cn or "sniper" in cn or "otm" in cn or "mk262" in cn or "mk316" in cn:
return "match"
if "incendiary" in cn or "incen" in cn:
return "incendiary"
if "he" in cn and "hedp" not in cn:
return "he"
if "hedp" in cn:
return "hedp"
return "ball"
# ============================================================================
# Armor tier classification
# ============================================================================
def classify_armor_vest(mass: float, chest_armor: float) -> str:
"""Classify a vest into an armor tier for matching."""
if chest_armor <= 0:
return "soft"
if chest_armor <= 16:
return "light"
if chest_armor <= 31:
return "medium"
if chest_armor <= 50:
return "heavy"
return "eod"
def classify_armor_helmet(mass: float, head_armor: float) -> str:
"""Classify a helmet into an armor tier for matching."""
if head_armor <= 0:
return "cap"
if head_armor <= 6:
return "light"
if head_armor <= 15:
return "medium"
if head_armor <= 25:
return "heavy"
return "special"
# ============================================================================
# Data loading
# ============================================================================
@dataclass
class AmmoEntry:
source_mod: str
name: str
simulation: str
classname: str
parent_class: str
hit: float
indirect_hit: float
indirect_hit_range: float
caliber: float
typical_speed: float
air_friction: float
visible_fire: float
audible_fire: float
cost: float
explosive: float
deflecting: float
air_lock: float
cartridge: str
ace_caliber: float = 0
ace_bullet_length: float = 0
ace_bullet_mass: float = 0
ace_bc: str = "[]"
ace_vb: str = "[]"
ace_drag_model: int = 0
ace_atmo: str = ""
ace_mv: str = "[]"
ace_bl: str = "[]"
ace_transonic: float = 0
ace_mv_var: float = 0
tier: str = ""
subtype: str = ""
source_folder: str = ""
@dataclass
class ArmorEntry:
source_mod: str
name: str
item_type: str # Vest or HeadGear
classname: str
mass: float
source_folder: str = ""
armor_head: float = 0
armor_neck: float = 0
armor_chest: float = 0
armor_diaphragm: float = 0
armor_abdomen: float = 0
armor_body: float = 0
armor_arms: float = 0
armor_legs: float = 0
pt_head: float = 1
pt_neck: float = 1
pt_chest: float = 1
pt_diaphragm: float = 1
pt_abdomen: float = 1
pt_body: float = 1
pt_arms: float = 1
pt_legs: float = 1
tier: str = ""
def parse_float(s: str) -> float:
try:
return float(s.strip().strip('"'))
except (ValueError, AttributeError):
return 0.0
def parse_int(s: str) -> int:
try:
return int(float(s.strip().strip('"')))
except (ValueError, AttributeError):
return 0
def load_ammo_csv(filepath: Path, folder: str = "") -> list[AmmoEntry]:
"""Load ammo CSV into AmmoEntry list."""
entries = []
if not filepath.exists():
return entries
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
reader = csv.reader(f, delimiter=";")
header = next(reader, None)
if not header:
return entries
for row in reader:
if len(row) < 19:
continue
e = AmmoEntry(
source_mod=row[0].strip().strip('"'),
name=row[1].strip().strip('"'),
simulation=row[2].strip().strip('"'),
classname=row[3].strip().strip('"'),
parent_class=row[4].strip().strip('"'),
hit=parse_float(row[5]),
indirect_hit=parse_float(row[6]),
indirect_hit_range=parse_float(row[7]),
caliber=parse_float(row[8]),
typical_speed=parse_float(row[9]),
air_friction=parse_float(row[10]),
visible_fire=parse_float(row[11]),
audible_fire=parse_float(row[12]),
cost=parse_float(row[13]),
explosive=parse_float(row[14]),
deflecting=parse_float(row[15]),
air_lock=parse_float(row[16]),
cartridge=row[17].strip().strip('"') if len(row) > 17 else "",
ace_caliber=parse_float(row[18]) if len(row) > 18 else 0,
ace_bullet_length=parse_float(row[19]) if len(row) > 19 else 0,
ace_bullet_mass=parse_float(row[20]) if len(row) > 20 else 0,
ace_bc=row[21].strip() if len(row) > 21 else "[]",
ace_vb=row[22].strip() if len(row) > 22 else "[]",
ace_drag_model=parse_int(row[23]) if len(row) > 23 else 0,
ace_atmo=row[24].strip().strip('"') if len(row) > 24 else "",
ace_mv=row[25].strip() if len(row) > 25 else "[]",
ace_bl=row[26].strip() if len(row) > 26 else "[]",
ace_transonic=parse_float(row[27]) if len(row) > 27 else 0,
ace_mv_var=parse_float(row[28]) if len(row) > 28 else 0,
source_folder=folder,
)
e.tier = classify_ammo(e.simulation, e.classname, e.parent_class,
e.hit, e.caliber, e.typical_speed,
e.indirect_hit, e.explosive)
e.subtype = detect_ammo_subtype(e.classname, e.hit,
e.typical_speed, e.caliber)
entries.append(e)
return entries
def load_armor_csv(filepath: Path, folder: str = "") -> list[ArmorEntry]:
"""Load armor CSV into ArmorEntry list."""
entries = []
if not filepath.exists():
return entries
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
reader = csv.reader(f, delimiter=";")
header = next(reader, None)
if not header:
return entries
for row in reader:
if len(row) < 14:
continue
e = ArmorEntry(
source_mod=row[0].strip().strip('"'),
name=row[1].strip().strip('"'),
item_type=row[2].strip().strip('"'),
classname=row[3].strip().strip('"'),
mass=parse_float(row[4]),
armor_head=parse_float(row[5]),
armor_neck=parse_float(row[6]),
armor_chest=parse_float(row[7]),
armor_diaphragm=parse_float(row[8]),
armor_abdomen=parse_float(row[9]),
armor_body=parse_float(row[10]),
armor_arms=parse_float(row[11]) if len(row) > 11 else 0,
armor_legs=parse_float(row[12]) if len(row) > 12 else 0,
pt_head=parse_float(row[13]) if len(row) > 13 else 1,
pt_neck=parse_float(row[14]) if len(row) > 14 else 1,
pt_chest=parse_float(row[15]) if len(row) > 15 else 1,
pt_diaphragm=parse_float(row[16]) if len(row) > 16 else 1,
pt_abdomen=parse_float(row[17]) if len(row) > 17 else 1,
pt_body=parse_float(row[18]) if len(row) > 18 else 1,
pt_arms=parse_float(row[19]) if len(row) > 19 else 1,
pt_legs=parse_float(row[20]) if len(row) > 20 else 1,
source_folder=folder,
)
if e.item_type == "Vest":
e.tier = classify_armor_vest(e.mass, e.armor_chest)
elif e.item_type == "HeadGear":
e.tier = classify_armor_helmet(e.mass, e.armor_head)
entries.append(e)
return entries
# ============================================================================
# Baseline building
# ============================================================================
def build_ammo_baseline(entries: list[AmmoEntry]) -> dict:
"""Build RHS baseline lookup: (tier, subtype) -> representative entry.
Uses the first RHS entry found for each tier+subtype combination.
Prefers entries from _rhs over _vanilla.
"""
baseline = {}
# Group by (tier, subtype)
groups = defaultdict(list)
for e in entries:
if e.source_folder in ("_rhs", "_rhsusf", "_rhsgref", "_rhssaf", "_vanilla", "_ace"):
groups[(e.tier, e.subtype)].append(e)
for key, group in groups.items():
# Prefer RHS entries
rhs_entries = [e for e in group if e.source_folder.startswith("_rhs")]
if rhs_entries:
baseline[key] = rhs_entries[0]
else:
baseline[key] = group[0]
return baseline
def build_armor_baseline(entries: list[ArmorEntry]) -> dict:
"""Build RHS baseline lookup: (item_type, tier) -> representative entry."""
baseline = {}
groups = defaultdict(list)
for e in entries:
if e.source_folder in ("_rhs", "_rhsusf", "_rhsgref", "_rhssaf", "_vanilla", "_ace"):
groups[(e.item_type, e.tier)].append(e)
for key, group in groups.items():
rhs_entries = [e for e in group if e.source_folder.startswith("_rhs")]
if rhs_entries:
baseline[key] = rhs_entries[0]
else:
baseline[key] = group[0]
return baseline
# ============================================================================
# Matching and delta computation
# ============================================================================
# Ammo fields to compare and potentially override
AMMO_FIELDS = [
"hit", "indirect_hit", "indirect_hit_range", "caliber",
"typical_speed", "air_friction", "deflecting", "explosive",
"ace_caliber", "ace_bullet_length", "ace_bullet_mass",
"ace_drag_model", "ace_transonic", "ace_mv_var",
]
# Armor fields per body part
ARMOR_FIELDS = [
"armor_head", "armor_neck", "armor_chest", "armor_diaphragm",
"armor_abdomen", "armor_body", "armor_arms", "armor_legs",
"pt_head", "pt_neck", "pt_chest", "pt_diaphragm",
"pt_abdomen", "pt_body", "pt_arms", "pt_legs",
]
@dataclass
class AmmoOverride:
classname: str
source_mod: str
changes: dict = field(default_factory=dict)
@dataclass
class ArmorOverride:
classname: str
source_mod: str
item_type: str
changes: dict = field(default_factory=dict)
def find_ammo_overrides(
mod_entries: list[AmmoEntry],
baseline: dict,
) -> list[AmmoOverride]:
"""Find ammo entries that differ from RHS baseline."""
overrides = []
for e in mod_entries:
key = (e.tier, e.subtype)
if key not in baseline:
continue
base = baseline[key]
changes = {}
for field_name in AMMO_FIELDS:
mod_val = getattr(e, field_name)
base_val = getattr(base, field_name)
if mod_val != base_val and mod_val != 0:
changes[field_name] = base_val
# Check ACE array fields (compare as strings)
for array_field in ["ace_bc", "ace_mv", "ace_bl", "ace_vb"]:
mod_val = getattr(e, array_field)
base_val = getattr(base, array_field)
if mod_val != base_val and mod_val not in ("[]", ""):
changes[array_field] = base_val
if changes:
overrides.append(AmmoOverride(
classname=e.classname,
source_mod=e.source_mod,
changes=changes,
))
return overrides
def find_armor_overrides(
mod_entries: list[ArmorEntry],
baseline: dict,
) -> list[ArmorOverride]:
"""Find armor entries that differ from RHS baseline."""
overrides = []
for e in mod_entries:
key = (e.item_type, e.tier)
if key not in baseline:
continue
base = baseline[key]
changes = {}
for field_name in ARMOR_FIELDS:
mod_val = getattr(e, field_name)
base_val = getattr(base, field_name)
# Don't override passThrough for soft items (keep their defaults)
if "pt_" in field_name and base_val >= 1.0 and mod_val >= 1.0:
continue
if mod_val != base_val:
changes[field_name] = base_val
if changes:
overrides.append(AmmoOverride(
classname=e.classname,
source_mod=e.source_mod,
item_type=e.item_type,
changes=changes,
) if hasattr(AmmoOverride, 'item_type') else ArmorOverride(
classname=e.classname,
source_mod=e.source_mod,
item_type=e.item_type,
changes=changes,
))
return overrides
# ============================================================================
# Config.cpp generation
# ============================================================================
FIELD_TO_CONFIG = {
# Ammo fields
"hit": "hit",
"indirect_hit": "indirectHit",
"indirect_hit_range": "indirectHitRange",
"caliber": "caliber",
"typical_speed": "typicalSpeed",
"air_friction": "airFriction",
"deflecting": "deflecting",
"explosive": "explosive",
"ace_caliber": "ACE_caliber",
"ace_bullet_length": "ACE_bulletLength",
"ace_bullet_mass": "ACE_bulletMass",
"ace_drag_model": "ACE_dragModel",
"ace_transonic": "ACE_transonicStabilityCoef",
"ace_mv_var": "ACE_muzzleVelocityVariationSD",
"ace_bc": "ACE_ballisticCoefficients",
"ace_vb": "ACE_velocityBoundaries",
"ace_mv": "ACE_muzzleVelocities",
"ace_bl": "ACE_barrelLengths",
}
# Armor field -> (hpp_section, property_name)
ARMOR_FIELD_MAP = {
"armor_head": ("Head", "armor"),
"armor_neck": ("Neck", "armor"),
"armor_chest": ("Chest", "armor"),
"armor_diaphragm": ("Diaphragm", "armor"),
"armor_abdomen": ("Abdomen", "armor"),
"armor_body": ("Body", "armor"),
"armor_arms": ("Arms", "armor"),
"armor_legs": ("Legs", "armor"),
"pt_head": ("Head", "passThrough"),
"pt_neck": ("Neck", "passThrough"),
"pt_chest": ("Chest", "passThrough"),
"pt_diaphragm": ("Diaphragm", "passThrough"),
"pt_abdomen": ("Abdomen", "passThrough"),
"pt_body": ("Body", "passThrough"),
"pt_arms": ("Arms", "passThrough"),
"pt_legs": ("Legs", "passThrough"),
}
def format_number(val) -> str:
"""Format a number for SQF config output."""
if isinstance(val, float):
if val == int(val) and abs(val) < 1e10:
return str(int(val))
return f"{val:.6g}"
return str(val)
def generate_config_cpp(
ammo_overrides: dict[str, list[AmmoOverride]],
armor_overrides: dict[str, list[ArmorOverride]],
source_mods: set[str],
) -> str:
"""Generate the config.cpp content."""
lines = []
lines.append('// ArmADump - Rebalance Config')
lines.append('// Auto-generated by generate_patches.py')
lines.append('// Compare to RHS baseline values')
lines.append('//')
lines.append('// WARNING: Review output/unmatched.csv for items without RHS equivalents')
lines.append('')
# CfgPatches
lines.append('class CfgPatches {')
for mod in sorted(source_mods):
safe_name = re.sub(r'[^a-zA-Z0-9_]', '_', mod)
lines.append(f' class armadump_{safe_name} {{')
lines.append(f' units[] = {{}};')
lines.append(f' weapons[] = {{}};')
lines.append(f' requiredAddons[] = {{"{mod}"}};')
lines.append(f' }};')
lines.append('};')
lines.append('')
# CfgAmmo overrides
has_ammo = any(v for v in ammo_overrides.values())
if has_ammo:
lines.append('class CfgAmmo {')
for mod in sorted(ammo_overrides.keys()):
overrides = ammo_overrides[mod]
if not overrides:
continue
for o in sorted(overrides, key=lambda x: x.classname):
lines.append(f' class {o.classname} {{')
for field_name, val in sorted(o.changes.items()):
config_name = FIELD_TO_CONFIG.get(field_name, field_name)
if isinstance(val, str) and val.startswith("["):
# Array value — write as SQF array
lines.append(f' {config_name}[] = {val};')
else:
lines.append(f' {config_name} = {format_number(val)};')
lines.append(' };')
lines.append('};')
lines.append('')
# CfgWeapons overrides (armor)
has_armor = any(v for v in armor_overrides.values())
if has_armor:
lines.append('class CfgWeapons {')
for mod in sorted(armor_overrides.keys()):
overrides = armor_overrides[mod]
if not overrides:
continue
for o in sorted(overrides, key=lambda x: x.classname):
lines.append(f' class {o.classname} {{')
lines.append(f' class ItemInfo {{')
lines.append(f' class HitpointsProtectionInfo {{')
# Group changes by body part
part_changes = defaultdict(dict)
for field_name, val in sorted(o.changes.items()):
section, prop = ARMOR_FIELD_MAP[field_name]
part_changes[section][prop] = val
for part in ["Head", "Neck", "Chest", "Diaphragm", "Abdomen",
"Body", "Arms", "Legs"]:
if part in part_changes:
lines.append(f' class {part} {{')
for prop, val in sorted(part_changes[part].items()):
lines.append(
f' {prop} = {format_number(val)};')
lines.append(f' }};')
lines.append(f' }};')
lines.append(f' }};')
lines.append(f' }};')
lines.append('};')
lines.append('')
return '\n'.join(lines)
def write_unmatched(
unmatched_ammo: list,
unmatched_armor: list,
):
"""Write unmatched items to CSV for manual review."""
out_path = OUTPUT_DIR / "unmatched.csv"
with open(out_path, "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f, delimiter=";")
writer.writerow(["type", "source_mod", "classname", "name",
"tier", "reason"])
for e in unmatched_ammo:
writer.writerow(["ammo", e.source_mod, e.classname, e.name,
e.tier, "no_rhs_tier_match"])
for e in unmatched_armor:
writer.writerow(["armor", e.source_mod, e.classname, e.name,
e.tier, "no_rhs_tier_match"])
print(f" Wrote {len(unmatched_ammo)} unmatched ammo, "
f"{len(unmatched_armor)} unmatched armor to {out_path}")
# ============================================================================
# Main
# ============================================================================
def main():
print("=" * 60)
print("ArmADump - Generate CfgPatches Rebalance Mod")
print("=" * 60)
# Load baseline (RHS + vanilla)
print("\nLoading RHS baseline...")
rhs_ammo = load_ammo_csv(DATA_DIR / "_rhs" / "ammo.csv", "_rhs")
rhs_ammo += load_ammo_csv(DATA_DIR / "_vanilla" / "ammo.csv", "_vanilla")
rhs_ammo += load_ammo_csv(DATA_DIR / "_rhsusf" / "ammo.csv", "_rhsusf")
rhs_ammo += load_ammo_csv(DATA_DIR / "_rhsgref" / "ammo.csv", "_rhsgref")
rhs_ammo += load_ammo_csv(DATA_DIR / "_rhssaf" / "ammo.csv", "_rhssaf")
rhs_armor = load_armor_csv(DATA_DIR / "_rhs" / "armor.csv", "_rhs")
rhs_armor += load_armor_csv(DATA_DIR / "_vanilla" / "armor.csv", "_vanilla")
rhs_armor += load_armor_csv(DATA_DIR / "_rhsusf" / "armor.csv", "_rhsusf")
rhs_armor += load_armor_csv(DATA_DIR / "_rhsgref" / "armor.csv", "_rhsgref")
rhs_armor += load_armor_csv(DATA_DIR / "_rhssaf" / "armor.csv", "_rhssaf")
print(f" RHS ammo: {len(rhs_ammo)} entries")
print(f" RHS armor: {len(rhs_armor)} entries")
ammo_baseline = build_ammo_baseline(rhs_ammo)
armor_baseline = build_armor_baseline(rhs_armor)
print(f" Ammo baseline tiers: {sorted(ammo_baseline.keys())}")
print(f" Armor baseline tiers: {sorted(armor_baseline.keys())}")
# Scan all mods
print("\nScanning non-baseline mods...")
all_ammo_overrides = {}
all_armor_overrides = {}
all_source_mods = set()
unmatched_ammo = []
unmatched_armor = []
for mod_dir in sorted(DATA_DIR.iterdir()):
if not mod_dir.is_dir():
continue
if mod_dir.name.startswith("_"):
continue # Skip baseline mods
mod_ammo = load_ammo_csv(mod_dir / "ammo.csv", mod_dir.name)
mod_armor = load_armor_csv(mod_dir / "armor.csv", mod_dir.name)
if not mod_ammo and not mod_armor:
continue
print(f"\n {mod_dir.name}: {len(mod_ammo)} ammo, {len(mod_armor)} armor")
# Find ammo overrides
ammo_ovs = find_ammo_overrides(mod_ammo, ammo_baseline)
if ammo_ovs:
all_ammo_overrides[mod_dir.name] = ammo_ovs
all_source_mods.add(mod_dir.name)
print(f" Ammo overrides: {len(ammo_ovs)}")
# Track unmatched
for e in mod_ammo:
key = (e.tier, e.subtype)
if key not in ammo_baseline:
unmatched_ammo.append(e)
# Find armor overrides
armor_ovs = find_armor_overrides(mod_armor, armor_baseline)
if armor_ovs:
all_armor_overrides[mod_dir.name] = armor_ovs
all_source_mods.add(mod_dir.name)
print(f" Armor overrides: {len(armor_ovs)}")
for e in mod_armor:
key = (e.item_type, e.tier)
if key not in armor_baseline:
unmatched_armor.append(e)
# Generate output
print("\nGenerating config.cpp...")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
config_cpp = generate_config_cpp(
all_ammo_overrides, all_armor_overrides, all_source_mods)
config_path = OUTPUT_DIR / "config.cpp"
with open(config_path, "w", encoding="utf-8") as f:
f.write(config_cpp)
print(f" Wrote {config_path}")
print("\nWriting unmatched items...")
write_unmatched(unmatched_ammo, unmatched_armor)
# Summary
total_ammo = sum(len(v) for v in all_ammo_overrides.values())
total_armor = sum(len(v) for v in all_armor_overrides.values())
print(f"\n{'=' * 60}")
print(f"DONE - {total_ammo} ammo overrides, {total_armor} armor overrides")
print(f" across {len(all_source_mods)} mods")
print(f" Config: {config_path.relative_to(REPO_ROOT)}")
print(f" Unmatched: {REPO_ROOT / 'output' / 'unmatched.csv'}")
print(f"{'=' * 60}")
if __name__ == "__main__":
main()
+186
View File
@@ -0,0 +1,186 @@
"""
split_by_mod.py — Split raw Arma 3 CSV exports into per-mod folders.
Reads raw CSV files (extracted from .rpt log) and organizes them by source mod.
Each raw CSV has a 'source_mod' column (first column) that identifies which
CfgPatches class defined that entry.
Usage:
python scripts/split_by_mod.py
Input files (in repo root):
ammo_raw.csv — from ammo.sqf
armor_raw.csv — from armor.sqf
weapons_raw.csv — from weapons.sqf
magazines_raw.csv — from magazines.sqf
Output structure:
data/
├── _rhs/
│ ├── ammo.csv
│ ├── armor.csv
│ ├── weapons.csv
│ ├── magazines.csv
├── _vanilla/
│ ├── ammo.csv
│ ├── ...
├── [mod_name]/
│ ├── ammo.csv
│ ├── ...
"""
import csv
import os
import re
import sys
from collections import defaultdict
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
DATA_DIR = REPO_ROOT / "data"
# Map (input_filename, output_filename)
CSV_FILES = [
("ammo_raw.csv", "ammo.csv"),
("armor_raw.csv", "armor.csv"),
("weapons_raw.csv", "weapons.csv"),
("magazines_raw.csv", "magazines.csv"),
]
# Known CfgPatches -> folder name mappings
# Prefix with _ for baseline mods (rhs, vanilla) so they sort first
# Order matters: more specific prefixes first
MOD_FOLDER_PREFIXES = [
# RHS family
("rhs_", "_rhs"),
("rhsusf_", "_rhsusf"),
("rhsgref_", "_rhsgref"),
("rhssaf_", "_rhssaf"),
# Vanilla
("A3_", "_vanilla"),
("A3a3_", "_vanilla"),
# ACE
("ace_", "_ace"),
# CBA
("cba_", "_cba"),
]
# Exact matches for edge cases
MOD_FOLDER_EXACT = {
"ace_main": "_ace",
"ace_ballistics": "_ace",
"cba_common": "_cba",
"cba_xeh": "_cba",
}
def sanitize_folder_name(mod_name: str) -> str:
"""Convert a CfgPatches class name to a safe folder name.
Uses prefix matching for known mod families, then falls back to
extracting the first meaningful segment.
"""
# Exact match first
if mod_name in MOD_FOLDER_EXACT:
return MOD_FOLDER_EXACT[mod_name]
# Prefix matching for known families
for prefix, folder in MOD_FOLDER_PREFIXES:
if mod_name.startswith(prefix):
return folder
# Heuristic: take the first segment before underscore,
# stripping common suffixes
parts = mod_name.lower().split("_")
suffixes = {"main", "weapons", "data", "config", "core", "compat",
"f", "e", "c", "add", "addons"}
clean = [p for p in parts if p not in suffixes and p]
if clean:
return clean[0]
return mod_name.lower()
def parse_rpt_csv(filepath: Path) -> list[list[str]]:
"""Parse a semicolon-delimited CSV file extracted from .rpt log.
Handles lines that may have been wrapped or have trailing semicolons.
"""
rows = []
if not filepath.exists():
print(f" Warning: {filepath} not found, skipping")
return rows
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if not line:
continue
# Split on semicolons, strip whitespace
parts = [p.strip() for p in line.split(";")]
# Remove trailing empty fields (from trailing semicolons)
while parts and parts[-1] == "":
parts.pop()
if parts:
rows.append(parts)
return rows
def split_csv(input_name: str, output_name: str):
"""Split a single raw CSV file into per-mod files."""
input_path = REPO_ROOT / input_name
print(f"\nProcessing {input_name}...")
rows = parse_rpt_csv(input_path)
if not rows:
return
# First row is header
header = rows[0]
data_rows = rows[1:]
# source_mod is always the first column
mod_groups = defaultdict(list)
for row in data_rows:
if len(row) < 2:
continue
mod_name = row[0].strip().strip('"')
folder = sanitize_folder_name(mod_name)
mod_groups[folder].append(row)
print(f" Found {len(mod_groups)} unique mods, {len(data_rows)} total entries")
# Write per-mod files
for folder, group_rows in sorted(mod_groups.items()):
mod_dir = DATA_DIR / folder
mod_dir.mkdir(parents=True, exist_ok=True)
out_path = mod_dir / output_name
with open(out_path, "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f, delimiter=";")
writer.writerow(header)
writer.writerows(group_rows)
print(f" {folder:30s} -> {len(group_rows):5d} entries ({out_path.relative_to(REPO_ROOT)})")
def main():
print("=" * 60)
print("ArmADump - Split CSVs by Mod")
print("=" * 60)
print(f"Repository root: {REPO_ROOT}")
print(f"Output directory: {DATA_DIR}")
DATA_DIR.mkdir(parents=True, exist_ok=True)
for input_name, output_name in CSV_FILES:
split_csv(input_name, output_name)
print("\n" + "=" * 60)
print("Done! Per-mod CSVs written to data/")
print("=" * 60)
if __name__ == "__main__":
main()
+83
View File
@@ -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];