Index: source/tools/entity/.flake8 =================================================================== --- /dev/null +++ source/tools/entity/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 150 Index: source/tools/entity/checkrefs.pl =================================================================== --- source/tools/entity/checkrefs.pl +++ /dev/null @@ -1,704 +0,0 @@ -use strict; -use warnings; -use Data::Dumper; -use File::Find; -use XML::Simple; -use JSON; -use Getopt::Long qw(GetOptions); - -use lib "."; -use Entity; - -GetOptions ( - '--check-unused' => \(my $checkUnused = 0), - '--check-map-xml' => \(my $checkMapXml = 0), - '--validate-templates' => \(my $validateTemplates = 0), - '--mod-to-check=s' => \(my $modToCheck = "public") -); - -my @files; -my @roots; -my @deps; - -# Force and checkMapXml if checkUnused is enabled to avoid false positives. -$checkMapXml |= $checkUnused; -my $vfsroot = '../../../binaries/data/mods'; -my $supportedTextureFormats = 'dds|png'; -my $mods = get_mod_dependencies_string($modToCheck); -my $mod_list_string = $modToCheck; -if ($mods ne "") -{ - $mod_list_string = $mod_list_string."|$mods"; -} -$mod_list_string = $mod_list_string."|mod"; -print("Checking $modToCheck\'s integrity. \n"); -print("The following mod(s) will be loaded: $mod_list_string. \n"); -my @mods_list = split(/\|/, "$mod_list_string"); - -sub get_mod_dependencies -{ - my ($mod) = @_; - my $modjson = parse_json_file_full_path("$vfsroot/$mod/mod.json"); - my $modjsondeps = $modjson->{'dependencies'}; - for my $dep (@{$modjsondeps}) - { - # 0ad's folder isn't named like the mod. - if(index($dep, "0ad") != -1) - { - $dep = "public"; - } - } - - return $modjsondeps; -} - -sub get_mod_dependencies_string -{ - my ($mod) = @_; - return join( '|',@{get_mod_dependencies($mod)}); -} - -sub vfs_to_physical -{ - my ($vfsPath) = @_; - my $fn = vfs_to_relative_to_mods($vfsPath); - return "$vfsroot/$fn"; -} - -sub vfs_to_relative_to_mods -{ - my ($vfsPath) = @_; - - for my $dep (@mods_list) - { - my $fn = "$dep/$vfsPath"; - - if (-e "$vfsroot/$fn") - { - return $fn; - } - } -} - -sub find_files -{ - my ($vfsPath, $extn) = @_; - my @files; - my $find_process = sub { - return $File::Find::prune = 1 if $_ eq '.svn'; - my $n = $File::Find::name; - return if /~$/; - return unless -f $_; - return unless /\.($extn)$/; - $n =~ s~\Q$vfsroot\E/($mod_list_string)/~~; - push @files, $n; - }; - - for my $dep (@mods_list) - { - find({ wanted => $find_process },"$vfsroot/$dep/$vfsPath") if -d "$vfsroot/$dep/$vfsPath"; - } - - return @files; -} - -sub parse_json_file_full_path -{ - my ($vfspath) = @_; - open my $fh, $vfspath or die "Failed to open '$vfspath': $!"; - # decode_json expects a UTF-8 string and doesn't handle BOMs, so we strip those - # (see http://trac.wildfiregames.com/ticket/1556) - return decode_json(do { local $/; my $file = <$fh>; $file =~ s/^\xEF\xBB\xBF//; $file }); -} - -sub parse_json_file -{ - my ($vfspath) = @_; - return parse_json_file_full_path(vfs_to_physical($vfspath)) -} - -sub add_entities -{ - print "Loading entities...\n"; - - my @entfiles = find_files('simulation/templates', 'xml'); - s~^simulation/templates/(.*)\.xml$~$1~ for @entfiles; - - for my $f (sort @entfiles) - { - my $path = "simulation/templates/$f.xml"; - push @files, $path; - my $ent = Entity::load_inherited($f, "$mod_list_string"); - - push @deps, [ $path, "simulation/templates/" . $ent->{Entity}{'@parent'}{' content'} . ".xml" ] if $ent->{Entity}{'@parent'}; - - if ($f !~ /^template_/) - { - push @roots, $path; - if ($ent->{Entity}{VisualActor} and $ent->{Entity}{VisualActor}{Actor}) - { - my $phenotypes = $ent->{Entity}{Identity}{Phenotype}{' content'} || "default"; - my @phenotypes = split /\s/,$phenotypes; - - for my $phenotype (@phenotypes) - { - # See simulation2/components/CCmpVisualActor.cpp and Identity.js for explanation. - my $actorPath = $ent->{Entity}{VisualActor}{Actor}{' content'}; - $actorPath =~ s/{phenotype}/$phenotype/g; - push @deps, [ $path, "art/actors/" . $actorPath ]; - } - - push @deps, [ $path, "art/actors/" . $ent->{Entity}{VisualActor}{FoundationActor}{' content'} ] if $ent->{Entity}{VisualActor}{FoundationActor}; - } - - if ($ent->{Entity}{Sound}) - { - my $phenotypes = $ent->{Entity}{Identity}{Phenotype}{' content'} || "default"; - my $lang = $ent->{Entity}{Identity}{Lang}{' content'} || "greek"; - - my @phenotypes = split /\s/,$phenotypes; - - for my $phenotype (@phenotypes) - { - for (grep ref($_), values %{$ent->{Entity}{Sound}{SoundGroups}}) - { - # see simulation/components/Sound.js and Identity.js for explanation - my $soundPath = $_->{' content'}; - $soundPath =~ s/{phenotype}/$phenotype/g; - $soundPath =~ s/{lang}/$lang/g; - push @deps, [ $path, "audio/" . $soundPath ]; - } - } - } - - if ($ent->{Entity}{Identity}) - { - push @deps, [ $path, "art/textures/ui/session/portraits/" . $ent->{Entity}{Identity}{Icon}{' content'} ] if $ent->{Entity}{Identity}{Icon} and $ent->{Entity}{Identity}{Icon}{' content'} ne ''; - } - - if ($ent->{Entity}{Heal} and $ent->{Entity}{Heal}{RangeOverlay}) - { - push @deps, [ $path, "art/textures/selection/" . $ent->{Entity}{Heal}{RangeOverlay}{LineTexture}{' content'} ] if $ent->{Entity}{Heal}{RangeOverlay}{LineTexture} and $ent->{Entity}{Heal}{RangeOverlay}{LineTexture}{' content'} ne ''; - push @deps, [ $path, "art/textures/selection/" . $ent->{Entity}{Heal}{RangeOverlay}{LineTextureMask}{' content'} ] if $ent->{Entity}{Heal}{RangeOverlay}{LineTextureMask} and $ent->{Entity}{Heal}{RangeOverlay}{LineTextureMask}{' content'} ne ''; - } - - if ($ent->{Entity}{Selectable} and $ent->{Entity}{Selectable}{Overlay} and $ent->{Entity}{Selectable}{Overlay}{Texture}) - { - push @deps, [ $path, "art/textures/selection/" . $ent->{Entity}{Selectable}{Overlay}{Texture}{MainTexture}{' content'} ] if $ent->{Entity}{Selectable}{Overlay}{Texture}{MainTexture} and $ent->{Entity}{Selectable}{Overlay}{Texture}{MainTexture}{' content'} ne ''; - push @deps, [ $path, "art/textures/selection/" . $ent->{Entity}{Selectable}{Overlay}{Texture}{MainTextureMask}{' content'} ] if $ent->{Entity}{Selectable}{Overlay}{Texture}{MainTextureMask} and $ent->{Entity}{Selectable}{Overlay}{Texture}{MainTextureMask}{' content'} ne ''; - } - - if ($ent->{Entity}{Formation}) - { - push @deps, [ $path, "art/textures/ui/session/icons/" . $ent->{Entity}{Formation}{Icon}{' content'} ] if $ent->{Entity}{Formation}{Icon} and $ent->{Entity}{Formation}{Icon}{' content'} ne ''; - } - } - } -} - -sub push_variant_dependencies -{ - my ($variant, $f) = @_; - push @deps, [ $f, "art/variants/$variant->{file}" ] if $variant->{file}; - push @deps, [ $f, "art/meshes/$variant->{mesh}" ] if $variant->{mesh}; - push @deps, [ $f, "art/particles/$variant->{particles}{file}" ] if $variant->{particles}{file}; - for my $tex (@{$variant->{textures}{texture}}) - { - push @deps, [ $f, "art/textures/skins/$tex->{file}" ] if $tex->{file}; - } - for my $prop (@{$variant->{props}{prop}}) - { - push @deps, [ $f, "art/actors/$prop->{actor}" ] if $prop->{actor}; - } - for my $anim (@{$variant->{animations}{animation}}) - { - push @deps, [ $f, "art/animation/$anim->{file}" ] if $anim->{file}; - } -} - -sub add_actors -{ - print "Loading actors...\n"; - - my @actorfiles = find_files('art/actors', 'xml'); - for my $f (sort @actorfiles) - { - push @files, $f; - push @roots, $f; - - my $actor = XMLin(vfs_to_physical($f), ForceArray => [qw(group variant texture prop animation)], KeyAttr => []) or die "Failed to parse '$f': $!"; - - for my $group (@{$actor->{group}}) - { - for my $variant (@{$group->{variant}}) - { - push_variant_dependencies($variant, $f); - } - } - - push @deps, [ $f, "art/materials/$actor->{material}" ] if $actor->{material}; - } -} - - -sub add_variants -{ - print "Loading variants...\n"; - my @variantfiles = find_files('art/variants', 'xml'); - - for my $f (sort @variantfiles) - { - push @files, $f; - push @roots, $f; - my $variant = XMLin(vfs_to_physical($f), ForceArray => [qw(texture prop animation)], KeyAttr => []) or die "Failed to parse '$f': $!"; - push_variant_dependencies($variant, $f); - } -} - -sub add_art -{ - print "Loading art files...\n"; - push @files, find_files('art/textures/particles', $supportedTextureFormats); - push @files, find_files('art/textures/terrain', $supportedTextureFormats); - push @files, find_files('art/textures/skins', $supportedTextureFormats); - push @files, find_files('art/meshes', 'pmd|dae'); - push @files, find_files('art/animation', 'psa|dae'); -} - -sub add_materials -{ - print "Loading materials...\n"; - my @materialfiles = find_files('art/materials', 'xml'); - for my $f (sort @materialfiles) - { - push @files, $f; - - my $material = XMLin(vfs_to_physical($f), ForceArray => [qw(alternative)], KeyAttr => []); - for my $alternative (@{$material->{alternative}}) - { - push @deps, [ $f, "art/materials/$alternative->{material}" ] if $alternative->{material}; - } - } -} - -sub add_particles -{ - print "Loading particles...\n"; - my @particlefiles = find_files('art/particles', 'xml'); - for my $f (sort @particlefiles) - { - push @files, $f; - - my $particle = XMLin(vfs_to_physical($f)); - push @deps, [ $f, "$particle->{texture}" ] if $particle->{texture}; - } -} - -sub add_maps_xml -{ - print "Loading maps XML...\n"; - my @mapfiles = find_files('maps/scenarios', 'xml'); - push @mapfiles, find_files('maps/skirmishes', 'xml'); - push @mapfiles, find_files('maps/tutorials', 'xml'); - for my $f (sort @mapfiles) - { - push @files, $f; - push @roots, $f; - - my $map = XMLin(vfs_to_physical($f), ForceArray => [qw(Entity)], KeyAttr => []) or die "Failed to parse '$f': $!"; - - my %used; - for my $entity (@{$map->{Entities}{Entity}}) - { - $used{$entity->{Template}} = 1; - } - - for my $template (keys %used) - { - if ($template =~ /^actor\|(.*)$/) - { - # Handle special 'actor|' case - push @deps, [ $f, "art/actors/$1" ]; - } - else - { - if ($template =~ /^resource\|(.*)$/) - { - # Handle special 'resource|' case - $template = $1; - } - push @deps, [ $f, "simulation/templates/$template.xml" ]; - } - } - - # Map previews - my $settings = decode_json($map->{ScriptSettings}); - push @deps, [ $f, "art/textures/ui/session/icons/mappreview/" . $settings->{Preview} ] if $settings->{Preview}; - } -} - -sub add_maps_pmp -{ - print "Loading maps PMP...\n"; - - # Need to generate terrain texture filename=>path lookup first - my %terrains; - for my $f (find_files('art/terrains', 'xml')) - { - $f =~ /([^\/]+)\.xml/ or die; - - # ignore terrains.xml - if ($f !~ /terrains.xml$/) - { - warn "Duplicate terrain name '$1' (from '$terrains{$1}' and '$f')\n" if $terrains{$1}; - $terrains{$1} = $f; - } - } - - my @mapfiles = find_files('maps/scenarios', 'pmp'); - push @mapfiles, find_files('maps/skirmishes', 'pmp'); - for my $f (sort @mapfiles) - { - push @files, $f; - - push @roots, $f; - - open my $fh, vfs_to_physical($f) or die "Failed to open '$f': $!"; - binmode $fh; - - my $buf; - - read $fh, $buf, 4; - die "Invalid PMP header ($buf) in '$f'" unless $buf eq "PSMP"; - - read $fh, $buf, 4; - my $version = unpack 'V', $buf; - die "Invalid PMP version ($version) in '$f'" unless $version == 6; - - read $fh, $buf, 4; - my $datasize = unpack 'V', $buf; - - read $fh, $buf, 4; - my $mapsize = unpack 'V', $buf; - - seek $fh, 2 * ($mapsize*16+1)*($mapsize*16+1), 1; # heightmap - - read $fh, $buf, 4; - my $numtexs = unpack 'V', $buf; - - for (0..$numtexs-1) - { - read $fh, $buf, 4; - my $len = unpack 'V', $buf; - my $str; - read $fh, $str, $len; - - push @deps, [ $f, $terrains{$str} || "art/terrains/(unknown)/$str" ]; - } - - # ignore patches data - } -} - -sub add_soundgroups -{ - print "Loading sound groups...\n"; - my @soundfiles = find_files('audio', 'xml'); - for my $f (sort @soundfiles) - { - push @files, $f; - push @roots, $f; - - my $sound = XMLin(vfs_to_physical($f), ForceArray => [qw(Sound)], KeyAttr => []) or die "Failed to parse '$f': $!"; - - my $path = $sound->{Path}; - $path =~ s/\/$//; # strip optional trailing slash - - for (@{$sound->{Sound}}) - { - push @deps, [$f, "$path/$_" ]; - } - } -} - -sub add_audio -{ - print "Loading audio files...\n"; - push @files, find_files('audio', 'ogg'); -} - -sub add_gui_xml -{ - print "Loading GUI XML...\n"; - my @guifiles = find_files('gui', 'xml'); - for my $f (sort @guifiles) - { - push @files, $f; - - if ($f =~ /^gui\/page_/) - { - push @roots, $f; - my $xml = XMLin(vfs_to_physical($f), ForceArray => [qw(include)], KeyAttr => []) or die "Failed to parse '$f': $!"; - - for my $include (@{$xml->{include}}) - { - # If including an entire directory, find all the *.xml files - if ($include =~ /\/$/) - { - push @deps, [ $f, $_ ] for find_files("gui/$include", 'xml'); - } - else - { - push @deps, [ $f, "gui/$include" ]; - } - } - } - else - { - my $xml = XMLin(vfs_to_physical($f), ForceArray => [qw(object script action sprite image)], KeyAttr => [], KeepRoot => 1) or die "Failed to parse '$f': $!"; - my $name = (keys %$xml)[0]; - if ($name eq 'objects' or $name eq 'object') - { - for (grep ref $_ , @{$xml->{objects}{script}}) - { - push @deps, [ $f, $_->{file} ] if $_->{file}; - if ($_->{directory}) - { - # If including an entire directory, find all the *.js files - push @deps, [ $f, $_ ] for find_files($_->{directory}, 'js') - } - } - my $add_objects; - $add_objects = sub - { - my ($parent) = @_; - for my $obj (@{$parent->{object}}) - { - # TODO: look at sprites, styles, etc - $add_objects->($obj); - } - }; - $add_objects->($xml->{objects}); - } - elsif ($name eq 'setup') - { - # TODO: look at sprites, styles, etc - } - elsif ($name eq 'styles') - { - # TODO: look at sprites, styles, etc - } - elsif ($name eq 'sprites') - { - for my $sprite (@{$xml->{sprites}{sprite}}) - { - for my $image (@{$sprite->{image}}) - { - push @deps, [ $f, "art/textures/ui/$image->{texture}" ] if $image->{texture}; - } - } - } - else - { - print "Unexpected GUI XML root element '$name':\n" . Dumper $xml; - exit; - } - } - } -} - -sub add_gui_data -{ - print "Loading GUI data...\n"; - push @files, find_files('gui', 'js'); - push @files, find_files('art/textures/ui', $supportedTextureFormats); - push @files, find_files('art/textures/selection', $supportedTextureFormats); -} - -sub add_civs -{ - print "Loading civs...\n"; - - my @civfiles = find_files('simulation/data/civs', 'json'); - for my $f (sort @civfiles) - { - push @files, $f; - - push @roots, $f; - - my $civ = parse_json_file($f); - - push @deps, [ $f, "art/textures/ui/" . $civ->{Emblem} ] if $civ->{Emblem}; - - push @deps, [ $f, "audio/music/" . $_->{File} ] for @{$civ->{Music}}; - } -} - -sub add_rms -{ - print "Loading random maps...\n"; - - push @files, find_files('maps/random', 'js'); - my @rmsdefs = find_files('maps/random', 'json'); - - for my $f (sort @rmsdefs) - { - next if $f =~ /^maps\/random\/rmbiome/; - - push @files, $f; - - push @roots, $f; - - my $rms = parse_json_file($f); - push @deps, [ $f, "maps/random/" . $rms->{settings}{Script} ] if $rms->{settings}{Script}; - - # Map previews - push @deps, [ $f, "art/textures/ui/session/icons/mappreview/" . $rms->{settings}{Preview} ] if $rms->{settings}{Preview}; - } -} - -sub add_techs -{ - print "Loading techs...\n"; - - my @techfiles = find_files('simulation/data/technologies', 'json'); - for my $f (sort @techfiles) - { - push @files, $f; - push @roots, $f; - - my $tech = parse_json_file($f); - - push @deps, [ $f, "art/textures/ui/session/portraits/technologies/" . $tech->{icon} ] if $tech->{icon}; - push @deps, [ $f, "simulation/data/technologies/" . $tech->{supersedes} . ".json" ] if $tech->{supersedes}; - } -} - -sub add_auras -{ - print "Loading auras...\n"; - - my @aurafiles = find_files('simulation/data/auras', 'json'); - for my $f (sort @aurafiles) - { - push @files, $f; - push @roots, $f; - - my $aura = parse_json_file($f); - - push @deps, [ $f, $aura->{overlayIcon} ] if $aura->{overlayIcon}; - - if($aura->{rangeOverlay}) - { - push @deps, [ $f, "art/textures/selection/" . $aura->{rangeOverlay}{lineTexture} ] if $aura->{rangeOverlay}{lineTexture}; - push @deps, [ $f, "art/textures/selection/" . $aura->{rangeOverlay}{lineTextureMask} ] if $aura->{rangeOverlay}{lineTextureMask}; - } - } -} - -sub add_terrains -{ - print "Loading terrains...\n"; - - my @terrains = find_files('art/terrains', 'xml'); - for my $f (sort @terrains) - { - # ignore terrains.xml - if ($f !~ /terrains.xml$/) - { - push @files, $f; - push @roots, $f; - - my $terrain = XMLin(vfs_to_physical($f), ForceArray => [qw(texture)], KeyAttr => []) or die "Failed to parse '$f': $!"; - - for my $texture (@{$terrain->{textures}{texture}}) - { - push @deps, [ $f, "art/textures/terrain/$texture->{file}" ] if $texture->{file}; - } - push @deps, [ $f, "art/materials/$terrain->{material}" ] if $terrain->{material}; - } - } -} - - -sub check_deps -{ - my %files; - @files{@files} = (); - - my %lcfiles; - @lcfiles{map lc($_), @files} = @files; - - my %revdeps; - for my $d (@deps) - { - push @{$revdeps{$d->[1]}}, $d->[0]; - } - - for my $f (sort keys %revdeps) - { - next if exists $files{$f}; - warn "Missing file '$f' referenced by: " . (join ', ', map "'$_'", map vfs_to_relative_to_mods($_), sort @{$revdeps{$f}}) . "\n"; - - if (exists $lcfiles{lc $f}) - { - warn "### Case-insensitive match (found '$lcfiles{lc $f}')\n"; - } - } -} - -sub check_unused -{ - my %reachable; - @reachable{@roots} = (); - - my %deps; - for my $d (@deps) - { - push @{$deps{$d->[0]}}, $d->[1]; - } - - while (1) - { - my @newreachable; - for my $r (keys %reachable) - { - push @newreachable, grep { not exists $reachable{$_} } @{$deps{$r}}; - } - last if @newreachable == 0; - @reachable{@newreachable} = (); - } - - for my $f (sort @files) - { - next if exists $reachable{$f} - || index($f, "art/terrains/") != -1 - || index($f, "maps/random/") != -1 - || index($f, "art/materials/") != -1; - warn "Unused file '" . vfs_to_relative_to_mods($f) . "'\n"; - } -} - - -add_maps_xml() if $checkMapXml; -add_maps_pmp(); -add_entities(); -add_actors(); -add_variants(); -add_art(); -add_materials(); -add_particles(); -add_soundgroups(); -add_audio(); -add_gui_xml(); -add_gui_data(); -add_civs(); -add_rms(); -add_techs(); -add_terrains(); -add_auras(); - -check_deps(); -check_unused() if $checkUnused; -print "\n" if $checkUnused; -system("perl ../xmlvalidator/validate.pl") if $validateTemplates; Index: source/tools/entity/checkrefs.py =================================================================== --- /dev/null +++ source/tools/entity/checkrefs.py @@ -0,0 +1,573 @@ +#!/usr/bin/env python3 +from argparse import ArgumentParser +from collections import Counter +from decimal import Decimal +from io import BytesIO +from json import load, loads +from pathlib import Path +from re import split +from struct import unpack, calcsize +from subprocess import run +from sys import stderr, exit +from xml.etree import ElementTree + + +def warn(s): + print(f"warn: {s}", file=stderr) + + +class SimulTemplateEntity: + def __init__(self, vfs_root): + self.vfs_root = vfs_root + + def get_file(self, vfs_path, mod): + return (self.vfs_root / mod / vfs_path).with_suffix('.xml') + + def get_main_mod(self, vfs_path, mods): + for mod in mods: + fp = self.get_file(vfs_path, mod) + if fp.exists(): + main_mod = mod + break + else: + # default to first mod + # it should then not exist + # it will raise an exception when trying to read it + main_mod = mods[0] + return main_mod + + def apply_layer(self, base_tag, tag): + if tag.get('datatype') == 'tokens': + base_tokens = split(r'\s+', base_tag.text or '') + tokens = split(r'\s+', tag.text or '') + final_tokens = base_tokens.copy() + for token in tokens: + if token.startswith('-'): + token_to_remove = token[1:] + if token_to_remove in final_tokens: + del final_tokens[token_to_remove] + elif token not in final_tokens: + final_tokens.append(token) + base_tag.text = ' '.join(final_tokens) + elif tag.get('op'): + op = tag.get('op') + op1 = Decimal(base_tag.text or '0') + op2 = Decimal(tag.text or '0') + if op == 'add': + base_tag.text = str(op1 + op2) + elif op == 'mul': + base_tag.text = str(op1 * op2) + else: + raise ValueError(f"Invalid operator '{op}'") + else: + base_tag.text = tag.text + for child in tag: + base_child = base_tag.find(child.tag) + if 'disable' in child.attrib: + if base_child: + base_tag.remove(base_child) + else: + if 'replace' in child.attrib and base_child: + base_tag.remove(base_child) + if not base_child: + base_child = ElementTree.Element(child.tag) + self.apply_layer(base_child, child) + if 'replace' in base_child.attrib: + del base_child.attrib['replace'] + + def load_inherited(self, base_path, vfs_path, mods): + """ + vfs_path should be relative to base_path in a mod + """ + main_mod = self.get_main_mod(base_path / vfs_path, mods) + fp = self.get_file(base_path / vfs_path, main_mod) + layer = ElementTree.parse(fp).getroot() + for el in layer.iter(): + children = [x.tag for x in el] + duplicates = [x for x, c in Counter(children).items() if c > 1] + if duplicates: + for dup in duplicates: + warn(f"Duplicate child node '{dup}' in tag {el.tag} of {fp}") + if layer.get('parent'): + parent = self.load_inherited(base_path, layer.get('parent'), mods) + self.apply_layer(parent, layer) + return parent + else: + return layer + + +class CheckRefs: + def __init__(self): + # list of relative root file:str + self.files = [] + # list of relative file:str + self.roots = [] + # list of tuple (parent_file:str, dep_file:str) + self.deps = [] + self.vfs_root = Path(__file__).resolve().parents[3] / 'binaries' / 'data' / 'mods' + self.supportedTextureFormats = ('dds', 'png') + self.supportedMeshesFormats = ('pmd', 'dae') + self.supportedAnimationFormats = ('psa', 'dae') + self.mods = [] + + def main(self): + ap = ArgumentParser(description="Checks the game files for missing dependencies, unused files," + " and for file integrity.") + ap.add_argument('-u', '--check-unused', action='store_true', + help="check for all the unused files in the given mods and their dependencies." + " Implies --check-map-xml. Currently yields a lot of false positives.") + ap.add_argument('-x', '--check-map-xml', action='store_true', + help="check maps for missing actor and templates.") + ap.add_argument('-t', '--validate-templates', action='store_true', + help="run the validator.py script to check if the xml files match their (.rng) grammar file." + " This currently only works for the public mod.") + ap.add_argument('-m', '--mod-to-check', metavar="MOD", dest='mods', nargs='+', default=['public'], + help="specify which mods to check. Default to public.") + args = ap.parse_args() + # force check_map_xml if check_unused is used to avoid false positives. + args.check_map_xml |= args.check_unused + # ordered uniq mods (dict maintains ordered keys from python 3.6) + self.mods = list(dict.fromkeys([*args.mods, *self.get_mod_dependencies(*args.mods), 'mod']).keys()) + print(f"Checking {'|'.join(args.mods)}'s integrity.") + print(f"The following mods will be loaded: {'|'.join(self.mods)}.") + if args.check_map_xml: + self.add_maps_xml() + self.add_maps_pmp() + self.add_entities() + self.add_actors() + self.add_variants() + self.add_art() + self.add_materials() + self.add_particles() + self.add_soundgroups() + self.add_audio() + self.add_gui_xml() + self.add_gui_data() + self.add_civs() + self.add_rms() + self.add_techs() + self.add_terrains() + self.add_auras() + # Do some checks based on file, roots, deps + self.check_deps() + if args.check_unused: + self.check_unused() + print('') + if args.validate_templates: + proc = run([str((Path(__file__).parent / '../xmlvalidator/validator.py').resolve())]) + exit(proc.returncode) + + def get_mod_dependencies(self, *mods): + modjsondeps = [] + for mod in mods: + with open(self.vfs_root / mod / 'mod.json') as f: + modjson = load(f) + # 0ad's folder isn't named like the mod. + modjsondeps.extend(['public' if '0ad' in dep else dep for dep in modjson.get('dependencies', [])]) + return modjsondeps + + def vfs_to_relative_to_mods(self, vfs_path): + for dep in self.mods: + fn = Path(dep) / vfs_path + if (self.vfs_root / fn).exists(): + return fn + return None + + def vfs_to_physical(self, vfs_path): + fn = self.vfs_to_relative_to_mods(vfs_path) + return self.vfs_root / fn + + def find_files(self, vfs_path, *ext_list): + """ + returns a list of 2-size tuple with: + - Path relative to the mod base + - full Path + """ + full_exts = ['.' + ext for ext in ext_list] + + def find_recursive(dp, base): + """(relative Path, full Path) generator""" + if dp.is_dir(): + if dp.name != '.svn' and not dp.name.endswith('~'): + for fp in dp.iterdir(): + yield from find_recursive(fp, base) + elif dp.suffix in full_exts: + relative_file_path = dp.relative_to(base) + yield (relative_file_path, dp.resolve()) + return [(rp, fp) for mod in self.mods for (rp, fp) in find_recursive(self.vfs_root / mod / vfs_path, self.vfs_root / mod)] + + def add_maps_xml(self): + print("Loading maps XML...") + mapfiles = self.find_files('maps/scenarios', 'xml') + mapfiles.extend(self.find_files('maps/skirmishes', 'xml')) + mapfiles.extend(self.find_files('maps/tutorials', 'xml')) + actor_prefix = 'actor|' + resource_prefix = 'resource|' + for (fp, ffp) in sorted(mapfiles): + self.files.append(str(fp)) + self.roots.append(str(fp)) + et_map = ElementTree.parse(ffp).getroot() + entities = et_map.find('Entities') + used = {entity.find('Template').text.strip() for entity in entities.findall('Entity')} if entities else {} + for template in used: + if template.startswith(actor_prefix): + self.deps.append((str(fp), f'art/actors/{template[len(actor_prefix):]}')) + elif template.startswith(resource_prefix): + self.deps.append((str(fp), f'simulation/templates/{template[len(resource_prefix):]}.xml')) + else: + self.deps.append((str(fp), f'simulation/templates/{template}.xml')) + # Map previews + settings = loads(et_map.find('ScriptSettings').text) + if settings.get('Preview', None): + self.deps.append((str(fp), f'art/textures/ui/session/icons/mappreview/{settings["Preview"]}')) + + def add_maps_pmp(self): + print("Loading maps PMP...") + # Need to generate terrain texture filename=>relative path lookup first + terrains = dict() + for (fp, ffp) in self.find_files('art/terrains', 'xml'): + name = fp.stem + # ignore terrains.xml + if name != 'terrains': + if name in terrains: + warn(f"Duplicate terrain name '{name}' (from '{terrains[name]}' and '{ffp}')") + terrains[name] = str(fp) + mapfiles = self.find_files('maps/scenarios', 'pmp') + mapfiles.extend(self.find_files('maps/skirmishes', 'pmp')) + for (fp, ffp) in sorted(mapfiles): + self.files.append(str(fp)) + self.roots.append(str(fp)) + with open(ffp, 'rb') as f: + expected_header = b'PSMP' + header = f.read(len(expected_header)) + if header != expected_header: + raise ValueError(f"Invalid PMP header {header} in '{ffp}'") + int_fmt = '