Index: ps/trunk/source/tools/entity/Entity.pm =================================================================== --- ps/trunk/source/tools/entity/Entity.pm +++ ps/trunk/source/tools/entity/Entity.pm @@ -1,191 +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/special/filter/$vfspath.xml"; - if (not -e $fn) { - $fn = "$vfsroot/$mod/simulation/templates/mixins/$vfspath.xml"; - } - if (not -e $fn) { - $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; - } - elsif ($op eq 'mul_round') { - # This is incorrect (floors instead of rounding) - # but for schema purposes it ought be fine. - $base->{' content'} = int($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, $base) = @_; - if ($vfspath =~ /\|/) { - my @paths = split(/\|/, $vfspath, 2); - $base = load_inherited($paths[1], $mods, $base); - $base = load_inherited($paths[0], $mods, $base); - return $base - } - 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, $base); - apply_layer($parent->{Entity}, $layer->{Entity}); - return $parent; - } else { - if (not $base) { - return $layer; - } - else { - apply_layer($base->{Entity}, $layer->{Entity}); - return $base - } - } -} - -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: ps/trunk/source/tools/entity/checkrefs.pl =================================================================== --- ps/trunk/source/tools/entity/checkrefs.pl +++ ps/trunk/source/tools/entity/checkrefs.pl @@ -1,715 +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"); - - if ($ent->{Entity}{'@parent'}) - { - my @parents = split(/\|/, $ent->{Entity}{'@parent'}{' content'}); - for my $parentPath (@parents) - { - push @deps, [ $path, "simulation/templates/" . $parentPath . ".xml" ]; - } - } - - 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 == 7; - - 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; - - # GUI page definitions are assumed to be named page[_something].xml and alone in that. - if ($f =~ /\/page(_[^.\/]+)?\.xml$/) - { - 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, "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) - { - if ($f =~ /simulation\/templates\//) - { - next if exists $files{$f =~ s/templates\//templates\/special\/filter\//r}; - next if exists $files{$f =~ s/templates\//templates\/mixins\//r}; - } - 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: ps/trunk/source/tools/entity/checkrefs.py =================================================================== --- ps/trunk/source/tools/entity/checkrefs.py +++ ps/trunk/source/tools/entity/checkrefs.py @@ -0,0 +1,605 @@ +#!/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, match +from struct import unpack, calcsize +from os.path import sep, exists, basename +from xml.etree import ElementTree +import sys +from scriptlib import SimulTemplateEntity, find_files +from logging import WARNING, getLogger, StreamHandler, INFO, Formatter, Filter + +class SingleLevelFilter(Filter): + def __init__(self, passlevel, reject): + self.passlevel = passlevel + self.reject = reject + + def filter(self, record): + if self.reject: + return (record.levelno != self.passlevel) + else: + return (record.levelno == self.passlevel) + +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.supportedAudioFormats = ('ogg') + self.mods = [] + self.__init_logger + + @property + def __init_logger(self): + logger = getLogger(__name__) + logger.setLevel(INFO) + # create a console handler, seems nicer to Windows and for future uses + ch = StreamHandler(sys.stdout) + ch.setLevel(INFO) + ch.setFormatter(Formatter('%(levelname)s - %(message)s')) + f1 = SingleLevelFilter(INFO, False) + ch.addFilter(f1) + logger.addHandler(ch) + errorch = StreamHandler(sys.stderr) + errorch.setLevel(WARNING) + errorch.setFormatter(Formatter('%(levelname)s - %(message)s')) + logger.addHandler(errorch) + self.logger = logger + + 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('-a', '--validate-actors', action='store_true', + help="run the validator.py script to check if the actors files have extra or missing textures." + " This currently only works for the public mod.") + 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.") + ap.add_argument('-m', '--mods', 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()) + self.logger.info(f"Checking {'|'.join(args.mods)}'s integrity.") + self.logger.info(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() + self.add_tips() + self.check_deps() + if args.check_unused: + self.check_unused() + if args.validate_templates: + sys.path.append("../xmlvalidator/") + from validate_grammar import RelaxNGValidator + validate = RelaxNGValidator(self.vfs_root, self.mods) + validate.run() + if args.validate_actors: + sys.path.append("../xmlvalidator/") + from validator import Validator + validator = Validator(self.vfs_root, self.mods) + validator.run() + + def get_mod_dependencies(self, *mods): + modjsondeps = [] + for mod in mods: + mod_json_path = self.vfs_root / mod / 'mod.json' + if not exists(mod_json_path): + continue + + with open(mod_json_path, encoding='utf-8') 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): + self.logger.info("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): + self.logger.info("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: + self.logger.warning(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 = ' {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: ps/trunk/source/tools/entity/entvalidate.py =================================================================== --- ps/trunk/source/tools/entity/entvalidate.py +++ ps/trunk/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: ps/trunk/source/tools/entity/readme.md =================================================================== --- ps/trunk/source/tools/entity/readme.md +++ ps/trunk/source/tools/entity/readme.md @@ -1,4 +1,4 @@ -# Checkrefs.pl +# Checkrefs.py ## Description @@ -6,24 +6,27 @@ ## Requirements -- Perl interpreter installed -- Dependencies: - - XML::Parser - - XML::Simple - - Getopt::Long - - File::Find - - Data::Dumper - - JSON +- Python 3.6+ interpreter installed +- lxml for the -a option. ## Usage -- cd in source/tools/entity and run the script. +- cd in `source/tools/entity` and run the script. ``` -Usage: perl checkrefs.pl [OPTION]... +usage: checkrefs.py [-h] [-u] [-x] [-a] [-t] [-m MOD [MOD ...]] + 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'. + +options: + -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. + -a, --validate-actors + run the validator.py script to check if the actors files have extra or missing textures. + -t, --validate-templates + run the validator.py script to check if the xml files match their (.rng) grammar file. + -m MOD [MOD ...], --mods MOD [MOD ...] + specify which mods to check. Default to public. ``` Index: ps/trunk/source/tools/entity/scriptlib/__init__.py =================================================================== --- ps/trunk/source/tools/entity/scriptlib/__init__.py +++ ps/trunk/source/tools/entity/scriptlib/__init__.py @@ -0,0 +1,128 @@ +from collections import Counter +from decimal import Decimal +from re import split +from sys import stderr +from xml.etree import ElementTree +from os.path import exists + +class SimulTemplateEntity: + def __init__(self, vfs_root, logger): + self.vfs_root = vfs_root + self.logger = logger + + def get_file(self, base_path, vfs_path, mod): + default_path = self.vfs_root / mod / base_path + file = (default_path/ "special" / "filter" / vfs_path).with_suffix('.xml') + if not exists(file): + file = (default_path / "mixins" / vfs_path).with_suffix('.xml') + if not exists(file): + file = (default_path / vfs_path).with_suffix('.xml') + return file + + def get_main_mod(self, base_path, vfs_path, mods): + for mod in mods: + fp = self.get_file(base_path, 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) + elif op == 'mul_round': + base_tag.text = str(round(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, base = None): + """ + vfs_path should be relative to base_path in a mod + """ + if '|' in vfs_path: + paths = vfs_path.split("|", 2) + base = self.load_inherited(base_path, paths[1], mods, base); + base = self.load_inherited(base_path, paths[0], mods, base); + return base + + 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: + self.logger.warning(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: + if not base: + return layer + else: + self.apply_layer(base, layer) + return base + + +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 dp.name != '.git' 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: ps/trunk/source/tools/xmlvalidator/validate.py =================================================================== --- ps/trunk/source/tools/xmlvalidator/validate.py +++ ps/trunk/source/tools/xmlvalidator/validate.py @@ -1,190 +0,0 @@ -#!/usr/bin/env python3 -from argparse import ArgumentParser -from pathlib import Path -from os.path import sep, join, realpath, exists, basename, dirname -from json import load, loads -from re import split, match -from logging import getLogger, StreamHandler, INFO, Formatter -import lxml.etree - -class VFS_File: - def __init__(self, mod_name, vfs_path): - self.mod_name = mod_name - self.vfs_path = vfs_path - -class RelaxNGValidator: - def __init__(self, vfs_root, mods=None, verbose=False): - self.mods = mods if mods is not None else [] - self.vfs_root = Path(vfs_root) - self.__init_logger - self.verbose = verbose - - @property - def __init_logger(self): - logger = getLogger(__name__) - logger.setLevel(INFO) - # create a console handler, seems nicer to Windows and for future uses - ch = StreamHandler() - ch.setLevel(INFO) - ch.setFormatter(Formatter('%(levelname)s - %(message)s')) - # ch.setFormatter(Formatter('%(message)s')) # same output as perl - logger.addHandler(ch) - self.logger = logger - - def run (self): - self.validate_actors() - self.validate_variants() - self.validate_guis() - self.validate_maps() - self.validate_materials() - self.validate_particles() - self.validate_simulation() - self.validate_soundgroups() - self.validate_terrains() - self.validate_textures() - - def main(self): - """ Program entry point, parses command line arguments and launches the validation """ - # ordered uniq mods (dict maintains ordered keys from python 3.6) - self.logger.info(f"Checking {'|'.join(self.mods)}'s integrity.") - self.logger.info(f"The following mods will be loaded: {'|'.join(self.mods)}.") - self.run() - - def find_files(self, 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 dp.name != '.git' 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)] - - def validate_actors(self): - self.logger.info('Validating actors...') - files = self.find_files(self.vfs_root, self.mods, 'art/actors/', 'xml') - self.validate_files('actors', files, 'art/actors/actor.rng') - - def validate_variants(self): - self.logger.info("Validating variants...") - files = self.find_files(self.vfs_root, self.mods, 'art/variants/', 'xml') - self.validate_files('variant', files, 'art/variants/variant.rng') - - def validate_guis(self): - self.logger.info("Validating gui files...") - pages = [file for file in self.find_files(self.vfs_root, self.mods, 'gui/', 'xml') if match(r".*[\\\/]page(_[^.\/\\]+)?\.xml$", str(file[0]))] - self.validate_files('gui page', pages, 'gui/gui_page.rng') - xmls = [file for file in self.find_files(self.vfs_root, self.mods, 'gui/', 'xml') if not match(r".*[\\\/]page(_[^.\/\\]+)?\.xml$", str(file[0]))] - self.validate_files('gui xml', xmls, 'gui/gui.rng') - - def validate_maps(self): - self.logger.info("Validating maps...") - files = self.find_files(self.vfs_root, self.mods, 'maps/scenarios/', 'xml') - self.validate_files('map', files, 'maps/scenario.rng') - files = self.find_files(self.vfs_root, self.mods, 'maps/skirmishes/', 'xml') - self.validate_files('map', files, 'maps/scenario.rng') - - def validate_materials(self): - self.logger.info("Validating materials...") - files = self.find_files(self.vfs_root, self.mods, 'art/materials/', 'xml') - self.validate_files('material', files, 'art/materials/material.rng') - - def validate_particles(self): - self.logger.info("Validating particles...") - files = self.find_files(self.vfs_root, self.mods, 'art/particles/', 'xml') - self.validate_files('particle', files, 'art/particles/particle.rng') - - def validate_simulation(self): - self.logger.info("Validating simulation...") - file = self.find_files(self.vfs_root, self.mods, 'simulation/data/pathfinder', 'xml') - self.validate_files('pathfinder', file, 'simulation/data/pathfinder.rng') - file = self.find_files(self.vfs_root, self.mods, 'simulation/data/territorymanager', 'xml') - self.validate_files('territory manager', file, 'simulation/data/territorymanager.rng') - - def validate_soundgroups(self): - self.logger.info("Validating soundgroups...") - files = self.find_files(self.vfs_root, self.mods, 'audio/', 'xml') - self.validate_files('sound group', files, 'audio/sound_group.rng') - - def validate_terrains(self): - self.logger.info("Validating terrains...") - terrains = [file for file in self.find_files(self.vfs_root, self.mods, 'art/terrains/', 'xml') if 'terrains.xml' in str(file[0])] - self.validate_files('terrain', terrains, 'art/terrains/terrain.rng') - terrains_textures = [file for file in self.find_files(self.vfs_root, self.mods, 'art/terrains/', 'xml') if 'terrains.xml' not in str(file[0])] - self.validate_files('terrain texture', terrains_textures, 'art/terrains/terrain_texture.rng') - - def validate_textures(self): - self.logger.info("Validating textures...") - files = [file for file in self.find_files(self.vfs_root, self.mods, 'art/textures/', 'xml') if 'textures.xml' in str(file[0])] - self.validate_files('texture', files, 'art/textures/texture.rng') - - def get_physical_path(self, mod_name, vfs_path): - return realpath(join(self.vfs_root, mod_name, vfs_path)) - - def get_relaxng_file(self, schemapath): - """We look for the highest priority mod relax NG file""" - for mod in self.mods: - relax_ng_path = self.get_physical_path(mod, schemapath) - if exists(relax_ng_path): - return relax_ng_path - - return "" - - def validate_files(self, name, files, schemapath): - relax_ng_path = self.get_relaxng_file(schemapath) - if relax_ng_path == "": - self.logger.warning(f"Could not find {schemapath}") - return - - data = lxml.etree.parse(relax_ng_path) - relaxng = lxml.etree.RelaxNG(data) - error_count = 0 - for file in sorted(files): - try: - doc = lxml.etree.parse(str(file[1])) - relaxng.assertValid(doc) - except Exception as e: - error_count = error_count + 1 - self.logger.error(f"{file[1]}: " + str(e)) - - if self.verbose: - self.logger.info(f"{error_count} {name} validation errors") - elif error_count > 0: - self.logger.error(f"{error_count} {name} validation errors") - - -def get_mod_dependencies(vfs_root, *mods): - modjsondeps = [] - for mod in mods: - mod_json_path = Path(vfs_root) / mod / 'mod.json' - if not exists(mod_json_path): - continue - - with open(mod_json_path, encoding='utf-8') 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 - -if __name__ == '__main__': - script_dir = dirname(realpath(__file__)) - default_root = join(script_dir, '..', '..', '..', 'binaries', 'data', 'mods') - ap = ArgumentParser(description="Validates XML files againt their Relax NG schemas") - ap.add_argument('-r', '--root', action='store', dest='root', default=default_root) - ap.add_argument('-v', '--verbose', action='store_true', default=True, - help="Log validation errors.") - ap.add_argument('-m', '--mods', metavar="MOD", dest='mods', nargs='+', default=['public'], - help="specify which mods to check. Default to public and mod.") - args = ap.parse_args() - mods = list(dict.fromkeys([*args.mods, *get_mod_dependencies(args.root, *args.mods), 'mod']).keys()) - relax_ng_validator = RelaxNGValidator(args.root, mods=mods, verbose=args.verbose) - relax_ng_validator.main() \ No newline at end of file Index: ps/trunk/source/tools/xmlvalidator/validate_grammar.py =================================================================== --- ps/trunk/source/tools/xmlvalidator/validate_grammar.py +++ ps/trunk/source/tools/xmlvalidator/validate_grammar.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +from argparse import ArgumentParser +from pathlib import Path +from os.path import sep, join, realpath, exists, basename, dirname +from json import load, loads +from re import split, match +from logging import getLogger, StreamHandler, INFO, WARNING, Filter, Formatter +import lxml.etree +import sys + +class SingleLevelFilter(Filter): + def __init__(self, passlevel, reject): + self.passlevel = passlevel + self.reject = reject + + def filter(self, record): + if self.reject: + return (record.levelno != self.passlevel) + else: + return (record.levelno == self.passlevel) + +class VFS_File: + def __init__(self, mod_name, vfs_path): + self.mod_name = mod_name + self.vfs_path = vfs_path + +class RelaxNGValidator: + def __init__(self, vfs_root, mods=None, verbose=False): + self.mods = mods if mods is not None else [] + self.vfs_root = Path(vfs_root) + self.__init_logger + self.verbose = verbose + + @property + def __init_logger(self): + logger = getLogger(__name__) + logger.setLevel(INFO) + # create a console handler, seems nicer to Windows and for future uses + ch = StreamHandler(sys.stdout) + ch.setLevel(INFO) + ch.setFormatter(Formatter('%(levelname)s - %(message)s')) + f1 = SingleLevelFilter(INFO, False) + ch.addFilter(f1) + logger.addHandler(ch) + errorch = StreamHandler(sys.stderr) + errorch.setLevel(WARNING) + errorch.setFormatter(Formatter('%(levelname)s - %(message)s')) + logger.addHandler(errorch) + self.logger = logger + + def run (self): + self.validate_actors() + self.validate_variants() + self.validate_guis() + self.validate_maps() + self.validate_materials() + self.validate_particles() + self.validate_simulation() + self.validate_soundgroups() + self.validate_terrains() + self.validate_textures() + + def main(self): + """ Program entry point, parses command line arguments and launches the validation """ + # ordered uniq mods (dict maintains ordered keys from python 3.6) + self.logger.info(f"Checking {'|'.join(self.mods)}'s integrity.") + self.logger.info(f"The following mods will be loaded: {'|'.join(self.mods)}.") + self.run() + + def find_files(self, 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 dp.name != '.git' 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)] + + def validate_actors(self): + self.logger.info('Validating actors...') + files = self.find_files(self.vfs_root, self.mods, 'art/actors/', 'xml') + self.validate_files('actors', files, 'art/actors/actor.rng') + + def validate_variants(self): + self.logger.info("Validating variants...") + files = self.find_files(self.vfs_root, self.mods, 'art/variants/', 'xml') + self.validate_files('variant', files, 'art/variants/variant.rng') + + def validate_guis(self): + self.logger.info("Validating gui files...") + pages = [file for file in self.find_files(self.vfs_root, self.mods, 'gui/', 'xml') if match(r".*[\\\/]page(_[^.\/\\]+)?\.xml$", str(file[0]))] + self.validate_files('gui page', pages, 'gui/gui_page.rng') + xmls = [file for file in self.find_files(self.vfs_root, self.mods, 'gui/', 'xml') if not match(r".*[\\\/]page(_[^.\/\\]+)?\.xml$", str(file[0]))] + self.validate_files('gui xml', xmls, 'gui/gui.rng') + + def validate_maps(self): + self.logger.info("Validating maps...") + files = self.find_files(self.vfs_root, self.mods, 'maps/scenarios/', 'xml') + self.validate_files('map', files, 'maps/scenario.rng') + files = self.find_files(self.vfs_root, self.mods, 'maps/skirmishes/', 'xml') + self.validate_files('map', files, 'maps/scenario.rng') + + def validate_materials(self): + self.logger.info("Validating materials...") + files = self.find_files(self.vfs_root, self.mods, 'art/materials/', 'xml') + self.validate_files('material', files, 'art/materials/material.rng') + + def validate_particles(self): + self.logger.info("Validating particles...") + files = self.find_files(self.vfs_root, self.mods, 'art/particles/', 'xml') + self.validate_files('particle', files, 'art/particles/particle.rng') + + def validate_simulation(self): + self.logger.info("Validating simulation...") + file = self.find_files(self.vfs_root, self.mods, 'simulation/data/pathfinder', 'xml') + self.validate_files('pathfinder', file, 'simulation/data/pathfinder.rng') + file = self.find_files(self.vfs_root, self.mods, 'simulation/data/territorymanager', 'xml') + self.validate_files('territory manager', file, 'simulation/data/territorymanager.rng') + + def validate_soundgroups(self): + self.logger.info("Validating soundgroups...") + files = self.find_files(self.vfs_root, self.mods, 'audio/', 'xml') + self.validate_files('sound group', files, 'audio/sound_group.rng') + + def validate_terrains(self): + self.logger.info("Validating terrains...") + terrains = [file for file in self.find_files(self.vfs_root, self.mods, 'art/terrains/', 'xml') if 'terrains.xml' in str(file[0])] + self.validate_files('terrain', terrains, 'art/terrains/terrain.rng') + terrains_textures = [file for file in self.find_files(self.vfs_root, self.mods, 'art/terrains/', 'xml') if 'terrains.xml' not in str(file[0])] + self.validate_files('terrain texture', terrains_textures, 'art/terrains/terrain_texture.rng') + + def validate_textures(self): + self.logger.info("Validating textures...") + files = [file for file in self.find_files(self.vfs_root, self.mods, 'art/textures/', 'xml') if 'textures.xml' in str(file[0])] + self.validate_files('texture', files, 'art/textures/texture.rng') + + def get_physical_path(self, mod_name, vfs_path): + return realpath(join(self.vfs_root, mod_name, vfs_path)) + + def get_relaxng_file(self, schemapath): + """We look for the highest priority mod relax NG file""" + for mod in self.mods: + relax_ng_path = self.get_physical_path(mod, schemapath) + if exists(relax_ng_path): + return relax_ng_path + + return "" + + def validate_files(self, name, files, schemapath): + relax_ng_path = self.get_relaxng_file(schemapath) + if relax_ng_path == "": + self.logger.warning(f"Could not find {schemapath}") + return + + data = lxml.etree.parse(relax_ng_path) + relaxng = lxml.etree.RelaxNG(data) + error_count = 0 + for file in sorted(files): + try: + doc = lxml.etree.parse(str(file[1])) + relaxng.assertValid(doc) + except Exception as e: + error_count = error_count + 1 + self.logger.error(f"{file[1]}: " + str(e)) + + if self.verbose: + self.logger.info(f"{error_count} {name} validation errors") + elif error_count > 0: + self.logger.error(f"{error_count} {name} validation errors") + + +def get_mod_dependencies(vfs_root, *mods): + modjsondeps = [] + for mod in mods: + mod_json_path = Path(vfs_root) / mod / 'mod.json' + if not exists(mod_json_path): + continue + + with open(mod_json_path, encoding='utf-8') 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 + +if __name__ == '__main__': + script_dir = dirname(realpath(__file__)) + default_root = join(script_dir, '..', '..', '..', 'binaries', 'data', 'mods') + ap = ArgumentParser(description="Validates XML files againt their Relax NG schemas") + ap.add_argument('-r', '--root', action='store', dest='root', default=default_root) + ap.add_argument('-v', '--verbose', action='store_true', default=True, + help="Log validation errors.") + ap.add_argument('-m', '--mods', metavar="MOD", dest='mods', nargs='+', default=['public'], + help="specify which mods to check. Default to public and mod.") + args = ap.parse_args() + mods = list(dict.fromkeys([*args.mods, *get_mod_dependencies(args.root, *args.mods), 'mod']).keys()) + relax_ng_validator = RelaxNGValidator(args.root, mods=mods, verbose=args.verbose) + relax_ng_validator.main() \ No newline at end of file Index: ps/trunk/source/tools/xmlvalidator/validator.py =================================================================== --- ps/trunk/source/tools/xmlvalidator/validator.py +++ ps/trunk/source/tools/xmlvalidator/validator.py @@ -1,10 +1,21 @@ #!/usr/bin/env python3 import argparse import os +import sys import re -import time import xml.etree.ElementTree -from logging import getLogger, StreamHandler, INFO, Formatter +from logging import getLogger, StreamHandler, INFO, WARNING, Formatter, Filter + +class SingleLevelFilter(Filter): + def __init__(self, passlevel, reject): + self.passlevel = passlevel + self.reject = reject + + def filter(self, record): + if self.reject: + return (record.levelno != self.passlevel) + else: + return (record.levelno == self.passlevel) class Actor: def __init__(self, mod_name, vfs_path): @@ -19,7 +30,7 @@ try: tree = xml.etree.ElementTree.parse(physical_path) except xml.etree.ElementTree.ParseError as err: - self.logger.error('"%s": %s\n' % (physical_path, err.msg)) + self.logger.error('"%s": %s' % (physical_path, err.msg)) return False root = tree.getroot() # Special case: particles don't need a diffuse texture. @@ -41,7 +52,7 @@ try: tree = xml.etree.ElementTree.parse(physical_path) except xml.etree.ElementTree.ParseError as err: - self.logger.error('"%s": %s\n' % (physical_path, err.msg)) + self.logger.error('"%s": %s' % (physical_path, err.msg)) return False root = tree.getroot() @@ -64,7 +75,7 @@ try: root = xml.etree.ElementTree.parse(physical_path).getroot() except xml.etree.ElementTree.ParseError as err: - self.logger.error('"%s": %s\n' % (physical_path, err.msg)) + self.logger.error('"%s": %s' % (physical_path, err.msg)) return False for element in root.findall('.//required_texture'): texture_name = element.get('name') @@ -88,10 +99,17 @@ def __init_logger(self): logger = getLogger(__name__) logger.setLevel(INFO) - ch = StreamHandler() + # create a console handler, seems nicer to Windows and for future uses + ch = StreamHandler(sys.stdout) ch.setLevel(INFO) ch.setFormatter(Formatter('%(levelname)s - %(message)s')) + f1 = SingleLevelFilter(INFO, False) + ch.addFilter(f1) logger.addHandler(ch) + errorch = StreamHandler(sys.stderr) + errorch.setLevel(WARNING) + errorch.setFormatter(Formatter('%(levelname)s - %(message)s')) + logger.addHandler(errorch) self.logger = logger def get_mod_path(self, mod_name, vfs_path): @@ -126,6 +144,7 @@ return result def find_materials(self, vfs_path): + self.logger.info('Collecting materials...') material_files = self.find_all_mods_files(vfs_path, re.compile(r'.*\.xml')) for material_file in material_files: material_name = os.path.basename(material_file['vfs_path']) @@ -138,6 +157,8 @@ self.invalid_materials[material_name] = material def find_actors(self, vfs_path): + self.logger.info('Collecting actors...') + actor_files = self.find_all_mods_files(vfs_path, re.compile(r'.*\.xml')) for actor_file in actor_files: actor = Actor(actor_file['mod_name'], actor_file['vfs_path']) @@ -145,12 +166,9 @@ self.actors.append(actor) def run(self): - start_time = time.time() - - self.logger.info('Collecting list of files to check\n') - self.find_materials(os.path.join('art', 'materials')) self.find_actors(os.path.join('art', 'actors')) + self.logger.info('Validating textures...') for actor in self.actors: if not actor.material: @@ -180,9 +198,6 @@ material.name )) - finish_time = time.time() - self.logger.info('Total execution time: %.3f seconds.\n' % (finish_time - start_time)) - if __name__ == '__main__': script_dir = os.path.dirname(os.path.realpath(__file__)) default_root = os.path.join(script_dir, '..', '..', '..', 'binaries', 'data', 'mods')