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