Skip to content

Commit 64ba4d9

Browse files
committed
Move unify_fgd 'count' functionality to its own module, break into functions
1 parent aec3846 commit 64ba4d9

File tree

3 files changed

+387
-293
lines changed

3 files changed

+387
-293
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,4 @@ examples/hammeraddons.vdf
7979
# Random extra things
8080
output.fgd
8181
build_for_testing.bat
82+
reports/

src/hammeraddons/fgd_reports.py

Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
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

Comments
 (0)