Data-SCS-DefParser

 view release on metacpan or  search on metacpan

lib/Data/SCS/DefParser.pm  view on Meta::CPAN


# The list of directories or archives to mount.
field @mounts :reader;

# The data source to use (game name, game/sii directory, array reference
# of mountable paths, Archive::SCS instance).
field $mount :param;

# The list of def file names to parse.
field @filenames = (
  "def/country.sii",
  "def/city.sii",
  "def/company.sii",
);

field $archive;
field %archive_has_entry;
field @company_files;

ADJUST :params ( :$parse = undef ) {
  if (defined $parse) {
    @filenames = ref $parse eq 'ARRAY' ? $parse->@* : $parse;
    @filenames or croak '"parse" cannot be an empty array';
  }
  if (ref $mount eq 'ARRAY') {
    @mounts = $mount->@*;
    @mounts or croak '"mount" cannot be an empty array';
  }
  elsif ($mount isa Archive::SCS) {
    $archive = $mount;
  }
  elsif (defined $mount) {
    $self->init_def($mount);
  }
  else {
    croak '"mount" cannot be undef';
  }
}


sub trim :prototype($) {
  my $str = shift;
  $str =~ s/^\s+//s;
  $str =~ s/\s+$//s;
  return $str;
}


sub parse_block {
  my $data = shift;
  my ($pre, $in) = $data =~ m/^([^\{]*)\{(.*)\}/s;
  return (trim $pre, trim $in);
}


method include_file ($file) {
  $archive_has_entry{$file} or croak
    sprintf "Couldn't find file '%s' in: %s", $file, join ", ", @mounts;
  my $inc = $archive->read_entry($file);
  utf8::decode($inc);
  my @inc = grep {$_} map {trim $_} split m/\n/, $inc;
  return @inc;
}


method parse_sii ($file) {
  utf8::decode my $sii = $archive->read_entry($file);
  my ($magic, $unit) = parse_block $sii;
  $magic =~ m/^ \N{ BYTE ORDER MARK }? SiiNunit $/x or die
    sprintf "Expected SiiNunit, found '%s' in %s", $magic, $file;
  my @input = grep {$_} map {trim $_} split m/\n/, $unit;
  my @lines;
  while (my $line = shift @input) {
    if (my ($inc) = $line =~ m/^\@include\s+"([^"]+)"$/) {
      my $inc_path = path("/$file")->parent->relative("/")->child($inc);
      unshift @input, $self->include_file($inc_path);
      next;
    }
    push @lines, $line;
  }
  @lines = map {trim $_} map {
    s{/\* .*? \*/}{}gx;
    m{/\*|\*/} and die "Multi-line comments unimplemented";
    # clip comments
    s{#.*$|//.*$}{}r;
  } @lines;
  @lines = grep {$_} map {trim $_} map {
    # make sure { and } stand by their own on a line
    my @line = ($_);
    while ($line[$#line] =~ m/^([^\{]+)([\{\}])(.*)/) {
      pop @line;
      push @line, $1, $2, $3;
    }
    @line;
  } @lines;
  return @lines;
}


sub parse_sui_data_value {
  my $value = shift;
  if ( $value =~ m/^&([0-9A-Fa-f]{8})$/ ) {  # IEEE 754 binary32 float
    return 'Inf' if lc $1 eq '7f7fffff';  # max finite value / no data marker
    return sprintf '%.9g', unpack 'f', pack 'h8', scalar reverse $1;
    # 9 significant digits are sufficient to represent any 32-bit float.
  }
  if ( $value =~ m/^\(([^()]+)\)$/ ) {
    return join ', ', map { parse_sui_data_value( trim $_ ) } split m/,/, $1;
  }
  if ( $value =~ m/^"([^"]+)"$/ ) {
    my $str = $1 =~ s{ \\x( [0-9A-Fa-f]{2} ) }{ chr hex $1 }egrx;
    utf8::decode $str;
    return $str;
  }
  if ( $value =~ m/^0x( [0-9A-Fa-f]{6,8} )$/x ) {
    return $1;
  }
  if ( $value eq 'true' ) {
    no warnings 'experimental::builtin';
    return builtin::true;
  }
  if ( $value eq 'false' ) {
    no warnings 'experimental::builtin';
    return builtin::false;
  }
  if ( Scalar::Util::looks_like_number $value ) {
    return 0 + $value;
  }
  if ( $value =~ m/^(\S+)$/ ) {
    return $1;
  }
  die "Unknown value format: '$value'";
}


sub parse_sui_data {
  my ($ats_data, $key, @raw) = @_;
  my $data = {};
  # parse key and insert data
  my ($type, $path) = $key =~ m/^(\S+)\s*:\s+(\S+)$/;
  if ($tidy) {
    # skip currently useless clutter
    return if $type eq 'license_plate_data';
  }
  # parse block contents
  for (@raw) {
    if ($tidy) {
      # skip currently useless clutter
      next if /city_name_localized/ || /sort_name/ || /time_zone/;
      next if /city_pin_scale_factor/;
      next if /map_._offsets/ || /license_plate/;
      next if $type eq 'prefab_model' && (/model_desc/ || /semaphore_profile/ || /use_semaphores/ || /gps_avoid/ || /use_perlin/ || /detail_veg_max_distance/ || /traffic_rules_input/ || /traffic_rules_output/ || /invisible/ || /category/ || /tweak_de...
      next if $type eq 'prefab_model' && (/dynamic_lod_/ || /corner\d/);  # code dies for these; not sure why
    }
    if (/(\w+)\s*:\s*(.+)$/) {
      $data->{$1} = parse_sui_data_value $2;
      next;
    }
    if (/(\w+)\[(\d*)\]\s*:\s*(.+)$/) {
      # init array, overwriting scalar array size if present
      $data->{$1} = [] unless ref $data->{$1};
      if (length $2) {
        $data->{$1}[0+$2] = parse_sui_data_value $3;
      }
      else {
        push @{$data->{$1}}, parse_sui_data_value $3;
      }
      next;
    }
    die "Unkown data format: '$_'";
  }
  #$data->{_raw} = [@raw];
  #$data->{_key_raw} = $key;
  #$data->{_type} = $type;
  if ($path =~ m/^[\.\w]+$/) {
    my $hashpath = $path =~ s/\./'}{'/gr;
    $hashpath =~ s/^\'}/_$type'}/;
    eval "\$ats_data->{'$hashpath'} = \$data";
  }
  else {
    die "Unimplemented path '$path'";
  }
}


sub parse_sui_blocks {
  my ($ats_data, @lines) = @_;
  my $block = 0;
  my @raw;
  my $key;
  for my $i (0..$#lines) {
    0 <= $block <= 1 or die $block;
    if ($lines[$i] eq '{') {
      $block++;
      $key = $lines[$i-1];
      @raw = ();
      next;
    }
    if ($lines[$i] eq '}') {
      parse_sui_data $ats_data, $key, @raw;
      $key = undef;
      $block--;
      next;
    }
    if ($block && $lines[$i] !~ m/"/ && $lines[$i] =~ m/:/) {  # parse Reforma one-liners
      push @raw, split m/(?<=[a-z])\s+/, $lines[$i];
      next;
    }
    if ($block) {
      push @raw, $lines[$i];
      next;
    }
  }
}


method init_def ($source) {
  my $is_path = $source isa Path::Tiny || $source =~ m|/|;
  if ( $is_path && path($source)->realpath->is_dir ) {
    @mounts = sort map { "$_" } path( $source )->children( qr/^def|^dlc_/ );

    # ATS_DB originally expected def.scs to be extracted directly into the
    # source dir. In this legacy case, the source dir must be mounted first.
    my $def_dir = "$source/def";
    if ( path($def_dir)->is_dir && ! path($def_dir)->child('def')->is_dir ) {
      @mounts = ( $source, grep { $_ ne $def_dir } @mounts );
    }
  }
  else {  # $source is abstract, e.g. 'ATS'
    my $gamedir = Archive::SCS::GameDir->new(game => $source);
    @mounts = grep { /^def|^dlc_/ } $gamedir->archives;

    if ( $gamedir->game =~ m/^A/i ) {
      # The DLC file names for ATS are well-known; limiting the mounts
      # to essentially the ones suggested by country.sii saves a bunch of time.
      # Using only the DLC file list for this would be unreliable unless you
      # always run the latest game version and always get all DLC immediately.
      my %countries = Data::SCS::DefParser->new(
        mount => $gamedir->mounted('def.scs'),
        parse => 'def/country.sii',
      )->raw_data->{country}{data}->%*;
      my %mount;
      $mount{$_}++ for (
        'def.scs',
        'dlc_kenworth_t680.scs',
        'dlc_peterbilt_579.scs',
        'dlc_westernstar_49x.scs',
        'dlc_arizona.scs',
        'dlc_nevada.scs',
        map { lc sprintf 'dlc_%s.scs', $_->{country_code} } values %countries,
      );
      @mounts = grep { $mount{$_} } $gamedir->archives;
    }

    @mounts = map { $gamedir->path->child($_)->stringify } @mounts;
  }
}


method sii_files () {
  my @files = grep $archive_has_entry{$_}, @filenames;
  for my $path (@mounts) {
    # Include files from DLCs, with file names containing the DLC archive name.
    my $dlc_name = path($path)->basename =~ s/\.scs$//r;
    push @files, grep $archive_has_entry{$_}, map { s/\.sii$/.$dlc_name.sii/r } @filenames;
  }



( run in 1.064 second using v1.01-cache-2.11-cpan-71847e10f99 )