فهرست منبع

Installer: presets, background install, video disks, audio tool finder

Rework the data installer so it works from a single installation
directory and is easier and safer to use.

Installation layout
- One installation directory (also the game's working directory):
  game data goes to <dir>/data, intermediates to <dir>/temp. Old
  ConfigDir/DataDir settings are migrated to InstallDir.
- "Copy V-Gears data files" copies the repository data/ folder to the
  installation directory, and any missing Ogre plugin from the system.
- Install the world map .MAP files the game reads from data/wm.

Usability
- Presets ("What to install"), option dependency rules, and a disk
  space estimate. Options are only editable with "Custom".
- Preflight checks before extracting anything (ISO, directory, free
  space, plugins, world map needing fields).
- Installation runs in a worker thread; unified colored log written to
  installer.log; per-step problem summaries.
- Verify install and Launch game buttons, Close button, About and Help
  dialogs. Remove the unused V-Gears Config tab.
- Install button is disabled after an installation starts, as the
  installer can only run once per window.

Conversion
- Unique material names per model and per-model cleanup of skeletons,
  fixing clashes between field, battle and world models that crashed
  or skipped models. Refresh the char resource location so field
  models are generated on a clean install.
- Audio conversion runs in a task pool in the background.
- Find FFmpeg and TiMidity (saved path, tools folders, PATH; Windows
  rules for PATHEXT), probe them, and run them without a shell. Warn
  before installing if they are missing and let the user locate them
  or continue without. Skipped audio keeps its originals and the
  sound/music indexes only list files that exist.
- Accept PC game disks 1-3 and extract their videos to data/video.
  Remove the FFmpeg/TiMidity checkboxes.

Data fixes
- Fix the font definition path, and the resources.cfg that defined
  every material twice. Remove stale files from data/.

Tests, docs and packaging
- Add v-gears-installer-tests (layout, option rules, verifier, log,
  task pool, video paths, tool finder).
- Update the installation, build and run guides.
- Fix the desktop file and the Debian menu, control, changelog and
  install list so the game starts from the installation directory.
- Stop tracking the generated ui_MainWindow.h.
Iñigo Valentin 1 روز پیش
والد
کامیت
dc8a7b4a96
50فایلهای تغییر یافته به همراه4746 افزوده شده و 1749 حذف شده
  1. 4 1
      .gitignore
  2. 2 2
      data/data/system/system.fontdef
  3. 0 18
      data/plugins.cfg_win32
  4. 6 6
      data/resources.cfg
  5. 0 6
      data/resources.cfg_linux
  6. 0 461
      data/system.fontdef
  7. 0 0
      data/v-gears.cfg_linux
  8. 1 1
      debian/changelog
  9. 3 3
      debian/control
  10. 1 2
      debian/install
  11. 1 1
      debian/menu
  12. 19 3
      doc/BUILD.md
  13. 3 7
      doc/INSTALL.md
  14. 39 51
      doc/INSTALL_DATA.md
  15. 2 0
      doc/RUN.md
  16. 16 16
      src/installer/BattleDataInstaller.cpp
  17. 5 0
      src/installer/BattleDataInstaller.h
  18. 7 0
      src/installer/CMakeLists.txt
  19. 87 68
      src/installer/DataInstaller.cpp
  20. 21 14
      src/installer/DataInstaller.h
  21. 126 9
      src/installer/DiskImage.cpp
  22. 25 0
      src/installer/DiskImage.h
  23. 325 0
      src/installer/ExternalTools.cpp
  24. 220 0
      src/installer/ExternalTools.h
  25. 50 34
      src/installer/FieldDataInstaller.cpp
  26. 12 0
      src/installer/FieldDataInstaller.h
  27. 79 0
      src/installer/InstallLayout.cpp
  28. 82 0
      src/installer/InstallLayout.h
  29. 178 0
      src/installer/InstallLog.cpp
  30. 141 0
      src/installer/InstallLog.h
  31. 128 0
      src/installer/InstallOptions.cpp
  32. 120 0
      src/installer/InstallOptions.h
  33. 184 0
      src/installer/InstallVerifier.cpp
  34. 68 0
      src/installer/InstallVerifier.h
  35. 881 88
      src/installer/MainWindow.cpp
  36. 164 26
      src/installer/MainWindow.h
  37. 201 178
      src/installer/MainWindow.ui
  38. 103 38
      src/installer/MediaDataInstaller.cpp
  39. 51 16
      src/installer/MediaDataInstaller.h
  40. 52 0
      src/installer/ModelMaterials.h
  41. 117 0
      src/installer/TaskPool.h
  42. 48 0
      src/installer/VideoFiles.cpp
  43. 42 0
      src/installer/VideoFiles.h
  44. 61 46
      src/installer/WorldInstaller.cpp
  45. 5 0
      src/installer/WorldInstaller.h
  46. 0 653
      src/installer/ui_MainWindow.h
  47. 16 0
      test/CMakeLists.txt
  48. 468 0
      test/installer/ExternalTools.cpp
  49. 581 0
      test/installer/InstallLogic.cpp
  50. 1 1
      v-gears.desktop

+ 4 - 1
.gitignore

@@ -25,5 +25,8 @@ doc/FFVII/RAW/
 .cproject
 .directory
 v-gears-new*.png
-/.vscode/c_cpp_properties.json
+.vscode
 src/installer/MainWindow.ui.autosave
+
+# Generated from MainWindow.ui at build time.
+src/installer/ui_MainWindow.h

+ 2 - 2
data/data/system/system.fontdef

@@ -1,7 +1,7 @@
 font FFVIIFont
 {
     type image
-    source system/courier_new_9_75.png
+    source courier_new_9_75.png
 
     glyph u32 0.015625 0.125 0.046875 0.1875
     glyph u33 0.078125 0.125 0.109375 0.1875
@@ -233,7 +233,7 @@ font FFVIIFont
 font CourierNew
 {
     type image
-    source system/courier_new_9_75.png
+    source courier_new_9_75.png
 
     glyph u32 0.015625 0.125 0.046875 0.1875
     glyph u33 0.078125 0.125 0.109375 0.1875

+ 0 - 18
data/plugins.cfg_win32

@@ -1,18 +0,0 @@
-# Defines plugins to load
-
-# Define plugin folder
-PluginFolder=.
-
-# Define plugins
-# Plugin=RenderSystem_Direct3D9
-# Plugin=RenderSystem_Direct3D11
- Plugin=RenderSystem_GL
-# Plugin=RenderSystem_GLES
-# Plugin=RenderSystem_GLES2
-# Plugin=Plugin_ParticleFX
-# Plugin=Plugin_BSPSceneManager
-# Plugin=Plugin_CgProgramManager
-# Plugin=Plugin_PCZSceneManager
-# Plugin=Plugin_OctreeZone
-# Plugin=Plugin_OctreeSceneManager
-# Plugin=Plugin_Data

+ 6 - 6
data/resources.cfg

@@ -1,6 +1,6 @@
 # alternative ressources directly from the game files
 [Game]
-FileSystem=data
+#FileSystem=data
 
 [FFVII_World]
 #LGP=~/.wine/drive_c/FF7/data/wm/world_us.lgp
@@ -8,13 +8,13 @@ FileSystem=data
 [FFVIIFields]
 
 [FFVIITextures]
-FileSystem=data/models/field/entities/
-FileSystem=data/models/battle/enemy/
-FileSystem=data/models/battle/char/
-FileSystem=data/models/battle/scene/
+#FileSystem=data/models/field/entities/
+#FileSystem=data/models/battle/enemy/
+#FileSystem=data/models/battle/char/
+#FileSystem=data/models/battle/scene/
 
 [Images]
-FileSystem=data/images/
+#FileSystem=data/images/
 
 [General]
 #FileSystem=/home/ivalentin/.q-gears/

+ 0 - 6
data/resources.cfg_linux

@@ -1,6 +0,0 @@
-# alternative ressources directly from the game files
-[Game]
-#FileSystem=data
-
-[FFVII_World]
-#LGP=~/.wine/drive_c/FF7/data/wm/world_us.lgp

+ 0 - 461
data/system.fontdef

@@ -1,461 +0,0 @@
-font FFVIIFont
-{
-    type image
-    source system/courier_new_9_75.png
-
-    glyph u32 0.015625 0.125 0.046875 0.1875
-    glyph u33 0.078125 0.125 0.109375 0.1875
-    glyph u34 0.140625 0.125 0.171875 0.1875
-    glyph u35 0.203125 0.125 0.234375 0.1875
-    glyph u36 0.265625 0.125 0.296875 0.1875
-    glyph u37 0.328125 0.125 0.359375 0.1875
-    glyph u38 0.390625 0.125 0.421875 0.1875
-    glyph u39 0.453125 0.125 0.484375 0.1875
-    glyph u40 0.515625 0.125 0.546875 0.1875
-    glyph u41 0.578125 0.125 0.609375 0.1875
-    glyph u42 0.640625 0.125 0.671875 0.1875
-    glyph u43 0.703125 0.125 0.734375 0.1875
-    glyph u44 0.765625 0.125 0.796875 0.1875
-    glyph u45 0.828125 0.125 0.859375 0.1875
-    glyph u46 0.890625 0.125 0.921875 0.1875
-    glyph u47 0.953125 0.125 0.984375 0.1875
-    glyph u48 0.015625 0.1875 0.046875 0.25
-    glyph u49 0.078125 0.1875 0.109375 0.25
-    glyph u50 0.140625 0.1875 0.171875 0.25
-    glyph u51 0.203125 0.1875 0.234375 0.25
-    glyph u52 0.265625 0.1875 0.296875 0.25
-    glyph u53 0.328125 0.1875 0.359375 0.25
-    glyph u54 0.390625 0.1875 0.421875 0.25
-    glyph u55 0.453125 0.1875 0.484375 0.25
-    glyph u56 0.515625 0.1875 0.546875 0.25
-    glyph u57 0.578125 0.1875 0.609375 0.25
-    glyph u58 0.640625 0.1875 0.671875 0.25
-    glyph u59 0.703125 0.1875 0.734375 0.25
-    glyph u60 0.765625 0.1875 0.796875 0.25
-    glyph u61 0.828125 0.1875 0.859375 0.25
-    glyph u62 0.890625 0.1875 0.921875 0.25
-    glyph u63 0.953125 0.1875 0.984375 0.25
-    glyph u64 0.015625 0.25 0.046875 0.3125
-    glyph u65 0.078125 0.25 0.109375 0.3125
-    glyph u66 0.140625 0.25 0.171875 0.3125
-    glyph u67 0.203125 0.25 0.234375 0.3125
-    glyph u68 0.265625 0.25 0.296875 0.3125
-    glyph u69 0.328125 0.25 0.359375 0.3125
-    glyph u70 0.390625 0.25 0.421875 0.3125
-    glyph u71 0.453125 0.25 0.484375 0.3125
-    glyph u72 0.515625 0.25 0.546875 0.3125
-    glyph u73 0.578125 0.25 0.609375 0.3125
-    glyph u74 0.640625 0.25 0.671875 0.3125
-    glyph u75 0.703125 0.25 0.734375 0.3125
-    glyph u76 0.765625 0.25 0.796875 0.3125
-    glyph u77 0.828125 0.25 0.859375 0.3125
-    glyph u78 0.890625 0.25 0.921875 0.3125
-    glyph u79 0.953125 0.25 0.984375 0.3125
-    glyph u80 0.015625 0.3125 0.046875 0.375
-    glyph u81 0.078125 0.3125 0.109375 0.375
-    glyph u82 0.140625 0.3125 0.171875 0.375
-    glyph u83 0.203125 0.3125 0.234375 0.375
-    glyph u84 0.265625 0.3125 0.296875 0.375
-    glyph u85 0.328125 0.3125 0.359375 0.375
-    glyph u86 0.390625 0.3125 0.421875 0.375
-    glyph u87 0.453125 0.3125 0.484375 0.375
-    glyph u88 0.515625 0.3125 0.546875 0.375
-    glyph u89 0.578125 0.3125 0.609375 0.375
-    glyph u90 0.640625 0.3125 0.671875 0.375
-    glyph u91 0.703125 0.3125 0.734375 0.375
-    glyph u92 0.765625 0.3125 0.796875 0.375
-    glyph u93 0.828125 0.3125 0.859375 0.375
-    glyph u94 0.890625 0.3125 0.921875 0.375
-    glyph u95 0.953125 0.3125 0.984375 0.375
-    glyph u96 0.015625 0.375 0.046875 0.4375
-    glyph u97 0.078125 0.375 0.109375 0.4375
-    glyph u98 0.140625 0.375 0.171875 0.4375
-    glyph u99 0.203125 0.375 0.234375 0.4375
-    glyph u100 0.265625 0.375 0.296875 0.4375
-    glyph u101 0.328125 0.375 0.359375 0.4375
-    glyph u102 0.390625 0.375 0.421875 0.4375
-    glyph u103 0.453125 0.375 0.484375 0.4375
-    glyph u104 0.515625 0.375 0.546875 0.4375
-    glyph u105 0.578125 0.375 0.609375 0.4375
-    glyph u106 0.640625 0.375 0.671875 0.4375
-    glyph u107 0.703125 0.375 0.734375 0.4375
-    glyph u108 0.765625 0.375 0.796875 0.4375
-    glyph u109 0.828125 0.375 0.859375 0.4375
-    glyph u110 0.890625 0.375 0.921875 0.4375
-    glyph u111 0.953125 0.375 0.984375 0.4375
-    glyph u112 0.015625 0.4375 0.046875 0.5
-    glyph u113 0.078125 0.4375 0.109375 0.5
-    glyph u114 0.140625 0.4375 0.171875 0.5
-    glyph u115 0.203125 0.4375 0.234375 0.5
-    glyph u116 0.265625 0.4375 0.296875 0.5
-    glyph u117 0.328125 0.4375 0.359375 0.5
-    glyph u118 0.390625 0.4375 0.421875 0.5
-    glyph u119 0.453125 0.4375 0.484375 0.5
-    glyph u120 0.515625 0.4375 0.546875 0.5
-    glyph u121 0.578125 0.4375 0.609375 0.5
-    glyph u122 0.640625 0.4375 0.671875 0.5
-    glyph u123 0.703125 0.4375 0.734375 0.5
-    glyph u124 0.765625 0.4375 0.796875 0.5
-    glyph u125 0.828125 0.4375 0.859375 0.5
-    glyph u126 0.890625 0.4375 0.921875 0.5
-    glyph u127 0.953125 0.4375 0.984375 0.5
-    glyph u128 0.015625 0.5 0.046875 0.5625
-    glyph u129 0.078125 0.5 0.109375 0.5625
-    glyph u130 0.140625 0.5 0.171875 0.5625
-    glyph u131 0.203125 0.5 0.234375 0.5625
-    glyph u132 0.265625 0.5 0.296875 0.5625
-    glyph u133 0.328125 0.5 0.359375 0.5625
-    glyph u134 0.390625 0.5 0.421875 0.5625
-    glyph u135 0.453125 0.5 0.484375 0.5625
-    glyph u136 0.515625 0.5 0.546875 0.5625
-    glyph u137 0.578125 0.5 0.609375 0.5625
-    glyph u138 0.640625 0.5 0.671875 0.5625
-    glyph u139 0.703125 0.5 0.734375 0.5625
-    glyph u140 0.765625 0.5 0.796875 0.5625
-    glyph u141 0.828125 0.5 0.859375 0.5625
-    glyph u142 0.890625 0.5 0.921875 0.5625
-    glyph u143 0.953125 0.5 0.984375 0.5625
-    glyph u144 0.015625 0.5625 0.046875 0.625
-    glyph u145 0.078125 0.5625 0.109375 0.625
-    glyph u146 0.140625 0.5625 0.171875 0.625
-    glyph u147 0.203125 0.5625 0.234375 0.625
-    glyph u148 0.265625 0.5625 0.296875 0.625
-    glyph u149 0.328125 0.5625 0.359375 0.625
-    glyph u150 0.390625 0.5625 0.421875 0.625
-    glyph u151 0.453125 0.5625 0.484375 0.625
-    glyph u152 0.515625 0.5625 0.546875 0.625
-    glyph u153 0.578125 0.5625 0.609375 0.625
-    glyph u154 0.640625 0.5625 0.671875 0.625
-    glyph u155 0.703125 0.5625 0.734375 0.625
-    glyph u156 0.765625 0.5625 0.796875 0.625
-    glyph u157 0.828125 0.5625 0.859375 0.625
-    glyph u158 0.890625 0.5625 0.921875 0.625
-    glyph u159 0.953125 0.5625 0.984375 0.625
-    glyph u160 0.015625 0.625 0.046875 0.6875
-    glyph u161 0.078125 0.625 0.109375 0.6875
-    glyph u162 0.140625 0.625 0.171875 0.6875
-    glyph u163 0.203125 0.625 0.234375 0.6875
-    glyph u164 0.265625 0.625 0.296875 0.6875
-    glyph u165 0.328125 0.625 0.359375 0.6875
-    glyph u166 0.390625 0.625 0.421875 0.6875
-    glyph u167 0.453125 0.625 0.484375 0.6875
-    glyph u168 0.515625 0.625 0.546875 0.6875
-    glyph u169 0.578125 0.625 0.609375 0.6875
-    glyph u170 0.640625 0.625 0.671875 0.6875
-    glyph u171 0.703125 0.625 0.734375 0.6875
-    glyph u172 0.765625 0.625 0.796875 0.6875
-    glyph u173 0.828125 0.625 0.859375 0.6875
-    glyph u174 0.890625 0.625 0.921875 0.6875
-    glyph u175 0.953125 0.625 0.984375 0.6875
-    glyph u176 0.015625 0.6875 0.046875 0.75
-    glyph u177 0.078125 0.6875 0.109375 0.75
-    glyph u178 0.140625 0.6875 0.171875 0.75
-    glyph u179 0.203125 0.6875 0.234375 0.75
-    glyph u180 0.265625 0.6875 0.296875 0.75
-    glyph u181 0.328125 0.6875 0.359375 0.75
-    glyph u182 0.390625 0.6875 0.421875 0.75
-    glyph u183 0.453125 0.6875 0.484375 0.75
-    glyph u184 0.515625 0.6875 0.546875 0.75
-    glyph u185 0.578125 0.6875 0.609375 0.75
-    glyph u186 0.640625 0.6875 0.671875 0.75
-    glyph u187 0.703125 0.6875 0.734375 0.75
-    glyph u188 0.765625 0.6875 0.796875 0.75
-    glyph u189 0.828125 0.6875 0.859375 0.75
-    glyph u190 0.890625 0.6875 0.921875 0.75
-    glyph u191 0.953125 0.6875 0.984375 0.75
-    glyph u192 0.015625 0.75 0.046875 0.8125
-    glyph u193 0.078125 0.75 0.109375 0.8125
-    glyph u194 0.140625 0.75 0.171875 0.8125
-    glyph u195 0.203125 0.75 0.234375 0.8125
-    glyph u196 0.265625 0.75 0.296875 0.8125
-    glyph u197 0.328125 0.75 0.359375 0.8125
-    glyph u198 0.390625 0.75 0.421875 0.8125
-    glyph u199 0.453125 0.75 0.484375 0.8125
-    glyph u200 0.515625 0.75 0.546875 0.8125
-    glyph u201 0.578125 0.75 0.609375 0.8125
-    glyph u202 0.640625 0.75 0.671875 0.8125
-    glyph u203 0.703125 0.75 0.734375 0.8125
-    glyph u204 0.765625 0.75 0.796875 0.8125
-    glyph u205 0.828125 0.75 0.859375 0.8125
-    glyph u206 0.890625 0.75 0.921875 0.8125
-    glyph u207 0.953125 0.75 0.984375 0.8125
-    glyph u208 0.015625 0.8125 0.046875 0.875
-    glyph u209 0.078125 0.8125 0.109375 0.875
-    glyph u210 0.140625 0.8125 0.171875 0.875
-    glyph u211 0.203125 0.8125 0.234375 0.875
-    glyph u212 0.265625 0.8125 0.296875 0.875
-    glyph u213 0.328125 0.8125 0.359375 0.875
-    glyph u214 0.390625 0.8125 0.421875 0.875
-    glyph u215 0.453125 0.8125 0.484375 0.875
-    glyph u216 0.515625 0.8125 0.546875 0.875
-    glyph u217 0.578125 0.8125 0.609375 0.875
-    glyph u218 0.640625 0.8125 0.671875 0.875
-    glyph u219 0.703125 0.8125 0.734375 0.875
-    glyph u220 0.765625 0.8125 0.796875 0.875
-    glyph u221 0.828125 0.8125 0.859375 0.875
-    glyph u222 0.890625 0.8125 0.921875 0.875
-    glyph u223 0.953125 0.8125 0.984375 0.875
-    glyph u224 0.015625 0.875 0.046875 0.9375
-    glyph u225 0.078125 0.875 0.109375 0.9375
-    glyph u226 0.140625 0.875 0.171875 0.9375
-    glyph u227 0.203125 0.875 0.234375 0.9375
-    glyph u228 0.265625 0.875 0.296875 0.9375
-    glyph u229 0.328125 0.875 0.359375 0.9375
-    glyph u230 0.390625 0.875 0.421875 0.9375
-    glyph u231 0.453125 0.875 0.484375 0.9375
-    glyph u232 0.515625 0.875 0.546875 0.9375
-    glyph u233 0.578125 0.875 0.609375 0.9375
-    glyph u234 0.640625 0.875 0.671875 0.9375
-    glyph u235 0.703125 0.875 0.734375 0.9375
-    glyph u236 0.765625 0.875 0.796875 0.9375
-    glyph u237 0.828125 0.875 0.859375 0.9375
-    glyph u238 0.890625 0.875 0.921875 0.9375
-    glyph u239 0.953125 0.875 0.984375 0.9375
-    glyph u240 0.015625 0.9375 0.046875 1
-    glyph u241 0.078125 0.9375 0.109375 1
-    glyph u242 0.140625 0.9375 0.171875 1
-    glyph u243 0.203125 0.9375 0.234375 1
-    glyph u244 0.265625 0.9375 0.296875 1
-    glyph u245 0.328125 0.9375 0.359375 1
-    glyph u246 0.390625 0.9375 0.421875 1
-    glyph u247 0.453125 0.9375 0.484375 1
-    glyph u248 0.515625 0.9375 0.546875 1
-    glyph u249 0.578125 0.9375 0.609375 1
-    glyph u250 0.640625 0.9375 0.671875 1
-    glyph u251 0.703125 0.9375 0.734375 1
-    glyph u252 0.765625 0.9375 0.796875 1
-    glyph u253 0.828125 0.9375 0.859375 1
-    glyph u254 0.890625 0.9375 0.921875 1
-    glyph u255 0.953125 0.9375 0.984375 1
-}
-
-font CourierNew
-{
-    type image
-    source system/courier_new_9_75.png
-
-    glyph u32 0.015625 0.125 0.046875 0.1875
-    glyph u33 0.078125 0.125 0.109375 0.1875
-    glyph u34 0.140625 0.125 0.171875 0.1875
-    glyph u35 0.203125 0.125 0.234375 0.1875
-    glyph u36 0.265625 0.125 0.296875 0.1875
-    glyph u37 0.328125 0.125 0.359375 0.1875
-    glyph u38 0.390625 0.125 0.421875 0.1875
-    glyph u39 0.453125 0.125 0.484375 0.1875
-    glyph u40 0.515625 0.125 0.546875 0.1875
-    glyph u41 0.578125 0.125 0.609375 0.1875
-    glyph u42 0.640625 0.125 0.671875 0.1875
-    glyph u43 0.703125 0.125 0.734375 0.1875
-    glyph u44 0.765625 0.125 0.796875 0.1875
-    glyph u45 0.828125 0.125 0.859375 0.1875
-    glyph u46 0.890625 0.125 0.921875 0.1875
-    glyph u47 0.953125 0.125 0.984375 0.1875
-    glyph u48 0.015625 0.1875 0.046875 0.25
-    glyph u49 0.078125 0.1875 0.109375 0.25
-    glyph u50 0.140625 0.1875 0.171875 0.25
-    glyph u51 0.203125 0.1875 0.234375 0.25
-    glyph u52 0.265625 0.1875 0.296875 0.25
-    glyph u53 0.328125 0.1875 0.359375 0.25
-    glyph u54 0.390625 0.1875 0.421875 0.25
-    glyph u55 0.453125 0.1875 0.484375 0.25
-    glyph u56 0.515625 0.1875 0.546875 0.25
-    glyph u57 0.578125 0.1875 0.609375 0.25
-    glyph u58 0.640625 0.1875 0.671875 0.25
-    glyph u59 0.703125 0.1875 0.734375 0.25
-    glyph u60 0.765625 0.1875 0.796875 0.25
-    glyph u61 0.828125 0.1875 0.859375 0.25
-    glyph u62 0.890625 0.1875 0.921875 0.25
-    glyph u63 0.953125 0.1875 0.984375 0.25
-    glyph u64 0.015625 0.25 0.046875 0.3125
-    glyph u65 0.078125 0.25 0.109375 0.3125
-    glyph u66 0.140625 0.25 0.171875 0.3125
-    glyph u67 0.203125 0.25 0.234375 0.3125
-    glyph u68 0.265625 0.25 0.296875 0.3125
-    glyph u69 0.328125 0.25 0.359375 0.3125
-    glyph u70 0.390625 0.25 0.421875 0.3125
-    glyph u71 0.453125 0.25 0.484375 0.3125
-    glyph u72 0.515625 0.25 0.546875 0.3125
-    glyph u73 0.578125 0.25 0.609375 0.3125
-    glyph u74 0.640625 0.25 0.671875 0.3125
-    glyph u75 0.703125 0.25 0.734375 0.3125
-    glyph u76 0.765625 0.25 0.796875 0.3125
-    glyph u77 0.828125 0.25 0.859375 0.3125
-    glyph u78 0.890625 0.25 0.921875 0.3125
-    glyph u79 0.953125 0.25 0.984375 0.3125
-    glyph u80 0.015625 0.3125 0.046875 0.375
-    glyph u81 0.078125 0.3125 0.109375 0.375
-    glyph u82 0.140625 0.3125 0.171875 0.375
-    glyph u83 0.203125 0.3125 0.234375 0.375
-    glyph u84 0.265625 0.3125 0.296875 0.375
-    glyph u85 0.328125 0.3125 0.359375 0.375
-    glyph u86 0.390625 0.3125 0.421875 0.375
-    glyph u87 0.453125 0.3125 0.484375 0.375
-    glyph u88 0.515625 0.3125 0.546875 0.375
-    glyph u89 0.578125 0.3125 0.609375 0.375
-    glyph u90 0.640625 0.3125 0.671875 0.375
-    glyph u91 0.703125 0.3125 0.734375 0.375
-    glyph u92 0.765625 0.3125 0.796875 0.375
-    glyph u93 0.828125 0.3125 0.859375 0.375
-    glyph u94 0.890625 0.3125 0.921875 0.375
-    glyph u95 0.953125 0.3125 0.984375 0.375
-    glyph u96 0.015625 0.375 0.046875 0.4375
-    glyph u97 0.078125 0.375 0.109375 0.4375
-    glyph u98 0.140625 0.375 0.171875 0.4375
-    glyph u99 0.203125 0.375 0.234375 0.4375
-    glyph u100 0.265625 0.375 0.296875 0.4375
-    glyph u101 0.328125 0.375 0.359375 0.4375
-    glyph u102 0.390625 0.375 0.421875 0.4375
-    glyph u103 0.453125 0.375 0.484375 0.4375
-    glyph u104 0.515625 0.375 0.546875 0.4375
-    glyph u105 0.578125 0.375 0.609375 0.4375
-    glyph u106 0.640625 0.375 0.671875 0.4375
-    glyph u107 0.703125 0.375 0.734375 0.4375
-    glyph u108 0.765625 0.375 0.796875 0.4375
-    glyph u109 0.828125 0.375 0.859375 0.4375
-    glyph u110 0.890625 0.375 0.921875 0.4375
-    glyph u111 0.953125 0.375 0.984375 0.4375
-    glyph u112 0.015625 0.4375 0.046875 0.5
-    glyph u113 0.078125 0.4375 0.109375 0.5
-    glyph u114 0.140625 0.4375 0.171875 0.5
-    glyph u115 0.203125 0.4375 0.234375 0.5
-    glyph u116 0.265625 0.4375 0.296875 0.5
-    glyph u117 0.328125 0.4375 0.359375 0.5
-    glyph u118 0.390625 0.4375 0.421875 0.5
-    glyph u119 0.453125 0.4375 0.484375 0.5
-    glyph u120 0.515625 0.4375 0.546875 0.5
-    glyph u121 0.578125 0.4375 0.609375 0.5
-    glyph u122 0.640625 0.4375 0.671875 0.5
-    glyph u123 0.703125 0.4375 0.734375 0.5
-    glyph u124 0.765625 0.4375 0.796875 0.5
-    glyph u125 0.828125 0.4375 0.859375 0.5
-    glyph u126 0.890625 0.4375 0.921875 0.5
-    glyph u127 0.953125 0.4375 0.984375 0.5
-    glyph u128 0.015625 0.5 0.046875 0.5625
-    glyph u129 0.078125 0.5 0.109375 0.5625
-    glyph u130 0.140625 0.5 0.171875 0.5625
-    glyph u131 0.203125 0.5 0.234375 0.5625
-    glyph u132 0.265625 0.5 0.296875 0.5625
-    glyph u133 0.328125 0.5 0.359375 0.5625
-    glyph u134 0.390625 0.5 0.421875 0.5625
-    glyph u135 0.453125 0.5 0.484375 0.5625
-    glyph u136 0.515625 0.5 0.546875 0.5625
-    glyph u137 0.578125 0.5 0.609375 0.5625
-    glyph u138 0.640625 0.5 0.671875 0.5625
-    glyph u139 0.703125 0.5 0.734375 0.5625
-    glyph u140 0.765625 0.5 0.796875 0.5625
-    glyph u141 0.828125 0.5 0.859375 0.5625
-    glyph u142 0.890625 0.5 0.921875 0.5625
-    glyph u143 0.953125 0.5 0.984375 0.5625
-    glyph u144 0.015625 0.5625 0.046875 0.625
-    glyph u145 0.078125 0.5625 0.109375 0.625
-    glyph u146 0.140625 0.5625 0.171875 0.625
-    glyph u147 0.203125 0.5625 0.234375 0.625
-    glyph u148 0.265625 0.5625 0.296875 0.625
-    glyph u149 0.328125 0.5625 0.359375 0.625
-    glyph u150 0.390625 0.5625 0.421875 0.625
-    glyph u151 0.453125 0.5625 0.484375 0.625
-    glyph u152 0.515625 0.5625 0.546875 0.625
-    glyph u153 0.578125 0.5625 0.609375 0.625
-    glyph u154 0.640625 0.5625 0.671875 0.625
-    glyph u155 0.703125 0.5625 0.734375 0.625
-    glyph u156 0.765625 0.5625 0.796875 0.625
-    glyph u157 0.828125 0.5625 0.859375 0.625
-    glyph u158 0.890625 0.5625 0.921875 0.625
-    glyph u159 0.953125 0.5625 0.984375 0.625
-    glyph u160 0.015625 0.625 0.046875 0.6875
-    glyph u161 0.078125 0.625 0.109375 0.6875
-    glyph u162 0.140625 0.625 0.171875 0.6875
-    glyph u163 0.203125 0.625 0.234375 0.6875
-    glyph u164 0.265625 0.625 0.296875 0.6875
-    glyph u165 0.328125 0.625 0.359375 0.6875
-    glyph u166 0.390625 0.625 0.421875 0.6875
-    glyph u167 0.453125 0.625 0.484375 0.6875
-    glyph u168 0.515625 0.625 0.546875 0.6875
-    glyph u169 0.578125 0.625 0.609375 0.6875
-    glyph u170 0.640625 0.625 0.671875 0.6875
-    glyph u171 0.703125 0.625 0.734375 0.6875
-    glyph u172 0.765625 0.625 0.796875 0.6875
-    glyph u173 0.828125 0.625 0.859375 0.6875
-    glyph u174 0.890625 0.625 0.921875 0.6875
-    glyph u175 0.953125 0.625 0.984375 0.6875
-    glyph u176 0.015625 0.6875 0.046875 0.75
-    glyph u177 0.078125 0.6875 0.109375 0.75
-    glyph u178 0.140625 0.6875 0.171875 0.75
-    glyph u179 0.203125 0.6875 0.234375 0.75
-    glyph u180 0.265625 0.6875 0.296875 0.75
-    glyph u181 0.328125 0.6875 0.359375 0.75
-    glyph u182 0.390625 0.6875 0.421875 0.75
-    glyph u183 0.453125 0.6875 0.484375 0.75
-    glyph u184 0.515625 0.6875 0.546875 0.75
-    glyph u185 0.578125 0.6875 0.609375 0.75
-    glyph u186 0.640625 0.6875 0.671875 0.75
-    glyph u187 0.703125 0.6875 0.734375 0.75
-    glyph u188 0.765625 0.6875 0.796875 0.75
-    glyph u189 0.828125 0.6875 0.859375 0.75
-    glyph u190 0.890625 0.6875 0.921875 0.75
-    glyph u191 0.953125 0.6875 0.984375 0.75
-    glyph u192 0.015625 0.75 0.046875 0.8125
-    glyph u193 0.078125 0.75 0.109375 0.8125
-    glyph u194 0.140625 0.75 0.171875 0.8125
-    glyph u195 0.203125 0.75 0.234375 0.8125
-    glyph u196 0.265625 0.75 0.296875 0.8125
-    glyph u197 0.328125 0.75 0.359375 0.8125
-    glyph u198 0.390625 0.75 0.421875 0.8125
-    glyph u199 0.453125 0.75 0.484375 0.8125
-    glyph u200 0.515625 0.75 0.546875 0.8125
-    glyph u201 0.578125 0.75 0.609375 0.8125
-    glyph u202 0.640625 0.75 0.671875 0.8125
-    glyph u203 0.703125 0.75 0.734375 0.8125
-    glyph u204 0.765625 0.75 0.796875 0.8125
-    glyph u205 0.828125 0.75 0.859375 0.8125
-    glyph u206 0.890625 0.75 0.921875 0.8125
-    glyph u207 0.953125 0.75 0.984375 0.8125
-    glyph u208 0.015625 0.8125 0.046875 0.875
-    glyph u209 0.078125 0.8125 0.109375 0.875
-    glyph u210 0.140625 0.8125 0.171875 0.875
-    glyph u211 0.203125 0.8125 0.234375 0.875
-    glyph u212 0.265625 0.8125 0.296875 0.875
-    glyph u213 0.328125 0.8125 0.359375 0.875
-    glyph u214 0.390625 0.8125 0.421875 0.875
-    glyph u215 0.453125 0.8125 0.484375 0.875
-    glyph u216 0.515625 0.8125 0.546875 0.875
-    glyph u217 0.578125 0.8125 0.609375 0.875
-    glyph u218 0.640625 0.8125 0.671875 0.875
-    glyph u219 0.703125 0.8125 0.734375 0.875
-    glyph u220 0.765625 0.8125 0.796875 0.875
-    glyph u221 0.828125 0.8125 0.859375 0.875
-    glyph u222 0.890625 0.8125 0.921875 0.875
-    glyph u223 0.953125 0.8125 0.984375 0.875
-    glyph u224 0.015625 0.875 0.046875 0.9375
-    glyph u225 0.078125 0.875 0.109375 0.9375
-    glyph u226 0.140625 0.875 0.171875 0.9375
-    glyph u227 0.203125 0.875 0.234375 0.9375
-    glyph u228 0.265625 0.875 0.296875 0.9375
-    glyph u229 0.328125 0.875 0.359375 0.9375
-    glyph u230 0.390625 0.875 0.421875 0.9375
-    glyph u231 0.453125 0.875 0.484375 0.9375
-    glyph u232 0.515625 0.875 0.546875 0.9375
-    glyph u233 0.578125 0.875 0.609375 0.9375
-    glyph u234 0.640625 0.875 0.671875 0.9375
-    glyph u235 0.703125 0.875 0.734375 0.9375
-    glyph u236 0.765625 0.875 0.796875 0.9375
-    glyph u237 0.828125 0.875 0.859375 0.9375
-    glyph u238 0.890625 0.875 0.921875 0.9375
-    glyph u239 0.953125 0.875 0.984375 0.9375
-    glyph u240 0.015625 0.9375 0.046875 1
-    glyph u241 0.078125 0.9375 0.109375 1
-    glyph u242 0.140625 0.9375 0.171875 1
-    glyph u243 0.203125 0.9375 0.234375 1
-    glyph u244 0.265625 0.9375 0.296875 1
-    glyph u245 0.328125 0.9375 0.359375 1
-    glyph u246 0.390625 0.9375 0.421875 1
-    glyph u247 0.453125 0.9375 0.484375 1
-    glyph u248 0.515625 0.9375 0.546875 1
-    glyph u249 0.578125 0.9375 0.609375 1
-    glyph u250 0.640625 0.9375 0.671875 1
-    glyph u251 0.703125 0.9375 0.734375 1
-    glyph u252 0.765625 0.9375 0.796875 1
-    glyph u253 0.828125 0.9375 0.859375 1
-    glyph u254 0.890625 0.9375 0.921875 1
-    glyph u255 0.953125 0.9375 0.984375 1
-}

+ 0 - 0
data/v-gears.cfg_linux


+ 1 - 1
debian/changelog

@@ -2,4 +2,4 @@ v-gears (0.0.1) bullseye; urgency=low
 
   First VGears Package
 
- -- Iñigo Valentin <i@inigovalentin.com>  Tue, 26 Jul 2022 22:07:00 -0100
+ -- Iñigo Valentin <i@inigovalentin.com>  Tue, 26 Jul 2022 22:07:00 -0100

+ 3 - 3
debian/control

@@ -1,11 +1,11 @@
 Source: v-gears
 Section: games
 Priority: extra
-Maintainer: I�igo Valentin <i@inigovalentin.com>
-Build-Depends: debhelper(>= 7.0.50~),libogre-1.12-dev, libtiyxml-dev, cmake, cdbs, libois-dev, libboost-program-options-dev, libboost-filesystem-dev, libvorbis-dev, libopenal-dev, libboost-test-dev, zlib1g-dev, libqt5core5a, libqt5widgets5, libluajit-5.1-dev
+Maintainer: Iñigo Valentin <i@inigovalentin.com>
+Build-Depends: debhelper(>= 7.0.50~),libogre-1.12-dev, libtinyxml-dev, cmake, cdbs, libois-dev, libboost-program-options-dev, libboost-filesystem-dev, libvorbis-dev, libopenal-dev, libboost-test-dev, zlib1g-dev, libarchive-dev, libqt5core5a, libqt5widgets5, libluajit-5.1-dev
 Standards-Version: 3.9.5
 Homepage: https://v-gears.inigovalentin.com
-Vcs-Git: git://https://github.com/v-gears/v-gears.git
+Vcs-Git: https://github.com/InigoValentin/V-Gears.git
 #Vcs-Browser: http://git.debian.org/?p=collab-maint/vgears.git;a=summary
 
 Package: v-gears

+ 1 - 2
debian/install

@@ -2,5 +2,4 @@ v-gears.desktop /usr/share/applications
 v-gears.png /usr/share/pixmaps
 v-gears-installer.desktop /usr/share/applications
 v-gears-installer.png /usr/share/pixmaps
-v-gears-launcher /usr/bin
-output/resources.cfg /usr/share/v-gears
+data/* /usr/share/v-gears

+ 1 - 1
debian/menu

@@ -1,3 +1,3 @@
 ?package(v-gears):needs="x11" section="Games/Toys"\
-  title="v-gears" command="/usr/bin/v-gears"
+  title="v-gears" command="sh -c 'cd ~/.v-gears && exec /usr/games/v-gears'"
   

+ 19 - 3
doc/BUILD.md

@@ -4,7 +4,7 @@
 If you are runind Debian (or derivates), you can install the required dependencies by running as root
 
 ```bash
-apt install g++ cmake libqt5widgets5 qtbase5-dev zlib1g-dev libogre-1.12-dev libois-dev libvorbis-dev libboost-dev libboost-program-options-dev libboost-test-dev libboost-filesystem-dev libboost-thread-dev libopenal-dev libtinyxml-dev libluajit-5.1-dev
+apt install g++ cmake libqt5widgets5 qtbase5-dev zlib1g-dev libarchive-dev libogre-1.12-dev libois-dev libvorbis-dev libboost-dev libboost-program-options-dev libboost-test-dev libboost-filesystem-dev libboost-thread-dev libopenal-dev libtinyxml-dev libluajit-5.1-dev
 ```
 
 ## Deterministic fallbacks
@@ -43,9 +43,25 @@ If you want to speed it up a bit (it can take several minutes, depending on your
 Once the build process finishes, you u sholh have the following files (from the project directory):
 
 - `build/bin/v-gears`, the engine executable.
-- `build/bin/v-gears-launcher`, the data installer.
+- `build/bin/v-gears-installer`, the data installer.
 
-Both the engine and the installer are a little pesky about from where they are launched, so before trying to run them, keep reading.
+The engine must be launched from the installation directory (the installer starts it from there for you), so before trying to run it, keep reading.
+
+## Run the tests (optional)
+
+The tests are not built by default. To build and run the tests of the installer logic (the option rules, the installation layout, the verifier, the log and the tool finder), which don't need the rest of the engine:
+
+```bash
+mkdir build-tests;
+cd build-tests;
+cmake ../ -DBUILD_TESTS=ON;
+make v-gears-installer-tests;
+./bin/v-gears-installer-tests
+```
+
+## Optional programs
+
+To convert the sound effects and the music, the installer uses FFmpeg and TiMidity. They are not needed to build V-Gears, only to install the data. See the [data installation guide](INSTALL_DATA.md "Install Data").
 
 ## Next steps
 

+ 3 - 7
doc/INSTALL.md

@@ -2,23 +2,19 @@
 
 ## Manual installation
 
-Currently, there is no easy way to install V-Gears automatically. Until it's implemented, this is the easiest method.
+The data installer can do this for you: enable "Copy V-Gears data files" in its advanced options and it copies the `data/` folder of the repository to the installation directory. To do it by hand:
 
 1. Create a folder for V-Gears data, outside the project. In Linux-based systems, it's recommended to use `~/.v-gears/`.
-2. Copy all the contents of the `output/` folder to the newly created directory. It should end up like this:
+2. Copy all the contents of the `data/` folder of the repository to the newly created directory. It should end up like this:
 ```
 ~/.v-gears/
   ├── data
   ├── field_models_and_animation_metadata.xml
   ├── plugins.cfg
-  ├── plugins.cfg_win32
   ├── plugins_d.cfg
   ├── RenderSystem_GL.so.1.12.10
   ├── resources.cfg
-  ├── resources.cfg_linux
-  ├── resources_d.cfg
-  ├── system.fontdef
-  └── v-gears.cfg_linux
+  └── resources_d.cfg
 ```
 3. Whenever you want to launch V-Gears or the V-Gears data installer, it must be done from this directory.
 

+ 39 - 51
doc/INSTALL_DATA.md

@@ -1,87 +1,75 @@
 # <img src="../v-gears.png" alt="V-Gears" width="50"/> V-Gears Data Installation Guide
 
-## Extract game data.
+## What you need
 
-To install Final Fantasy VII game data, you'll nedd a PC version copy of if. Extract the contents of the installation dist to a folder anywehre you like. For this guide, we'll be calling that folder `FFVIIPC/`. Once extracted, verify that the directory structure contains the following subdirectories:
-```
-FFVIIPC/
-├── battle
-├── cd
-├── field
-├── kernel
-├── menu
-├── midi
-├── minigame
-├── music
-├── sound
-└── wm
-```
+To install the Final Fantasy VII game data you need your own copy of the PC version of the game. The installer reads the **ISO image of the install disk** directly, so there is nothing to extract by hand. It extracts what it needs to `<installation directory>/original_data/` and converts it from there.
 
-## Install FFmpeg
+If you also want the videos, you need the ISO images of the PC game disks 1, 2 and 3 (see [Extract the videos](#extract-the-videos)). The disks of the PlayStation versions are not supported.
 
-The installer uses [FFmpeg](https://ffmpeg.org "FFmpeg") to convert sound effect files. To do this, it uses the ffmpeg executable in the system, and the sounds files will not be properly installed if there is such executable. In most GNU/Linux distros, ffmpeg is available as a package, so you can install it with your package manager. For the rest of cases, see [the downloads page](https://ffmpeg.org/download.html "FFmpeg download page").
+## FFmpeg and TiMidity
 
-It's important that the ffmpeg executable is located in a globally accessed location. To verify this, try to run this command from a console: `ffmpeg -version`. If FFmpeg version is show, you are all set. If not, you must include the path to the ffmpeg executable to your system PATH environment variable.
+The installer converts the sound effects and the music to `.ogg` files with two external programs:
 
-If you can't (or don't want to) install FFmpeg in your system, a workaround is explained later.
+- [FFmpeg](https://ffmpeg.org/download.html "FFmpeg") converts the sound effects and encodes the music.
+- [TiMidity](https://timidity.sourceforge.net/#download "TiMidity") plays the original MIDI music so FFmpeg can encode it. Only the music needs it.
 
-## Install TiMidity
+You don't have to configure anything if they are installed. The installer looks for them, in this order:
 
-The installer uses [TiMidity](http://timidity.sourceforge.net/ "TiMidity") to convert the original music tracks. To do this, it uses the timidity executable in the system, and the tracks files will not be properly installed if there is such executable. In most GNU/Linux distros, timidity is available as a package, so you can install it with your package manager. For the rest of cases, see [the downloads page](https://timidity.sourceforge.net/#download "TiMidity download page").
+1. The program you chose with **Locate…**. The choice is remembered.
+2. A `tools` folder inside the installation directory, or next to the installer. On Windows, the easiest way is to copy `ffmpeg.exe` there.
+3. The system `PATH`. In most GNU/Linux distributions both are available as packages (for example, `sudo apt install ffmpeg timidity`).
 
-It's important that the timidity executable is located in a globally accessed location. To verify this, try to run this command from a console: `timidity -v`. If TiMidity version is show, you are all set. If not, you must include the path to the timidity executable to your system PATH environment variable.
+The window shows what the installer found (`Audio tools: FFmpeg ✓ … · TiMidity ✓ …`). Every program is run once to check that it works, and FFmpeg must be able to write ogg files (it needs the Vorbis encoder). On Windows, TiMidity also needs its `timidity.cfg` and the instrument files next to `timidity.exe`.
 
-If you can't (or don't want to) install TiMidity in your system, a workaround is explained later.
+If a program that the installation needs is missing, the installer tells you before it starts. The message says what you would lose, and you can:
 
-## Launch the Installer
+- **Locate…** the program in your system.
+- **Get it…**: open its download page.
+- **Check again**, for when you have just installed it.
+- **Continue without** it. The rest is installed normally, and the sound effects, the music, or both are left out. The extracted `.wav` and `.mid` files stay in `~/.v-gears/data/audio/`, and the indexes only list the files that exist.
+- **Cancel** the installation.
 
-If you have followed the [build](BUILD.md "Building guide") and [installaton](INSTALL.md "Installation guide") guides, you'll have the game data copied to `~/.v-gears/`, and the binaries compilled somewhere (we'll assum they are in `~/V-Gears/build/bin`, change the directories according to your system).
+To add the missing audio later, install the programs and run the installer again with only the sound effects and the music selected (the *Media only* option, or *Custom*).
 
+## Launch the Installer
 
-On a terminal, do into the game data folder, and from there, run the installer
+If you have followed the [build](BUILD.md "Building guide") and [installaton](INSTALL.md "Installation guide") guides, you'll have the game data copied to `~/.v-gears/` (or you can let the installer copy it, see below), and the binaries compiled somewhere (we'll assume they are in `~/V-Gears/build/bin`, change the directories according to your system). Just run the installer:
 
 ```Bash
-cd ~/.v-gears/;
-~/V-Gears/build/bin/v-gears-launcher;
-
+~/V-Gears/build/bin/v-gears-installer
 ```
 
+It can be started from any directory. When the installation starts, it works from the installation directory.
+
 ## Run the Installer
 
-In the 'Data Installer' tab, in the 'Import data source' field, select the folder where you extracted the game data (in this guide `FFVIIPC/`), and in the 'VGears Data Path', select the folder whith the game data (in this guide `~/.v-gears/`).
+1. In **Import data source**, select the ISO image of the install disk. The installer tells you if the image is valid.
+2. In **VGears installation directory**, select where to install, for example `~/.v-gears/`. It will be created if it doesn't exist. It holds the configuration and the converted data, and it is where the game runs.
+3. (Optional) Select **Show advanced options** to choose what to install (see below). The defaults install everything. If the directory doesn't have the V-Gears files yet (configuration, scripts, screens, fonts...), leave **Copy V-Gears data files** checked, and the installer copies them there from the `data/` folder of the repository.
+4. Click **Install data**.
 
-Then just click 'Install Data'.
+Before extracting anything, the installer checks that everything is in place: that the ISO is valid, that the directory is writable and has enough free space (about 3 GB plus the size of the ISO), and that the FFmpeg and TiMidity programs it needs can be used (see below). If something is wrong it tells you and doesn't start.
 
-The process shouldn't take too long. Dont' be discouraged by the huge amount of errors you might see, if at the end you see 'Installation finished', the data is succesfully installed.
+The log shows the progress. Problems are shown in color (red for errors, orange for warnings), and the full log is written to `installer.log` in the installation directory. Don't be discouraged by some warnings about skipped fields or models, they are known limitations of the converter. The installation is finished when the progress bar reaches 100% and the window can be closed again.
 
-### Workaround: FFmpeg is installed, but TiMidity is not.
+An installation can only be done once per installer window. To install again, close the installer and open it again. The estimate under the options shows about how much disk space it will use.
 
-If your system has FFmpeg installed, but not Timidity, you will stil get the sound effects, but V-Gears will not be able to play background music unless you find a way to convert mid files to ogg. If you know of one, here is wht you can do:
+When it finishes:
 
-- In the installer, open the advanced options.
-- Check the options 'Prevent TiMidity calls' and 'Keep originals'.
-- Proceed with the installation normally.
-- Once is done, use your own method to convert the `.mid` files in `~/.v-gears/data/audio/music/` to `.ogg` files, while keeping the same name.
-- (Optional) When done, mid files can be safely deleted.
+- **Verify install** checks that the installation directory has everything the game needs, and lists what is missing, grouped by part. If you skipped a part on purpose, its files are expected to be missing.
+- **Launch game** starts V-Gears from the installation directory.
 
-### Workaround: FFmpeg is not installed.
+## Extract the videos
 
-If your system doesn't have ffmpeg installed, the instaler will not be able to convert sound effects or background music. If you have a way to convert `.mid` and `.wav` files to `.ogg`, there is still something you can do:
+The videos are not on the install disk, but on the PC game disks 1, 2 and 3. To get them, run the installer once for each disk: select the disk's ISO as the data source and the same installation directory, and click 'Install data'. The installer detects a game disk, extracts only its videos to `<installation directory>/data/video/` and doesn't touch anything else. Several disks have the same videos, so those already installed are skipped. Unlike the full installation, it can be repeated with the same installer window.
 
-- In the installer, open the advanced options.
-- Check the options 'Prevent FFmpeg calls' and 'Keep originals'.
-- Proceed with the installation normally.
-- Once is done, use your own method to convert the `.mid` files in `~/.v-gears/data/audio/music/` to `.ogg` files, while keeping the same name.
-- Use your own method to convert the `.wav` files in `~/.v-gears/data/audio/sound/` to `.ogg` files, while keeping the same name.
-- The installation disk has 4 `.wav` files that you will also need to convert to `.ogg`. They are located in `data/music/`. For each one, you can find the corespondant name in the file `~/.v-gears/data/musics.xml` (it wil be one or two digits). Convert the tracks to `ogg`, with that name, and place them in `~/.v-gears/data/audio/music/`.
-- (Optional) When done, mid and wav files can be safely deleted.
+The videos are copied as they are (AVI files with the Duck TrueMotion 2 codec, which FFmpeg can decode). V-Gears doesn't play them yet.
 
 ## Advanced options
 
-The installer has a list of advanced options. They have tooltips with short explanations of what they do. For a fresh installation, there is ususlly no nned to check any advanced options (unless you are using one of the workarounds described below).
-
-For subsequent installations, it's generally usefull ti check the options 'Don't extract sounds' and 'Don't extract music'. By doing so, music and sounds will not be reinstalled, and the installation wil shorten by afew minutes.
+The installer has a list of advanced options, under **Show advanced options**. They have tooltips with short explanations of what they do. For a fresh installation there is usually no need to change them: the *Everything* option installs it all.
 
+*What to install* has ready-made sets of options. Choose *Custom* to pick them one by one. For later installations it's useful to choose only what you need (for example, only the fields), which shortens the installation by a few minutes.
 
 ## Next steps
 

+ 2 - 0
doc/RUN.md

@@ -12,6 +12,8 @@ cd ~/.v-gears/;
 
 ```
 
+The data installer also has a **Launch game** button, which starts the engine from the installation directory.
+
 ## Key Mappings
 
 Here is a list of key mappings, adapted from the PSX version of the game:

+ 16 - 16
src/installer/BattleDataInstaller.cpp

@@ -24,6 +24,7 @@
 #include <boost/filesystem.hpp>
 #include <OgreMesh.h>
 #include "BattleDataInstaller.h"
+#include "ModelMaterials.h"
 #include "TexFile.h"
 #include "data/VGearsHRCFileManager.h"
 #include "data/VGearsAFileManager.h"
@@ -36,7 +37,7 @@
 BattleDataInstaller::BattleDataInstaller(
   const DiskImage& disk_image, const std::string output_dir, Ogre::ResourceGroupManager* res_mgr
 ):
-  disk_image_(disk_image), output_dir_(output_dir),
+  disk_image_(disk_image), output_dir_(output_dir), data_dir_(output_dir + "data/"),
   scene_bin_(*const_cast<DiskImage&>(disk_image).filePath("data/battle/scene.bin")), total_scenes_(0), next_scene_(0),
   res_mgr_(res_mgr)
 {}
@@ -257,7 +258,7 @@ unsigned int BattleDataInstaller::ProcessBattleModel(){
           output_dir_ + "temp/battle_models/" + battle_lgp_file_names_[next_model_to_process_] + ".tex"
         );
         TexFile tex(battle_lgp_files_[next_model_to_process_]);
-        std::string path = output_dir_ + "models/battle/";
+        std::string path = data_dir_ + "models/battle/";
         if (info.is_player){
             path += "characters/";
             // Add or update on character_data_
@@ -418,13 +419,13 @@ unsigned int BattleDataInstaller::ProcessSpellModel(){
         // add them to a particular model.
         magic_lgp_files_[next_model_to_process_].WriteFile(output_dir_ + "temp/spell_models/" + fname);
         TexFile tex(output_dir_ + "temp/spell_models/" + fname);
-        std::string path = output_dir_ + "models/battle/attacks/" + id + ".png";
+        std::string path = data_dir_ + "models/battle/attacks/" + id + ".png";
         // Many of these tex files are wrong and dummied out in the OG, so try/catch.
         try{
             tex.SavePng(path);
         }
         catch(const std::exception& e){
-            std::cerr << "Error converting texture " << (output_dir_ + "temp/spell_models/" + fname)
+            std::cerr << "[WARNING] Error converting texture " << (output_dir_ + "temp/spell_models/" + fname)
               << " to png: " << e.what() << std::endl;
         }
     }
@@ -494,7 +495,7 @@ unsigned int BattleDataInstaller::ConvertBattleModel(){
 
         if ("" != model.script){ // Skip non scripted models
             AbFile ab(File(output_dir_ + "temp/battle_models/" + model.script), info.is_enemy);
-            ab.GenerateScripts(model.id + "_" + info.name_normal, output_dir_ + "scripts/battle_models/");
+            ab.GenerateScripts(model.id + "_" + info.name_normal, data_dir_ + "scripts/battle_models/");
         }
 
         // Reload the resources for the newly created hrc and rsd.
@@ -718,7 +719,7 @@ void BattleDataInstaller::WriteEnemies(){
         }
         xml.LinkEndChild(container.release());
         std::string file_name = BuildEnemyFileName(enemy);
-        xml.SaveFile(output_dir_ + file_name);
+        xml.SaveFile(data_dir_ + file_name);
         enemy_map_[enemy.id] = file_name;
 
     }
@@ -865,7 +866,7 @@ void BattleDataInstaller::WriteAttacks(){
         container->LinkEndChild(anim.release());
         xml.LinkEndChild(container.release());
         std::string file_name = BuildAttackFileName(attack);
-        xml.SaveFile(output_dir_ + file_name);
+        xml.SaveFile(data_dir_ + file_name);
         attack_map_[attack.id] = file_name;
     }
     // Index of the attack files by ID, as file names include the attack name.
@@ -879,7 +880,7 @@ void BattleDataInstaller::WriteAttacks(){
         attacks->LinkEndChild(xml_attack.release());
     }
     index.LinkEndChild(attacks.release());
-    index.SaveFile(output_dir_ + "gamedata/attack/_index.xml");
+    index.SaveFile(data_dir_ + "gamedata/attack/_index.xml");
 }
 
 void BattleDataInstaller::WriteFormations(){
@@ -945,7 +946,7 @@ void BattleDataInstaller::WriteFormations(){
         std::string file_name = std::to_string(formation.id);
         while (file_name.length() < 4) file_name = "0" + file_name;
         file_name = "gamedata/formation/" + file_name + ".xml";
-        xml.SaveFile(output_dir_ + file_name);
+        xml.SaveFile(data_dir_ + file_name);
         formation_map_[formation.id] = file_name;
     }
 }
@@ -965,7 +966,7 @@ void BattleDataInstaller::WriteCharacterData(){
         container->LinkEndChild(xml_character.release());
     }
     xml.LinkEndChild(container.release());
-    xml.SaveFile(output_dir_ + "models/battle/characters.xml");
+    xml.SaveFile(data_dir_ + "models/battle/characters.xml");
 }
 
 void BattleDataInstaller::WriteSceneData(){
@@ -979,7 +980,7 @@ void BattleDataInstaller::WriteSceneData(){
         container->LinkEndChild(xml_scene.release());
     }
     xml.LinkEndChild(container.release());
-    xml.SaveFile(output_dir_ + "models/battle/scenes.xml");
+    xml.SaveFile(data_dir_ + "models/battle/scenes.xml");
 }
 
 std::string BattleDataInstaller::BuildEnemyFileName(Enemy enemy){
@@ -1086,20 +1087,19 @@ File* BattleDataInstaller::ExtractGZipScene(File file){
 void BattleDataInstaller::ExportMesh(const std::string outdir, const Ogre::MeshPtr &mesh){
     VGears::String base_mesh_name;
     VGears::StringUtil::splitFull(mesh->getName(), base_mesh_name);
+    MakeMaterialsUnique(mesh, outdir + base_mesh_name);
     Ogre::MeshSerializer mesh_serializer;
     Ogre::SkeletonPtr skeleton(mesh->getSkeleton());
     Ogre::SkeletonSerializer skeleton_serializer;
     skeleton_serializer.exportSkeleton(
-      skeleton.get(), output_dir_ + "models/battle/" + outdir + base_mesh_name + ".skeleton"
+      skeleton.get(), data_dir_ + "models/battle/" + outdir + base_mesh_name + ".skeleton"
     );
     mesh->setSkeletonName(outdir + base_mesh_name + ".skeleton");
-    mesh_serializer.exportMesh(mesh.get(), output_dir_ + "models/battle/" + outdir + mesh->getName());
+    mesh_serializer.exportMesh(mesh.get(), data_dir_ + "models/battle/" + outdir + mesh->getName());
     Ogre::MaterialSerializer mat_ser;
     std::set<std::string> textures;
     Ogre::Mesh::SubMeshList sub_meshes = mesh->getSubMeshes();
     for (Ogre::SubMesh* sub_mesh : sub_meshes){
-        // Change material name to avoid conflicts with field model materials.
-        sub_mesh->setMaterialName(sub_mesh->getMaterialName());
         Ogre::MaterialPtr mat(Ogre::MaterialManager::getSingleton().getByName(sub_mesh->getMaterialName()));
         if (mat == nullptr) continue;
         for (size_t techs = 0; techs < mat->getNumTechniques(); techs ++){
@@ -1127,7 +1127,7 @@ void BattleDataInstaller::ExportMesh(const std::string outdir, const Ogre::MeshP
             battle_materials_.push_back(sub_mesh->getMaterialName());
         }
     }
-    mat_ser.exportQueued(output_dir_ + "models/battle/" + outdir + base_mesh_name + VGears::EXT_MATERIAL);
+    mat_ser.exportQueued(data_dir_ + "models/battle/" + outdir + base_mesh_name + VGears::EXT_MATERIAL);
 }
 
 void BattleDataInstaller::GenerateRsdFiles(BattleModel model, std::string path){

+ 5 - 0
src/installer/BattleDataInstaller.h

@@ -426,6 +426,11 @@ class BattleDataInstaller{
          */
         std::string output_dir_;
 
+        /**
+         * Directory where the game data is installed (<install dir>/data/).
+         */
+        std::string data_dir_;
+
         /**
          * Pointer to the application resource manager.
          */

+ 7 - 0
src/installer/CMakeLists.txt

@@ -12,6 +12,12 @@ set(INSTALLER_SOURCE_FILES
     DiskImage.cpp
     FieldDataInstaller.cpp
     FieldTextWriter.cpp
+    InstallLayout.cpp
+    InstallLog.cpp
+    InstallOptions.cpp
+    ExternalTools.cpp
+    InstallVerifier.cpp
+    VideoFiles.cpp
     KernelDataInstaller.cpp
     MainWindow.cpp
     MediaDataInstaller.cpp
@@ -123,6 +129,7 @@ endif()
 
 # Generate v-gears-installer executable.
 add_executable (v-gears-installer ${INSTALLER_SOURCE_FILES} ${UIS} ${MOCS} ${RCC_SOURCES})
+target_compile_definitions(v-gears-installer PRIVATE VGEARS_INSTALLER_VERSION="${PROJECT_VERSION}")
 SET_PROPERTY(TARGET v-gears-installer PROPERTY FOLDER "build/v-gears-installer")
 if (APPLE)
     target_link_libraries(v-gears-installer "-framework CoreFoundation -framework Cocoa -framework IOKit")

+ 87 - 68
src/installer/DataInstaller.cpp

@@ -16,6 +16,8 @@
 #include <QtCore/QDir>
 #include <boost/filesystem.hpp>
 #include "DataInstaller.h"
+#include "InstallLayout.h"
+#include "InstallLog.h"
 #include "DiskImage.h"
 
 float DataInstaller::LINE_SCALE_FACTOR = 0.0078124970964f;
@@ -29,7 +31,7 @@ DataInstaller::DataInstaller(
 #else
   : application_("plugins.cfg", "resources.cfg", "install.log"),
 #endif
-  disk_image_(disk_image), output_dir_(output_dir),
+  disk_image_(disk_image), output_dir_(output_dir), data_dir_(InstallLayout::DataDir(output_dir)),
   options_(options), write_output_line_(write_output_line)
 {
     if (!application_.initOgre(true)) throw std::runtime_error("Ogre init failure");
@@ -54,7 +56,34 @@ DataInstaller::DataInstaller(
 
 DataInstaller::~DataInstaller(){}
 
+const char* DataInstaller::StepGroup(int state){
+    if (state >= FIELD_CONVERT_MODELS_INIT && state <= FIELD_CONVERT_MODELS)
+        return "Field models";
+    if (state >= FIELD_SPAWN_POINTS_AND_SCALE_FACTORS_INIT && state <= FIELD_WRITE_END)
+        return "Field maps";
+    if (state == WM_MODELS) return "World map models";
+    if (state >= WM_INIT && state <= WM_MAPS) return "World map";
+    if (state >= MEDIA_VIDEOS && state < FIELD_SPAWN_POINTS_AND_SCALE_FACTORS_INIT)
+        return "Videos";
+    if (state >= MEDIA_MUSICS_INIT && state < MEDIA_VIDEOS) return "Music";
+    if (state >= MEDIA_SOUNDS_INIT && state < MEDIA_MUSICS_INIT) return "Sounds";
+    if (state == MEDIA_IMAGES) return "Images";
+    if (state >= KERNEL_PRICES && state < MEDIA_IMAGES) return "Kernel data";
+    if (state >= SPELL_MODELS_INIT && state < KERNEL_PRICES) return "Spell models";
+    if (state >= BATTLE_MODELS_INIT && state < SPELL_MODELS_INIT) return "Battle models";
+    if (state >= BATTLE_SCENES_INIT && state < BATTLE_MODELS_INIT) return "Battle data";
+    if (state == CLEAN) return "Cleanup";
+    return "Preparation";
+}
+
 float DataInstaller::Progress(){
+    // Report each part of the installation separately in the log.
+    const char* group = StepGroup(installation_state_);
+    if (current_group_ != group){
+        current_group_ = group;
+        InstallLog::Get().BeginStep(group);
+    }
+    if (installation_state_ == DONE) InstallLog::Get().EndStep();
     switch (installation_state_){
         case IDLE:
             installation_state_ = CREATE_DIRECTORIES;
@@ -70,7 +99,7 @@ float DataInstaller::Progress(){
             if (input_dir.empty()) throw std::runtime_error("Input data directory is empty");
             kernel_installer_ = std::make_unique<KernelDataInstaller>(disk_image_);
             media_installer_ = std::make_unique<MediaDataInstaller>(
-              disk_image_, output_dir_, options_.keep_originals, options_.ffmpeg, options_.timidity
+              disk_image_, output_dir_, options_.keep_originals
             );
                         field_installer_ = std::make_unique<FieldDataInstaller>(
                             disk_image_, output_dir_, [this](std::string log_message){
@@ -191,73 +220,73 @@ float DataInstaller::Progress(){
         case KERNEL_COMMANDS:
             write_output_line_("Extracting command data...", 2, true);
             kernel_installer_->ReadCommands();
-            kernel_installer_->WriteCommands(output_dir_ + "gamedata/commands.lua");
+            kernel_installer_->WriteCommands(data_dir_ + "gamedata/commands.lua");
             installation_state_ = KERNEL_ATTACKS;
             return CalcProgress();
         case KERNEL_ATTACKS:
             write_output_line_("Extracting attack data...", 2, true);
             kernel_installer_->ReadAttacks();
-            kernel_installer_->WriteAttacks(output_dir_ + "gamedata/attacks.lua");
+            kernel_installer_->WriteAttacks(data_dir_ + "gamedata/attacks.lua");
             installation_state_ = KERNEL_CHARACTERS;
             return CalcProgress();
         case KERNEL_CHARACTERS:
             write_output_line_("Extracting character data...", 2, true);
             kernel_installer_->ReadCharacters();
-            kernel_installer_->WriteCharacters(output_dir_ + "gamedata/characters.lua");
+            kernel_installer_->WriteCharacters(data_dir_ + "gamedata/characters.lua");
             installation_state_ = KERNEL_ITEMS;
             return CalcProgress();
         case KERNEL_ITEMS:
             write_output_line_("Extracting item data...", 2, true);
             kernel_installer_->ReadItems();
-            kernel_installer_->WriteItems(output_dir_ + "gamedata/items.lua");
+            kernel_installer_->WriteItems(data_dir_ + "gamedata/items.lua");
             installation_state_ = KERNEL_GROWTH;
             return CalcProgress();
         case KERNEL_GROWTH:
             write_output_line_("Extracting growth data...", 2, true);
             kernel_installer_->ReadGrowth();
-            kernel_installer_->WriteGrowth(output_dir_ + "gamedata/growth.lua");
+            kernel_installer_->WriteGrowth(data_dir_ + "gamedata/growth.lua");
             installation_state_ = KERNEL_WEAPONS;
             return CalcProgress();
         case KERNEL_WEAPONS:
             write_output_line_("Extracting weapon data...", 2, true);
             kernel_installer_->ReadWeapons();
-            kernel_installer_->WriteWeapons(output_dir_ + "gamedata/weapons.lua");
+            kernel_installer_->WriteWeapons(data_dir_ + "gamedata/weapons.lua");
             installation_state_ = KERNEL_ARMORS;
             return CalcProgress();
         case KERNEL_ARMORS:
             write_output_line_("Extracting armor data...", 2, true);
             kernel_installer_->ReadArmors();
-            kernel_installer_->WriteArmors(output_dir_ + "gamedata/armors.lua");
+            kernel_installer_->WriteArmors(data_dir_ + "gamedata/armors.lua");
             installation_state_ = KERNEL_ACCESSORIES;
             return CalcProgress();
         case KERNEL_ACCESSORIES:
             write_output_line_("Extracting accessory data...", 2, true);
             kernel_installer_->ReadAccessories();
-            kernel_installer_->WriteAccessories(output_dir_ + "gamedata/accessories.lua");
+            kernel_installer_->WriteAccessories(data_dir_ + "gamedata/accessories.lua");
             installation_state_ = KERNEL_MATERIA;
             return CalcProgress();
         case KERNEL_MATERIA:
             write_output_line_("Extracting materia data...", 2, true);
             kernel_installer_->ReadMateria();
-            kernel_installer_->WriteMateria(output_dir_ + "gamedata/materia.lua");
+            kernel_installer_->WriteMateria(data_dir_ + "gamedata/materia.lua");
             installation_state_ = KERNEL_KEY_ITEMS;
             return CalcProgress();
         case KERNEL_KEY_ITEMS:
             write_output_line_("Extracting key item data...", 2, true);
             kernel_installer_->ReadKeyItems();
-            kernel_installer_->WriteKeyItems(output_dir_ + "gamedata/key_items.lua");
+            kernel_installer_->WriteKeyItems(data_dir_ + "gamedata/key_items.lua");
             installation_state_ = KERNEL_SUMMON_NAMES;
             return CalcProgress();
         case KERNEL_SUMMON_NAMES:
             write_output_line_("Extracting summon attack names data...", 2, true);
             kernel_installer_->ReadSummonNames();
-            kernel_installer_->WriteSummonNames(output_dir_ + "gamedata/summons.lua");
+            kernel_installer_->WriteSummonNames(data_dir_ + "gamedata/summons.lua");
             installation_state_ = KERNEL_SAVEMAP;
             return CalcProgress();
         case KERNEL_SAVEMAP:
             write_output_line_("Extracting the initial savemap...", 2, true);
             kernel_installer_->ReadInitialSaveMap();
-            kernel_installer_->WriteInitialSaveMap(output_dir_ + "gamedata/initial_savemap.lua");
+            kernel_installer_->WriteInitialSaveMap(data_dir_ + "gamedata/initial_savemap.lua");
             installation_state_ = MEDIA_IMAGES;
             return CalcProgress();
         case MEDIA_IMAGES:
@@ -288,8 +317,7 @@ float DataInstaller::Progress(){
             else cur_substep_ ++;
             return CalcProgress();
         case MEDIA_SOUNDS_INDEX:
-            write_output_line_("Building sound index...", 2, true);
-            media_installer_->WriteSoundIndex();
+            // The index is written when the conversions, which run in the background, have finished.
             installation_state_ = MEDIA_MUSICS_INIT;
             return CalcProgress();
         case MEDIA_MUSICS_INIT:
@@ -315,8 +343,7 @@ float DataInstaller::Progress(){
             installation_state_ = MEDIA_MUSICS_INDEX;
             return CalcProgress();
         case MEDIA_MUSICS_INDEX:
-            write_output_line_("Building music track index...", 2, true);
-            media_installer_->WriteMusicsIndex();
+            // The index is written when the conversions, which run in the background, have finished.
             installation_state_ = FIELD_SPAWN_POINTS_AND_SCALE_FACTORS_INIT;
             return CalcProgress();
         case MEDIA_VIDEOS:
@@ -332,12 +359,14 @@ float DataInstaller::Progress(){
             return CalcProgress();
         case FIELD_SPAWN_POINTS_AND_SCALE_FACTORS_INIT:
             // Skip fields if option is set.
-            if (!options_.fields){
+            if (!options_.fields && !options_.wm){
                 write_output_line_("Skipping field maps installation...", 2, true);
                 installation_state_ = WM_INIT;
                 return CalcProgress();
             }
-            write_output_line_("Collecting spawn points and scale factors...", 2, true);
+            // The world map scripts need the field list and scale factors even if the fields aren't converted.
+            if (!options_.fields) write_output_line_("Collecting field data for the world map...", 2, true);
+            else write_output_line_("Collecting spawn points and scale factors...", 2, true);
             substeps_ = field_installer_->CollectSpawnAndScaleFactorsInit(application_.ResMgr());
             cur_substep_ = 0;
             installation_state_ = FIELD_SPAWN_POINTS_AND_SCALE_FACTORS;
@@ -346,8 +375,14 @@ float DataInstaller::Progress(){
             field_installer_->CollectSpawnAndScaleFactors(cur_substep_);
             cur_substep_ ++;
             if (cur_substep_ == substeps_){
-                installation_state_ = FIELD_CONVERT;
                 cur_substep_ = 0;
+                if (options_.fields) installation_state_ = FIELD_CONVERT;
+                else{
+                    // Fields are not converted: use the ones already installed.
+                    write_output_line_("Skipping field maps installation...", 2, true);
+                    field_installer_->LoadInstalledFields();
+                    installation_state_ = WM_INIT;
+                }
             }
             return CalcProgress();
         case FIELD_CONVERT:
@@ -436,6 +471,20 @@ float DataInstaller::Progress(){
             installation_state_ = CLEAN;
             return CalcProgress();
         case CLEAN:
+            // The audio conversions run in the background while the other steps are done.
+            if (media_installer_){
+                write_output_line_("Waiting for the audio conversions to finish...", 2, true);
+                media_installer_->WaitForConversions();
+                // Now the files that exist are known, so the indexes list only those.
+                if (options_.sounds){
+                    write_output_line_("Building sound index...", 2, true);
+                    media_installer_->WriteSoundIndex();
+                }
+                if (options_.music){
+                    write_output_line_("Building music track index...", 2, true);
+                    media_installer_->WriteMusicsIndex();
+                }
+            }
             write_output_line_("Cleaning up...", 2, true);
             if (!options_.keep_originals) CleanInstall();
             installation_state_ = DONE;
@@ -467,75 +516,45 @@ const float DataInstaller::CalcProgress(){
 }
 
 void DataInstaller::CreateDirectories(){
-    CreateDir("audio/musics");
-    CreateDir("audio/sounds");
-    CreateDir("fields");
-    CreateDir("fonts");
-    CreateDir("temp");
-    CreateDir("temp/char");
-    CreateDir("temp/battle_models");
-    CreateDir("temp/world_models");
-    CreateDir("temp/spell_models");
-    CreateDir("temp/wm");
-    CreateDir("gamedata");
-    CreateDir("gamedata/enemy");
-    CreateDir("gamedata/attack");
-    CreateDir("gamedata/formation");
-    CreateDir("images/icons");
-    CreateDir("images/other");
-    CreateDir("images/characters");
-    CreateDir("images/fonts");
-    CreateDir("images/reels");
-    CreateDir("images/window");
-    CreateDir("screens");
-    CreateDir("scripts");
-    CreateDir("scripts/battle_models");
-    CreateDir("system");
-    CreateDir("texts");
-    CreateDir("models/fields/");
-    CreateDir("models/battle/characters");
-    CreateDir("models/battle/scenes");
-    CreateDir("models/battle/enemies");
-    CreateDir("models/battle/attacks");
-    CreateDir("models/world/terrain/0");
-    CreateDir("models/world/terrain/1");
-    CreateDir("models/world/terrain/2");
-    CreateDir("models/world/elements/");
-    CreateDir("world/0");
-    CreateDir("world/1");
-    CreateDir("world/2");
+    for (const std::string& dir : InstallLayout::DataSubdirs()) CreateDir(dir);
+    CreateDir("temp", true);
+    for (const std::string& dir : InstallLayout::TempSubdirs()) CreateDir("temp/" + dir, true);
 
     application_.ResMgr()->addResourceLocation(output_dir_ + "temp/char/", "FileSystem", "FFVII", true, true);
     application_.ResMgr()->addResourceLocation(output_dir_ + "temp/battle_models/", "FileSystem", "FFVII", true, true);
     application_.ResMgr()->addResourceLocation(output_dir_ + "temp/world_models/", "FileSystem", "FFVII", true, true);
-    application_.ResMgr()->addResourceLocation(output_dir_ + "models/", "FileSystem", "FFVII", true, true);
+    application_.ResMgr()->addResourceLocation(data_dir_ + "models/", "FileSystem", "FFVII", true, true);
     application_.ResMgr()->addResourceLocation(
-      output_dir_ + "models/fields/", "FileSystem", "FFVIITextures", true, true
+      data_dir_ + "models/fields/", "FileSystem", "FFVIITextures", true, true
     );
     application_.ResMgr()->addResourceLocation(
-      output_dir_ + "models/battle/characters/", "FileSystem", "FFVIITextures", true, true
+      data_dir_ + "models/battle/characters/", "FileSystem", "FFVIITextures", true, true
     );
     application_.ResMgr()->addResourceLocation(
-      output_dir_ + "models/battle/scenes/", "FileSystem", "FFVIITextures", true, true
+      data_dir_ + "models/battle/scenes/", "FileSystem", "FFVIITextures", true, true
     );
     application_.ResMgr()->addResourceLocation(
-      output_dir_ + "models/battle/enemies/", "FileSystem", "FFVIITextures", true, true
+      data_dir_ + "models/battle/enemies/", "FileSystem", "FFVIITextures", true, true
     );
     application_.ResMgr()->addResourceLocation(
-      output_dir_ + "models/battle/attacks/", "FileSystem", "FFVIITextures", true, true
+      data_dir_ + "models/battle/attacks/", "FileSystem", "FFVIITextures", true, true
     );
     application_.ResMgr()->addResourceLocation(
-      output_dir_ + "models/world/", "FileSystem", "FFVIITextures", true, true
+      data_dir_ + "models/world/", "FileSystem", "FFVIITextures", true, true
     );
     const auto fields_lgp_path = disk_image_.filePath("data/field/flevel.lgp");
     if (!fields_lgp_path) throw std::runtime_error("Missing required file: data/field/flevel.lgp");
     fields_lgp_ = std::make_unique<ScopedLgp>(application_.getRoot(), *fields_lgp_path, "LGP", "FFVIIFields");
 }
 
-void DataInstaller::CleanInstall(){boost::filesystem::remove_all(output_dir_ + "data/temp/");}
+void DataInstaller::CleanInstall(){
+    // The game itself reads some of the temp directories, so only the rest are removed.
+    for (const std::string& dir : InstallLayout::RemovableTempSubdirs())
+        boost::filesystem::remove_all(InstallLayout::TempDir(output_dir_) + dir + "/");
+}
 
-void DataInstaller::CreateDir(const std::string& path){
-    QString target = QString::fromStdString(output_dir_ + path);
+void DataInstaller::CreateDir(const std::string& path, bool in_root){
+    QString target = QString::fromStdString((in_root ? output_dir_ : data_dir_) + path);
     QDir dir(target);
     if (!dir.mkpath(".")) throw std::runtime_error("Failed to mkpath");
 }

+ 21 - 14
src/installer/DataInstaller.h

@@ -74,16 +74,6 @@ class DataInstaller{
              */
             bool videos;
 
-            /**
-             * Option to enable calls to the ffmpeg executable.
-             */
-            bool ffmpeg;
-
-            /**
-             * Option to enable calls to the timidity executable
-             */
-            bool timidity;
-
             /**
              * Option to install field maps.
              */
@@ -159,13 +149,13 @@ class DataInstaller{
         const float CalcProgress();
 
         /**
-         * Creates a directory in the outputh path.
+         * Creates a directory in the data path (<installation dir>/data/).
          *
-         * @param[in] dir Path of the directory to create, relative to the
-         * output path.
+         * @param[in] dir Path of the directory to create, relative to the data path.
+         * @param[in] in_root True to make the path relative to the installation dir instead.
          * @throws std::runtime_error If the directory can't be created.
          */
-        void CreateDir(const std::string& dir);
+        void CreateDir(const std::string& dir, bool in_root = false);
 
         /**
          * Creates all directories required for the installation.
@@ -465,6 +455,13 @@ class DataInstaller{
          */
         InstallationSteps installation_state_ = IDLE;
 
+        /**
+         * Gets the name of the part of the installation a step belongs to.
+         *
+         * @param[in] state An installation step.
+         */
+        static const char* StepGroup(int state);
+
         /**
          * Substeps in the current installation step.
          */
@@ -497,6 +494,16 @@ class DataInstaller{
          */
         std::string output_dir_;
 
+        /**
+         * Directory where the game data is installed (<install dir>/data/).
+         */
+        std::string data_dir_;
+
+        /**
+         * Name of the part of the installation being done, used to report problems per part.
+         */
+        const char* current_group_ = nullptr;
+
         /**
          * Advanced options.
          */

+ 126 - 9
src/installer/DiskImage.cpp

@@ -20,6 +20,7 @@
 #include <archive.h>
 #include <archive_entry.h>
 #include "DiskImage.h"
+#include "VideoFiles.h"
 #include <QtCore/qstring.h>
 #include <QtCore/qdir.h>
 #include <QtCore/qdiriterator.h>
@@ -134,9 +135,10 @@ DiskImage::DiskImage(std::string path) : DiskImage() {
             region = REGION_NORTH_AMERICA;
             language = LANGUAGE_ENGLISH;
             disk = DISK_1;
-            supported = false;
-            valid = false;
-            error_message = "To install from a PC release disk, use the install disk instead.";
+            // The game disks only have the videos; the rest of the game is installed from the install disk.
+            supported = true;
+            valid = true;
+            error_message = "";
             warning_message = "";
             return true;
         }
@@ -147,9 +149,10 @@ DiskImage::DiskImage(std::string path) : DiskImage() {
             region = REGION_NORTH_AMERICA;
             language = LANGUAGE_ENGLISH;
             disk = DISK_2;
-            supported = false;
-            valid = false;
-            error_message = "To install from a PC release disk, use the install disk instead.";
+            // The game disks only have the videos; the rest of the game is installed from the install disk.
+            supported = true;
+            valid = true;
+            error_message = "";
             warning_message = "";
             return true;
         }
@@ -160,9 +163,10 @@ DiskImage::DiskImage(std::string path) : DiskImage() {
             region = REGION_NORTH_AMERICA;
             language = LANGUAGE_ENGLISH;
             disk = DISK_3;
-            supported = false;
-            valid = false;
-            error_message = "To install from a PC release disk, use the install disk instead.";
+            // The game disks only have the videos; the rest of the game is installed from the install disk.
+            supported = true;
+            valid = true;
+            error_message = "";
             warning_message = "";
             return true;
         }
@@ -998,6 +1002,119 @@ const std::string DiskImage::getWarningMessage(){
     return warning_message;
 };
 
+bool const DiskImage::isVideoDisk(){
+    return valid && platform == PLATFORM_PC && (disk == DISK_1 || disk == DISK_2 || disk == DISK_3);
+}
+
+bool DiskImage::extractVideos(
+  const std::string& output_dir, const std::function<void(float)>& progress,
+  const std::function<void(const std::string&)>& log, int* extracted, int* skipped
+){
+    if (extracted) *extracted = 0;
+    if (skipped) *skipped = 0;
+    const std::string video_dir = output_dir + "video/";
+    if (!QDir().mkpath(QString::fromStdString(video_dir))){
+        log("[ERROR] Could not create " + video_dir);
+        return false;
+    }
+
+    // First pass: which movies the disk has, and their total size, to report the progress.
+    uint64_t total_bytes = 0;
+    {
+        struct archive* a = archive_read_new();
+        archive_read_support_format_all(a);
+        archive_read_support_filter_all(a);
+        if (archive_read_open_filename(a, path.c_str(), 10240) != ARCHIVE_OK){
+            log("[ERROR] Could not open the disk image: " + std::string(archive_error_string(a)));
+            archive_read_free(a);
+            return false;
+        }
+        struct archive_entry* entry;
+        while (archive_read_next_header(a, &entry) == ARCHIVE_OK){
+            const char* entry_path = archive_entry_pathname(entry);
+            if (entry_path && archive_entry_filetype(entry) == AE_IFREG && VideoFiles::IsMovie(entry_path))
+                total_bytes += archive_entry_size(entry);
+        }
+        archive_read_free(a);
+    }
+    if (total_bytes == 0){
+        log("[ERROR] The disk image has no videos.");
+        return false;
+    }
+
+    // Second pass: copy them.
+    struct archive* a = archive_read_new();
+    archive_read_support_format_all(a);
+    archive_read_support_filter_all(a);
+    if (archive_read_open_filename(a, path.c_str(), 10240) != ARCHIVE_OK){
+        log("[ERROR] Could not open the disk image: " + std::string(archive_error_string(a)));
+        archive_read_free(a);
+        return false;
+    }
+    bool ok = true;
+    uint64_t done_bytes = 0;
+    struct archive_entry* entry;
+    while (archive_read_next_header(a, &entry) == ARCHIVE_OK){
+        const char* entry_path = archive_entry_pathname(entry);
+        std::string name;
+        if (!entry_path || archive_entry_filetype(entry) != AE_IFREG || !VideoFiles::IsMovie(entry_path, &name)) continue;
+        const uint64_t size = archive_entry_size(entry);
+        const std::string destination = video_dir + name;
+
+        // Several disks have the same videos: skip the ones that are already there.
+        struct stat existing;
+        if (stat(destination.c_str(), &existing) == 0 && static_cast<uint64_t>(existing.st_size) == size){
+            if (skipped) ++ *skipped;
+            done_bytes += size;
+            progress(100.0f * done_bytes / total_bytes);
+            continue;
+        }
+
+        // Written under a temporary name so an interrupted copy is never taken for a video.
+        const std::string partial = destination + ".part";
+        std::ofstream out(partial, std::ios::binary | std::ios::trunc);
+        if (!out){
+            log("[ERROR] Could not write " + destination);
+            ok = false;
+            continue;
+        }
+        const void* buffer;
+        size_t length;
+        la_int64_t offset;
+        bool file_ok = true;
+        while (true){
+            const int r = archive_read_data_block(a, &buffer, &length, &offset);
+            if (r == ARCHIVE_EOF) break;
+            if (r < ARCHIVE_OK){
+                log("[ERROR] Could not read " + name + ": " + archive_error_string(a));
+                file_ok = false;
+                break;
+            }
+            out.seekp(offset);
+            out.write(static_cast<const char*>(buffer), length);
+            done_bytes += length;
+            progress(100.0f * std::min<uint64_t>(done_bytes, total_bytes) / total_bytes);
+        }
+        out.close();
+        if (!file_ok || !out){
+            std::remove(partial.c_str());
+            ok = false;
+            continue;
+        }
+        std::remove(destination.c_str());
+        if (std::rename(partial.c_str(), destination.c_str()) != 0){
+            log("[ERROR] Could not create " + destination);
+            ok = false;
+            continue;
+        }
+        log("Extracted " + name);
+        if (extracted) ++ *extracted;
+    }
+    archive_read_free(a);
+    progress(100);
+    return ok;
+}
+
 std::string const DiskImage::getContentPath() {
     return content_path;
 }

+ 25 - 0
src/installer/DiskImage.h

@@ -14,6 +14,7 @@
  */
 
 #pragma once
+#include <functional>
 #include <memory>
 #include <string>
 
@@ -155,6 +156,30 @@ struct DiskImage{
          */
         bool const isValid();
 
+        /**
+         * Checks if the disk image is a PC game disk (1, 2 or 3). They only have the videos of the game, so they
+         * can't be installed like the install disk: use {@see extractVideos}.
+         */
+        bool const isVideoDisk();
+
+        /**
+         * Extracts the videos of a game disk.
+         *
+         * They are written to "<output_dir>/video/" with lower case names. Videos that already exist with the same
+         * size (several disks have the same ones) are skipped.
+         *
+         * @param[in] output_dir Directory where the game data is installed (ending with '/').
+         * @param[in] progress Receives the progress [0-100].
+         * @param[in] log Receives messages. Those that start with "[ERROR]" are problems.
+         * @param[out] extracted Number of videos copied. Optional.
+         * @param[out] skipped Number of videos that were already installed. Optional.
+         * @return True if every video was extracted.
+         */
+        bool extractVideos(
+          const std::string& output_dir, const std::function<void(float)>& progress,
+          const std::function<void(const std::string&)>& log, int* extracted = nullptr, int* skipped = nullptr
+        );
+
         /**
          * Gets the error message for the disk image.
          *

+ 325 - 0
src/installer/ExternalTools.cpp

@@ -0,0 +1,325 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#include "ExternalTools.h"
+#include <QtCore/QProcess>
+#include <QtCore/QString>
+#include <QtCore/QStringList>
+#include <algorithm>
+#include <boost/filesystem.hpp>
+#include <cctype>
+#include <cstdlib>
+
+namespace fs = boost::filesystem;
+
+namespace{
+
+    std::string Lower(std::string text){
+        std::transform(text.begin(), text.end(), text.begin(), [](unsigned char c){return std::tolower(c);});
+        return text;
+    }
+
+    std::vector<std::string> Split(const std::string& text, char separator){
+        std::vector<std::string> parts;
+        std::string current;
+        for (char c : text){
+            if (c == separator){
+                parts.push_back(current);
+                current.clear();
+            }
+            else current += c;
+        }
+        parts.push_back(current);
+        return parts;
+    }
+
+    /**
+     * Checks that a path is a file that can be run.
+     */
+    bool IsExecutable(const std::string& path, const ExternalTools::Environment& env){
+        boost::system::error_code ec;
+        if (!fs::is_regular_file(path, ec)) return false;
+        if (!env.windows){
+            // The permission to run it, for the owner, the group or others.
+            const fs::perms permissions = fs::status(path, ec).permissions();
+            return !ec && (permissions & (fs::owner_exe | fs::group_exe | fs::others_exe)) != 0;
+        }
+        // In Windows a file is executable by its extension.
+        const std::string extension = Lower(fs::path(path).extension().string());
+        for (const std::string& allowed : Split(Lower(env.pathext), ';')){
+            if (!allowed.empty() && allowed == extension) return true;
+        }
+        return false;
+    }
+
+    /**
+     * Gets the files in a directory that could be a program with a name.
+     */
+    std::vector<std::string> InDirectory(
+      const std::string& directory, const std::string& name, const ExternalTools::Environment& env
+    ){
+        std::vector<std::string> found;
+        if (directory.empty()) return found;
+        const fs::path base = fs::path(directory) / name;
+        if (!env.windows){
+            if (IsExecutable(base.string(), env)) found.push_back(base.string());
+            return found;
+        }
+        // The name with each of the executable extensions, in the order of PATHEXT.
+        for (const std::string& extension : Split(env.pathext, ';')){
+            if (extension.empty()) continue;
+            const std::string candidate = base.string() + extension;
+            if (IsExecutable(candidate, env)) found.push_back(candidate);
+            // Windows file names are case insensitive: the file may be called "FFMPEG.EXE".
+            else{
+                boost::system::error_code ec;
+                for (fs::directory_iterator it(directory, ec), end; !ec && it != end; it.increment(ec)){
+                    const std::string file = it->path().filename().string();
+                    if (Lower(file) == Lower(name + extension) && IsExecutable(it->path().string(), env)){
+                        found.push_back(it->path().string());
+                        break;
+                    }
+                }
+            }
+        }
+        return found;
+    }
+
+    std::string FirstLine(const std::string& text){
+        const std::size_t end = text.find_first_of("\r\n");
+        return end == std::string::npos ? text : text.substr(0, end);
+    }
+
+    /**
+     * Runs the candidates of a program until one is usable.
+     */
+    ExternalTools::Tool FindTool(
+      const std::string& name, const std::string& saved, const ExternalTools::Locations& locations,
+      const ExternalTools::Environment& env, ExternalTools::Tool (*probe)(const std::string&)
+    ){
+        const std::vector<std::string> candidates
+          = ExternalTools::Candidates(name, saved, locations.directories, env);
+        ExternalTools::Tool first_failure;
+        for (const std::string& candidate : candidates){
+            ExternalTools::Tool tool = probe(candidate);
+            if (tool.usable) return tool;
+            if (first_failure.path.empty()) first_failure = tool;
+        }
+        if (!first_failure.path.empty()) return first_failure;
+        ExternalTools::Tool missing;
+        missing.problem = name + " was not found";
+        return missing;
+    }
+
+    /**
+     * Gets the text of a failed run for a message.
+     */
+    std::string Describe(const ExternalTools::RunResult& result){
+        if (!result.started) return "it could not be started";
+        if (!result.finished) return "it did not finish";
+        std::string text = "exit code " + std::to_string(result.exit_code);
+        const std::string line = FirstLine(result.output);
+        if (!line.empty()) text += ": " + line;
+        return text;
+    }
+
+}
+
+namespace ExternalTools{
+
+    Environment Environment::FromSystem(){
+        Environment env;
+        const char* path = std::getenv("PATH");
+        const char* pathext = std::getenv("PATHEXT");
+        env.path = path ? path : "";
+#ifdef _WIN32
+        env.windows = true;
+        env.pathext = pathext ? pathext : ".COM;.EXE;.BAT;.CMD";
+#else
+        env.windows = false;
+        env.pathext = pathext ? pathext : "";
+#endif
+        return env;
+    }
+
+    Locations& CurrentLocations(){
+        static Locations locations;
+        return locations;
+    }
+
+    std::vector<std::string> Candidates(
+      const std::string& name, const std::string& saved_path, const std::vector<std::string>& directories,
+      const Environment& env
+    ){
+        std::vector<std::string> candidates;
+        const auto add = [&candidates](const std::string& path){
+            if (std::find(candidates.begin(), candidates.end(), path) == candidates.end()) candidates.push_back(path);
+        };
+        if (!saved_path.empty() && IsExecutable(saved_path, env)) add(saved_path);
+        for (const std::string& directory : directories)
+            for (const std::string& file : InDirectory(directory, name, env)) add(file);
+        for (const std::string& directory : Split(env.path, env.windows ? ';' : ':')){
+            // Empty entries mean "the current directory", and searching there is a security risk.
+            if (directory.empty() || !fs::path(directory).is_absolute()) continue;
+            for (const std::string& file : InDirectory(directory, name, env)) add(file);
+        }
+        return candidates;
+    }
+
+    std::string ShortVersion(const Tool& tool){
+        const std::string lower = Lower(tool.version);
+        const std::size_t word = lower.find("version ");
+        if (word == std::string::npos) return tool.version;
+        const std::size_t start = word + 8;
+        const std::size_t end = tool.version.find_first_of(" \t", start);
+        return tool.version.substr(start, end == std::string::npos ? std::string::npos : end - start);
+    }
+
+    RunResult Run(const std::string& program, const std::vector<std::string>& arguments, int timeout_ms){
+        RunResult result;
+        QStringList list;
+        for (const std::string& argument : arguments) list << QString::fromStdString(argument);
+        QProcess process;
+        process.setProcessChannelMode(QProcess::MergedChannels);
+        process.start(QString::fromStdString(program), list);
+        result.started = process.waitForStarted(10000);
+        if (!result.started) return result;
+        result.finished = process.waitForFinished(timeout_ms);
+        if (!result.finished){
+            process.kill();
+            process.waitForFinished(5000);
+        }
+        result.output = QString::fromLocal8Bit(process.readAll()).toStdString();
+        result.exit_code = process.exitStatus() == QProcess::NormalExit ? process.exitCode() : -1;
+        return result;
+    }
+
+    Tool ProbeFfmpeg(const std::string& path){
+        Tool tool;
+        tool.path = path;
+        const RunResult version = Run(path, {"-version"}, 15000);
+        if (!version.started || !version.finished || version.exit_code != 0
+            || Lower(version.output).find("ffmpeg version") == std::string::npos){
+            tool.problem = path + " is not FFmpeg (" + Describe(version) + ")";
+            return tool;
+        }
+        tool.version = FirstLine(version.output);
+        // The music and the sounds are ogg files, which need the Vorbis encoder.
+        const RunResult encoders = Run(path, {"-hide_banner", "-encoders"}, 15000);
+        if (encoders.output.find("libvorbis") == std::string::npos){
+            tool.problem = tool.version + " can't write ogg files: it was built without libvorbis";
+            return tool;
+        }
+        tool.usable = true;
+        return tool;
+    }
+
+    Tool ProbeTimidity(const std::string& path){
+        Tool tool;
+        tool.path = path;
+        const RunResult version = Run(path, {"-v"}, 15000);
+        if (!version.started || !version.finished || Lower(version.output).find("timidity") == std::string::npos){
+            tool.problem = path + " is not TiMidity (" + Describe(version) + ")";
+            return tool;
+        }
+        tool.version = FirstLine(version.output);
+        tool.usable = true;
+        return tool;
+    }
+
+    Tool FindFfmpeg(const Locations& locations, const Environment& env){
+        return FindTool("ffmpeg", locations.ffmpeg, locations, env, ProbeFfmpeg);
+    }
+
+    Tool FindTimidity(const Locations& locations, const Environment& env){
+        return FindTool("timidity", locations.timidity, locations, env, ProbeTimidity);
+    }
+
+    std::vector<std::string> FfmpegArguments(const std::string& input, const std::string& output){
+        return {"-hide_banner", "-loglevel", "panic", "-y", "-i", input, output};
+    }
+
+    std::vector<std::string> FfmpegStdinArguments(const std::string& output){
+        return FfmpegArguments("-", output);
+    }
+
+    std::vector<std::string> TimidityArguments(const std::string& midi){
+        return {"--quiet=3", midi, "-Ow", "-o", "-"};
+    }
+
+    bool ConvertToOgg(
+      const std::string& ffmpeg, const std::string& input, const std::string& output, std::string* error
+    ){
+        const RunResult result = Run(ffmpeg, FfmpegArguments(input, output), -1);
+        boost::system::error_code ec;
+        if (result.started && result.finished && result.exit_code == 0 && fs::file_size(output, ec) > 0 && !ec)
+            return true;
+        fs::remove(output, ec);
+        if (error) *error = "ffmpeg failed: " + Describe(result);
+        return false;
+    }
+
+    bool ConvertMidiToOgg(
+      const std::string& timidity, const std::string& ffmpeg, const std::string& midi, const std::string& output,
+      std::string* error
+    ){
+        QProcess render;
+        QProcess encode;
+        // render | encode, without a shell.
+        render.setStandardOutputProcess(&encode);
+        render.setStandardErrorFile(QProcess::nullDevice());
+        encode.setStandardOutputFile(QProcess::nullDevice());
+        encode.setStandardErrorFile(QProcess::nullDevice());
+        const auto to_list = [](const std::vector<std::string>& arguments){
+            QStringList list;
+            for (const std::string& argument : arguments) list << QString::fromStdString(argument);
+            return list;
+        };
+        // The source is started first, as Qt documents for piped processes.
+        render.start(QString::fromStdString(timidity), to_list(TimidityArguments(midi)));
+        encode.start(QString::fromStdString(ffmpeg), to_list(FfmpegStdinArguments(output)));
+        const bool render_started = render.waitForStarted(10000);
+        const bool encode_started = encode.waitForStarted(10000);
+        bool ok = false;
+        std::string reason;
+        if (!render_started || !encode_started){
+            render.kill();
+            encode.kill();
+            render.waitForFinished(2000);
+            encode.waitForFinished(2000);
+            reason = std::string("could not start ") + (!render_started ? "timidity" : "ffmpeg");
+        }
+        else{
+            render.waitForFinished(-1);
+            encode.waitForFinished(-1);
+            if (render.exitStatus() != QProcess::NormalExit || render.exitCode() != 0)
+                reason = "timidity failed with exit code " + std::to_string(render.exitCode());
+            else if (encode.exitStatus() != QProcess::NormalExit || encode.exitCode() != 0)
+                reason = "ffmpeg failed with exit code " + std::to_string(encode.exitCode());
+            else ok = true;
+        }
+        boost::system::error_code ec;
+        if (ok && !(fs::file_size(output, ec) > 0 && !ec)){
+            ok = false;
+            reason = "no audio was produced";
+        }
+        if (!ok){
+            fs::remove(output, ec);
+            if (error) *error = reason;
+        }
+        return ok;
+    }
+
+}

+ 220 - 0
src/installer/ExternalTools.h

@@ -0,0 +1,220 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#pragma once
+
+#include <string>
+#include <vector>
+
+/**
+ * Finding and running the external programs the installer uses to convert audio: FFmpeg (sounds and music) and
+ * TiMidity (to render the MIDI music).
+ *
+ * Programs are searched in this order: the path the user chose, a list of directories (for example a "tools" folder
+ * in the installation directory), and the system PATH. Every candidate is run to check that it really is the
+ * program and that it can do what the installer needs, so a broken file doesn't hide a good one further down.
+ *
+ * Programs are run directly with a list of arguments, not through a shell: there is no quoting to get wrong, paths
+ * can have spaces, and it works the same on Windows.
+ */
+namespace ExternalTools{
+
+    /**
+     * What is needed to search for an executable.
+     */
+    struct Environment{
+
+        /**
+         * The PATH variable.
+         */
+        std::string path;
+
+        /**
+         * The PATHEXT variable (Windows): extensions that make a file executable, separated by ';'.
+         */
+        std::string pathext;
+
+        /**
+         * True to search with the rules of Windows (';' separates the PATH entries, a file is executable if it has
+         * one of the PATHEXT extensions) and false for the rules of Unix (':' and the executable permission).
+         */
+        bool windows = false;
+
+        /**
+         * Gets the environment of the running system.
+         */
+        static Environment FromSystem();
+    };
+
+    /**
+     * Where to look for the programs.
+     */
+    struct Locations{
+
+        /**
+         * Path of FFmpeg that the user chose. Empty if none.
+         */
+        std::string ffmpeg;
+
+        /**
+         * Path of TiMidity that the user chose. Empty if none.
+         */
+        std::string timidity;
+
+        /**
+         * Directories searched before the PATH.
+         */
+        std::vector<std::string> directories;
+    };
+
+    /**
+     * Gets the locations used by the installer. Set them before an installation.
+     */
+    Locations& CurrentLocations();
+
+    /**
+     * Lists the executables that could be a program, in the order they are tried.
+     *
+     * @param[in] name Name of the program, without extension.
+     * @param[in] saved_path Path chosen by the user. It is the first candidate if it is a file.
+     * @param[in] directories Directories searched before the PATH.
+     * @param[in] env The environment.
+     */
+    std::vector<std::string> Candidates(
+      const std::string& name, const std::string& saved_path, const std::vector<std::string>& directories,
+      const Environment& env
+    );
+
+    /**
+     * A program found in the system.
+     */
+    struct Tool{
+
+        /**
+         * Path of the program. Empty if it wasn't found.
+         */
+        std::string path;
+
+        /**
+         * First line of the version of the program.
+         */
+        std::string version;
+
+        /**
+         * True if a working program was found.
+         */
+        bool usable = false;
+
+        /**
+         * Why it can't be used. Empty if it is usable. It's about the first candidate that failed if there were any,
+         * and says it wasn't found if there were none.
+         */
+        std::string problem;
+    };
+
+    /**
+     * Gets the version number of a tool for the window: "9.0.2-1" from "ffmpeg version 9.0.2-1 Copyright ...", and
+     * "2.14.0" from "TiMidity++ version 2.14.0". If there is no "version" word it is the whole version line.
+     * Empty if the tool has no version.
+     */
+    std::string ShortVersion(const Tool& tool);
+
+    /**
+     * Result of running a program.
+     */
+    struct RunResult{
+        bool started = false;
+        bool finished = false;
+        int exit_code = -1;
+
+        /**
+         * Standard output and error.
+         */
+        std::string output;
+    };
+
+    /**
+     * Runs a program and waits for it.
+     *
+     * @param[in] program Path of the program.
+     * @param[in] arguments Arguments, without quoting.
+     * @param[in] timeout_ms Milliseconds to wait before killing it. Negative to wait forever.
+     */
+    RunResult Run(const std::string& program, const std::vector<std::string>& arguments, int timeout_ms);
+
+    /**
+     * Checks that a file is FFmpeg and that it can write ogg files (it has the Vorbis encoder).
+     */
+    Tool ProbeFfmpeg(const std::string& path);
+
+    /**
+     * Checks that a file is TiMidity.
+     */
+    Tool ProbeTimidity(const std::string& path);
+
+    /**
+     * Finds a working FFmpeg.
+     */
+    Tool FindFfmpeg(const Locations& locations, const Environment& env = Environment::FromSystem());
+
+    /**
+     * Finds a working TiMidity.
+     */
+    Tool FindTimidity(const Locations& locations, const Environment& env = Environment::FromSystem());
+
+    /**
+     * Arguments to convert an audio file to ogg with FFmpeg.
+     */
+    std::vector<std::string> FfmpegArguments(const std::string& input, const std::string& output);
+
+    /**
+     * Arguments to convert the audio FFmpeg reads from its standard input to ogg.
+     */
+    std::vector<std::string> FfmpegStdinArguments(const std::string& output);
+
+    /**
+     * Arguments to render a MIDI file with TiMidity to a wav file written to the standard output.
+     */
+    std::vector<std::string> TimidityArguments(const std::string& midi);
+
+    /**
+     * Converts an audio file (wav) to ogg.
+     *
+     * @param[in] ffmpeg Path of FFmpeg.
+     * @param[in] input File to convert.
+     * @param[in] output File to create. It is removed if the conversion fails.
+     * @param[out] error Reason of the failure. Optional.
+     * @return True if the output was created.
+     */
+    bool ConvertToOgg(
+      const std::string& ffmpeg, const std::string& input, const std::string& output, std::string* error = nullptr
+    );
+
+    /**
+     * Converts a MIDI file to ogg: TiMidity renders it and its output is sent to FFmpeg.
+     *
+     * @param[in] timidity Path of TiMidity.
+     * @param[in] ffmpeg Path of FFmpeg.
+     * @param[in] midi File to convert.
+     * @param[in] output File to create. It is removed if the conversion fails.
+     * @param[out] error Reason of the failure. Optional.
+     * @return True if the output was created.
+     */
+    bool ConvertMidiToOgg(
+      const std::string& timidity, const std::string& ffmpeg, const std::string& midi, const std::string& output,
+      std::string* error = nullptr
+    );
+
+}

+ 50 - 34
src/installer/FieldDataInstaller.cpp

@@ -19,6 +19,7 @@
 #include <QtCore/QDir>
 #include "DiskImage.h"
 #include "FieldDataInstaller.h"
+#include "ModelMaterials.h"
 #include "TexFile.h"
 #include "common/File.h"
 #include "data/VGearsMapListFile.h"
@@ -157,7 +158,7 @@ bool FieldDataInstaller::IsTestField(const Ogre::String& resource_name){
 }
 
 void FieldDataInstaller::CreateDir(const std::string& path){
-    QString target = QString::fromStdString(output_dir_ + path);
+    QString target = QString::fromStdString(data_dir_ + path);
     QDir dir(target);
     if (!dir.mkpath(".")) throw std::runtime_error("Failed to mkpath");
 }
@@ -190,7 +191,7 @@ bool FieldDataInstaller::CollectSpawnPoints(
         );
     }
     catch (const ::DecompilerException& ex){
-        std::cerr << "CollectSpawnPoints: decompiler failed for " << field->getName()
+        std::cerr << "[WARNING] CollectSpawnPoints: decompiler failed for " << field->getName()
           << ": " << ex.what() << std::endl;
                 return false;
     }
@@ -233,7 +234,7 @@ FieldDataInstaller::FieldDataInstaller(
     const DiskImage& disk_image, const std::string output_dir,
     std::function<void(std::string)> log_output_line
 ):
-    disk_image_(disk_image), output_dir_(output_dir),
+    disk_image_(disk_image), output_dir_(output_dir), data_dir_(output_dir + "data/"),
     write_output_line_(log_output_line)
 {}
 
@@ -272,12 +273,21 @@ void FieldDataInstaller::CollectSpawnAndScaleFactors(int field_index){
                 }
                 catch (const std::exception& exception){
                         failed_fields_.push_back(resource_name);
-                        std::cerr << "CollectSpawnAndScaleFactors: skipped " << resource_name
+                        std::cerr << "[WARNING] CollectSpawnAndScaleFactors: skipped " << resource_name
                             << " after error: " << exception.what() << std::endl;
                 }
     }
 }
 
+void FieldDataInstaller::LoadInstalledFields(){
+    converted_map_list_.clear();
+    for (const std::string& name : map_list_){
+        if (boost::filesystem::exists(data_dir_ + FIELD_MAPS_DIR + "/" + name + "/map.xml")){
+            converted_map_list_.push_back(name);
+        }
+    }
+}
+
 void FieldDataInstaller::Convert(int field_index){
     // Do the full conversion with the collated data.
     if (field_index >= flevel_file_list_->size()){
@@ -328,7 +338,7 @@ void FieldDataInstaller::Write(int field_index){
 
 void FieldDataInstaller::WriteEnd(){
     doc_->LinkEndChild(element_.release());
-    doc_->SaveFile(output_dir_ + "/fields/_fields.xml");
+    doc_->SaveFile(data_dir_ + "/fields/_fields.xml");
 }
 
 std::vector<std::string> FieldDataInstaller::ConvertModelsInit(){
@@ -354,6 +364,11 @@ std::vector<std::string> FieldDataInstaller::ConvertModelsInit(){
         out.close();
         //field_model_file_list_->push_back(f.file_name);
     }
+    // Ogre indexes a resource location when it is added, so files written afterwards are not found until the
+    // location is registered again. On a clean install the folder was empty when it was first added.
+    Ogre::ResourceGroupManager& res_mgr = Ogre::ResourceGroupManager::getSingleton();
+    res_mgr.removeResourceLocation(output_dir_ + "temp/char/", "FFVII");
+    res_mgr.addResourceLocation(output_dir_ + "temp/char/", "FileSystem", "FFVII", true, true);
     for (auto it = used_models_and_anims_.map.begin(); it != used_models_and_anims_.map.end(); it ++){
         models.push_back(it->first);
     }
@@ -391,14 +406,21 @@ void FieldDataInstaller::ConvertModels(std::string model_name){
                             animated_bounds.getCenter().length() + animated_bounds.getHalfSize().length()
                         ));
         }
-        ExportMesh(output_dir_ + FIELD_MODELS_DIR + "/", mesh);
+        ExportMesh(data_dir_ + FIELD_MODELS_DIR + "/", mesh);
+        // Models can share a skeleton name with different bones. Drop the model (and its skeleton and mesh) so the
+        // next one doesn't reuse them.
+        VGears::HRCFileManager::GetSingleton().remove(model->first, "FFVII");
     }
     catch (const Ogre::Exception& ex){
-        //write_output_line_("[ERROR] Ogre exception converting model " + model->first + ": " + ex.what());
+        if (VGears::HRCFileManager::GetSingleton().resourceExists(model->first, "FFVII"))
+            VGears::HRCFileManager::GetSingleton().remove(model->first, "FFVII");
+        write_output_line_("[ERROR] Ogre exception converting model " + model->first + ": " + ex.what());
         std::cerr << "[ERROR] Ogre exception converting model " << model->first <<": " << ex.what() << std::endl;
     }
     catch (const std::exception& ex){
-        //write_output_line_("[ERROR] Exception converting model " + model->first + ": " + ex.what());
+        if (VGears::HRCFileManager::GetSingleton().resourceExists(model->first, "FFVII"))
+            VGears::HRCFileManager::GetSingleton().remove(model->first, "FFVII");
+        write_output_line_("[ERROR] Exception converting model " + model->first + ": " + ex.what());
         std::cerr << "[ERROR] Exception converting model " << model->first << ": " << ex.what() << std::endl;
     }
 }
@@ -409,6 +431,7 @@ void FieldDataInstaller::ExportMesh(const std::string outdir, const Ogre::MeshPt
     // TODO: Share function with pc model exporter
     VGears::String base_mesh_name;
     VGears::StringUtil::splitFull(mesh->getName(), base_mesh_name);
+    MakeMaterialsUnique(mesh, base_mesh_name);
     Ogre::MeshSerializer mesh_serializer;
     Ogre::SkeletonPtr skeleton(mesh->getSkeleton());
     Ogre::SkeletonSerializer skeleton_serializer;
@@ -418,7 +441,6 @@ void FieldDataInstaller::ExportMesh(const std::string outdir, const Ogre::MeshPt
     Ogre::Mesh::SubMeshIterator it(mesh->getSubMeshIterator());
     Ogre::MaterialSerializer mat_ser;
     size_t i(0);
-    std::set<std::string> textures;
     while (it.hasMoreElements()){
         Ogre::SubMesh *sub_mesh(it.getNext());
         Ogre::MaterialPtr mat(Ogre::MaterialManager::getSingleton().getByName(sub_mesh->getMaterialName()));
@@ -436,6 +458,16 @@ void FieldDataInstaller::ExportMesh(const std::string outdir, const Ogre::MeshPt
                             ){
                                 Ogre::TextureUnitState* unit = pass->getTextureUnitState(texture_unit_num);
                                 if (unit && unit->getTextureName().empty() == false){
+                                    // Materials are shared by name across all model types, so this one may have
+                                    // already been converted for a battle model (its texture is then a png in
+                                    // another folder). There is nothing to convert in that case.
+                                    if (!boost::filesystem::exists(output_dir_ + "temp/char/" + unit->getTextureName())){
+                                        write_output_line_(
+                                          "[WARNING] Texture " + unit->getTextureName() + " of model " + base_mesh_name
+                                          + " not found; skipping it."
+                                        );
+                                        continue;
+                                    }
                                     // Convert the texture from .tex to .png.
                                     TexFile tex(output_dir_ + "temp/char/" + unit->getTextureName());
 
@@ -445,17 +477,16 @@ void FieldDataInstaller::ExportMesh(const std::string outdir, const Ogre::MeshPt
                                     VGears::StringUtil::splitBase(unit->getTextureName(), base_name);
                                     unit->setTextureName(base_mesh_name + "_" + base_name + ".png");
 
-                                    tex.SavePng(output_dir_ + FIELD_MODELS_DIR + "/" + base_name + ".png", 0);
+                                    tex.SavePng(data_dir_ + FIELD_MODELS_DIR + "/" + base_name + ".png", 0);
                                     // Copy subtexture (xxxx.png) to model_xxxx.png
                                     // TODO: obtain the "data" folder
                                     // programatically.
                                     boost::filesystem::copy_file(
-                                        output_dir_ + FIELD_MODELS_DIR + "/" + base_name + ".png",
-                                        output_dir_ + FIELD_MODELS_DIR + "/"
+                                        data_dir_ + FIELD_MODELS_DIR + "/" + base_name + ".png",
+                                        data_dir_ + FIELD_MODELS_DIR + "/"
                                         + base_mesh_name + "_" + base_name + ".png",
                                       boost::filesystem::copy_options::overwrite_existing
                                     );
-                                    textures.insert(unit->getTextureName());
                                 }
                             }
                         }
@@ -471,21 +502,6 @@ void FieldDataInstaller::ExportMesh(const std::string outdir, const Ogre::MeshPt
         ++ i;
     }
     mat_ser.exportQueued(outdir + base_mesh_name + VGears::EXT_MATERIAL);
-    for (auto& texture_name : textures){
-        std::string tex_name = texture_name.c_str();
-        try{
-            Ogre::TexturePtr texture_ptr = Ogre::TextureManager::getSingleton().load(tex_name, "FFVIITextures");
-            //Ogre::TexturePtr texture_ptr = Ogre::TextureManager::getSingleton().load(tex_name, "FFVII");
-            Ogre::Image image;
-            texture_ptr->convertToImage(image);
-            Ogre::String base_name;
-            VGears::StringUtil::splitBase(texture_name, base_name);
-            image.save(outdir + base_mesh_name + "_" + base_name + ".png");
-        }
-        catch (std::exception const& ex){
-            std::cerr << "[ERROR] Exception: " << ex.what() << std::endl;
-        }
-    }
 }
 
 float FieldDataInstaller::GetFieldScaleFactor(size_t field_id){
@@ -540,10 +556,10 @@ void FieldDataInstaller::PcFieldToVGearsField(VGears::FLevelFilePtr& field){
         decompiled = FieldDecompiler::Decompile(
           field->getName(), raw_field_data, formatter, gateway_script_data, FieldScriptHeader()
         );
-        std::ofstream script_file(output_dir_ + "/" + FIELD_MAPS_DIR + "/" + field->getName() + "/script.lua");
+        std::ofstream script_file(data_dir_ + "/" + FIELD_MAPS_DIR + "/" + field->getName() + "/script.lua");
         if (script_file.is_open()){
             script_file << decompiled.luaScript;
-            field_text_writer_.Begin(output_dir_ + "/" + FIELD_MAPS_DIR + "/" + field->getName() + "/text.xml");
+            field_text_writer_.Begin(data_dir_ + "/" + FIELD_MAPS_DIR + "/" + field->getName() + "/text.xml");
             try{field_text_writer_.Write(raw_field_data, field->getName());}
             catch (const std::out_of_range& ex){
                 write_output_line_("[ERROR] Failed to read texts from field " + field->getName() + ": " + ex.what());
@@ -791,12 +807,12 @@ void FieldDataInstaller::PcFieldToVGearsField(VGears::FLevelFilePtr& field){
 
         doc.LinkEndChild(element.release());
 
-        doc.SaveFile(output_dir_ + "/" + FIELD_MAPS_DIR + "/" + field->getName() + "/map.xml");
+        doc.SaveFile(data_dir_ + "/" + FIELD_MAPS_DIR + "/" + field->getName() + "/map.xml");
         const VGears::PaletteFilePtr& pal = field->GetPalette();
         const VGears::BackgroundFilePtr& bg = field->GetBackground();
         std::unique_ptr<Ogre::Image> bg_image(bg->CreateImage(pal));
         bg_image->encode("png");
-        bg_image->save(output_dir_ + "/" + FIELD_MAPS_DIR + "/" + field->getName() + "/tiles.png");
+        bg_image->save(data_dir_ + "/" + FIELD_MAPS_DIR + "/" + field->getName() + "/tiles.png");
         {
             TiXmlDocument bg_doc;
             std::unique_ptr<TiXmlElement> bg_element(new TiXmlElement("background2d"));
@@ -890,7 +906,7 @@ void FieldDataInstaller::PcFieldToVGearsField(VGears::FLevelFilePtr& field){
                 //}
             }
             bg_doc.LinkEndChild(bg_element.release());
-            bg_doc.SaveFile(output_dir_ + "/" + FIELD_MAPS_DIR + "/" + field->getName() + "/bg.xml");
+            bg_doc.SaveFile(data_dir_ + "/" + FIELD_MAPS_DIR + "/" + field->getName() + "/bg.xml");
         }
     }
     {
@@ -911,7 +927,7 @@ void FieldDataInstaller::PcFieldToVGearsField(VGears::FLevelFilePtr& field){
             wmesh_element->LinkEndChild(xml_element.release());
         }
         doc.LinkEndChild(wmesh_element.release());
-        doc.SaveFile(output_dir_ + "/" + FIELD_MAPS_DIR + "/" + field->getName() + "/wm.xml");
+        doc.SaveFile(data_dir_ + "/" + FIELD_MAPS_DIR + "/" + field->getName() + "/wm.xml");
     }
     converted_map_list_.push_back(field->getName());
 }

+ 12 - 0
src/installer/FieldDataInstaller.h

@@ -356,6 +356,13 @@ class FieldDataInstaller{
           std::function<void(std::string)> log_output_line
         );
 
+        /**
+         * Fills the converted field list with the fields already installed in the data directory.
+         *
+         * Used when the fields are not converted in this run but other steps (like the world map scripts) need them.
+         */
+        void LoadInstalledFields();
+
         /**
          * Installer destructor.
          */
@@ -624,6 +631,11 @@ class FieldDataInstaller{
          */
         std::string output_dir_;
 
+        /**
+         * Directory where the game data is installed (<install dir>/data/).
+         */
+        std::string data_dir_;
+
         /**
          * Helper variable to indicate internal progress of installation steps.
          */

+ 79 - 0
src/installer/InstallLayout.cpp

@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#include "InstallLayout.h"
+
+namespace InstallLayout{
+
+    std::string DataDir(const std::string& root){return root + "data/";}
+
+    std::string TempDir(const std::string& root){return root + "temp/";}
+
+    const std::vector<std::string>& DataSubdirs(){
+        static const std::vector<std::string> dirs = {
+          "audio/musics", "audio/sounds", "fields", "fonts", "gamedata", "gamedata/enemy", "gamedata/attack",
+          "gamedata/formation", "images/icons", "images/other", "images/characters", "images/fonts", "images/reels",
+          "images/window", "screens", "scripts", "scripts/battle_models", "system", "texts", "models/fields",
+          "models/battle/characters", "models/battle/scenes", "models/battle/enemies", "models/battle/attacks",
+          "models/world/terrain/0", "models/world/terrain/1", "models/world/terrain/2", "models/world/elements",
+          "world/0", "world/1", "world/2", "wm"
+        };
+        return dirs;
+    }
+
+    const std::vector<std::string>& TempSubdirs(){
+        static const std::vector<std::string> dirs = {"char", "battle_models", "world_models", "spell_models", "wm"};
+        return dirs;
+    }
+
+    const std::vector<std::string>& KeptTempSubdirs(){
+        static const std::vector<std::string> dirs = {"world_models"};
+        return dirs;
+    }
+
+    std::vector<std::string> RemovableTempSubdirs(){
+        std::vector<std::string> removable;
+        for (const std::string& dir : TempSubdirs()){
+            bool kept = false;
+            for (const std::string& k : KeptTempSubdirs()) kept = kept || (k == dir);
+            if (!kept) removable.push_back(dir);
+        }
+        return removable;
+    }
+
+    const std::vector<std::string>& GameResourceDirs(){
+        static const std::vector<std::string> dirs = {
+          "data/system", "data/audio", "data/fields", "data/fonts", "data/gamedata", "data/images", "data/models/fields",
+          "data/models/world", "data/models/battle", "data/screens", "data/scripts", "data/texts", "data/wm",
+          "temp/world_models"
+        };
+        return dirs;
+    }
+
+    std::string MigrateInstallDir(
+      std::string legacy_data_dir, const std::string& legacy_config_dir, const std::string& default_dir
+    ){
+        while (!legacy_data_dir.empty() && legacy_data_dir.back() == '/') legacy_data_dir.pop_back();
+        const std::string suffix = "/data";
+        if (
+          legacy_data_dir.size() >= suffix.size()
+          && legacy_data_dir.compare(legacy_data_dir.size() - suffix.size(), suffix.size(), suffix) == 0
+        ) legacy_data_dir.erase(legacy_data_dir.size() - suffix.size());
+        if (!legacy_data_dir.empty()) return legacy_data_dir;
+        if (!legacy_config_dir.empty()) return legacy_config_dir;
+        return default_dir;
+    }
+
+}

+ 82 - 0
src/installer/InstallLayout.h

@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#pragma once
+
+#include <string>
+#include <vector>
+
+/**
+ * Where the installer puts things.
+ *
+ * The installation directory is also the working directory of the game, which reads its data from
+ * "<dir>/data/..." and its intermediate world map files from "<dir>/temp/world_models".
+ *
+ * Every path is relative to the installation directory unless stated otherwise.
+ */
+namespace InstallLayout{
+
+    /**
+     * Gets the directory with the game data.
+     *
+     * @param[in] root Installation directory, ending with '/'.
+     */
+    std::string DataDir(const std::string& root);
+
+    /**
+     * Gets the directory with the intermediate files.
+     *
+     * @param[in] root Installation directory, ending with '/'.
+     */
+    std::string TempDir(const std::string& root);
+
+    /**
+     * Directories created inside the data directory.
+     */
+    const std::vector<std::string>& DataSubdirs();
+
+    /**
+     * Directories created inside the temp directory.
+     */
+    const std::vector<std::string>& TempSubdirs();
+
+    /**
+     * Temp directories that the game itself needs, so they are not removed when cleaning up.
+     */
+    const std::vector<std::string>& KeptTempSubdirs();
+
+    /**
+     * Temp directories that are removed when cleaning up.
+     */
+    std::vector<std::string> RemovableTempSubdirs();
+
+    /**
+     * Directories the game registers as resource locations, relative to the installation directory.
+     */
+    const std::vector<std::string>& GameResourceDirs();
+
+    /**
+     * Calculates the installation directory from the settings of older versions, which stored the configuration
+     * directory and the data directory (<installation directory>/data) separately.
+     *
+     * @param[in] legacy_data_dir Old data directory setting, can be empty.
+     * @param[in] legacy_config_dir Old configuration directory setting, can be empty.
+     * @param[in] default_dir Directory to use when there is nothing to migrate.
+     */
+    std::string MigrateInstallDir(
+      std::string legacy_data_dir, const std::string& legacy_config_dir, const std::string& default_dir
+    );
+
+}

+ 178 - 0
src/installer/InstallLog.cpp

@@ -0,0 +1,178 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#include "InstallLog.h"
+
+/**
+ * Number of messages of each severity that are kept for the summary.
+ */
+static const size_t kKeptMessages = 200;
+
+/**
+ * Stream buffer that passes everything through to the original buffer and sends each complete line to the log.
+ */
+class InstallLog::Tee : public std::streambuf{
+
+    public:
+
+        Tee(std::ostream& stream, InstallLog& log): stream_(stream), log_(log), original_(stream.rdbuf(this)){}
+
+        ~Tee(){stream_.rdbuf(original_);}
+
+        /**
+         * Writes directly to the original stream, without capturing it.
+         */
+        void WriteOriginal(const std::string& text){original_->sputn(text.data(), text.size());}
+
+    protected:
+
+        int_type overflow(int_type c) override{
+            if (c == traits_type::eof()) return traits_type::not_eof(c);
+            original_->sputc(static_cast<char>(c));
+            if (c == '\n'){
+                std::string line;
+                line.swap(line_);
+                if (!line.empty()) log_.AddCaptured(line);
+            }
+            else line_ += static_cast<char>(c);
+            return c;
+        }
+
+        int sync() override{return original_->pubsync();}
+
+    private:
+
+        std::ostream& stream_;
+        InstallLog& log_;
+        std::streambuf* original_;
+        std::string line_;
+};
+
+InstallLog::InstallLog(): counts_{0, 0, 0}{}
+
+InstallLog& InstallLog::Get(){
+    static InstallLog log;
+    return log;
+}
+
+void InstallLog::Open(const std::string& path, bool append){
+    Close();
+    std::lock_guard<std::mutex> lock(mutex_);
+    path_ = path;
+    file_.open(path, std::ios::out | (append ? std::ios::app : std::ios::trunc));
+    for (int i = 0; i < 3; ++ i){
+        counts_[i] = 0;
+        first_[i].clear();
+    }
+    last_message_.clear();
+    in_step_ = false;
+    tee_out_ = std::make_unique<Tee>(std::cout, *this);
+    tee_err_ = std::make_unique<Tee>(std::cerr, *this);
+}
+
+void InstallLog::Close(){
+    // The tees restore the streams on destruction. Destroy them without holding the lock, since they
+    // don't use it.
+    tee_out_.reset();
+    tee_err_.reset();
+    std::lock_guard<std::mutex> lock(mutex_);
+    if (file_.is_open()) file_.close();
+}
+
+void InstallLog::SetSink(Sink sink){
+    std::lock_guard<std::mutex> lock(mutex_);
+    sink_ = std::move(sink);
+}
+
+InstallLog::Level InstallLog::Classify(const std::string& message){
+    if (message.compare(0, 7, "[ERROR]") == 0 || message.compare(0, 9, "Exception") == 0) return ERROR_LEVEL;
+    if (message.compare(0, 9, "[WARNING]") == 0) return WARNING;
+    return INFO;
+}
+
+void InstallLog::Add(const std::string& message, bool show){
+    const Level level = Classify(message);
+    Sink sink;
+    {
+        std::lock_guard<std::mutex> lock(mutex_);
+        // Some messages are written twice in a row by the code that produces them.
+        if (level != INFO && message == last_message_) return;
+        last_message_ = message;
+        ++ counts_[level];
+        if (first_[level].size() < kKeptMessages) first_[level].push_back(message);
+        if (file_.is_open()) file_ << message << '\n' << std::flush;
+        if (show) sink = sink_;
+    }
+    // Echo to the terminal without capturing the message again.
+    if (tee_out_) tee_out_->WriteOriginal(message + "\n");
+    if (sink) sink(level, message);
+}
+
+void InstallLog::AddCaptured(const std::string& line){
+    const Level level = Classify(line);
+    Sink sink;
+    {
+        std::lock_guard<std::mutex> lock(mutex_);
+        if (level != INFO && line == last_message_) return;
+        if (level != INFO) last_message_ = line;
+        ++ counts_[level];
+        if (level != INFO && first_[level].size() < kKeptMessages) first_[level].push_back(line);
+        if (file_.is_open()) file_ << line << '\n';
+        // Only problems are shown in the window; the rest of the captured output is too verbose.
+        if (level != INFO) sink = sink_;
+    }
+    if (sink) sink(level, line);
+}
+
+void InstallLog::BeginStep(const std::string& name){
+    EndStep();
+    std::lock_guard<std::mutex> lock(mutex_);
+    step_name_ = name;
+    for (int i = 0; i < 3; ++ i) step_start_[i] = counts_[i];
+    in_step_ = true;
+}
+
+void InstallLog::EndStep(){
+    std::string summary;
+    {
+        std::lock_guard<std::mutex> lock(mutex_);
+        if (!in_step_) return;
+        in_step_ = false;
+        const size_t errors = counts_[ERROR_LEVEL] - step_start_[ERROR_LEVEL];
+        const size_t warnings = counts_[WARNING] - step_start_[WARNING];
+        if (errors == 0 && warnings == 0) return;
+        summary = "Step \"" + step_name_ + "\" finished with " + std::to_string(errors) + " error(s) and "
+          + std::to_string(warnings) + " warning(s).";
+    }
+    // Not prefixed, so it is information and doesn't count as a problem itself.
+    Add(summary);
+}
+
+size_t InstallLog::Count(Level level) const{
+    std::lock_guard<std::mutex> lock(mutex_);
+    return counts_[level];
+}
+
+std::vector<std::string> InstallLog::First(Level level, size_t max) const{
+    std::lock_guard<std::mutex> lock(mutex_);
+    std::vector<std::string> messages = first_[level];
+    if (messages.size() > max) messages.resize(max);
+    return messages;
+}
+
+std::string InstallLog::Path() const{
+    std::lock_guard<std::mutex> lock(mutex_);
+    return path_;
+}

+ 141 - 0
src/installer/InstallLog.h

@@ -0,0 +1,141 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#pragma once
+
+#include <fstream>
+#include <functional>
+#include <iostream>
+#include <memory>
+#include <mutex>
+#include <string>
+#include <vector>
+
+/**
+ * The installer log.
+ *
+ * Collects every message of an installation in one place: it is written to a log file in the installation
+ * directory, passed to an optional sink (the log window), and counted by severity to show a summary at the end.
+ *
+ * While open, anything the installer prints to std::cout or std::cerr is captured too, so messages printed by
+ * code that knows nothing about the log are not lost. The severity of a line is taken from its prefix:
+ * "[ERROR]" and "Exception" are errors, "[WARNING]" is a warning, anything else is information.
+ *
+ * It is thread-safe.
+ */
+class InstallLog{
+
+    public:
+
+        /**
+         * Message severity.
+         */
+        enum Level{INFO, WARNING, ERROR_LEVEL};
+
+        /**
+         * Receives the messages that must be shown to the user.
+         */
+        typedef std::function<void(Level, const std::string&)> Sink;
+
+        /**
+         * Gets the log.
+         */
+        static InstallLog& Get();
+
+        /**
+         * Starts a new log: truncates the file, resets the counters and starts capturing std::cout and std::cerr.
+         *
+         * @param[in] path Path of the log file.
+         * @param[in] append True to add to the file instead of replacing it.
+         */
+        void Open(const std::string& path, bool append = false);
+
+        /**
+         * Stops capturing the standard streams and closes the file.
+         */
+        void Close();
+
+        /**
+         * Sets the sink that receives the messages that must be shown to the user.
+         */
+        void SetSink(Sink sink);
+
+        /**
+         * Adds a message.
+         *
+         * @param[in] message The message. Its prefix sets the severity.
+         * @param[in] show True if the message must also be passed to the sink.
+         */
+        void Add(const std::string& message, bool show = true);
+
+        /**
+         * Starts a step of the installation. The previous step, if any, is ended.
+         *
+         * @param[in] name Name of the step, shown in its summary.
+         */
+        void BeginStep(const std::string& name);
+
+        /**
+         * Ends the current step. If it had errors or warnings, a line with their number is added to the log.
+         */
+        void EndStep();
+
+        /**
+         * Gets the number of messages of a severity.
+         */
+        size_t Count(Level level) const;
+
+        /**
+         * Gets the first messages of a severity.
+         *
+         * @param[in] level The severity.
+         * @param[in] max Maximum number of messages.
+         */
+        std::vector<std::string> First(Level level, size_t max) const;
+
+        /**
+         * Gets the path of the log file.
+         */
+        std::string Path() const;
+
+        /**
+         * Gets the severity of a message according to its prefix.
+         */
+        static Level Classify(const std::string& message);
+
+    private:
+
+        class Tee;
+
+        InstallLog();
+
+        /**
+         * Handles a line captured from a standard stream.
+         */
+        void AddCaptured(const std::string& line);
+
+        mutable std::mutex mutex_;
+        std::ofstream file_;
+        std::string path_;
+        Sink sink_;
+        size_t counts_[3];
+        std::vector<std::string> first_[3];
+        std::string last_message_;
+        std::string step_name_;
+        size_t step_start_[3];
+        bool in_step_ = false;
+        std::unique_ptr<Tee> tee_out_;
+        std::unique_ptr<Tee> tee_err_;
+};

+ 128 - 0
src/installer/InstallOptions.cpp

@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#include "InstallOptions.h"
+
+namespace InstallRules{
+
+    InstallOptions Preset(InstallPreset preset, bool videos_available){
+        InstallOptions o;
+        switch (preset){
+            case InstallPreset::EVERYTHING:
+                o.battle_data = o.battle_models = o.kernel = o.images = o.sounds = o.music = true;
+                o.fields = o.field_models = o.wm = o.wm_models = true;
+                o.videos = videos_available;
+                break;
+            case InstallPreset::FIELDS:
+                o.kernel = o.fields = o.field_models = true;
+                break;
+            case InstallPreset::WORLD_MAP:
+                o.wm = o.wm_models = true;
+                break;
+            case InstallPreset::BATTLE:
+                o.kernel = o.battle_data = o.battle_models = true;
+                break;
+            case InstallPreset::MEDIA:
+                o.images = o.sounds = o.music = true;
+                o.videos = videos_available;
+                break;
+            case InstallPreset::CUSTOM:
+                break;
+        }
+        ApplyDependencies(o);
+        return o;
+    }
+
+    InstallOptions Selectable(const InstallOptions& options){
+        InstallOptions s;
+        s.battle_data = s.battle_models = s.kernel = s.images = s.sounds = s.music = s.videos = true;
+        s.fields = s.wm = true;
+        s.field_models = options.fields;
+        s.wm_models = options.wm;
+        return s;
+    }
+
+    void ApplyDependencies(InstallOptions& o){
+        // Applied until nothing changes, in case a rule depends on the result of another.
+        bool changed = true;
+        while (changed){
+            const InstallOptions before = o;
+            const InstallOptions s = Selectable(o);
+            o.field_models = o.field_models && s.field_models;
+            o.wm_models = o.wm_models && s.wm_models;
+            changed = o.field_models != before.field_models || o.wm_models != before.wm_models;
+        }
+    }
+
+    std::vector<std::string> Problems(const InstallOptions& options, bool fields_installed){
+        std::vector<std::string> problems;
+        if (options.wm && !options.fields && !fields_installed){
+            problems.push_back(
+              "The world map scripts need the field maps: select 'Extract field maps', or install them first."
+            );
+        }
+        return problems;
+    }
+
+    ToolNeeds ToolsNeeded(const InstallOptions& o){
+        ToolNeeds needs;
+        needs.ffmpeg = o.sounds || o.music;
+        needs.timidity = o.music;
+        return needs;
+    }
+
+    std::vector<std::string> ToolConsequences(const InstallOptions& o, bool ffmpeg_usable, bool timidity_usable){
+        std::vector<std::string> consequences;
+        const ToolNeeds needs = ToolsNeeded(o);
+        if (needs.ffmpeg && !ffmpeg_usable){
+            if (o.sounds) consequences.push_back("The sound effects will not be installed: FFmpeg is needed to convert them.");
+            if (o.music) consequences.push_back("The music will not be installed: FFmpeg is needed to convert it.");
+        }
+        else if (needs.timidity && !timidity_usable){
+            consequences.push_back("The music will not be installed: TiMidity is needed to play the MIDI files.");
+        }
+        return consequences;
+    }
+
+    uint64_t EstimateMegabytes(const InstallOptions& o, uint64_t* temporary){
+        uint64_t installed = 0;
+        uint64_t temp = 0;
+        if (o.kernel) installed += 1;
+        if (o.battle_data) installed += 10;
+        if (o.battle_models){
+            installed += 220;
+            temp += 225;
+        }
+        if (o.images) installed += 2;
+        if (o.sounds) installed += 100;
+        if (o.music) installed += 130;
+        if (o.fields) installed += 380;
+        if (o.field_models){
+            installed += 190;
+            temp += 85;
+        }
+        if (o.wm){
+            installed += 30;
+            temp += 6;
+        }
+        if (o.wm_models){
+            installed += 7;
+            temp += 6;
+        }
+        if (temporary) *temporary = temp;
+        return installed;
+    }
+
+}

+ 120 - 0
src/installer/InstallOptions.h

@@ -0,0 +1,120 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#pragma once
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+/**
+ * The options of an installation, and the rules between them.
+ */
+struct InstallOptions{
+    bool battle_data = false;
+    bool battle_models = false;
+    bool kernel = false;
+    bool images = false;
+    bool sounds = false;
+    bool music = false;
+    bool videos = false;
+    bool fields = false;
+    bool field_models = false;
+    bool wm = false;
+    bool wm_models = false;
+};
+
+/**
+ * Ready-made sets of options.
+ */
+enum class InstallPreset{EVERYTHING, FIELDS, WORLD_MAP, BATTLE, MEDIA, CUSTOM};
+
+namespace InstallRules{
+
+    /**
+     * Gets the options of a preset.
+     *
+     * The custom preset has everything off.
+     *
+     * @param[in] preset The preset.
+     * @param[in] videos_available True if the input disk has videos.
+     */
+    InstallOptions Preset(InstallPreset preset, bool videos_available);
+
+    /**
+     * Disables every option whose requirements are not met, so a disabled option is never active.
+     *
+     * - Field 3D models need field maps.
+     * - World map 3D models need world map data.
+     */
+    void ApplyDependencies(InstallOptions& options);
+
+    /**
+     * Options that can currently be chosen, that is, whose requirements are met.
+     */
+    InstallOptions Selectable(const InstallOptions& options);
+
+    /**
+     * Describes the problems of an installation that can be detected from its options.
+     *
+     * @param[in] options The options.
+     * @param[in] fields_installed True if the field maps are already installed in the installation directory.
+     */
+    std::vector<std::string> Problems(const InstallOptions& options, bool fields_installed);
+
+    /**
+     * Which external programs the options need.
+     */
+    struct ToolNeeds{
+
+        /**
+         * FFmpeg: converts the sounds and the music.
+         */
+        bool ffmpeg = false;
+
+        /**
+         * TiMidity: renders the MIDI music, and its output is converted by FFmpeg.
+         */
+        bool timidity = false;
+    };
+
+    /**
+     * Gets the programs needed to install what the options choose.
+     */
+    ToolNeeds ToolsNeeded(const InstallOptions& options);
+
+    /**
+     * Describes what the installation will be missing if the programs can't be used.
+     *
+     * @param[in] options The options.
+     * @param[in] ffmpeg_usable True if a working FFmpeg was found.
+     * @param[in] timidity_usable True if a working TiMidity was found.
+     * @return One sentence for each part that can't be installed. Empty if nothing will be missing.
+     */
+    std::vector<std::string> ToolConsequences(const InstallOptions& options, bool ffmpeg_usable, bool timidity_usable);
+
+    /**
+     * Estimates the disk space the options use, in MB.
+     *
+     * The values come from a full installation of the PC game: they are an orientation, not a promise. Videos are
+     * not estimated because they depend on the disk.
+     *
+     * @param[in] options The options.
+     * @param[out] temporary Extra space used while installing that is released at the end.
+     * @return Space used by the installed data.
+     */
+    uint64_t EstimateMegabytes(const InstallOptions& options, uint64_t* temporary = nullptr);
+
+}

+ 184 - 0
src/installer/InstallVerifier.cpp

@@ -0,0 +1,184 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#include "InstallVerifier.h"
+#include <boost/filesystem.hpp>
+
+namespace fs = boost::filesystem;
+
+namespace{
+
+    /**
+     * Collects the results of the checks.
+     */
+    class Checker{
+
+        public:
+
+            Checker(const std::string& root, VerifyResult& result): root_(root), result_(result){
+                if (!root_.empty() && root_.back() != '/') root_ += "/";
+            }
+
+            /**
+             * A file must exist.
+             */
+            void File(const std::string& group, const std::string& path){
+                ++ result_.checked;
+                boost::system::error_code ec;
+                if (!fs::is_regular_file(root_ + path, ec)) Fail(group, path + " is missing");
+            }
+
+            /**
+             * A file whose name starts with a prefix (a library with a version number) must exist in a directory.
+             */
+            void FileWithPrefix(const std::string& group, const std::string& dir, const std::string& prefix){
+                ++ result_.checked;
+                boost::system::error_code ec;
+                for (fs::directory_iterator it(root_ + dir, ec), end; !ec && it != end; it.increment(ec)){
+                    if (it->path().filename().string().compare(0, prefix.size(), prefix) == 0) return;
+                }
+                Fail(group, "no " + prefix + "* file in " + (dir.empty() ? "the installation directory" : dir));
+            }
+
+            /**
+             * A directory must have at least a number of files with an extension, searching in subdirectories.
+             *
+             * @param[in] extension Extension with the dot, or the full file name if it has none (like "map.xml").
+             * An empty string matches every file.
+             */
+            void Files(
+              const std::string& group, const std::string& dir, const std::string& extension, int minimum
+            ){
+                ++ result_.checked;
+                int found = 0;
+                boost::system::error_code ec;
+                for (fs::recursive_directory_iterator it(root_ + dir, ec), end; !ec && it != end; it.increment(ec)){
+                    if (!fs::is_regular_file(it->path(), ec)) continue;
+                    const std::string name = it->path().filename().string();
+                    if (
+                      extension.empty() || (name.size() >= extension.size()
+                        && name.compare(name.size() - extension.size(), extension.size(), extension) == 0)
+                    ){
+                        if (++ found >= minimum) return;
+                    }
+                }
+                std::string wanted = extension.empty() ? "files" : extension + " files";
+                Fail(
+                  group, dir + " has " + std::to_string(found) + " " + wanted + ", at least "
+                    + std::to_string(minimum) + " expected"
+                );
+            }
+
+            void Fail(const std::string& group, const std::string& message){
+                result_.problems.push_back({group, message});
+            }
+
+        private:
+
+            std::string root_;
+            VerifyResult& result_;
+    };
+
+}
+
+VerifyResult VerifyInstallation(const std::string& root, const std::string& reference_dir){
+    VerifyResult result;
+    Checker check(root, result);
+
+    // Configuration, plugins and the files of the repository, which the game always needs.
+    const std::string config = "Configuration";
+    check.File(config, "plugins.cfg");
+    check.File(config, "resources.cfg");
+    check.FileWithPrefix(config, "", "RenderSystem_GL");
+    check.FileWithPrefix(config, "", "Codec_STBI");
+    const std::string game = "Game files";
+    for (const char* file : {
+      "data/config/config.cfg", "data/system/system.fontdef", "data/system/system.lua", "data/system/system.material",
+      "data/fonts/_fonts.xml", "data/screens/_screens.xml", "data/scripts/_scripts.xml", "data/texts/_texts.xml"
+    }) check.File(game, file);
+
+    // Everything in the repository's data folder.
+    if (!reference_dir.empty()){
+        std::string reference = reference_dir;
+        if (reference.back() != '/') reference += "/";
+        boost::system::error_code ec;
+        std::string installed = root;
+        if (installed.empty() || installed.back() != '/') installed += "/";
+        int missing = 0;
+        std::string first;
+        for (fs::recursive_directory_iterator it(reference, ec), end; !ec && it != end; it.increment(ec)){
+            if (!fs::is_regular_file(it->path(), ec)) continue;
+            ++ result.checked;
+            const std::string relative = it->path().string().substr(reference.size());
+            if (!fs::exists(installed + relative, ec)){
+                if (missing ++ == 0) first = relative;
+            }
+        }
+        if (missing > 0){
+            check.Fail(
+              "Copied data files",
+              std::to_string(missing) + " file(s) of the V-Gears data folder are missing, for example " + first
+            );
+        }
+    }
+
+    // Converted game data.
+    const std::string kernel = "Kernel data";
+    for (const char* file : {
+      "accessories", "armors", "attacks", "characters", "commands", "growth", "initial_savemap", "items", "key_items",
+      "materia", "summons", "weapons"
+    }) check.File(kernel, std::string("data/gamedata/") + file + ".lua");
+
+    const std::string battle = "Battle data";
+    check.File(battle, "data/gamedata/attack/_index.xml");
+    check.Files(battle, "data/gamedata/enemy", ".xml", 1);
+    check.Files(battle, "data/gamedata/formation", ".xml", 1);
+
+    const std::string battle_models = "Battle models";
+    check.File(battle_models, "data/models/battle/characters.xml");
+    check.File(battle_models, "data/models/battle/scenes.xml");
+    check.Files(battle_models, "data/models/battle", ".mesh", 1);
+
+    const std::string fields = "Field maps";
+    check.File(fields, "data/fields/_fields.xml");
+    check.File(fields, "data/fields/md1stin/map.xml");
+    check.Files(fields, "data/fields", "map.xml", 500);
+
+    const std::string field_models = "Field models";
+    for (const char* file : {"cloud.mesh", "cloud.skeleton", "cloud.material"})
+        check.File(field_models, std::string("data/models/fields/") + file);
+    check.Files(field_models, "data/models/fields", ".mesh", 100);
+
+    const std::string world = "World map";
+    for (const char* file : {"WM0.MAP", "WM2.MAP", "WM3.MAP"}) check.File(world, std::string("data/wm/") + file);
+    check.File(world, "data/scripts/worldmap/wm0.lua");
+    check.File(world, "data/gamedata/world_encounters.xml");
+    check.Files(world, "data/models/world/terrain/0", ".mesh", 1);
+
+    const std::string world_models = "World map models";
+    check.Files(world_models, "temp/world_models", "", 1);
+    check.Files(world_models, "data/models/world", ".skeleton", 1);
+
+    // The sounds and the music are converted with FFmpeg and TiMidity: without them there are no ogg files.
+    const std::string sounds = "Sound effects";
+    check.File(sounds, "data/audio/sounds/_sounds.xml");
+    check.Files(sounds, "data/audio/sounds", ".ogg", 100);
+    const std::string music = "Music";
+    check.File(music, "data/audio/musics/_musics.xml");
+    check.Files(music, "data/audio/musics", ".ogg", 50);
+    const std::string images = "Images";
+    check.Files(images, "data/images/icons", "", 1);
+    return result;
+}

+ 68 - 0
src/installer/InstallVerifier.h

@@ -0,0 +1,68 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#pragma once
+
+#include <string>
+#include <vector>
+
+/**
+ * A thing that is missing in an installation.
+ */
+struct VerifyProblem{
+
+    /**
+     * Part of the installation it belongs to ("Field maps", "World map"...).
+     */
+    std::string group;
+
+    /**
+     * What is missing.
+     */
+    std::string message;
+};
+
+/**
+ * Result of verifying an installation.
+ */
+struct VerifyResult{
+
+    /**
+     * What is missing.
+     */
+    std::vector<VerifyProblem> problems;
+
+    /**
+     * Number of checks done.
+     */
+    int checked = 0;
+
+    /**
+     * True if nothing is missing.
+     */
+    bool Ok() const{return problems.empty();}
+};
+
+/**
+ * Checks that an installation has everything the game needs.
+ *
+ * It only looks at the existence of files, it doesn't open them. Parts of the installation can be skipped on purpose
+ * (for example the music), so a missing item is reported under the part it belongs to.
+ *
+ * @param[in] root Installation directory.
+ * @param[in] reference_dir V-Gears "data" folder of the game (the one copied to the installation). If not empty,
+ * every file in it must exist in the installation too. It can be empty.
+ */
+VerifyResult VerifyInstallation(const std::string& root, const std::string& reference_dir = "");

+ 881 - 88
src/installer/MainWindow.cpp

@@ -23,7 +23,26 @@
 #include <QtWidgets/QMessageBox>
 #include <QtCore/QTimer>
 #include <QtWidgets/QCheckBox>
+#include <QtWidgets/QPushButton>
+#include <QtGui/QDesktopServices>
+#include <QtCore/QUrl>
+#include <QtWidgets/QComboBox>
+#include <QtWidgets/QDialog>
+#include <QtWidgets/QDialogButtonBox>
+#include <QtWidgets/QTextBrowser>
+#include <QtWidgets/QVBoxLayout>
+#include <QtCore/QCoreApplication>
+#include <QtCore/QDirIterator>
+#include <QtCore/QFile>
+#include <QtCore/QFileInfo>
+#include <QtCore/QStorageInfo>
+#include <chrono>
 #include "DataInstaller.h"
+#include "ExternalTools.h"
+#include "InstallLayout.h"
+#include "InstallLog.h"
+#include "InstallOptions.h"
+#include "InstallVerifier.h"
 #include "MainWindow.h"
 #include <ui_MainWindow.h>
 
@@ -32,23 +51,224 @@
  */
 static bool installer_created = false;
 
+/**
+ * Finds the V-Gears "data" folder shipped with the game.
+ *
+ * It contains the configuration files and plugins (copied to the installation directory) and a "data" subfolder with
+ * the game data (copied to <installation directory>/data).
+ *
+ * @return Path of the folder, or an empty string if it can't be found.
+ */
+static QString FindGameDataDir(){
+    const QString app = QCoreApplication::applicationDirPath();
+    const QStringList candidates = {
+      app + "/data", app + "/../data", app + "/../../data", app + "/../share/v-gears",
+      "/usr/share/v-gears", QDir::currentPath() + "/data", QDir::currentPath() + "/../data"
+    };
+    for (const QString& c : candidates)
+        if (QDir(c + "/data").exists() && QFile::exists(c + "/plugins.cfg")) return QDir::cleanPath(c);
+    return QString();
+}
+
+/**
+ * Directories where the system's Ogre plugins are usually installed.
+ */
+static const QStringList kSystemPluginDirs = {
+  "/usr/lib/x86_64-linux-gnu/OGRE", "/usr/lib/OGRE", "/usr/lib64/OGRE", "/usr/local/lib/OGRE",
+  "/usr/lib/aarch64-linux-gnu/OGRE"
+};
+
+/**
+ * Gets the names of the plugins enabled in a plugins.cfg file.
+ */
+static QStringList ListOgrePlugins(const QString& cfg_path){
+    QStringList plugins;
+    QFile cfg(cfg_path);
+    if (!cfg.open(QIODevice::ReadOnly | QIODevice::Text)) return plugins;
+    while (!cfg.atEnd()){
+        const QString line = QString::fromUtf8(cfg.readLine()).trimmed();
+        if (line.startsWith("Plugin=") && !line.mid(7).trimmed().isEmpty()) plugins << line.mid(7).trimmed();
+    }
+    return plugins;
+}
+
+/**
+ * Finds a plugin library in a directory.
+ *
+ * @return Directory-relative file names of the library (it can have several names), empty if not found.
+ */
+static QStringList FindPluginFiles(const QString& dir, const QString& name){
+    return QDir(dir).entryList({name + ".so*", name + ".dll", name + ".dylib"}, QDir::Files);
+}
+
+/**
+ * Names of the plugins in the plugins.cfg of a folder that are neither in it nor in the system's Ogre folders.
+ */
+static QStringList MissingOgrePlugins(const QString& source_dir){
+    QStringList missing;
+    for (const QString& name : ListOgrePlugins(source_dir + "/plugins.cfg")){
+        bool found = !FindPluginFiles(source_dir, name).isEmpty();
+        for (const QString& dir : kSystemPluginDirs) found = found || !FindPluginFiles(dir, name).isEmpty();
+        if (!found) missing << name;
+    }
+    return missing;
+}
+
+/**
+ * Copies the Ogre plugins listed in the installation's plugins.cfg that are not already present there (for example
+ * the stock plugins shipped with the system's Ogre) from the system's Ogre plugin directory.
+ *
+ * @return Names of the plugins that couldn't be found anywhere.
+ */
+static QStringList CopyMissingOgrePlugins(const QString& install_dir){
+    QStringList missing;
+    for (const QString& name : ListOgrePlugins(install_dir + "plugins.cfg")){
+        if (!FindPluginFiles(install_dir, name).isEmpty()) continue;
+        bool found = false;
+        for (const QString& dir : kSystemPluginDirs){
+            for (const QString& f : FindPluginFiles(dir, name)){
+                if (QFile::copy(dir + "/" + f, install_dir + f)) found = true;
+            }
+            if (found) break;
+        }
+        if (!found) missing << name;
+    }
+    return missing;
+}
+
+/**
+ * Minimum free space needed besides the ISO extraction: converted data, intermediate files and logs.
+ */
+static const qint64 kRequiredFreeSpace = 3LL * 1024 * 1024 * 1024;
+
+/**
+ * Result of the checks done before starting an installation.
+ */
+struct PreflightResult{
+    QStringList errors;
+    QStringList warnings;
+};
+
+/**
+ * Checks that an installation can succeed before anything is extracted or written.
+ *
+ * @param[in] disk_image The selected disk image.
+ * @param[in] output Absolute installation directory, ending with '/'.
+ * @param[in] copy_data True if the V-Gears data files will be copied.
+ * @param[in] game_data_dir The V-Gears "data" folder to copy (only used if copy_data is true).
+ */
+static PreflightResult RunPreflight(
+  DiskImage& disk_image, const QString& output, bool copy_data, const QString& game_data_dir
+){
+    PreflightResult result;
+    const QString iso_path = QString::fromStdString(disk_image.getPath());
+
+    // Input.
+    if (iso_path.isEmpty()) result.errors << QObject::tr("No ISO image selected.");
+    else if (!QFileInfo(iso_path).isReadable()) result.errors << QObject::tr("The ISO image can't be read: %1").arg(iso_path);
+    else if (!disk_image.isValid()) result.errors << QObject::tr("The selected ISO is not a valid Final Fantasy VII image.");
+    else if (!disk_image.isSupported()) result.warnings << QString::fromStdString(disk_image.getWarningMessage());
+
+    // Output: it must be creatable and writable.
+    if (!QDir().mkpath(output)) result.errors << QObject::tr("The installation directory can't be created: %1").arg(output);
+    else{
+        QFile probe(output + ".v-gears-write-test");
+        if (!probe.open(QIODevice::WriteOnly)) result.errors << QObject::tr("The installation directory is not writable: %1").arg(output);
+        else{
+            probe.close();
+            probe.remove();
+        }
+        // Free space: the extracted ISO plus the converted data.
+        QStorageInfo storage(output);
+        const qint64 needed = kRequiredFreeSpace + (iso_path.isEmpty() ? 0 : QFileInfo(iso_path).size());
+        if (storage.isValid() && storage.bytesAvailable() < needed){
+            result.errors << QObject::tr("Not enough free space in %1: %2 MB available, about %3 MB needed.")
+              .arg(storage.rootPath()).arg(storage.bytesAvailable() / (1024 * 1024)).arg(needed / (1024 * 1024));
+        }
+    }
+
+    // Configuration and plugins.
+    if (copy_data){
+        if (game_data_dir.isEmpty() || !QFile::exists(game_data_dir + "/plugins.cfg") || !QDir(game_data_dir + "/data").exists())
+            result.errors << QObject::tr("The V-Gears 'data' folder was not found (it must contain plugins.cfg and a data folder).");
+        else{
+            const QStringList missing = MissingOgrePlugins(game_data_dir);
+            if (!missing.isEmpty())
+                result.errors << QObject::tr("Ogre plugins not found in the data folder or the system: %1").arg(missing.join(", "));
+        }
+    }
+    else{
+        if (!QFile::exists(output + "plugins.cfg") || !QFile::exists(output + "resources.cfg"))
+            result.errors << QObject::tr("The installation directory has no plugins.cfg and resources.cfg. Enable 'Copy V-Gears data files'.");
+        else{
+            const QStringList missing = MissingOgrePlugins(output);
+            if (!missing.isEmpty())
+                result.errors << QObject::tr("Ogre plugins not found in the installation directory or the system: %1").arg(missing.join(", "));
+        }
+    }
+    return result;
+}
+
+/**
+ * Recursively copies a directory, overwriting existing files.
+ *
+ * @return True if every file was copied.
+ */
+static bool CopyDirectory(const QString& src, const QString& dst){
+    const QDir src_dir(src);
+    if (!QDir().mkpath(dst)) return false;
+    bool ok = true;
+    QDirIterator it(src, QDir::Files | QDir::NoDotAndDotDot, QDirIterator::Subdirectories);
+    while (it.hasNext()){
+        const QString file = it.next();
+        const QString target = dst + "/" + src_dir.relativeFilePath(file);
+        QDir().mkpath(QFileInfo(target).absolutePath());
+        if (QFile::exists(target)) QFile::remove(target);
+        if (!QFile::copy(file, target)){
+            std::cout << "Could not copy " << file.toStdString() << " to " << target.toStdString() << std::endl;
+            ok = false;
+        }
+    }
+    return ok;
+}
+
 MainWindow::MainWindow(QWidget *parent): QMainWindow(parent), main_window_(new Ui::MainWindow){
     main_window_->setupUi(this);
     main_window_->advancedOptions->setVisible(main_window_->chk_advanced_options->isChecked());
-    // Goto the data tab by default.
-    main_window_->tabWidget->setCurrentIndex(0);
-    // Turn off the launcher tab for now since it dosen't actually work
-    //main_window_->tabWidget->setTabEnabled(0, false);
+    // Presets and the rules between options. Changing an option by hand switches the preset to "Custom".
+    for (QCheckBox* box : OptionBoxes()){
+        connect(box, &QCheckBox::toggled, this, [this](){UpdateOptions();});
+        connect(box, &QCheckBox::clicked, this, [this](){
+            if (main_window_->combo_preset->currentIndex() != PRESET_CUSTOM){
+                const QSignalBlocker blocker(main_window_->combo_preset);
+                main_window_->combo_preset->setCurrentIndex(PRESET_CUSTOM);
+            }
+        });
+    }
+    connect(
+      main_window_->combo_preset, QOverload<int>::of(&QComboBox::currentIndexChanged), this,
+      [this](int index){ApplyPreset(index);}
+    );
+    ApplyPreset(main_window_->combo_preset->currentIndex());
     InitSettings();
-    main_window_->btn_vgears_config->setText(settings_->value("ConfigDir").toString());
-    main_window_->line_data_dst->setText(settings_->value("DataDir").toString());
-    main_window_->line_vgears_exe->setText(settings_->value("VGearsEXE").toString());
-    //main_window_->line_data_dst->setText("/home/ivalentin/.v-gears/data/");
+    main_window_->line_data_dst->setText(settings_->value("InstallDir").toString());
+    // The audio tools are looked for once the window is shown.
+    connect(main_window_->label_tools, &QLabel::linkActivated, this, [this](const QString& link){
+        if (link == "locate-ffmpeg") LocateTool(true);
+        else if (link == "locate-timidity") LocateTool(false);
+    });
+    QTimer::singleShot(0, this, [this](){RefreshToolStatus();});
+    connect(main_window_->btn_close, &QPushButton::clicked, this, &QWidget::close);
     timer_ = new QTimer(this);
     connect(timer_, SIGNAL(timeout()), this, SLOT(DoProgress()));
 }
 
-MainWindow::~MainWindow(){delete main_window_;}
+MainWindow::~MainWindow(){
+    // Stop the installation, if there is one running, before the window goes away.
+    stop_requested_ = true;
+    if (worker_.joinable()) worker_.join();
+    delete main_window_;
+}
 
 void MainWindow::InitSettings(void){
     bool win = false;
@@ -61,16 +281,19 @@ void MainWindow::InitSettings(void){
 #else
     settings_ = new QSettings(QSettings::NativeFormat,QSettings::UserScope, "v-gears", "launcher", 0);
 #endif
-    //Check settings.
-    if (settings_->value("ConfigDir").isNull()){
-        if (win) settings_->setValue("ConfigDir", QString("%1/v-gears").arg(QDir::homePath()));
-        else settings_->setValue("ConfigDir", QString("%1/.v-gears").arg(QDir::homePath()));
-    }
-
-    if (settings_->value("DataDir").isNull()){
-        if (win) settings_->setValue("DataDir",QString("%1/v-gears/data").arg(QDir::homePath()));
-        else settings_->setValue("DataDir",QString("%1/.v-gears/data").arg(QDir::homePath()));
+    // There is a single installation directory: it holds the configuration (plugins.cfg, resources.cfg...) and the
+    // game data (<dir>/data), and it is where the game runs. Older versions stored two settings, ConfigDir and
+    // DataDir (the latter pointing to <dir>/data), so migrate them.
+    if (settings_->value("InstallDir").isNull()){
+        const QString default_dir = win ? QString("%1/v-gears").arg(QDir::homePath()) : QString("%1/.v-gears").arg(QDir::homePath());
+        const QString dir = QString::fromStdString(InstallLayout::MigrateInstallDir(
+          settings_->value("DataDir").toString().toStdString(), settings_->value("ConfigDir").toString().toStdString(),
+          default_dir.toStdString()
+        ));
+        settings_->setValue("InstallDir", dir);
     }
+    settings_->remove("DataDir");
+    settings_->remove("ConfigDir");
 
     if (settings_->value("VGearsEXE").isNull()){
         if (win) settings_->setValue("VGearsEXE",QString("%1/v-gears.exe").arg(QCoreApplication::applicationDirPath()));
@@ -78,52 +301,270 @@ void MainWindow::InitSettings(void){
     }
 }
 
-void MainWindow::on_line_vgears_config_editingFinished(){
-    settings_->setValue("ConfigDir",main_window_->btn_vgears_config->text());
+void MainWindow::OpenInstallLog(const QString& dir, bool append){
+    InstallLog& install_log = InstallLog::Get();
+    install_log.Open((dir + "installer.log").toStdString(), append);
+    install_log.SetSink([this](InstallLog::Level level, const std::string& message){
+        const QString text = QString::fromStdString(message).toHtmlEscaped();
+        const QString color = level == InstallLog::ERROR_LEVEL ? "#c62828" : (level == InstallLog::WARNING ? "#e08600" : "");
+        const QString html = color.isEmpty() ? text : QString("<span style=\"color:%1\">%2</span>").arg(color, text);
+        // The log can be written from any thread.
+        QMetaObject::invokeMethod(this, [this, html](){main_window_->data_log->append(html);}, Qt::QueuedConnection);
+    });
 }
 
-void MainWindow::on_btn_vgears_config_clicked(){
-    QString temp = QFileDialog::getExistingDirectory(
-      this, tr("Select Location of VGears Configuration Data,"), settings_->value("ConfigDir").toString()
-    );
-    if (!temp.isNull()){
-        settings_->setValue("ConfigDir", temp);
-        main_window_->btn_vgears_config->setText(temp);
+std::vector<QCheckBox*> MainWindow::OptionBoxes() const{
+    return {
+      main_window_->chk_battle_data, main_window_->chk_battle_models, main_window_->chk_kernel,
+      main_window_->chk_images, main_window_->chk_sounds, main_window_->chk_music, main_window_->chk_videos,
+      main_window_->chk_fields, main_window_->chk_field_models, main_window_->chk_wm, main_window_->chk_wm_models
+    };
+}
+
+InstallOptions MainWindow::CurrentOptions() const{
+    InstallOptions o;
+    o.battle_data = main_window_->chk_battle_data->isChecked();
+    o.battle_models = main_window_->chk_battle_models->isChecked();
+    o.kernel = main_window_->chk_kernel->isChecked();
+    o.images = main_window_->chk_images->isChecked();
+    o.sounds = main_window_->chk_sounds->isChecked();
+    o.music = main_window_->chk_music->isChecked();
+    o.videos = main_window_->chk_videos->isChecked();
+    o.fields = main_window_->chk_fields->isChecked();
+    o.field_models = main_window_->chk_field_models->isChecked();
+    o.wm = main_window_->chk_wm->isChecked();
+    o.wm_models = main_window_->chk_wm_models->isChecked();
+    return o;
+}
+
+void MainWindow::SetOptions(const InstallOptions& o){
+    main_window_->chk_battle_data->setChecked(o.battle_data);
+    main_window_->chk_battle_models->setChecked(o.battle_models);
+    main_window_->chk_kernel->setChecked(o.kernel);
+    main_window_->chk_images->setChecked(o.images);
+    main_window_->chk_sounds->setChecked(o.sounds);
+    main_window_->chk_music->setChecked(o.music);
+    // Videos can only be chosen when the disk has them.
+    main_window_->chk_videos->setChecked(o.videos);
+    main_window_->chk_fields->setChecked(o.fields);
+    main_window_->chk_field_models->setChecked(o.field_models);
+    main_window_->chk_wm->setChecked(o.wm);
+    main_window_->chk_wm_models->setChecked(o.wm_models);
+}
+
+void MainWindow::ApplyPreset(int index){
+    if (index == PRESET_CUSTOM){
+        UpdateOptions();
+        return;
     }
+    static const InstallPreset presets[] = {
+      InstallPreset::EVERYTHING, InstallPreset::FIELDS, InstallPreset::WORLD_MAP, InstallPreset::BATTLE,
+      InstallPreset::MEDIA
+    };
+    if (index < 0 || index >= static_cast<int>(sizeof(presets) / sizeof(presets[0]))) return;
+    // Videos are not on the install disk: they are extracted from the game disks, which have their own flow.
+    SetOptions(InstallRules::Preset(presets[index], false));
+    UpdateOptions();
 }
 
-void MainWindow::on_line_vgears_exe_editingFinished(){
-    settings_->setValue("VGearsEXE",main_window_->line_vgears_exe->text());
+void MainWindow::UpdateOptions(){
+    if (updating_options_) return;
+    updating_options_ = true;
+    // Options whose requirements are not met are disabled, and so unchecked.
+    InstallOptions options = CurrentOptions();
+    InstallRules::ApplyDependencies(options);
+    const InstallOptions selectable = InstallRules::Selectable(options);
+    const auto apply = [](QCheckBox* box, bool checked, bool enabled){
+        if (box->isChecked() != checked) box->setChecked(checked);
+        box->setEnabled(enabled);
+    };
+    // The options can only be changed by hand with the "Custom" preset, and then only if their requirements are met.
+    // "Clean install" doesn't depend on what is installed, so it is always available.
+    const bool custom = main_window_->combo_preset->currentIndex() == PRESET_CUSTOM;
+    apply(main_window_->chk_field_models, options.field_models, custom && selectable.field_models);
+    apply(main_window_->chk_wm_models, options.wm_models, custom && selectable.wm_models);
+    apply(main_window_->chk_videos, main_window_->chk_videos->isChecked(), custom);
+    for (QCheckBox* box : {
+      main_window_->chk_battle_data, main_window_->chk_battle_models, main_window_->chk_kernel,
+      main_window_->chk_images, main_window_->chk_sounds, main_window_->chk_music, main_window_->chk_fields,
+      main_window_->chk_wm, main_window_->chk_copy_data, main_window_->chk_keep_original
+    }) box->setEnabled(custom);
+    main_window_->chk_clean_install->setEnabled(true);
+
+    // Disk space.
+    uint64_t temporary = 0;
+    const uint64_t installed = InstallRules::EstimateMegabytes(options, &temporary);
+    const auto size = [](uint64_t mb){
+        return mb >= 1024 ? QString("%1 GB").arg(mb / 1024.0, 0, 'f', 1) : QString("%1 MB").arg(mb);
+    };
+    QString text = tr("Estimated disk space: about %1 for the converted data").arg(size(installed));
+    if (temporary > 0) text += tr(", plus %1 of temporary files while installing").arg(size(temporary));
+    text += tr(", plus the extracted ISO (up to about 1 GB).");
+    if (options.videos) text += tr(" Videos are not included in the estimate.");
+    main_window_->label_estimate->setText(text);
+    updating_options_ = false;
 }
 
-void MainWindow::on_btn_vgears_exe_clicked(){
-    QString temp = QFileDialog::getOpenFileName(this, tr("Location of VGears Executable,"), QDir::rootPath());
-    if (!temp.isNull()){
-        settings_->setValue("VGearsEXE", temp);
-        main_window_->line_vgears_exe->setText(temp);
+void MainWindow::on_btn_verify_clicked(){
+    QString dir = QDir::fromNativeSeparators(main_window_->line_data_dst->text());
+    if (dir.isEmpty() || !QDir(dir).exists()){
+        QMessageBox::warning(this, tr("Verify install"), tr("The installation directory does not exist: %1").arg(dir));
+        return;
+    }
+    const VerifyResult result = VerifyInstallation(dir.toStdString(), FindGameDataDir().toStdString());
+    // The game can start if its configuration and the V-Gears data files are there.
+    bool can_launch = true;
+    QString text;
+    std::string group;
+    for (const VerifyProblem& problem : result.problems){
+        if (problem.group == "Configuration" || problem.group == "Game files" || problem.group == "Copied data files")
+            can_launch = false;
+        if (problem.group != group){
+            group = problem.group;
+            text += QString("\n%1:\n").arg(QString::fromStdString(group));
+        }
+        text += QString("  - %1\n").arg(QString::fromStdString(problem.message));
+    }
+    if (result.Ok()){
+        QMessageBox::information(
+          this, tr("Verify install"), tr("Everything the game needs is installed (%1 checks).").arg(result.checked)
+        );
+    }
+    else{
+        QMessageBox::warning(
+          this, tr("Verify install"),
+          tr("Some files are missing:\n%1\nIf you skipped a part of the installation on purpose (for example the "
+             "music), the files of that part are expected to be missing.").arg(text)
+        );
     }
+    launch_available_ = can_launch;
+    main_window_->btn_launch->setEnabled(launch_available_);
 }
 
-void MainWindow::on_btn_vgears_run_clicked(){
-    QString configDir(main_window_->btn_vgears_config->text());
-    QString exe(main_window_->line_vgears_exe->text());
+void MainWindow::on_btn_launch_clicked(){
+    LaunchGame(QDir::fromNativeSeparators(main_window_->line_data_dst->text()));
+}
+
+void MainWindow::LaunchGame(const QString& dir){
+    // The executable of the settings, or the one built next to the installer.
+    QString exe = settings_->value("VGearsEXE").toString();
+    if (exe.isEmpty() || !QFileInfo(exe).isExecutable()){
+        exe.clear();
+        for (const QString& candidate : {QCoreApplication::applicationDirPath() + "/v-gears", QString("/usr/games/v-gears")}){
+            if (QFileInfo(candidate).isExecutable()){
+                exe = candidate;
+                break;
+            }
+        }
+    }
+    if (exe.isEmpty()){
+        exe = QFileDialog::getOpenFileName(this, tr("Location of the V-Gears executable"), QDir::homePath());
+        if (exe.isEmpty()) return;
+    }
+    settings_->setValue("VGearsEXE", exe);
+    // The game runs from the installation directory, where it finds its data.
     QStringList args;
-    args.append(QString("--resources-file=%1/resources.cfg").arg(main_window_->btn_vgears_config->text()));
-    args.append(QString("--config-file=%1/v-gears.cfg").arg(main_window_->btn_vgears_config->text()));
-    args.append(QString("--plugins-file=/%1/plugins.cfg").arg(main_window_->btn_vgears_config->text()));
-    // Check that the config dir is set up correctly.
-    QProcess vGears;
-    vGears.startDetached(exe,args,configDir);
+    args << QString("--resources-file=%1/resources.cfg").arg(dir) << QString("--config-file=%1/v-gears.cfg").arg(dir)
+         << QString("--plugins-file=%1/plugins.cfg").arg(dir);
+    if (!QProcess::startDetached(exe, args, dir)){
+        QMessageBox::critical(this, tr("Launch game"), tr("Could not start %1.").arg(exe));
+    }
+}
+
+/**
+ * Version of the installer, set by the build.
+ */
+#ifndef VGEARS_INSTALLER_VERSION
+#define VGEARS_INSTALLER_VERSION "unknown"
+#endif
+
+/**
+ * Web pages of the project.
+ */
+static const char* const kHomepage = "https://v-gears.inigovalentin.com";
+static const char* const kRepository = "https://github.com/InigoValentin/V-Gears";
+
+void MainWindow::on_actionABout_triggered(){
+    QMessageBox box(this);
+    box.setWindowTitle(tr("About V-Gears Installer"));
+    box.setIconPixmap(windowIcon().pixmap(64, 64));
+    box.setTextFormat(Qt::RichText);
+    box.setTextInteractionFlags(Qt::TextBrowserInteraction);
+    box.setText(tr(
+      "<h3>V-Gears Installer %1</h3>"
+      "<p>Prepares the data that <b>V-Gears</b>, a free engine for Final Fantasy VII, needs: it converts the data of "
+      "your own copy of the PC game into formats the engine can use.</p>"
+      "<p>It doesn't include any data from the original game, and you need a legal copy of it.</p>"
+      "<p><a href=\"%2\">Website</a> &middot; <a href=\"%3\">Source code</a></p>"
+      "<p>Copyright &copy; The V-Gears Team.<br>"
+      "Free software, licensed under the GNU General Public License, version 3.</p>"
+    ).arg(VGEARS_INSTALLER_VERSION, kHomepage, kRepository));
+    box.exec();
 }
 
+void MainWindow::on_actionHelp_triggered(){
+    QDialog dialog(this);
+    dialog.setWindowTitle(tr("V-Gears Installer Help"));
+    dialog.resize(640, 560);
+    QVBoxLayout* layout = new QVBoxLayout(&dialog);
+    QTextBrowser* browser = new QTextBrowser(&dialog);
+    browser->setOpenExternalLinks(true);
+    browser->setHtml(tr(
+      "<h2>Installing the game data</h2>"
+      "<ol>"
+      "<li><b>Select the ISO</b> of the <i>install disk</i> of the PC version of Final Fantasy VII. The installer "
+      "tells you if the image is valid.</li>"
+      "<li><b>Choose the installation directory</b>, for example <code>~/.v-gears</code>. It holds the "
+      "configuration and the converted data, and it is where the game runs.</li>"
+      "<li>Click <b>Install data</b>. The log shows the progress. Problems are shown in color and also written to "
+      "<code>installer.log</code> in the installation directory.</li>"
+      "</ol>"
+      "<p>The installation can only be done once per window. To install again, close the installer and open it "
+      "again.</p>"
+      "<h3>Options</h3>"
+      "<p>Select <b>Show advanced options</b> to choose what to install. <b>What to install</b> offers ready-made "
+      "sets; choose <b>Custom</b> to pick the options by hand. Some options need others (for example the field "
+      "models need the field maps). <b>Clean install</b> deletes the data of a previous installation first. The "
+      "estimate under the options shows about how much disk space it needs.</p>"
+      "<p>With <b>Copy V-Gears data files</b> the installer also copies the files that come with V-Gears (scripts, "
+      "screens, fonts and configuration) to the installation directory.</p>"
+      "<h3>Videos</h3>"
+      "<p>The videos are on the PC <i>game disks</i> 1, 2 and 3, not on the install disk. Select one of those "
+      "disks as the ISO, with the same installation directory, and click <b>Install data</b>: only its videos are "
+      "extracted, to <code>data/video</code>. Repeat it for each disk. Videos that are already installed are "
+      "skipped.</p>"
+      "<h3>FFmpeg and TiMidity</h3>"
+      "<p>The sounds and the music are converted with <b>FFmpeg</b>, and the music also with <b>TiMidity</b>. "
+      "The installer looks for them in the folder you chose with <i>Locate…</i>, in a <code>tools</code> folder "
+      "inside the installation directory or next to the installer (handy on Windows: put <code>ffmpeg.exe</code> "
+      "there), and in the system <code>PATH</code>. The window shows what it found. If something is missing when "
+      "you start, it tells you what you would lose and lets you locate the program, open its download page, check "
+      "again, continue without it, or cancel.</p>"
+      "<h3>When it finishes</h3>"
+      "<p><b>Verify install</b> checks that the installation directory has everything the game needs. If you "
+      "skipped a part on purpose, the files of that part are reported as missing. <b>Launch game</b> starts "
+      "V-Gears from the installation directory.</p>"
+      "<h3>More help</h3>"
+      "<p><a href=\"%1\">Project website</a> &middot; <a href=\"%2\">Source code and documentation</a></p>"
+    ).arg(kHomepage, kRepository));
+    layout->addWidget(browser);
+    QDialogButtonBox* buttons = new QDialogButtonBox(QDialogButtonBox::Close, &dialog);
+    connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject);
+    layout->addWidget(buttons);
+    dialog.exec();
+}
+
+void MainWindow::on_actionExit_triggered(){close();}
+
 void MainWindow::on_btn_data_src_clicked(){
     const QString filter = tr("ISO images (*.iso)");
     QString temp = QFileDialog::getOpenFileName(
-      this, tr("Select ISO image"), settings_->value("DataDir").toString(), filter
+      this, tr("Select ISO image"), settings_->value("IsoDir", QDir::homePath()).toString(), filter
     );
     std::cout << "Selected ISO: " << temp.toStdString() << std::endl;
     if (!temp.isNull()){
         main_window_->line_data_src->setText(temp);
+        settings_->setValue("IsoDir", QFileInfo(temp).absolutePath());
         disk_image_ = DiskImage(temp.toStdString());
         main_window_->isoData->setText(QString::fromStdString(disk_image_.getId()));
         if (!disk_image_.isValid()){
@@ -134,47 +575,90 @@ void MainWindow::on_btn_data_src_clicked(){
             main_window_->isoError->setStyleSheet("QLabel { color : orange; }");
             main_window_->isoError->setText(QString::fromStdString(disk_image_.getWarningMessage()));
         }
+        else if (disk_image_.isVideoDisk()){
+            main_window_->isoError->setStyleSheet("QLabel { color : green; }");
+            main_window_->isoError->setText(
+              tr("Game disk: only its videos will be extracted. Install the game from the install disk.")
+            );
+        }
         else{
             main_window_->isoError->setStyleSheet("QLabel { color : green; }");
             main_window_->isoError->setText(tr("ISO is valid and supported."));
         }
+        // A game disk only has videos, so there is nothing else to choose.
+        main_window_->advancedOptions->setEnabled(!disk_image_.isVideoDisk());
+        main_window_->combo_preset->setEnabled(!disk_image_.isVideoDisk());
     }
 }
 
 void MainWindow::on_line_data_dst_editingFinished(){
-    settings_->setValue("DataDir", main_window_->line_data_dst->text());
+    settings_->setValue("InstallDir", main_window_->line_data_dst->text());
+    // The installation directory can have a "tools" folder.
+    RefreshToolStatus();
 }
 
 void MainWindow::on_btn_data_dst_clicked(){
     QString temp = QFileDialog::getExistingDirectory(
-      this, tr("V-Gears data installation directory"), settings_->value("DataDir").toString()
+      this, tr("V-Gears installation directory"), settings_->value("InstallDir").toString()
     );
     if (!temp.isNull()){
-        settings_->setValue("DataDir",temp);
+        settings_->setValue("InstallDir", temp);
         main_window_->line_data_dst->setText(temp);
+        RefreshToolStatus();
     }
 }
 
 void MainWindow::on_btn_data_run_clicked(){
-    if (main_window_->line_data_src->text().isEmpty()){
+    if (main_window_->line_data_dst->text().isEmpty()){
+        QMessageBox::critical(this, tr("Output error"), tr("No output path provided"));
+        return;
+    }
+    // Normalize the output path.
+    QString output = QDir::fromNativeSeparators(main_window_->line_data_dst->text());
+    if (!output.endsWith("/")) output += "/";
+    // The working directory changes during installation, so the path must be absolute.
+    if (QDir::isRelativePath(output)) output = QDir(output).absolutePath() + "/";
+
+    // A game disk only has videos: extract them, without the rest of the installation.
+    if (disk_image_.isValid() && disk_image_.isVideoDisk()){
+        StartVideoExtraction(output);
+        return;
+    }
+
+    // Locate the V-Gears data folder to copy, asking if it can't be found.
+    const bool copy_data = main_window_->chk_copy_data->checkState() == Qt::Checked;
+    QString game_data;
+    if (copy_data){
+        game_data = FindGameDataDir();
+        if (game_data.isEmpty()){
+            game_data = QFileDialog::getExistingDirectory(
+              this, tr("Select the V-Gears 'data' folder to copy"), QCoreApplication::applicationDirPath()
+            );
+            if (game_data.isEmpty()) return;
+        }
+    }
+
+    // Check everything that can be checked before touching the disk.
+    PreflightResult preflight = RunPreflight(disk_image_, output, copy_data, game_data);
+    for (const std::string& problem : InstallRules::Problems(CurrentOptions(), QFile::exists(output + "data/fields/_fields.xml")))
+        preflight.errors << QString::fromStdString(problem);
+    if (!preflight.errors.isEmpty()){
         QMessageBox::critical(
-          this, tr("Input error"),
-          tr("The installation needs a Final Fantasy VII ISO image.\n\nSelect the ISO from the PC install disk.")
+          this, tr("The installation can't start"),
+          tr("Fix these problems and try again:\n\n- %1").arg(preflight.errors.join("\n- "))
         );
+        return;
     }
-    if (disk_image_.isValid() == false){
-        QMessageBox::critical(
-          this, tr("Input error"),
-          tr("The selected ISO is not a valid Final Fantasy VII game.\n\nSelect a valid image of Final Fantasy VII.")
+    if (!preflight.warnings.isEmpty()){
+        const auto answer = QMessageBox::warning(
+          this, tr("Warnings"), tr("%1\n\nContinue anyway?").arg(preflight.warnings.join("\n")),
+          QMessageBox::Yes | QMessageBox::No, QMessageBox::No
         );
+        if (answer != QMessageBox::Yes) return;
     }
-    else if (main_window_->line_data_dst->text().isEmpty())
-        QMessageBox::critical(this, tr("Output error"), tr("No output path provided"));
-    else{
-        // Normalize the output path and extract to original_data.
-        QString output = QDir::fromNativeSeparators(main_window_->line_data_dst->text());
-        if (!output.endsWith("/")) output += "/";
-
+    // The audio is converted with external programs: say now, before anything is extracted, if they are missing.
+    if (!ResolveMissingTools(CurrentOptions())) return;
+    {
         bool extraction_success = disk_image_.extractImage(output.toStdString());
         if (!extraction_success) {
             QMessageBox::critical(this, tr("Extraction error"), tr("Failed to extract ISO file."));
@@ -221,6 +705,41 @@ void MainWindow::on_btn_data_run_clicked(){
                 return;
             }
         }
+        // Copy the V-Gears data files (scripts, screens, fonts...) to the installation directory.
+        if (copy_data){
+            main_window_->data_log->append(tr("Copying data files from %1...").arg(game_data));
+            // Everything in the folder goes to the installation directory, and its "data" subfolder to <dir>/data.
+            if (!CopyDirectory(game_data, output)){
+                QMessageBox::critical(
+                  this, tr("Copy error"), tr("Failed to copy the V-Gears data files to the installation directory.")
+                );
+                return;
+            }
+            const QStringList missing_plugins = CopyMissingOgrePlugins(output);
+            if (!missing_plugins.isEmpty()){
+                QMessageBox::critical(
+                  this, tr("Missing Ogre plugins"),
+                  tr("These Ogre plugins are not in the installation directory nor in the system:\n\n%1")
+                    .arg(missing_plugins.join(", "))
+                );
+                return;
+            }
+            main_window_->data_log->append(tr("Data files copied."));
+        }
+        // The installer's Ogre setup loads plugins.cfg, resources.cfg and the render plugin from the working
+        // directory (and writes install.log there), so run from the installation directory.
+        if (!QFile::exists(output + "plugins.cfg") || !QFile::exists(output + "resources.cfg")){
+            QMessageBox::critical(
+              this, tr("Missing configuration files"),
+              tr("The installation directory must contain plugins.cfg and resources.cfg.\n\n"
+                 "Enable 'Copy V-Gears data files' to put them there.")
+            );
+            return;
+        }
+        if (!QDir::setCurrent(output)){
+            QMessageBox::critical(this, tr("Output error"), tr("Could not enter the installation directory."));
+            return;
+        }
         if (installer_created){
             // Due to the use of singletons, the installer can't be re-run.
             // TODO: Verify that this is true, and if so, try to fix it.
@@ -229,6 +748,9 @@ void MainWindow::on_btn_data_run_clicked(){
             );
             return;
         }
+        // Collect every message of the installation in one place: the log file, the log window and the summary.
+        UpdateToolLocations(output);
+        OpenInstallLog(output, false);
         // Start data conversion
         try{
             // Parse advanced options.
@@ -239,30 +761,51 @@ void MainWindow::on_btn_data_run_clicked(){
             options.images = (Qt::Checked == main_window_->chk_images->checkState());
             options.sounds = (Qt::Checked == main_window_->chk_sounds->checkState());
             options.music = (Qt::Checked == main_window_->chk_music->checkState());
-            options.videos = (Qt::Checked == main_window_->chk_videos->checkState());
+            options.videos = false;
+            if (Qt::Checked == main_window_->chk_videos->checkState()){
+                InstallLog::Get().Add(
+                  "[WARNING] The install disk has no videos. To extract them, select game disk 1, 2 or 3 as the "
+                  "ISO and install again (with a new installer window)."
+                );
+            }
             options.fields = (Qt::Checked == main_window_->chk_fields->checkState());
-            options.field_models = (Qt::Checked == main_window_->chk_field_models->checkState());
+            options.field_models = options.fields && (Qt::Checked == main_window_->chk_field_models->checkState());
             options.wm = (Qt::Checked == main_window_->chk_wm->checkState());
-            options.wm_models = (Qt::Checked == main_window_->chk_wm_models->checkState());
-            options.ffmpeg = (Qt::Checked == main_window_->chk_ffmpeg->checkState());
-            options.timidity = (Qt::Checked == main_window_->chk_timidity->checkState());
+            options.wm_models = options.wm && (Qt::Checked == main_window_->chk_wm_models->checkState());
             options.keep_originals = (Qt::Checked == main_window_->chk_keep_original->checkState());
             installer_created = true;
-            installer_ = std::make_unique<DataInstaller>(
-              disk_image_,
-              QDir::toNativeSeparators(output).toStdString(),
-              options,
-              [this](const std::string log_line, int level, bool as_progress = false){
-                 main_window_->data_log->append(log_line.c_str());
-                 std::cout << log_line << std::endl;
-                 if (as_progress) main_window_->label_progress->setText(log_line.c_str());
-              }
-            );
-            OnInstallStarted();
+            install_started_ = true;
+            const std::string output_path = QDir::toNativeSeparators(output).toStdString();
+            const auto write_line = [this](const std::string log_line, int level, bool as_progress = false){
+                InstallLog::Get().Add(log_line);
+                if (as_progress){
+                    const QString text = QString::fromStdString(log_line);
+                    QMetaObject::invokeMethod(
+                      this, [this, text](){main_window_->label_progress->setText(text);}, Qt::QueuedConnection
+                    );
+                }
+            };
+            // The installation runs in a worker thread so the window stays responsive. Ogre, and the OpenGL context
+            // it creates, belong to the thread that creates them, so the installer is created, run and destroyed
+            // there. Setting "BackgroundInstall" to false runs it in the UI thread, step by step from a timer.
+            background_ = settings_->value("BackgroundInstall", true).toBool();
+            if (background_){
+                stop_requested_ = false;
+                OnInstallStarted();
+                worker_ = std::thread([this, options, output_path, write_line](){RunInstallation(options, output_path, write_line);});
+            }
+            else{
+                installer_ = std::make_unique<DataInstaller>(disk_image_, output_path, options, write_line);
+                OnInstallStarted();
+            }
         }
         catch (const std::exception& ex){
-            std::cout << "Exception: " << ex.what() << std::endl;
+            InstallLog::Get().Add(std::string("[ERROR] Installation failed: ") + ex.what());
+            InstallLog::Get().Close();
+            installer_.reset();
+            installer_created = false;
             OnInstallStopped();
+            QMessageBox::critical(this, tr("Installation error"), ex.what());
         }
     }
 }
@@ -272,13 +815,20 @@ void MainWindow::on_chk_advanced_options_stateChanged(){
 }
 
 void MainWindow::EnableUi(bool enable){
-    main_window_->btn_data_run->setEnabled(enable);
+    // The installer can't run twice, so once an installation has started the button never comes back.
+    main_window_->btn_data_run->setEnabled(enable && !install_started_);
+    main_window_->btn_close->setEnabled(enable);
+    main_window_->actionExit->setEnabled(enable);
+    main_window_->label_tools->setEnabled(enable);
+    main_window_->btn_verify->setEnabled(enable);
+    main_window_->btn_launch->setEnabled(enable && launch_available_);
     main_window_->line_data_src->setEnabled(enable);
     main_window_->btn_data_src->setEnabled(enable);
     main_window_->line_data_dst->setEnabled(enable);
     main_window_->btn_data_dst->setEnabled(enable);
     main_window_->data_progress_bar->setValue(0);
-    if (!enable) timer_->start(0);
+    // In background mode the worker thread does the work, so there is nothing to poll.
+    if (!enable && !background_) timer_->start(0);
     else timer_->stop();
 }
 
@@ -286,19 +836,262 @@ void MainWindow::OnInstallStarted(){EnableUi(false);}
 
 void MainWindow::OnInstallStopped(){EnableUi(true);}
 
+void MainWindow::StartVideoExtraction(const QString& output){
+    // Only the output directory is needed: no plugins, no configuration, no Ogre.
+    QStringList errors;
+    if (!QDir().mkpath(output)) errors << tr("The installation directory can't be created: %1").arg(output);
+    else{
+        QFile probe(output + ".v-gears-write-test");
+        if (!probe.open(QIODevice::WriteOnly)) errors << tr("The installation directory is not writable: %1").arg(output);
+        else{
+            probe.close();
+            probe.remove();
+        }
+        const QStorageInfo storage(output);
+        const qint64 needed = 1024LL * 1024 * 1024;
+        if (storage.isValid() && storage.bytesAvailable() < needed){
+            errors << tr("Not enough free space in %1: %2 MB available, about %3 MB needed.")
+              .arg(storage.rootPath()).arg(storage.bytesAvailable() / (1024 * 1024)).arg(needed / (1024 * 1024));
+        }
+    }
+    if (!errors.isEmpty()){
+        QMessageBox::critical(
+          this, tr("The extraction can't start"), tr("Fix these problems and try again:\n\n- %1").arg(errors.join("\n- "))
+        );
+        return;
+    }
+    // The installation log of a previous run is kept.
+    OpenInstallLog(output, true);
+    // It doesn't use the installer (and its singletons), so it can be done again with another disk.
+    background_ = true;
+    stop_requested_ = false;
+    OnInstallStarted();
+    InstallLog::Get().Add("Extracting the videos of " + disk_image_.getId() + "...");
+    worker_ = std::thread([this, output](){
+        int extracted = 0;
+        int skipped = 0;
+        bool ok = false;
+        std::string error;
+        try{
+            ok = disk_image_.extractVideos(
+              output.toStdString() + "data/",
+              [this](float progress){
+                  QMetaObject::invokeMethod(this, [this, progress](){ShowProgress(progress);}, Qt::QueuedConnection);
+              },
+              [](const std::string& message){InstallLog::Get().Add(message);}, &extracted, &skipped
+            );
+        }
+        catch (const std::exception& ex){
+            error = ex.what();
+        }
+        if (stop_requested_) return;
+        QMetaObject::invokeMethod(this, [this, ok, error, extracted, skipped](){
+            if (worker_.joinable()) worker_.join();
+            if (ok && error.empty()){
+                ShowProgress(100);
+                InstallLog::Get().Add(
+                  "Videos extracted: " + std::to_string(extracted) + " new, " + std::to_string(skipped)
+                  + " already installed."
+                );
+            }
+            else InstallLog::Get().Add("[ERROR] Video extraction failed" + (error.empty() ? std::string() : ": " + error));
+            InstallLog::Get().Close();
+            OnInstallStopped();
+            if (!ok || !error.empty())
+                QMessageBox::critical(this, tr("Video extraction"), tr("Some videos could not be extracted. See the log."));
+        }, Qt::QueuedConnection);
+    });
+}
+
+void MainWindow::UpdateToolLocations(const QString& install_dir){
+    // Where to look for FFmpeg and TiMidity: what the user chose, then a "tools" folder in the installation
+    // directory or next to the installer, then the system PATH.
+    QString dir = QDir::fromNativeSeparators(install_dir);
+    if (!dir.isEmpty() && !dir.endsWith("/")) dir += "/";
+    ExternalTools::Locations& tools = ExternalTools::CurrentLocations();
+    tools.ffmpeg = settings_->value("FFmpegPath").toString().toStdString();
+    tools.timidity = settings_->value("TimidityPath").toString().toStdString();
+    const QString app_dir = QCoreApplication::applicationDirPath();
+    tools.directories.clear();
+    if (!dir.isEmpty()) tools.directories.push_back((dir + "tools").toStdString());
+    tools.directories.push_back((app_dir + "/tools").toStdString());
+    tools.directories.push_back(app_dir.toStdString());
+}
+
+void MainWindow::RefreshToolStatus(){
+    UpdateToolLocations(main_window_->line_data_dst->text());
+    const ExternalTools::Locations& locations = ExternalTools::CurrentLocations();
+    ffmpeg_tool_ = ExternalTools::FindFfmpeg(locations);
+    timidity_tool_ = ExternalTools::FindTimidity(locations);
+    const auto describe = [](const QString& name, const ExternalTools::Tool& tool, const QString& link){
+        if (tool.usable)
+            return tr("%1 \u2713 %2").arg(name, QString::fromStdString(ExternalTools::ShortVersion(tool)));
+        return tr("%1 <span style=\"color:#c62828\">\u2717 not found</span> (<a href=\"%2\">Locate\u2026</a>)").arg(name, link);
+    };
+    main_window_->label_tools->setText(
+      tr("Audio tools: %1 \u00b7 %2").arg(
+        describe("FFmpeg", ffmpeg_tool_, "locate-ffmpeg"), describe("TiMidity", timidity_tool_, "locate-timidity")
+      )
+    );
+    const auto tip = [](const QString& name, const ExternalTools::Tool& tool){
+        return name + ": " + QString::fromStdString(tool.usable ? tool.path : tool.problem);
+    };
+    main_window_->label_tools->setToolTip(tip("FFmpeg", ffmpeg_tool_) + "\n" + tip("TiMidity", timidity_tool_));
+}
+
+bool MainWindow::LocateTool(bool ffmpeg){
+    const QString name = ffmpeg ? "FFmpeg" : "TiMidity";
+    const QString key = ffmpeg ? "FFmpegPath" : "TimidityPath";
+    const QString saved = settings_->value(key).toString();
+#ifdef Q_OS_WIN
+    const QString filter = tr("Programs (*.exe);;All files (*)");
+#else
+    const QString filter = tr("All files (*)");
+#endif
+    const QString file = QFileDialog::getOpenFileName(
+      this, tr("Locate %1").arg(name), saved.isEmpty() ? QDir::homePath() : QFileInfo(saved).absolutePath(), filter
+    );
+    if (file.isEmpty()) return false;
+    // It must really be the program, and work.
+    const ExternalTools::Tool tool = ffmpeg ? ExternalTools::ProbeFfmpeg(file.toStdString()) : ExternalTools::ProbeTimidity(file.toStdString());
+    if (!tool.usable){
+        QMessageBox::warning(this, tr("This program can't be used"), QString::fromStdString(tool.problem));
+        return false;
+    }
+    settings_->setValue(key, file);
+    RefreshToolStatus();
+    return true;
+}
+
+bool MainWindow::ResolveMissingTools(const InstallOptions& options){
+    while (true){
+        RefreshToolStatus();
+        // What is going to be used.
+        const InstallRules::ToolNeeds needs = InstallRules::ToolsNeeded(options);
+        const bool want_ffmpeg = needs.ffmpeg;
+        const bool want_timidity = needs.timidity;
+        const bool missing_ffmpeg = want_ffmpeg && !ffmpeg_tool_.usable;
+        const bool missing_timidity = want_timidity && !timidity_tool_.usable;
+        const std::vector<std::string> consequences
+          = InstallRules::ToolConsequences(options, !missing_ffmpeg, !missing_timidity);
+        if (consequences.empty()) return true;
+
+        QStringList lost;
+        for (const std::string& line : consequences) lost << QString::fromStdString(line);
+        QStringList reasons;
+        if (missing_ffmpeg) reasons << "FFmpeg: " + QString::fromStdString(ffmpeg_tool_.problem);
+        if (missing_timidity) reasons << "TiMidity: " + QString::fromStdString(timidity_tool_.problem);
+
+        QMessageBox box(this);
+        box.setIcon(QMessageBox::Warning);
+        box.setWindowTitle(tr("Audio tools missing"));
+        box.setText(tr("The installer needs programs it can't use:\n\n%1\n\nIf you continue without them:\n\n%2")
+          .arg("- " + reasons.join("\n- "), "- " + lost.join("\n- ")));
+        const QString tools_dir
+          = QDir::toNativeSeparators(QDir::fromNativeSeparators(main_window_->line_data_dst->text()) + "/tools");
+        QString help = tr("You can also copy the programs to %1 and choose 'Check again'.\n\n").arg(tools_dir);
+#if defined(Q_OS_WIN)
+        help += tr("Windows: download a build of FFmpeg (ffmpeg.exe) from https://ffmpeg.org/download.html. TiMidity++ "
+                   "also needs its timidity.cfg and instrument files next to timidity.exe.");
+#elif defined(Q_OS_MAC)
+        help += tr("macOS: brew install ffmpeg timidity");
+#else
+        help += tr("Debian, Ubuntu: sudo apt install ffmpeg timidity\nFedora: sudo dnf install ffmpeg timidity++\n"
+                   "Arch: sudo pacman -S ffmpeg timidity++");
+#endif
+        box.setDetailedText(help);
+        QPushButton* locate = box.addButton(tr("Locate\u2026"), QMessageBox::ActionRole);
+        QPushButton* get = box.addButton(tr("Get it\u2026"), QMessageBox::ActionRole);
+        QPushButton* again = box.addButton(tr("Check again"), QMessageBox::ActionRole);
+        QPushButton* skip = box.addButton(tr("Continue without"), QMessageBox::AcceptRole);
+        box.addButton(QMessageBox::Cancel);
+        box.setDefaultButton(locate);
+        box.exec();
+
+        QAbstractButton* clicked = box.clickedButton();
+        if (clicked == skip) return true;
+        if (clicked == locate){
+            if (missing_ffmpeg) LocateTool(true);
+            if (missing_timidity) LocateTool(false);
+        }
+        else if (clicked == get){
+            if (missing_ffmpeg) QDesktopServices::openUrl(QUrl("https://ffmpeg.org/download.html"));
+            if (missing_timidity) QDesktopServices::openUrl(QUrl("https://timidity.sourceforge.net/#download"));
+        }
+        else if (clicked != again) return false;   // Cancel, or the window was closed.
+    }
+}
+
+void MainWindow::ShowProgress(float progress){
+    main_window_->data_progress_bar->setValue(std::floor(progress));
+    std::stringstream stream;
+    stream << std::fixed << std::setprecision(2) << progress << "%";
+    main_window_->label_percent->setText(QString::fromUtf8(stream.str().c_str()));
+}
+
+void MainWindow::RunInstallation(
+  DataInstaller::AdvancedOptions options, std::string output_path,
+  std::function<void(std::string, int, bool)> write_line
+){
+    std::string error;
+    bool created = false;
+    try{
+        installer_ = std::make_unique<DataInstaller>(disk_image_, output_path, options, write_line);
+        created = true;
+        float progress = 0;
+        auto last_shown = std::chrono::steady_clock::now();
+        while (progress < 100 && !stop_requested_){
+            progress = installer_->Progress();
+            // Don't flood the UI thread with an update per step.
+            const auto now = std::chrono::steady_clock::now();
+            if (progress >= 100 || now - last_shown > std::chrono::milliseconds(100)){
+                last_shown = now;
+                QMetaObject::invokeMethod(this, [this, progress](){ShowProgress(progress);}, Qt::QueuedConnection);
+            }
+        }
+    }
+    catch (const std::exception& ex){
+        error = ex.what();
+    }
+    catch (...){
+        error = "Unknown error";
+    }
+    const bool finished = error.empty() && !stop_requested_;
+    // Destroy the installer in the thread that created it.
+    try{installer_.reset();}
+    catch (...){}
+    if (stop_requested_) return;
+    QMetaObject::invokeMethod(this, [this, finished, created, error](){
+        if (worker_.joinable()) worker_.join();
+        if (finished){
+            launch_available_ = true;
+            OnInstallStopped();
+            InstallLog::Get().Close();
+        }
+        else{
+            InstallLog::Get().Add("[ERROR] Installation failed: " + error);
+            InstallLog::Get().Close();
+            // If the installer could not even be created it can be tried again.
+            if (!created) installer_created = false;
+            OnInstallStopped();
+            QMessageBox::critical(this, created ? tr("Data conversion exception") : tr("Installation error"), QString::fromStdString(error));
+        }
+    }, Qt::QueuedConnection);
+}
+
 void MainWindow::DoProgress(){
     try{
         const float progress = installer_->Progress();
-        main_window_->data_progress_bar->setValue(std::floor(progress));
-        std::stringstream stream;
-        stream << std::fixed << std::setprecision(2) << progress;
-        stream << "%";
-        std::string s = stream.str();
-        main_window_->label_percent->setText(QString::fromUtf8(s.c_str()));
-        if (progress >= 100) OnInstallStopped();
+        ShowProgress(progress);
+        if (progress >= 100){
+            launch_available_ = true;
+            OnInstallStopped();
+            InstallLog::Get().Close();
+        }
     }
     catch (const std::exception& ex){
-        std::cout << "Exception: " << ex.what() << std::endl;
+        InstallLog::Get().Add(std::string("[ERROR] Installation failed: ") + ex.what());
+        InstallLog::Get().Close();
         OnInstallStopped();
         QMessageBox::critical(this, tr("Data conversion exception"), ex.what());
     }

+ 164 - 26
src/installer/MainWindow.h

@@ -17,8 +17,17 @@
 
 #include <QtWidgets/QMainWindow>
 #include <QtCore/QSettings>
+#include <atomic>
+#include <functional>
 #include <memory>
+#include <thread>
+#include <vector>
+#include "DataInstaller.h"
 #include "DiskImage.h"
+#include "ExternalTools.h"
+
+class QCheckBox;
+struct InstallOptions;
 
 namespace Ui {
     class MainWindow;
@@ -46,39 +55,19 @@ class MainWindow : public QMainWindow{
     private slots:
 
         /**
-         * Triggered when the configuration directory has done being edited.
-         *
-         * Sets the selected directory.
+         * Triggered when About is chosen in the menu. Shows the version and the license.
          */
-        void on_line_vgears_config_editingFinished();
+        void on_actionABout_triggered();
 
         /**
-         * Triggered when the configuration directory button is clicked.
-         *
-         * Opens a file manager for directory selection.
+         * Triggered when Help is chosen in the menu. Explains how to use the installer.
          */
-        void on_btn_vgears_config_clicked();
+        void on_actionHelp_triggered();
 
         /**
-         * Triggered when the executable directory has done being edited.
-         *
-         * Sets the selected directory.
+         * Triggered when Exit is chosen in the menu. Disabled while installing, like the close button.
          */
-        void on_line_vgears_exe_editingFinished();
-
-        /**
-         * Triggered when the executable directory button is clicked.
-         *
-         * Opens a file manager for file selection.
-         */
-        void on_btn_vgears_exe_clicked();
-
-        /**
-         * Triggered when the launch button is clicked.
-         *
-         * Launches V-Gears.
-         */
-        void on_btn_vgears_run_clicked();
+        void on_actionExit_triggered();
 
         /**
          * Triggered when the input data directory button is clicked.
@@ -108,6 +97,18 @@ class MainWindow : public QMainWindow{
          */
         void on_btn_data_run_clicked();
 
+        /**
+         * Triggered when the verify button is clicked.
+         *
+         * Checks that the installation has everything the game needs.
+         */
+        void on_btn_verify_clicked();
+
+        /**
+         * Triggered when the launch game button is clicked.
+         */
+        void on_btn_launch_clicked();
+
         /**
          * Triggered when the advanced options checkbox is checked or unchecked.
          */
@@ -129,6 +130,106 @@ class MainWindow : public QMainWindow{
          */
         void EnableUi(bool enable);
 
+        /**
+         * Sets where the installer looks for FFmpeg and TiMidity.
+         *
+         * @param[in] install_dir Installation directory.
+         */
+        void UpdateToolLocations(const QString& install_dir);
+
+        /**
+         * Looks for FFmpeg and TiMidity and shows what was found in the window.
+         */
+        void RefreshToolStatus();
+
+        /**
+         * Asks the user for the path of FFmpeg or TiMidity. It is saved if it works.
+         *
+         * @param[in] ffmpeg True for FFmpeg, false for TiMidity.
+         * @return True if a working program was chosen.
+         */
+        bool LocateTool(bool ffmpeg);
+
+        /**
+         * Checks that the programs the options need can be used and, if not, tells the user what would be lost and
+         * offers to locate them, get them, check again, continue without them, or cancel.
+         *
+         * @param[in] options The installation options.
+         * @return True to go on with the installation, false to cancel it.
+         */
+        bool ResolveMissingTools(const InstallOptions& options);
+
+        /**
+         * Starts the log of an installation: the file in the installation directory and the log window.
+         *
+         * @param[in] dir Installation directory, ending with '/'.
+         * @param[in] append True to keep what a previous run wrote to the log file.
+         */
+        void OpenInstallLog(const QString& dir, bool append);
+
+        /**
+         * Extracts the videos of the selected game disk, in the worker thread.
+         *
+         * @param[in] output Installation directory, ending with '/'.
+         */
+        void StartVideoExtraction(const QString& output);
+
+        /**
+         * The checkboxes of the options that choose what to install.
+         */
+        std::vector<QCheckBox*> OptionBoxes() const;
+
+        /**
+         * Reads the options from the window.
+         */
+        InstallOptions CurrentOptions() const;
+
+        /**
+         * Sets the options in the window.
+         */
+        void SetOptions(const InstallOptions& options);
+
+        /**
+         * Applies a preset.
+         *
+         * @param[in] index Index of the preset in the preset box.
+         */
+        void ApplyPreset(int index);
+
+        /**
+         * Enforces the rules between options (disabling what can't be chosen) and updates the space estimate.
+         */
+        void UpdateOptions();
+
+        /**
+         * Starts V-Gears from an installation directory.
+         */
+        void LaunchGame(const QString& dir);
+
+        /**
+         * Index of the "Custom" preset.
+         */
+        static const int PRESET_CUSTOM = 5;
+
+        /**
+         * Shows the installation progress.
+         *
+         * @param[in] progress Installation progress [0-100].
+         */
+        void ShowProgress(float progress);
+
+        /**
+         * Runs the whole installation. It is the body of the worker thread.
+         *
+         * @param[in] options The installation options.
+         * @param[in] output_path The installation directory.
+         * @param[in] write_line Receives the installer messages.
+         */
+        void RunInstallation(
+          DataInstaller::AdvancedOptions options, std::string output_path,
+          std::function<void(std::string, int, bool)> write_line
+        );
+
         /**
          * Triggered when the installation starts.
          *
@@ -173,6 +274,43 @@ class MainWindow : public QMainWindow{
          */
         std::unique_ptr<class DataInstaller> installer_;
 
+        /**
+         * The thread that runs the installation.
+         */
+        std::thread worker_;
+
+        /**
+         * Set to ask the worker thread to stop as soon as possible.
+         */
+        std::atomic<bool> stop_requested_{false};
+
+        /**
+         * True if the current installation runs in the worker thread, false if it is advanced by the timer.
+         */
+        bool background_ = false;
+
+        /**
+         * True once an installation has started. The installer can only run once.
+         */
+        bool install_started_ = false;
+
+        /**
+         * True while the option rules are being applied, to avoid applying them recursively.
+         */
+        bool updating_options_ = false;
+
+
+        /**
+         * True if the game can be launched from the window (after an installation or a verification).
+         */
+        bool launch_available_ = false;
+
+        /**
+         * The conversion programs found by {@see RefreshToolStatus}.
+         */
+        ExternalTools::Tool ffmpeg_tool_;
+        ExternalTools::Tool timidity_tool_;
+
         /**
          * The selected disk image.
          */

+ 201 - 178
src/installer/MainWindow.ui

@@ -22,23 +22,13 @@
   <widget class="QWidget" name="centralWidget">
    <layout class="QVBoxLayout" name="verticalLayout">
     <item>
-     <widget class="QTabWidget" name="tabWidget">
-      <property name="tabPosition">
-       <enum>QTabWidget::TabPosition::North</enum>
-      </property>
-      <property name="currentIndex">
-       <number>0</number>
-      </property>
-      <widget class="QWidget" name="tab_installer">
+     <widget class="QWidget" name="tab_installer">
        <property name="minimumSize">
         <size>
          <width>698</width>
          <height>630</height>
         </size>
        </property>
-       <attribute name="title">
-        <string>Data Installer</string>
-       </attribute>
        <layout class="QVBoxLayout" name="verticalLayout_3">
         <item>
          <layout class="QHBoxLayout" name="isoLocator">
@@ -190,6 +180,29 @@
           </item>
          </layout>
         </item>
+        <item>
+         <widget class="QLabel" name="label_estimate">
+          <property name="text">
+           <string/>
+          </property>
+          <property name="wordWrap">
+           <bool>true</bool>
+          </property>
+         </widget>
+        </item>
+        <item>
+         <widget class="QLabel" name="label_tools">
+          <property name="text">
+           <string/>
+          </property>
+          <property name="wordWrap">
+           <bool>true</bool>
+          </property>
+          <property name="textInteractionFlags">
+           <set>Qt::LinksAccessibleByMouse</set>
+          </property>
+         </widget>
+        </item>
         <item>
          <widget class="QCheckBox" name="chk_advanced_options">
           <property name="text">
@@ -211,13 +224,13 @@
           <property name="minimumSize">
            <size>
             <width>680</width>
-            <height>180</height>
+            <height>210</height>
            </size>
           </property>
           <property name="maximumSize">
            <size>
             <width>16777215</width>
-            <height>180</height>
+            <height>210</height>
            </size>
           </property>
           <property name="title">
@@ -232,7 +245,7 @@
           <property name="checkable">
            <bool>false</bool>
           </property>
-          <widget class="QLabel" name="advancedLabelBattle">
+          <widget class="QLabel" name="label_preset">
            <property name="geometry">
             <rect>
              <x>10</x>
@@ -247,6 +260,115 @@
              <underline>true</underline>
             </font>
            </property>
+           <property name="text">
+            <string>What to install</string>
+           </property>
+           <property name="alignment">
+            <set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
+           </property>
+          </widget>
+          <widget class="QComboBox" name="combo_preset">
+           <property name="geometry">
+            <rect>
+             <x>150</x>
+             <y>8</y>
+             <width>410</width>
+             <height>24</height>
+            </rect>
+           </property>
+           <property name="toolTip">
+            <string>Ready-made sets of options. Choose Custom to pick the options by hand.</string>
+           </property>
+            <property name="toolTip">
+             <string>Ready-made sets of options. Changing an option by hand switches to Custom.</string>
+            </property>
+            <item>
+             <property name="text">
+              <string>Everything</string>
+             </property>
+            </item>
+            <item>
+             <property name="text">
+              <string>Field maps and models</string>
+             </property>
+            </item>
+            <item>
+             <property name="text">
+              <string>World map only</string>
+             </property>
+            </item>
+            <item>
+             <property name="text">
+              <string>Battle data and models</string>
+             </property>
+            </item>
+            <item>
+             <property name="text">
+              <string>Media only (images, sounds, music, videos)</string>
+             </property>
+            </item>
+            <item>
+             <property name="text">
+              <string>Custom</string>
+             </property>
+            </item>
+           </widget>
+          <widget class="QLabel" name="advancedLabelData">
+           <property name="geometry">
+            <rect>
+             <x>10</x>
+             <y>40</y>
+             <width>130</width>
+             <height>20</height>
+            </rect>
+           </property>
+           <property name="font">
+            <font>
+             <bold>true</bold>
+             <underline>true</underline>
+            </font>
+           </property>
+           <property name="text">
+            <string>Data files</string>
+           </property>
+           <property name="alignment">
+            <set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
+           </property>
+          </widget>
+          <widget class="QCheckBox" name="chk_copy_data">
+           <property name="geometry">
+            <rect>
+             <x>150</x>
+             <y>40</y>
+             <width>200</width>
+             <height>20</height>
+            </rect>
+           </property>
+           <property name="toolTip">
+            <string>Copy the V-Gears data files (scripts, screens, fonts, config...) from the data/ folder to the installation directory.</string>
+           </property>
+           <property name="text">
+            <string>Copy V-Gears data files</string>
+           </property>
+           <property name="checked">
+            <bool>true</bool>
+           </property>
+          </widget>
+          <widget class="QLabel" name="advancedLabelBattle">
+           <property name="geometry">
+            <rect>
+             <x>10</x>
+             <y>60</y>
+             <width>130</width>
+             <height>20</height>
+            </rect>
+           </property>
+           <property name="font">
+            <font>
+             <bold>true</bold>
+             <underline>true</underline>
+            </font>
+           </property>
            <property name="text">
             <string>Battle data</string>
            </property>
@@ -258,7 +380,7 @@
            <property name="geometry">
             <rect>
              <x>10</x>
-             <y>30</y>
+             <y>80</y>
              <width>130</width>
              <height>20</height>
             </rect>
@@ -280,7 +402,7 @@
            <property name="geometry">
             <rect>
              <x>10</x>
-             <y>50</y>
+             <y>100</y>
              <width>130</width>
              <height>20</height>
             </rect>
@@ -302,7 +424,7 @@
            <property name="geometry">
             <rect>
              <x>360</x>
-             <y>50</y>
+             <y>100</y>
              <width>200</width>
              <height>20</height>
             </rect>
@@ -333,7 +455,7 @@
            <property name="geometry">
             <rect>
              <x>150</x>
-             <y>70</y>
+             <y>120</y>
              <width>200</width>
              <height>20</height>
             </rect>
@@ -364,7 +486,7 @@
            <property name="geometry">
             <rect>
              <x>10</x>
-             <y>110</y>
+             <y>140</y>
              <width>130</width>
              <height>20</height>
             </rect>
@@ -386,7 +508,7 @@
            <property name="geometry">
             <rect>
              <x>10</x>
-             <y>130</y>
+             <y>160</y>
              <width>130</width>
              <height>20</height>
             </rect>
@@ -408,7 +530,7 @@
            <property name="geometry">
             <rect>
              <x>10</x>
-             <y>150</y>
+             <y>180</y>
              <width>130</width>
              <height>20</height>
             </rect>
@@ -430,7 +552,7 @@
            <property name="geometry">
             <rect>
              <x>360</x>
-             <y>150</y>
+             <y>180</y>
              <width>200</width>
              <height>20</height>
             </rect>
@@ -461,7 +583,7 @@
            <property name="geometry">
             <rect>
              <x>150</x>
-             <y>130</y>
+             <y>160</y>
              <width>200</width>
              <height>20</height>
             </rect>
@@ -491,42 +613,11 @@
             <bool>false</bool>
            </property>
           </widget>
-          <widget class="QCheckBox" name="chk_ffmpeg">
-           <property name="geometry">
-            <rect>
-             <x>150</x>
-             <y>90</y>
-             <width>200</width>
-             <height>20</height>
-            </rect>
-           </property>
-           <property name="minimumSize">
-            <size>
-             <width>200</width>
-             <height>20</height>
-            </size>
-           </property>
-           <property name="maximumSize">
-            <size>
-             <width>200</width>
-             <height>20</height>
-            </size>
-           </property>
-           <property name="toolTip">
-            <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;b&gt;Use FFmpeg for media conversion.&lt;/b&gt;&lt;br&gt;&lt;br&gt;Checked enables FFmpeg to convert audio and video assets.&lt;/body&gt;&lt;/html&gt;</string>
-           </property>
-            <property name="text">
-             <string>Use FFmpeg for media conversion</string>
-           </property>
-           <property name="checked">
-            <bool>true</bool>
-           </property>
-          </widget>
           <widget class="QCheckBox" name="chk_images">
            <property name="geometry">
             <rect>
              <x>150</x>
-             <y>50</y>
+             <y>100</y>
              <width>200</width>
              <height>20</height>
             </rect>
@@ -557,7 +648,7 @@
            <property name="geometry">
             <rect>
              <x>150</x>
-             <y>10</y>
+             <y>60</y>
              <width>200</width>
              <height>20</height>
             </rect>
@@ -588,7 +679,7 @@
            <property name="geometry">
             <rect>
              <x>360</x>
-             <y>130</y>
+             <y>160</y>
              <width>200</width>
              <height>20</height>
             </rect>
@@ -615,42 +706,11 @@
             <bool>true</bool>
            </property>
           </widget>
-          <widget class="QCheckBox" name="chk_timidity">
-           <property name="geometry">
-            <rect>
-             <x>360</x>
-             <y>90</y>
-             <width>200</width>
-             <height>20</height>
-            </rect>
-           </property>
-           <property name="minimumSize">
-            <size>
-             <width>200</width>
-             <height>20</height>
-            </size>
-           </property>
-           <property name="maximumSize">
-            <size>
-             <width>200</width>
-             <height>20</height>
-            </size>
-           </property>
-           <property name="toolTip">
-            <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;b&gt;Use TiMidity for MIDI conversion.&lt;/b&gt;&lt;br&gt;&lt;br&gt;Checked enables TiMidity when converting MIDI music.&lt;/body&gt;&lt;/html&gt;</string>
-           </property>
-            <property name="text">
-             <string>Use TiMidity for MIDI conversion</string>
-           </property>
-           <property name="checked">
-            <bool>true</bool>
-           </property>
-          </widget>
           <widget class="QCheckBox" name="chk_keep_original">
            <property name="geometry">
             <rect>
              <x>150</x>
-             <y>150</y>
+             <y>180</y>
              <width>200</width>
              <height>20</height>
             </rect>
@@ -681,7 +741,7 @@
            <property name="geometry">
             <rect>
              <x>150</x>
-             <y>110</y>
+             <y>140</y>
              <width>200</width>
              <height>20</height>
             </rect>
@@ -712,7 +772,7 @@
            <property name="geometry">
             <rect>
              <x>150</x>
-             <y>30</y>
+             <y>80</y>
              <width>200</width>
              <height>20</height>
             </rect>
@@ -743,7 +803,7 @@
            <property name="geometry">
             <rect>
              <x>360</x>
-             <y>110</y>
+             <y>140</y>
              <width>200</width>
              <height>20</height>
             </rect>
@@ -774,7 +834,7 @@
            <property name="geometry">
             <rect>
              <x>360</x>
-             <y>70</y>
+             <y>120</y>
              <width>200</width>
              <height>20</height>
             </rect>
@@ -805,7 +865,7 @@
            <property name="geometry">
             <rect>
              <x>360</x>
-             <y>10</y>
+             <y>60</y>
              <width>200</width>
              <height>20</height>
             </rect>
@@ -971,87 +1031,53 @@
         </item>
        </layout>
       </widget>
-      <widget class="QWidget" name="tab_vgears">
-       <attribute name="title">
-        <string>V-Gears Config</string>
-       </attribute>
-       <layout class="QVBoxLayout" name="verticalLayout_2">
-        <item>
-         <layout class="QHBoxLayout" name="horizontalLayout">
-          <item>
-           <widget class="QLabel" name="label_2">
-            <property name="text">
-             <string>VGears Path</string>
-            </property>
-           </widget>
-          </item>
-          <item>
-           <widget class="QLineEdit" name="line_vgears_exe"/>
-          </item>
-          <item>
-           <widget class="QPushButton" name="btn_vgears_exe">
-            <property name="sizePolicy">
-             <sizepolicy hsizetype="Fixed" vsizetype="Fixed">
-              <horstretch>0</horstretch>
-              <verstretch>0</verstretch>
-             </sizepolicy>
-            </property>
-            <property name="maximumSize">
-             <size>
-              <width>24</width>
-              <height>24</height>
-             </size>
-            </property>
-            <property name="text">
-             <string>...</string>
-            </property>
-           </widget>
-          </item>
-         </layout>
-        </item>
-        <item>
-         <layout class="QHBoxLayout" name="horizontalLayout_2">
-          <item>
-           <widget class="QLabel" name="label">
-            <property name="text">
-             <string>Config Directory</string>
-            </property>
-           </widget>
-          </item>
-          <item>
-           <widget class="QLineEdit" name="line_vgears_config"/>
-          </item>
-          <item>
-           <widget class="QPushButton" name="btn_vgears_config">
-            <property name="sizePolicy">
-             <sizepolicy hsizetype="Fixed" vsizetype="Fixed">
-              <horstretch>0</horstretch>
-              <verstretch>0</verstretch>
-             </sizepolicy>
-            </property>
-            <property name="maximumSize">
-             <size>
-              <width>24</width>
-              <height>24</height>
-             </size>
-            </property>
-            <property name="text">
-             <string>...</string>
-            </property>
-           </widget>
-          </item>
-         </layout>
-        </item>
-        <item>
-         <widget class="QPushButton" name="btn_vgears_launch">
-          <property name="text">
-           <string>Run V-Gears</string>
-          </property>
-         </widget>
-        </item>
-       </layout>
-      </widget>
-     </widget>
+    </item>
+    <item>
+     <layout class="QHBoxLayout" name="bottomButtons">
+      <item>
+       <widget class="QPushButton" name="btn_verify">
+        <property name="toolTip">
+         <string>Checks that the installation directory has everything the game needs.</string>
+        </property>
+        <property name="text">
+         <string>Verify install</string>
+        </property>
+       </widget>
+      </item>
+      <item>
+       <widget class="QPushButton" name="btn_launch">
+        <property name="enabled">
+         <bool>false</bool>
+        </property>
+        <property name="toolTip">
+         <string>Starts V-Gears. Enabled after a successful installation or verification.</string>
+        </property>
+        <property name="text">
+         <string>Launch game</string>
+        </property>
+       </widget>
+      </item>
+      <item>
+       <spacer name="bottomSpacer">
+        <property name="orientation">
+         <enum>Qt::Horizontal</enum>
+        </property>
+        <property name="sizeHint" stdset="0">
+         <size>
+          <width>40</width>
+          <height>20</height>
+         </size>
+        </property>
+       </spacer>
+      </item>
+      <item>
+       <widget class="QPushButton" name="btn_close">
+        <property name="text">
+         <string>Close</string>
+        </property>
+       </widget>
+      </item>
+     </layout>
     </item>
    </layout>
   </widget>
@@ -1093,7 +1119,6 @@
  </widget>
  <layoutdefault spacing="6" margin="11"/>
  <tabstops>
-  <tabstop>tabWidget</tabstop>
   <tabstop>line_data_src</tabstop>
   <tabstop>btn_data_src</tabstop>
   <tabstop>help_data_src</tabstop>
@@ -1108,8 +1133,6 @@
   <tabstop>chk_sounds</tabstop>
   <tabstop>chk_music</tabstop>
   <tabstop>chk_videos</tabstop>
-  <tabstop>chk_ffmpeg</tabstop>
-  <tabstop>chk_timidity</tabstop>
   <tabstop>chk_fields</tabstop>
   <tabstop>chk_field_models</tabstop>
   <tabstop>chk_wm</tabstop>

+ 103 - 38
src/installer/MediaDataInstaller.cpp

@@ -26,6 +26,8 @@
 #include <tinyxml.h>
 #include "DiskImage.h"
 #include "MediaDataInstaller.h"
+#include "ExternalTools.h"
+#include "InstallLog.h"
 #include "SfxDump.h"
 #include "data/VGearsLGPArchive.h"
 #include "data/VGearsTexFile.h"
@@ -34,11 +36,9 @@
 int MediaDataInstaller::TOTAL_SOUNDS = 750;
 
 MediaDataInstaller::MediaDataInstaller(
-  const DiskImage& disk_image, const std::string output_dir, const bool keep_originals,
-  const bool ffmpeg, const bool timidity
+  const DiskImage& disk_image, const std::string output_dir, const bool keep_originals
 ):
-  disk_image_(disk_image), output_dir_(output_dir), keep_originals_(keep_originals),
-  ffmpeg_(ffmpeg), timidity_(timidity),
+  disk_image_(disk_image), output_dir_(output_dir), data_dir_(output_dir + "data/"), keep_originals_(keep_originals),
     window_(*disk_image_.filePath("data/kernel/WINDOW.BIN")),
     processed_sounds_(0),
     menu_(VGears::LGPArchive(*disk_image_.filePath("data/menu/menu_us.lgp"), "LGP")),
@@ -1116,14 +1116,14 @@ void MediaDataInstaller::InstallSprites(){
 
         // Save the TEX File
         std::fstream out;
-        out.open(output_dir_ + "images/" + f.file_name, std::ios::out);
+        out.open(data_dir_ + "images/" + f.file_name, std::ios::out);
         menu.SetOffset(f.data_offset);
         for (int j = 0; j < f.data_size; j ++) out << menu.readU8();
         out.close();
 
         // Open the TEX file and save PNGs.
-        std::string img_dir = output_dir_ + "images/";
-        TexFile tex(output_dir_ + "images/" + f.file_name);
+        std::string img_dir = data_dir_ + "images/";
+        TexFile tex(data_dir_ + "images/" + f.file_name);
         if (f.file_name == "barre.tex")
             tex.SavePng(img_dir + "characters/1.png", 0, 0, 83, 95, 0);
         else if (f.file_name == "bins.tex")
@@ -1310,7 +1310,7 @@ void MediaDataInstaller::InstallSprites(){
         // TODO: Extract arena reels from coloa.tex, colob.tex and coloc.tex
 
         // Delete Tex file.
-        if (!keep_originals_) std::remove((output_dir_ + "images/" + f.file_name).c_str());
+        if (!keep_originals_) std::remove((data_dir_ + "images/" + f.file_name).c_str());
     }
 }
 
@@ -1318,7 +1318,7 @@ int MediaDataInstaller::InstallSoundsInit(){
     std::unique_ptr<std::string> fmt_path = disk_image_.filePath("data/sound/audio.fmt");
     std::unique_ptr<std::string> data_path = disk_image_.filePath("data/sound/audio.dat");
     std::string sound_dir =
-      (boost::filesystem::path(output_dir_) / "audio" / "sounds").string();
+      (boost::filesystem::path(data_dir_) / "audio" / "sounds").string();
 
     if (!fmt_path){
         std::cerr << "MediaDataInstaller: could not find data/sound/audio.fmt in the selected game data."
@@ -1337,21 +1337,22 @@ int MediaDataInstaller::InstallSoundsInit(){
 }
 
 bool MediaDataInstaller::InstallSounds(){
-    if (ffmpeg_){
-        // TODO: check for ffmpeg executable and warn if not found.
-        std::string f_path = output_dir_ + "audio/sounds/" + std::to_string(processed_sounds_) + ".wav";
-        std::ifstream file(f_path);
+    if (ToolsReady(false)){
+        const std::string wav = data_dir_ + "audio/sounds/" + std::to_string(processed_sounds_) + ".wav";
+        const std::string ogg = data_dir_ + "audio/sounds/" + std::to_string(processed_sounds_) + ".ogg";
+        std::ifstream file(wav);
         if (file.is_open()){
             file.close();
-             std::string command = (boost::format(
-              "ffmpeg -hide_banner -loglevel panic -y  -i %1%audio/sounds/%2%.wav %1%audio/sounds/%2%.ogg"
-            ) % output_dir_ % processed_sounds_).str();
-            std::system(command.c_str());
+            // Converted in the background. The wav file is removed once it has been converted.
+            const std::string ffmpeg = ffmpeg_tool_.path;
+            const bool keep = keep_originals_;
+            RunConversion(
+              "sound " + std::to_string(processed_sounds_),
+              [ffmpeg, wav, ogg](std::string* error){return ExternalTools::ConvertToOgg(ffmpeg, wav, ogg, error);},
+              [wav, keep](){if (!keep) std::remove(wav.c_str());}
+            );
             std::cout << "    ADD SOUND " << processed_sounds_ << ".ogg" << std::endl;
             sounds_.push_back("audio/sounds/" + std::to_string(processed_sounds_) + ".ogg");
-            // Remove the wav file.
-            if (!keep_originals_)
-                std::remove((output_dir_ + "audio/sounds/" + std::to_string(processed_sounds_) + ".wav").c_str());
         }
         else{
             sounds_.push_back("audio/sounds/INVALID.ogg");
@@ -1369,6 +1370,8 @@ void MediaDataInstaller::WriteSoundIndex(){
     //for (string item : items_){
     for (int id = 0; id < sounds_.size(); id ++){
         std::string path = sounds_[id];
+        // A sound that could not be converted has no file, and listing it would make the game look for it.
+        if (path != "audio/sounds/INVALID.ogg" && !boost::filesystem::exists(data_dir_ + path)) continue;
         std::unique_ptr<TiXmlElement> xml_sound(new TiXmlElement("sound"));
         xml_sound->SetAttribute("file_name", path);
         xml_sound->SetAttribute("name", id);
@@ -1384,7 +1387,7 @@ void MediaDataInstaller::WriteSoundIndex(){
         }
     }
     xml.LinkEndChild(container.release());
-    xml.SaveFile(output_dir_ + "audio/sounds/_sounds.xml");
+    xml.SaveFile(data_dir_ + "audio/sounds/_sounds.xml");
 }
 
 int MediaDataInstaller::InstallMusicsInit(){
@@ -1426,21 +1429,28 @@ bool MediaDataInstaller::InstallMusics(){
     File midi(*disk_image_.filePath("data/midi/midi.lgp"));
 
     std::fstream out;
-    out.open(output_dir_ + "audio/musics/" + std::to_string(index) + ".mid", std::ios::out);
+    out.open(data_dir_ + "audio/musics/" + std::to_string(index) + ".mid", std::ios::out);
     midi.SetOffset(f.data_offset);
     for (int j = 0; j < f.data_size; j ++) out << midi.readU8();
     out.close();
 
-    // Convert to ogg (TiMidity + FFMpeg)
-    // TODO: Chek for ffmpeg and timidity executables and warn if not found.
-    std::string command = (boost::format(
-      "timidity --quiet=3 %1%audio/musics/%2%.mid -Ow -o - "
-      "| ffmpeg -hide_banner -loglevel panic -y -i - %1%audio/musics/%2%.ogg"
-    ) % output_dir_ % index).str();
-    if (ffmpeg_ && timidity_) std::system(command.c_str());
-
-    // Remove the midi file.
-    if (!keep_originals_) std::remove((output_dir_ + "audio/musics/" + std::to_string(index) + ".mid").c_str());
+    // Convert to ogg (TiMidity + FFMpeg).
+    const std::string midi_path = data_dir_ + "audio/musics/" + std::to_string(index) + ".mid";
+    const std::string ogg_path = data_dir_ + "audio/musics/" + std::to_string(index) + ".ogg";
+    const bool keep = keep_originals_;
+    if (ToolsReady(true)){
+        // Converted in the background. The midi file is removed once it has been converted.
+        const std::string timidity = timidity_tool_.path;
+        const std::string ffmpeg = ffmpeg_tool_.path;
+        RunConversion(
+          "music " + std::to_string(index),
+          [timidity, ffmpeg, midi_path, ogg_path](std::string* error){
+              return ExternalTools::ConvertMidiToOgg(timidity, ffmpeg, midi_path, ogg_path, error);
+          },
+          [midi_path, keep](){if (!keep) std::remove(midi_path.c_str());}
+        );
+    }
+    // Without the programs the midi file is the only copy of the track, so it stays.
 
     musics_.push_back("audio/musics/" + std::to_string(processed_musics_) + ".ogg");
     processed_musics_ ++;
@@ -1460,18 +1470,73 @@ void MediaDataInstaller::InstallHQMusics(){
             }
         }
 
-        std::string command = (boost::format(
-          "ffmpeg -hide_banner -loglevel panic -y -i %1%musics/%2%.wav %3%audio/sounds/%4%.ogg"
-        ) % disk_image_.getContentPath() % hq_music % output_dir_ % index).str();
-        if (ffmpeg_)  std::system(command.c_str());
+        if (ToolsReady(false)){
+            const std::string ffmpeg = ffmpeg_tool_.path;
+            // TODO: This path doesn't exist in the PC install disk (the files are in DATA/MUSIC, and one of them is
+            // called HEART, not "hearth"), so these conversions have never run. They are skipped quietly, as
+            // before, until the source files and the destination are sorted out.
+            const std::string wav = disk_image_.getContentPath() + "musics/" + hq_music + ".wav";
+            const std::string ogg = data_dir_ + "audio/sounds/" + std::to_string(index) + ".ogg";
+            if (!boost::filesystem::exists(wav)) continue;
+            RunConversion(
+              "high quality music " + hq_music,
+              [ffmpeg, wav, ogg](std::string* error){return ExternalTools::ConvertToOgg(ffmpeg, wav, ogg, error);}
+            );
+        }
     }
 }
 
+bool MediaDataInstaller::ToolsReady(bool need_timidity){
+    if (!tools_resolved_){
+        tools_resolved_ = true;
+        const ExternalTools::Locations& locations = ExternalTools::CurrentLocations();
+        ffmpeg_tool_ = ExternalTools::FindFfmpeg(locations);
+        timidity_tool_ = ExternalTools::FindTimidity(locations);
+        if (ffmpeg_tool_.usable) InstallLog::Get().Add("Using " + ffmpeg_tool_.version + " (" + ffmpeg_tool_.path + ")");
+        else{
+            InstallLog::Get().Add(
+              "[WARNING] FFmpeg can't be used, so the sound effects and the music will not be converted (the "
+              "extracted .wav and .mid files are left in place): " + ffmpeg_tool_.problem
+            );
+        }
+        if (timidity_tool_.usable) InstallLog::Get().Add("Using " + timidity_tool_.version + " (" + timidity_tool_.path + ")");
+        else{
+            InstallLog::Get().Add(
+              "[WARNING] TiMidity can't be used, so the music will not be converted (the extracted .mid files are "
+              "left in place): " + timidity_tool_.problem
+            );
+        }
+    }
+    return ffmpeg_tool_.usable && (!need_timidity || timidity_tool_.usable);
+}
+
+void MediaDataInstaller::RunConversion(
+  const std::string& description, std::function<bool(std::string*)> job, std::function<void()> on_success
+){
+    if (!conversions_) conversions_ = std::make_unique<TaskPool>();
+    conversions_->Run([description, job, on_success](){
+        std::string error;
+        if (job(&error)){
+            if (on_success) on_success();
+        }
+        else{
+            // The source file is kept, since it hasn't been converted.
+            InstallLog::Get().Add("[WARNING] Could not convert " + description + ": " + error);
+        }
+    });
+}
+
+void MediaDataInstaller::WaitForConversions(){
+    if (conversions_) conversions_->Wait();
+}
+
 void MediaDataInstaller::WriteMusicsIndex(){
     TiXmlDocument xml;
     std::unique_ptr<TiXmlElement> container(new TiXmlElement("musics"));
     for (int id = 0; id < musics_.size(); id ++){
         std::string path = musics_[id];
+        // A track that could not be converted has no file, and listing it would make the game look for it.
+        if (!boost::filesystem::exists(data_dir_ + path)) continue;
         std::unique_ptr<TiXmlElement> xml_music(new TiXmlElement("music"));
         xml_music->SetAttribute("file_name", path);
         xml_music->SetAttribute("name", id);
@@ -1523,7 +1588,7 @@ void MediaDataInstaller::WriteMusicsIndex(){
         }
     }
     xml.LinkEndChild(container.release());
-    xml.SaveFile(output_dir_ + "audio/musics/_musics.xml");
+    xml.SaveFile(data_dir_ + "audio/musics/_musics.xml");
 }
 
 int MediaDataInstaller::InstallVideosInit(){
@@ -1545,7 +1610,7 @@ int MediaDataInstaller::InstallVideosInit(){
 
 void MediaDataInstaller::InstallVideos(){
     if (processed_videos_ < 0 || processed_videos_ >= videos_.size()) return;
-    const std::string output_dir = output_dir_ + "video/";
+    const std::string output_dir = data_dir_ + "video/";
     boost::filesystem::create_directories(output_dir);
     const boost::filesystem::path source(videos_[processed_videos_]);
     const boost::filesystem::path destination = boost::filesystem::path(output_dir) / source.filename();

+ 51 - 16
src/installer/MediaDataInstaller.h

@@ -15,11 +15,14 @@
 
 #pragma once
 
+#include <memory>
 #include <unordered_map>
 #include "DiskImage.h"
 #include "data/VGearsLGPArchive.h"
 #include "common/BinGZipFile.h"
 #include "TexFile.h"
+#include "ExternalTools.h"
+#include "TaskPool.h"
 
 /**
  * The media data installer.
@@ -35,13 +38,8 @@ class MediaDataInstaller{
          * @param[in] disk_image Disk image to extract the data from.
          * @param[in] output_dir Path to the directory of the installation data.
          * @param[in] keep_originals True to keep original data after conversion, false to remove.
-         * @param[in] no_ffmpeg True to prevent system calls to ffmpeg command, false to allow.
-         * @param[in] no_timidity True to prevent system calls to timidity command, false to allow.
          */
-        MediaDataInstaller(
-          const DiskImage& disk_image, const std::string output_dir, const bool keep_originals,
-          const bool no_ffmpeg, const bool no_timidity
-        );
+        MediaDataInstaller(const DiskImage& disk_image, const std::string output_dir, const bool keep_originals);
 
         /**
          * Destructor.
@@ -101,6 +99,14 @@ class MediaDataInstaller{
          */
         void InstallHQMusics();
 
+        /**
+         * Waits until every audio conversion started by the install steps has finished.
+         *
+         * The conversions run in the background, so the other steps don't wait for them. Call it before using or
+         * deleting their results.
+         */
+        void WaitForConversions();
+
         /**
          * Writes the XML file with all music tracks.
          */
@@ -120,6 +126,40 @@ class MediaDataInstaller{
 
     private:
 
+        /**
+         * Runs a conversion in the background.
+         *
+         * A failed conversion is logged as a warning.
+         *
+         * @param[in] description What is being converted, for the log.
+         * @param[in] job Does the conversion. Returns false, and sets the error, if it fails.
+         * @param[in] on_success Called, in the background thread, if the conversion succeeds.
+         */
+        void RunConversion(
+          const std::string& description, std::function<bool(std::string*)> job,
+          std::function<void()> on_success = nullptr
+        );
+
+        /**
+         * Finds the conversion programs the first time it is called, and reports what is missing.
+         *
+         * @param[in] need_timidity True if TiMidity is needed too (the music).
+         * @return True if the programs are usable.
+         */
+        bool ToolsReady(bool need_timidity);
+
+        /**
+         * FFmpeg and TiMidity, found by {@see ToolsReady}.
+         */
+        ExternalTools::Tool ffmpeg_tool_;
+        ExternalTools::Tool timidity_tool_;
+        bool tools_resolved_ = false;
+
+        /**
+         * Threads that run the conversions. Created when the first conversion starts.
+         */
+        std::unique_ptr<TaskPool> conversions_;
+
         /**
          * Number of sound files to extract.
          */
@@ -135,6 +175,11 @@ class MediaDataInstaller{
          */
         std::string output_dir_;
 
+        /**
+         * Directory where the game data is installed (<install dir>/data/).
+         */
+        std::string data_dir_;
+
         /**
          * The menu.lgp file.
          */
@@ -200,14 +245,4 @@ class MediaDataInstaller{
          */
         bool keep_originals_;
 
-        /**
-         * Flag to enable system calls to ffmpeg.
-         */
-        bool ffmpeg_;
-
-        /**
-         * Flag to enable system calls to timidity.
-         */
-        bool timidity_;
-
 };

+ 52 - 0
src/installer/ModelMaterials.h

@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#pragma once
+
+#include <OgreMaterial.h>
+#include <OgreMaterialManager.h>
+#include <OgreMesh.h>
+#include <OgreSubMesh.h>
+#include <string>
+
+/**
+ * Gives a mesh its own copy of each of its materials.
+ *
+ * The original game names the materials of many unrelated models the same ("gzbb/0" is both a field model and a
+ * battle enemy), but Ogre keeps all materials in a single namespace. Converting the models changes their materials
+ * (texture names...), so a material shared by name was changed by one model under the feet of the next, and the
+ * exported material scripts of different models defined the same material twice.
+ *
+ * Every sub mesh material is replaced by a copy named "<owner>/<original name>", which is unique to the model. It
+ * must be called before the mesh is exported, so the exported mesh refers to the new names.
+ *
+ * @param[in] mesh The mesh.
+ * @param[in] owner Name that identifies the model, used as prefix.
+ */
+inline void MakeMaterialsUnique(const Ogre::MeshPtr& mesh, const std::string& owner){
+    if (mesh.isNull()) return;
+    const std::string prefix = owner + "/";
+    Ogre::MaterialManager& manager = Ogre::MaterialManager::getSingleton();
+    for (Ogre::SubMesh* sub_mesh : mesh->getSubMeshes()){
+        const std::string name = sub_mesh->getMaterialName();
+        // Already done (the mesh is exported more than once or reused).
+        if (name.compare(0, prefix.size(), prefix) == 0) continue;
+        Ogre::MaterialPtr material = manager.getByName(name);
+        if (material.isNull()) continue;
+        const std::string unique_name = prefix + name;
+        if (manager.getByName(unique_name).isNull()) material->clone(unique_name);
+        sub_mesh->setMaterialName(unique_name);
+    }
+}

+ 117 - 0
src/installer/TaskPool.h

@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#pragma once
+
+#include <algorithm>
+#include <condition_variable>
+#include <deque>
+#include <functional>
+#include <mutex>
+#include <thread>
+#include <vector>
+
+/**
+ * A fixed number of threads that run tasks.
+ *
+ * Meant for independent jobs that don't touch shared state, like converting one file with an external program.
+ * Tasks must not throw: an exception that escapes a task is swallowed.
+ */
+class TaskPool{
+
+    public:
+
+        /**
+         * Constructor.
+         *
+         * @param[in] threads Number of threads. Zero uses the number of cores, at most 8 (more doesn't help when
+         * the tasks write to the same disk).
+         */
+        explicit TaskPool(unsigned threads = 0){
+            if (threads == 0) threads = std::min(std::max(std::thread::hardware_concurrency(), 1u), 8u);
+            for (unsigned i = 0; i < threads; ++ i) workers_.emplace_back([this](){Work();});
+        }
+
+        /**
+         * Destructor. Waits for the pending tasks.
+         */
+        ~TaskPool(){
+            {
+                std::unique_lock<std::mutex> lock(mutex_);
+                stop_ = true;
+            }
+            work_available_.notify_all();
+            for (std::thread& worker : workers_) worker.join();
+        }
+
+        TaskPool(const TaskPool&) = delete;
+        TaskPool& operator=(const TaskPool&) = delete;
+
+        /**
+         * Adds a task. It starts as soon as a thread is free.
+         */
+        void Run(std::function<void()> task){
+            {
+                std::unique_lock<std::mutex> lock(mutex_);
+                queue_.push_back(std::move(task));
+                ++ pending_;
+            }
+            work_available_.notify_one();
+        }
+
+        /**
+         * Blocks until every task added so far has finished.
+         */
+        void Wait(){
+            std::unique_lock<std::mutex> lock(mutex_);
+            all_done_.wait(lock, [this](){return pending_ == 0;});
+        }
+
+        /**
+         * Gets the number of threads.
+         */
+        unsigned Threads() const{return static_cast<unsigned>(workers_.size());}
+
+    private:
+
+        void Work(){
+            while (true){
+                std::function<void()> task;
+                {
+                    std::unique_lock<std::mutex> lock(mutex_);
+                    work_available_.wait(lock, [this](){return stop_ || !queue_.empty();});
+                    // On stop, the remaining tasks still run.
+                    if (queue_.empty()) return;
+                    task = std::move(queue_.front());
+                    queue_.pop_front();
+                }
+                try{task();}
+                catch (...){}
+                {
+                    std::unique_lock<std::mutex> lock(mutex_);
+                    -- pending_;
+                }
+                all_done_.notify_all();
+            }
+        }
+
+        std::mutex mutex_;
+        std::condition_variable work_available_;
+        std::condition_variable all_done_;
+        std::deque<std::function<void()>> queue_;
+        std::vector<std::thread> workers_;
+        size_t pending_ = 0;
+        bool stop_ = false;
+};

+ 48 - 0
src/installer/VideoFiles.cpp

@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#include "VideoFiles.h"
+#include <algorithm>
+#include <cctype>
+
+namespace VideoFiles{
+
+    std::string NormalizePath(std::string path){
+        std::replace(path.begin(), path.end(), '\\', '/');
+        const std::size_t semicolon = path.find(';');
+        if (semicolon != std::string::npos){
+            bool version = semicolon + 1 < path.size();
+            for (std::size_t i = semicolon + 1; i < path.size(); ++ i)
+                version = version && std::isdigit(static_cast<unsigned char>(path[i]));
+            if (version) path.erase(semicolon);
+        }
+        while (!path.empty() && path.back() == '.') path.pop_back();
+        while (!path.empty() && path.front() == '/') path.erase(path.begin());
+        std::transform(path.begin(), path.end(), path.begin(), [](unsigned char c){return std::tolower(c);});
+        return path;
+    }
+
+    bool IsMovie(const std::string& path, std::string* name){
+        static const std::string folder = "ff7/movies/";
+        const std::string normalized = NormalizePath(path);
+        if (normalized.size() <= folder.size() || normalized.compare(0, folder.size(), folder) != 0) return false;
+        const std::string file = normalized.substr(folder.size());
+        // Files in subdirectories, and directories (which end with a slash), are not movies.
+        if (file.find('/') != std::string::npos) return false;
+        if (name) *name = file;
+        return true;
+    }
+
+}

+ 42 - 0
src/installer/VideoFiles.h

@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#pragma once
+
+#include <string>
+
+/**
+ * Helpers to find the videos in the game disks.
+ *
+ * The PC game disks (1, 2 and 3) have the videos in "ff7/movies". Different disks repeat some of them.
+ */
+namespace VideoFiles{
+
+    /**
+     * Normalizes a path inside a disk image: forward slashes, no leading slash, no ISO9660 version suffix (";1"),
+     * no trailing dots, lower case.
+     */
+    std::string NormalizePath(std::string path);
+
+    /**
+     * Checks if a path inside a disk image is a file of the movies folder, and gets its name.
+     *
+     * @param[in] path Path inside the image, with any case and separators.
+     * @param[out] name Name of the file in lower case, without directories. Only set if it returns true.
+     * @return True if the path is a file inside "ff7/movies" (not in a subdirectory of it).
+     */
+    bool IsMovie(const std::string& path, std::string* name = nullptr);
+
+}

+ 61 - 46
src/installer/WorldInstaller.cpp

@@ -23,6 +23,7 @@
 #include <OgreMeshSerializer.h>
 #include <boost/filesystem.hpp>
 #include "WorldInstaller.h"
+#include "ModelMaterials.h"
 #include "TexFile.h"
 #include "common/Lzs.h"
 #include "common/FinalFantasy7/FF7NameLookup.h"
@@ -42,7 +43,7 @@ std::string WorldInstaller::TERRAIN_MODELS_DIR("models/world/terrain");
 WorldInstaller::WorldInstaller(
   const DiskImage& disk_image, const std::string output_dir,
   const bool keep_originals, Ogre::ResourceGroupManager* res_mgr
-): disk_image_(disk_image), output_dir_(output_dir), keep_originals_(keep_originals), res_mgr_(res_mgr) {}
+): disk_image_(disk_image), output_dir_(output_dir), data_dir_(output_dir + "data/"), keep_originals_(keep_originals), res_mgr_(res_mgr) {}
 
 WorldInstaller::~WorldInstaller(){}
 
@@ -52,6 +53,15 @@ unsigned int WorldInstaller::Initialize(){
     wm_map_.push_back(File(*disk_image_.filePath("data/wm/WM0.MAP")));
     wm_map_.push_back(File(*disk_image_.filePath("data/wm/WM2.MAP")));
     wm_map_.push_back(File(*disk_image_.filePath("data/wm/WM3.MAP")));
+
+    // The game reads the original map files at runtime (data/wm), so install a copy.
+    boost::filesystem::create_directories(data_dir_ + "wm/");
+    for (const char* map : {"WM0.MAP", "WM2.MAP", "WM3.MAP"}){
+        boost::filesystem::copy_file(
+          *disk_image_.filePath(std::string("data/wm/") + map), data_dir_ + "wm/" + map,
+          boost::filesystem::copy_options::overwrite_existing
+        );
+    }
     return wm_map_.size();
 }
 
@@ -345,7 +355,7 @@ void WorldInstaller::GenerateMaterials(){
         Ogre::Technique* tech = mat->getTechnique(0);
         Ogre::Pass* pass = tech->getPass(0);
         pass->setDiffuse(0.7, 0.7, 0.7, 0.7);
-        mSer.exportMaterial(mat, output_dir_ + "models/world/" + t.name + ".material");
+        mSer.exportMaterial(mat, data_dir_ + "models/world/" + t.name + ".material");
     }
 }
 
@@ -353,7 +363,7 @@ void WorldInstaller::ProcessModels(){
     // Open world_us.lgp
     File world_file(*disk_image_.filePath("data/wm/world_us.lgp"));
     boost::filesystem::create_directories(output_dir_ + "temp/world_models/");
-    boost::filesystem::create_directories(output_dir_ + "data/models/world/");
+    boost::filesystem::create_directories(data_dir_ + "models/world/");
     VGears::LGPArchive world_lgp(*disk_image_.filePath("data/wm/world_us.lgp"), "LGP");
     world_lgp.open(*disk_image_.filePath("data/wm/world_us.lgp"), true);
     world_lgp.load();
@@ -383,25 +393,35 @@ void WorldInstaller::ProcessModels(){
         res_mgr_->removeResourceLocation(output_dir_ + "temp/world_models/", "FFVII");
         res_mgr_->addResourceLocation(output_dir_ + "temp/world_models/", "FileSystem", "FFVII", true, true);
 
-        // TODO: This needs work to assemble all the pieces. Similar to field models.
-        Ogre::ResourcePtr hrc = VGears::HRCFileManager::GetSingleton().load(file_name, "FFVII");
-        Ogre::String hrc_base_name;
-        VGears::StringUtil::splitBase(file_name, hrc_base_name);
-        auto mesh_name = VGears::NameLookup::model(hrc_base_name) + ".mesh";
-        Ogre::MeshPtr mesh(Ogre::MeshManager::getSingleton().load(mesh_name, "FFVII"));
-        Ogre::SkeletonPtr skeleton(mesh->getSkeleton());
-        // TODO: a files??
-        /*for (std::string anim : model.a){
-            VGears::AFileManager &afl_mgr(VGears::AFileManager::GetSingleton());
-            Ogre::String a_base_name;
-            VGears::StringUtil::splitBase(anim, a_base_name);
-            VGears::AFilePtr a
-              = afl_mgr.load(a_base_name + ".a", "FFVII").staticCast<VGears::AFile>();
-            // Convert the FF7 name to a more readable name set in the meta data.
-            VGears::StringUtil::splitBase(anim, base_name);
-            a->AddTo(skeleton, VGears::NameLookup::Animation(base_name));
-        }*/
-        ExportMesh(output_dir_ + "data/models/world/", mesh);
+        try{
+            // TODO: This needs work to assemble all the pieces. Similar to field models.
+            Ogre::ResourcePtr hrc = VGears::HRCFileManager::GetSingleton().load(file_name, "FFVII");
+            Ogre::String hrc_base_name;
+            VGears::StringUtil::splitBase(file_name, hrc_base_name);
+            auto mesh_name = VGears::NameLookup::model(hrc_base_name) + ".mesh";
+            Ogre::MeshPtr mesh(Ogre::MeshManager::getSingleton().load(mesh_name, "FFVII"));
+            Ogre::SkeletonPtr skeleton(mesh->getSkeleton());
+            // TODO: a files??
+            /*for (std::string anim : model.a){
+                VGears::AFileManager &afl_mgr(VGears::AFileManager::GetSingleton());
+                Ogre::String a_base_name;
+                VGears::StringUtil::splitBase(anim, a_base_name);
+                VGears::AFilePtr a
+                  = afl_mgr.load(a_base_name + ".a", "FFVII").staticCast<VGears::AFile>();
+                // Convert the FF7 name to a more readable name set in the meta data.
+                VGears::StringUtil::splitBase(anim, base_name);
+                a->AddTo(skeleton, VGears::NameLookup::Animation(base_name));
+            }*/
+            ExportMesh(data_dir_ + "models/world/", mesh);
+            // Models can share a skeleton name with different bones (also with field models), so drop this one
+            // (with its skeleton and mesh) before converting the next.
+            VGears::HRCFileManager::GetSingleton().remove(file_name, "FFVII");
+        }
+        catch (const std::exception& ex){
+            std::cerr << "[ERROR] Exception converting world model " << file_name << ": " << ex.what() << std::endl;
+            if (VGears::HRCFileManager::GetSingleton().resourceExists(file_name, "FFVII"))
+                VGears::HRCFileManager::GetSingleton().remove(file_name, "FFVII");
+        }
     }
 }
 
@@ -585,11 +605,11 @@ bool WorldInstaller::ProcessMap(){
         // Export the mesh.
         man.end();
         Ogre::MeshPtr mesh = man.convertToMesh(name);
-        //std::cout << "[WI] Export mesh " << (output_dir_ + "wm/" + mesh->getName() + ".mesh")
+        //std::cout << "[WI] Export mesh " << (data_dir_ + "wm/" + mesh->getName() + ".mesh")
         //  << std::endl;
         mesh_serializer.exportMesh(
           mesh.getPointer(),
-          output_dir_ + TERRAIN_MODELS_DIR + "/" + std::to_string(processed_maps_) + "/" + mesh->getName() + ".mesh"
+          data_dir_ + TERRAIN_MODELS_DIR + "/" + std::to_string(processed_maps_) + "/" + mesh->getName() + ".mesh"
         );
         // WM0 is te main worldmap, its shape changes according to the history progress, it has
         // 68 blocks but only 63 are used at a time
@@ -638,10 +658,10 @@ bool WorldInstaller::ProcessMap(){
     doc.LinkEndChild(xml.release());
     // TODO: Add models, entities...
     doc.SaveFile(
-      output_dir_ + "/world/" + std::to_string(processed_maps_) + "/world" + std::to_string(processed_maps_) + ".xml"
+      data_dir_ + "/world/" + std::to_string(processed_maps_) + "/world" + std::to_string(processed_maps_) + ".xml"
     );
     // Generate the walkmesh xml file
-    walkmesh.generate(output_dir_ + "/world/" + std::to_string(processed_maps_) + "/wm.xml");
+    walkmesh.generate(data_dir_ + "/world/" + std::to_string(processed_maps_) + "/wm.xml");
     // TODO: Generate other files: background, script, texts...
     // Ready for next map or next step.
     processed_maps_ ++;
@@ -676,7 +696,7 @@ void WorldInstaller::ExportEncounters(
       {0, 0, 1, 17}, {0, 9, 14, 17}, {0, 9, 25, 17}, {0, 10, 9, 17},
       {0, 9, 25, 17}, {0, 0, 8, 11}, {0, 0, 8, 0}, {0, 0, 1, 0}
     };
-    boost::filesystem::create_directories(output_dir_ + "/gamedata");
+    boost::filesystem::create_directories(data_dir_ + "/gamedata");
     TiXmlDocument doc;
     std::unique_ptr<TiXmlElement> root(new TiXmlElement("world_encounters"));
     for (unsigned int region = 0; region < 16; ++ region){
@@ -699,7 +719,7 @@ void WorldInstaller::ExportEncounters(
     }
     doc.LinkEndChild(new TiXmlDeclaration("1.0", "", ""));
     doc.LinkEndChild(root.release());
-    doc.SaveFile(output_dir_ + "/gamedata/world_encounters.xml");
+    doc.SaveFile(data_dir_ + "/gamedata/world_encounters.xml");
 }
 
 bool WorldInstaller::GenerateScripts(
@@ -774,7 +794,7 @@ bool WorldInstaller::GenerateScripts(
       field_table_data.data(), 0, field_table_entry->data_size
     );
 
-    const std::string script_directory = output_dir_ + "/scripts/worldmap";
+    const std::string script_directory = data_dir_ + "/scripts/worldmap";
     boost::filesystem::create_directories(script_directory);
     for (const VGears::LGPArchive::FileEntry* event_entry : event_entries){
         File event_file(&archive_file, event_entry->data_offset, event_entry->data_size);
@@ -817,10 +837,14 @@ bool WorldInstaller::GenerateScripts(
             ++generated_function_count;
         }
         catch (const DecompilerException&){
+            std::cerr << "[WARNING] World function " << script_index << " of " << map_name
+              << " could not be decompiled." << std::endl;
             script_file << "-- world function " << script_index
               << " could not be decompiled.\n";
         }
         catch (const std::exception& exception){
+            std::cerr << "[WARNING] World function " << script_index << " of " << map_name
+              << " failed: " << exception.what() << std::endl;
             script_file << "-- world function " << script_index
               << " failed: " << exception.what() << ".\n";
         }
@@ -908,6 +932,7 @@ void WorldInstaller::ExportMesh(const std::string outdir, const Ogre::MeshPtr &m
     // TODO: Share function with pc model exporter
     VGears::String base_mesh_name;
     VGears::StringUtil::splitFull(mesh->getName(), base_mesh_name);
+    MakeMaterialsUnique(mesh, base_mesh_name);
     Ogre::MeshSerializer mesh_serializer;
     Ogre::SkeletonPtr skeleton(mesh->getSkeleton());
     if (!skeleton.isNull()){
@@ -919,7 +944,6 @@ void WorldInstaller::ExportMesh(const std::string outdir, const Ogre::MeshPtr &m
     Ogre::Mesh::SubMeshIterator it(mesh->getSubMeshIterator());
     Ogre::MaterialSerializer mat_ser;
     size_t i(0);
-    std::set<std::string> textures;
     std::set<std::string> queued_materials;
     bool has_queued_materials = false;
     while (it.hasMoreElements()){
@@ -939,6 +963,13 @@ void WorldInstaller::ExportMesh(const std::string outdir, const Ogre::MeshPtr &m
                             ){
                                 Ogre::TextureUnitState* unit = pass->getTextureUnitState(texture_unit_num);
                                 if (unit && unit->getTextureName().empty() == false){
+                                    // Materials are shared by name across all model types, so this one may have
+                                    // already been converted for a field or battle model (its texture is then a png).
+                                    if (!boost::filesystem::exists(output_dir_ + "temp/world_models/" + unit->getTextureName())){
+                                        std::cerr << "[WARNING] Texture " << unit->getTextureName()
+                                          << " not found in temp/world_models; skipping it." << std::endl;
+                                        continue;
+                                    }
                                     try{
                                         // Convert the texture from .tex to .png.
                                         TexFile tex(output_dir_ + "temp/world_models/" + unit->getTextureName());
@@ -955,7 +986,6 @@ void WorldInstaller::ExportMesh(const std::string outdir, const Ogre::MeshPtr &m
                                         boost::filesystem::copy_file(
                                           plain_png, model_png, boost::filesystem::copy_options::overwrite_existing
                                         );
-                                        textures.insert(unit->getTextureName());
                                     }
                                     catch (const std::exception& ex){
                                         std::cerr << "[ERROR] Texture export failed for "
@@ -977,20 +1007,5 @@ void WorldInstaller::ExportMesh(const std::string outdir, const Ogre::MeshPtr &m
         ++ i;
     }
     if (has_queued_materials) mat_ser.exportQueued(outdir + base_mesh_name + VGears::EXT_MATERIAL);
-    for (auto& texture_name : textures){
-        std::string tex_name = texture_name.c_str();
-        try{
-            Ogre::TexturePtr texture_ptr = Ogre::TextureManager::getSingleton().load(tex_name, "FFVIITextures");
-            //Ogre::TexturePtr texture_ptr = Ogre::TextureManager::getSingleton().load(tex_name, "FFVII");
-            Ogre::Image image;
-            texture_ptr->convertToImage(image);
-            Ogre::String base_name;
-            VGears::StringUtil::splitBase(texture_name, base_name);
-            image.save(outdir + base_mesh_name + "_" + base_name + ".png");
-        }
-        catch (std::exception const& ex){
-            std::cerr << "[ERROR] Exception: " << ex.what() << std::endl;
-        }
-    }
 }
 

+ 5 - 0
src/installer/WorldInstaller.h

@@ -342,6 +342,11 @@ class WorldInstaller{
          */
         std::string output_dir_;
 
+        /**
+         * Directory where the game data is installed (<install dir>/data/).
+         */
+        std::string data_dir_;
+
         /**
          * If true, original files will not be deleted after conversion.
          */

+ 0 - 653
src/installer/ui_MainWindow.h

@@ -1,653 +0,0 @@
-/********************************************************************************
-** Form generated from reading UI file 'MainWindow.ui'
-**
-** Created by: Qt User Interface Compiler version 5.15.19
-**
-** WARNING! All changes made in this file will be lost when recompiling UI file!
-********************************************************************************/
-
-#ifndef UI_MAINWINDOW_H
-#define UI_MAINWINDOW_H
-
-#include <QtCore/QVariant>
-#include <QtGui/QIcon>
-#include <QtWidgets/QAction>
-#include <QtWidgets/QApplication>
-#include <QtWidgets/QCheckBox>
-#include <QtWidgets/QGroupBox>
-#include <QtWidgets/QHBoxLayout>
-#include <QtWidgets/QLabel>
-#include <QtWidgets/QLineEdit>
-#include <QtWidgets/QMainWindow>
-#include <QtWidgets/QMenu>
-#include <QtWidgets/QMenuBar>
-#include <QtWidgets/QProgressBar>
-#include <QtWidgets/QPushButton>
-#include <QtWidgets/QTabWidget>
-#include <QtWidgets/QTextEdit>
-#include <QtWidgets/QVBoxLayout>
-#include <QtWidgets/QWidget>
-
-QT_BEGIN_NAMESPACE
-
-class Ui_MainWindow
-{
-public:
-    QAction *actionExit;
-    QAction *actionABout;
-    QAction *actionHelp;
-    QWidget *centralWidget;
-    QVBoxLayout *verticalLayout;
-    QTabWidget *tabWidget;
-    QWidget *tab_installer;
-    QVBoxLayout *verticalLayout_3;
-    QHBoxLayout *isoLocator;
-    QLabel *label_4;
-    QLineEdit *line_data_src;
-    QPushButton *btn_data_src;
-    QPushButton *help_data_src;
-    QVBoxLayout *isoInfo;
-    QLabel *isoData;
-    QLabel *isoError;
-    QHBoxLayout *output;
-    QLabel *label_3;
-    QLineEdit *line_data_dst;
-    QPushButton *btn_data_dst;
-    QPushButton *help_data_dest;
-    QCheckBox *chk_advanced_options;
-    QGroupBox *advancedOptions;
-    QLabel *advancedLabelBattle;
-    QLabel *advancedLabelGame;
-    QLabel *advancedLabelMedia;
-    QCheckBox *chk_sounds;
-    QCheckBox *chk_music;
-    QLabel *advancedLabelFields;
-    QLabel *advancedLabelWorld;
-    QLabel *advancedLabelOther;
-    QCheckBox *chk_clean_install;
-    QCheckBox *chk_wm;
-    QCheckBox *chk_ffmpeg;
-    QCheckBox *chk_images;
-    QCheckBox *chk_battle_data;
-    QCheckBox *chk_wm_models;
-    QCheckBox *chk_timidity;
-    QCheckBox *chk_keep_original;
-    QCheckBox *chk_fields;
-    QCheckBox *chk_kernel;
-    QCheckBox *chk_field_models;
-    QCheckBox *chk_videos;
-    QCheckBox *chk_battle_models;
-    QPushButton *btn_data_run;
-    QProgressBar *data_progress_bar;
-    QLabel *label_percent;
-    QLabel *label_progress;
-    QLabel *label_5;
-    QTextEdit *data_log;
-    QWidget *tab_vgears;
-    QVBoxLayout *verticalLayout_2;
-    QHBoxLayout *horizontalLayout;
-    QLabel *label_2;
-    QLineEdit *line_vgears_exe;
-    QPushButton *btn_vgears_exe;
-    QHBoxLayout *horizontalLayout_2;
-    QLabel *label;
-    QLineEdit *line_vgears_config;
-    QPushButton *btn_vgears_config;
-    QPushButton *btn_vgears_launch;
-    QMenuBar *menuBar;
-    QMenu *menuFile;
-
-    void setupUi(QMainWindow *MainWindow)
-    {
-        if (MainWindow->objectName().isEmpty())
-            MainWindow->setObjectName(QString::fromUtf8("MainWindow"));
-        MainWindow->resize(720, 705);
-        MainWindow->setMinimumSize(QSize(0, 0));
-        actionExit = new QAction(MainWindow);
-        actionExit->setObjectName(QString::fromUtf8("actionExit"));
-        actionABout = new QAction(MainWindow);
-        actionABout->setObjectName(QString::fromUtf8("actionABout"));
-        actionHelp = new QAction(MainWindow);
-        actionHelp->setObjectName(QString::fromUtf8("actionHelp"));
-        centralWidget = new QWidget(MainWindow);
-        centralWidget->setObjectName(QString::fromUtf8("centralWidget"));
-        verticalLayout = new QVBoxLayout(centralWidget);
-        verticalLayout->setSpacing(6);
-        verticalLayout->setContentsMargins(11, 11, 11, 11);
-        verticalLayout->setObjectName(QString::fromUtf8("verticalLayout"));
-        tabWidget = new QTabWidget(centralWidget);
-        tabWidget->setObjectName(QString::fromUtf8("tabWidget"));
-        tabWidget->setTabPosition(QTabWidget::TabPosition::North);
-        tab_installer = new QWidget();
-        tab_installer->setObjectName(QString::fromUtf8("tab_installer"));
-        tab_installer->setMinimumSize(QSize(698, 630));
-        verticalLayout_3 = new QVBoxLayout(tab_installer);
-        verticalLayout_3->setSpacing(6);
-        verticalLayout_3->setContentsMargins(11, 11, 11, 11);
-        verticalLayout_3->setObjectName(QString::fromUtf8("verticalLayout_3"));
-        isoLocator = new QHBoxLayout();
-        isoLocator->setSpacing(6);
-        isoLocator->setObjectName(QString::fromUtf8("isoLocator"));
-        label_4 = new QLabel(tab_installer);
-        label_4->setObjectName(QString::fromUtf8("label_4"));
-        label_4->setMinimumSize(QSize(200, 0));
-        label_4->setMaximumSize(QSize(200, 50));
-        label_4->setAlignment(Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignVCenter);
-
-        isoLocator->addWidget(label_4);
-
-        line_data_src = new QLineEdit(tab_installer);
-        line_data_src->setObjectName(QString::fromUtf8("line_data_src"));
-
-        isoLocator->addWidget(line_data_src);
-
-        btn_data_src = new QPushButton(tab_installer);
-        btn_data_src->setObjectName(QString::fromUtf8("btn_data_src"));
-        btn_data_src->setMaximumSize(QSize(24, 24));
-
-        isoLocator->addWidget(btn_data_src);
-
-        help_data_src = new QPushButton(tab_installer);
-        help_data_src->setObjectName(QString::fromUtf8("help_data_src"));
-        QIcon icon;
-        QString iconThemeName = QString::fromUtf8("dialog-information");
-        if (QIcon::hasThemeIcon(iconThemeName)) {
-            icon = QIcon::fromTheme(iconThemeName);
-        } else {
-            icon.addFile(QString::fromUtf8("Workspace/Software/V-Gears/V-Gears-Installer/src"), QSize(), QIcon::Normal, QIcon::Off);
-        }
-        help_data_src->setIcon(icon);
-        help_data_src->setCheckable(false);
-        help_data_src->setFlat(true);
-
-        isoLocator->addWidget(help_data_src);
-
-
-        verticalLayout_3->addLayout(isoLocator);
-
-        isoInfo = new QVBoxLayout();
-        isoInfo->setSpacing(6);
-        isoInfo->setObjectName(QString::fromUtf8("isoInfo"));
-        isoInfo->setContentsMargins(30, -1, -1, -1);
-        isoData = new QLabel(tab_installer);
-        isoData->setObjectName(QString::fromUtf8("isoData"));
-        isoData->setMargin(0);
-
-        isoInfo->addWidget(isoData);
-
-        isoError = new QLabel(tab_installer);
-        isoError->setObjectName(QString::fromUtf8("isoError"));
-
-        isoInfo->addWidget(isoError);
-
-
-        verticalLayout_3->addLayout(isoInfo);
-
-        output = new QHBoxLayout();
-        output->setSpacing(6);
-        output->setObjectName(QString::fromUtf8("output"));
-        label_3 = new QLabel(tab_installer);
-        label_3->setObjectName(QString::fromUtf8("label_3"));
-        label_3->setMinimumSize(QSize(200, 0));
-        label_3->setMaximumSize(QSize(200, 50));
-        label_3->setAlignment(Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignVCenter);
-
-        output->addWidget(label_3);
-
-        line_data_dst = new QLineEdit(tab_installer);
-        line_data_dst->setObjectName(QString::fromUtf8("line_data_dst"));
-
-        output->addWidget(line_data_dst);
-
-        btn_data_dst = new QPushButton(tab_installer);
-        btn_data_dst->setObjectName(QString::fromUtf8("btn_data_dst"));
-        btn_data_dst->setMaximumSize(QSize(24, 24));
-
-        output->addWidget(btn_data_dst);
-
-        help_data_dest = new QPushButton(tab_installer);
-        help_data_dest->setObjectName(QString::fromUtf8("help_data_dest"));
-        help_data_dest->setIcon(icon);
-        help_data_dest->setCheckable(false);
-        help_data_dest->setFlat(true);
-
-        output->addWidget(help_data_dest);
-
-
-        verticalLayout_3->addLayout(output);
-
-        chk_advanced_options = new QCheckBox(tab_installer);
-        chk_advanced_options->setObjectName(QString::fromUtf8("chk_advanced_options"));
-
-        verticalLayout_3->addWidget(chk_advanced_options);
-
-        advancedOptions = new QGroupBox(tab_installer);
-        advancedOptions->setObjectName(QString::fromUtf8("advancedOptions"));
-        advancedOptions->setEnabled(true);
-        QSizePolicy sizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed);
-        sizePolicy.setHorizontalStretch(0);
-        sizePolicy.setVerticalStretch(0);
-        sizePolicy.setHeightForWidth(advancedOptions->sizePolicy().hasHeightForWidth());
-        advancedOptions->setSizePolicy(sizePolicy);
-        advancedOptions->setMinimumSize(QSize(680, 180));
-        advancedOptions->setMaximumSize(QSize(16777215, 180));
-        advancedOptions->setAlignment(Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop);
-        advancedOptions->setFlat(false);
-        advancedOptions->setCheckable(false);
-        advancedLabelBattle = new QLabel(advancedOptions);
-        advancedLabelBattle->setObjectName(QString::fromUtf8("advancedLabelBattle"));
-        advancedLabelBattle->setGeometry(QRect(10, 10, 120, 20));
-        QFont font;
-        font.setBold(true);
-        font.setUnderline(true);
-        advancedLabelBattle->setFont(font);
-        advancedLabelGame = new QLabel(advancedOptions);
-        advancedLabelGame->setObjectName(QString::fromUtf8("advancedLabelGame"));
-        advancedLabelGame->setGeometry(QRect(10, 30, 120, 20));
-        advancedLabelGame->setFont(font);
-        advancedLabelMedia = new QLabel(advancedOptions);
-        advancedLabelMedia->setObjectName(QString::fromUtf8("advancedLabelMedia"));
-        advancedLabelMedia->setGeometry(QRect(10, 50, 120, 20));
-        advancedLabelMedia->setFont(font);
-        chk_sounds = new QCheckBox(advancedOptions);
-        chk_sounds->setObjectName(QString::fromUtf8("chk_sounds"));
-        chk_sounds->setGeometry(QRect(360, 50, 200, 20));
-        chk_sounds->setMinimumSize(QSize(200, 20));
-        chk_sounds->setMaximumSize(QSize(200, 20));
-        chk_sounds->setChecked(true);
-        chk_music = new QCheckBox(advancedOptions);
-        chk_music->setObjectName(QString::fromUtf8("chk_music"));
-        chk_music->setGeometry(QRect(150, 70, 200, 20));
-        chk_music->setMinimumSize(QSize(200, 20));
-        chk_music->setMaximumSize(QSize(200, 20));
-        chk_music->setChecked(true);
-        advancedLabelFields = new QLabel(advancedOptions);
-        advancedLabelFields->setObjectName(QString::fromUtf8("advancedLabelFields"));
-        advancedLabelFields->setGeometry(QRect(10, 110, 120, 20));
-        advancedLabelFields->setFont(font);
-        advancedLabelWorld = new QLabel(advancedOptions);
-        advancedLabelWorld->setObjectName(QString::fromUtf8("advancedLabelWorld"));
-        advancedLabelWorld->setGeometry(QRect(10, 130, 120, 20));
-        advancedLabelWorld->setFont(font);
-        advancedLabelOther = new QLabel(advancedOptions);
-        advancedLabelOther->setObjectName(QString::fromUtf8("advancedLabelOther"));
-        advancedLabelOther->setGeometry(QRect(10, 150, 120, 20));
-        advancedLabelOther->setFont(font);
-        chk_clean_install = new QCheckBox(advancedOptions);
-        chk_clean_install->setObjectName(QString::fromUtf8("chk_clean_install"));
-        chk_clean_install->setGeometry(QRect(360, 150, 200, 20));
-        chk_clean_install->setMinimumSize(QSize(200, 20));
-        chk_clean_install->setMaximumSize(QSize(200, 20));
-        chk_clean_install->setCheckable(true);
-        chk_wm = new QCheckBox(advancedOptions);
-        chk_wm->setObjectName(QString::fromUtf8("chk_wm"));
-        chk_wm->setGeometry(QRect(150, 130, 200, 20));
-        chk_wm->setMinimumSize(QSize(200, 20));
-        chk_wm->setMaximumSize(QSize(200, 20));
-        chk_wm->setChecked(true);
-        chk_wm->setTristate(false);
-        chk_ffmpeg = new QCheckBox(advancedOptions);
-        chk_ffmpeg->setObjectName(QString::fromUtf8("chk_ffmpeg"));
-        chk_ffmpeg->setGeometry(QRect(150, 90, 200, 20));
-        chk_ffmpeg->setMinimumSize(QSize(200, 20));
-        chk_ffmpeg->setMaximumSize(QSize(200, 20));
-        chk_ffmpeg->setChecked(true);
-        chk_images = new QCheckBox(advancedOptions);
-        chk_images->setObjectName(QString::fromUtf8("chk_images"));
-        chk_images->setGeometry(QRect(150, 50, 200, 20));
-        chk_images->setMinimumSize(QSize(200, 20));
-        chk_images->setMaximumSize(QSize(200, 20));
-        chk_images->setChecked(true);
-        chk_battle_data = new QCheckBox(advancedOptions);
-        chk_battle_data->setObjectName(QString::fromUtf8("chk_battle_data"));
-        chk_battle_data->setGeometry(QRect(150, 10, 200, 20));
-        chk_battle_data->setMinimumSize(QSize(200, 20));
-        chk_battle_data->setMaximumSize(QSize(200, 20));
-        chk_battle_data->setChecked(true);
-        chk_wm_models = new QCheckBox(advancedOptions);
-        chk_wm_models->setObjectName(QString::fromUtf8("chk_wm_models"));
-        chk_wm_models->setGeometry(QRect(360, 130, 200, 20));
-        chk_wm_models->setMinimumSize(QSize(200, 20));
-        chk_wm_models->setMaximumSize(QSize(200, 20));
-        chk_wm_models->setChecked(true);
-        chk_timidity = new QCheckBox(advancedOptions);
-        chk_timidity->setObjectName(QString::fromUtf8("chk_timidity"));
-        chk_timidity->setGeometry(QRect(360, 90, 200, 20));
-        chk_timidity->setMinimumSize(QSize(200, 20));
-        chk_timidity->setMaximumSize(QSize(200, 20));
-        chk_timidity->setChecked(true);
-        chk_keep_original = new QCheckBox(advancedOptions);
-        chk_keep_original->setObjectName(QString::fromUtf8("chk_keep_original"));
-        chk_keep_original->setGeometry(QRect(150, 150, 200, 20));
-        chk_keep_original->setMinimumSize(QSize(200, 20));
-        chk_keep_original->setMaximumSize(QSize(200, 20));
-        chk_keep_original->setChecked(false);
-        chk_fields = new QCheckBox(advancedOptions);
-        chk_fields->setObjectName(QString::fromUtf8("chk_fields"));
-        chk_fields->setGeometry(QRect(150, 110, 200, 20));
-        chk_fields->setMinimumSize(QSize(200, 20));
-        chk_fields->setMaximumSize(QSize(200, 20));
-        chk_fields->setChecked(true);
-        chk_kernel = new QCheckBox(advancedOptions);
-        chk_kernel->setObjectName(QString::fromUtf8("chk_kernel"));
-        chk_kernel->setGeometry(QRect(150, 30, 200, 20));
-        chk_kernel->setMinimumSize(QSize(200, 20));
-        chk_kernel->setMaximumSize(QSize(200, 20));
-        chk_kernel->setChecked(true);
-        chk_field_models = new QCheckBox(advancedOptions);
-        chk_field_models->setObjectName(QString::fromUtf8("chk_field_models"));
-        chk_field_models->setGeometry(QRect(360, 110, 200, 20));
-        chk_field_models->setMinimumSize(QSize(200, 20));
-        chk_field_models->setMaximumSize(QSize(200, 20));
-        chk_field_models->setChecked(true);
-        chk_videos = new QCheckBox(advancedOptions);
-        chk_videos->setObjectName(QString::fromUtf8("chk_videos"));
-        chk_videos->setGeometry(QRect(360, 70, 200, 20));
-        chk_videos->setMinimumSize(QSize(200, 20));
-        chk_videos->setMaximumSize(QSize(200, 20));
-        chk_videos->setChecked(true);
-        chk_battle_models = new QCheckBox(advancedOptions);
-        chk_battle_models->setObjectName(QString::fromUtf8("chk_battle_models"));
-        chk_battle_models->setGeometry(QRect(360, 10, 200, 20));
-        chk_battle_models->setMinimumSize(QSize(200, 20));
-        chk_battle_models->setMaximumSize(QSize(200, 20));
-        chk_battle_models->setBaseSize(QSize(0, 0));
-        chk_battle_models->setChecked(true);
-
-        verticalLayout_3->addWidget(advancedOptions);
-
-        btn_data_run = new QPushButton(tab_installer);
-        btn_data_run->setObjectName(QString::fromUtf8("btn_data_run"));
-
-        verticalLayout_3->addWidget(btn_data_run);
-
-        data_progress_bar = new QProgressBar(tab_installer);
-        data_progress_bar->setObjectName(QString::fromUtf8("data_progress_bar"));
-        data_progress_bar->setValue(0);
-        data_progress_bar->setAlignment(Qt::AlignmentFlag::AlignCenter);
-        data_progress_bar->setTextVisible(false);
-
-        verticalLayout_3->addWidget(data_progress_bar);
-
-        label_percent = new QLabel(tab_installer);
-        label_percent->setObjectName(QString::fromUtf8("label_percent"));
-        QSizePolicy sizePolicy1(QSizePolicy::Expanding, QSizePolicy::Fixed);
-        sizePolicy1.setHorizontalStretch(1);
-        sizePolicy1.setVerticalStretch(0);
-        sizePolicy1.setHeightForWidth(label_percent->sizePolicy().hasHeightForWidth());
-        label_percent->setSizePolicy(sizePolicy1);
-        label_percent->setMinimumSize(QSize(0, 0));
-        label_percent->setMaximumSize(QSize(16777215, 10));
-        QFont font1;
-        font1.setPointSize(8);
-        font1.setItalic(true);
-        label_percent->setFont(font1);
-        label_percent->setAlignment(Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter);
-
-        verticalLayout_3->addWidget(label_percent);
-
-        label_progress = new QLabel(tab_installer);
-        label_progress->setObjectName(QString::fromUtf8("label_progress"));
-        sizePolicy1.setHeightForWidth(label_progress->sizePolicy().hasHeightForWidth());
-        label_progress->setSizePolicy(sizePolicy1);
-        label_progress->setMinimumSize(QSize(0, 0));
-        label_progress->setMaximumSize(QSize(16777215, 10));
-        label_progress->setFont(font1);
-        label_progress->setAlignment(Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter);
-
-        verticalLayout_3->addWidget(label_progress);
-
-        label_5 = new QLabel(tab_installer);
-        label_5->setObjectName(QString::fromUtf8("label_5"));
-        label_5->setMaximumSize(QSize(16777215, 15));
-        QFont font2;
-        font2.setFamily(QString::fromUtf8("Courier"));
-        font2.setPointSize(8);
-        label_5->setFont(font2);
-        label_5->setMargin(-1);
-
-        verticalLayout_3->addWidget(label_5);
-
-        data_log = new QTextEdit(tab_installer);
-        data_log->setObjectName(QString::fromUtf8("data_log"));
-        data_log->setFont(font2);
-        data_log->setVerticalScrollBarPolicy(Qt::ScrollBarPolicy::ScrollBarAlwaysOn);
-        data_log->setUndoRedoEnabled(false);
-        data_log->setReadOnly(true);
-
-        verticalLayout_3->addWidget(data_log);
-
-        tabWidget->addTab(tab_installer, QString());
-        tab_vgears = new QWidget();
-        tab_vgears->setObjectName(QString::fromUtf8("tab_vgears"));
-        verticalLayout_2 = new QVBoxLayout(tab_vgears);
-        verticalLayout_2->setSpacing(6);
-        verticalLayout_2->setContentsMargins(11, 11, 11, 11);
-        verticalLayout_2->setObjectName(QString::fromUtf8("verticalLayout_2"));
-        horizontalLayout = new QHBoxLayout();
-        horizontalLayout->setSpacing(6);
-        horizontalLayout->setObjectName(QString::fromUtf8("horizontalLayout"));
-        label_2 = new QLabel(tab_vgears);
-        label_2->setObjectName(QString::fromUtf8("label_2"));
-
-        horizontalLayout->addWidget(label_2);
-
-        line_vgears_exe = new QLineEdit(tab_vgears);
-        line_vgears_exe->setObjectName(QString::fromUtf8("line_vgears_exe"));
-
-        horizontalLayout->addWidget(line_vgears_exe);
-
-        btn_vgears_exe = new QPushButton(tab_vgears);
-        btn_vgears_exe->setObjectName(QString::fromUtf8("btn_vgears_exe"));
-        QSizePolicy sizePolicy2(QSizePolicy::Fixed, QSizePolicy::Fixed);
-        sizePolicy2.setHorizontalStretch(0);
-        sizePolicy2.setVerticalStretch(0);
-        sizePolicy2.setHeightForWidth(btn_vgears_exe->sizePolicy().hasHeightForWidth());
-        btn_vgears_exe->setSizePolicy(sizePolicy2);
-        btn_vgears_exe->setMaximumSize(QSize(24, 24));
-
-        horizontalLayout->addWidget(btn_vgears_exe);
-
-
-        verticalLayout_2->addLayout(horizontalLayout);
-
-        horizontalLayout_2 = new QHBoxLayout();
-        horizontalLayout_2->setSpacing(6);
-        horizontalLayout_2->setObjectName(QString::fromUtf8("horizontalLayout_2"));
-        label = new QLabel(tab_vgears);
-        label->setObjectName(QString::fromUtf8("label"));
-
-        horizontalLayout_2->addWidget(label);
-
-        line_vgears_config = new QLineEdit(tab_vgears);
-        line_vgears_config->setObjectName(QString::fromUtf8("line_vgears_config"));
-
-        horizontalLayout_2->addWidget(line_vgears_config);
-
-        btn_vgears_config = new QPushButton(tab_vgears);
-        btn_vgears_config->setObjectName(QString::fromUtf8("btn_vgears_config"));
-        sizePolicy2.setHeightForWidth(btn_vgears_config->sizePolicy().hasHeightForWidth());
-        btn_vgears_config->setSizePolicy(sizePolicy2);
-        btn_vgears_config->setMaximumSize(QSize(24, 24));
-
-        horizontalLayout_2->addWidget(btn_vgears_config);
-
-
-        verticalLayout_2->addLayout(horizontalLayout_2);
-
-        btn_vgears_launch = new QPushButton(tab_vgears);
-        btn_vgears_launch->setObjectName(QString::fromUtf8("btn_vgears_launch"));
-
-        verticalLayout_2->addWidget(btn_vgears_launch);
-
-        tabWidget->addTab(tab_vgears, QString());
-
-        verticalLayout->addWidget(tabWidget);
-
-        MainWindow->setCentralWidget(centralWidget);
-        menuBar = new QMenuBar(MainWindow);
-        menuBar->setObjectName(QString::fromUtf8("menuBar"));
-        menuBar->setGeometry(QRect(0, 0, 720, 23));
-        menuFile = new QMenu(menuBar);
-        menuFile->setObjectName(QString::fromUtf8("menuFile"));
-        MainWindow->setMenuBar(menuBar);
-        QWidget::setTabOrder(tabWidget, line_data_src);
-        QWidget::setTabOrder(line_data_src, btn_data_src);
-        QWidget::setTabOrder(btn_data_src, help_data_src);
-        QWidget::setTabOrder(help_data_src, line_data_dst);
-        QWidget::setTabOrder(line_data_dst, btn_data_dst);
-        QWidget::setTabOrder(btn_data_dst, help_data_dest);
-        QWidget::setTabOrder(help_data_dest, chk_advanced_options);
-        QWidget::setTabOrder(chk_advanced_options, chk_battle_data);
-        QWidget::setTabOrder(chk_battle_data, chk_battle_models);
-        QWidget::setTabOrder(chk_battle_models, chk_kernel);
-        QWidget::setTabOrder(chk_kernel, chk_images);
-        QWidget::setTabOrder(chk_images, chk_sounds);
-        QWidget::setTabOrder(chk_sounds, chk_music);
-        QWidget::setTabOrder(chk_music, chk_videos);
-        QWidget::setTabOrder(chk_videos, chk_ffmpeg);
-        QWidget::setTabOrder(chk_ffmpeg, chk_timidity);
-        QWidget::setTabOrder(chk_timidity, chk_fields);
-        QWidget::setTabOrder(chk_fields, chk_field_models);
-        QWidget::setTabOrder(chk_field_models, chk_wm);
-        QWidget::setTabOrder(chk_wm, chk_wm_models);
-        QWidget::setTabOrder(chk_wm_models, chk_keep_original);
-        QWidget::setTabOrder(chk_keep_original, chk_clean_install);
-        QWidget::setTabOrder(chk_clean_install, btn_data_run);
-        QWidget::setTabOrder(btn_data_run, data_log);
-        QWidget::setTabOrder(data_log, line_vgears_exe);
-        QWidget::setTabOrder(line_vgears_exe, btn_vgears_exe);
-        QWidget::setTabOrder(btn_vgears_exe, line_vgears_config);
-        QWidget::setTabOrder(line_vgears_config, btn_vgears_config);
-        QWidget::setTabOrder(btn_vgears_config, btn_vgears_launch);
-
-        menuBar->addAction(menuFile->menuAction());
-        menuFile->addAction(actionABout);
-        menuFile->addAction(actionHelp);
-        menuFile->addSeparator();
-        menuFile->addAction(actionExit);
-
-        retranslateUi(MainWindow);
-        QObject::connect(actionExit, SIGNAL(triggered()), MainWindow, SLOT(close()));
-
-        tabWidget->setCurrentIndex(0);
-
-
-        QMetaObject::connectSlotsByName(MainWindow);
-    } // setupUi
-
-    void retranslateUi(QMainWindow *MainWindow)
-    {
-        MainWindow->setWindowTitle(QCoreApplication::translate("MainWindow", "VGears Installer", nullptr));
-        actionExit->setText(QCoreApplication::translate("MainWindow", "Exit", nullptr));
-        actionABout->setText(QCoreApplication::translate("MainWindow", "About", nullptr));
-        actionHelp->setText(QCoreApplication::translate("MainWindow", "Help", nullptr));
-        label_4->setText(QCoreApplication::translate("MainWindow", "Final Fantasy VII ISO file: ", nullptr));
-        btn_data_src->setText(QCoreApplication::translate("MainWindow", "...", nullptr));
-#if QT_CONFIG(tooltip)
-        help_data_src->setToolTip(QCoreApplication::translate("MainWindow", "\n"
-"              <html><head/><body><b>Path to the extracted FFVII (PC version) data.</b><br><br>It must contain (at least) the folders 'kernel' and 'field'.</body></html>\n"
-"             ", nullptr));
-#endif // QT_CONFIG(tooltip)
-        help_data_src->setText(QString());
-        isoData->setText(QString());
-        isoError->setText(QString());
-        label_3->setText(QCoreApplication::translate("MainWindow", "VGears installation directory:", nullptr));
-        btn_data_dst->setText(QCoreApplication::translate("MainWindow", "...", nullptr));
-#if QT_CONFIG(tooltip)
-        help_data_dest->setToolTip(QCoreApplication::translate("MainWindow", "\n"
-"              <html><head/><body><b>V-Gears data installation directory.</b><br><br>The data will be installed to this location. Existing installations will be overwritten.</body></html>\n"
-"             ", nullptr));
-#endif // QT_CONFIG(tooltip)
-        help_data_dest->setText(QString());
-        chk_advanced_options->setText(QCoreApplication::translate("MainWindow", "Show advanced options", nullptr));
-        advancedOptions->setTitle(QString());
-        advancedLabelBattle->setText(QCoreApplication::translate("MainWindow", "Battle data", nullptr));
-        advancedLabelGame->setText(QCoreApplication::translate("MainWindow", "Game data", nullptr));
-        advancedLabelMedia->setText(QCoreApplication::translate("MainWindow", "Multimedia", nullptr));
-#if QT_CONFIG(tooltip)
-        chk_sounds->setToolTip(QCoreApplication::translate("MainWindow", "<html><head/><body><p><span style=\" font-weight:700;\">Don't extract sound.</span><br/><br/>If checked, game sound efects will not be extracted.<br/><br/>Sound extraction can take a few minutes, and it's ok to skip this step if it has already been installed.</p></body></html>", nullptr));
-#endif // QT_CONFIG(tooltip)
-        chk_sounds->setText(QCoreApplication::translate("MainWindow", "Don't extract sounds", nullptr));
-#if QT_CONFIG(tooltip)
-        chk_music->setToolTip(QCoreApplication::translate("MainWindow", "<html><head/><body><p><span style=\" font-weight:700;\">Don't extract music.</span><br/><br/>If checked, game music tracks will not be extracted.<br/><br/>Music extraction can take a few minutes, and it's ok to skip this step if it has already been installed.</p></body></html>", nullptr));
-#endif // QT_CONFIG(tooltip)
-        chk_music->setText(QCoreApplication::translate("MainWindow", "Don't extract music", nullptr));
-        advancedLabelFields->setText(QCoreApplication::translate("MainWindow", "Field Maps", nullptr));
-        advancedLabelWorld->setText(QCoreApplication::translate("MainWindow", "World Map", nullptr));
-        advancedLabelOther->setText(QCoreApplication::translate("MainWindow", "Other", nullptr));
-#if QT_CONFIG(tooltip)
-        chk_clean_install->setToolTip(QCoreApplication::translate("MainWindow", "<html><head/><body><p><span style=\" font-weight:700;\">Delete previously installed files.</span></p><p><br/></p><p>Performs a clean installation, unconditionally removing all previously installed data.</p></body></html>", nullptr));
-#endif // QT_CONFIG(tooltip)
-        chk_clean_install->setText(QCoreApplication::translate("MainWindow", "Clean install", nullptr));
-#if QT_CONFIG(tooltip)
-        chk_wm->setToolTip(QCoreApplication::translate("MainWindow", "<html><head/><body><b>Don't extract world map data.</b><br><br>If checked, world map models and data will not be extracted. Data from previous installations will not be deleted.<br><br>This installation step is not usually very long, so there is no reason to skip it unless you have manually edited the world map data. Skipping world map data installation will also skip the installation of the world map 3D models.</body></html>", nullptr));
-#endif // QT_CONFIG(tooltip)
-        chk_wm->setText(QCoreApplication::translate("MainWindow", "Don't extract world map data", nullptr));
-#if QT_CONFIG(tooltip)
-        chk_ffmpeg->setToolTip(QCoreApplication::translate("MainWindow", "<html><head/><body><b>Don't use the ffmpeg command.</b><br><br>If checked, sound effects and background music will not be available to V-Gears.<br><br>Sound effects and music indexes will still be built, but you will need to provide your own ogg files.</body></html>", nullptr));
-#endif // QT_CONFIG(tooltip)
-        chk_ffmpeg->setText(QCoreApplication::translate("MainWindow", "Prevent FFmpeg calls", nullptr));
-#if QT_CONFIG(tooltip)
-        chk_images->setToolTip(QCoreApplication::translate("MainWindow", "<html><head/><body><b>Don't extract images.</b><br><br>If checked, images will not be extracted. This includes window decorations, menus icons, character portraits... It doesn' include backgrounds or textures. Images from previous installations will not be deleted.<br><br>This installation step is usually quite fast, so there is no reason to skip it unless you have manually edited the images.</body></html>", nullptr));
-#endif // QT_CONFIG(tooltip)
-        chk_images->setText(QCoreApplication::translate("MainWindow", "Don't extract images", nullptr));
-#if QT_CONFIG(tooltip)
-        chk_battle_data->setToolTip(QCoreApplication::translate("MainWindow", "<html><head/><body><b>Don't extract battle data.</b><br><br>If checked, battle data such as enemies, attacks or enemy formations will not be extracted. Data from previous installations will not be deleted.<br><br>This installation step is usually quite fast, so there is no reason to skip it unless you have manually changed data.</body></html>", nullptr));
-#endif // QT_CONFIG(tooltip)
-        chk_battle_data->setText(QCoreApplication::translate("MainWindow", "Don't extract battle data", nullptr));
-#if QT_CONFIG(tooltip)
-        chk_wm_models->setToolTip(QCoreApplication::translate("MainWindow", "<html><head/><body><b>Don't extract world map 3D models.</b><br><br>If checked, world map 3D models and textures will not be extracted. Models from previous installations will not be deleted.<br><br>This installation step is not usually very long, so there is no reason to skip it unless you have manually edited the models. Skipping world map data installation will also skip the installation of world map 3D models.</body></html>", nullptr));
-#endif // QT_CONFIG(tooltip)
-        chk_wm_models->setText(QCoreApplication::translate("MainWindow", "Dont't extract world 3D models", nullptr));
-#if QT_CONFIG(tooltip)
-        chk_timidity->setToolTip(QCoreApplication::translate("MainWindow", "<html><head/><body><b>Don't use the timidity command.</b><br><br>If checked, background music will not be available to V-Gears.<br><br>Music indexes will still be built, but you will need to provide your own ogg tracks.</body></html>", nullptr));
-#endif // QT_CONFIG(tooltip)
-        chk_timidity->setText(QCoreApplication::translate("MainWindow", "Prevent TiMidity calls", nullptr));
-#if QT_CONFIG(tooltip)
-        chk_keep_original->setToolTip(QCoreApplication::translate("MainWindow", "<html><head/><body><b>Don't delete original data after installation.</b><br><br>The installer extracts some original data from the installation disk, such as MIDI sounds, TEX images, and some LGP archives. If checked, this data will not be deleted when the installation is complete.<br><br>This data is never used by V-Gears, and there is usually no need to check this.</body></html>", nullptr));
-#endif // QT_CONFIG(tooltip)
-        chk_keep_original->setText(QCoreApplication::translate("MainWindow", "Preserve original data", nullptr));
-#if QT_CONFIG(tooltip)
-        chk_fields->setToolTip(QCoreApplication::translate("MainWindow", "<html><head/><body><b>Don't extract field maps.</b><br><br>If checked, map fields will not be extracted. Field maps from previous installations will not be deleted.<br><br>This installation step is not usually very long, so there is no reason to skip it unless you have manually edited the fields. Skipping field map installation will also skip the installation of field 3D models.</body></html>", nullptr));
-#endif // QT_CONFIG(tooltip)
-        chk_fields->setText(QCoreApplication::translate("MainWindow", "Don't extract field maps", nullptr));
-#if QT_CONFIG(tooltip)
-        chk_kernel->setToolTip(QCoreApplication::translate("MainWindow", "<html><head/><body><b>Don't extract kernel data.</b><br><br>If checked, game data such as items, materia, character information or initial savemap will not be extracted. Data from previous installations will not be deleted.<br><br>This installation step is usually quite fast, so there is no reason to skip it unless you have manually changed data.</body></html>", nullptr));
-#endif // QT_CONFIG(tooltip)
-        chk_kernel->setText(QCoreApplication::translate("MainWindow", "Don't extract kernel data", nullptr));
-#if QT_CONFIG(tooltip)
-        chk_field_models->setToolTip(QCoreApplication::translate("MainWindow", "<html><head/><body><b>Don't extract field 3D models.</b><br><br>If checked, field 3D models and textures will not be extracted. Models from previous installations will not be deleted.<br><br>This installation step is not usually very long, so there is no reason to skip it unless you have manually edited the models. Skipping field map installation will also skip the installation of field 3D models.</body></html>", nullptr));
-#endif // QT_CONFIG(tooltip)
-        chk_field_models->setText(QCoreApplication::translate("MainWindow", "Dont't extract field 3D models", nullptr));
-#if QT_CONFIG(tooltip)
-        chk_videos->setToolTip(QCoreApplication::translate("MainWindow", "<html><head/><body><b>Don't extract videos.</b><br><br>If checked, FMV videos will not be extracted. Videos from previous installations will not be deleted.<br><br>Installing videos can take a few minutes, and it's OK to skip this step if they are already installed.</body></html>", nullptr));
-#endif // QT_CONFIG(tooltip)
-        chk_videos->setText(QCoreApplication::translate("MainWindow", "Don't extract videos", nullptr));
-#if QT_CONFIG(tooltip)
-        chk_battle_models->setToolTip(QCoreApplication::translate("MainWindow", "<html><head/><body><b>Don't extract battle models.</b><br><br>If checked, battle 3D models will not be extracted. This includes models for players, enemies, attacks, battle scenarios... Models from previous installations will not be deleted.<br><br>This installation step is usually quite fast, so there is no reason to skip it unless you have manually edited the models.</body></html>", nullptr));
-#endif // QT_CONFIG(tooltip)
-        chk_battle_models->setText(QCoreApplication::translate("MainWindow", "Don't extract battle models", nullptr));
-        btn_data_run->setText(QCoreApplication::translate("MainWindow", "Install data", nullptr));
-        label_percent->setText(QString());
-        label_progress->setText(QString());
-        label_5->setText(QCoreApplication::translate("MainWindow", "Installer log:", nullptr));
-        tabWidget->setTabText(tabWidget->indexOf(tab_installer), QCoreApplication::translate("MainWindow", "Data Installer", nullptr));
-        label_2->setText(QCoreApplication::translate("MainWindow", "VGears Path", nullptr));
-        btn_vgears_exe->setText(QCoreApplication::translate("MainWindow", "...", nullptr));
-        label->setText(QCoreApplication::translate("MainWindow", "Config Directory", nullptr));
-        btn_vgears_config->setText(QCoreApplication::translate("MainWindow", "...", nullptr));
-        btn_vgears_launch->setText(QCoreApplication::translate("MainWindow", "Run V-Gears", nullptr));
-        tabWidget->setTabText(tabWidget->indexOf(tab_vgears), QCoreApplication::translate("MainWindow", "V-Gears Config", nullptr));
-        menuFile->setTitle(QCoreApplication::translate("MainWindow", "File", nullptr));
-    } // retranslateUi
-
-};
-
-namespace Ui {
-    class MainWindow: public Ui_MainWindow {};
-} // namespace Ui
-
-QT_END_NAMESPACE
-
-#endif // UI_MAINWINDOW_H

+ 16 - 0
test/CMakeLists.txt

@@ -277,3 +277,19 @@ target_link_libraries(v-gears-tests
     ${ZLIB_LIBRARIES}
 )
 add_test(NAME v-gears-tests COMMAND v-gears-tests)
+
+
+# Tests of the installer logic that doesn't need the rest of the engine (layout, option rules, verifier, log).
+add_executable(v-gears-installer-tests
+    installer/InstallLogic.cpp
+    installer/ExternalTools.cpp
+    ${CMAKE_SOURCE_DIR}/src/installer/ExternalTools.cpp
+    ${CMAKE_SOURCE_DIR}/src/installer/InstallLayout.cpp
+    ${CMAKE_SOURCE_DIR}/src/installer/InstallLog.cpp
+    ${CMAKE_SOURCE_DIR}/src/installer/InstallOptions.cpp
+    ${CMAKE_SOURCE_DIR}/src/installer/InstallVerifier.cpp
+    ${CMAKE_SOURCE_DIR}/src/installer/VideoFiles.cpp
+)
+SET_PROPERTY(TARGET v-gears-installer-tests PROPERTY FOLDER "build/v-gears-test")
+target_link_libraries(v-gears-installer-tests ${_test_qt_libs} ${Boost_LIBRARIES})
+add_test(NAME v-gears-installer-tests COMMAND v-gears-installer-tests)

+ 468 - 0
test/installer/ExternalTools.cpp

@@ -0,0 +1,468 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#include <boost/test/unit_test.hpp>
+#include <boost/filesystem.hpp>
+#include <QtCore/QCoreApplication>
+#include <fstream>
+#include <sstream>
+#include "installer/ExternalTools.h"
+
+namespace fs = boost::filesystem;
+using namespace ExternalTools;
+
+namespace{
+
+    /**
+     * The programs are run with Qt, which needs an application object.
+     */
+    struct QtApplication{
+        int argc = 1;
+        char name[6] = "tests";
+        char* argv[2] = {name, nullptr};
+        QCoreApplication application;
+        QtApplication(): application(argc, argv){}
+    };
+
+    /**
+     * A temporary directory that is removed on destruction.
+     */
+    struct Dir{
+        fs::path path;
+        explicit Dir(const std::string& name = ""): path(fs::temp_directory_path() / fs::unique_path("vgears-tools-%%%%-%%%%")){
+            if (!name.empty()) path /= name;
+            fs::create_directories(path);
+        }
+        ~Dir(){
+            boost::system::error_code ec;
+            fs::remove_all(path.parent_path().filename().string().compare(0, 13, "vgears-tools-") == 0 ? path.parent_path() : path, ec);
+        }
+        std::string Str() const{return path.string();}
+
+        /**
+         * Creates a file. With a mode of executable it can be run.
+         */
+        std::string File(const std::string& name, const std::string& content = "x", bool executable = true) const{
+            const fs::path file = path / name;
+            fs::create_directories(file.parent_path());
+            std::ofstream(file.string()) << content;
+            fs::permissions(file, executable ? fs::owner_all | fs::group_read | fs::others_read : fs::owner_read | fs::owner_write);
+            return file.string();
+        }
+    };
+
+    Environment Unix(const std::string& path){
+        Environment env;
+        env.path = path;
+        env.windows = false;
+        return env;
+    }
+
+    Environment Windows(const std::string& path, const std::string& pathext = ".COM;.EXE;.BAT;.CMD"){
+        Environment env;
+        env.path = path;
+        env.pathext = pathext;
+        env.windows = true;
+        return env;
+    }
+
+#ifndef _WIN32
+    /**
+     * A shell script that behaves like FFmpeg.
+     *
+     * @param[in] vorbis True if it says it has the Vorbis encoder.
+     */
+    std::string FakeFfmpeg(const Dir& dir, bool vorbis = true, const std::string& name = "ffmpeg"){
+        std::ostringstream script;
+        script << "#!/bin/sh\n"
+          << "if [ \"$1\" = \"-version\" ]; then echo \"ffmpeg version 6.0 Copyright (c) the FFmpeg developers\"; exit 0; fi\n"
+          << "if [ \"$2\" = \"-encoders\" ]; then echo \" A....D " << (vorbis ? "libvorbis" : "libmp3lame")
+          << "  encoder\"; exit 0; fi\n"
+          // A conversion: -hide_banner -loglevel panic -y -i INPUT OUTPUT.
+          << "input=\"$6\"\nfor last; do :; done\n"
+          << "if [ \"$input\" = \"-\" ]; then cat > \"$last\"; else cat \"$input\" > \"$last\"; fi\n";
+        return dir.File(name, script.str());
+    }
+
+    /**
+     * A shell script that behaves like TiMidity: it "renders" the file by printing it with a header.
+     */
+    std::string FakeTimidity(const Dir& dir, bool works = true){
+        std::ostringstream script;
+        script << "#!/bin/sh\n"
+          << "if [ \"$1\" = \"-v\" ]; then echo \"TiMidity++ version 2.15.0\"; exit 0; fi\n"
+          << (works ? "echo RENDERED; cat \"$2\"; exit 0\n" : "exit 3\n");
+        return dir.File("timidity", script.str());
+    }
+
+    std::string Read(const std::string& path){
+        std::ifstream file(path);
+        return std::string((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
+    }
+#endif
+
+}
+
+BOOST_GLOBAL_FIXTURE(QtApplication);
+
+// ---------------------------------------------------------------------------------------------------------------
+// Searching.
+// ---------------------------------------------------------------------------------------------------------------
+
+BOOST_AUTO_TEST_SUITE(ExternalToolsSearchTests)
+
+BOOST_AUTO_TEST_CASE(SearchesThePathInOrder){
+    Dir first("a");
+    Dir second("b");
+    const std::string in_first = first.File("ffmpeg");
+    const std::string in_second = second.File("ffmpeg");
+    const auto found = Candidates("ffmpeg", "", {}, Unix(first.Str() + ":" + second.Str()));
+    BOOST_REQUIRE_EQUAL(found.size(), 2u);
+    BOOST_CHECK_EQUAL(found[0], in_first);
+    BOOST_CHECK_EQUAL(found[1], in_second);
+}
+
+BOOST_AUTO_TEST_CASE(DirectoriesComeBeforeThePathAndTheSavedPathBeforeBoth){
+    Dir saved;
+    Dir tools;
+    Dir path_dir;
+    const std::string chosen = saved.File("my-ffmpeg");
+    const std::string in_tools = tools.File("ffmpeg");
+    const std::string in_path = path_dir.File("ffmpeg");
+    const auto found = Candidates("ffmpeg", chosen, {tools.Str()}, Unix(path_dir.Str()));
+    BOOST_REQUIRE_EQUAL(found.size(), 3u);
+    BOOST_CHECK_EQUAL(found[0], chosen);
+    BOOST_CHECK_EQUAL(found[1], in_tools);
+    BOOST_CHECK_EQUAL(found[2], in_path);
+}
+
+BOOST_AUTO_TEST_CASE(SkipsWhatCantBeRun){
+    Dir dir;
+    dir.File("not-executable", "x", false);
+    fs::create_directories(dir.path / "ffmpeg");
+    // A directory with the name, and a file without the permission to run it.
+    BOOST_CHECK(Candidates("ffmpeg", "", {}, Unix(dir.Str())).empty());
+    BOOST_CHECK(Candidates("not-executable", "", {}, Unix(dir.Str())).empty());
+    // A saved path that doesn't exist is ignored, not returned.
+    BOOST_CHECK(Candidates("ffmpeg", dir.Str() + "/missing", {}, Unix("")).empty());
+}
+
+BOOST_AUTO_TEST_CASE(IgnoresEmptyAndRelativePathEntries){
+    Dir dir;
+    dir.File("ffmpeg");
+    const std::string relative = fs::relative(dir.path, fs::current_path()).string();
+    // An empty entry means the current directory, and relative ones depend on where the installer is run from.
+    BOOST_CHECK(Candidates("ffmpeg", "", {}, Unix(":" + relative + "::")).empty());
+    BOOST_CHECK_EQUAL(Candidates("ffmpeg", "", {}, Unix(":" + dir.Str() + ":")).size(), 1u);
+}
+
+BOOST_AUTO_TEST_CASE(NoDuplicates){
+    Dir dir;
+    dir.File("ffmpeg");
+    BOOST_CHECK_EQUAL(Candidates("ffmpeg", dir.Str() + "/ffmpeg", {dir.Str()}, Unix(dir.Str())).size(), 1u);
+}
+
+BOOST_AUTO_TEST_CASE(PathsWithSpaces){
+    Dir dir("Program Files (x86)/with space");
+    const std::string file = dir.File("ffmpeg");
+    const auto found = Candidates("ffmpeg", "", {}, Unix(dir.Str()));
+    BOOST_REQUIRE_EQUAL(found.size(), 1u);
+    BOOST_CHECK_EQUAL(found[0], file);
+}
+
+BOOST_AUTO_TEST_CASE(WindowsUsesPathExt){
+    Dir bin;
+    const std::string exe = bin.File("ffmpeg.exe", "x", false);
+    bin.File("ffmpeg.txt", "x", false);
+    // Windows doesn't have the executable permission: the extension decides.
+    const auto found = Candidates("ffmpeg", "", {}, Windows(bin.Str()));
+    BOOST_REQUIRE_EQUAL(found.size(), 1u);
+    BOOST_CHECK_EQUAL(found[0], exe);
+}
+
+BOOST_AUTO_TEST_CASE(WindowsExtensionsAreTriedInOrderAndCaseDoesNotMatter){
+    Dir bin;
+    const std::string cmd = bin.File("timidity.cmd", "x", false);
+    const std::string upper = bin.File("TIMIDITY.EXE", "x", false);
+    const auto found = Candidates("timidity", "", {}, Windows(bin.Str(), ".exe;.cmd"));
+    BOOST_REQUIRE_EQUAL(found.size(), 2u);
+    BOOST_CHECK_EQUAL(found[0], upper);
+    BOOST_CHECK_EQUAL(found[1], cmd);
+}
+
+BOOST_AUTO_TEST_CASE(WindowsUsesSemicolons){
+    Dir a("a");
+    Dir b("b");
+    a.File("ffmpeg.exe", "x", false);
+    b.File("ffmpeg.exe", "x", false);
+    // A drive letter has a colon in it: entries are split with ';' only.
+    BOOST_CHECK_EQUAL(Candidates("ffmpeg", "", {}, Windows(a.Str() + ";" + b.Str())).size(), 2u);
+    BOOST_CHECK_EQUAL(Candidates("ffmpeg", "", {}, Windows("")).size(), 0u);
+}
+
+BOOST_AUTO_TEST_CASE(EnvironmentOfTheSystem){
+    const Environment env = Environment::FromSystem();
+#ifdef _WIN32
+    BOOST_CHECK(env.windows);
+    BOOST_CHECK(!env.pathext.empty());
+#else
+    BOOST_CHECK(!env.windows);
+#endif
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+
+// ---------------------------------------------------------------------------------------------------------------
+// Arguments.
+// ---------------------------------------------------------------------------------------------------------------
+
+BOOST_AUTO_TEST_SUITE(ExternalToolsArgumentTests)
+
+BOOST_AUTO_TEST_CASE(FfmpegArgumentsDontNeedQuoting){
+    const std::vector<std::string> expected = {
+      "-hide_banner", "-loglevel", "panic", "-y", "-i", "/path with spaces/in put.wav", "/out dir/o'ut.ogg"
+    };
+    BOOST_CHECK(FfmpegArguments("/path with spaces/in put.wav", "/out dir/o'ut.ogg") == expected);
+}
+
+BOOST_AUTO_TEST_CASE(FfmpegReadsStdin){
+    const std::vector<std::string> expected = {"-hide_banner", "-loglevel", "panic", "-y", "-i", "-", "out.ogg"};
+    BOOST_CHECK(FfmpegStdinArguments("out.ogg") == expected);
+}
+
+BOOST_AUTO_TEST_CASE(ShortVersions){
+    Tool ffmpeg;
+    ffmpeg.version = "ffmpeg version 9.0.2-1 Copyright (c) 2000-2026 the FFmpeg developers";
+    BOOST_CHECK_EQUAL(ShortVersion(ffmpeg), "9.0.2-1");
+    Tool timidity;
+    timidity.version = "TiMidity++ version 2.14.0";
+    BOOST_CHECK_EQUAL(ShortVersion(timidity), "2.14.0");
+    // Builds from git have a long version, and some tools say it with another case.
+    Tool git;
+    git.version = "FFmpeg Version N-12345-gabcdef Copyright";
+    BOOST_CHECK_EQUAL(ShortVersion(git), "N-12345-gabcdef");
+    // Without the word it is the line as it is, and nothing is nothing.
+    Tool odd;
+    odd.version = "something 1.0";
+    BOOST_CHECK_EQUAL(ShortVersion(odd), "something 1.0");
+    BOOST_CHECK_EQUAL(ShortVersion(Tool()), "");
+}
+
+BOOST_AUTO_TEST_CASE(TimidityWritesToStdout){
+    const std::vector<std::string> expected = {"--quiet=3", "my song.mid", "-Ow", "-o", "-"};
+    BOOST_CHECK(TimidityArguments("my song.mid") == expected);
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+
+#ifndef _WIN32
+
+// ---------------------------------------------------------------------------------------------------------------
+// Running programs (with scripts that behave like them).
+// ---------------------------------------------------------------------------------------------------------------
+
+BOOST_AUTO_TEST_SUITE(ExternalToolsRunTests)
+
+BOOST_AUTO_TEST_CASE(RunsAndCapturesOutput){
+    Dir dir;
+    const std::string script = dir.File("hello", "#!/bin/sh\necho \"hello $1\"\nexit 4\n");
+    const RunResult result = Run(script, {"world"}, 10000);
+    BOOST_CHECK(result.started && result.finished);
+    BOOST_CHECK_EQUAL(result.exit_code, 4);
+    BOOST_CHECK_EQUAL(result.output, "hello world\n");
+}
+
+BOOST_AUTO_TEST_CASE(ArgumentsArriveUnchanged){
+    Dir dir;
+    const std::string script = dir.File("args", "#!/bin/sh\nfor a in \"$@\"; do echo \"[$a]\"; done\n");
+    const RunResult result = Run(script, {"a b", "c'd", "$HOME", "e;f"}, 10000);
+    // No shell is involved: spaces, quotes and special characters are not interpreted.
+    BOOST_CHECK_EQUAL(result.output, "[a b]\n[c'd]\n[$HOME]\n[e;f]\n");
+}
+
+BOOST_AUTO_TEST_CASE(ProgramThatDoesNotExist){
+    const RunResult result = Run("/nonexistent/program", {}, 10000);
+    BOOST_CHECK(!result.started);
+}
+
+BOOST_AUTO_TEST_CASE(KillsWhatTakesTooLong){
+    Dir dir;
+    const std::string script = dir.File("slow", "#!/bin/sh\nsleep 30\n");
+    const RunResult result = Run(script, {}, 200);
+    BOOST_CHECK(result.started);
+    BOOST_CHECK(!result.finished);
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+
+// ---------------------------------------------------------------------------------------------------------------
+// Probing and finding.
+// ---------------------------------------------------------------------------------------------------------------
+
+BOOST_AUTO_TEST_SUITE(ExternalToolsProbeTests)
+
+BOOST_AUTO_TEST_CASE(AGoodFfmpegIsUsable){
+    Dir dir;
+    const Tool tool = ProbeFfmpeg(FakeFfmpeg(dir));
+    BOOST_CHECK(tool.usable);
+    BOOST_CHECK(tool.problem.empty());
+    BOOST_CHECK_EQUAL(tool.version, "ffmpeg version 6.0 Copyright (c) the FFmpeg developers");
+}
+
+BOOST_AUTO_TEST_CASE(FfmpegWithoutVorbisIsNotUsable){
+    Dir dir;
+    const Tool tool = ProbeFfmpeg(FakeFfmpeg(dir, false));
+    BOOST_CHECK(!tool.usable);
+    BOOST_CHECK(tool.problem.find("libvorbis") != std::string::npos);
+}
+
+BOOST_AUTO_TEST_CASE(ProgramsThatAreNotFfmpeg){
+    Dir dir;
+    BOOST_CHECK(!ProbeFfmpeg(dir.File("fake", "#!/bin/sh\nexit 1\n")).usable);
+    BOOST_CHECK(!ProbeFfmpeg(dir.File("other", "#!/bin/sh\necho hello\n")).usable);
+    BOOST_CHECK(!ProbeFfmpeg(dir.File("garbage", "not a program")).usable);
+    BOOST_CHECK(!ProbeFfmpeg(dir.Str() + "/missing").usable);
+}
+
+BOOST_AUTO_TEST_CASE(TimidityProbe){
+    Dir dir;
+    const Tool tool = ProbeTimidity(FakeTimidity(dir));
+    BOOST_CHECK(tool.usable);
+    BOOST_CHECK_EQUAL(tool.version, "TiMidity++ version 2.15.0");
+    BOOST_CHECK(!ProbeTimidity(dir.File("fake", "#!/bin/sh\nexit 1\n")).usable);
+    BOOST_CHECK(!ProbeTimidity(FakeFfmpeg(dir)).usable);
+}
+
+BOOST_AUTO_TEST_CASE(FindingFollowsThePriorityAndSkipsBrokenOnes){
+    Dir broken_dir("broken");
+    Dir good_dir("good");
+    const std::string broken = FakeFfmpeg(broken_dir, false);
+    const std::string good = FakeFfmpeg(good_dir);
+    Locations locations;
+    locations.ffmpeg = broken;
+    // The user's choice doesn't work (no Vorbis), so the next one is used.
+    Tool tool = FindFfmpeg(locations, Unix(good_dir.Str()));
+    BOOST_CHECK(tool.usable);
+    BOOST_CHECK_EQUAL(tool.path, good);
+    // A choice that works wins over the PATH.
+    locations.ffmpeg = good;
+    BOOST_CHECK_EQUAL(FindFfmpeg(locations, Unix(broken_dir.Str())).path, good);
+}
+
+BOOST_AUTO_TEST_CASE(FindingInTheToolsDirectories){
+    Dir tools;
+    const std::string ffmpeg = FakeFfmpeg(tools);
+    const std::string timidity = FakeTimidity(tools);
+    Locations locations;
+    locations.directories = {tools.Str()};
+    BOOST_CHECK_EQUAL(FindFfmpeg(locations, Unix("")).path, ffmpeg);
+    BOOST_CHECK_EQUAL(FindTimidity(locations, Unix("")).path, timidity);
+}
+
+BOOST_AUTO_TEST_CASE(NothingFound){
+    Dir empty;
+    Locations locations;
+    const Tool tool = FindFfmpeg(locations, Unix(empty.Str()));
+    BOOST_CHECK(!tool.usable);
+    BOOST_CHECK(tool.path.empty());
+    BOOST_CHECK(tool.problem.find("not found") != std::string::npos);
+}
+
+BOOST_AUTO_TEST_CASE(ABrokenProgramIsReportedInsteadOfNotFound){
+    Dir dir;
+    FakeFfmpeg(dir, false);
+    const Tool tool = FindFfmpeg(Locations(), Unix(dir.Str()));
+    BOOST_CHECK(!tool.usable);
+    BOOST_CHECK(!tool.path.empty());
+    BOOST_CHECK(tool.problem.find("libvorbis") != std::string::npos);
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+
+// ---------------------------------------------------------------------------------------------------------------
+// Converting.
+// ---------------------------------------------------------------------------------------------------------------
+
+BOOST_AUTO_TEST_SUITE(ExternalToolsConvertTests)
+
+BOOST_AUTO_TEST_CASE(ConvertsToOgg){
+    Dir tools;
+    Dir files("Final Fantasy VII (PC, 1998)");
+    const std::string ffmpeg = FakeFfmpeg(tools);
+    const std::string input = files.File("1 sound.wav", "SOUNDDATA");
+    const std::string output = (files.path / "1 sound.ogg").string();
+    std::string error;
+    BOOST_CHECK_MESSAGE(ConvertToOgg(ffmpeg, input, output, &error), error);
+    BOOST_CHECK_EQUAL(Read(output), "SOUNDDATA");
+}
+
+BOOST_AUTO_TEST_CASE(FailedConversionLeavesNothingBehind){
+    Dir tools;
+    Dir files;
+    const std::string failing = tools.File("ffmpeg", "#!/bin/sh\nfor last; do :; done\necho partial > \"$last\"\nexit 1\n");
+    const std::string input = files.File("a.wav", "x");
+    const std::string output = (files.path / "a.ogg").string();
+    std::string error;
+    BOOST_CHECK(!ConvertToOgg(failing, input, output, &error));
+    BOOST_CHECK(!fs::exists(output));
+    BOOST_CHECK(error.find("exit code 1") != std::string::npos);
+}
+
+BOOST_AUTO_TEST_CASE(EmptyOutputIsAFailure){
+    Dir tools;
+    Dir files;
+    const std::string silent = tools.File("ffmpeg", "#!/bin/sh\nfor last; do :; done\n: > \"$last\"\nexit 0\n");
+    const std::string output = (files.path / "a.ogg").string();
+    BOOST_CHECK(!ConvertToOgg(silent, files.File("a.wav"), output));
+    BOOST_CHECK(!fs::exists(output));
+}
+
+BOOST_AUTO_TEST_CASE(ConvertsMidiThroughAPipe){
+    Dir tools;
+    Dir files("my music");
+    const std::string timidity = FakeTimidity(tools);
+    const std::string ffmpeg = FakeFfmpeg(tools);
+    const std::string midi = files.File("5.mid", "MIDIDATA");
+    const std::string output = (files.path / "5.ogg").string();
+    std::string error;
+    BOOST_CHECK_MESSAGE(ConvertMidiToOgg(timidity, ffmpeg, midi, output, &error), error);
+    // What TiMidity wrote reached FFmpeg, which wrote the file.
+    BOOST_CHECK_EQUAL(Read(output), "RENDERED\nMIDIDATA");
+}
+
+BOOST_AUTO_TEST_CASE(MidiConversionFailsIfTimidityFails){
+    Dir tools;
+    Dir files;
+    const std::string timidity = FakeTimidity(tools, false);
+    const std::string ffmpeg = FakeFfmpeg(tools);
+    const std::string output = (files.path / "5.ogg").string();
+    std::string error;
+    BOOST_CHECK(!ConvertMidiToOgg(timidity, ffmpeg, files.File("5.mid"), output, &error));
+    BOOST_CHECK(!fs::exists(output));
+    BOOST_CHECK(error.find("timidity failed") != std::string::npos);
+}
+
+BOOST_AUTO_TEST_CASE(MidiConversionFailsIfProgramsAreMissing){
+    Dir files;
+    const std::string output = (files.path / "5.ogg").string();
+    std::string error;
+    BOOST_CHECK(!ConvertMidiToOgg("/nonexistent/timidity", "/nonexistent/ffmpeg", files.File("5.mid"), output, &error));
+    BOOST_CHECK(!fs::exists(output));
+    BOOST_CHECK(!error.empty());
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+
+#endif

+ 581 - 0
test/installer/InstallLogic.cpp

@@ -0,0 +1,581 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#define BOOST_TEST_MODULE VGearsInstallerLogic
+#include <boost/test/unit_test.hpp>
+#include <boost/filesystem.hpp>
+#include <algorithm>
+#include <fstream>
+#include "installer/InstallLayout.h"
+#include "installer/InstallLog.h"
+#include "installer/InstallOptions.h"
+#include "installer/InstallVerifier.h"
+#include "installer/TaskPool.h"
+#include "installer/VideoFiles.h"
+#include <atomic>
+#include <chrono>
+#include <set>
+
+namespace fs = boost::filesystem;
+
+namespace{
+
+    bool Contains(const std::vector<std::string>& list, const std::string& value){
+        return std::find(list.begin(), list.end(), value) != list.end();
+    }
+
+    bool StartsWith(const std::string& text, const std::string& prefix){return text.compare(0, prefix.size(), prefix) == 0;}
+
+    /**
+     * A temporary directory that is removed on destruction.
+     */
+    struct TempDir{
+        fs::path path;
+        TempDir(): path(fs::temp_directory_path() / fs::unique_path("vgears-test-%%%%-%%%%")){
+            fs::create_directories(path);
+        }
+        ~TempDir(){
+            boost::system::error_code ec;
+            fs::remove_all(path, ec);
+        }
+        std::string Root() const{return path.string() + "/";}
+        void Touch(const std::string& relative) const{
+            fs::create_directories((path / relative).parent_path());
+            std::ofstream(( path / relative).string()) << "x";
+        }
+    };
+
+    bool HasProblem(const VerifyResult& result, const std::string& group){
+        for (const VerifyProblem& problem : result.problems) if (problem.group == group) return true;
+        return false;
+    }
+
+}
+
+// ---------------------------------------------------------------------------------------------------------------
+// Layout: where things go.
+// ---------------------------------------------------------------------------------------------------------------
+
+BOOST_AUTO_TEST_SUITE(InstallLayoutTests)
+
+BOOST_AUTO_TEST_CASE(DataAndTempAreInsideTheInstallationDirectory){
+    BOOST_CHECK_EQUAL(InstallLayout::DataDir("/home/u/.v-gears/"), "/home/u/.v-gears/data/");
+    BOOST_CHECK_EQUAL(InstallLayout::TempDir("/home/u/.v-gears/"), "/home/u/.v-gears/temp/");
+}
+
+BOOST_AUTO_TEST_CASE(DataSubdirsAreRelativeAndUnique){
+    const auto& dirs = InstallLayout::DataSubdirs();
+    for (const std::string& dir : dirs){
+        BOOST_CHECK_MESSAGE(!dir.empty() && dir[0] != '/', "not relative: " << dir);
+        BOOST_CHECK_MESSAGE(!StartsWith(dir, "data/") && !StartsWith(dir, "temp"), "wrong root: " << dir);
+        BOOST_CHECK_MESSAGE(std::count(dirs.begin(), dirs.end(), dir) == 1, "duplicated: " << dir);
+    }
+}
+
+BOOST_AUTO_TEST_CASE(EveryDirectoryTheGameReadsIsCreated){
+    // The game reads "./data/<x>" and "./temp/<x>"; the installer must create them (or be able to write in them).
+    for (const std::string& resource : InstallLayout::GameResourceDirs()){
+        if (StartsWith(resource, "data/")){
+            const std::string dir = resource.substr(5);
+            bool created = false;
+            for (const std::string& sub : InstallLayout::DataSubdirs())
+                created = created || sub == dir || StartsWith(sub, dir + "/");
+            BOOST_CHECK_MESSAGE(created, "the installer doesn't create " << resource);
+        }
+        else{
+            BOOST_REQUIRE_MESSAGE(StartsWith(resource, "temp/"), "unexpected root: " << resource);
+            BOOST_CHECK_MESSAGE(Contains(InstallLayout::TempSubdirs(), resource.substr(5)), "not created: " << resource);
+        }
+    }
+}
+
+BOOST_AUTO_TEST_CASE(CleaningKeepsWhatTheGameNeeds){
+    const auto removable = InstallLayout::RemovableTempSubdirs();
+    BOOST_CHECK(!Contains(removable, "world_models"));
+    for (const std::string& kept : InstallLayout::KeptTempSubdirs()){
+        BOOST_CHECK(Contains(InstallLayout::TempSubdirs(), kept));
+        BOOST_CHECK(!Contains(removable, kept));
+    }
+    BOOST_CHECK_EQUAL(removable.size() + InstallLayout::KeptTempSubdirs().size(), InstallLayout::TempSubdirs().size());
+    // The game reads temp/world_models, so it must be a kept directory.
+    BOOST_CHECK(Contains(InstallLayout::KeptTempSubdirs(), "world_models"));
+}
+
+BOOST_AUTO_TEST_CASE(OldSettingsAreMigrated){
+    // The data directory used to be <installation directory>/data.
+    BOOST_CHECK_EQUAL(InstallLayout::MigrateInstallDir("/home/u/.v-gears/data", "", "/def"), "/home/u/.v-gears");
+    BOOST_CHECK_EQUAL(InstallLayout::MigrateInstallDir("/home/u/.v-gears/data/", "/other", "/def"), "/home/u/.v-gears");
+    // A data directory that isn't called "data" is kept as is.
+    BOOST_CHECK_EQUAL(InstallLayout::MigrateInstallDir("/games/ff7", "", "/def"), "/games/ff7");
+    // Without a data directory the configuration directory is used, and then the default.
+    BOOST_CHECK_EQUAL(InstallLayout::MigrateInstallDir("", "/home/u/.v-gears", "/def"), "/home/u/.v-gears");
+    BOOST_CHECK_EQUAL(InstallLayout::MigrateInstallDir("", "", "/def"), "/def");
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+
+// ---------------------------------------------------------------------------------------------------------------
+// Options: dependencies, presets, estimates.
+// ---------------------------------------------------------------------------------------------------------------
+
+BOOST_AUTO_TEST_SUITE(InstallOptionsTests)
+
+BOOST_AUTO_TEST_CASE(ModelsNeedTheirMaps){
+    InstallOptions o;
+    o.field_models = true;
+    o.wm_models = true;
+    InstallRules::ApplyDependencies(o);
+    BOOST_CHECK(!o.field_models);
+    BOOST_CHECK(!o.wm_models);
+
+    o.fields = o.wm = o.field_models = o.wm_models = true;
+    InstallRules::ApplyDependencies(o);
+    BOOST_CHECK(o.field_models);
+    BOOST_CHECK(o.wm_models);
+}
+
+BOOST_AUTO_TEST_CASE(OneMapDoesNotEnableTheOtherModels){
+    InstallOptions o;
+    o.fields = true;
+    o.wm_models = true;
+    InstallRules::ApplyDependencies(o);
+    BOOST_CHECK(!o.wm_models);
+}
+
+BOOST_AUTO_TEST_CASE(SelectableMatchesDependencies){
+    // An option is selectable exactly when applying the rules to it keeps it.
+    for (int bits = 0; bits < (1 << 2); ++ bits){
+        InstallOptions o;
+        o.fields = bits & 1;
+        o.wm = bits & 2;
+        o.field_models = o.wm_models = true;
+        const InstallOptions selectable = InstallRules::Selectable(o);
+        InstallRules::ApplyDependencies(o);
+        BOOST_CHECK_EQUAL(o.field_models, selectable.field_models);
+        BOOST_CHECK_EQUAL(o.wm_models, selectable.wm_models);
+    }
+}
+
+BOOST_AUTO_TEST_CASE(PresetsRespectTheRules){
+    for (InstallPreset preset : {
+      InstallPreset::EVERYTHING, InstallPreset::FIELDS, InstallPreset::WORLD_MAP, InstallPreset::BATTLE,
+      InstallPreset::MEDIA, InstallPreset::CUSTOM
+    }){
+        for (bool videos : {false, true}){
+            InstallOptions o = InstallRules::Preset(preset, videos);
+            InstallOptions checked = o;
+            InstallRules::ApplyDependencies(checked);
+            BOOST_CHECK(o.field_models == checked.field_models && o.wm_models == checked.wm_models);
+            // Videos are only chosen when the disk has them.
+            if (!videos) BOOST_CHECK(!o.videos);
+        }
+    }
+}
+
+BOOST_AUTO_TEST_CASE(PresetContents){
+    const InstallOptions all = InstallRules::Preset(InstallPreset::EVERYTHING, true);
+    BOOST_CHECK(all.battle_data && all.battle_models && all.kernel && all.images && all.sounds && all.music);
+    BOOST_CHECK(all.fields && all.field_models && all.wm && all.wm_models && all.videos);
+
+    const InstallOptions world = InstallRules::Preset(InstallPreset::WORLD_MAP, true);
+    BOOST_CHECK(world.wm && world.wm_models);
+    BOOST_CHECK(!world.fields && !world.field_models && !world.music && !world.videos);
+
+    const InstallOptions fields = InstallRules::Preset(InstallPreset::FIELDS, true);
+    BOOST_CHECK(fields.fields && fields.field_models && !fields.wm);
+
+    const InstallOptions none = InstallRules::Preset(InstallPreset::CUSTOM, true);
+    BOOST_CHECK(!none.fields && !none.wm && !none.music && !none.sounds && !none.videos);
+}
+
+BOOST_AUTO_TEST_CASE(ToolsNeededByTheAudio){
+    InstallOptions none;
+    BOOST_CHECK(!InstallRules::ToolsNeeded(none).ffmpeg && !InstallRules::ToolsNeeded(none).timidity);
+
+    InstallOptions sounds;
+    sounds.sounds = true;
+    BOOST_CHECK(InstallRules::ToolsNeeded(sounds).ffmpeg);
+    BOOST_CHECK(!InstallRules::ToolsNeeded(sounds).timidity);
+
+    // The music needs both: TiMidity renders the MIDI and FFmpeg encodes it.
+    InstallOptions music;
+    music.music = true;
+    BOOST_CHECK(InstallRules::ToolsNeeded(music).ffmpeg && InstallRules::ToolsNeeded(music).timidity);
+
+    // Nothing else needs them.
+    InstallOptions other;
+    other.fields = other.field_models = other.wm = other.battle_data = other.images = other.kernel = true;
+    BOOST_CHECK(!InstallRules::ToolsNeeded(other).ffmpeg && !InstallRules::ToolsNeeded(other).timidity);
+}
+
+BOOST_AUTO_TEST_CASE(WhatIsLostWithoutTheTools){
+    InstallOptions all;
+    all.sounds = all.music = true;
+    // Nothing is lost when the programs work, or when nothing needs them.
+    BOOST_CHECK(InstallRules::ToolConsequences(all, true, true).empty());
+    BOOST_CHECK(InstallRules::ToolConsequences(InstallOptions(), false, false).empty());
+
+    // Without FFmpeg, both the sounds and the music are lost.
+    const auto no_ffmpeg = InstallRules::ToolConsequences(all, false, true);
+    BOOST_REQUIRE_EQUAL(no_ffmpeg.size(), 2u);
+    BOOST_CHECK(no_ffmpeg[0].find("sound") != std::string::npos);
+    BOOST_CHECK(no_ffmpeg[1].find("music") != std::string::npos);
+
+    // Without TiMidity only the music is lost.
+    const auto no_timidity = InstallRules::ToolConsequences(all, true, false);
+    BOOST_REQUIRE_EQUAL(no_timidity.size(), 1u);
+    BOOST_CHECK(no_timidity[0].find("music") != std::string::npos);
+    BOOST_CHECK(no_timidity[0].find("TiMidity") != std::string::npos);
+
+    // Without both it is not said twice.
+    BOOST_CHECK_EQUAL(InstallRules::ToolConsequences(all, false, false).size(), 2u);
+
+    // Sound effects don't need TiMidity.
+    InstallOptions sounds;
+    sounds.sounds = true;
+    BOOST_CHECK(InstallRules::ToolConsequences(sounds, true, false).empty());
+    BOOST_CHECK_EQUAL(InstallRules::ToolConsequences(sounds, false, true).size(), 1u);
+
+    // Music alone, without FFmpeg.
+    InstallOptions music;
+    music.music = true;
+    const auto music_only = InstallRules::ToolConsequences(music, false, true);
+    BOOST_REQUIRE_EQUAL(music_only.size(), 1u);
+    BOOST_CHECK(music_only[0].find("music") != std::string::npos);
+}
+
+BOOST_AUTO_TEST_CASE(WorldMapNeedsFields){
+    InstallOptions o;
+    o.wm = true;
+    BOOST_CHECK_EQUAL(InstallRules::Problems(o, false).size(), 1u);
+    // Fine if the fields are installed now or were installed before.
+    BOOST_CHECK(InstallRules::Problems(o, true).empty());
+    o.fields = true;
+    BOOST_CHECK(InstallRules::Problems(o, false).empty());
+    // No world map, no problem.
+    BOOST_CHECK(InstallRules::Problems(InstallOptions(), false).empty());
+}
+
+BOOST_AUTO_TEST_CASE(Estimates){
+    uint64_t temporary = 99;
+    BOOST_CHECK_EQUAL(InstallRules::EstimateMegabytes(InstallOptions(), &temporary), 0u);
+    BOOST_CHECK_EQUAL(temporary, 0u);
+
+    // Selecting more never makes it smaller.
+    const InstallOptions all = InstallRules::Preset(InstallPreset::EVERYTHING, false);
+    uint64_t all_temporary = 0;
+    const uint64_t all_size = InstallRules::EstimateMegabytes(all, &all_temporary);
+    for (InstallPreset preset : {InstallPreset::FIELDS, InstallPreset::WORLD_MAP, InstallPreset::BATTLE, InstallPreset::MEDIA}){
+        uint64_t t = 0;
+        BOOST_CHECK_LE(InstallRules::EstimateMegabytes(InstallRules::Preset(preset, false), &t), all_size);
+        BOOST_CHECK_LE(t, all_temporary);
+    }
+    // The pointer is optional.
+    BOOST_CHECK_EQUAL(InstallRules::EstimateMegabytes(all), all_size);
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+
+// ---------------------------------------------------------------------------------------------------------------
+// Verifier.
+// ---------------------------------------------------------------------------------------------------------------
+
+BOOST_AUTO_TEST_SUITE(InstallVerifierTests)
+
+BOOST_AUTO_TEST_CASE(EmptyDirectoryMissesEverything){
+    TempDir dir;
+    const VerifyResult result = VerifyInstallation(dir.Root());
+    BOOST_CHECK(!result.Ok());
+    for (const char* group : {
+      "Configuration", "Game files", "Kernel data", "Battle data", "Battle models", "Field maps", "Field models",
+      "World map", "World map models", "Sound effects", "Music", "Images"
+    }) BOOST_CHECK_MESSAGE(HasProblem(result, group), "no problem reported for " << group);
+    BOOST_CHECK_GT(result.checked, 0);
+}
+
+BOOST_AUTO_TEST_CASE(ConfigurationAndPlugins){
+    TempDir dir;
+    dir.Touch("plugins.cfg");
+    dir.Touch("resources.cfg");
+    BOOST_CHECK(HasProblem(VerifyInstallation(dir.Root()), "Configuration"));
+    // The plugins have a version suffix.
+    dir.Touch("RenderSystem_GL.so.1.12.10");
+    dir.Touch("Codec_STBI.so.1.12.10");
+    BOOST_CHECK(!HasProblem(VerifyInstallation(dir.Root()), "Configuration"));
+}
+
+BOOST_AUTO_TEST_CASE(RootWithoutTrailingSlash){
+    TempDir dir;
+    dir.Touch("plugins.cfg");
+    const VerifyResult with = VerifyInstallation(dir.Root());
+    const VerifyResult without = VerifyInstallation(dir.path.string());
+    BOOST_CHECK_EQUAL(with.problems.size(), without.problems.size());
+}
+
+BOOST_AUTO_TEST_CASE(FilesOfTheDataFolderMustBeCopied){
+    TempDir installation;
+    TempDir reference;
+    reference.Touch("plugins.cfg");
+    reference.Touch("data/scripts/a.lua");
+    BOOST_CHECK(HasProblem(VerifyInstallation(installation.Root(), reference.Root()), "Copied data files"));
+    installation.Touch("plugins.cfg");
+    BOOST_CHECK(HasProblem(VerifyInstallation(installation.Root(), reference.Root()), "Copied data files"));
+    installation.Touch("data/scripts/a.lua");
+    BOOST_CHECK(!HasProblem(VerifyInstallation(installation.Root(), reference.Root()), "Copied data files"));
+    // Without a reference there is nothing to compare.
+    BOOST_CHECK(!HasProblem(VerifyInstallation(installation.Root()), "Copied data files"));
+}
+
+BOOST_AUTO_TEST_CASE(CountsFilesInsteadOfJustLookingForOne){
+    TempDir dir;
+    for (int i = 0; i < 3; ++ i) dir.Touch("data/fields/f" + std::to_string(i) + "/map.xml");
+    // Three fields are not an installation of the fields.
+    BOOST_CHECK(HasProblem(VerifyInstallation(dir.Root()), "Field maps"));
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+
+// ---------------------------------------------------------------------------------------------------------------
+// Log.
+// ---------------------------------------------------------------------------------------------------------------
+
+BOOST_AUTO_TEST_SUITE(InstallLogTests)
+
+BOOST_AUTO_TEST_CASE(Severity){
+    BOOST_CHECK_EQUAL(InstallLog::Classify("[ERROR] Skip field: x"), InstallLog::ERROR_LEVEL);
+    BOOST_CHECK_EQUAL(InstallLog::Classify("Exception: Bone not found"), InstallLog::ERROR_LEVEL);
+    BOOST_CHECK_EQUAL(InstallLog::Classify("[WARNING] Texture not found"), InstallLog::WARNING);
+    BOOST_CHECK_EQUAL(InstallLog::Classify("Converting field models..."), InstallLog::INFO);
+    BOOST_CHECK_EQUAL(InstallLog::Classify(""), InstallLog::INFO);
+}
+
+BOOST_AUTO_TEST_CASE(CountsFileAndSink){
+    TempDir dir;
+    InstallLog& log = InstallLog::Get();
+    std::vector<std::string> shown;
+    log.Open(dir.Root() + "installer.log");
+    log.SetSink([&shown](InstallLog::Level, const std::string& message){shown.push_back(message);});
+    log.Add("Starting");
+    log.Add("[WARNING] careful");
+    log.Add("[ERROR] broken");
+    // The same problem reported twice in a row counts once.
+    log.Add("[ERROR] broken");
+    log.Add("hidden", false);
+    // What the installer prints is captured too, and only problems reach the sink.
+    std::cerr << "[ERROR] printed" << std::endl;
+    std::cout << "plain output" << std::endl;
+    BOOST_CHECK_EQUAL(log.Count(InstallLog::ERROR_LEVEL), 2u);
+    BOOST_CHECK_EQUAL(log.Count(InstallLog::WARNING), 1u);
+    BOOST_CHECK(Contains(shown, "Starting"));
+    BOOST_CHECK(!Contains(shown, "hidden"));
+    BOOST_CHECK(!Contains(shown, "plain output"));
+    BOOST_CHECK(Contains(shown, "[ERROR] printed"));
+    BOOST_REQUIRE_EQUAL(log.First(InstallLog::ERROR_LEVEL, 1).size(), 1u);
+    BOOST_CHECK_EQUAL(log.First(InstallLog::ERROR_LEVEL, 1)[0], "[ERROR] broken");
+    log.SetSink(nullptr);
+    log.Close();
+
+    std::ifstream file(dir.Root() + "installer.log");
+    std::string content((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
+    BOOST_CHECK(content.find("careful") != std::string::npos);
+    BOOST_CHECK(content.find("hidden") != std::string::npos);
+    BOOST_CHECK(content.find("plain output") != std::string::npos);
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+
+// ---------------------------------------------------------------------------------------------------------------
+// Steps of the log.
+// ---------------------------------------------------------------------------------------------------------------
+
+BOOST_AUTO_TEST_SUITE(InstallLogStepTests)
+
+BOOST_AUTO_TEST_CASE(StepsReportTheirOwnProblems){
+    TempDir dir;
+    InstallLog& log = InstallLog::Get();
+    std::vector<std::string> shown;
+    log.Open(dir.Root() + "installer.log");
+    log.SetSink([&shown](InstallLog::Level, const std::string& message){shown.push_back(message);});
+
+    log.BeginStep("Clean step");
+    log.Add("fine");
+    log.BeginStep("Bad step");
+    log.Add("[ERROR] one");
+    log.Add("[ERROR] two");
+    log.Add("[WARNING] three");
+    log.BeginStep("Next step");
+    log.Add("[WARNING] four");
+    log.EndStep();
+    log.SetSink(nullptr);
+    log.Close();
+
+    auto summary = [&shown](const std::string& step){
+        for (const std::string& line : shown) if (line.find("\"" + step + "\" finished") != std::string::npos) return line;
+        return std::string();
+    };
+    // Steps without problems are not mentioned, the others only count their own.
+    BOOST_CHECK(summary("Clean step").empty());
+    BOOST_CHECK(summary("Bad step").find("2 error(s) and 1 warning(s)") != std::string::npos);
+    BOOST_CHECK(summary("Next step").find("0 error(s) and 1 warning(s)") != std::string::npos);
+    // The summaries don't count as problems themselves.
+    BOOST_CHECK_EQUAL(InstallLog::Classify(summary("Bad step")), InstallLog::INFO);
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+
+// ---------------------------------------------------------------------------------------------------------------
+// Task pool.
+// ---------------------------------------------------------------------------------------------------------------
+
+BOOST_AUTO_TEST_SUITE(TaskPoolTests)
+
+BOOST_AUTO_TEST_CASE(RunsEveryTask){
+    std::atomic<int> done(0);
+    TaskPool pool(4);
+    for (int i = 0; i < 200; ++ i) pool.Run([&done](){++ done;});
+    pool.Wait();
+    BOOST_CHECK_EQUAL(done.load(), 200);
+}
+
+BOOST_AUTO_TEST_CASE(WaitWithoutTasksReturns){
+    TaskPool pool(2);
+    pool.Wait();
+    BOOST_CHECK_EQUAL(pool.Threads(), 2u);
+}
+
+BOOST_AUTO_TEST_CASE(RunsInParallel){
+    // Tasks that wait for each other can only finish if they really run at the same time.
+    std::atomic<int> arrived(0);
+    TaskPool pool(3);
+    for (int i = 0; i < 3; ++ i){
+        pool.Run([&arrived](){
+            ++ arrived;
+            const auto limit = std::chrono::steady_clock::now() + std::chrono::seconds(5);
+            while (arrived < 3 && std::chrono::steady_clock::now() < limit) std::this_thread::yield();
+        });
+    }
+    pool.Wait();
+    BOOST_CHECK_EQUAL(arrived.load(), 3);
+}
+
+BOOST_AUTO_TEST_CASE(UsesSeveralThreads){
+    std::mutex mutex;
+    std::set<std::thread::id> threads;
+    TaskPool pool(4);
+    for (int i = 0; i < 50; ++ i){
+        pool.Run([&](){
+            std::this_thread::sleep_for(std::chrono::milliseconds(5));
+            std::lock_guard<std::mutex> lock(mutex);
+            threads.insert(std::this_thread::get_id());
+        });
+    }
+    pool.Wait();
+    BOOST_CHECK_GT(threads.size(), 1u);
+    BOOST_CHECK_LE(threads.size(), 4u);
+}
+
+BOOST_AUTO_TEST_CASE(DestructorFinishesPendingTasks){
+    std::atomic<int> done(0);
+    {
+        TaskPool pool(2);
+        for (int i = 0; i < 20; ++ i) pool.Run([&done](){
+            std::this_thread::sleep_for(std::chrono::milliseconds(2));
+            ++ done;
+        });
+    }
+    BOOST_CHECK_EQUAL(done.load(), 20);
+}
+
+BOOST_AUTO_TEST_CASE(ATaskThatThrowsDoesNotStopThePool){
+    std::atomic<int> done(0);
+    TaskPool pool(2);
+    pool.Run([](){throw std::runtime_error("boom");});
+    for (int i = 0; i < 10; ++ i) pool.Run([&done](){++ done;});
+    pool.Wait();
+    BOOST_CHECK_EQUAL(done.load(), 10);
+}
+
+BOOST_AUTO_TEST_CASE(DefaultNumberOfThreadsIsReasonable){
+    TaskPool pool;
+    BOOST_CHECK_GE(pool.Threads(), 1u);
+    BOOST_CHECK_LE(pool.Threads(), 8u);
+}
+
+BOOST_AUTO_TEST_SUITE_END()
+
+// ---------------------------------------------------------------------------------------------------------------
+// Videos of the game disks.
+// ---------------------------------------------------------------------------------------------------------------
+
+BOOST_AUTO_TEST_SUITE(VideoFilesTests)
+
+BOOST_AUTO_TEST_CASE(RecognizesMovies){
+    std::string name;
+    BOOST_CHECK(VideoFiles::IsMovie("ff7/movies/bike.avi", &name));
+    BOOST_CHECK_EQUAL(name, "bike.avi");
+    // The disks mix the case, and ISO9660 adds version numbers and dots.
+    BOOST_CHECK(VideoFiles::IsMovie("FF7/MOVIES/SQLOGO.AVI;1", &name));
+    BOOST_CHECK_EQUAL(name, "sqlogo.avi");
+    BOOST_CHECK(VideoFiles::IsMovie("/ff7/Movies/Explode.avi", &name));
+    BOOST_CHECK_EQUAL(name, "explode.avi");
+    BOOST_CHECK(VideoFiles::IsMovie("ff7\\movies\\ending1.avi", &name));
+    BOOST_CHECK_EQUAL(name, "ending1.avi");
+    // Other files of the folder are copied too (the camera data of the movies).
+    BOOST_CHECK(VideoFiles::IsMovie("ff7/movies/moviecam.lgp", &name));
+    BOOST_CHECK_EQUAL(name, "moviecam.lgp");
+}
+
+BOOST_AUTO_TEST_CASE(IgnoresEverythingElse){
+    BOOST_CHECK(!VideoFiles::IsMovie("ff7/movies"));
+    BOOST_CHECK(!VideoFiles::IsMovie("ff7/movies/"));
+    BOOST_CHECK(!VideoFiles::IsMovie("ff7/movies/sub/bike.avi"));
+    BOOST_CHECK(!VideoFiles::IsMovie("ff7/field/char.lgp"));
+    BOOST_CHECK(!VideoFiles::IsMovie("ff7/moviesx/bike.avi"));
+    BOOST_CHECK(!VideoFiles::IsMovie("data/movies/bike.avi"));
+    BOOST_CHECK(!VideoFiles::IsMovie(""));
+    std::string name = "untouched";
+    BOOST_CHECK(!VideoFiles::IsMovie("ff7/wm/x.avi", &name));
+    BOOST_CHECK_EQUAL(name, "untouched");
+}
+
+BOOST_AUTO_TEST_CASE(NormalizesPaths){
+    BOOST_CHECK_EQUAL(VideoFiles::NormalizePath("/FF7/Movies/A.AVI;1"), "ff7/movies/a.avi");
+    BOOST_CHECK_EQUAL(VideoFiles::NormalizePath("FF7/MOVIES/A."), "ff7/movies/a");
+    // A semicolon that is not a version stays.
+    BOOST_CHECK_EQUAL(VideoFiles::NormalizePath("a;b"), "a;b");
+}
+
+BOOST_AUTO_TEST_CASE(LogCanBeAppended){
+    TempDir dir;
+    InstallLog& log = InstallLog::Get();
+    log.Open(dir.Root() + "installer.log");
+    log.Add("first run");
+    log.Close();
+    log.Open(dir.Root() + "installer.log", true);
+    log.Add("second run");
+    log.Close();
+    std::ifstream file(dir.Root() + "installer.log");
+    std::string content((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
+    BOOST_CHECK(content.find("first run") != std::string::npos);
+    BOOST_CHECK(content.find("second run") != std::string::npos);
+    // Without append the file starts over.
+    log.Open(dir.Root() + "installer.log");
+    log.Close();
+    std::ifstream again(dir.Root() + "installer.log");
+    std::string content2((std::istreambuf_iterator<char>(again)), std::istreambuf_iterator<char>());
+    BOOST_CHECK(content2.empty());
+}
+
+BOOST_AUTO_TEST_SUITE_END()

+ 1 - 1
v-gears.desktop

@@ -1,7 +1,7 @@
 [Desktop Entry]
 Categories=Application;Game;
 Encoding=UTF-8
-Exec=/usr/bin/v-gears-launcher
+Exec=sh -c "cd ~/.v-gears && exec /usr/games/v-gears"
 #Path=/usr/share/v-gears
 GenericName=V Gears
 Icon=v-gears.png