hytale 0.0.1 → 0.1.1
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.
- checksums.yaml +4 -4
- data/LICENSE.txt +21 -0
- data/README.md +1430 -15
- data/exe/hytale +497 -0
- data/lib/hytale/client/assets.rb +207 -0
- data/lib/hytale/client/block_type.rb +169 -0
- data/lib/hytale/client/config.rb +98 -0
- data/lib/hytale/client/cosmetics.rb +95 -0
- data/lib/hytale/client/item_type.rb +248 -0
- data/lib/hytale/client/launcher_log/launcher_log_entry.rb +58 -0
- data/lib/hytale/client/launcher_log/launcher_log_session.rb +57 -0
- data/lib/hytale/client/launcher_log.rb +99 -0
- data/lib/hytale/client/locale.rb +234 -0
- data/lib/hytale/client/map/block.rb +135 -0
- data/lib/hytale/client/map/chunk.rb +695 -0
- data/lib/hytale/client/map/marker.rb +50 -0
- data/lib/hytale/client/map/region.rb +278 -0
- data/lib/hytale/client/map/renderer.rb +435 -0
- data/lib/hytale/client/map.rb +271 -0
- data/lib/hytale/client/memories.rb +59 -0
- data/lib/hytale/client/npc_memory.rb +39 -0
- data/lib/hytale/client/permissions.rb +52 -0
- data/lib/hytale/client/player/entity_stats.rb +46 -0
- data/lib/hytale/client/player/inventory.rb +54 -0
- data/lib/hytale/client/player/item.rb +102 -0
- data/lib/hytale/client/player/item_storage.rb +40 -0
- data/lib/hytale/client/player/player_memory.rb +29 -0
- data/lib/hytale/client/player/position.rb +12 -0
- data/lib/hytale/client/player/rotation.rb +11 -0
- data/lib/hytale/client/player/vector3.rb +12 -0
- data/lib/hytale/client/player.rb +101 -0
- data/lib/hytale/client/player_skin.rb +179 -0
- data/lib/hytale/client/prefab/palette_entry.rb +49 -0
- data/lib/hytale/client/prefab.rb +184 -0
- data/lib/hytale/client/process.rb +57 -0
- data/lib/hytale/client/save/backup.rb +43 -0
- data/lib/hytale/client/save/server_log.rb +42 -0
- data/lib/hytale/client/save.rb +157 -0
- data/lib/hytale/client/settings/audio_settings.rb +30 -0
- data/lib/hytale/client/settings/builder_tools_settings.rb +27 -0
- data/lib/hytale/client/settings/gameplay_settings.rb +23 -0
- data/lib/hytale/client/settings/input_bindings.rb +25 -0
- data/lib/hytale/client/settings/mouse_settings.rb +23 -0
- data/lib/hytale/client/settings/rendering_settings.rb +30 -0
- data/lib/hytale/client/settings.rb +74 -0
- data/lib/hytale/client/world/client_effects.rb +24 -0
- data/lib/hytale/client/world/death_settings.rb +22 -0
- data/lib/hytale/client/world.rb +88 -0
- data/lib/hytale/client/zone/region.rb +52 -0
- data/lib/hytale/client/zone.rb +68 -0
- data/lib/hytale/client.rb +142 -0
- data/lib/hytale/server/process.rb +11 -0
- data/lib/hytale/server.rb +6 -0
- data/lib/hytale/version.rb +1 -1
- data/lib/hytale.rb +37 -2
- metadata +119 -10
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module Hytale
|
|
7
|
+
module Client
|
|
8
|
+
class Map
|
|
9
|
+
# Represents a single chunk of terrain data
|
|
10
|
+
#
|
|
11
|
+
# Chunks are 16x16xN blocks of terrain data.
|
|
12
|
+
# The binary format appears to contain:
|
|
13
|
+
# - Block palette (string identifiers for block types)
|
|
14
|
+
# - Block data (indices into palette + state data)
|
|
15
|
+
# - Additional metadata
|
|
16
|
+
#
|
|
17
|
+
class Chunk
|
|
18
|
+
CHUNK_WIDTH = 16
|
|
19
|
+
CHUNK_DEPTH = 16
|
|
20
|
+
|
|
21
|
+
attr_reader :data, :index, :region
|
|
22
|
+
|
|
23
|
+
def initialize(data, index: nil, region: nil)
|
|
24
|
+
@data = data
|
|
25
|
+
@index = index
|
|
26
|
+
@region = region
|
|
27
|
+
@parsed = false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def size
|
|
31
|
+
data.bytesize
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def local_x
|
|
35
|
+
return nil unless index
|
|
36
|
+
|
|
37
|
+
index % 32
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def local_z
|
|
41
|
+
return nil unless index
|
|
42
|
+
|
|
43
|
+
index / 32
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def world_x
|
|
47
|
+
return nil unless region && local_x
|
|
48
|
+
|
|
49
|
+
(region.x * 32 * CHUNK_WIDTH) + (local_x * CHUNK_WIDTH)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def world_z
|
|
53
|
+
return nil unless region && local_z
|
|
54
|
+
|
|
55
|
+
(region.z * 32 * CHUNK_DEPTH) + (local_z * CHUNK_DEPTH)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def block_types
|
|
59
|
+
@block_types ||= extract_block_types
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def block_palette
|
|
63
|
+
@block_palette ||= extract_palette
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def block?(block_type)
|
|
67
|
+
block_types.include?(block_type)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def water?
|
|
71
|
+
block_types.any? { |t| t.include?("Water") }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def vegetation?
|
|
75
|
+
block_types.any? { |t| t.include?("Plant") || t.include?("Grass") || t.include?("Tree") }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def terrain_type
|
|
79
|
+
if water?
|
|
80
|
+
:water
|
|
81
|
+
elsif block_types.any? { |t| t.include?("Sand") }
|
|
82
|
+
:desert
|
|
83
|
+
elsif block_types.any? { |t| t.include?("Snow") || t.include?("Ice") }
|
|
84
|
+
:snow
|
|
85
|
+
elsif vegetation?
|
|
86
|
+
:grassland
|
|
87
|
+
else
|
|
88
|
+
:rocky
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def to_s
|
|
93
|
+
position = if local_x && local_z
|
|
94
|
+
"(#{local_x}, #{local_z})"
|
|
95
|
+
else
|
|
96
|
+
"##{index}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
"Chunk #{position} - #{size} bytes, #{block_types.size} block types"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def inspect
|
|
103
|
+
"#<Hytale::Client::Map::Chunk index=#{index} size=#{size} block_types=#{block_types.size}>"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Returns the sections parsed from the chunk data
|
|
107
|
+
# Each section contains block data for a vertical slice of the chunk
|
|
108
|
+
def sections
|
|
109
|
+
@sections ||= parse_sections
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Returns a 16x16 grid of the topmost Block at each X,Z position
|
|
113
|
+
# @return [Array<Array<Block, nil>>] 2D array indexed as [z][x]
|
|
114
|
+
def surface_blocks
|
|
115
|
+
@surface_blocks ||= begin
|
|
116
|
+
grid = Array.new(CHUNK_DEPTH) { Array.new(CHUNK_WIDTH) }
|
|
117
|
+
|
|
118
|
+
CHUNK_DEPTH.times do |z|
|
|
119
|
+
CHUNK_WIDTH.times do |x|
|
|
120
|
+
grid[z][x] = surface_at(x, z)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
grid
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Returns a simple top-down view of the chunk as a 2D array
|
|
129
|
+
# Each cell contains the name of the topmost visible block at that X,Z position
|
|
130
|
+
def top_down_view
|
|
131
|
+
@top_down_view ||= generate_top_down_view
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def to_ascii_map
|
|
135
|
+
view = top_down_view
|
|
136
|
+
return "No block data" if view.empty?
|
|
137
|
+
|
|
138
|
+
lines = []
|
|
139
|
+
|
|
140
|
+
view.each do |row|
|
|
141
|
+
line = row.map do |block|
|
|
142
|
+
case block
|
|
143
|
+
when /Grass/ then "G"
|
|
144
|
+
when /Dirt/ then "D"
|
|
145
|
+
when /Stone/ then "S"
|
|
146
|
+
when /Rock/ then "R"
|
|
147
|
+
when /Water/ then "~"
|
|
148
|
+
when /Sand/ then "."
|
|
149
|
+
when /Wood/ then "W"
|
|
150
|
+
when /Plant/ then "P"
|
|
151
|
+
when /Empty/, nil then " "
|
|
152
|
+
else "?"
|
|
153
|
+
end
|
|
154
|
+
end.join
|
|
155
|
+
|
|
156
|
+
lines << line
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
lines.join("\n")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Render chunk to PNG file
|
|
163
|
+
# @param path [String] Output file path
|
|
164
|
+
# @param scale [Integer] Scale factor (1 = 16x16 pixels)
|
|
165
|
+
# @param detailed [Boolean] If true, render each block individually
|
|
166
|
+
# @param shading [Boolean] If true, apply height-based shading for depth visualization
|
|
167
|
+
def render_to_png(path, scale: 1, detailed: true, shading: true)
|
|
168
|
+
renderer = Renderer.new
|
|
169
|
+
renderer.save_chunk(self, path, scale: scale, detailed: detailed, shading: shading)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Render chunk using actual block textures
|
|
173
|
+
# @param path [String, nil] Output file path (nil = use cache path)
|
|
174
|
+
# @param texture_scale [Integer] Pixels per block (16 = full texture resolution)
|
|
175
|
+
# @param shading [Boolean] If true, apply height-based shading
|
|
176
|
+
# @param cache [Boolean] If true, use cached image if available
|
|
177
|
+
def render_textured(path = nil, texture_scale: 16, shading: true, cache: true)
|
|
178
|
+
path ||= cache_path(texture_scale: texture_scale, shading: shading)
|
|
179
|
+
|
|
180
|
+
return path if cache && File.exist?(path)
|
|
181
|
+
|
|
182
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
183
|
+
|
|
184
|
+
renderer = Renderer.new
|
|
185
|
+
png = renderer.render_chunk_textured(self, texture_scale: texture_scale, shading: shading)
|
|
186
|
+
png.save(path)
|
|
187
|
+
|
|
188
|
+
path
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Returns the cache path for this chunk's rendered image
|
|
192
|
+
# @param texture_scale [Integer] Texture scale used
|
|
193
|
+
# @param shading [Boolean] Whether shading is enabled
|
|
194
|
+
# @return [String] Cache file path
|
|
195
|
+
def cache_path(texture_scale: 16, shading: true)
|
|
196
|
+
return nil unless region
|
|
197
|
+
|
|
198
|
+
save_name = region.path.split("/").find { |p| p.include?("Saves") }&.then { |_| region.path.split("Saves/")[1]&.split("/")&.first } || "unknown"
|
|
199
|
+
world_name = region.path.split("/worlds/")[1]&.split("/")&.first || "default"
|
|
200
|
+
|
|
201
|
+
cache_dir = File.join(
|
|
202
|
+
Dir.tmpdir,
|
|
203
|
+
"hytale_cache",
|
|
204
|
+
save_name,
|
|
205
|
+
world_name,
|
|
206
|
+
"regions",
|
|
207
|
+
"#{region.x}_#{region.z}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
shading_suffix = shading ? "" : "_noshade"
|
|
211
|
+
filename = "chunk_#{local_x}_#{local_z}_#{texture_scale}x#{shading_suffix}.png"
|
|
212
|
+
|
|
213
|
+
File.join(cache_dir, filename)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def cached?(texture_scale: 16, shading: true)
|
|
217
|
+
path = cache_path(texture_scale: texture_scale, shading: shading)
|
|
218
|
+
|
|
219
|
+
path && File.exist?(path)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def clear_cache!
|
|
223
|
+
path = cache_path
|
|
224
|
+
|
|
225
|
+
return unless path
|
|
226
|
+
|
|
227
|
+
dir = File.dirname(path)
|
|
228
|
+
|
|
229
|
+
FileUtils.rm_rf(dir) if File.directory?(dir)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Returns a Block instance at local coordinates (x, y, z) within this chunk
|
|
233
|
+
# x, z: 0-15 (horizontal position within chunk)
|
|
234
|
+
# y: 0-N (vertical position, 0 = bottom)
|
|
235
|
+
#
|
|
236
|
+
# @return [Block, nil] Block instance or nil if out of bounds
|
|
237
|
+
def block_at(x, y, z)
|
|
238
|
+
type_id = block_type_at(x, y, z)
|
|
239
|
+
return nil unless type_id
|
|
240
|
+
|
|
241
|
+
block_type = get_or_create_block_type(type_id)
|
|
242
|
+
|
|
243
|
+
Block.new(block_type, x: x, y: y, z: z, chunk: self)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Returns the block type ID (string) at local coordinates
|
|
247
|
+
# Use this for performance-critical code that doesn't need Block instances
|
|
248
|
+
#
|
|
249
|
+
# @return [String, nil] Block type ID (e.g., "Rock_Stone") or nil
|
|
250
|
+
def block_type_at(x, y, z)
|
|
251
|
+
return nil unless x.between?(0, CHUNK_WIDTH - 1)
|
|
252
|
+
return nil unless z.between?(0, CHUNK_DEPTH - 1)
|
|
253
|
+
|
|
254
|
+
parsed = parsed_block_data
|
|
255
|
+
return nil unless parsed
|
|
256
|
+
|
|
257
|
+
palette = parsed[:palette]
|
|
258
|
+
block_data = parsed[:block_data]
|
|
259
|
+
|
|
260
|
+
return nil if y.negative? || y >= parsed[:height]
|
|
261
|
+
|
|
262
|
+
block_index = (z * CHUNK_WIDTH) + x
|
|
263
|
+
|
|
264
|
+
# Encoding depends on palette size:
|
|
265
|
+
# - Palette <= 16: 4-bit encoding (128 bytes per layer, 2 blocks per byte)
|
|
266
|
+
# - Palette > 16: 8-bit encoding (256 bytes per layer, 1 block per byte)
|
|
267
|
+
if palette.size <= 16
|
|
268
|
+
layer_offset = y * 128
|
|
269
|
+
byte_offset = layer_offset + (block_index / 2)
|
|
270
|
+
|
|
271
|
+
return nil if byte_offset >= block_data.size
|
|
272
|
+
|
|
273
|
+
byte = block_data[byte_offset].ord
|
|
274
|
+
index = if block_index.even?
|
|
275
|
+
byte & 0x0F
|
|
276
|
+
else
|
|
277
|
+
(byte >> 4) & 0x0F
|
|
278
|
+
end
|
|
279
|
+
else
|
|
280
|
+
layer_offset = y * 256
|
|
281
|
+
byte_offset = layer_offset + block_index
|
|
282
|
+
|
|
283
|
+
return nil if byte_offset >= block_data.size
|
|
284
|
+
|
|
285
|
+
index = block_data[byte_offset].ord
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
return nil if index.zero? # Index 0 is always air/void
|
|
289
|
+
|
|
290
|
+
palette[index]
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Returns the height (number of Y layers) in this chunk section
|
|
294
|
+
def height
|
|
295
|
+
parsed = parsed_block_data
|
|
296
|
+
return 0 unless parsed
|
|
297
|
+
|
|
298
|
+
parsed[:height]
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Finds the highest non-empty block at the given X, Z position
|
|
302
|
+
#
|
|
303
|
+
# @return [Block, nil] The surface Block instance or nil if none found
|
|
304
|
+
def surface_at(x, z)
|
|
305
|
+
return nil unless x.between?(0, CHUNK_WIDTH - 1)
|
|
306
|
+
return nil unless z.between?(0, CHUNK_DEPTH - 1)
|
|
307
|
+
|
|
308
|
+
(height - 1).downto(0) do |y|
|
|
309
|
+
type_id = block_type_at(x, y, z)
|
|
310
|
+
next if type_id.nil? || type_id == "Empty" || type_id.start_with?("Air")
|
|
311
|
+
|
|
312
|
+
block_type = get_or_create_block_type(type_id)
|
|
313
|
+
return Block.new(block_type, x: x, y: y, z: z, chunk: self)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
nil
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def parsed_block_data
|
|
320
|
+
@parsed_block_data ||= parse_block_data_structure
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
private
|
|
324
|
+
|
|
325
|
+
def block_type_cache
|
|
326
|
+
@block_type_cache ||= {}
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def get_or_create_block_type(type_id)
|
|
330
|
+
block_type_cache[type_id] ||= BlockType.new(type_id)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Parses the BSON-like block data structure
|
|
334
|
+
# Format:
|
|
335
|
+
# Header (9 bytes): 00 00 00 0a 01 00 [palette_count] 00 00
|
|
336
|
+
# Palette entries: [length 1B] [name] [metadata 4B with index at byte 2]
|
|
337
|
+
# Block data: 4-bit packed indices (128 bytes per Y layer)
|
|
338
|
+
#
|
|
339
|
+
# Chunks contain multiple sections for different Y ranges. This method
|
|
340
|
+
# finds the section containing surface blocks (Grass, Soil_Dirt, etc.)
|
|
341
|
+
# for accurate terrain rendering.
|
|
342
|
+
def parse_block_data_structure
|
|
343
|
+
sections = find_all_block_sections
|
|
344
|
+
return nil if sections.empty?
|
|
345
|
+
|
|
346
|
+
# Sections are ordered by Y-level (higher index = higher elevation)
|
|
347
|
+
# Prefer the LAST (highest) section with surface blocks
|
|
348
|
+
surface_sections = sections.select { |s| s[:has_surface_blocks] }
|
|
349
|
+
|
|
350
|
+
section = if surface_sections.any?
|
|
351
|
+
surface_sections.last
|
|
352
|
+
else
|
|
353
|
+
sections.max_by { |s| s[:data_size] }
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
parse_section_data(section[:data_marker], section[:data_size])
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def find_all_block_sections
|
|
360
|
+
sections = []
|
|
361
|
+
position = 0
|
|
362
|
+
|
|
363
|
+
while (block_marker = data.index("\x03Block\x00", position))
|
|
364
|
+
data_marker = data.index("\x05Data\x00", block_marker)
|
|
365
|
+
break unless data_marker && data_marker < block_marker + 100
|
|
366
|
+
|
|
367
|
+
data_size = begin
|
|
368
|
+
data[data_marker + 6, 4].unpack1("V")
|
|
369
|
+
rescue StandardError
|
|
370
|
+
0
|
|
371
|
+
end
|
|
372
|
+
next if data_size.zero?
|
|
373
|
+
|
|
374
|
+
position = block_marker + 1
|
|
375
|
+
|
|
376
|
+
raw_data = data[data_marker + 11, [data_size, 500].min]
|
|
377
|
+
has_surface = raw_data&.match?(/Soil_Grass|Soil_Dirt[^_]|Soil_Pathway/)
|
|
378
|
+
|
|
379
|
+
sections << {
|
|
380
|
+
block_marker: block_marker,
|
|
381
|
+
data_marker: data_marker,
|
|
382
|
+
data_size: data_size,
|
|
383
|
+
has_surface_blocks: has_surface,
|
|
384
|
+
}
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
sections
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def parse_section_data(data_marker, data_size)
|
|
391
|
+
raw_data = data[data_marker + 11, data_size]
|
|
392
|
+
return nil unless raw_data && raw_data.size > 20
|
|
393
|
+
|
|
394
|
+
palette_count = raw_data[6].ord
|
|
395
|
+
return nil if palette_count.zero? || palette_count > 64
|
|
396
|
+
|
|
397
|
+
# Parse palette entries starting at offset 9
|
|
398
|
+
# Index 0 is implicitly air/void (no block data)
|
|
399
|
+
offset = 9
|
|
400
|
+
palette = {}
|
|
401
|
+
|
|
402
|
+
palette_count.times do
|
|
403
|
+
break if offset >= raw_data.size - 10
|
|
404
|
+
|
|
405
|
+
len = raw_data[offset].ord
|
|
406
|
+
break if len.zero? || len > 100
|
|
407
|
+
|
|
408
|
+
name = raw_data[offset + 1, len]
|
|
409
|
+
meta = raw_data[offset + 1 + len, 4]
|
|
410
|
+
break unless meta && meta.size >= 3
|
|
411
|
+
|
|
412
|
+
# Index is at byte 2 of the 4-byte metadata
|
|
413
|
+
index = meta[2].ord
|
|
414
|
+
palette[index] = name if index < 256
|
|
415
|
+
|
|
416
|
+
offset += 1 + len + 4
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
block_data = raw_data[offset..]
|
|
420
|
+
return nil unless block_data&.size&.positive?
|
|
421
|
+
|
|
422
|
+
# Calculate height based on encoding:
|
|
423
|
+
# - Palette <= 16: 4-bit encoding (128 bytes per layer)
|
|
424
|
+
# - Palette > 16: 8-bit encoding (256 bytes per layer)
|
|
425
|
+
bytes_per_layer = palette.size <= 16 ? 128 : 256
|
|
426
|
+
height = block_data.size / bytes_per_layer
|
|
427
|
+
|
|
428
|
+
{
|
|
429
|
+
palette: palette,
|
|
430
|
+
block_data: block_data,
|
|
431
|
+
height: height,
|
|
432
|
+
}
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def extract_block_types
|
|
436
|
+
types = Set.new
|
|
437
|
+
|
|
438
|
+
data.scan(/(?:Rock|Soil|Water|Plant|Wood|Ore|Sand|Stone|Env|Air|Grass|Snow|Ice|Lava|Clay|Metal|Crystal|Fungi)_[A-Za-z_0-9]+/) do |match|
|
|
439
|
+
types << match
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
types.to_a.sort
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def extract_palette
|
|
446
|
+
palette = []
|
|
447
|
+
position = 0
|
|
448
|
+
|
|
449
|
+
while position < data.size - 2
|
|
450
|
+
length = begin
|
|
451
|
+
data[position].ord
|
|
452
|
+
rescue StandardError
|
|
453
|
+
0
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
if length.positive? && length < 64 && position + 1 + length <= data.size
|
|
457
|
+
string = data[position + 1, length]
|
|
458
|
+
|
|
459
|
+
if string =~ /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
460
|
+
palette << string
|
|
461
|
+
position += 1 + length
|
|
462
|
+
|
|
463
|
+
next
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
position += 1
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
palette.uniq
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# Parse the "ChunkSection" blocks from the chunk data
|
|
474
|
+
# Each section represents a 16x16x16 vertical slice
|
|
475
|
+
def parse_sections
|
|
476
|
+
sections = []
|
|
477
|
+
|
|
478
|
+
block_sections = find_block_data_sections
|
|
479
|
+
sections.concat(block_sections)
|
|
480
|
+
|
|
481
|
+
sections
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def find_block_data_sections
|
|
485
|
+
sections = []
|
|
486
|
+
position = 0
|
|
487
|
+
|
|
488
|
+
while position < data.size - 100
|
|
489
|
+
block_marker = data.index("Block", position)
|
|
490
|
+
break unless block_marker
|
|
491
|
+
|
|
492
|
+
data_marker = data.index("Data", block_marker)
|
|
493
|
+
|
|
494
|
+
if data_marker && data_marker < block_marker + 100
|
|
495
|
+
section = parse_block_data_section(data_marker)
|
|
496
|
+
|
|
497
|
+
sections << section if section
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
position = block_marker + 1
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
sections
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# After "Data" there's a size marker and then the block data
|
|
507
|
+
# Format appears to be: "Data" + 0x00 + size(4 bytes) + 0x00*4 + palette + block_data
|
|
508
|
+
def parse_block_data_section(data_offset)
|
|
509
|
+
position = data_offset + 4
|
|
510
|
+
return nil if position + 4 >= data.size
|
|
511
|
+
|
|
512
|
+
size = begin
|
|
513
|
+
data[position + 1, 4].unpack1("L<")
|
|
514
|
+
rescue StandardError
|
|
515
|
+
0
|
|
516
|
+
end
|
|
517
|
+
return nil if size.zero? || size > 100_000
|
|
518
|
+
|
|
519
|
+
palette = {}
|
|
520
|
+
palette_end = nil
|
|
521
|
+
|
|
522
|
+
search_start = position
|
|
523
|
+
search_end = [position + size + 100, data.size].min
|
|
524
|
+
search_data = data[search_start...search_end]
|
|
525
|
+
|
|
526
|
+
search_data.scan(/([A-Za-z]+_[A-Za-z_]+)[\x00-\x10]/) do |match|
|
|
527
|
+
name = match[0]
|
|
528
|
+
next unless name =~ /\A(Rock|Soil|Water|Plant|Wood|Ore|Sand|Stone|Env|Air|Grass|Snow|Ice|Empty)_/
|
|
529
|
+
|
|
530
|
+
match_position = search_data.index(name)
|
|
531
|
+
next unless match_position
|
|
532
|
+
|
|
533
|
+
index_position = match_position + name.length
|
|
534
|
+
|
|
535
|
+
if index_position + 4 < search_data.size
|
|
536
|
+
index_data = search_data[index_position, 4]
|
|
537
|
+
index = begin
|
|
538
|
+
index_data.bytes[2]
|
|
539
|
+
rescue StandardError
|
|
540
|
+
nil
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
palette[index] = name if index
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
palette_end = search_start + match_position + name.length + 4
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
palette[1] ||= "Empty" if search_data.include?("Empty")
|
|
550
|
+
|
|
551
|
+
{
|
|
552
|
+
offset: data_offset,
|
|
553
|
+
size: size,
|
|
554
|
+
palette: palette,
|
|
555
|
+
palette_end: palette_end,
|
|
556
|
+
}
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Create a 16x16 grid for the top-down view
|
|
560
|
+
def generate_top_down_view
|
|
561
|
+
view = Array.new(CHUNK_DEPTH) { Array.new(CHUNK_WIDTH) }
|
|
562
|
+
|
|
563
|
+
palette = build_primary_palette
|
|
564
|
+
return view if palette.empty?
|
|
565
|
+
|
|
566
|
+
block_data_areas = find_block_data_arrays
|
|
567
|
+
|
|
568
|
+
block_data_areas.each do |area|
|
|
569
|
+
next unless area[:data] && area[:palette]
|
|
570
|
+
|
|
571
|
+
fill_view_from_data(view, area)
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
view
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def build_primary_palette
|
|
578
|
+
palette = {}
|
|
579
|
+
|
|
580
|
+
block_types.each_with_index do |name, index|
|
|
581
|
+
palette[index] = name
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
palette
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Find areas in the chunk that contain block index data
|
|
588
|
+
# These are typically after a palette section and contain repeated byte values
|
|
589
|
+
def find_block_data_arrays
|
|
590
|
+
areas = []
|
|
591
|
+
|
|
592
|
+
block_types.each do |block_type|
|
|
593
|
+
type_position = data.index(block_type)
|
|
594
|
+
next unless type_position
|
|
595
|
+
|
|
596
|
+
after_type = type_position + block_type.length + 10
|
|
597
|
+
|
|
598
|
+
next unless after_type < data.size - 256
|
|
599
|
+
|
|
600
|
+
sample = data[after_type, 256]
|
|
601
|
+
byte_counts = Hash.new(0)
|
|
602
|
+
sample.bytes.each { |b| byte_counts[b] += 1 }
|
|
603
|
+
|
|
604
|
+
max_count = byte_counts.values.max || 0
|
|
605
|
+
|
|
606
|
+
next unless max_count > 128
|
|
607
|
+
|
|
608
|
+
dominant_byte = byte_counts.key(max_count)
|
|
609
|
+
|
|
610
|
+
areas << {
|
|
611
|
+
offset: after_type,
|
|
612
|
+
dominant_value: dominant_byte,
|
|
613
|
+
palette: build_section_palette(type_position),
|
|
614
|
+
data: sample,
|
|
615
|
+
}
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
areas
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def build_section_palette(section_start)
|
|
622
|
+
palette = {}
|
|
623
|
+
|
|
624
|
+
search_start = [0, section_start - 100].max
|
|
625
|
+
search_end = [section_start + 200, data.size].min
|
|
626
|
+
search_data = data[search_start...search_end]
|
|
627
|
+
|
|
628
|
+
position = 0
|
|
629
|
+
|
|
630
|
+
while position < search_data.size - 10
|
|
631
|
+
length = begin
|
|
632
|
+
search_data[position].ord
|
|
633
|
+
rescue StandardError
|
|
634
|
+
0
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
if length > 4 && length < 30
|
|
638
|
+
string = begin
|
|
639
|
+
search_data[position + 1, length]
|
|
640
|
+
rescue StandardError
|
|
641
|
+
""
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
if string =~ /\A(Rock|Soil|Water|Plant|Wood|Ore|Sand|Stone|Env|Air|Grass|Snow|Ice|Empty)_[A-Za-z_]*\z/ || string == "Empty"
|
|
645
|
+
index_position = position + 1 + length + 2
|
|
646
|
+
index = begin
|
|
647
|
+
search_data[index_position].ord
|
|
648
|
+
rescue StandardError # rubocop:disable Metrics/BlockNesting
|
|
649
|
+
nil
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
palette[index] = str if index && index < 16
|
|
653
|
+
position += length + 5
|
|
654
|
+
|
|
655
|
+
next
|
|
656
|
+
end
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
position += 1
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
palette
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
# Fill the 16x16 view from packed block data
|
|
666
|
+
# Block data uses 2 bits per block when palette has <=4 entries
|
|
667
|
+
def fill_view_from_data(view, area)
|
|
668
|
+
palette = area[:palette]
|
|
669
|
+
return if palette.empty?
|
|
670
|
+
|
|
671
|
+
bits_per_block = calculate_bits_per_block(palette.size)
|
|
672
|
+
return if bits_per_block.zero?
|
|
673
|
+
|
|
674
|
+
dominant_block = palette.values.find { |n| n =~ /Grass|Soil|Rock|Stone/ } || palette.values.first
|
|
675
|
+
|
|
676
|
+
CHUNK_DEPTH.times do |z|
|
|
677
|
+
CHUNK_WIDTH.times do |x|
|
|
678
|
+
view[z][x] ||= dominant_block
|
|
679
|
+
end
|
|
680
|
+
end
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
def calculate_bits_per_block(palette_size)
|
|
684
|
+
case palette_size
|
|
685
|
+
when 0 then 0
|
|
686
|
+
when 1..2 then 1
|
|
687
|
+
when 3..4 then 2
|
|
688
|
+
when 5..16 then 4
|
|
689
|
+
else 8
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
end
|
|
695
|
+
end
|