|
| 1 | +"""Defines various reports which analyse the database, identifying possible improvements.""" |
| 2 | +from collections import Counter, defaultdict |
| 3 | +from typing import TextIO |
| 4 | + |
| 5 | +from pathlib import Path |
| 6 | +from pprint import pprint, pformat |
| 7 | + |
| 8 | +from collections.abc import MutableMapping |
| 9 | + |
| 10 | +from srctools import FGD |
| 11 | +from srctools.fgd import ( |
| 12 | + EntityTypes, ValueTypes, EntityDef, ResourceCtx, |
| 13 | + TagsSet, match_tags, HelperModel, HelperSprite, |
| 14 | +) |
| 15 | + |
| 16 | +from .unify_fgd import ( |
| 17 | + ALL_GAMES, ALL_MODS, SNIPPET_USED, GAME_ORDER, expand_tags, get_appliesto, |
| 18 | + UNIQUE_HELPERS, |
| 19 | +) |
| 20 | + |
| 21 | + |
| 22 | +def report_counts(fgd: FGD, report_dir: Path) -> MutableMapping[str, set[str]]: |
| 23 | + """Count how many of each entity type exist. |
| 24 | +
|
| 25 | + This also returns a mapping from games to the entities contained within. |
| 26 | + """ |
| 27 | + count_base: dict[str, int] = Counter() |
| 28 | + count_point: dict[str, int] = Counter() |
| 29 | + count_brush: dict[str, int] = Counter() |
| 30 | + |
| 31 | + all_tags = { |
| 32 | + tag.lstrip('+-!').upper() |
| 33 | + for ent in fgd |
| 34 | + for tag in get_appliesto(ent) |
| 35 | + } |
| 36 | + |
| 37 | + games = (ALL_GAMES | ALL_MODS) & all_tags |
| 38 | + |
| 39 | + print(f'Defined games: {pformat(games, compact=True)}') |
| 40 | + |
| 41 | + expanded: dict[str, TagsSet] = { |
| 42 | + # Opt into complete list, since we're checking against engine dumps. |
| 43 | + game: expand_tags(frozenset({game, 'COMPLETE'})) |
| 44 | + for game in ALL_GAMES | ALL_MODS |
| 45 | + } |
| 46 | + expanded['ALL'] = frozenset() |
| 47 | + |
| 48 | + game_classes: MutableMapping[tuple[str, str], set[str]] = defaultdict(set) |
| 49 | + base_uses: MutableMapping[str, set[str]] = defaultdict(set) |
| 50 | + all_ents: MutableMapping[str, set[str]] = defaultdict(set) |
| 51 | + |
| 52 | + kv_counts: dict[tuple, list[tuple]] = defaultdict(list) |
| 53 | + inp_counts: dict[tuple, list[tuple]] = defaultdict(list) |
| 54 | + out_counts: dict[tuple, list[tuple]] = defaultdict(list) |
| 55 | + desc_counts: dict[tuple, list[tuple]] = defaultdict(list) |
| 56 | + val_list_counts: dict[tuple, list[tuple]] = defaultdict(list) |
| 57 | + |
| 58 | + for ent in fgd: |
| 59 | + if ent.type is EntityTypes.BASE: |
| 60 | + counter = count_base |
| 61 | + typ = 'Base' |
| 62 | + # Ensure it's present, so we detect 0-use bases. |
| 63 | + base_uses[ent.classname] # noqa |
| 64 | + elif ent.type is EntityTypes.BRUSH: |
| 65 | + counter = count_brush |
| 66 | + typ = 'Brush' |
| 67 | + else: |
| 68 | + counter = count_point |
| 69 | + typ = 'Point' |
| 70 | + appliesto = get_appliesto(ent) |
| 71 | + |
| 72 | + has_ent = set() |
| 73 | + |
| 74 | + for base in ent.bases: |
| 75 | + assert isinstance(base, EntityDef), (ent, ent.bases) |
| 76 | + base_uses[base.classname].add(ent.classname) |
| 77 | + |
| 78 | + for game, tags in expanded.items(): |
| 79 | + if match_tags(tags, appliesto): |
| 80 | + counter[game] += 1 |
| 81 | + game_classes[game, typ].add(ent.classname) |
| 82 | + has_ent.add(game) |
| 83 | + # Allow explicitly saying certain ents aren't in the actual game |
| 84 | + # with the "engine" tag, or only adding them to this + the binary dump. |
| 85 | + if ent.type is not EntityTypes.BASE and match_tags(tags | {'ENGINE'}, appliesto): |
| 86 | + all_ents[game].add(ent.classname.casefold()) |
| 87 | + |
| 88 | + has_ent.discard('ALL') |
| 89 | + |
| 90 | + if has_ent == games: |
| 91 | + # Applies to all, strip. |
| 92 | + game_classes['ALL', typ].add(ent.classname) |
| 93 | + counter['ALL'] += 1 |
| 94 | + if appliesto: |
| 95 | + print('ALL game: ', ent.classname) |
| 96 | + for game in games: |
| 97 | + counter[game] -= 1 |
| 98 | + game_classes[game, typ].discard(ent.classname) |
| 99 | + |
| 100 | + if ent.classname in SNIPPET_USED: |
| 101 | + # This entity does use snippets already, don't count it. |
| 102 | + continue |
| 103 | + |
| 104 | + for name, kv_map in ent.keyvalues.items(): |
| 105 | + for tags, kv in kv_map.items(): |
| 106 | + if 'ENGINE' in tags or '+ENGINE' in tags or kv.type is ValueTypes.SPAWNFLAGS: |
| 107 | + continue |
| 108 | + if kv.desc: # Blank is not a duplicate! |
| 109 | + desc_counts[kv.desc, ].append((ent.classname, name)) |
| 110 | + kv_counts[ |
| 111 | + kv.name, kv.type, (tuple(kv.val_list) if kv.val_list is not None else ()), kv.desc, kv.default, |
| 112 | + ].append((ent.classname, name, kv.desc)) |
| 113 | + if kv.val_list is not None: |
| 114 | + val_list_counts[tuple(kv.val_list)].append((ent.classname, name)) |
| 115 | + for name, io_map in ent.inputs.items(): |
| 116 | + for tags, io in io_map.items(): |
| 117 | + if 'ENGINE' in tags or '+ENGINE' in tags: |
| 118 | + continue |
| 119 | + inp_counts[io.name, io.type, io.desc].append((ent.classname, name, io.desc)) |
| 120 | + for name, io_map in ent.outputs.items(): |
| 121 | + for tags, io in io_map.items(): |
| 122 | + if 'ENGINE' in tags or '+ENGINE' in tags: |
| 123 | + continue |
| 124 | + out_counts[io.name, io.type, io.desc].append((ent.classname, name, io.desc)) |
| 125 | + |
| 126 | + all_games: set[str] = {*count_base, *count_point, *count_brush} |
| 127 | + |
| 128 | + def ordering(game: str) -> tuple: |
| 129 | + """Put ALL at the start, mods at the end.""" |
| 130 | + if game == 'ALL': |
| 131 | + return (0, 0) |
| 132 | + try: |
| 133 | + return (1, GAME_ORDER.index(game)) |
| 134 | + except ValueError: |
| 135 | + return (2, game) # Mods |
| 136 | + |
| 137 | + game_order = sorted(all_games, key=ordering) |
| 138 | + |
| 139 | + row_temp = '{:^9} | {:^6} | {:^6} | {:^6}\n' |
| 140 | + header = row_temp.format('Game', 'Base', 'Point', 'Brush') |
| 141 | + print('Counted entities.') |
| 142 | + |
| 143 | + with open(report_dir / 'counts.txt', 'w') as f: |
| 144 | + f.write(header) |
| 145 | + print('-' * len(header), file=f) |
| 146 | + |
| 147 | + for game in game_order: |
| 148 | + f.write(row_temp.format( |
| 149 | + game, |
| 150 | + count_base[game], |
| 151 | + count_point[game], |
| 152 | + count_brush[game], |
| 153 | + )) |
| 154 | + |
| 155 | + f.write('\n\nBases:\n') |
| 156 | + for base, count in sorted(base_uses.items(), key=lambda x: (len(x[1]), x[0])): |
| 157 | + ent = fgd[base] |
| 158 | + if ent.type is EntityTypes.BASE and ( |
| 159 | + ent.keyvalues or ent.outputs or ent.inputs |
| 160 | + ): |
| 161 | + f.write(f'{base} {len(count)} {count if len(count) == 1 else '...'}\n') |
| 162 | + |
| 163 | + for kind_name, count_map in ( |
| 164 | + ('keyvalues', kv_counts), |
| 165 | + ('inputs', inp_counts), |
| 166 | + ('outputs', out_counts), |
| 167 | + ('val list', val_list_counts), |
| 168 | + ('desc', desc_counts) |
| 169 | + ): |
| 170 | + count = 0 |
| 171 | + with open(report_dir / f'duplicate_{kind_name}.txt', 'w') as f: |
| 172 | + for key, info in sorted(count_map.items(), key=lambda v: len(v[1]), reverse=True): |
| 173 | + if len(info) <= 2: |
| 174 | + continue |
| 175 | + f.write(f'{len(info):02}: {key[:64]!r} -> {info}\n') |
| 176 | + count += 1 |
| 177 | + print(f'{count} duplicate {kind_name}.') |
| 178 | + |
| 179 | + return all_ents |
| 180 | + |
| 181 | + |
| 182 | +def report_factories( |
| 183 | + fgd: FGD, |
| 184 | + all_ents: MutableMapping[str, set[str]], |
| 185 | + *, |
| 186 | + report_dir: Path, factories_folder: Path, |
| 187 | +) -> None: |
| 188 | + """Use a dump of entity factories from games to check for missing/extra ents.""" |
| 189 | + all_classes = set() |
| 190 | + used_classes = set() |
| 191 | + for dump_path in factories_folder.glob('*.txt'): |
| 192 | + dump_classes = set() |
| 193 | + with dump_path.open() as f: |
| 194 | + for line in f: |
| 195 | + line = line.casefold().strip() |
| 196 | + if line.isspace(): |
| 197 | + continue |
| 198 | + # Strata's output has lines like 'hl2:weapon_crowbar'. We don't care right now. |
| 199 | + if ':' in line: |
| 200 | + line = line.split(':', 1)[1] |
| 201 | + dump_classes.add(line) |
| 202 | + game = dump_path.stem.upper() |
| 203 | + tags = frozenset(game.split('_')) |
| 204 | + |
| 205 | + defined_classes = { |
| 206 | + cls |
| 207 | + for tag in tags |
| 208 | + for cls in all_ents.get(tag, ()) |
| 209 | + if not cls.startswith('comp_') |
| 210 | + } |
| 211 | + if not defined_classes: |
| 212 | + print(f'No dump for tags "{game}"!') |
| 213 | + continue |
| 214 | + |
| 215 | + extra = defined_classes - dump_classes |
| 216 | + missing = dump_classes - defined_classes |
| 217 | + all_classes |= defined_classes |
| 218 | + used_classes |= dump_classes |
| 219 | + with open(report_dir / f'factories_{game.lower()}.txt', 'w') as rep_f: |
| 220 | + rep_f.write('Extraneous definitions: \n') |
| 221 | + pprint(sorted(extra), rep_f, compact=True) |
| 222 | + rep_f.write('\n\nMissing definitions: \n') |
| 223 | + pprint(sorted(missing), rep_f, compact=True) |
| 224 | + |
| 225 | + unused = all_classes - used_classes |
| 226 | + with open(report_dir / f'factories_unused.txt', 'w') as rep_f: |
| 227 | + pprint(sorted(unused), rep_f, compact=True) |
| 228 | + print(f'Checked entity factories. {len(unused)} totally unused.') |
| 229 | + |
| 230 | + |
| 231 | +def report_undefined_resources(fgd: FGD, report_dir: Path) -> None: |
| 232 | + """Identify entities without class resources defined.""" |
| 233 | + missing_count = defined_count = empty_count = 0 |
| 234 | + not_in_engine = {'-ENGINE', '!ENGINE', 'SRCTOOLS', '+SRCTOOLS'} |
| 235 | + class_res = defaultdict(list) |
| 236 | + for clsname in sorted(fgd.entities): |
| 237 | + ent = fgd.entities[clsname] |
| 238 | + if ent.type is EntityTypes.BASE or ent.is_alias: |
| 239 | + continue |
| 240 | + appliesto = get_appliesto(ent) |
| 241 | + |
| 242 | + if not not_in_engine.isdisjoint(appliesto): |
| 243 | + continue |
| 244 | + if ent.resources_defined(): |
| 245 | + defined_count += 1 |
| 246 | + if len(ent.resources) == 0: |
| 247 | + empty_count += 1 |
| 248 | + else: |
| 249 | + class_res[frozenset(appliesto)].append(ent.classname) |
| 250 | + missing_count += 1 |
| 251 | + |
| 252 | + with open(report_dir / 'undefined_resources.txt', 'w') as f: |
| 253 | + for tags_list, classnames in class_res.items(): |
| 254 | + classnames.sort() |
| 255 | + f.write(f'{', '.join(tags_list)} = {pformat(classnames, compact=True)}\n') |
| 256 | + summary = ( |
| 257 | + f'\nMissing: {missing_count}, ' |
| 258 | + f'Defined: {defined_count} = {defined_count/(missing_count + defined_count):.2%}, empty={empty_count}' |
| 259 | + ) |
| 260 | + print(summary) |
| 261 | + f.write(summary + '\n') |
| 262 | + |
| 263 | + |
| 264 | +def report_missing_resources(fgd: FGD, report_dir: Path) -> None: |
| 265 | + """Report resource references which don't resolve.""" |
| 266 | + count = 0 |
| 267 | + |
| 268 | + def report(msg: str) -> None: |
| 269 | + """Report errors in resources.""" |
| 270 | + nonlocal count |
| 271 | + count += 1 |
| 272 | + f.write(f'Ent {ent.classname} res error: {msg}\n') |
| 273 | + res_ctx = ResourceCtx(fgd=fgd) |
| 274 | + |
| 275 | + with open(report_dir / 'missing_resources.txt', 'w') as f: |
| 276 | + for ent in fgd.entities.values(): |
| 277 | + # Get them all, checking validity in the process. |
| 278 | + for _ in ent.get_resources(res_ctx, ent=None, on_error=report): |
| 279 | + pass |
| 280 | + |
| 281 | + print(f'Found {count} missing resources.') |
| 282 | + |
| 283 | + |
| 284 | +def check_ent_sprites(used: dict[str, list[str]], f: TextIO, ent: EntityDef) -> None: |
| 285 | + """Check if the specified entity has a unique sprite.""" |
| 286 | + mdl: str | None = None |
| 287 | + sprite: str | None = None |
| 288 | + for helper in ent.helpers: |
| 289 | + if type(helper) in UNIQUE_HELPERS: |
| 290 | + return # Specialised helper is sufficient. |
| 291 | + if isinstance(helper, HelperModel): |
| 292 | + if helper.model is None and 'model' in ent.kv: |
| 293 | + return # Model is customisable. |
| 294 | + mdl = helper.model |
| 295 | + if isinstance(helper, HelperSprite): |
| 296 | + if helper.mat is None: |
| 297 | + f.write(f'{ent.classname}: {helper!r}???\n') |
| 298 | + sprite = helper.mat |
| 299 | + # If both model and sprite, allow model to be duplicate. |
| 300 | + if mdl and sprite: |
| 301 | + display = sprite |
| 302 | + elif mdl: |
| 303 | + display = mdl |
| 304 | + elif sprite: |
| 305 | + display = sprite |
| 306 | + else: |
| 307 | + tags = get_appliesto(ent) |
| 308 | + if 'ENGINE' not in tags and '+ENGINE' not in tags: |
| 309 | + f.write(f'{ent.classname}: No sprite/model? {pformat(ent.helpers)}\n') |
| 310 | + return |
| 311 | + used[display].append(ent.classname) |
| 312 | + |
| 313 | + |
| 314 | +def report_helper_reuse(fgd: FGD, report_dir: Path) -> None: |
| 315 | + """Report missing or reused helpers.""" |
| 316 | + mdl_or_sprites: dict[str, list[str]] = defaultdict(list) |
| 317 | + with open(report_dir / 'helper_reuse.txt', 'w') as f: |
| 318 | + for ent in fgd: |
| 319 | + if ent.type is not EntityTypes.BASE and ent.type is not EntityTypes.BRUSH and not ent.is_alias: |
| 320 | + check_ent_sprites(mdl_or_sprites, f, ent) |
| 321 | + for resource, classes in mdl_or_sprites.items(): |
| 322 | + if len(classes) > 1: |
| 323 | + classes.sort() |
| 324 | + f.write(f'Reused {resource}: {classes}\n') |
| 325 | + print('Checked helper reuse.') |
| 326 | + |
| 327 | + |
| 328 | +def report_repeated_bases(fgd: FGD, report_dir: Path) -> None: |
| 329 | + """Report entities which include the same base multiple times in their hierachy.""" |
| 330 | + |
| 331 | + def check_parents(done: set[EntityDef], repeat: set[EntityDef], ent: EntityDef) -> None: |
| 332 | + """Recursively check a hierachy.""" |
| 333 | + if ent in done: |
| 334 | + repeat.add(ent) |
| 335 | + else: |
| 336 | + done.add(ent) |
| 337 | + for base in ent.bases: |
| 338 | + assert isinstance(base, EntityDef), (ent, ent.bases) |
| 339 | + check_parents(done, repeat, base) |
| 340 | + |
| 341 | + with open(report_dir / 'repeated_bases.txt', 'w') as f: |
| 342 | + for ent in fgd: |
| 343 | + done: set[EntityDef] = set() |
| 344 | + repeat: set[EntityDef] = set() |
| 345 | + check_parents(done, repeat, ent) |
| 346 | + if repeat: |
| 347 | + print( |
| 348 | + f'Repeated bases: {ent.classname} = ' |
| 349 | + f'{[ent.classname for ent in repeat]}, ' |
| 350 | + f'all={[ent.classname for ent in done]}' |
| 351 | + ) |
| 352 | + print('Checked repeated bases.') |
0 commit comments