From 5d1be18c35e08a9e8c06bf4bc7d41591b12b6655 Mon Sep 17 00:00:00 2001 From: Samuele Lorefice Date: Mon, 20 Jul 2026 19:33:55 +0200 Subject: [PATCH] Per-mod output, packaging script, fix requiredAddons CfgPatches names - Split single config.cpp into per-mod output//config.cpp files - Use original CfgPatches class names (not sanitized folders) in requiredAddons - Skip 'unknown' source_mod from requiredAddons - Add scripts/package_mod.py with armake2 PBO packing - Add package_mod.ini for configurable mod packaging - Use {} array syntax instead of [] for Arma config compatibility --- AGENTS.md | 4 +- package_mod.ini | 11 ++ scripts/generate_patches.py | 158 +++++++++++----------- scripts/package_mod.py | 253 ++++++++++++++++++++++++++++++++++++ 4 files changed, 351 insertions(+), 75 deletions(-) create mode 100644 package_mod.ini create mode 100644 scripts/package_mod.py diff --git a/AGENTS.md b/AGENTS.md index 88b3923..951862a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,11 +51,11 @@ Creates `data/[mod_name]/` folders with per-mod CSVs. Baseline mods (`_rhs`, `_v python scripts/generate_patches.py ``` Produces: -- `output/config.cpp` — CfgPatches config with overrides +- `output//config.cpp` — Per-mod 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. +Drop the `output/` folder contents into `@mod/addons//config.cpp` structure and load it. Each subfolder is an independent addon with its own CfgPatches dependency. ## How the scripts work diff --git a/package_mod.ini b/package_mod.ini new file mode 100644 index 0000000..fa3cec8 --- /dev/null +++ b/package_mod.ini @@ -0,0 +1,11 @@ +[mod] +mod_name = A3Rebalancer +mod_display_name = A3 Rebalancer +mod_description = Normalizes non-RHS ammo and armor values to RHS baseline. +mod_author = REDCODE +mod_version = 1.0 + +[paths] +; Arma 3 user mods folder (where @mod folders go) +; Windows default: Documents\Arma 3\!mods +arma_mods_dir = C:\Users\REDCODE\Documents\Arma 3\!mods diff --git a/scripts/generate_patches.py b/scripts/generate_patches.py index e893d1e..4bc5c9e 100644 --- a/scripts/generate_patches.py +++ b/scripts/generate_patches.py @@ -2,14 +2,14 @@ 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. +per-mod config.cpp files that override 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) + output//config.cpp — One rebalance config per mod + output/unmatched.csv — Items with no RHS equivalent (for manual review) """ import csv @@ -589,84 +589,75 @@ def format_number(val) -> str: return str(val) -def generate_config_cpp( - ammo_overrides: dict[str, list[AmmoOverride]], - armor_overrides: dict[str, list[ArmorOverride]], - source_mods: set[str], +def generate_mod_config_cpp( + mod: str, + ammo_overrides: list[AmmoOverride], + armor_overrides: list[ArmorOverride], + source_mods: list[str] = None, ) -> str: - """Generate the config.cpp content.""" + """Generate config.cpp content for a single mod.""" + safe_name = re.sub(r'[^a-zA-Z0-9_]', '_', mod) + # Use original CfgPatches class names for requiredAddons, skip bogus ones + skip_deps = {"unknown", ""} + deps = [d for d in (source_mods if source_mods else [mod]) if d not in skip_deps] + deps_str = ", ".join(f'"{d}"' for d in sorted(deps)) lines = [] lines.append('// ArmADump - Rebalance Config') + lines.append(f'// Mod: {mod}') 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(f' class armadump_{safe_name} {{') + lines.append(f' units[] = {{}};') + lines.append(f' weapons[] = {{}};') + lines.append(f' requiredAddons[] = {{{deps_str}}};') + lines.append(f' }};') lines.append('};') lines.append('') # CfgAmmo overrides - has_ammo = any(v for v in ammo_overrides.values()) - if has_ammo: + if ammo_overrides: 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(' };') + for o in sorted(ammo_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("["): + sqf_val = val.replace("[", "{").replace("]", "}") + lines.append(f' {config_name}[] = {sqf_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: + if armor_overrides: 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 {{') + for o in sorted(armor_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 + 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' }};') + 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(f' }};') + lines.append(f' }};') + lines.append(f' }};') lines.append('};') lines.append('') @@ -726,7 +717,7 @@ def main(): print("\nScanning non-baseline mods...") all_ammo_overrides = {} all_armor_overrides = {} - all_source_mods = set() + all_source_mods_per_folder = defaultdict(set) unmatched_ammo = [] unmatched_armor = [] @@ -748,7 +739,8 @@ def main(): 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) + for o in ammo_ovs: + all_source_mods_per_folder[mod_dir.name].add(o.source_mod) print(f" Ammo overrides: {len(ammo_ovs)}") # Track unmatched @@ -761,7 +753,8 @@ def main(): 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) + for o in armor_ovs: + all_source_mods_per_folder[mod_dir.name].add(o.source_mod) print(f" Armor overrides: {len(armor_ovs)}") for e in mod_armor: @@ -770,18 +763,37 @@ def main(): unmatched_armor.append(e) # Generate output - print("\nGenerating config.cpp...") + print("\nGenerating per-mod config files...") OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - config_cpp = generate_config_cpp( - all_ammo_overrides, all_armor_overrides, all_source_mods) + # Clean old output + for child in OUTPUT_DIR.iterdir(): + if child.is_dir(): + for f in child.iterdir(): + f.unlink() + child.rmdir() + elif child.name != "unmatched.csv": + child.unlink() - 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}") + mods_with_overrides = set() + for mod in sorted(set(list(all_ammo_overrides.keys()) + list(all_armor_overrides.keys()))): + mod_ammo = all_ammo_overrides.get(mod, []) + mod_armor = all_armor_overrides.get(mod, []) + if not mod_ammo and not mod_armor: + continue - print("\nWriting unmatched items...") + mod_dir = OUTPUT_DIR / mod + mod_dir.mkdir(parents=True, exist_ok=True) + + source_mods = sorted(all_source_mods_per_folder.get(mod, {mod})) + config_cpp = generate_mod_config_cpp(mod, mod_ammo, mod_armor, source_mods) + config_path = mod_dir / "config.cpp" + with open(config_path, "w", encoding="utf-8") as f: + f.write(config_cpp) + mods_with_overrides.add(mod) + print(f" {mod}/config.cpp ({len(mod_ammo)} ammo, {len(mod_armor)} armor)") + + print(f"\nWriting unmatched items...") write_unmatched(unmatched_ammo, unmatched_armor) # Summary @@ -789,8 +801,8 @@ def main(): 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" across {len(mods_with_overrides)} mods") + print(f" Output: {OUTPUT_DIR.relative_to(REPO_ROOT)}//config.cpp") print(f" Unmatched: {REPO_ROOT / 'output' / 'unmatched.csv'}") print(f"{'=' * 60}") diff --git a/scripts/package_mod.py b/scripts/package_mod.py new file mode 100644 index 0000000..f67e5ff --- /dev/null +++ b/scripts/package_mod.py @@ -0,0 +1,253 @@ +""" +package_mod.py — Package output/ into a loadable Arma 3 mod. + +Uses armake2 to pack each addon into a .pbo file. +Creates mod.cpp and installs to your Arma 3 addons directory. + +Usage: + python scripts/package_mod.py + python scripts/package_mod.py --name "My Rebalance" --output "C:/Arma 3/!mods" + python scripts/package_mod.py --dry-run + +Settings are read from package_mod.ini in the repo root if it exists. +Command-line arguments override INI values. +""" + +import argparse +import os +import shutil +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +OUTPUT_DIR = REPO_ROOT / "output" +INI_PATH = REPO_ROOT / "package_mod.ini" + +DEFAULTS = { + "mod_name": "A3Rebalancer", + "mod_display_name": "A3 Rebalancer", + "mod_description": "Normalizes non-RHS ammo and armor values to RHS baseline.", + "mod_author": "REDCODE", + "mod_version": "1.0", + "arma_mods_dir": str(Path.home() / "Documents" / "Arma 3" / "!mods"), +} + + +def read_ini() -> dict: + """Read package_mod.ini if it exists.""" + if not INI_PATH.exists(): + return {} + config = {} + current_section = None + with open(INI_PATH, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or line.startswith(";"): + continue + if line.startswith("[") and line.endswith("]"): + current_section = line[1:-1] + continue + if "=" in line: + key, _, val = line.partition("=") + key = key.strip() + val = val.strip().strip('"').strip("'") + if current_section: + key = f"{current_section}.{key}" + config[key] = val + return config + + +def generate_mod_cpp(display_name: str, description: str, author: str, + version: str) -> str: + """Generate mod.cpp content.""" + desc_escaped = description.replace('"', '""') + return f'''beta = 0; +action = ""; +hideName = 0; +hidePicture = 0; +name = "{display_name}"; +picture = ""; +logo = ""; +logoOver = ""; +tooltip = "{desc_escaped}"; +overview = "{desc_escaped}"; +author = "{author}"; +authorID = ""; +version = "{version}"; +versionArma[] = {{3, 0, 0}}; +versionAI[] = {{3, 0, 0}}; +versionExt[] = {{1, 0, 0}}; +''' + + +def find_armake2() -> str: + """Find armake2 binary.""" + # Check PATH first + result = subprocess.run( + ["where", "armake2"], capture_output=True, text=True) + if result.returncode == 0: + return result.stdout.strip().splitlines()[0] + + # Check tools/ directory + tools_path = REPO_ROOT / "tools" / "armake2.exe" + if tools_path.exists(): + return str(tools_path) + + print("ERROR: armake2 not found.") + print("Install from: https://github.com/KoffeinFlummi/armake2") + print("Or place armake2.exe in tools/") + sys.exit(1) + + +def pack_pbo(armake2: str, source_dir: Path, target_pbo: Path, + prefix: str) -> bool: + """Pack a directory into a .pbo using armake2 build (with rapification).""" + cmd = [ + armake2, "build", "-f", + "-e", f"prefix={prefix}", + str(source_dir), + str(target_pbo), + ] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f" ERROR packing {source_dir.name}: {result.stderr.strip()}") + return False + return True + + +def main(): + parser = argparse.ArgumentParser( + description="Package ArmADump output into an Arma 3 mod") + parser.add_argument("--name", help="Mod folder name (default: A3Rebalancer)") + parser.add_argument("--display-name", help="Mod display name") + parser.add_argument("--description", help="Mod description") + parser.add_argument("--author", help="Mod author") + parser.add_argument("--version", help="Mod version") + parser.add_argument("--output", help="Arma 3 mods directory") + parser.add_argument("--dry-run", action="store_true", + help="Show what would be done without building") + args = parser.parse_args() + + # Read INI config + ini = read_ini() + + # Resolve values: CLI > INI > defaults + mod_name = args.name or ini.get("mod_name", DEFAULTS["mod_name"]) + display_name = (args.display_name or + ini.get("mod_display_name", DEFAULTS["mod_display_name"])) + description = (args.description or + ini.get("mod_description", DEFAULTS["mod_description"])) + author = args.author or ini.get("mod_author", DEFAULTS["mod_author"]) + version = args.version or ini.get("mod_version", DEFAULTS["mod_version"]) + mods_dir = args.output or ini.get("arma_mods_dir", DEFAULTS["arma_mods_dir"]) + + print("=" * 60) + print("ArmADump - Package Mod") + print("=" * 60) + print(f" Mod name: @{mod_name}") + print(f" Display name: {display_name}") + print(f" Author: {author}") + print(f" Version: {version}") + print(f" Output: {Path(mods_dir) / ('@' + mod_name)}") + print() + + # Validate output dir exists + if not OUTPUT_DIR.exists(): + print(f"ERROR: {OUTPUT_DIR} does not exist.") + print("Run generate_patches.py first.") + sys.exit(1) + + # Check for config.cpp files + mod_dirs = [d for d in OUTPUT_DIR.iterdir() + if d.is_dir() and (d / "config.cpp").exists()] + if not mod_dirs: + print(f"ERROR: No mod folders with config.cpp found in {OUTPUT_DIR}") + sys.exit(1) + + print(f" Found {len(mod_dirs)} addon folders") + + # Find armake2 + if not args.dry_run: + armake2 = find_armake2() + print(f" armake2: {armake2}") + print() + + # Build target path + target_dir = Path(mods_dir) / f"@{mod_name}" + addons_dir = target_dir / "addons" + + if args.dry_run: + print(f" [DRY RUN] Would create:") + print(f" {target_dir / 'mod.cpp'}") + for d in sorted(mod_dirs): + pbo_name = f"armadump_{d.name}.pbo" + print(f" {addons_dir / pbo_name}") + print(f"\n Total: {len(mod_dirs)} PBOs") + return + + # Create directories + addons_dir.mkdir(parents=True, exist_ok=True) + + # Generate mod.cpp + mod_cpp = generate_mod_cpp(display_name, description, author, version) + mod_cpp_path = target_dir / "mod.cpp" + with open(mod_cpp_path, "w", encoding="utf-8") as f: + f.write(mod_cpp) + print(f" Created {mod_cpp_path}") + print() + + # Pack each addon into a PBO (all to temp first, then copy to final location) + prefix_base = mod_name.lower() + packed = 0 + failed = 0 + temp_dir = OUTPUT_DIR / "_pack_temp" + addons_temp = temp_dir / "addons" + addons_temp.mkdir(parents=True, exist_ok=True) + + for d in sorted(mod_dirs): + pbo_name = f"armadump_{d.name}.pbo" + temp_pbo = addons_temp / pbo_name + prefix = f"{prefix_base}\\{d.name}" + + print(f" Packing {d.name}...") + if pack_pbo(armake2, d, temp_pbo, prefix): + size_kb = temp_pbo.stat().st_size / 1024 + print(f" -> {pbo_name} ({size_kb:.1f} KB)") + packed += 1 + else: + failed += 1 + + if packed == 0: + print("\nERROR: No PBOs packed.") + sys.exit(1) + + # Copy mod.cpp to temp + mod_cpp_temp = temp_dir / "mod.cpp" + shutil.copy2(str(mod_cpp_path), str(mod_cpp_temp)) + + # Swap: delete old dir, move temp into place + print(f"\n Installing to {target_dir}...") + try: + if target_dir.exists(): + shutil.rmtree(str(target_dir)) + except PermissionError: + print(" WARNING: Could not remove old directory (Arma may have it locked).") + print(" PBOs are ready in output/_pack_temp/ — close Arma and re-run, or copy manually.") + + try: + shutil.move(str(temp_dir), str(target_dir)) + except Exception as e: + print(f" Could not move to {target_dir}: {e}") + print(f" PBOs are ready at: {temp_dir}") + return + + print(f"\n{'=' * 60}") + print(f"Done! {packed} PBOs packed, {failed} failed") + print(f" Mod: {target_dir}") + print(f" Load in Arma 3 launcher or via -mod=@{mod_name}") + print(f"{'=' * 60}") + + +if __name__ == "__main__": + main()