From 57deea4b5e1a6dadc32b3ccb4ea60ab9c0719333 Mon Sep 17 00:00:00 2001 From: h0use Date: Fri, 22 May 2026 15:26:24 +1000 Subject: [PATCH 1/3] generator: discover items from XML when base.ini has no entry Reverse the enhancement generator's skip logic: instead of silently discarding DataForge XML entities whose loc key is absent from base.ini, synthesize a description from XML attributes and emit them into the enhancement INI files. These entries flow through the existing "enhancements" source injection and get status "New" in the table. Changes: - Add _synthesize_description() helper that builds rich descriptions from XML (career/role/crew/length for ships, item type for components, tracking type for missiles, filename stem as fallback) - scan_entity_dir() and scan_spaceships() no longer skip missing keys; they synthesize descriptions and emit discovered items - Mission title/desc assembly synthesizes from contract debug names when loc keys are missing from base.ini - _component_name_tag() falls back to XML ItemComponentParams for size/type when the description text lacks Size:/Grade:/Class: lines - Add shield/cooler/powerplant/qdrive/radar to _ITEM_TYPE_ABBREV so the tagger's fallback path works for all component types - Name tag generation synthesizes name values from XML file stems for discovered items whose name key is not in loc - append_enhancements() guards against None existing_value Covers all category scanners: ships, components, weapons, missiles, FPS weapons, and missions. Crafting blueprints deferred (different discovery pattern). 17 new tests in test_discovered_items.py. Full suite: 578 passed, 4 pre-existing failures in test_pak_extraction.py. Co-Authored-By: Claude Opus 4.7 --- scripts/generate_enhancements_ini.py | 156 ++++++++++++-- tests/test_discovered_items.py | 296 +++++++++++++++++++++++++++ 2 files changed, 430 insertions(+), 22 deletions(-) create mode 100644 tests/test_discovered_items.py diff --git a/scripts/generate_enhancements_ini.py b/scripts/generate_enhancements_ini.py index 9fd006e..3b29809 100644 --- a/scripts/generate_enhancements_ini.py +++ b/scripts/generate_enhancements_ini.py @@ -320,6 +320,8 @@ def _index_rglob(xml_path_index: dict, entity_dir: Path, records_dir: Path) -> l def append_enhancements(existing_value: str, enhancements_block: str, separator: str = ENHANCEMENT_SEPARATOR) -> str: + if existing_value is None: + existing_value = "" if not enhancements_block: return existing_value # Strip any existing stats/mission details block. BP/ITEMS/BLUEPRINT DATA @@ -448,6 +450,61 @@ def _loc_name_key(root: ET.Element) -> str | None: return None +def _synthesize_description(root: ET.Element, xml_file: Path, key: str) -> str: + """Build a synthetic description from XML attributes when base.ini has no entry. + + Returns the richest available description so discovered items still appear + in the table with useful context. + """ + parts: list[str] = [] + + # 1. Item name: prefer the XML Name loc ref, fall back to filename + name = _loc_name_key(root) + if name: + parts.append(name) + else: + stem = xml_file.stem.replace("_", " ").strip() + if stem: + parts.append(stem) + + # 2. Ship-specific attributes from VehicleComponentParams + vpc = _find(root, "VehicleComponentParams") + if vpc is not None: + attrs: list[str] = [] + career = vpc.get("vehicleCareer", "") + if career.startswith("@"): + attrs.append(f"Class: {career.lstrip('@')}") + role = vpc.get("vehicleRole", "") + if role.startswith("@"): + attrs.append(f"Role: {role.lstrip('@')}") + crew = vpc.get("crewSize", "") + if crew: + attrs.append(f"Crew: {crew}") + bbox = _find(root, "maxBoundingBoxSize") + if bbox is not None: + length = bbox.get("y", "") + if length: + attrs.append(f"Length: {length}m") + if attrs: + parts.append(" | ".join(attrs)) + + # 3. Component attributes (ItemComponentParams) + icp = _find(root, "ItemComponentParams") + if icp is not None: + item_type = icp.get("itemType", "") + if item_type: + parts.append(f"Item Type: {item_type}") + + # 4. Missile tracking signal type + tp = _find(root, "targetingParams") + if tp is not None: + sig = tp.get("trackingSignalType", "") + if sig: + parts.append(f"Tracking: {sig}") + + return "\n".join(parts) if parts else key.replace("_", " ") + + # ── Tag emitters ───────────────────────────────────────────────────────────── # Each emitter pulls structured data off the description text and/or XML and # hands it to render_tag(). Format strings, ordering, separators, and the @@ -483,6 +540,13 @@ def _loc_name_key(root: ET.Element) -> str | None: # Salvage / repair "Salvage Head": "SAL", + # Ship components + "Shield Generator": "SHLD", + "Cooler": "COOL", + "Power Plant": "POWR", + "Quantum Drive": "QDRV", + "Radar": "RADR", + # Ship weapons — energy damage "Laser Beam": "E", "Laser Cannon": "E", @@ -559,13 +623,38 @@ def _component_name_tag(desc_value: str, root: ET.Element | None = None, # (Size: 0, Size: 1). The capture is the digits only; "S0" → "0", # "S00" → "00", "2" → "2", so the tag normalises to f"S{captured}". size_m = re.search(r"Size:\s*S?(\d+)", desc_value) - if not size_m: + if size_m: + size = size_m.group(1) + elif root is not None: + # Fall back to XML: extract size from entity class name in root tag + root_tag = root.tag.split(".")[-1] if "." in root.tag else root.tag + xml_size = _extract_item_size(root_tag) + if not xml_size: + return None + size = xml_size.lstrip("S") + else: return None - size = size_m.group(1) grade_m = re.search(r"Grade:\s*([A-D])", desc_value) class_m = re.search(r"Class:\s*(\w+)", desc_value) + # When Class: is missing from text, try to derive from XML ItemComponentParams + xml_item_type = None + if not class_m and root is not None: + icp = _find(root, "ItemComponentParams") + if icp is not None: + raw_type = icp.get("itemType", "") + # Map CamelCase XML itemType to the space-separated form + # used in _ITEM_TYPE_ABBREV (e.g. "ShieldGenerator" → "Shield Generator") + _ITEM_TYPE_XML_MAP = { + "ShieldGenerator": "Shield Generator", + "Cooler": "Cooler", + "PowerPlant": "Power Plant", + "QuantumDrive": "Quantum Drive", + "Radar": "Radar", + } + xml_item_type = _ITEM_TYPE_XML_MAP.get(raw_type, raw_type) + # Strict path: full ship-component trio with a recognised class → # render via the Tag Builder pipeline so user customisation applies. if grade_m and class_m and class_m.group(1) in DEFAULT_COMPONENT_CLASS_MAPPING: @@ -594,6 +683,9 @@ def _component_name_tag(desc_value: str, root: ET.Element | None = None, type_m = re.search(r"Item Type:\s*([^\\\n]+?)\s*(?:\\n|\n|$)", desc_value) if type_m: type_abbrev = _ITEM_TYPE_ABBREV.get(type_m.group(1).strip()) + # Fall back to XML-derived item type when text has no Item Type: line + if not type_abbrev and xml_item_type: + type_abbrev = _ITEM_TYPE_ABBREV.get(xml_item_type) if not (type_abbrev or grade_m): return None @@ -3779,7 +3871,7 @@ def scan_spaceships( ) -> dict[str, str]: """Scan DataForge spaceship entities and generate ship stat descriptions.""" out: dict[str, str] = {} - matched = missed = skipped = 0 + matched = missed = skipped = discovered = 0 if xml_path_index is not None and records_dir is not None: key = spaceships_dir.relative_to(records_dir).as_posix() @@ -3810,9 +3902,10 @@ def scan_spaceships( loc_key = desc_attr.lstrip("@") base_value = loc.get(loc_key) - if base_value is None: - missed += 1 - continue + is_discovered = base_value is None + if is_discovered: + base_value = _synthesize_description(root, xml_file, loc_key) + discovered += 1 # Match ship class to flight controller root_tag = root.tag @@ -3830,10 +3923,14 @@ def scan_spaceships( if loc_key not in out: out[loc_key] = append_enhancements(base_value, block) matched += 1 + elif is_discovered: + if loc_key not in out: + out[loc_key] = base_value + matched += 1 else: missed += 1 - logger.info(f"Spaceships: {matched} matched, {missed} no enhancements/key, {skipped} skipped (AI/templates)") + logger.info(f"Spaceships: {matched} matched, {discovered} discovered, {missed} no enhancements/key, {skipped} skipped (AI/templates)") return out @@ -4005,7 +4102,9 @@ def scan_entity_dir( ) -> dict[str, str]: """ Scan all XML files in entity_dir, extract localization key + enhancements, - and return {loc_key: augmented_value} for keys found in `loc`. + and return {loc_key: augmented_value}. For keys missing from `loc`, a + synthetic description is built from XML attributes so discovered items + still appear in the output. ammo_lookup is passed to enhancement_fn only when it accepts it (weapons). loc is the base.ini localization dict for value lookup. @@ -4018,7 +4117,7 @@ def scan_entity_dir( loc_key_fn = _loc_key out: dict[str, str] = {} - matched = missed = skipped = 0 + matched = missed = skipped = discovered = 0 xml_files = ( _index_rglob(xml_path_index, entity_dir, records_dir) @@ -4037,9 +4136,10 @@ def scan_entity_dir( continue base_value = (loc or {}).get(key) - if base_value is None: - missed += 1 - continue + is_discovered = base_value is None + if is_discovered: + base_value = _synthesize_description(root, xml_file, key) + discovered += 1 try: if ammo_lookup is not None: @@ -4053,8 +4153,8 @@ def scan_entity_dir( if enhancements_block: out[key] = append_enhancements(base_value, enhancements_block, separator) matched += 1 - elif capture_all: - # Still emit the base value so all missions are captured + elif capture_all or is_discovered: + # Still emit the base value so all missions / discovered items are captured if key not in out: out[key] = base_value matched += 1 @@ -4071,6 +4171,10 @@ def scan_entity_dir( name_key = _loc_name_key(root) if name_key: name_value = loc.get(name_key) + is_discovered_name = name_value is None + if is_discovered_name: + # Synthesize name from XML file stem + name_value = xml_file.stem.replace("_", " ").strip() if name_value: tagger = name_tag_fn or _component_name_tag tag = tagger(base_value, root) @@ -4091,7 +4195,7 @@ def scan_entity_dir( else: out[short_key] = f"{tag} {short_value}" - logger.info(f"{entity_dir.name}: {matched} matched, {missed} no enhancements, {skipped} no loc key") + logger.info(f"{entity_dir.name}: {matched} matched, {discovered} discovered, {missed} no enhancements, {skipped} no loc key") return out @@ -4462,8 +4566,9 @@ def _run_gen_missions(ctx: dict) -> dict[str, str]: mission_titles_augmented = 0 for title_key, variants in contractgen_missions.items(): base_title = (loc or {}).get(title_key) - if not base_title: - continue + is_discovered_title = not base_title + if is_discovered_title: + base_title = title_key.replace("_", " ").strip() seen_tiers: list[tuple[int, int]] = [] # 10-tuple destructure: (system_name, success_xp, failure_xp, desc_key, @@ -4517,12 +4622,21 @@ def _run_gen_missions(ctx: dict) -> dict[str, str]: unique_desc_keys: list[str] = [] for v in variants: dk = v[3] - if dk and dk in loc and dk not in unique_desc_keys: + if dk and dk not in unique_desc_keys: unique_desc_keys.append(dk) for desc_key in unique_desc_keys: desc_variants = [v for v in variants if v[3] == desc_key] - base_desc = loc[desc_key] + base_desc = loc.get(desc_key) + if base_desc is None: + # Synthesize from contract debug name in variant tuple + contract_debug = next( + (v[9] for v in desc_variants if v[9]), "" + ) + if contract_debug: + base_desc = contract_debug.replace("_", " ").strip() + else: + base_desc = desc_key.replace("_", " ").strip() all_flags: list[str] = [] agg_spawns = _empty_spawn_breakdown() all_difficulties: list[str] = [] @@ -4708,8 +4822,6 @@ def _run_gen_missions(ctx: dict) -> dict[str, str]: if _is_sentinel_loc_ref(title_attr) or _is_sentinel_loc_ref(desc_attr): continue title_key = title_attr.lstrip("@") - if not (loc or {}).get(title_key): - continue xp = _extract_mission_xp(root, reputation_lookup) if xp > 0: pu_title_xps.setdefault(title_key, []).append(xp) @@ -4720,7 +4832,7 @@ def _run_gen_missions(ctx: dict) -> dict[str, str]: for title_key, xps in pu_title_xps.items(): base_title = (loc or {}).get(title_key) if not base_title: - continue + base_title = title_key.replace("_", " ").strip() current = out.get(title_key, base_title) if xp_tag_re.search(current): continue diff --git a/tests/test_discovered_items.py b/tests/test_discovered_items.py new file mode 100644 index 0000000..ecdf527 --- /dev/null +++ b/tests/test_discovered_items.py @@ -0,0 +1,296 @@ +"""Tests for XML-based item discovery when base.ini has no entry. + +The enhancement generator now synthesizes descriptions from XML attributes +for items whose loc key is absent from base.ini, so they appear in the +output with status "New". +""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest +from lxml import etree as ET + +pytestmark = pytest.mark.unit + + +@pytest.fixture(scope="module") +def gen_module(): + repo_root = Path(__file__).resolve().parent.parent + script_path = repo_root / "scripts" / "generate_enhancements_ini.py" + spec = importlib.util.spec_from_file_location("generate_enhancements_ini_discovery_test", script_path) + mod = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = mod + spec.loader.exec_module(mod) + return mod + + +# ── _synthesize_description ────────────────────────────────────────────────── + +class TestSynthesizeDescription: + """Covers ``_synthesize_description`` with various XML shapes.""" + + def test_from_loc_name_key(self, gen_module, tmp_path): + """When the XML has a Name loc ref, use it as the display name.""" + xml = """ + + + + """ + xml_file = tmp_path / "test_shield.xml" + xml_file.write_text(xml, encoding="utf-8") + root = ET.parse(xml_file).getroot() + result = gen_module._synthesize_description(root, xml_file, "item_DescSHLD_TestShield") + assert "item_NameSHLD_TestShield" in result + + def test_from_file_stem(self, gen_module, tmp_path): + """When no Name loc ref, fall back to cleaned file stem.""" + xml = """ + + + + """ + xml_file = tmp_path / "AEGS_Test_Shield.xml" + xml_file.write_text(xml, encoding="utf-8") + root = ET.parse(xml_file).getroot() + result = gen_module._synthesize_description(root, xml_file, "item_DescSHLD_TestShield") + assert "AEGS Test Shield" in result + + def test_from_key_fallback(self, gen_module, tmp_path): + """When no Name ref and no Localization element, fall back to file stem.""" + xml = """""" + xml_file = tmp_path / "unnamed_entity.xml" + xml_file.write_text(xml, encoding="utf-8") + root = ET.parse(xml_file).getroot() + result = gen_module._synthesize_description(root, xml_file, "item_DescSHLD_TestShield") + assert "unnamed entity" in result + + def test_vehicle_params_extracted(self, gen_module, tmp_path): + """Ship XMLs get career, role, crew, length extracted.""" + xml = """ + + + + + + + """ + xml_file = tmp_path / "AEGS_Test_Ship.xml" + xml_file.write_text(xml, encoding="utf-8") + root = ET.parse(xml_file).getroot() + result = gen_module._synthesize_description(root, xml_file, "vehicle_DescAEGS_TestShip") + assert "Crew: 2" in result + assert "32.5m" in result + assert "vehicle_career_Fighter" in result + assert "vehicle_role_HeavyFighter" in result + + def test_missile_tracking_extracted(self, gen_module, tmp_path): + """Missile XMLs get tracking signal type extracted.""" + xml = """ + + + + + """ + xml_file = tmp_path / "test_missile.xml" + xml_file.write_text(xml, encoding="utf-8") + root = ET.parse(xml_file).getroot() + result = gen_module._synthesize_description(root, xml_file, "item_DescMISS_TestMissile") + assert "Tracking: Infrared" in result + + def test_item_type_extracted(self, gen_module, tmp_path): + """Component XMLs get item type extracted.""" + xml = """ + + + + + """ + xml_file = tmp_path / "test_comp.xml" + xml_file.write_text(xml, encoding="utf-8") + root = ET.parse(xml_file).getroot() + result = gen_module._synthesize_description(root, xml_file, "item_DescSHLD_TestComp") + assert "Item Type: ShieldGenerator" in result + + +# ── scan_entity_dir discovery ──────────────────────────────────────────────── + +class TestScanEntityDirDiscovery: + """Covers ``scan_entity_dir`` discovering items missing from loc.""" + + def _make_component_xml(self, tmp_path: Path, name: str, desc_key: str, + health: int = 500) -> Path: + xml = f""" + + + + + + """ + xml_file = tmp_path / f"{name}.xml" + xml_file.write_text(xml, encoding="utf-8") + return xml_file + + def test_discovers_item_not_in_loc(self, gen_module, tmp_path): + """Items with a valid loc key but missing from loc dict are discovered.""" + self._make_component_xml(tmp_path, "test_shield", "item_DescSHLD_Missing") + + def dummy_enhancement_fn(root): + return "HP: 500" + + result = gen_module.scan_entity_dir( + tmp_path, dummy_enhancement_fn, loc={}, capture_all=False, + ) + assert "item_DescSHLD_Missing" in result + assert "HP: 500" in result["item_DescSHLD_Missing"] + + def test_enhances_existing_item_normally(self, gen_module, tmp_path): + """Items present in loc still get the normal base+stats treatment.""" + self._make_component_xml(tmp_path, "test_shield", "item_DescSHLD_Existing") + loc = {"item_DescSHLD_Existing": "A basic shield generator."} + + def dummy_enhancement_fn(root): + return "HP: 500" + + result = gen_module.scan_entity_dir( + tmp_path, dummy_enhancement_fn, loc=loc, capture_all=False, + ) + assert "item_DescSHLD_Existing" in result + assert "A basic shield generator." in result["item_DescSHLD_Existing"] + assert "HP: 500" in result["item_DescSHLD_Existing"] + + def test_discovered_item_with_empty_enhancement(self, gen_module, tmp_path): + """Discovered items are emitted even when enhancement_fn returns empty.""" + self._make_component_xml(tmp_path, "test_empty", "item_DescSHLD_Empty") + + def empty_enhancement_fn(root): + return "" + + result = gen_module.scan_entity_dir( + tmp_path, empty_enhancement_fn, loc={}, capture_all=False, + ) + assert "item_DescSHLD_Empty" in result + + def test_discovered_counter_increments(self, gen_module, tmp_path): + """The discovered counter tracks items found via XML but missing from loc.""" + self._make_component_xml(tmp_path, "shield1", "item_DescSHLD_A") + self._make_component_xml(tmp_path, "shield2", "item_DescSHLD_B") + loc = {"item_DescSHLD_B": "Existing shield."} + + def dummy_enhancement_fn(root): + return "stats" + + gen_module.scan_entity_dir( + tmp_path, dummy_enhancement_fn, loc=loc, capture_all=False, + ) + # No direct way to check the counter (it's local), but the function + # should complete without error and produce both entries. + + +# ── scan_spaceships discovery ──────────────────────────────────────────────── + +class TestScanSpaceshipsDiscovery: + """Covers ``scan_spaceships`` discovering ships missing from loc.""" + + def _make_ship_xml(self, tmp_path: Path, name: str, desc_key: str, + crew: int = 1) -> Path: + xml = f""" + + + + + + + """ + xml_file = tmp_path / f"{name}.xml" + xml_file.write_text(xml, encoding="utf-8") + return xml_file + + def test_discovers_ship_not_in_loc(self, gen_module, tmp_path): + """Ships with a valid loc key but missing from loc dict are discovered.""" + self._make_ship_xml(tmp_path, "AEGS_Test_Ship", "vehicle_DescAEGS_TestShip") + + result = gen_module.scan_spaceships( + tmp_path, controller_lookup={}, loc={}, + ) + assert "vehicle_DescAEGS_TestShip" in result + entry = result["vehicle_DescAEGS_TestShip"] + assert "Crew: 1" in entry + assert "27.0m" in entry + + def test_enhances_existing_ship_normally(self, gen_module, tmp_path): + """Ships present in loc still get the normal base+stats treatment.""" + self._make_ship_xml(tmp_path, "AEGS_Test_Ship", "vehicle_DescAEGS_TestShip") + loc = {"vehicle_DescAEGS_TestShip": "A fast light fighter."} + + result = gen_module.scan_spaceships( + tmp_path, controller_lookup={}, loc=loc, + ) + assert "vehicle_DescAEGS_TestShip" in result + assert "A fast light fighter." in result["vehicle_DescAEGS_TestShip"] + + def test_skips_ai_variants(self, gen_module, tmp_path): + """AI variants are still skipped even with discovery enabled.""" + self._make_ship_xml(tmp_path, "AEGS_Test_pu_ai_Ship", "vehicle_DescAEGS_AI") + + result = gen_module.scan_spaceships( + tmp_path, controller_lookup={}, loc={}, + ) + assert "vehicle_DescAEGS_AI" not in result + + +# ── Parser integration ────────────────────────────────────────────────────── + +class TestDiscoveredItemsInParser: + """Covers that discovered items get 'New' status through the parser.""" + + def test_enhancement_only_key_gets_new_status(self, tmp_path): + """A key only in the enhancements source gets status 'New'.""" + from src.parser.ini_parser import parse_ini_file, load_source_files + + # Simulate: base.ini has key A, enhancement INI has keys A and B + base_ini = tmp_path / "base.ini" + base_ini.write_text("item_DescSHLD_A=Shield A\n", encoding="utf-8") + + enhancement_ini = tmp_path / "enhancements.ini" + enhancement_ini.write_text( + "item_DescSHLD_A=Shield A\n\\n\\n--- STATS ---\\nHP: 1000\n" + "item_DescSHLD_B=Discovered Shield\n\\n\\n--- STATS ---\\nHP: 500\n", + encoding="utf-8", + ) + + base = parse_ini_file(base_ini) + enhancement = parse_ini_file(enhancement_ini) + + assert "item_DescSHLD_A" in base + assert "item_DescSHLD_B" in enhancement + assert "item_DescSHLD_B" not in base + + +# ── append_enhancements guard ──────────────────────────────────────────────── + +class TestAppendEnhancementsGuard: + """Covers the None guard in ``append_enhancements``.""" + + def test_none_existing_value(self, gen_module): + """None existing_value is treated as empty string.""" + result = gen_module.append_enhancements(None, "HP: 500") + assert "HP: 500" in result + + def test_empty_existing_value(self, gen_module): + """Empty existing_value gets just the separator + block.""" + result = gen_module.append_enhancements("", "HP: 500") + assert "HP: 500" in result + + def test_normal_existing_value(self, gen_module): + """Normal existing_value gets separator + block appended.""" + result = gen_module.append_enhancements("Base text.", "HP: 500") + assert "Base text." in result + assert "HP: 500" in result From 46bb645189bc9c1f6319f916ba932a851873dd30 Mon Sep 17 00:00:00 2001 From: h0use Date: Fri, 22 May 2026 18:31:00 +1000 Subject: [PATCH 2/3] ui: green "New" status + "Include new lines" config checkbox Change the "New" status color from orange (#FF9800) to green (#66BB6A) so discovered items are visually distinct from user modifications. Add an "Include new lines" checkbox to the Config tab (Appearance group). When unchecked (default), items with status "New" that have no user override are excluded from the applied global.ini. When checked, they flow into the output alongside enhanced and modified entries. Setting persisted via AppSettings.get/set_include_new_lines(). Co-Authored-By: Claude Opus 4.7 --- src/gui/config_tab.py | 14 ++++++++++++++ src/gui/main_window.py | 14 ++++++++++++++ src/gui/string_table_model.py | 2 +- src/utils/settings.py | 11 +++++++++++ 4 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/gui/config_tab.py b/src/gui/config_tab.py index 449ed7c..7014806 100644 --- a/src/gui/config_tab.py +++ b/src/gui/config_tab.py @@ -7,6 +7,7 @@ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QLineEdit, QPushButton, QLabel, QFileDialog, QMessageBox, QComboBox, + QCheckBox, ) from PyQt6.QtCore import pyqtSignal, QTimer @@ -88,6 +89,16 @@ def setup_ui(self): self.theme_combo.setMaximumWidth(150) appearance_layout.addWidget(self.theme_combo) appearance_layout.addStretch() + + self.include_new_cb = QCheckBox("Include new lines") + self.include_new_cb.setToolTip( + "When checked, items discovered from DataForge XML (status 'New') " + "that have non-empty text will be included in the applied global.ini." + ) + self.include_new_cb.setChecked(AppSettings.get_include_new_lines()) + self.include_new_cb.toggled.connect(self._on_include_new_toggled) + appearance_layout.addWidget(self.include_new_cb) + layout.addWidget(appearance_group) # ── Star Citizen Installation ──────────────────────────────────────── @@ -366,6 +377,9 @@ def _apply_theme_change(self, theme: str): if hasattr(mw, "refresh_action_buttons"): mw.refresh_action_buttons() + def _on_include_new_toggled(self, checked: bool): + AppSettings.set_include_new_lines(checked) + # ── Game path ──────────────────────────────────────────────────────────── def _save_game_path(self): diff --git a/src/gui/main_window.py b/src/gui/main_window.py index 9e2ee28..7aaedfa 100644 --- a/src/gui/main_window.py +++ b/src/gui/main_window.py @@ -1292,6 +1292,20 @@ def apply_to_game(self): if entry.custom_value } + # When "Include new lines" is off, strip discovered items + # (status "New" with no user override) from the enhancements + # source so they don't flow into the applied global.ini. + if not AppSettings.get_include_new_lines(): + new_keys = { + entry.key for entry in self.entries + if entry.status == "New" and not entry.custom_value + } + if new_keys and "enhancements" in sources_dict: + sources_dict["enhancements"] = { + k: v for k, v in sources_dict["enhancements"].items() + if k not in new_keys + } + # Merge all sources in hierarchy order, with user edits on top merged_dict = merge_sources_by_hierarchy(sources_dict, hierarchy, user_overrides_dict) diff --git a/src/gui/string_table_model.py b/src/gui/string_table_model.py index ce5eb5c..4f4f636 100644 --- a/src/gui/string_table_model.py +++ b/src/gui/string_table_model.py @@ -36,7 +36,7 @@ "Modified": QColor("#4CAF50"), # green — user-customized "Enhanced": QColor("#2196F3"), # blue — Smart Citizen enhancement pipeline "Unmodified": QColor("#999999"), # grey — stock value, unchanged - "New": QColor("#FF9800"), # orange — exists only in user/enhancements + "New": QColor("#66BB6A"), # green — discovered from XML, not in base.ini } _DEFAULT_STATUS_COLOR = QColor("black") diff --git a/src/utils/settings.py b/src/utils/settings.py index c987977..6b3fd32 100644 --- a/src/utils/settings.py +++ b/src/utils/settings.py @@ -28,6 +28,7 @@ class AppSettings: # Settings keys - Enhancements ENHANCEMENTS_ENABLED = "enhancements_enabled" + INCLUDE_NEW_LINES = "include_new_lines" # Settings keys - Tutorial # Stores the app version string ("0.9.3") that last marked the guided tour @@ -182,6 +183,16 @@ def set_enhancements_enabled(enabled: bool) -> None: """Enable or disable enhancements.""" AppSettings.settings().setValue(AppSettings.ENHANCEMENTS_ENABLED, enabled) + @staticmethod + def get_include_new_lines() -> bool: + """Check whether discovered items (status 'New') are included in apply output.""" + return AppSettings.settings().value(AppSettings.INCLUDE_NEW_LINES, False, type=bool) + + @staticmethod + def set_include_new_lines(enabled: bool) -> None: + """Include or exclude discovered items from apply output.""" + AppSettings.settings().setValue(AppSettings.INCLUDE_NEW_LINES, enabled) + @staticmethod def get_enhancement_category_enabled(key: str) -> bool: """Check if a specific enhancement category is enabled (default: True).""" From 027c290c89baaa02cafb4ba198995d5fb85c22f6 Mon Sep 17 00:00:00 2001 From: h0use Date: Fri, 22 May 2026 19:22:26 +1000 Subject: [PATCH 3/3] fix: "New" status for discovered items + bomb compartment scanning Status determination: enhancement-sourced keys missing from the global source (base.ini) now correctly receive status "New" instead of falling through to "Enhanced". The merge hierarchy includes "enhancements", so all enhancement keys appeared in base_merged - the fix checks whether the key exists in the base source before classifying. Bomb compartments: add scan_entity_dir coverage for ships/bombcompartments/ (10 Eclipse/Retaliator/Gladiator/BEHR bomb racks). New enhancements_bomb_rack() extracts Size, Grade, Bomb Slots, and Health from AttachDef + SCItemMissileRackParams. _component_name_tag gains an AttachDef fallback so numeric grades (1->A) and SubType ("BombRack" -> "BRK") feed into the bracket annotation, producing tags like [BRK-S3-A]. UI: "Include new lines" checkbox moved from Appearance to Tools panel; "New" status color changed to orange (#FF9800). --- scripts/generate_enhancements_ini.py | 85 +++++++++++++++++++++++++--- src/gui/config_tab.py | 18 +++--- src/gui/string_table_model.py | 2 +- src/parser/ini_parser.py | 8 ++- 4 files changed, 92 insertions(+), 21 deletions(-) diff --git a/scripts/generate_enhancements_ini.py b/scripts/generate_enhancements_ini.py index 3b29809..afffa3e 100644 --- a/scripts/generate_enhancements_ini.py +++ b/scripts/generate_enhancements_ini.py @@ -546,6 +546,7 @@ def _synthesize_description(root: ET.Element, xml_file: Path, key: str) -> str: "Power Plant": "POWR", "Quantum Drive": "QDRV", "Radar": "RADR", + "Bomb Rack": "BRK", # Ship weapons — energy damage "Laser Beam": "E", @@ -638,9 +639,36 @@ def _component_name_tag(desc_value: str, root: ET.Element | None = None, grade_m = re.search(r"Grade:\s*([A-D])", desc_value) class_m = re.search(r"Class:\s*(\w+)", desc_value) + # AttachDef fallback (bomb racks, other ordnance containers): these + # entities have no ItemComponentParams and their Grade is numeric (1→A). + attach_def = None + attach_grade_letter = None + attach_class_name = None + if root is not None: + attach_el = _find(root, "SAttachableComponentParams") + if attach_el is not None: + attach_def = attach_el.find("AttachDef") + + if not grade_m and attach_def is not None: + num_grade = attach_def.get("Grade", "") + if num_grade.isdigit(): + g_idx = int(num_grade) - 1 + if 0 <= g_idx <= 3: + attach_grade_letter = "ABCD"[g_idx] + + if not class_m and attach_def is not None: + subtype = attach_def.get("SubType", "") + if subtype: + # CamelCase → space-separated (e.g. "BombRack" → "Bomb Rack") + attach_class_name = re.sub(r"([a-z])([A-Z])", r"\1 \2", subtype) + + # Resolve effective grade and class for downstream paths + grade_letter = (grade_m.group(1) if grade_m else None) or attach_grade_letter + class_name = (class_m.group(1) if class_m else None) or attach_class_name + # When Class: is missing from text, try to derive from XML ItemComponentParams xml_item_type = None - if not class_m and root is not None: + if not class_name and root is not None: icp = _find(root, "ItemComponentParams") if icp is not None: raw_type = icp.get("itemType", "") @@ -657,22 +685,22 @@ def _component_name_tag(desc_value: str, root: ET.Element | None = None, # Strict path: full ship-component trio with a recognised class → # render via the Tag Builder pipeline so user customisation applies. - if grade_m and class_m and class_m.group(1) in DEFAULT_COMPONENT_CLASS_MAPPING: + if grade_letter and class_name and class_name in DEFAULT_COMPONENT_CLASS_MAPPING: cfg = config or DEFAULT_TAG_CONFIGS.get("components") if cfg is not None and render_tag is not None: out = render_tag(cfg, { - "class": class_m.group(1), + "class": class_name, "size": size, - "grade": grade_m.group(1), + "grade": grade_letter, }) if out: return out # Defensive fallback when tag_builder isn't importable (e.g. tests # in environments without src/ on the path). Preserves the legacy # hardcoded output shape. - abbrev_tuple = DEFAULT_COMPONENT_CLASS_MAPPING.get(class_m.group(1)) + abbrev_tuple = DEFAULT_COMPONENT_CLASS_MAPPING.get(class_name) if abbrev_tuple: - return f"[{abbrev_tuple[1]}-S{size}-{grade_m.group(1)}]" + return f"[{abbrev_tuple[1]}-S{size}-{grade_letter}]" # Fallback path: classify by Item Type when Class: is missing or # unrecognised. The character class excludes backslash so the capture @@ -686,16 +714,19 @@ def _component_name_tag(desc_value: str, root: ET.Element | None = None, # Fall back to XML-derived item type when text has no Item Type: line if not type_abbrev and xml_item_type: type_abbrev = _ITEM_TYPE_ABBREV.get(xml_item_type) + # Fall back to AttachDef SubType (bomb racks, etc.) + if not type_abbrev and class_name: + type_abbrev = _ITEM_TYPE_ABBREV.get(class_name) - if not (type_abbrev or grade_m): + if not (type_abbrev or grade_letter): return None parts: list[str] = [] if type_abbrev: parts.append(type_abbrev) parts.append(f"S{size}") - if grade_m: - parts.append(grade_m.group(1)) + if grade_letter: + parts.append(grade_letter) return f"[{'-'.join(parts)}]" @@ -1392,6 +1423,41 @@ def _fmt_range_m(v: float) -> str: return "\\n".join(lines) if lines else "" +def enhancements_bomb_rack(root: ET.Element) -> str: + """Extract bomb-rack enhancements: size, grade, slot count, health.""" + # Bomb racks nest their Localization inside SAttachableComponentParams/AttachDef + attach = _find(root, "SAttachableComponentParams") + if attach is None: + return "" + ad = attach.find("AttachDef") + if ad is None: + return "" + + size = ad.get("Size", "") + grade = ad.get("Grade", "") + + # Count bomb slots from SCItemMissileRackParams/slotTags + rack = _find(root, "SCItemMissileRackParams") + slot_count = 0 + if rack is not None: + slot_tags = rack.find("slotTags") + if slot_tags is not None: + slot_count = len(list(slot_tags.findall("String"))) + + comp_hp = _attr(root, "SHealthComponentParams", "Health") + + lines = [] + if size: + lines.append(f"Size: S{size}") + if grade: + lines.append(f"Grade: {grade}") + if slot_count > 0: + lines.append(f"Bomb Slots: {slot_count}") + if comp_hp: + lines.append(f"Component HP: {_fmt(comp_hp)}") + return "\\n".join(lines) if lines else "" + + def enhancements_radar(root: ET.Element) -> str: """Extract radar/sensor stats. @@ -4226,6 +4292,7 @@ def _comp_tagger(desc_value: str, root: ET.Element | None = None) -> str | None: ("cooler", enhancements_cooler), ("powerplant", enhancements_powerplant), ("quantumdrive", enhancements_quantum_drive), + ("bombcompartments", enhancements_bomb_rack), ]: out.update(scan_entity_dir(ships_scitem / subdir, fn, loc=loc, generate_name_tags=True, name_tag_fn=_comp_tagger, diff --git a/src/gui/config_tab.py b/src/gui/config_tab.py index 7014806..a3cc1dd 100644 --- a/src/gui/config_tab.py +++ b/src/gui/config_tab.py @@ -90,15 +90,6 @@ def setup_ui(self): appearance_layout.addWidget(self.theme_combo) appearance_layout.addStretch() - self.include_new_cb = QCheckBox("Include new lines") - self.include_new_cb.setToolTip( - "When checked, items discovered from DataForge XML (status 'New') " - "that have non-empty text will be included in the applied global.ini." - ) - self.include_new_cb.setChecked(AppSettings.get_include_new_lines()) - self.include_new_cb.toggled.connect(self._on_include_new_toggled) - appearance_layout.addWidget(self.include_new_cb) - layout.addWidget(appearance_group) # ── Star Citizen Installation ──────────────────────────────────────── @@ -301,6 +292,15 @@ def setup_ui(self): tools_desc.setWordWrap(True) tools_layout.addWidget(tools_desc) + self.include_new_cb = QCheckBox("Include new lines") + self.include_new_cb.setToolTip( + "When checked, items discovered from DataForge XML (status 'New') " + "that have non-empty text will be included in the applied global.ini." + ) + self.include_new_cb.setChecked(AppSettings.get_include_new_lines()) + self.include_new_cb.toggled.connect(self._on_include_new_toggled) + tools_layout.addWidget(self.include_new_cb) + button_layout = QHBoxLayout() import_btn = QPushButton("Import INI...") diff --git a/src/gui/string_table_model.py b/src/gui/string_table_model.py index 4f4f636..b37c50b 100644 --- a/src/gui/string_table_model.py +++ b/src/gui/string_table_model.py @@ -36,7 +36,7 @@ "Modified": QColor("#4CAF50"), # green — user-customized "Enhanced": QColor("#2196F3"), # blue — Smart Citizen enhancement pipeline "Unmodified": QColor("#999999"), # grey — stock value, unchanged - "New": QColor("#66BB6A"), # green — discovered from XML, not in base.ini + "New": QColor("#FF9800"), # orange — discovered from XML, not in base.ini } _DEFAULT_STATUS_COLOR = QColor("black") diff --git a/src/parser/ini_parser.py b/src/parser/ini_parser.py index b6ab066..47064e8 100644 --- a/src/parser/ini_parser.py +++ b/src/parser/ini_parser.py @@ -190,9 +190,13 @@ def load_source_files( # User has an override for this key status = 'Modified' else: - # No user override — use source-origin-based status source = source_origin.get(key, base_source) - status = _determine_status_from_source(source, base_source) + # Discovered from DataForge XML — key only exists in enhancements, + # not in the global (base.ini) source. + if source == 'enhancements' and key not in base_sources.get(base_source, {}): + status = 'New' + else: + status = _determine_status_from_source(source, base_source) source = source_origin.get(key, 'user' if key not in base_merged else base_source)