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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +21 -0
  3. data/README.md +1430 -15
  4. data/exe/hytale +497 -0
  5. data/lib/hytale/client/assets.rb +207 -0
  6. data/lib/hytale/client/block_type.rb +169 -0
  7. data/lib/hytale/client/config.rb +98 -0
  8. data/lib/hytale/client/cosmetics.rb +95 -0
  9. data/lib/hytale/client/item_type.rb +248 -0
  10. data/lib/hytale/client/launcher_log/launcher_log_entry.rb +58 -0
  11. data/lib/hytale/client/launcher_log/launcher_log_session.rb +57 -0
  12. data/lib/hytale/client/launcher_log.rb +99 -0
  13. data/lib/hytale/client/locale.rb +234 -0
  14. data/lib/hytale/client/map/block.rb +135 -0
  15. data/lib/hytale/client/map/chunk.rb +695 -0
  16. data/lib/hytale/client/map/marker.rb +50 -0
  17. data/lib/hytale/client/map/region.rb +278 -0
  18. data/lib/hytale/client/map/renderer.rb +435 -0
  19. data/lib/hytale/client/map.rb +271 -0
  20. data/lib/hytale/client/memories.rb +59 -0
  21. data/lib/hytale/client/npc_memory.rb +39 -0
  22. data/lib/hytale/client/permissions.rb +52 -0
  23. data/lib/hytale/client/player/entity_stats.rb +46 -0
  24. data/lib/hytale/client/player/inventory.rb +54 -0
  25. data/lib/hytale/client/player/item.rb +102 -0
  26. data/lib/hytale/client/player/item_storage.rb +40 -0
  27. data/lib/hytale/client/player/player_memory.rb +29 -0
  28. data/lib/hytale/client/player/position.rb +12 -0
  29. data/lib/hytale/client/player/rotation.rb +11 -0
  30. data/lib/hytale/client/player/vector3.rb +12 -0
  31. data/lib/hytale/client/player.rb +101 -0
  32. data/lib/hytale/client/player_skin.rb +179 -0
  33. data/lib/hytale/client/prefab/palette_entry.rb +49 -0
  34. data/lib/hytale/client/prefab.rb +184 -0
  35. data/lib/hytale/client/process.rb +57 -0
  36. data/lib/hytale/client/save/backup.rb +43 -0
  37. data/lib/hytale/client/save/server_log.rb +42 -0
  38. data/lib/hytale/client/save.rb +157 -0
  39. data/lib/hytale/client/settings/audio_settings.rb +30 -0
  40. data/lib/hytale/client/settings/builder_tools_settings.rb +27 -0
  41. data/lib/hytale/client/settings/gameplay_settings.rb +23 -0
  42. data/lib/hytale/client/settings/input_bindings.rb +25 -0
  43. data/lib/hytale/client/settings/mouse_settings.rb +23 -0
  44. data/lib/hytale/client/settings/rendering_settings.rb +30 -0
  45. data/lib/hytale/client/settings.rb +74 -0
  46. data/lib/hytale/client/world/client_effects.rb +24 -0
  47. data/lib/hytale/client/world/death_settings.rb +22 -0
  48. data/lib/hytale/client/world.rb +88 -0
  49. data/lib/hytale/client/zone/region.rb +52 -0
  50. data/lib/hytale/client/zone.rb +68 -0
  51. data/lib/hytale/client.rb +142 -0
  52. data/lib/hytale/server/process.rb +11 -0
  53. data/lib/hytale/server.rb +6 -0
  54. data/lib/hytale/version.rb +1 -1
  55. data/lib/hytale.rb +37 -2
  56. 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