class WolfTrans::Patch

Public Class Methods

new(game_path, patch_path) click to toggle source
# File lib/wolftrans.rb, line 10
def initialize(game_path, patch_path)
  @strings = Hash.new { |hash, key| hash[key] = Hash.new }
  load_data(game_path)
  load_patch(patch_path)
end

Public Instance Methods

apply(out_dir) click to toggle source

Apply the patch to the files in the game path and write them to the output directory

# File lib/wolftrans/patch_data.rb, line 111
def apply(out_dir)
  out_dir = Util.sanitize_path(out_dir)
  out_data_dir = "#{out_dir}/Data"

  # Clear out directory
  FileUtils.rm_rf(out_dir)
  FileUtils.mkdir_p("#{out_data_dir}/BasicData")

  #TODO create directories for each asset

  # Patch the databases
  @databases.each do |db_name, db|
    db.types.each_with_index do |type, type_index|
      next if type.name.empty?
      type.data.each_with_index do |datum, datum_index|
        datum.each_translatable do |str, field|
          context = Context::Database.from_data(db_name, type_index, type, datum_index, datum, field)
          yield_translation(str, context) do |newstr|
            datum[field] = newstr
          end
        end
      end
    end
    name_noext = "#{out_data_dir}/BasicData/#{db_name}"
    db.dump("#{name_noext}.project", "#{name_noext}.dat")
  end

  # Patch the common events
  @common_events.events.each do |event|
    event.commands.each_with_index do |command, cmd_index|
      context = Context::CommonEvent.from_data(event, cmd_index, command)
      patch_command(command, context)
    end
  end
  @common_events.dump("#{out_data_dir}/BasicData/CommonEvent.dat")

  # Patch Game.dat
  patch_game_dat
  @game_dat.dump("#{out_dir}/#{@game_dat_filename}")

  # Patch all the maps
  @maps.each do |map_name, map|
    map.events.each do |event|
      next unless event
      event.pages.each do |page|
        page.commands.each_with_index do |command, cmd_index|
          context = Context::MapEvent.from_data(map_name, event, page, cmd_index, command)
          patch_command(command, context)
        end
      end
    end
    # Translate path
    assetpath = @assets["mapdata/#{map_name.downcase}.mps"]
    fullpath = "#{out_data_dir}/#{assetpath}"
    map.dump(fullpath)
  end

  # Copy remaining BasicData files
  copy_data_files(Util.join_path_nocase(@game_data_dir, 'basicdata'),
                  ['xxxxx', 'dat', 'project', 'png'],
                  "#{out_data_dir}/BasicData")

  # Copy remaining assets
  @assets.each_pair do |fn, newfn|
    filename = get_asset_filename(fn)
    next unless filename
    FileUtils.cp(filename, "#{out_data_dir}/#{newfn}")
  end

  # Copy fonts
  if @patch_data_dir
    copy_data_files(@patch_data_dir, ['ttf','ttc','otf'], out_data_dir)
  end
  copy_data_files(@game_data_dir, ['ttf','ttc','otf'], out_data_dir)

  # Copy remainder of files in the base patch/game dirs
  copy_files(@patch_assets_dir, out_dir)
  copy_files(@game_dir, out_dir)
end
load_data(game_dir) click to toggle source
# File lib/wolftrans/patch_data.rb, line 12
def load_data(game_dir)
  @game_dir = Util.sanitize_path(game_dir)
  unless Dir.exist? @game_dir
    raise "could not find game folder '#{@game_dir}'"
  end
  @game_data_dir = Util.join_path_nocase(@game_dir, 'data')
  if @game_data_dir == nil
    raise "could not find Data folder in '#{@game_dir}'"
  end

  # Load databases, Game.dat, and common events
  @databases = {}
  basicdata_dir = Util.join_path_nocase(@game_data_dir, 'basicdata')
  if basicdata_dir == nil
    raise "could not find BasicData folder in '#{@game_data_dir}'"
  end
  Dir.entries(basicdata_dir).each do |entry|
    entry_downcase = entry.downcase
    filename = "#{basicdata_dir}/#{entry}"
    if entry_downcase == 'game.dat'
      @game_dat_filename = 'Data/BasicData/Game.dat'
      load_game_dat(filename)
    elsif entry_downcase.end_with?('.project')
      next if entry_downcase == 'sysdatabasebasic.project'
      basename = File.basename(entry_downcase, '.*')
      dat_filename = Util.join_path_nocase(basicdata_dir, "#{basename}.dat")
      next if dat_filename == nil
      load_game_database(filename, dat_filename)
    elsif entry_downcase == 'commonevent.dat'
      load_common_events(filename)
    end
  end

  # Game.dat is in a different place on older versions
  unless @game_dat
    Dir.entries(@game_dir).each do |entry|
      if entry.downcase == 'game.dat'
        @game_dat_filename = 'Game.dat'
        load_game_dat("#{@game_dir}/#{entry}")
        break
      end
    end
  end

  # Gather list of asset and map filenames
  map_names = Set.new
  @assets = {}
  @databases.each_value do |db|
    db.each_filename do |fn|
      fn_downcase = fn.downcase
      @assets[fn_downcase] = fn
      if fn_downcase.end_with?('.mps')
        map_names.add(File.basename(fn_downcase, '.*'))
      end
    end
  end
  @game_dat.each_filename do |fn|
    @assets[fn.downcase] = fn
  end
  @common_events.each_filename do |fn|
    @assets[fn.downcase] = fn
  end

  # Load maps
  maps_path = Util.join_path_nocase(@game_data_dir, 'mapdata')
  if maps_path == nil
    raise "could not find MapData folder in '#{@game_data_dir}'"
  end
  @maps = {}
  map_names.each do |name|
    map_path = Util.join_path_nocase(maps_path, name + '.mps')
    if map_path == nil
      STDERR.puts "warn: could not find map '#{name}'"
      next
    end
    load_map(map_path)
  end

  # Gather remaining asset filenames
  @maps.each_value do |map|
    map.each_filename do |fn|
      @assets[fn.downcase] = fn
    end
  end

  # Make sure not to treat certain kinds of filenames as assets
  @assets.reject! { |k, v| k.start_with?('save/') }

  # Rewrite asset filenames
  extcounts = Hash.new(0)
  @assets.keys.sort.each do |fn|
    ext = File.extname(fn)[1..-1]
    @assets[fn] = '%04d.%s' % [extcounts[ext], ext]
    extcounts[ext] += 1
  end
end
load_patch(patch_dir) click to toggle source

Loading Patch data #

# File lib/wolftrans/patch_text.rb, line 11
def load_patch(patch_dir)
  @patch_dir = Util.sanitize_path(patch_dir)
  @patch_assets_dir = "#{@patch_dir}/Assets"
  @patch_strings_dir = "#{@patch_dir}/Patch"

  # Make sure these directories all exist
  [@patch_assets_dir, @patch_strings_dir].each do |dir|
    FileUtils.mkdir_p dir
  end

  # Find data dir
  @patch_data_dir = Util.join_path_nocase(@patch_assets_dir, 'data')

  # Load blacklist
  @file_blacklist = []
  if File.exists? "#{patch_dir}/blacklist.txt"
    Util.read_txt("#{patch_dir}/blacklist.txt").each_line do |line|
      line.strip!
      next if line.empty?
      if line.include? '\\'
        raise "file specified in blacklist contains a backslash (use a forward slash instead)"
      end
      @file_blacklist << line.downcase!
    end
  end

  # Load strings
  Find.find(@patch_strings_dir) do |path|
    next if FileTest.directory? path
    next unless File.extname(path).casecmp '.txt'
    process_patch_file(path, :load)
  end

  # Write back to patch files
  processed_filenames = []

  Find.find(@patch_strings_dir) do |path|
    next if FileTest.directory? path
    next unless File.extname(path).casecmp '.txt'
    process_patch_file(path, :update)
    processed_filenames << path[@patch_strings_dir.length+1..-1]
  end

  # Now "process" any files that should be generated
  @strings.each do |string, contexts|
    contexts.each do |context, trans|
      unless processed_filenames.include? trans.patch_filename
        process_patch_file("#{@patch_strings_dir}/#{trans.patch_filename}", :update)
        processed_filenames << trans.patch_filename
      end
    end
  end
end
process_patch_file(filename, mode) click to toggle source

Load the translation strings indicated in the patch file, generate a new patch file with updated context information, and overwrite the patch

# File lib/wolftrans/patch_text.rb, line 68
def process_patch_file(filename, mode)
  patch_filename = filename[@patch_strings_dir.length+1..-1]

  txt_version = nil

  # Parser state information
  state = :expecting
  original_string = ''
  contexts = []
  translated_string = ''
  new_contexts = nil

  # Variables for the revised patch
  context_comments = {}

  # The revised patch
  output = ''
  output_write = false
  pristine_translated_string = ''

  if File.exists? filename
    output_write = true if mode == :update
    Util.read_txt(filename).each_line.with_index do |pristine_line, index|
      # Remove comments and strip
      pristine_line.gsub!(/\n$/, '')
      line = pristine_line.gsub(/(?!\\)#.*$/, '').rstrip
      comment = pristine_line.match(/(?<!\\)#.*$/).to_s.rstrip
      line_num = index + 1

      if line.start_with? '>'
        instruction = line.gsub(/^>\s+/, '')

        # Parse the patch version
        parse_instruction(instruction, 'WOLF TRANS PATCH FILE VERSION') do |args|
          unless txt_version == nil
            raise "two version strings in file (line #{line_num})"
          end
          txt_version = Version.new(str: args.first)
          if txt_version > TXT_VERSION
            raise "patch version (#{new_version}) newer than can be read (#{TXT_VERSION})"
          end
          if mode == :update
            output << "> WOLF TRANS PATCH FILE VERSION #{TXT_VERSION}"
            output << comment unless comment.empty?
            output << "\n"
          end
        end

        # Make sure we have a version specified before reading other instructions
        if txt_version == nil
          raise "no version specified before first instruction"
        end

        # Now parse the instructions
        parse_instruction(instruction, 'BEGIN STRING') do |args|
          unless state == :expecting
            raise "began another string without ending previous string (line #{line_num})"
          end
          state = :reading_original
          original_string = ''
          if mode == :update
            output << pristine_line << "\n"
          end
        end

        parse_instruction(instruction, 'END STRING') do |args|
          if state == :expecting
            raise "ended string without a begin (line #{line_num})"
          elsif state == :reading_original
            raise "ended string without a translation block (line #{line_num})"
          end
          state = :expecting
          new_contexts = []
        end

        parse_instruction(instruction, 'CONTEXT') do |args|
          if state == :expecting
            raise "context outside of begin/end block (line #{line_num})"
          end
          if args.empty?
            raise "no context string provided in context line (line #{line_num})"
          end

          # After a context, we're no longer reading the original text.
          state = :reading_translation
          begin
            new_context = Context.from_string(args.shift)
          rescue => e
            raise e, "#{e} (line #{line_num})", e.backtrace
          end
          # Append context if translated_string is empty, since that means
          # no translation was given.
          if translated_string.empty?
            contexts << new_context
          else
            new_contexts = [new_context]
          end
          if mode == :update
            # Save the comment for later
            context_comments[new_context] = comment
          end
        end

        # If we have a new context list queued, flush the translation to all
        # of the collected contexts
        if new_contexts
          original_string_new = unescape_string(original_string, false)
          translated_string_new = unescape_string(translated_string, true)
          contexts.each do |context|
            if mode == :update
              # Write an appropriate context line to the output
              output << '> CONTEXT '
              if @strings.include?(original_string_new) &&
                  @strings[original_string_new].include?(context)
                output << @strings[original_string_new].select { |k,v| k.eql? context }.keys.first.to_s
                output << ' < UNTRANSLATED' if translated_string_new.empty?
              else
                output << context.to_s << ' < UNUSED'
              end
              output << " " << context_comments[context] unless comment.empty?
              output << "\n"
            else
              # Put translation in hash
              @strings[original_string_new][context] = Translation.new(patch_filename, translated_string_new, false)
            end
          end
          if mode == :update
            # Write the translation
            output << pristine_translated_string.rstrip << "\n"
            # If the state is "expecting", that means we need to write the END STRING
            # line to the output too.
            if state == :expecting
              output << pristine_line << "\n"
            end
          end

          # Reset variables for next read
          translated_string = ''
          pristine_translated_string = ''
          contexts = new_contexts

          new_contexts = nil
        end
      else
        # Parse text
        if state == :expecting
          unless line.empty?
            raise "stray text outside of begin/end block (line #{line_num})"
          end
        elsif state == :reading_original
          original_string << line << "\n"
        elsif state == :reading_translation
          translated_string << line << "\n"
          if mode == :update
            pristine_translated_string << pristine_line << "\n"
          end
        end
        # Make no modifications to the patch line if we're not reading translations
        unless state == :reading_translation
          if mode == :update
            output << pristine_line << "\n"
          end
        end
      end
    end

    # Final error checking
    if state != :expecting
      raise "final begin/end block has no end"
    end
  else
    # It's a new file, so just stick a header on it
    if mode == :update
      output << "> WOLF TRANS PATCH FILE VERSION #{TXT_VERSION}\n"
    end
  end

  if mode == :update
    # Write all the new strings to the file
    @strings.each do |orig_string, contexts|
      if contexts.values.any? { |trans| trans.autogenerate? && trans.patch_filename == patch_filename }
        output_write = true
        output << "\n> BEGIN STRING\n#{escape_string(orig_string)}\n"
        contexts.each do |context, trans|
          next unless trans.autogenerate?
          trans.autogenerate = false
          output << "> CONTEXT " << context.to_s << " < UNTRANSLATED\n"
        end
        output << "\n> END STRING\n"
      end
    end

    # Write the output to the file
    if output_write
      FileUtils.mkdir_p(File.dirname(filename))
      File.open(filename, 'wb') { |file| file.write(output) }
    end
  end
end

Private Instance Methods

copy_data_files(src_dir, extensions, out_dir) click to toggle source

Copy data files

# File lib/wolftrans/patch_data.rb, line 367
def copy_data_files(src_dir, extensions, out_dir)
  Dir.entries(src_dir).each do |entry|
    # Don't care about directories
    next if entry == '.' || entry == '..'
    path = "#{src_dir}/#{entry}"
    next if FileTest.directory? path

    # Skip invalid file extensions
    next unless extensions.include? File.extname(entry)[1..-1]

    # Copy the file if it doesn't already exist
    next if Util.join_path_nocase(out_dir, entry)

    FileUtils.cp(path, "#{out_dir}/#{entry}")
  end
end
copy_files(src_dir, out_dir) click to toggle source

Copy normal, non-data files

# File lib/wolftrans/patch_data.rb, line 342
def copy_files(src_dir, out_dir)
  Find.find(src_dir) do |path|
    basename = File.basename(path)
    basename_downcase = basename.downcase

    # Don't do anything in Data/
    Find.prune if basename_downcase == 'data' && File.dirname(path) == src_dir

    # Skip directories
    next if FileTest.directory? path

    # "Short name", relative to the game base dir
    short_path = path[src_dir.length+1..-1]
    Find.prune if @file_blacklist.include? short_path.downcase

    out_path = "#{out_dir}/#{short_path}"
    next if ['thumbs.db', 'desktop.ini', '.ds_store'].include? basename_downcase
    next if File.exist? out_path
    # Make directory only only when copying a file to avoid making empty directories
    FileUtils.mkdir_p(File.dirname(out_path))
    FileUtils.cp(path, out_path)
  end
end
escape_string(string) click to toggle source

Escapes a string for writing into a patch file

# File lib/wolftrans/patch_text.rb, line 305
def escape_string(string)
  string = string.gsub("\\", "\x00") #HACK
  string.gsub!("\t", "\\t")
  string.gsub!("\r", "\\r")
  string.gsub!(">", "\\>")
  string.gsub!("#", "\\#")
  string.gsub!("\x00") { "\\\\" } #HACK
  # Replace trailing spaces with \s
  string.gsub!(/ *$/) { |m| "\\s" * m.length }
  # Replace trailing newlines with \n
  string.gsub!(/\n*\z/m) { |m| "\\n" * m.length }
  string
end
get_asset_filename(fn) click to toggle source

Get a full filename based on a short asset filename

# File lib/wolftrans/patch_data.rb, line 385
def get_asset_filename(fn)
  # Find the correct filename case
  dirname, basename = fn.split('/')
  if @patch_data_dir
    path = Util.join_path_nocase(@patch_data_dir, dirname)
    if path
      path = Util.join_path_nocase(path, basename)
      return path if path
    end
  end
  path = Util.join_path_nocase(@game_data_dir, dirname)
  if path
    path = Util.join_path_nocase(path, basename)
    return path if path
  end
  return nil
end
load_common_events(filename) click to toggle source
# File lib/wolftrans/patch_data.rb, line 248
def load_common_events(filename)
  @common_events = WolfRpg::CommonEvents.new(filename)
  @common_events.events.each do |event|
    patch_filename = "dump/common/#{'%03d' % event.id}_#{Util.escape_path(event.name)}.txt"
    event.commands.each_with_index do |command, cmd_index|
      strings_of_command(command) do |string|
        @strings[string][Context::CommonEvent.from_data(event, cmd_index, command)] ||=
          Translation.new(patch_filename)
      end
    end
  end
end
load_game_dat(filename) click to toggle source
# File lib/wolftrans/patch_data.rb, line 211
def load_game_dat(filename)
  patch_filename = 'dump/GameDat.txt'
  @game_dat = WolfRpg::GameDat.new(filename)
  unless @game_dat.title.empty?
    @strings[@game_dat.title][Context::GameDat.from_data('Title')] = Translation.new(patch_filename)
  end
  unless @game_dat.version.empty?
    @strings[@game_dat.version][Context::GameDat.from_data('Version')] = Translation.new(patch_filename)
  end
  unless @game_dat.font.empty?
    @strings[@game_dat.font][Context::GameDat.from_data('Font')] = Translation.new(patch_filename)
  end
  @game_dat.subfonts.each_with_index do |sf, i|
    unless sf.empty?
      name = 'SubFont' + (i + 1).to_s
      @strings[sf][Context::GameDat.from_data(name)] ||=
        Translation.new(patch_filename)
    end
  end
end
load_game_database(project_filename, dat_filename) click to toggle source
# File lib/wolftrans/patch_data.rb, line 232
def load_game_database(project_filename, dat_filename)
  db_name = File.basename(project_filename, '.*')
  db = WolfRpg::Database.new(project_filename, dat_filename)
  db.types.each_with_index do |type, type_index|
    next if type.name.empty?
    patch_filename = "dump/db/#{db_name}/#{Util.escape_path(type.name)}.txt"
    type.data.each_with_index do |datum, datum_index|
      datum.each_translatable do |str, field|
        context = Context::Database.from_data(db_name, type_index, type, datum_index, datum, field)
        @strings[str][context] ||= Translation.new(patch_filename)
      end
    end
  end
  @databases[db_name] = db
end
load_map(filename) click to toggle source
# File lib/wolftrans/patch_data.rb, line 192
def load_map(filename)
  map_name = File.basename(filename, '.*')
  patch_filename = "dump/mps/#{map_name}.txt"

  map = WolfRpg::Map.new(filename)
  map.events.each do |event|
    next unless event
    event.pages.each do |page|
      page.commands.each_with_index do |command, cmd_index|
        strings_of_command(command) do |string|
          @strings[string][Context::MapEvent.from_data(map_name, event, page, cmd_index, command)] ||=
            Translation.new(patch_filename)
        end
      end
    end
  end
  @maps[map_name] = map
end
parse_instruction(instruction, tested_instruction) { || ... } click to toggle source

Yields the arguments of the instruction if it matches tested_instruction

# File lib/wolftrans/patch_text.rb, line 270
def parse_instruction(instruction, tested_instruction)
  if instruction.start_with? tested_instruction
    args = instruction.slice(tested_instruction.length..-1).strip.split(/\s+<\s+/)
    if args.first == nil || args.first.empty?
      yield []
    else
      yield args
    end
  end
end
patch_command(command, context) click to toggle source
# File lib/wolftrans/patch_data.rb, line 282
def patch_command(command, context)
  case command
  when WolfRpg::Command::Message
    yield_translation(command.text, context) do |str|
      command.text = str
    end
  when WolfRpg::Command::Choices
    command.text.each_with_index do |text, i|
      yield_translation(text, context) do |str|
        command.text[i] = str
      end
    end
  when WolfRpg::Command::StringCondition
    command.string_args.each_with_index do |arg, i|
      next if arg.empty?
      yield_translation(arg, context) do |str|
        command.string_args[i] = str
      end
    end
  when WolfRpg::Command::SetString
    yield_translation(command.text, context) do |str|
      command.text = str
    end
  when WolfRpg::Command::Picture
    if command.type == :text
      yield_translation(command.text, context) do |str|
        command.text = str
      end
    end
  end
end
patch_game_dat() click to toggle source
# File lib/wolftrans/patch_data.rb, line 314
def patch_game_dat
  yield_translation(@game_dat.title, Context::GameDat.from_data('Title')) do |str|
    @game_dat.title = str
  end
  yield_translation(@game_dat.version, Context::GameDat.from_data('Version')) do |str|
    @game_dat.version = str
  end
  yield_translation(@game_dat.font, Context::GameDat.from_data('Font')) do |str|
    @game_dat.font = str
  end
  @game_dat.subfonts.each_with_index do |sf, i|
    name = 'SubFont' + (i + 1).to_s
    yield_translation(sf, Context::GameDat.from_data(name)) do |str|
      @game_dat.subfonts[i] = str
    end
  end
end
strings_of_command(command) { |text| ... } click to toggle source
# File lib/wolftrans/patch_data.rb, line 261
def strings_of_command(command)
  case command
  when WolfRpg::Command::Message
    yield command.text if Util.translatable? command.text
  when WolfRpg::Command::Choices
    command.text.each do |s|
      yield s if Util.translatable? s
    end
  when WolfRpg::Command::StringCondition
    command.string_args.each do |s|
      yield s if Util.translatable? s
    end
  when WolfRpg::Command::SetString
    yield command.text if Util.translatable? command.text
  when WolfRpg::Command::Picture
    if command.type == :text
      yield command.text if Util.translatable? command.text
    end
  end
end
unescape_string(string, do_rtrim) click to toggle source

Unescapes a patch file string

# File lib/wolftrans/patch_text.rb, line 282
def unescape_string(string, do_rtrim)
  # Remove trailing whitespace or just newline
  if do_rtrim
    string = string.rstrip
  else
    string = string.gsub(/\n\z/m, '')
  end
  # Change escape sequences
  string.match(/(?<!\\)\\[^sntr><#\\]/) do |match|
    raise "unknown escape sequence '#{match}' #{string}"
  end
  string.gsub!("\\\\", "\x00") #HACK
  string.gsub!("\\s", " ")
  string.gsub!("\\n", "\n")
  string.gsub!("\\t", "\t")
  string.gsub!("\\r", "\r")
  string.gsub!("\\>", ">")
  string.gsub!("\\#", "#")
  string.gsub!("\x00") { "\\" } #HACK
  string
end
yield_translation(string, context) { |str| ... } click to toggle source

Yield a translation for the given string and context if it exists

# File lib/wolftrans/patch_data.rb, line 333
def yield_translation(string, context)
  return unless Util.translatable? string
  if @strings.include? string
    str = @strings[string][context].string
    yield str if Util.translatable? str
  end
end