hytale 0.0.1 → 0.1.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +21 -0
  3. data/README.md +1315 -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 +55 -0
  12. data/lib/hytale/client/launcher_log.rb +94 -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 +693 -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 +437 -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 +46 -0
  25. data/lib/hytale/client/player/item.rb +102 -0
  26. data/lib/hytale/client/player/item_storage.rb +32 -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 +99 -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 +81 -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.rb +142 -0
  50. data/lib/hytale/server/process.rb +8 -0
  51. data/lib/hytale/server.rb +6 -0
  52. data/lib/hytale/version.rb +1 -1
  53. data/lib/hytale.rb +37 -2
  54. metadata +117 -10
@@ -0,0 +1,693 @@
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 has_block?(block_type)
67
+ block_types.include?(block_type)
68
+ end
69
+
70
+ def has_water?
71
+ block_types.any? { |t| t.include?("Water") }
72
+ end
73
+
74
+ def has_vegetation?
75
+ block_types.any? { |t| t.include?("Plant") || t.include?("Grass") || t.include?("Tree") }
76
+ end
77
+
78
+ def terrain_type
79
+ if has_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 has_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 do |p|
199
+ p.include?("Saves")
200
+ end&.then { |_| region.path.split("Saves/")[1]&.split("/")&.first } || "unknown"
201
+ world_name = region.path.split("/worlds/")[1]&.split("/")&.first || "default"
202
+
203
+ cache_dir = File.join(
204
+ Dir.tmpdir,
205
+ "hytale_cache",
206
+ save_name,
207
+ world_name,
208
+ "regions",
209
+ "#{region.x}_#{region.z}"
210
+ )
211
+
212
+ shading_suffix = shading ? "" : "_noshade"
213
+ filename = "chunk_#{local_x}_#{local_z}_#{texture_scale}x#{shading_suffix}.png"
214
+
215
+ File.join(cache_dir, filename)
216
+ end
217
+
218
+ def cached?(texture_scale: 16, shading: true)
219
+ path = cache_path(texture_scale: texture_scale, shading: shading)
220
+ path && File.exist?(path)
221
+ end
222
+
223
+ def clear_cache!
224
+ path = cache_path
225
+ return unless path
226
+
227
+ dir = File.dirname(path)
228
+ FileUtils.rm_rf(dir) if File.directory?(dir)
229
+ end
230
+
231
+ # Returns a Block instance at local coordinates (x, y, z) within this chunk
232
+ # x, z: 0-15 (horizontal position within chunk)
233
+ # y: 0-N (vertical position, 0 = bottom)
234
+ #
235
+ # @return [Block, nil] Block instance or nil if out of bounds
236
+ def block_at(x, y, z)
237
+ type_id = block_type_at(x, y, z)
238
+ return nil unless type_id
239
+
240
+ block_type = get_or_create_block_type(type_id)
241
+ Block.new(block_type, x: x, y: y, z: z, chunk: self)
242
+ end
243
+
244
+ # Returns the block type ID (string) at local coordinates
245
+ # Use this for performance-critical code that doesn't need Block instances
246
+ #
247
+ # @return [String, nil] Block type ID (e.g., "Rock_Stone") or nil
248
+ def block_type_at(x, y, z)
249
+ return nil unless x.between?(0, CHUNK_WIDTH - 1)
250
+ return nil unless z.between?(0, CHUNK_DEPTH - 1)
251
+
252
+ parsed = parsed_block_data
253
+ return nil unless parsed
254
+
255
+ palette = parsed[:palette]
256
+ block_data = parsed[:block_data]
257
+
258
+ return nil if y.negative? || y >= parsed[:height]
259
+
260
+ block_index = (z * CHUNK_WIDTH) + x
261
+
262
+ # Encoding depends on palette size:
263
+ # - Palette <= 16: 4-bit encoding (128 bytes per layer, 2 blocks per byte)
264
+ # - Palette > 16: 8-bit encoding (256 bytes per layer, 1 block per byte)
265
+ if palette.size <= 16
266
+ layer_offset = y * 128
267
+ byte_offset = layer_offset + (block_index / 2)
268
+
269
+ return nil if byte_offset >= block_data.size
270
+
271
+ byte = block_data[byte_offset].ord
272
+ index = if block_index.even?
273
+ byte & 0x0F
274
+ else
275
+ (byte >> 4) & 0x0F
276
+ end
277
+ else
278
+ layer_offset = y * 256
279
+ byte_offset = layer_offset + block_index
280
+
281
+ return nil if byte_offset >= block_data.size
282
+
283
+ index = block_data[byte_offset].ord
284
+ end
285
+
286
+ return nil if index.zero? # Index 0 is always air/void
287
+
288
+ palette[index]
289
+ end
290
+
291
+ # Returns the height (number of Y layers) in this chunk section
292
+ def height
293
+ parsed = parsed_block_data
294
+ return 0 unless parsed
295
+
296
+ parsed[:height]
297
+ end
298
+
299
+ # Finds the highest non-empty block at the given X, Z position
300
+ #
301
+ # @return [Block, nil] The surface Block instance or nil if none found
302
+ def surface_at(x, z)
303
+ return nil unless x.between?(0, CHUNK_WIDTH - 1)
304
+ return nil unless z.between?(0, CHUNK_DEPTH - 1)
305
+
306
+ (height - 1).downto(0) do |y|
307
+ type_id = block_type_at(x, y, z)
308
+ next if type_id.nil? || type_id == "Empty" || type_id.start_with?("Air")
309
+
310
+ block_type = get_or_create_block_type(type_id)
311
+ return Block.new(block_type, x: x, y: y, z: z, chunk: self)
312
+ end
313
+
314
+ nil
315
+ end
316
+
317
+ def parsed_block_data
318
+ @parsed_block_data ||= parse_block_data_structure
319
+ end
320
+
321
+ private
322
+
323
+ def block_type_cache
324
+ @block_type_cache ||= {}
325
+ end
326
+
327
+ def get_or_create_block_type(type_id)
328
+ block_type_cache[type_id] ||= BlockType.new(type_id)
329
+ end
330
+
331
+ # Parses the BSON-like block data structure
332
+ # Format:
333
+ # Header (9 bytes): 00 00 00 0a 01 00 [palette_count] 00 00
334
+ # Palette entries: [length 1B] [name] [metadata 4B with index at byte 2]
335
+ # Block data: 4-bit packed indices (128 bytes per Y layer)
336
+ #
337
+ # Chunks contain multiple sections for different Y ranges. This method
338
+ # finds the section containing surface blocks (Grass, Soil_Dirt, etc.)
339
+ # for accurate terrain rendering.
340
+ def parse_block_data_structure
341
+ sections = find_all_block_sections
342
+ return nil if sections.empty?
343
+
344
+ # Sections are ordered by Y-level (higher index = higher elevation)
345
+ # Prefer the LAST (highest) section with surface blocks
346
+ surface_sections = sections.select { |s| s[:has_surface_blocks] }
347
+
348
+ section = if surface_sections.any?
349
+ surface_sections.last
350
+ else
351
+ sections.max_by { |s| s[:data_size] }
352
+ end
353
+
354
+ parse_section_data(section[:data_marker], section[:data_size])
355
+ end
356
+
357
+ def find_all_block_sections
358
+ sections = []
359
+ position = 0
360
+
361
+ while (block_marker = data.index("\x03Block\x00", position))
362
+ data_marker = data.index("\x05Data\x00", block_marker)
363
+ break unless data_marker && data_marker < block_marker + 100
364
+
365
+ data_size = begin
366
+ data[data_marker + 6, 4].unpack1("V")
367
+ rescue StandardError
368
+ 0
369
+ end
370
+ next if data_size.zero?
371
+
372
+ position = block_marker + 1
373
+
374
+ raw_data = data[data_marker + 11, [data_size, 500].min]
375
+ has_surface = raw_data&.match?(/Soil_Grass|Soil_Dirt[^_]|Soil_Pathway/)
376
+
377
+ sections << {
378
+ block_marker: block_marker,
379
+ data_marker: data_marker,
380
+ data_size: data_size,
381
+ has_surface_blocks: has_surface,
382
+ }
383
+ end
384
+
385
+ sections
386
+ end
387
+
388
+ def parse_section_data(data_marker, data_size)
389
+ raw_data = data[data_marker + 11, data_size]
390
+ return nil unless raw_data && raw_data.size > 20
391
+
392
+ palette_count = raw_data[6].ord
393
+ return nil if palette_count.zero? || palette_count > 64
394
+
395
+ # Parse palette entries starting at offset 9
396
+ # Index 0 is implicitly air/void (no block data)
397
+ offset = 9
398
+ palette = {}
399
+
400
+ palette_count.times do
401
+ break if offset >= raw_data.size - 10
402
+
403
+ len = raw_data[offset].ord
404
+ break if len.zero? || len > 100
405
+
406
+ name = raw_data[offset + 1, len]
407
+ meta = raw_data[offset + 1 + len, 4]
408
+ break unless meta && meta.size >= 3
409
+
410
+ # Index is at byte 2 of the 4-byte metadata
411
+ index = meta[2].ord
412
+ palette[index] = name if index < 256
413
+
414
+ offset += 1 + len + 4
415
+ end
416
+
417
+ block_data = raw_data[offset..]
418
+ return nil unless block_data&.size&.positive?
419
+
420
+ # Calculate height based on encoding:
421
+ # - Palette <= 16: 4-bit encoding (128 bytes per layer)
422
+ # - Palette > 16: 8-bit encoding (256 bytes per layer)
423
+ bytes_per_layer = palette.size <= 16 ? 128 : 256
424
+ height = block_data.size / bytes_per_layer
425
+
426
+ {
427
+ palette: palette,
428
+ block_data: block_data,
429
+ height: height,
430
+ }
431
+ end
432
+
433
+ def extract_block_types
434
+ types = Set.new
435
+
436
+ 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|
437
+ types << match
438
+ end
439
+
440
+ types.to_a.sort
441
+ end
442
+
443
+ def extract_palette
444
+ palette = []
445
+ position = 0
446
+
447
+ while position < data.size - 2
448
+ length = begin
449
+ data[position].ord
450
+ rescue StandardError
451
+ 0
452
+ end
453
+
454
+ if length.positive? && length < 64 && position + 1 + length <= data.size
455
+ string = data[position + 1, length]
456
+
457
+ if string =~ /\A[A-Za-z_][A-Za-z0-9_]*\z/
458
+ palette << string
459
+ position += 1 + length
460
+
461
+ next
462
+ end
463
+ end
464
+
465
+ position += 1
466
+ end
467
+
468
+ palette.uniq
469
+ end
470
+
471
+ # Parse the "ChunkSection" blocks from the chunk data
472
+ # Each section represents a 16x16x16 vertical slice
473
+ def parse_sections
474
+ sections = []
475
+
476
+ block_sections = find_block_data_sections
477
+ sections.concat(block_sections)
478
+
479
+ sections
480
+ end
481
+
482
+ def find_block_data_sections
483
+ sections = []
484
+ position = 0
485
+
486
+ while position < data.size - 100
487
+ block_marker = data.index("Block", position)
488
+ break unless block_marker
489
+
490
+ data_marker = data.index("Data", block_marker)
491
+
492
+ if data_marker && data_marker < block_marker + 100
493
+ section = parse_block_data_section(data_marker)
494
+
495
+ sections << section if section
496
+ end
497
+
498
+ position = block_marker + 1
499
+ end
500
+
501
+ sections
502
+ end
503
+
504
+ # After "Data" there's a size marker and then the block data
505
+ # Format appears to be: "Data" + 0x00 + size(4 bytes) + 0x00*4 + palette + block_data
506
+ def parse_block_data_section(data_offset)
507
+ position = data_offset + 4
508
+ return nil if position + 4 >= data.size
509
+
510
+ size = begin
511
+ data[position + 1, 4].unpack1("L<")
512
+ rescue StandardError
513
+ 0
514
+ end
515
+ return nil if size.zero? || size > 100_000
516
+
517
+ palette = {}
518
+ palette_end = nil
519
+
520
+ search_start = position
521
+ search_end = [position + size + 100, data.size].min
522
+ search_data = data[search_start...search_end]
523
+
524
+ search_data.scan(/([A-Za-z]+_[A-Za-z_]+)[\x00-\x10]/) do |match|
525
+ name = match[0]
526
+ next unless name =~ /\A(Rock|Soil|Water|Plant|Wood|Ore|Sand|Stone|Env|Air|Grass|Snow|Ice|Empty)_/
527
+
528
+ match_position = search_data.index(name)
529
+ next unless match_position
530
+
531
+ index_position = match_position + name.length
532
+
533
+ if index_position + 4 < search_data.size
534
+ index_data = search_data[index_position, 4]
535
+ index = begin
536
+ index_data.bytes[2]
537
+ rescue StandardError
538
+ nil
539
+ end
540
+
541
+ palette[index] = name if index
542
+ end
543
+
544
+ palette_end = search_start + match_position + name.length + 4
545
+ end
546
+
547
+ palette[1] ||= "Empty" if search_data.include?("Empty")
548
+
549
+ {
550
+ offset: data_offset,
551
+ size: size,
552
+ palette: palette,
553
+ palette_end: palette_end,
554
+ }
555
+ end
556
+
557
+ # Create a 16x16 grid for the top-down view
558
+ def generate_top_down_view
559
+ view = Array.new(CHUNK_DEPTH) { Array.new(CHUNK_WIDTH) }
560
+
561
+ palette = build_primary_palette
562
+ return view if palette.empty?
563
+
564
+ block_data_areas = find_block_data_arrays
565
+
566
+ block_data_areas.each do |area|
567
+ next unless area[:data] && area[:palette]
568
+
569
+ fill_view_from_data(view, area)
570
+ end
571
+
572
+ view
573
+ end
574
+
575
+ def build_primary_palette
576
+ palette = {}
577
+
578
+ block_types.each_with_index do |name, index|
579
+ palette[index] = name
580
+ end
581
+
582
+ palette
583
+ end
584
+
585
+ # Find areas in the chunk that contain block index data
586
+ # These are typically after a palette section and contain repeated byte values
587
+ def find_block_data_arrays
588
+ areas = []
589
+
590
+ block_types.each do |block_type|
591
+ type_position = data.index(block_type)
592
+ next unless type_position
593
+
594
+ after_type = type_position + block_type.length + 10
595
+
596
+ next unless after_type < data.size - 256
597
+
598
+ sample = data[after_type, 256]
599
+ byte_counts = Hash.new(0)
600
+ sample.bytes.each { |b| byte_counts[b] += 1 }
601
+
602
+ max_count = byte_counts.values.max || 0
603
+
604
+ next unless max_count > 128
605
+
606
+ dominant_byte = byte_counts.key(max_count)
607
+
608
+ areas << {
609
+ offset: after_type,
610
+ dominant_value: dominant_byte,
611
+ palette: build_section_palette(type_position),
612
+ data: sample,
613
+ }
614
+ end
615
+
616
+ areas
617
+ end
618
+
619
+ def build_section_palette(section_start)
620
+ palette = {}
621
+
622
+ search_start = [0, section_start - 100].max
623
+ search_end = [section_start + 200, data.size].min
624
+ search_data = data[search_start...search_end]
625
+
626
+ position = 0
627
+
628
+ while position < search_data.size - 10
629
+ length = begin
630
+ search_data[position].ord
631
+ rescue StandardError
632
+ 0
633
+ end
634
+
635
+ if length > 4 && length < 30
636
+ string = begin
637
+ search_data[position + 1, length]
638
+ rescue StandardError
639
+ ""
640
+ end
641
+
642
+ if string =~ /\A(Rock|Soil|Water|Plant|Wood|Ore|Sand|Stone|Env|Air|Grass|Snow|Ice|Empty)_[A-Za-z_]*\z/ || string == "Empty"
643
+ index_position = position + 1 + length + 2
644
+ index = begin
645
+ search_data[index_position].ord
646
+ rescue StandardError
647
+ nil
648
+ end
649
+
650
+ palette[index] = str if index && index < 16
651
+ position += length + 5
652
+
653
+ next
654
+ end
655
+ end
656
+
657
+ position += 1
658
+ end
659
+
660
+ palette
661
+ end
662
+
663
+ # Fill the 16x16 view from packed block data
664
+ # Block data uses 2 bits per block when palette has <=4 entries
665
+ def fill_view_from_data(view, area)
666
+ palette = area[:palette]
667
+ return if palette.empty?
668
+
669
+ bits_per_block = calculate_bits_per_block(palette.size)
670
+ return if bits_per_block.zero?
671
+
672
+ dominant_block = palette.values.find { |n| n =~ /Grass|Soil|Rock|Stone/ } || palette.values.first
673
+
674
+ CHUNK_DEPTH.times do |z|
675
+ CHUNK_WIDTH.times do |x|
676
+ view[z][x] ||= dominant_block
677
+ end
678
+ end
679
+ end
680
+
681
+ def calculate_bits_per_block(palette_size)
682
+ case palette_size
683
+ when 0 then 0
684
+ when 1..2 then 1
685
+ when 3..4 then 2
686
+ when 5..16 then 4
687
+ else 8
688
+ end
689
+ end
690
+ end
691
+ end
692
+ end
693
+ end