Index: source/tools/entity/.flake8 =================================================================== --- /dev/null +++ source/tools/entity/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 150 Index: source/tools/entity/.gitignore =================================================================== --- /dev/null +++ source/tools/entity/.gitignore @@ -0,0 +1,3 @@ +*.dot +*.png +__pycache__ Index: source/tools/entity/Entity.pm =================================================================== --- source/tools/entity/Entity.pm +++ /dev/null @@ -1,168 +0,0 @@ -package Entity; - -use strict; -use warnings; - -use XML::Parser; -use Data::Dumper; -use File::Find; - -my $vfsroot = '../../../binaries/data/mods'; - -sub get_filename -{ - my ($vfspath, $mod) = @_; - my $fn = "$vfsroot/$mod/simulation/templates/$vfspath.xml"; - return $fn; -} - -sub get_file -{ - my ($vfspath, $mod) = @_; - my $fn = get_filename($vfspath, $mod); - open my $f, $fn or die "Error loading $fn: $!"; - local $/; - return <$f>; -} - -sub trim -{ - my ($t) = @_; - return '' if not defined $t; - $t =~ /^\s*(.*?)\s*$/s; - return $1; -} - -sub load_xml -{ - my ($vfspath, $file) = @_; - my $root = {}; - my @stack = ($root); - my $p = new XML::Parser(Handlers => { - Start => sub { - my ($e, $n, %a) = @_; - my $t = {}; - die "Duplicate child node '$n'" if exists $stack[-1]{$n}; - $stack[-1]{$n} = $t; - for (keys %a) { - $t->{'@'.$_}{' content'} = trim($a{$_}); - } - push @stack, $t; - }, - End => sub { - my ($e, $n) = @_; - $stack[-1]{' content'} = trim($stack[-1]{' content'}); - pop @stack; - }, - Char => sub { - my ($e, $str) = @_; - $stack[-1]{' content'} .= $str; - }, - }); - eval { - $p->parse($file); - }; - if ($@) { - die "Error parsing $vfspath: $@"; - } - return $root; -} - -sub apply_layer -{ - my ($base, $new) = @_; - if ($new->{'@datatype'} and $new->{'@datatype'}{' content'} eq 'tokens') { - my @old = split /\s+/, ($base->{' content'} || ''); - my @new = split /\s+/, ($new->{' content'} || ''); - my @t = @old; - for my $n (@new) { - if ($n =~ /^-(.*)/) { - @t = grep $_ ne $1, @t; - } else { - push @t, $n if not grep $_ eq $n, @t; - } - } - $base->{' content'} = join ' ', @t; - } elsif ($new->{'@op'}) { - my $op = $new->{'@op'}{' content'}; - my $op1 = $base->{' content'}; - my $op2 = $new->{' content'}; - if ($op eq 'add') { - $base->{' content'} = $op1 + $op2; - } - elsif ($op eq 'mul') { - $base->{' content'} = $op1 * $op2; - } - else { - die "Invalid operator '$op'"; - } - } else { - $base->{' content'} = $new->{' content'}; - } - for my $k (grep $_ ne ' content', keys %$new) { - if ($new->{$k}{'@disable'}) { - delete $base->{$k}; - } else { - if ($new->{$k}{'@replace'}) { - delete $base->{$k}; - } - $base->{$k} ||= {}; - apply_layer($base->{$k}, $new->{$k}); - delete $base->{$k}{'@replace'}; - } - } -} - -sub get_main_mod -{ - my ($vfspath, $mods) = @_; - my @mods_list = split(/\|/, $mods); - my $main_mod = $mods_list[0]; - my $fn = "$vfsroot/$main_mod/simulation/templates/$vfspath.xml"; - if (not -e $fn) - { - for my $dep (@mods_list) - { - $fn = "$vfsroot/$dep/simulation/templates/$vfspath.xml"; - if (-e $fn) - { - $main_mod = $dep; - last; - } - } - } - return $main_mod; -} - -sub load_inherited -{ - my ($vfspath, $mods) = @_; - my $main_mod = get_main_mod($vfspath, $mods); - my $layer = load_xml($vfspath, get_file($vfspath, $main_mod)); - - if ($layer->{Entity}{'@parent'}) { - my $parent = load_inherited($layer->{Entity}{'@parent'}{' content'}, $mods); - apply_layer($parent->{Entity}, $layer->{Entity}); - return $parent; - } else { - return $layer; - } -} - -sub find_entities -{ - my ($modName) = @_; - my @files; - my $find_process = sub { - return $File::Find::prune = 1 if $_ eq '.svn'; - my $n = $File::Find::name; - return if /~$/; - return unless -f $_; - $n =~ s~\Q$vfsroot\E/$modName/simulation/templates/~~; - $n =~ s/\.xml$//; - push @files, $n; - }; - find({ wanted => $find_process }, "$vfsroot/$modName/simulation/templates"); - - return @files; -} 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,475 @@ +#!/usr/bin/env python3 +from argparse import ArgumentParser +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 exit +from xml.etree import ElementTree +from scriptlib import warn, SimulTemplateEntity, find_files + + +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): + return find_files(self.vfs_root, self.mods, vfs_path, *ext_list) + + 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 is not None 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 = ' 1), @files; - -open my $g, '>', 'creation.dot' or die $!; -print $g "digraph G {\n"; - -for my $f (sort @files) { - next if $f =~ /^template_/; - print "# $f...\n"; - my $ent = Entity::load_inherited($f, "public"); - - if ($ent->{Entity}{Builder}) { - my $ents = $ent->{Entity}{Builder}{Entities}{' content'}; - $ents =~ s/\{civ\}/$ent->{Entity}{Identity}{Civ}{' content'}/eg; - for my $b (split /\s+/, $ents) { - warn "Invalid Builder reference: $f -> $b\n" unless $files{$b}; - print $g qq{"$f" -> "$b" [color=green];\n}; - } - } - - if ($ent->{Entity}{TrainingQueue}) { - my $ents = $ent->{Entity}{TrainingQueue}{Entities}{' content'}; - $ents =~ s/\{civ\}/$ent->{Entity}{Identity}{Civ}{' content'}/eg; - for my $b (split /\s+/, $ents) { - warn "Invalid TrainingQueue reference: $f -> $b\n" unless $files{$b}; - print $g qq{"$f" -> "$b" [color=blue];\n}; - } - } -} - -print $g "}\n"; - -close $g; - -system("dot -Tpng creation.dot -o creation.png"); Index: source/tools/entity/creationgraph.py =================================================================== --- /dev/null +++ source/tools/entity/creationgraph.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +from os import chdir +from pathlib import Path +from re import split +from subprocess import run +from sys import exit +from scriptlib import warn, SimulTemplateEntity, find_files + + +def find_entities(vfs_root): + base = vfs_root / 'public' / 'simulation' / 'templates' + return [str(fp.relative_to(base).with_suffix('')) for (_, fp) in find_files(vfs_root, ['public'], 'simulation/templates', 'xml')] + + +def main(): + vfs_root = Path(__file__).resolve().parents[3] / 'binaries' / 'data' / 'mods' + simul_templates_path = Path('simulation/templates') + simul_template_entity = SimulTemplateEntity(vfs_root) + with open('creation.dot', 'w') as dot_f: + dot_f.write('digraph G {\n') + files = sorted(find_entities(vfs_root)) + for f in files: + if f.startswith('template_'): + continue + print(f"# {f}...") + entity = simul_template_entity.load_inherited(simul_templates_path, f, ['public']) + if entity.find('Builder') is not None and entity.find('Builder').find('Entities') is not None: + entities = entity.find('Builder').find('Entities').text.replace('{civ}', entity.find('Identity').find('Civ').text) + builders = split(r'\s+', entities.strip()) + for builder in builders: + if Path(builder) in files: + warn(f"Invalid Builder reference: {f} -> {builder}") + dot_f.write(f'"{f}" -> "{builder}" [color=green];\n') + if entity.find('TrainingQueue') is not None and entity.find('TrainingQueue').find('Entities') is not None: + entities = entity.find('TrainingQueue').find('Entities').text.replace('{civ}', entity.find('Identity').find('Civ').text) + training_queues = split(r'\s+', entities.strip()) + for training_queue in training_queues: + if Path(training_queue) in files: + warn(f"Invalid TrainingQueue reference: {f} -> {training_queue}") + dot_f.write(f'"{f}" -> "{training_queue}" [color=blue];\n') + dot_f.write('}\n') + if run(['dot', '-V'], capture_output=True).returncode == 0: + exit(run(['dot', '-Tpng', 'creation.dot', '-o', 'creation.png'], text=True).returncode) + + +if __name__ == '__main__': + chdir(Path(__file__).resolve().parent) + main() Index: source/tools/entity/entvalidate.pl =================================================================== --- source/tools/entity/entvalidate.pl +++ /dev/null @@ -1,73 +0,0 @@ -use strict; -use warnings; - -use XML::LibXML; - -use lib "."; -use Entity; - -my $rngschema = XML::LibXML::RelaxNG->new(location => '../../../binaries/system/entity.rng'); - -sub escape_xml -{ - my ($t) = @_; - $t =~ s/&/&/g; - $t =~ s//>/g; - $t =~ s/"/"/g; - $t =~ s/\t/ /g; - $t =~ s/\n/ /g; - $t =~ s/\r/ /g; - $t; -} - -sub to_xml -{ - my ($e) = @_; - my $r = $e->{' content'}; - $r = '' if not defined $r; - for my $k (sort grep !/^[\@ ]/, keys %$e) { - $r .= "<$k"; - for my $a (sort grep /^\@/, keys %{$e->{$k}}) { - $a =~ /^\@(.*)/; - $r .= " $1=\"".escape_xml($e->{$k}{$a}{' content'})."\""; - } - $r .= ">"; - $r .= to_xml($e->{$k}); - $r .= ""; - } - return $r; -} - -sub validate -{ - my ($vfspath) = @_; - my $xml = to_xml(Entity::load_inherited($vfspath)); - my $doc = XML::LibXML->new->parse_string($xml); - $rngschema->validate($doc); -} - - -sub check_all -{ - my @files = Entity::find_entities("public"); - - my $count = 0; - my $failed = 0; - for my $f (sort @files) { - next if $f =~ /^template_/; - print "# $f...\n"; - ++$count; - eval { - validate($f); - }; - if ($@) { - ++$failed; - print $@; - eval { print to_xml(load_inherited($f)), "\n"; } - } - } - print "\nTotal: $count; failed: $failed\n"; -} - -check_all(); Index: source/tools/entity/entvalidate.py =================================================================== --- /dev/null +++ source/tools/entity/entvalidate.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +from os import chdir +from pathlib import Path +from subprocess import run, CalledProcessError +from sys import exit +from xml.etree import ElementTree +from scriptlib import warn, SimulTemplateEntity, find_files + + +def main(): + root = Path(__file__).resolve().parents[3] + relaxng_schema = root / 'binaries' / 'system' / 'entity.rng' + if not relaxng_schema.exists(): + warn(f"""Relax NG schema non existant. +Please create the file {relaxng_schema.relative_to(root)} +You can do that by running 'pyrogenesis -dumpSchema' in the 'system' directory""") + exit(1) + if run(['xmllint', '--version'], capture_output=True).returncode != 0: + warn("xmllint not found in your PATH, please install it (usually in libxml2 package)") + exit(2) + vfs_root = root / 'binaries' / 'data' / 'mods' + simul_templates_path = Path('simulation/templates') + simul_template_entity = SimulTemplateEntity(vfs_root) + count = 0 + failed = 0 + for fp, _ in sorted(find_files(vfs_root, ['public'], 'simulation/templates', 'xml')): + if fp.stem.startswith('template_'): + continue + print(f"# {fp}...") + count += 1 + entity = simul_template_entity.load_inherited(simul_templates_path, str(fp.relative_to(simul_templates_path)), ['public']) + xmlcontent = ElementTree.tostring(entity, encoding='unicode') + try: + run(['xmllint', '--relaxng', str(relaxng_schema.resolve()), '-'], input=xmlcontent, capture_output=True, text=True, check=True) + except CalledProcessError as e: + failed += 1 + print(e.stderr) + print(e.stdout) + print(f"\nTotal: {count}; failed: {failed}") + + +if __name__ == '__main__': + chdir(Path(__file__).resolve().parent) + main() Index: source/tools/entity/readme.md =================================================================== --- source/tools/entity/readme.md +++ source/tools/entity/readme.md @@ -1,4 +1,4 @@ -# Checkrefs.pl +# Checkrefs.py ## Description @@ -6,24 +6,28 @@ ## Requirements -- Perl interpreter installed -- Dependencies: - - XML::Parser - - XML::Simple - - Getopt::Long - - File::Find - - Data::Dumper - - JSON +- Python 3.6+ interpreter installed ## Usage -- cd in source/tools/entity and run the script. +- cd in `source/tools/entity` and run the script. ``` -Usage: perl checkrefs.pl [OPTION]... -Checks the game files for missing dependencies, unused files, and for file integrity. - --check-unused check for all the unused files in the given mods and their dependencies. Implies --check-map-xml. Currently yields a lot of false positives. - --check-map-xml check maps for missing actor and templates. - --validate-templates run the validate.pl script to check if the xml files match their (.rng) grammar file. This currently only works for the public mod. - --mod-to-check=mods specify which mods to check. 'mods' should be a list of mods separated by '|'. Default value: 'public|mod'. +usage: checkrefs.py [-h] [-u] [-x] [-t] [-m MOD [MOD ...]] + +Checks the game files for missing dependencies, unused files, and for file +integrity. + +optional arguments: + -h, --help show this help message and exit + -u, --check-unused check for all the unused files in the given mods and + their dependencies. Implies --check-map-xml. Currently + yields a lot of false positives. + -x, --check-map-xml check maps for missing actor and templates. + -t, --validate-templates + run the validator.py script to check if the xml files + match their (.rng) grammar file. This currently only + works for the public mod. + -m MOD [MOD ...], --mod-to-check MOD [MOD ...] + specify which mods to check. Default to public. ``` Index: source/tools/entity/scriptlib/__init__.py =================================================================== --- /dev/null +++ source/tools/entity/scriptlib/__init__.py @@ -0,0 +1,113 @@ +from collections import Counter +from decimal import Decimal +from re import split +from sys import stderr +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): + """ + apply tag layer to base_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: + final_tokens.remove(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 is not None: + base_tag.remove(base_child) + else: + if 'replace' in child.attrib and base_child is not None: + base_tag.remove(base_child) + if base_child is None: + base_child = ElementTree.Element(child.tag) + base_tag.append(base_child) + 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 + + +def find_files(vfs_root, mods, 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 mods for (rp, fp) in find_recursive(vfs_root / mod / vfs_path, vfs_root / mod)] Index: source/tools/xmlvalidator/validator.py =================================================================== --- source/tools/xmlvalidator/validator.py +++ source/tools/xmlvalidator/validator.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import argparse import os import re