bg3-authoring 1.0.0b1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bg3_authoring-1.0.0b1/.gitignore +58 -0
- bg3_authoring-1.0.0b1/PKG-INFO +70 -0
- bg3_authoring-1.0.0b1/README.md +62 -0
- bg3_authoring-1.0.0b1/bg3_authoring/__init__.py +0 -0
- bg3_authoring-1.0.0b1/bg3_authoring/add_mod_dependency.py +367 -0
- bg3_authoring-1.0.0b1/bg3_authoring/cli.py +32 -0
- bg3_authoring-1.0.0b1/bg3_authoring/create_mod_scaffold.py +469 -0
- bg3_authoring-1.0.0b1/bg3_authoring/diagnostics/__init__.py +0 -0
- bg3_authoring-1.0.0b1/bg3_authoring/diagnostics/check_mod_conflicts.py +637 -0
- bg3_authoring-1.0.0b1/bg3_authoring/diagnostics/diagnose_toolkit_logs.py +765 -0
- bg3_authoring-1.0.0b1/bg3_authoring/diagnostics/inspect_save_game.py +29 -0
- bg3_authoring-1.0.0b1/bg3_authoring/diagnostics/sync_localization.py +280 -0
- bg3_authoring-1.0.0b1/bg3_authoring/diagnostics/validate_mod.py +576 -0
- bg3_authoring-1.0.0b1/bg3_authoring/generate_compat_patch.py +400 -0
- bg3_authoring-1.0.0b1/bg3_authoring/inject_treasure_table.py +267 -0
- bg3_authoring-1.0.0b1/bg3_authoring/merge_staging_to_mod.py +297 -0
- bg3_authoring-1.0.0b1/bg3_authoring/packaging/__init__.py +0 -0
- bg3_authoring-1.0.0b1/bg3_authoring/packaging/pack_mod_pak.py +385 -0
- bg3_authoring-1.0.0b1/bg3_authoring/packaging/sync_game_data.py +773 -0
- bg3_authoring-1.0.0b1/bg3_authoring/scaffold_action_resource.py +222 -0
- bg3_authoring-1.0.0b1/bg3_authoring/scaffold_appearance_visual.py +238 -0
- bg3_authoring-1.0.0b1/bg3_authoring/scaffold_class_description.py +455 -0
- bg3_authoring-1.0.0b1/bg3_authoring/scaffold_default_values.py +250 -0
- bg3_authoring-1.0.0b1/bg3_authoring/scaffold_feat.py +269 -0
- bg3_authoring-1.0.0b1/bg3_authoring/scaffold_icon_uvs.py +235 -0
- bg3_authoring-1.0.0b1/bg3_authoring/scaffold_level_map.py +210 -0
- bg3_authoring-1.0.0b1/bg3_authoring/scaffold_origin.py +223 -0
- bg3_authoring-1.0.0b1/bg3_authoring/scaffold_progression_node.py +360 -0
- bg3_authoring-1.0.0b1/bg3_authoring/scaffold_race.py +298 -0
- bg3_authoring-1.0.0b1/bg3_authoring/scaffold_root_template.py +403 -0
- bg3_authoring-1.0.0b1/bg3_authoring/scaffold_spell_list.py +196 -0
- bg3_authoring-1.0.0b1/bg3_authoring/scaffold_stat_entry.py +455 -0
- bg3_authoring-1.0.0b1/bg3_authoring/scaffold_tag.py +179 -0
- bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-check-mod-conflicts/SKILL.md +45 -0
- bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-check-mod-conflicts/references/conflict_methodology.md +33 -0
- bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-check-mod-conflicts/resources/remediation_patch_template.md +43 -0
- bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-create-mod/SKILL.md +55 -0
- bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-create-mod/references/grill_me_interview_protocol.md +15 -0
- bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-create-mod/resources/subagent_templates.md +40 -0
- bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-diagnose-mod/SKILL.md +52 -0
- bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-diagnose-mod/references/runtime_log_triage.md +30 -0
- bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-post-release/SKILL.md +52 -0
- bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-post-release/references/storefront_metadata_guide.md +37 -0
- bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-release-prep/SKILL.md +52 -0
- bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-release-prep/references/release_pipeline_specs.md +17 -0
- bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-test-mod/SKILL.md +58 -0
- bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-test-mod/references/save_audit_runbook.md +21 -0
- bg3_authoring-1.0.0b1/bg3_authoring/skills/bg3-test-mod/references/smoke_testing_runbook.md +23 -0
- bg3_authoring-1.0.0b1/bg3_authoring/templates/custom_mod_readme.md +30 -0
- bg3_authoring-1.0.0b1/pyproject.toml +19 -0
- bg3_authoring-1.0.0b1/templates/custom_mod_readme.md +30 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# Baldur's Gate 3 Mod Studio & Mod.io Toolkit - Git Ignore Rules
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
|
|
5
|
+
# Secrets & Environment Configurations
|
|
6
|
+
.env
|
|
7
|
+
*.env
|
|
8
|
+
.env.*.local
|
|
9
|
+
|
|
10
|
+
# Python runtime & caches
|
|
11
|
+
__pycache__/
|
|
12
|
+
*.py[cod]
|
|
13
|
+
*$py.class
|
|
14
|
+
*.so
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.coverage
|
|
17
|
+
htmlcov/
|
|
18
|
+
dist/
|
|
19
|
+
build/
|
|
20
|
+
*.egg-info/
|
|
21
|
+
.eggs/
|
|
22
|
+
.cache/
|
|
23
|
+
|
|
24
|
+
# Virtual Environments
|
|
25
|
+
.venv/
|
|
26
|
+
venv/
|
|
27
|
+
env/
|
|
28
|
+
ENV/
|
|
29
|
+
|
|
30
|
+
# IDE & Editor Files
|
|
31
|
+
.idea/
|
|
32
|
+
.vscode/
|
|
33
|
+
*.swp
|
|
34
|
+
*.swo
|
|
35
|
+
*~
|
|
36
|
+
|
|
37
|
+
# Operating System Files
|
|
38
|
+
.DS_Store
|
|
39
|
+
Thumbs.db
|
|
40
|
+
desktop.ini
|
|
41
|
+
|
|
42
|
+
# Build artifacts, logs & archives
|
|
43
|
+
*.log
|
|
44
|
+
*.tmp
|
|
45
|
+
*.bak
|
|
46
|
+
*.pak
|
|
47
|
+
*.loca
|
|
48
|
+
*.zip
|
|
49
|
+
|
|
50
|
+
# Scratch / temporary experimental folders
|
|
51
|
+
scratch/
|
|
52
|
+
|
|
53
|
+
# Externalized User Content (Ignore any local studio folders or accidental spills)
|
|
54
|
+
build_guides/
|
|
55
|
+
mods/
|
|
56
|
+
my_mods/
|
|
57
|
+
BG3_Mod_Studio/
|
|
58
|
+
.agents/ledgers/artifacts-ledger.md
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bg3-authoring
|
|
3
|
+
Version: 1.0.0b1
|
|
4
|
+
Summary: Baldur's Gate 3 Mod Studio Authoring, Diagnostics, and Packaging Toolkit
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: bg3-core
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# bg3-authoring
|
|
10
|
+
|
|
11
|
+
Mod authoring, asset scaffolding, static diagnostics, and packaging toolchain for the **Baldur's Gate 3 Mod Studio** workspace.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Domain Architecture & Scope
|
|
16
|
+
|
|
17
|
+
`bg3-authoring` provides end-to-end tooling for creating, linting, packaging, and debugging custom *Baldur's Gate 3* mods. It generates Patch 8 compliant Larian XML structures, deterministic UUIDv4 identifiers, and localized string handles (`h...g...`), enforcing clean separation between authored source mods and the game's Virtual File System (VFS).
|
|
18
|
+
|
|
19
|
+
```mermaid
|
|
20
|
+
flowchart TD
|
|
21
|
+
Scaffold["14 Asset Generators\n(bg3_authoring.scaffold_*)"] --> ModDir["Mods/authored/<ModSlug>/"]
|
|
22
|
+
ModDir --> Diagnostics["Static Diagnostics & Linter\n(validate_mod, check_mod_conflicts)"]
|
|
23
|
+
Diagnostics --> Packaging["LSPK v18 Packager\n(packaging.pack_mod_pak)"]
|
|
24
|
+
Packaging --> VFS["Game VFS Sync\n(packaging.sync_game_data)"]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Subpackages & Core Capabilities
|
|
30
|
+
|
|
31
|
+
### 1. Asset Scaffolding Generators
|
|
32
|
+
14 specialized generator modules creating standard Larian engine structures:
|
|
33
|
+
- `create_mod_scaffold.py`: Generates the canonical 4-root mod directory layout (`Mods/`, `Projects/`, `Public/`, `Localization/`).
|
|
34
|
+
- `scaffold_class_description.py`: Scaffolds classes, subclasses, ProgressionDescriptions, and parent progression wiring.
|
|
35
|
+
- `scaffold_feat.py`: Generates feats, FeatDescriptions, and localization string tables.
|
|
36
|
+
- `scaffold_spell_list.py`: Scaffolds standalone SpellLists and PassiveLists.
|
|
37
|
+
- `scaffold_level_map.py`: Configures LevelMapValues scaling curves across character levels 1–20.
|
|
38
|
+
- `scaffold_action_resource.py`: Creates custom action points, spell slots, and resource replenish conditions.
|
|
39
|
+
- `scaffold_progression_node.py`: Appends level-up progressions into `Progressions.lsx`.
|
|
40
|
+
- `scaffold_root_template.py`: Generates 3D GameObject root templates for items and equipment.
|
|
41
|
+
- `scaffold_stat_entry.py`: Scaffolds Weapons, Armor, Spells, Passives, and StatusData text entries.
|
|
42
|
+
- `scaffold_icon_uvs.py`: Computes texture atlas UV coordinates for custom spell and item icons.
|
|
43
|
+
|
|
44
|
+
### 2. Diagnostics & Compatibility (`bg3_authoring.diagnostics`)
|
|
45
|
+
- `validate_mod.py`: Deep static validation checking XML/LSX syntax, UUID collisions, missing string handles, and required folder layouts.
|
|
46
|
+
- `check_mod_conflicts.py`: Cross-mod progression collision detector auditing vanilla and community mod overrides.
|
|
47
|
+
- `diagnose_toolkit_logs.py`: Automated triage of Larian Toolkit and engine runtime crash logs with actionable fix recommendations.
|
|
48
|
+
|
|
49
|
+
### 3. Packaging & Synchronization (`bg3_authoring.packaging`)
|
|
50
|
+
- `pack_mod_pak.py`: Pure-Python LSPK v18 binary compiler that writes 40-byte headers, compresses files using LZ4 block compression (LSPK v18), compiles XML localization into binary `.loca`, and outputs deployable `.pak` archives.
|
|
51
|
+
- `sync_game_data.py`: Idempotent differential file synchronizer that mirrors mod assets into the game's `Data/` directory for live in-engine playtesting.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## CLI Console Entrypoint
|
|
56
|
+
|
|
57
|
+
This package registers the `bg3-mod` console script (also routed via the main `bg3` router):
|
|
58
|
+
|
|
59
|
+
```toml
|
|
60
|
+
[project.scripts]
|
|
61
|
+
bg3-mod = "bg3_authoring.cli:main"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Common Commands:
|
|
65
|
+
- `bg3 mod scaffold <asset-type> --mod <ModSlug> ...` — Scaffold new game assets.
|
|
66
|
+
- `bg3 mod validate <ModSlug>` — Run strict pre-commit validation.
|
|
67
|
+
- `bg3 mod conflict <path> --against-library` — Check progression collisions.
|
|
68
|
+
- `bg3 mod pack <ModSlug> --publish-local` — Compile LSPK `.pak` and deploy to local mods folder.
|
|
69
|
+
- `bg3 mod sync-game --mods <ModSlug> [--watch]` — Hot-reload source files into the game runtime.
|
|
70
|
+
- `bg3 save inspect latest` — Audit save-game mod dependencies before launching the game.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# bg3-authoring
|
|
2
|
+
|
|
3
|
+
Mod authoring, asset scaffolding, static diagnostics, and packaging toolchain for the **Baldur's Gate 3 Mod Studio** workspace.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Domain Architecture & Scope
|
|
8
|
+
|
|
9
|
+
`bg3-authoring` provides end-to-end tooling for creating, linting, packaging, and debugging custom *Baldur's Gate 3* mods. It generates Patch 8 compliant Larian XML structures, deterministic UUIDv4 identifiers, and localized string handles (`h...g...`), enforcing clean separation between authored source mods and the game's Virtual File System (VFS).
|
|
10
|
+
|
|
11
|
+
```mermaid
|
|
12
|
+
flowchart TD
|
|
13
|
+
Scaffold["14 Asset Generators\n(bg3_authoring.scaffold_*)"] --> ModDir["Mods/authored/<ModSlug>/"]
|
|
14
|
+
ModDir --> Diagnostics["Static Diagnostics & Linter\n(validate_mod, check_mod_conflicts)"]
|
|
15
|
+
Diagnostics --> Packaging["LSPK v18 Packager\n(packaging.pack_mod_pak)"]
|
|
16
|
+
Packaging --> VFS["Game VFS Sync\n(packaging.sync_game_data)"]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Subpackages & Core Capabilities
|
|
22
|
+
|
|
23
|
+
### 1. Asset Scaffolding Generators
|
|
24
|
+
14 specialized generator modules creating standard Larian engine structures:
|
|
25
|
+
- `create_mod_scaffold.py`: Generates the canonical 4-root mod directory layout (`Mods/`, `Projects/`, `Public/`, `Localization/`).
|
|
26
|
+
- `scaffold_class_description.py`: Scaffolds classes, subclasses, ProgressionDescriptions, and parent progression wiring.
|
|
27
|
+
- `scaffold_feat.py`: Generates feats, FeatDescriptions, and localization string tables.
|
|
28
|
+
- `scaffold_spell_list.py`: Scaffolds standalone SpellLists and PassiveLists.
|
|
29
|
+
- `scaffold_level_map.py`: Configures LevelMapValues scaling curves across character levels 1–20.
|
|
30
|
+
- `scaffold_action_resource.py`: Creates custom action points, spell slots, and resource replenish conditions.
|
|
31
|
+
- `scaffold_progression_node.py`: Appends level-up progressions into `Progressions.lsx`.
|
|
32
|
+
- `scaffold_root_template.py`: Generates 3D GameObject root templates for items and equipment.
|
|
33
|
+
- `scaffold_stat_entry.py`: Scaffolds Weapons, Armor, Spells, Passives, and StatusData text entries.
|
|
34
|
+
- `scaffold_icon_uvs.py`: Computes texture atlas UV coordinates for custom spell and item icons.
|
|
35
|
+
|
|
36
|
+
### 2. Diagnostics & Compatibility (`bg3_authoring.diagnostics`)
|
|
37
|
+
- `validate_mod.py`: Deep static validation checking XML/LSX syntax, UUID collisions, missing string handles, and required folder layouts.
|
|
38
|
+
- `check_mod_conflicts.py`: Cross-mod progression collision detector auditing vanilla and community mod overrides.
|
|
39
|
+
- `diagnose_toolkit_logs.py`: Automated triage of Larian Toolkit and engine runtime crash logs with actionable fix recommendations.
|
|
40
|
+
|
|
41
|
+
### 3. Packaging & Synchronization (`bg3_authoring.packaging`)
|
|
42
|
+
- `pack_mod_pak.py`: Pure-Python LSPK v18 binary compiler that writes 40-byte headers, compresses files using LZ4 block compression (LSPK v18), compiles XML localization into binary `.loca`, and outputs deployable `.pak` archives.
|
|
43
|
+
- `sync_game_data.py`: Idempotent differential file synchronizer that mirrors mod assets into the game's `Data/` directory for live in-engine playtesting.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## CLI Console Entrypoint
|
|
48
|
+
|
|
49
|
+
This package registers the `bg3-mod` console script (also routed via the main `bg3` router):
|
|
50
|
+
|
|
51
|
+
```toml
|
|
52
|
+
[project.scripts]
|
|
53
|
+
bg3-mod = "bg3_authoring.cli:main"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Common Commands:
|
|
57
|
+
- `bg3 mod scaffold <asset-type> --mod <ModSlug> ...` — Scaffold new game assets.
|
|
58
|
+
- `bg3 mod validate <ModSlug>` — Run strict pre-commit validation.
|
|
59
|
+
- `bg3 mod conflict <path> --against-library` — Check progression collisions.
|
|
60
|
+
- `bg3 mod pack <ModSlug> --publish-local` — Compile LSPK `.pak` and deploy to local mods folder.
|
|
61
|
+
- `bg3 mod sync-game --mods <ModSlug> [--watch]` — Hot-reload source files into the game runtime.
|
|
62
|
+
- `bg3 save inspect latest` — Audit save-game mod dependencies before launching the game.
|
|
File without changes
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
packages/bg3-authoring/bg3_authoring/add_mod_dependency.py
|
|
4
|
+
-----------------------------------------------------------
|
|
5
|
+
Appends community or core mod dependencies and test dependencies to an existing custom mod in my_mods/.
|
|
6
|
+
|
|
7
|
+
Features:
|
|
8
|
+
- Resolves dependency metadata (UUID, Version64, MD5, Folder, Name, PublishHandle)
|
|
9
|
+
via local mods/library.md before falling back to mod.io.
|
|
10
|
+
- Injects standard Larian v4.8 ModuleShortDesc nodes into Mods/*/meta.lsx.
|
|
11
|
+
- Updates mod README.md under ## Dependencies.
|
|
12
|
+
- Supports adding both permanent dependencies and test dependencies in a single invocation.
|
|
13
|
+
- Supports multiple comma-separated mod slugs or repeated CLI flags.
|
|
14
|
+
- Supports --test-only (with values or as a flag) and --test-dep flags to manage test dependencies in .test_deps.json.
|
|
15
|
+
- Supports target mod detection via --mod, positional argument, or current working directory.
|
|
16
|
+
- Supports --dry-run and structured --json output.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
import xml.etree.ElementTree as ET
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
26
|
+
|
|
27
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
|
28
|
+
from bg3_core.env_paths import MY_MODS_DIR, resolve_mod_dir
|
|
29
|
+
from bg3_core.mod_resolver import resolve_dependency_metadata
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def detect_mod_from_cwd() -> Optional[str]:
|
|
33
|
+
"""Detects mod name if the current working directory is inside a mod in MY_MODS_DIR."""
|
|
34
|
+
try:
|
|
35
|
+
cwd = Path.cwd().resolve()
|
|
36
|
+
my_mods = MY_MODS_DIR.resolve()
|
|
37
|
+
rel = cwd.relative_to(my_mods)
|
|
38
|
+
if rel.parts:
|
|
39
|
+
return rel.parts[0]
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_slug_list(values: Any) -> List[str]:
|
|
46
|
+
"""Parses a list of CLI values which may contain comma-separated mod slugs."""
|
|
47
|
+
if not values:
|
|
48
|
+
return []
|
|
49
|
+
if isinstance(values, str):
|
|
50
|
+
values = [values]
|
|
51
|
+
slugs: List[str] = []
|
|
52
|
+
for val in values:
|
|
53
|
+
if isinstance(val, str):
|
|
54
|
+
for part in val.split(","):
|
|
55
|
+
cleaned = part.strip()
|
|
56
|
+
if cleaned and cleaned not in slugs:
|
|
57
|
+
slugs.append(cleaned)
|
|
58
|
+
return slugs
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def add_test_dependencies(mod_dir: Path, dep_metas: List[Dict[str, Any]], dry_run: bool = False) -> List[Dict[str, Any]]:
|
|
62
|
+
"""Adds temporary test dependencies to .test_deps.json in the mod workspace."""
|
|
63
|
+
config_file = mod_dir / ".test_deps.json"
|
|
64
|
+
data: Dict[str, Any] = {"test_dependencies": []}
|
|
65
|
+
if config_file.is_file():
|
|
66
|
+
try:
|
|
67
|
+
data = json.loads(config_file.read_text(encoding="utf-8"))
|
|
68
|
+
if "test_dependencies" not in data or not isinstance(data["test_dependencies"], list):
|
|
69
|
+
data["test_dependencies"] = []
|
|
70
|
+
except Exception:
|
|
71
|
+
data = {"test_dependencies": []}
|
|
72
|
+
|
|
73
|
+
existing = [str(d).lower() for d in data["test_dependencies"]]
|
|
74
|
+
results: List[Dict[str, Any]] = []
|
|
75
|
+
modified = False
|
|
76
|
+
|
|
77
|
+
for dep_meta in dep_metas:
|
|
78
|
+
dep_name = dep_meta.get("Name") or dep_meta.get("Folder") or dep_meta.get("UUID") or "Unknown"
|
|
79
|
+
dep_identifier = dep_meta.get("Folder") or dep_name
|
|
80
|
+
|
|
81
|
+
if dep_name.lower() in existing or dep_identifier.lower() in existing:
|
|
82
|
+
results.append({
|
|
83
|
+
"status": "already_present",
|
|
84
|
+
"mod": mod_dir.name,
|
|
85
|
+
"type": "test_dependency",
|
|
86
|
+
"dependency": dep_name,
|
|
87
|
+
"config_file": str(config_file),
|
|
88
|
+
})
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
data["test_dependencies"].append(dep_name)
|
|
92
|
+
existing.append(dep_name.lower())
|
|
93
|
+
existing.append(dep_identifier.lower())
|
|
94
|
+
modified = True
|
|
95
|
+
|
|
96
|
+
results.append({
|
|
97
|
+
"status": "added_test_dependency",
|
|
98
|
+
"mod": mod_dir.name,
|
|
99
|
+
"type": "test_dependency",
|
|
100
|
+
"dependency": dep_name,
|
|
101
|
+
"uuid": dep_meta.get("UUID"),
|
|
102
|
+
"config_file": str(config_file),
|
|
103
|
+
"dry_run": dry_run,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
if modified and not dry_run:
|
|
107
|
+
config_file.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
108
|
+
|
|
109
|
+
return results
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def add_test_dependency(mod_dir: Path, dep_meta: Dict[str, Any], dry_run: bool = False) -> Dict[str, Any]:
|
|
113
|
+
"""Single-dependency wrapper for add_test_dependencies."""
|
|
114
|
+
res = add_test_dependencies(mod_dir, [dep_meta], dry_run=dry_run)
|
|
115
|
+
return res[0] if res else {}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def add_permanent_dependencies(mod_dir: Path, dep_metas: List[Dict[str, Any]], dry_run: bool = False) -> List[Dict[str, Any]]:
|
|
119
|
+
"""Injects dependency ModuleShortDesc nodes into meta.lsx and updates README.md."""
|
|
120
|
+
meta_files = list(mod_dir.glob("Mods/*/meta.lsx"))
|
|
121
|
+
if not meta_files:
|
|
122
|
+
raise FileNotFoundError(f"Could not locate Mods/*/meta.lsx in '{mod_dir}'.")
|
|
123
|
+
|
|
124
|
+
target_meta = meta_files[0]
|
|
125
|
+
tree = ET.parse(target_meta)
|
|
126
|
+
root = tree.getroot()
|
|
127
|
+
|
|
128
|
+
# Find or create Dependencies node
|
|
129
|
+
deps_node = root.find(".//node[@id='Dependencies']")
|
|
130
|
+
if deps_node is None:
|
|
131
|
+
root_node = root.find(".//node[@id='root']/children")
|
|
132
|
+
if root_node is None:
|
|
133
|
+
raise ValueError(f"Malformed meta.lsx in '{target_meta}': missing root children.")
|
|
134
|
+
deps_node = ET.SubElement(root_node, "node", id="Dependencies")
|
|
135
|
+
|
|
136
|
+
children = deps_node.find("children")
|
|
137
|
+
if children is None:
|
|
138
|
+
children = ET.SubElement(deps_node, "children")
|
|
139
|
+
|
|
140
|
+
existing_uuids = set()
|
|
141
|
+
for short_desc in children.findall("node[@id='ModuleShortDesc']"):
|
|
142
|
+
u_attr = short_desc.find("attribute[@id='UUID']")
|
|
143
|
+
if u_attr is not None and u_attr.get("value"):
|
|
144
|
+
existing_uuids.add(u_attr.get("value", "").lower())
|
|
145
|
+
|
|
146
|
+
results: List[Dict[str, Any]] = []
|
|
147
|
+
meta_modified = False
|
|
148
|
+
new_readme_entries: List[Tuple[Dict[str, Any], str]] = []
|
|
149
|
+
|
|
150
|
+
for dep_meta in dep_metas:
|
|
151
|
+
dep_uuid = (dep_meta.get("UUID") or "").lower()
|
|
152
|
+
if dep_uuid and dep_uuid in existing_uuids:
|
|
153
|
+
results.append({
|
|
154
|
+
"status": "already_present",
|
|
155
|
+
"mod": mod_dir.name,
|
|
156
|
+
"type": "permanent_dependency",
|
|
157
|
+
"dependency": dep_meta.get("Name"),
|
|
158
|
+
"uuid": dep_meta.get("UUID"),
|
|
159
|
+
"file": str(target_meta),
|
|
160
|
+
})
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
# Construct new ModuleShortDesc node
|
|
164
|
+
new_desc = ET.Element("node", id="ModuleShortDesc")
|
|
165
|
+
ET.SubElement(new_desc, "attribute", id="Folder", type="LSString", value=dep_meta.get("Folder", dep_meta.get("Name", "")))
|
|
166
|
+
ET.SubElement(new_desc, "attribute", id="MD5", type="LSString", value=dep_meta.get("MD5", ""))
|
|
167
|
+
ET.SubElement(new_desc, "attribute", id="Name", type="LSString", value=dep_meta.get("Name", ""))
|
|
168
|
+
ET.SubElement(new_desc, "attribute", id="PublishHandle", type="uint64", value=str(dep_meta.get("PublishHandle", 0)))
|
|
169
|
+
ET.SubElement(new_desc, "attribute", id="UUID", type="guid", value=dep_meta.get("UUID", ""))
|
|
170
|
+
ET.SubElement(new_desc, "attribute", id="Version64", type="int64", value=str(dep_meta.get("Version64", 36028797018963968)))
|
|
171
|
+
|
|
172
|
+
children.append(new_desc)
|
|
173
|
+
if dep_uuid:
|
|
174
|
+
existing_uuids.add(dep_uuid)
|
|
175
|
+
meta_modified = True
|
|
176
|
+
|
|
177
|
+
dep_entry = f"- `{dep_meta.get('Name')}` (`{dep_meta.get('UUID')}`)"
|
|
178
|
+
new_readme_entries.append((dep_meta, dep_entry))
|
|
179
|
+
|
|
180
|
+
results.append({
|
|
181
|
+
"status": "added_dependency",
|
|
182
|
+
"mod": mod_dir.name,
|
|
183
|
+
"type": "permanent_dependency",
|
|
184
|
+
"dependency": dep_meta.get("Name"),
|
|
185
|
+
"uuid": dep_meta.get("UUID"),
|
|
186
|
+
"folder": dep_meta.get("Folder"),
|
|
187
|
+
"publish_handle": dep_meta.get("PublishHandle"),
|
|
188
|
+
"meta_file": str(target_meta),
|
|
189
|
+
"readme_updated": False,
|
|
190
|
+
"dry_run": dry_run,
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
if meta_modified and not dry_run:
|
|
194
|
+
ET.indent(tree, space=" ")
|
|
195
|
+
tree.write(target_meta, encoding="utf-8", xml_declaration=True)
|
|
196
|
+
|
|
197
|
+
# Update README.md if present
|
|
198
|
+
readme_path = mod_dir / "README.md"
|
|
199
|
+
readme_updated = False
|
|
200
|
+
if readme_path.is_file() and new_readme_entries:
|
|
201
|
+
readme_txt = readme_path.read_text(encoding="utf-8")
|
|
202
|
+
unlisted = [e for e in new_readme_entries if e[0].get("UUID") and e[0]["UUID"] not in readme_txt]
|
|
203
|
+
if unlisted and "## Dependencies" in readme_txt:
|
|
204
|
+
entries_block = "\n".join(e[1] for e in unlisted)
|
|
205
|
+
if re.search(r"## Dependencies\s*\n\s*- None\.", readme_txt):
|
|
206
|
+
readme_txt = re.sub(r"(## Dependencies\s*\n)\s*- None\.", r"\1" + entries_block, readme_txt)
|
|
207
|
+
else:
|
|
208
|
+
readme_txt = re.sub(r"(## Dependencies\s*\n)", r"\1" + entries_block + "\n", readme_txt)
|
|
209
|
+
readme_updated = True
|
|
210
|
+
if not dry_run:
|
|
211
|
+
readme_path.write_text(readme_txt, encoding="utf-8")
|
|
212
|
+
|
|
213
|
+
for r in results:
|
|
214
|
+
if r.get("status") == "added_dependency":
|
|
215
|
+
r["readme_updated"] = readme_updated
|
|
216
|
+
|
|
217
|
+
return results
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def add_permanent_dependency(mod_dir: Path, dep_meta: Dict[str, Any], dry_run: bool = False) -> Dict[str, Any]:
|
|
221
|
+
"""Single-dependency wrapper for add_permanent_dependencies."""
|
|
222
|
+
res = add_permanent_dependencies(mod_dir, [dep_meta], dry_run=dry_run)
|
|
223
|
+
return res[0] if res else {}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def resolve_cli_targets(args: argparse.Namespace) -> Tuple[List[str], List[str]]:
|
|
227
|
+
"""
|
|
228
|
+
Categorizes CLI input into permanent dependencies and test dependencies.
|
|
229
|
+
Supports comma-separated slugs, repeated flags, --test-dep, and --test-only as flag or value.
|
|
230
|
+
"""
|
|
231
|
+
perm_slugs: List[str] = []
|
|
232
|
+
test_slugs: List[str] = []
|
|
233
|
+
|
|
234
|
+
# 1. Parse explicit --test-dep / --test-deps values
|
|
235
|
+
if getattr(args, "test_dep", None):
|
|
236
|
+
test_slugs.extend(parse_slug_list(args.test_dep))
|
|
237
|
+
|
|
238
|
+
# 2. Parse --test-only
|
|
239
|
+
raw_test_only = getattr(args, "test_only", [])
|
|
240
|
+
has_test_only_flag = any(v is True for v in raw_test_only)
|
|
241
|
+
test_only_values = parse_slug_list([v for v in raw_test_only if isinstance(v, str)])
|
|
242
|
+
if test_only_values:
|
|
243
|
+
test_slugs.extend(test_only_values)
|
|
244
|
+
|
|
245
|
+
# 3. Parse --dep / --dependency
|
|
246
|
+
raw_dep = getattr(args, "dep", [])
|
|
247
|
+
dep_slugs = parse_slug_list(raw_dep)
|
|
248
|
+
|
|
249
|
+
# If --test-only was passed as a bare flag without slug values:
|
|
250
|
+
# route --dep values to test dependencies (preserves backward compatibility)
|
|
251
|
+
if has_test_only_flag and not test_only_values:
|
|
252
|
+
test_slugs.extend(dep_slugs)
|
|
253
|
+
else:
|
|
254
|
+
perm_slugs.extend(dep_slugs)
|
|
255
|
+
|
|
256
|
+
# Remove duplicates preserving order
|
|
257
|
+
dedup_perm: List[str] = []
|
|
258
|
+
for s in perm_slugs:
|
|
259
|
+
if s not in dedup_perm:
|
|
260
|
+
dedup_perm.append(s)
|
|
261
|
+
|
|
262
|
+
dedup_test: List[str] = []
|
|
263
|
+
for s in test_slugs:
|
|
264
|
+
if s not in dedup_test:
|
|
265
|
+
dedup_test.append(s)
|
|
266
|
+
|
|
267
|
+
return dedup_perm, dedup_test
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def main() -> None:
|
|
271
|
+
parser = argparse.ArgumentParser(
|
|
272
|
+
description="Add community or core mod dependencies and test dependencies to an existing custom mod in my_mods/."
|
|
273
|
+
)
|
|
274
|
+
parser.add_argument("pos_mod", nargs="?", default=None, help="Target mod name or slug (optional if --mod is specified)")
|
|
275
|
+
parser.add_argument("--mod", default=None, help="Custom mod name, slug, or folder in my_mods/")
|
|
276
|
+
parser.add_argument("--dep", "--dependency", dest="dep", action="append", default=[], help="Dependency mod name(s), slug(s), or UUID(s) (comma-separated or repeated)")
|
|
277
|
+
parser.add_argument("--test-dep", "--test-deps", dest="test_dep", action="append", default=[], help="Test dependency mod name(s), slug(s), or UUID(s) (comma-separated or repeated)")
|
|
278
|
+
parser.add_argument(
|
|
279
|
+
"--test-only",
|
|
280
|
+
dest="test_only",
|
|
281
|
+
nargs="?",
|
|
282
|
+
const=True,
|
|
283
|
+
action="append",
|
|
284
|
+
default=[],
|
|
285
|
+
help="Add as test dependency in .test_deps.json. Can be a bare flag or take comma-separated mod slug(s)",
|
|
286
|
+
)
|
|
287
|
+
parser.add_argument("--dry-run", action="store_true", help="Preview changes without modifying files")
|
|
288
|
+
parser.add_argument("--json", action="store_true", help="Output machine-readable JSON result")
|
|
289
|
+
|
|
290
|
+
args = parser.parse_args()
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
target_mod = args.mod or args.pos_mod or detect_mod_from_cwd()
|
|
294
|
+
if not target_mod:
|
|
295
|
+
raise ValueError("Target mod must be specified via --mod <name>, positional argument, or by running inside a mod folder in my_mods/.")
|
|
296
|
+
|
|
297
|
+
mod_dir = resolve_mod_dir(target_mod, prefer_my_mods=True)
|
|
298
|
+
if not mod_dir or not mod_dir.is_dir():
|
|
299
|
+
raise FileNotFoundError(f"Could not locate mod directory for '{target_mod}'.")
|
|
300
|
+
|
|
301
|
+
perm_slugs, test_slugs = resolve_cli_targets(args)
|
|
302
|
+
if not perm_slugs and not test_slugs:
|
|
303
|
+
raise ValueError("No dependencies specified. Provide dependency mod slugs via --dep, --test-dep, or --test-only.")
|
|
304
|
+
|
|
305
|
+
perm_results: List[Dict[str, Any]] = []
|
|
306
|
+
if perm_slugs:
|
|
307
|
+
perm_metas = [
|
|
308
|
+
resolve_dependency_metadata(slug, auto_document=True, strict=True)
|
|
309
|
+
for slug in perm_slugs
|
|
310
|
+
]
|
|
311
|
+
perm_results = add_permanent_dependencies(mod_dir, perm_metas, dry_run=args.dry_run)
|
|
312
|
+
|
|
313
|
+
test_results: List[Dict[str, Any]] = []
|
|
314
|
+
if test_slugs:
|
|
315
|
+
test_metas = [
|
|
316
|
+
resolve_dependency_metadata(slug, auto_document=True, strict=False)
|
|
317
|
+
for slug in test_slugs
|
|
318
|
+
]
|
|
319
|
+
test_results = add_test_dependencies(mod_dir, test_metas, dry_run=args.dry_run)
|
|
320
|
+
|
|
321
|
+
all_results = perm_results + test_results
|
|
322
|
+
success = True
|
|
323
|
+
|
|
324
|
+
if args.json:
|
|
325
|
+
response: Dict[str, Any] = {
|
|
326
|
+
"mod": mod_dir.name,
|
|
327
|
+
"status": "success",
|
|
328
|
+
"dependencies": perm_results,
|
|
329
|
+
"test_dependencies": test_results,
|
|
330
|
+
"results": all_results,
|
|
331
|
+
"success": success,
|
|
332
|
+
"dry_run": args.dry_run,
|
|
333
|
+
}
|
|
334
|
+
if len(all_results) == 1:
|
|
335
|
+
single = all_results[0]
|
|
336
|
+
for k, v in single.items():
|
|
337
|
+
if k not in response:
|
|
338
|
+
response[k] = v
|
|
339
|
+
response["status"] = single.get("status", "success")
|
|
340
|
+
print(json.dumps(response, indent=2))
|
|
341
|
+
else:
|
|
342
|
+
verb = "[DRY-RUN] Would add" if args.dry_run else "Successfully added"
|
|
343
|
+
for r in all_results:
|
|
344
|
+
dep_name = r.get("dependency") or r.get("uuid") or "Unknown"
|
|
345
|
+
status = r.get("status")
|
|
346
|
+
dep_kind = "test dependency" if r.get("type") == "test_dependency" else "dependency"
|
|
347
|
+
if status == "already_present":
|
|
348
|
+
print(f"• {dep_kind.capitalize()} '{dep_name}' is already present in '{r.get('mod')}'.")
|
|
349
|
+
else:
|
|
350
|
+
print(f"✓ {verb} {dep_kind} '{dep_name}' to '{r.get('mod')}'.")
|
|
351
|
+
if r.get("uuid"):
|
|
352
|
+
print(f" UUID: {r.get('uuid')}")
|
|
353
|
+
if r.get("meta_file"):
|
|
354
|
+
print(f" Updated: {r.get('meta_file')}")
|
|
355
|
+
elif r.get("config_file") and status != "already_present":
|
|
356
|
+
print(f" Updated: {r.get('config_file')}")
|
|
357
|
+
|
|
358
|
+
except Exception as e:
|
|
359
|
+
if args.json:
|
|
360
|
+
print(json.dumps({"error": str(e), "success": False}, indent=2))
|
|
361
|
+
else:
|
|
362
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
363
|
+
sys.exit(1)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
if __name__ == "__main__":
|
|
367
|
+
main()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""bg3-authoring CLI entrypoint."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from bg3_core.cli import build_parser, dispatch_command
|
|
6
|
+
from bg3_core.cli_common import ConsoleColor, ExitCode, OutputEmitter
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
parser = build_parser()
|
|
11
|
+
if len(sys.argv) == 1:
|
|
12
|
+
parser.print_help(sys.stderr)
|
|
13
|
+
sys.exit(ExitCode.USAGE)
|
|
14
|
+
|
|
15
|
+
args = parser.parse_args()
|
|
16
|
+
color = ConsoleColor(stream=sys.stderr, force_color=False)
|
|
17
|
+
emitter = OutputEmitter(json_mode=args.json, color=color)
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
ret = dispatch_command(args, emitter)
|
|
21
|
+
sys.exit(ret)
|
|
22
|
+
except KeyboardInterrupt:
|
|
23
|
+
emitter.error(
|
|
24
|
+
message="Operation aborted by user.",
|
|
25
|
+
code="SIGINT",
|
|
26
|
+
exit_code=130,
|
|
27
|
+
exit_process=True,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
main()
|