factorix 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +20 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +105 -0
  5. data/completion/_factorix.bash +202 -0
  6. data/completion/_factorix.fish +197 -0
  7. data/completion/_factorix.zsh +376 -0
  8. data/doc/factorix.1 +377 -0
  9. data/exe/factorix +20 -0
  10. data/lib/factorix/api/category.rb +69 -0
  11. data/lib/factorix/api/image.rb +35 -0
  12. data/lib/factorix/api/license.rb +71 -0
  13. data/lib/factorix/api/mod_download_api.rb +66 -0
  14. data/lib/factorix/api/mod_info.rb +166 -0
  15. data/lib/factorix/api/mod_management_api.rb +237 -0
  16. data/lib/factorix/api/mod_portal_api.rb +204 -0
  17. data/lib/factorix/api/release.rb +49 -0
  18. data/lib/factorix/api/tag.rb +95 -0
  19. data/lib/factorix/api.rb +7 -0
  20. data/lib/factorix/api_credential.rb +54 -0
  21. data/lib/factorix/application.rb +218 -0
  22. data/lib/factorix/cache/file_system.rb +307 -0
  23. data/lib/factorix/cli/commands/backup_support.rb +46 -0
  24. data/lib/factorix/cli/commands/base.rb +90 -0
  25. data/lib/factorix/cli/commands/cache/evict.rb +180 -0
  26. data/lib/factorix/cli/commands/cache/stat.rb +201 -0
  27. data/lib/factorix/cli/commands/command_wrapper.rb +71 -0
  28. data/lib/factorix/cli/commands/completion.rb +83 -0
  29. data/lib/factorix/cli/commands/confirmable.rb +53 -0
  30. data/lib/factorix/cli/commands/download_support.rb +123 -0
  31. data/lib/factorix/cli/commands/launch.rb +79 -0
  32. data/lib/factorix/cli/commands/man.rb +29 -0
  33. data/lib/factorix/cli/commands/mod/check.rb +99 -0
  34. data/lib/factorix/cli/commands/mod/disable.rb +188 -0
  35. data/lib/factorix/cli/commands/mod/download.rb +291 -0
  36. data/lib/factorix/cli/commands/mod/edit.rb +114 -0
  37. data/lib/factorix/cli/commands/mod/enable.rb +216 -0
  38. data/lib/factorix/cli/commands/mod/image/add.rb +47 -0
  39. data/lib/factorix/cli/commands/mod/image/edit.rb +41 -0
  40. data/lib/factorix/cli/commands/mod/image/list.rb +74 -0
  41. data/lib/factorix/cli/commands/mod/install.rb +443 -0
  42. data/lib/factorix/cli/commands/mod/list.rb +372 -0
  43. data/lib/factorix/cli/commands/mod/search.rb +134 -0
  44. data/lib/factorix/cli/commands/mod/settings/dump.rb +88 -0
  45. data/lib/factorix/cli/commands/mod/settings/restore.rb +101 -0
  46. data/lib/factorix/cli/commands/mod/show.rb +202 -0
  47. data/lib/factorix/cli/commands/mod/sync.rb +299 -0
  48. data/lib/factorix/cli/commands/mod/uninstall.rb +325 -0
  49. data/lib/factorix/cli/commands/mod/update.rb +222 -0
  50. data/lib/factorix/cli/commands/mod/upload.rb +90 -0
  51. data/lib/factorix/cli/commands/path.rb +79 -0
  52. data/lib/factorix/cli/commands/requires_game_stopped.rb +32 -0
  53. data/lib/factorix/cli/commands/version.rb +25 -0
  54. data/lib/factorix/cli.rb +42 -0
  55. data/lib/factorix/dependency/edge.rb +89 -0
  56. data/lib/factorix/dependency/entry.rb +124 -0
  57. data/lib/factorix/dependency/graph/builder.rb +108 -0
  58. data/lib/factorix/dependency/graph.rb +210 -0
  59. data/lib/factorix/dependency/list.rb +244 -0
  60. data/lib/factorix/dependency/mod_version_requirement.rb +73 -0
  61. data/lib/factorix/dependency/node.rb +60 -0
  62. data/lib/factorix/dependency/parser.rb +148 -0
  63. data/lib/factorix/dependency/validation_result.rb +138 -0
  64. data/lib/factorix/dependency/validator.rb +190 -0
  65. data/lib/factorix/errors.rb +112 -0
  66. data/lib/factorix/formatting.rb +56 -0
  67. data/lib/factorix/game_version.rb +98 -0
  68. data/lib/factorix/http/cache_decorator.rb +106 -0
  69. data/lib/factorix/http/cached_response.rb +37 -0
  70. data/lib/factorix/http/client.rb +187 -0
  71. data/lib/factorix/http/response.rb +31 -0
  72. data/lib/factorix/http/retry_decorator.rb +59 -0
  73. data/lib/factorix/http/retry_strategy.rb +80 -0
  74. data/lib/factorix/info_json.rb +90 -0
  75. data/lib/factorix/installed_mod.rb +239 -0
  76. data/lib/factorix/mod.rb +55 -0
  77. data/lib/factorix/mod_list.rb +174 -0
  78. data/lib/factorix/mod_settings.rb +278 -0
  79. data/lib/factorix/mod_state.rb +34 -0
  80. data/lib/factorix/mod_version.rb +99 -0
  81. data/lib/factorix/portal.rb +185 -0
  82. data/lib/factorix/progress/download_handler.rb +46 -0
  83. data/lib/factorix/progress/multi_presenter.rb +45 -0
  84. data/lib/factorix/progress/presenter.rb +67 -0
  85. data/lib/factorix/progress/presenter_adapter.rb +46 -0
  86. data/lib/factorix/progress/scan_handler.rb +33 -0
  87. data/lib/factorix/progress/upload_handler.rb +33 -0
  88. data/lib/factorix/runtime/base.rb +233 -0
  89. data/lib/factorix/runtime/linux.rb +32 -0
  90. data/lib/factorix/runtime/mac_os.rb +53 -0
  91. data/lib/factorix/runtime/user_configurable.rb +69 -0
  92. data/lib/factorix/runtime/windows.rb +85 -0
  93. data/lib/factorix/runtime/wsl.rb +118 -0
  94. data/lib/factorix/runtime.rb +32 -0
  95. data/lib/factorix/save_file.rb +178 -0
  96. data/lib/factorix/ser_des/deserializer.rb +198 -0
  97. data/lib/factorix/ser_des/serializer.rb +231 -0
  98. data/lib/factorix/ser_des/signed_integer.rb +63 -0
  99. data/lib/factorix/ser_des/unsigned_integer.rb +65 -0
  100. data/lib/factorix/service_credential.rb +127 -0
  101. data/lib/factorix/transfer/downloader.rb +162 -0
  102. data/lib/factorix/transfer/uploader.rb +232 -0
  103. data/lib/factorix/version.rb +6 -0
  104. data/lib/factorix.rb +38 -0
  105. data/sig/dry/auto_inject.rbs +15 -0
  106. data/sig/dry/cli.rbs +19 -0
  107. data/sig/dry/configurable.rbs +13 -0
  108. data/sig/dry/core/container.rbs +17 -0
  109. data/sig/dry/events/publisher.rbs +22 -0
  110. data/sig/dry/logger.rbs +16 -0
  111. data/sig/factorix/api/category.rbs +15 -0
  112. data/sig/factorix/api/image.rbs +15 -0
  113. data/sig/factorix/api/license.rbs +20 -0
  114. data/sig/factorix/api/mod_download_api.rbs +18 -0
  115. data/sig/factorix/api/mod_info.rbs +67 -0
  116. data/sig/factorix/api/mod_management_api.rbs +25 -0
  117. data/sig/factorix/api/mod_portal_api.rbs +31 -0
  118. data/sig/factorix/api/release.rbs +27 -0
  119. data/sig/factorix/api/tag.rbs +15 -0
  120. data/sig/factorix/api.rbs +8 -0
  121. data/sig/factorix/api_credential.rbs +17 -0
  122. data/sig/factorix/application.rbs +86 -0
  123. data/sig/factorix/cache/file_system.rbs +35 -0
  124. data/sig/factorix/cli/commands/base.rbs +13 -0
  125. data/sig/factorix/cli/commands/cache/evict.rbs +17 -0
  126. data/sig/factorix/cli/commands/cache/stat.rbs +17 -0
  127. data/sig/factorix/cli/commands/command_wrapper.rbs +13 -0
  128. data/sig/factorix/cli/commands/completion/zsh.rbs +15 -0
  129. data/sig/factorix/cli/commands/confirmable.rbs +12 -0
  130. data/sig/factorix/cli/commands/download_support.rbs +12 -0
  131. data/sig/factorix/cli/commands/launch.rbs +15 -0
  132. data/sig/factorix/cli/commands/mod/check.rbs +18 -0
  133. data/sig/factorix/cli/commands/mod/disable.rbs +20 -0
  134. data/sig/factorix/cli/commands/mod/download.rbs +18 -0
  135. data/sig/factorix/cli/commands/mod/edit.rbs +30 -0
  136. data/sig/factorix/cli/commands/mod/enable.rbs +20 -0
  137. data/sig/factorix/cli/commands/mod/image/add.rbs +19 -0
  138. data/sig/factorix/cli/commands/mod/image/edit.rbs +19 -0
  139. data/sig/factorix/cli/commands/mod/image/list.rbs +19 -0
  140. data/sig/factorix/cli/commands/mod/install.rbs +19 -0
  141. data/sig/factorix/cli/commands/mod/list.rbs +30 -0
  142. data/sig/factorix/cli/commands/mod/search.rbs +18 -0
  143. data/sig/factorix/cli/commands/mod/settings/dump.rbs +17 -0
  144. data/sig/factorix/cli/commands/mod/settings/restore.rbs +17 -0
  145. data/sig/factorix/cli/commands/mod/sync.rbs +19 -0
  146. data/sig/factorix/cli/commands/mod/uninstall.rbs +20 -0
  147. data/sig/factorix/cli/commands/mod/update.rbs +19 -0
  148. data/sig/factorix/cli/commands/mod/upload.rbs +24 -0
  149. data/sig/factorix/cli/commands/path.rbs +18 -0
  150. data/sig/factorix/cli/commands/requires_game_stopped.rbs +13 -0
  151. data/sig/factorix/cli/commands/version.rbs +13 -0
  152. data/sig/factorix/cli.rbs +11 -0
  153. data/sig/factorix/dependency/edge.rbs +32 -0
  154. data/sig/factorix/dependency/entry.rbs +30 -0
  155. data/sig/factorix/dependency/graph/builder.rbs +17 -0
  156. data/sig/factorix/dependency/graph.rbs +39 -0
  157. data/sig/factorix/dependency/list.rbs +69 -0
  158. data/sig/factorix/dependency/mod_version_requirement.rbs +18 -0
  159. data/sig/factorix/dependency/node.rbs +24 -0
  160. data/sig/factorix/dependency/parser.rbs +11 -0
  161. data/sig/factorix/dependency/validation_result.rbs +56 -0
  162. data/sig/factorix/dependency/validator.rbs +13 -0
  163. data/sig/factorix/errors.rbs +132 -0
  164. data/sig/factorix/formatting.rbs +8 -0
  165. data/sig/factorix/game_version.rbs +24 -0
  166. data/sig/factorix/http/cache_decorator.rbs +64 -0
  167. data/sig/factorix/http/client.rbs +55 -0
  168. data/sig/factorix/http/response.rbs +28 -0
  169. data/sig/factorix/http/retry_decorator.rbs +44 -0
  170. data/sig/factorix/http/retry_strategy.rbs +42 -0
  171. data/sig/factorix/info_json.rbs +19 -0
  172. data/sig/factorix/installed_mod.rbs +34 -0
  173. data/sig/factorix/mod.rbs +20 -0
  174. data/sig/factorix/mod_list.rbs +44 -0
  175. data/sig/factorix/mod_settings.rbs +47 -0
  176. data/sig/factorix/mod_state.rbs +18 -0
  177. data/sig/factorix/mod_version.rbs +23 -0
  178. data/sig/factorix/portal.rbs +37 -0
  179. data/sig/factorix/progress/download_handler.rbs +19 -0
  180. data/sig/factorix/progress/multi_presenter.rbs +15 -0
  181. data/sig/factorix/progress/presenter.rbs +17 -0
  182. data/sig/factorix/progress/presenter_adapter.rbs +17 -0
  183. data/sig/factorix/progress/scan_handler.rbs +16 -0
  184. data/sig/factorix/progress/upload_handler.rbs +17 -0
  185. data/sig/factorix/runtime/base.rbs +45 -0
  186. data/sig/factorix/runtime/linux.rbs +15 -0
  187. data/sig/factorix/runtime/mac_os.rbs +15 -0
  188. data/sig/factorix/runtime/user_configurable.rbs +13 -0
  189. data/sig/factorix/runtime/windows.rbs +23 -0
  190. data/sig/factorix/runtime/wsl.rbs +19 -0
  191. data/sig/factorix/runtime.rbs +9 -0
  192. data/sig/factorix/save_file.rbs +40 -0
  193. data/sig/factorix/ser_des/deserializer.rbs +49 -0
  194. data/sig/factorix/ser_des/serializer.rbs +45 -0
  195. data/sig/factorix/ser_des/signed_integer.rbs +37 -0
  196. data/sig/factorix/ser_des/unsigned_integer.rbs +37 -0
  197. data/sig/factorix/service_credential.rbs +19 -0
  198. data/sig/factorix/transfer/downloader.rbs +15 -0
  199. data/sig/factorix/transfer/uploader.rbs +21 -0
  200. data/sig/factorix.rbs +9 -0
  201. data/sig/tty/progressbar.rbs +18 -0
  202. metadata +431 -0
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+
6
+ module Factorix
7
+ class Runtime
8
+ # WSL (Windows Subsystem for Linux) runtime environment
9
+ #
10
+ # This implementation inherits from Windows and converts Windows paths
11
+ # to WSL paths. It retrieves Windows environment variables via PowerShell
12
+ # in a single batch operation and converts paths using native Ruby code.
13
+ class WSL < Windows
14
+ # Initialize WSL runtime environment
15
+ #
16
+ # @param path [WSLPath] the path provider (for dependency injection)
17
+ def initialize(path: WSLPath.new) = super
18
+
19
+ # WSL-specific path provider
20
+ #
21
+ # This class fetches Windows environment variables via PowerShell in one batch operation and converts Windows paths to WSL paths.
22
+ class WSLPath
23
+ # Default WSL mount root for Windows drives
24
+ MOUNT_ROOT = "/mnt"
25
+ private_constant :MOUNT_ROOT
26
+
27
+ # PowerShell script to fetch Windows environment variables
28
+ POWERSHELL_SCRIPT = <<~POWERSHELL
29
+ [pscustomobject]@{
30
+ "ProgramFiles(x86)" = ${Env:ProgramFiles(x86)};
31
+ "APPDATA" = ${Env:APPDATA};
32
+ "LOCALAPPDATA" = ${Env:LOCALAPPDATA}
33
+ } | ConvertTo-Json -Compress
34
+ POWERSHELL
35
+ private_constant :POWERSHELL_SCRIPT
36
+
37
+ POWERSHELL_FALLBACK_PATHS = %w[
38
+ /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe
39
+ /mnt/c/Windows/system32/WindowsPowerShell/v1.0/powershell.exe
40
+ ].freeze
41
+ private_constant :POWERSHELL_FALLBACK_PATHS
42
+
43
+ # Initialize the path provider
44
+ #
45
+ # Sets up the mutex for thread-safe lazy initialization
46
+ def initialize
47
+ @mutex = Mutex.new
48
+ end
49
+
50
+ # Get the Program Files (x86) directory path (WSL-converted)
51
+ #
52
+ # @return [Pathname] the Program Files (x86) directory
53
+ def program_files_x86 = @program_files_x86 ||= Pathname(convert_windows_to_wsl(windows_paths["ProgramFiles(x86)"]))
54
+
55
+ # Get the AppData directory path (WSL-converted)
56
+ #
57
+ # @return [Pathname] the AppData directory
58
+ def app_data = @app_data ||= Pathname(convert_windows_to_wsl(windows_paths["APPDATA"]))
59
+
60
+ # Get the Local AppData directory path (WSL-converted)
61
+ #
62
+ # @return [Pathname] the Local AppData directory
63
+ def local_app_data = @local_app_data ||= Pathname(convert_windows_to_wsl(windows_paths["LOCALAPPDATA"]))
64
+
65
+ # Fetch and cache all Windows environment variables
66
+ #
67
+ # @return [Hash] the environment variables as a hash
68
+ private def windows_paths
69
+ return @windows_paths if @windows_paths
70
+
71
+ @mutex.synchronize do
72
+ @windows_paths ||= fetch_windows_paths_via_powershell
73
+ end
74
+ end
75
+
76
+ # Fetch Windows environment variables via PowerShell
77
+ #
78
+ # @return [Hash] the environment variables as a hash
79
+ # @raise [PlatformError] if PowerShell execution fails
80
+ private def fetch_windows_paths_via_powershell
81
+ ps = find_powershell_exe
82
+
83
+ stdout, status = Open3.capture2(ps, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", POWERSHELL_SCRIPT)
84
+
85
+ raise PlatformError, "PowerShell execution failed: #{status}" unless status.success?
86
+
87
+ JSON.parse(stdout.encode("UTF-8", invalid: :replace, undef: :replace))
88
+ end
89
+
90
+ # Convert Windows path to WSL path
91
+ #
92
+ # @param windows_path [String] Windows-style path to convert
93
+ # @return [String] equivalent WSL path
94
+ # @raise [PlatformError] if the path format is invalid
95
+ private def convert_windows_to_wsl(windows_path)
96
+ raise PlatformError, "Invalid Windows path: #{windows_path}" unless windows_path =~ %r{\A([A-Za-z]):[\\/]?(.*)\z}
97
+
98
+ drive = $1.downcase
99
+ path = $2.tr("\\", "/")
100
+ result = "#{MOUNT_ROOT}/#{drive}/#{path}"
101
+ # Normalize: collapse multiple slashes and remove trailing slash
102
+ result.squeeze("/").delete_suffix("/")
103
+ end
104
+
105
+ # Find powershell.exe executable
106
+ #
107
+ # @return [String] path to powershell.exe
108
+ # @raise [PlatformError] if powershell.exe is not found
109
+ private def find_powershell_exe
110
+ return "powershell.exe" if system("which", "powershell.exe", out: File::NULL, err: File::NULL)
111
+
112
+ POWERSHELL_FALLBACK_PATHS.find {|path| File.exist?(path) } ||
113
+ raise(PlatformError, "powershell.exe not found in PATH or default locations")
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ # Factorio runtime environment abstraction
5
+ #
6
+ # This class provides a factory method to detect the current platform
7
+ # and return the appropriate runtime environment instance.
8
+ class Runtime
9
+ # Detect the current platform and return the appropriate runtime
10
+ #
11
+ # @return [Runtime::Base] the runtime environment for the current platform
12
+ # @raise [UnsupportedPlatformError] if the platform is not supported
13
+ def self.detect
14
+ case RUBY_PLATFORM
15
+ when /darwin/
16
+ MacOS.new
17
+ when /mingw|mswin/
18
+ Windows.new
19
+ when /linux/
20
+ wsl? ? WSL.new : Linux.new
21
+ else
22
+ raise UnsupportedPlatformError, "Platform is not supported: #{RUBY_PLATFORM}"
23
+ end
24
+ end
25
+
26
+ # Check if running on WSL
27
+ #
28
+ # @return [Boolean] true if running on WSL, false otherwise
29
+ def self.wsl? = File.exist?("/proc/version") && /microsoft/i.match?(File.read("/proc/version"))
30
+ private_class_method :wsl?
31
+ end
32
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zip"
4
+ require "zlib"
5
+
6
+ module Factorix
7
+ SaveFile = Data.define(:version, :mods, :startup_settings)
8
+
9
+ # Data structure for Factorio save file information
10
+ #
11
+ # SaveFile provides functionality to extract MOD information and startup settings
12
+ # from Factorio save files (.zip format containing level.dat0 or level-init.dat).
13
+ class SaveFile
14
+ # @!attribute [r] version
15
+ # @return [Factorix::GameVersion] Game version from the save file
16
+ # @!attribute [r] mods
17
+ # @return [Hash<String, Factorix::MODState>] Hash of MOD name to MODState
18
+ # @!attribute [r] startup_settings
19
+ # @return [Factorix::MODSettings::Section] Startup settings section
20
+
21
+ # Level file names to search for, in priority order
22
+ LEVEL_FILE_NAMES = %w[level.dat0 level-init.dat].freeze
23
+ private_constant :LEVEL_FILE_NAMES
24
+
25
+ # Load a save file and extract MOD information and settings
26
+ #
27
+ # @param path [Pathname] Path to the save file (.zip)
28
+ # @return [SaveFile] Extracted save file data
29
+ # @raise [FileFormatError] If save file or level file not found
30
+ # @raise [Factorix::Error] If save file format is invalid
31
+ def self.load(path) = Parser.new(path).parse
32
+
33
+ # Internal parser for save files
34
+ class Parser
35
+ # Initialize a new Parser instance
36
+ #
37
+ # @param path [Pathname] Path to the save file
38
+ def initialize(path) = @path = path
39
+
40
+ # Parse the save file and return extracted data
41
+ #
42
+ # @return [SaveFile] Extracted save file data
43
+ # @raise [FileFormatError] If save file or level file not found
44
+ def parse
45
+ open_level_file do |stream|
46
+ deserializer = SerDes::Deserializer.new(stream)
47
+ parse_save_header(deserializer)
48
+ skip_unknown_bytes(deserializer)
49
+ parse_startup_settings(deserializer)
50
+ end
51
+
52
+ SaveFile.new(version: @version, mods: @mods, startup_settings: @startup_settings)
53
+ end
54
+
55
+ private def open_level_file
56
+ Zip::File.open(@path) do |zip_file|
57
+ LEVEL_FILE_NAMES.each do |file_name|
58
+ entry = find_level_entry(zip_file, file_name)
59
+ next unless entry
60
+
61
+ stream = entry.get_input_stream
62
+ return yield decompress_if_needed(stream)
63
+ end
64
+
65
+ raise FileFormatError, "level.dat0 or level-init.dat not found in #{@path}"
66
+ end
67
+ end
68
+
69
+ # Find a level file entry in the zip file
70
+ #
71
+ # @param zip_file [Zip::File] The zip file
72
+ # @param file_name [String] The level file name to search for
73
+ # @return [Zip::Entry, nil] The entry if found, nil otherwise
74
+ private def find_level_entry(zip_file, file_name) = zip_file.glob("*/#{file_name}").first
75
+
76
+ # Decompress stream if it's zlib compressed
77
+ #
78
+ # @param stream [IO] The input stream
79
+ # @return [IO] Decompressed stream or original stream
80
+ private def decompress_if_needed(stream)
81
+ # Read CMF (Compression Method and Flags) byte
82
+ cmf = stream.read(1)
83
+ return StringIO.new("") if cmf.nil?
84
+
85
+ stream.rewind
86
+
87
+ # CMF = 0x78 indicates zlib compression (DEFLATE with 32K window)
88
+ if cmf.unpack1("C") == 0x78
89
+ StringIO.new(Zlib::Inflate.inflate(stream.read))
90
+ else
91
+ stream
92
+ end
93
+ end
94
+
95
+ # Parse save header to extract game version and MOD list
96
+ #
97
+ # @param deserializer [Factorix::SerDes::Deserializer] The deserializer
98
+ # @return [void]
99
+ private def parse_save_header(deserializer)
100
+ # Read game version
101
+ @version = deserializer.read_game_version
102
+
103
+ # Skip 1 byte after version
104
+ deserializer.read_u8
105
+
106
+ # Skip fields we don't need
107
+ deserializer.read_str # campaign
108
+ deserializer.read_str # level_name
109
+ deserializer.read_str # base_mod
110
+ deserializer.read_u8 # difficulty
111
+ deserializer.read_bool # finished
112
+ deserializer.read_bool # player_won
113
+ deserializer.read_str # next_level
114
+ deserializer.read_bool # can_continue
115
+ deserializer.read_bool # finished_but_continuing
116
+ deserializer.read_bool # saving_replay
117
+ deserializer.read_bool # allow_non_admin_debug_options
118
+ deserializer.read_mod_version # loaded_from (MODVersion, not GameVersion)
119
+ deserializer.read_u16 # loaded_from_build
120
+ deserializer.read_u8 # allowed_commands
121
+
122
+ # Additional fields before the MOD list
123
+ # These fields' purposes are not yet fully understood
124
+ deserializer.read_bool # Unknown boolean field
125
+ deserializer.read_u32 # Unknown u32 field
126
+ deserializer.read_bool # Unknown boolean field
127
+
128
+ # Read MOD list
129
+ parse_mods(deserializer)
130
+ end
131
+
132
+ # Parse MOD list from save header
133
+ #
134
+ # @param deserializer [Factorix::SerDes::Deserializer] The deserializer
135
+ # @return [void]
136
+ private def parse_mods(deserializer)
137
+ mods_count = deserializer.read_optim_u32
138
+ @mods = {}
139
+
140
+ mods_count.times do
141
+ name = deserializer.read_str
142
+ version = deserializer.read_mod_version
143
+ _crc = deserializer.read_u32
144
+
145
+ # All MODs in save file are enabled
146
+ @mods[name] = MODState.new(enabled: true, version:)
147
+ end
148
+ end
149
+
150
+ # Skip unknown 4 bytes after MOD list
151
+ #
152
+ # @param deserializer [Factorix::SerDes::Deserializer] The deserializer
153
+ # @return [void]
154
+ private def skip_unknown_bytes(deserializer) = deserializer.read_bytes(4)
155
+
156
+ # Parse startup settings from save file
157
+ #
158
+ # @param deserializer [Factorix::SerDes::Deserializer] The deserializer
159
+ # @return [void]
160
+ private def parse_startup_settings(deserializer)
161
+ raw_settings = deserializer.read_property_tree
162
+
163
+ # Create a new Section and populate it
164
+ @startup_settings = MODSettings::Section.new("startup")
165
+
166
+ return unless raw_settings.is_a?(Hash)
167
+
168
+ raw_settings.each do |key, value_hash|
169
+ # Extract the actual value from the {"value" => X} hash
170
+ next unless value_hash.is_a?(Hash)
171
+
172
+ @startup_settings[key] = value_hash["value"]
173
+ end
174
+ end
175
+ end
176
+ private_constant :Parser
177
+ end
178
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ module SerDes
5
+ # Deserialize data from binary format
6
+ #
7
+ # This class provides methods to deserialize various data types from Factorio's
8
+ # binary file format, following the specifications documented in the Factorio wiki.
9
+ class Deserializer
10
+ # @!parse
11
+ # # @return [Dry::Logger::Dispatcher]
12
+ # attr_reader :logger
13
+ include Import[:logger]
14
+
15
+ # Create a new Deserializer instance
16
+ #
17
+ # @param stream [IO] An IO-like object that responds to #read
18
+ # @param logger [Dry::Logger::Dispatcher] optional logger
19
+ # @raise [ArgumentError] If the stream doesn't respond to #read
20
+ def initialize(stream, logger: nil)
21
+ super(logger:)
22
+ raise ArgumentError, "can't read from the given argument" unless stream.respond_to?(:read)
23
+
24
+ @stream = stream
25
+ logger.debug "Initializing Deserializer"
26
+ end
27
+
28
+ # Read raw bytes from the stream
29
+ #
30
+ # @param length [Integer] Number of bytes to read
31
+ # @raise [Factorix::InvalidLengthError] If length is nil or negative
32
+ # @raise [EOFError] If end of file is reached before reading length bytes
33
+ # @return [String] Binary data read
34
+ def read_bytes(length)
35
+ raise InvalidLengthError, "nil length" if length.nil?
36
+ raise InvalidLengthError, "negative length #{length}" if length.negative?
37
+ return +"" if length.zero?
38
+
39
+ bytes = @stream.read(length)
40
+ if bytes.nil? || bytes.size < length
41
+ logger.debug("Unexpected EOF", requested_bytes: length)
42
+ raise EOFError
43
+ end
44
+
45
+ bytes
46
+ end
47
+
48
+ # Read an unsigned 8-bit integer
49
+ #
50
+ # @return [Integer] 8-bit unsigned integer
51
+ def read_u8 = read_bytes(1).unpack1("C")
52
+
53
+ # Read an unsigned 16-bit integer
54
+ #
55
+ # @return [Integer] 16-bit unsigned integer
56
+ def read_u16 = read_bytes(2).unpack1("v")
57
+
58
+ # Read an unsigned 32-bit integer
59
+ #
60
+ # @return [Integer] 32-bit unsigned integer
61
+ def read_u32 = read_bytes(4).unpack1("V")
62
+
63
+ # Read a space-optimized 16-bit unsigned integer
64
+ #
65
+ # @see https://wiki.factorio.com/Data_types#Space_Optimized
66
+ # @return [Integer] 16-bit unsigned integer
67
+ def read_optim_u16
68
+ byte = read_u8
69
+ byte == 0xFF ? read_u16 : byte
70
+ end
71
+
72
+ # Read a space-optimized 32-bit unsigned integer
73
+ #
74
+ # @see https://wiki.factorio.com/Data_types#Space_Optimized
75
+ # @return [Integer] 32-bit unsigned integer
76
+ def read_optim_u32
77
+ byte = read_u8
78
+ byte == 0xFF ? read_u32 : byte
79
+ end
80
+
81
+ # Read a tuple of 16-bit unsigned integers
82
+ #
83
+ # @param length [Integer] Number of integers to read
84
+ # @return [Array<Integer>] Array of 16-bit unsigned integers
85
+ def read_u16_tuple(length) = Array.new(length) { read_u16 }
86
+
87
+ # Read a boolean value
88
+ #
89
+ # @return [Boolean] Boolean value
90
+ def read_bool = read_u8 != 0
91
+
92
+ # Read a string
93
+ #
94
+ # @return [String] String read
95
+ def read_str
96
+ length = read_optim_u32
97
+ read_bytes(length).force_encoding(Encoding::UTF_8)
98
+ end
99
+
100
+ # Read a string property
101
+ #
102
+ # @see https://wiki.factorio.com/Property_tree#String
103
+ # @return [String] String property
104
+ def read_str_property = read_bool ? "" : read_str
105
+
106
+ # Read a double-precision floating point number
107
+ #
108
+ # @see https://wiki.factorio.com/Property_tree#Number
109
+ # @return [Float] Double-precision floating point number
110
+ def read_double = read_bytes(8).unpack1("d")
111
+
112
+ # Read a GameVersion object
113
+ #
114
+ # @return [GameVersion] GameVersion object
115
+ def read_game_version = GameVersion.from_numbers(read_u16, read_u16, read_u16, read_u16)
116
+
117
+ # Read a MODVersion object
118
+ #
119
+ # @return [MODVersion] MODVersion object
120
+ def read_mod_version = MODVersion.from_numbers(read_optim_u16, read_optim_u16, read_optim_u16)
121
+
122
+ # Read a signed long integer (8 bytes)
123
+ #
124
+ # @return [Integer] Signed long integer
125
+ def read_long = read_bytes(8).unpack1("q<")
126
+
127
+ # Read an unsigned long integer (8 bytes)
128
+ #
129
+ # @return [Integer] Unsigned long integer
130
+ def read_unsigned_long = read_bytes(8).unpack1("Q<")
131
+
132
+ # Read a dictionary
133
+ #
134
+ # @see https://wiki.factorio.com/Property_tree#Dictionary
135
+ # @return [Hash] Dictionary of key-value pairs
136
+ def read_dictionary
137
+ length = read_u32
138
+ logger.debug("Reading dictionary", length:)
139
+ length.times.each_with_object({}) do |_i, dict|
140
+ key = read_str_property
141
+ dict[key] = read_property_tree
142
+ end
143
+ end
144
+
145
+ # Read a list
146
+ # This type is identical to dictionary
147
+ #
148
+ # @see https://wiki.factorio.com/Property_tree#List
149
+ alias read_list read_dictionary
150
+
151
+ # Read a property tree
152
+ #
153
+ # @raise [Factorix::UnknownPropertyType] If the property type is not supported
154
+ # @return [Object] Object read
155
+ def read_property_tree
156
+ type = read_u8
157
+ _any_type_flag = read_bool
158
+ logger.debug("Reading property tree", type:)
159
+
160
+ case type
161
+ when 0
162
+ # Handle type 0 - None (null value)
163
+ #
164
+ # @see https://wiki.factorio.com/Property_tree
165
+ nil
166
+ when 1
167
+ read_bool
168
+ when 2
169
+ read_double
170
+ when 3
171
+ read_str_property
172
+ when 4
173
+ read_list
174
+ when 5
175
+ read_dictionary
176
+ when 6
177
+ # Handle type 6 - Signed integer
178
+ #
179
+ # @see https://wiki.factorio.com/Property_tree
180
+ SignedInteger.new(read_long)
181
+ when 7
182
+ # Handle type 7 - Unsigned integer
183
+ #
184
+ # @see https://wiki.factorio.com/Property_tree
185
+ UnsignedInteger.new(read_unsigned_long)
186
+ else
187
+ logger.debug("Unknown property type", type:)
188
+ raise UnknownPropertyType, "Unknown property type: #{type}"
189
+ end
190
+ end
191
+
192
+ # Check if the stream is at EOF
193
+ #
194
+ # @return [Boolean] True if at end of file, false otherwise
195
+ def eof? = @stream.eof?
196
+ end
197
+ end
198
+ end