Track many more dynamically-constructed UI texture paths

Several categories of session UI textures were falsely reported as
unused because checkrefs only parsed XML/JSON data files and missed
texture references buried in JavaScript runtime logic.

Entity templates:
- Parse <Identity><Rank> to track session/icons/ranks/{rank}.png
- Parse <Minimap><Icon> to track session/icons/minimap/{icon}.png

Data-driven JSON files:
- Add add_resources(): scan simulation/data/resources/*.json for
  resource 'code' fields -> session/icons/resources/{code}.png
- Add add_status_effects(): scan simulation/data/status_effects/*.json
  for 'icon' fields -> session/icons/status_effects/{icon}.png

Technology files:
- In add_techs(): detect phase_*.json base techs (e.g. phase_city.json)
  and add session/panel_phase_emblems_{phase}.png as a dep

JS scanning (extended):
- Extend the GUI JS literal-path regex to also recognise:
  - 'texture:' sprite modifier prefix
  - full 'art/textures/ui/...' literal paths
  - bare 'session/...' paths used as runtime sprite components
- Also scan simulation/components, simulation/helpers and globalscripts
  JS files (registered as roots) with the same regex so textures
  referenced from simulation code are tracked
- Add _add_unit_action_icon_deps(): parse unit_actions.js for
  'icon': 'X.png' properties -> session/icons/X
- Add _add_status_bar_icon_deps(): parse StatusBars.js AddBar() calls
  for bar type strings -> session/icons/{type}_bg/fg.png
- Add _add_stance_icon_deps(): parse g_Stances in UnitAI.js for
  selectable stance keys -> session/icons/stances/{stance}.png
This commit is contained in:
Stanislas Daniel Claude Dolcini authored and Stan committed 2026-10-03 22:55:40 +02:00
1 parent 0a8369c0b8
commit 4bc1938ddc
1 file changed
+164 -7
+164 -7
View File
@@ -152,6 +152,8 @@ class CheckRefs:
self.add_techs()
self.add_terrains()
self.add_auras()
self.add_resources()
self.add_status_effects()
self.add_tips()
self.add_cpp_hardcoded_assets()
self.check_deps()
@@ -407,6 +409,22 @@ class CheckRefs:
self.deps.append(
(fp, Path(f"art/textures/ui/session/portraits/{icon.text}"))
)
rank = entity.find("Identity").find("Rank")
if rank is not None and rank.text:
self.deps.append(
(fp, Path(f"art/textures/ui/session/icons/ranks/{rank.text}.png"))
)
if entity.find("Minimap") is not None:
minimap_icon = entity.find("Minimap").find("Icon")
if minimap_icon is not None and minimap_icon.text:
self.deps.append(
(
fp,
Path(
f"art/textures/ui/session/icons/minimap/{minimap_icon.text}"
),
)
)
if (
entity.find("Heal") is not None
and entity.find("Heal").find("RangeOverlay") is not None
@@ -827,19 +845,43 @@ class CheckRefs:
def add_gui_data(self):
self.logger.info("Loading GUI data...")
# Regex to find literal (non-dynamic) sprite paths in JS that end with a
# known texture extension. Excludes strings containing JS interpolation
# operators (+, ${, `), which would mean the path is only a prefix.
ext_pattern = "|".join(re.escape(ext) for ext in self.supportedTextureFormats)
# Finds literal sprite/texture paths in JS. Three forms are matched:
# 1. "stretched:path", "cropped:...:path", "texture:path" — the last `:` segment
# is the path relative to art/textures/ui/.
# 2. Bare "session/path.ext" — used as a component that gets a modifier prefix
# prepended at runtime (e.g. prototype sprite assignments, civPhaseEmblems).
# 3. Full "art/textures/ui/path.ext" — literal full VFS path used in simulation JS.
# Strings containing JS dynamic operators (+, ${, `) are excluded.
js_sprite_regex = re.compile(
r'["\'](?:stretched|cropped)[^"\']*?:([^"\'$`+]*\.(?:'
+ "|".join(re.escape(ext) for ext in self.supportedTextureFormats)
+ r'))["\']'
r'["\'](?:'
r'(?:(?:stretched|cropped|texture)[^"\']*?:)([^"\'$`+]*\.(?:' + ext_pattern + r'))'
r'|(?:art/textures/ui/)([^"\'$`+]*\.(?:' + ext_pattern + r'))'
r'|session/([^"\'$`+]*\.(?:' + ext_pattern + r'))'
r')["\']'
)
for fp, ffp in self.find_files("gui", "js"):
self.files.append(fp)
content = ffp.read_text(encoding="utf-8")
for m in js_sprite_regex.finditer(content):
self.deps.append((fp, Path(f"art/textures/ui/{m.group(1)}")))
# group(1): modifier-prefixed path, group(2): full art path,
# group(3): bare session/ path
texture_file = m.group(1) or m.group(2) or ("session/" + m.group(3))
self.deps.append((fp, Path(f"art/textures/ui/{texture_file}")))
self._add_unit_action_icon_deps()
# Scan simulation/helpers and globalscripts JS for literal texture paths.
# These files are engine-loaded by C++ unconditionally, so they are registered
# as roots (not just files) so the graph traversal visits their texture deps.
for scan_dir in ("simulation/components", "simulation/helpers", "globalscripts"):
for fp, ffp in self.find_files(scan_dir, "js"):
self.files.append(fp)
self.roots.append(fp)
content = ffp.read_text(encoding="utf-8")
for m in js_sprite_regex.finditer(content):
texture_file = m.group(1) or m.group(2) or ("session/" + m.group(3))
self.deps.append((fp, Path(f"art/textures/ui/{texture_file}")))
self._add_status_bar_icon_deps()
self._add_stance_icon_deps()
self.files.extend([fp for (fp, ffp) in self.find_files("gamesettings", "js")])
self.files.extend([fp for (fp, ffp) in self.find_files("autostart", "js")])
self.roots.extend([fp for (fp, ffp) in self.find_files("autostart", "js")])
@@ -855,6 +897,86 @@ class CheckRefs:
]
)
def _add_unit_action_icon_deps(self):
"""Track session/icons/ textures referenced via short names in unit_actions.js.
unit_actions.js defines action objects with an "icon" property whose value is
a bare filename (e.g. "garrison.png"). selection_panels.js then constructs the
sprite as "stretched:session/icons/" + action.icon, so checkrefs never sees
the full path. This method extracts all such short icon names and registers
the corresponding texture deps.
"""
unit_actions_fp = Path("gui/session/unit_actions.js")
for mod in self.mods:
ffp = self.vfs_root / mod / unit_actions_fp
if not ffp.exists():
continue
content = ffp.read_text(encoding="utf-8")
icon_name_regex = re.compile(
r'"icon"\s*:\s*"([^"$`+]*\.(?:'
+ "|".join(re.escape(ext) for ext in self.supportedTextureFormats)
+ r'))"'
)
for m in icon_name_regex.finditer(content):
icon_name = m.group(1)
# Short names without a "/" are relative to session/icons/;
# names already containing "/" are used as-is relative to session/.
if "/" not in icon_name:
self.deps.append(
(unit_actions_fp, Path(f"art/textures/ui/session/icons/{icon_name}"))
)
def _add_status_bar_icon_deps(self):
"""Track session/icons/{type}_bg.png and {type}_fg.png for StatusBars.js bar types.
StatusBars.js constructs paths as "art/textures/ui/session/icons/" + type + "_bg.png"
where type is a string literal passed to AddBar(). Extract these literals with a
targeted regex.
"""
status_bars_fp = Path("simulation/components/StatusBars.js")
for mod in self.mods:
ffp = self.vfs_root / mod / status_bars_fp
if not ffp.exists():
continue
content = ffp.read_text(encoding="utf-8")
bar_type_regex = re.compile(r'AddBar\([^,]+,\s*[^,]+,\s*"([a-z_]+)"')
for m in bar_type_regex.finditer(content):
bar_type = m.group(1)
for suffix in ("_bg", "_fg"):
self.deps.append(
(
status_bars_fp,
Path(f"art/textures/ui/session/icons/{bar_type}{suffix}.png"),
)
)
def _add_stance_icon_deps(self):
"""Track session/icons/stances/{stance}.png for selectable stances in UnitAI.js.
selection_panels.js constructs stance icon paths as
"stretched:session/icons/stances/" + stance + ".png" where the stance keys
come from the g_Stances object in UnitAI.js. Only stances with "selectable": true
appear in the UI and therefore need an icon texture.
"""
unit_ai_fp = Path("simulation/components/UnitAI.js")
for mod in self.mods:
ffp = self.vfs_root / mod / unit_ai_fp
if not ffp.exists():
continue
content = ffp.read_text(encoding="utf-8")
g_stances_match = re.search(r"var g_Stances\s*=\s*\{(.*?)^};", content, re.DOTALL | re.MULTILINE)
if not g_stances_match:
continue
stances_block = g_stances_match.group(1)
# Extract each top-level stance entry (name followed by a brace-delimited block).
for stance_match in re.finditer(r'^\t"([a-z_-]+)"\s*:\s*\{([^}]*)\}', stances_block, re.MULTILINE | re.DOTALL):
stance = stance_match.group(1)
stance_body = stance_match.group(2)
if re.search(r'"selectable"\s*:\s*true', stance_body):
self.deps.append(
(unit_ai_fp, Path(f"art/textures/ui/session/icons/stances/{stance}.png"))
)
def add_cpp_hardcoded_assets(self):
"""Mark VFS assets that are loaded directly by C++ engine code as known-used.
@@ -964,6 +1086,9 @@ class CheckRefs:
def add_techs(self):
self.logger.info("Loading techs...")
# phase_village.json, phase_town.json, phase_city.json etc. correspond to
# panel_phase_emblems_{name}.png used in selection_details.js.
phase_tech_regex = re.compile(r"^phase_([a-z]+)$")
for fp, ffp in self.find_files("simulation/data/technologies", "json"):
self.files.append(fp)
with open(ffp, encoding="utf-8") as f:
@@ -982,6 +1107,12 @@ class CheckRefs:
self.deps.append((fp, Path(f"simulation/data/technologies/{tech['top']}.json")))
if tech.get("bottom"):
self.deps.append((fp, Path(f"simulation/data/technologies/{tech['bottom']}.json")))
phase_match = phase_tech_regex.match(fp.stem)
if phase_match:
phase_name = phase_match.group(1)
self.deps.append(
(fp, Path(f"art/textures/ui/session/panel_phase_emblems_{phase_name}.png"))
)
def add_terrains(self):
self.logger.info("Loading terrains...")
@@ -1044,6 +1175,32 @@ class CheckRefs:
if range_overlay.get(prop):
self.deps.append((fp, Path(f"art/textures/selection/{range_overlay[prop]}")))
def add_resources(self):
self.logger.info("Loading resources...")
for fp, ffp in self.find_files("simulation/data/resources", "json"):
self.files.append(fp)
self.roots.append(fp)
with open(ffp, encoding="utf-8") as f:
resource = load(f)
code = resource.get("code")
if code:
self.deps.append(
(fp, Path(f"art/textures/ui/session/icons/resources/{code}.png"))
)
def add_status_effects(self):
self.logger.info("Loading status effects...")
for fp, ffp in self.find_files("simulation/data/status_effects", "json"):
self.files.append(fp)
self.roots.append(fp)
with open(ffp, encoding="utf-8") as f:
effect = load(f)
icon = effect.get("icon")
if icon:
self.deps.append(
(fp, Path(f"art/textures/ui/session/icons/status_effects/{icon}.png"))
)
def check_deps(self):
self.logger.info("Looking for missing files...")
uniq_files = {r.as_posix() for r in self.files}