diff --git a/README.md b/README.md index c055205..a8503a3 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ I have mostly solved parsing of SNES menus in a sister project, however there ar - [ ] Weapon animations (will hardcode these) - [x] Enemy sprites - [ ] Enemy sprite separate shadows -- [ ] Backgrounds (solved problem, soon™) +- [x] Backgrounds - [ ] Enemy AI (needs research) - [ ] Abilities (will hardcode these, with fixes and extensions where appropriate) - [ ] Calculations (will hardcode these from Algorithms guide, with fixes and extensions where appropriate) diff --git a/data/SNES_PSX_addresses.tsv b/data/SNES_PSX_addresses.tsv index a5b39bc..a60311e 100644 --- a/data/SNES_PSX_addresses.tsv +++ b/data/SNES_PSX_addresses.tsv @@ -44,6 +44,15 @@ enemy_battle_sprite_data 0x14B180 384 of EnemySpriteData length 0x780 character_battle_sprite_layouts 0x14B997 /btl/ff5_btl.bin 0x028997 11 of 6 of u8 tbl_battle_backgrounds 0x14BA21 34 of BattleBackgroundData battle_background_palettes 0x14BB31 84 of Palette16Of555 +ptrs_battle_background_tile_animations 0x14C5B1 8 of u16 bank 0x140000 (0xD40000) +battle_background_tile_animations_data 0x14C5C1 see above +ptrs_battle_background_palette_animations 0x14C6CD 3 of u16 bank 0x140000 (0xD40000) +battle_background_palette_animations_data 0x14C6D3 see above +ptrs_battle_background_tilemap_flips 0x14C736 9 of u16 bank 0x140000 (0xD40000) +battle_background_tilemap_flips_data 0x14C75C see above +ptrs_battle_background_tilemaps 0x14C86D 28 of u16 bank 0x140000 (0xD40000) +? 0x14C8A5 +battle_background_tilemaps_data 0x14E09B see above enemy_battle_sprite_tiles 0x150000 See enemy_battle_sprite_data for pointers ptrs_battle_background_tileset_skips 0x184157 21 of u24 RAM addresses, subtract 0x7FC000 from results to get offset from tileset ptrs_battle_background_tilesets 0x184196 21 of u24 ROM addresses, subtract 0xC00000 diff --git a/data/SNES_other.tsv b/data/SNES_other.tsv index d6fd337..3f31556 100644 --- a/data/SNES_other.tsv +++ b/data/SNES_other.tsv @@ -130,9 +130,9 @@ u8 layout_id # Small? <<3, + 0x10D004, take 8 bytes. Large? <<5, + 0x10D334, tak struct BattleBackgroundData u8 tileset_id -u8 pal1_id -u8 pal2_id +2 of u8 palette_ids u8 tilemap_id u8 tilemap_flips_id +u8 tilemap_v_flips_id # Unused, all 0xFF = no flips u8 tilecycle_id u8 palcycle_id diff --git a/scripts/loaders/RomLoader.gd b/scripts/loaders/RomLoader.gd index 5457585..3a41483 100644 --- a/scripts/loaders/RomLoader.gd +++ b/scripts/loaders/RomLoader.gd @@ -68,9 +68,10 @@ func load_snes_rom(filename: String): var ability_learned: int = buffer.get_u8() ability_list.append({'ABP': abp_requirement, 'ability': ability_learned}) snes_data.job_levels.append(ability_list) - print(snes_data.job_levels) + #print(snes_data.job_levels) SpriteLoader.load_from_structs(snes_data) SpriteLoader.load_enemy_battle_sprites(snes_data, buffer) + SpriteLoader.load_battle_bgs(snes_data, buffer) MapLoader.load_snes_rom(rom_snes) func load_psx_folder(_dirname: String): diff --git a/scripts/loaders/SpriteLoader.gd b/scripts/loaders/SpriteLoader.gd index f4c1bfe..0a732aa 100644 --- a/scripts/loaders/SpriteLoader.gd +++ b/scripts/loaders/SpriteLoader.gd @@ -3,6 +3,7 @@ extends Node const INDEX_FORMAT := globals.INDEX_FORMAT const snes_graphics := preload('res://scripts/loaders/snes/graphics.gd') +const snes_battle_bgs := preload('res://scripts/loaders/snes/battle_bgs.gd') const gba_graphics := preload('res://scripts/loaders/gba/graphics.gd') var shader_material = load('res://palette_mat.tres') const TILE_RECT := Rect2(0, 0, 8, 8) @@ -369,6 +370,60 @@ func load_enemy_battle_sprites(data: Dictionary, buffer: StreamPeerBuffer): # Save monster data.monster_battle_sprites.append(entry) + +class BattleBackground: + var tilemap_image: Image + var tilemap_tex: Texture + var palette_images: Array + var palette_texs: Array + var tile_atlas_images: Array + var tile_atlas_texs: Array + +var battle_backgrounds := [] +func load_battle_bgs(data: Dictionary, buffer: StreamPeerBuffer): + var bg_palettes = data.battle_background_palettes + for map in snes_battle_bgs.get_all_battle_background_tilemaps(buffer, data): + var bg := BattleBackground.new() + bg.tilemap_image = map.tilemap_image + bg.tilemap_tex = texture_from_image(bg.tilemap_image) + var palette_images = [] + var palette_texs = [] + for ids in map.palette_ids: + var pal_image := generate_palette_from_colorarray(bg_palettes[ids[0]] + bg_palettes[ids[1]]) + palette_images.append(pal_image) + palette_texs.append(texture_from_image(pal_image)) + bg.palette_images = palette_images + bg.palette_texs = palette_texs + var tiles := [] + var ts: PoolByteArray = map.tileset + var ts_l = len(ts) + for i in 128: + var start: int = i*32 + var end: int = start+31 # inclusive... + if end >= ts_l: + break + tiles.append(snes_graphics._4plane_to_tile(ts.subarray(start, end))) + if 'animated_tiles' in map: + bg.tile_atlas_images = [] + bg.tile_atlas_texs = [] + var frames = [] + for frame in 4: + frames.append(tiles.duplicate()) + for pair in map.animated_tiles: + var frame = pair[0] >> 6 + var tile_dst = pair[0] & 0x3F + var tile_src = pair[1] + frames[frame][tile_dst] = tiles[tile_src] + for frame in 4: + var atlas_image = make_tile_atlas(frames[frame]) + bg.tile_atlas_images.append(atlas_image) + bg.tile_atlas_texs.append(texture_from_image(atlas_image)) + else: + var atlas_image = make_tile_atlas(tiles) + bg.tile_atlas_images = [atlas_image] + bg.tile_atlas_texs = [texture_from_image(atlas_image)] + battle_backgrounds.append(bg) + static func bias_tile(unbiased: PoolByteArray, bias: int) -> Image: var image := Image.new() var biased = ByteArray(64) diff --git a/scripts/loaders/snes/battle_bgs.gd b/scripts/loaders/snes/battle_bgs.gd index 107317d..bb6bf20 100644 --- a/scripts/loaders/snes/battle_bgs.gd +++ b/scripts/loaders/snes/battle_bgs.gd @@ -1,7 +1,50 @@ -extends Node -const LENGTH := 0x500 / 2 # Expanded length 0x500, RLE step produces 0x280 tile mappings +const NUM_COLUMNS := 32 +const NUM_ROWS := 20 +const LENGTH := NUM_COLUMNS * NUM_ROWS # 0x500 / 2 # Expanded length 0x500, RLE step produces 0x280 tile mappings +const NUM_BATTLE_BG_TILESETS := 21 # These combine with a RAM skip amount to form a subset used as a tile atlas +const NUM_BATTLE_BG_TILEMAPS := 28 +const NUM_BATTLE_BG_FLIPSETS := 9 +const IMAGE_FORMAT := Image.FORMAT_LA8 +const compression = preload('res://scripts/loaders/snes/compression.gd') -func decompress_battle_tilemap(buffer: StreamPeerBuffer) -> Array: + +class TileMapping: + var tile_index: int + var palette: int + var priority: bool + var h_flip: bool + var v_flip: bool + + static func from_tilemap_word(w: int) -> TileMapping: + var t := TileMapping.new() + t.tile_index = w & 0x03FF + t.palette = (w & 0x1C00) >> 10 + t.priority = bool(w & 0x2000) + t.h_flip = bool(w & 0x4000) + t.v_flip = bool(w & 0x8000) + return t + + static func from_battle_byte(b: int) -> TileMapping: + var t := TileMapping.new() + t.tile_index = b & 0x7F # In-game this gets |= 0x80, as the BG tiles are from 0x80 to 0xFF + t.palette = b >> 7 # In-game this gets incremented by 1, as the BG palettes are #1 and #2 leaving #0 for UI elements + return t + + func serialize(buffer: StreamPeer) -> void: + # 8bit for tile_index + # 6bit for palette + # 1bit for each flip + # Do nothing with priority, should have two textures for each layer depending on it + buffer.put_u8(self.tile_index) + var byte2 := self.palette + if self.h_flip: + byte2 += 0x40 + if self.v_flip: + byte2 += 0x80 + buffer.put_u8(byte2) + + +static func decompress_battle_tilemap(buffer: StreamPeer) -> Array: # Decompresses the tilemap for a battle background. # Battle BGs use a type of RLE with 2byte repeat and 1byte incremental repeat. var mappings := [] @@ -27,12 +70,7 @@ func decompress_battle_tilemap(buffer: StreamPeerBuffer) -> Array: byte += inc return mappings -func apply_battle_tilemap_flips(buffer: StreamPeerBuffer, id: int, tilemap: Array): - if id==0xFF: - return - buffer.seek(0x14C736+(id*2)) - var ptr := 0x140000 + buffer.get_u16() - buffer.seek(ptr) +static func apply_battle_tilemap_flips(buffer: StreamPeer, tilemap: Array): var tile_i := 0 while tile_i < LENGTH: var a := buffer.get_u8() @@ -44,25 +82,78 @@ func apply_battle_tilemap_flips(buffer: StreamPeerBuffer, id: int, tilemap: Arra tilemap[tile_i].h_flip = bool((a>>b) & 0x01) tile_i += 1 +static func add_anim_palettes(buffer: StreamPeer, palettes: Array): + # TODO: check if the very first entry is added too + var pal1_id: int = palettes[0][0] + var pal2_id: int = palettes[0][1] + while true: + var b := buffer.get_u8() + if b == 0xFF: + break + if b & 0x80: + pal2_id = b & 0x7F + else: + pal1_id = b + palettes.append([pal1_id, pal2_id]) -class TileMapping: - var tile_index: int - var palette: int - var priority: bool - var h_flip: bool - var v_flip: bool +static func get_anim_tiles(buffer: StreamPeer) -> Array: + var output := [] + while true: + var b := buffer.get_u8() # 2 MSb frame number (0-3), 6 bit tile index to replace + var b2 := buffer.get_u8() # tile index to copy from + if (b == 0xFF) or (b2 == 0xFF): + break + output.append([b, b2]) + return output - static func from_tilemap_word(w: int) -> TileMapping: - var t := TileMapping.new() - t.tile_index = w & 0x03FF - t.palette = (w & 0x1C00) >> 10 - t.priority = bool(w & 0x2000) - t.h_flip = bool(w & 0x4000) - t.v_flip = bool(w & 0x8000) - return t +static func array_of_tilemappings_to_image(tilemappings: Array) -> Image: + var out := Image.new() + var buffer := StreamPeerBuffer.new() + for tilemapping in tilemappings: + tilemapping.serialize(buffer) + out.create_from_data(NUM_COLUMNS, NUM_ROWS, false, IMAGE_FORMAT, buffer.data_array) + return out - static func from_battle_byte(b: int) -> TileMapping: - var t := TileMapping.new() - t.tile_index = b | 0x80 - t.palette = 1 + (b >> 7) - return t +static func get_all_battle_background_tilesets(buffer: StreamPeer, tileset_offsets: Array, skip_offsets: Array) -> Array: + # Convert these to 4bpp tiles to create your atlases + var raw_tilesets := {} # key is offset, value is decompressed tiles + var tilesets := [] + for i in NUM_BATTLE_BG_TILESETS: + var skip: int = skip_offsets[i] - 0x7FC000 + var offset: int = tileset_offsets[i] - 0xC00000 + if not (offset in raw_tilesets): + buffer.seek(offset) + raw_tilesets[offset] = compression.decompress_lzss(buffer) + var raw: PoolByteArray = raw_tilesets[offset] + var skipped_raw := raw.subarray(skip, -1) + tilesets.append(skipped_raw) + return tilesets + +static func get_all_battle_background_tilemaps(buffer: StreamPeerBuffer, data: Dictionary) -> Array: + var tbl_battle_backgrounds: Array = data.tbl_battle_backgrounds + var tilesets = get_all_battle_background_tilesets(buffer, data.ptrs_battle_background_tilesets, data.ptrs_battle_background_tileset_skips) + + var tilemaps = [] + for i in NUM_BATTLE_BG_TILEMAPS: + buffer.seek(data.ptrs_battle_background_tilemaps[i] + 0x140000) + tilemaps.append(decompress_battle_tilemap(buffer)) + + var output := [] + for tbl in tbl_battle_backgrounds: + var out := {} + out.tileset = tilesets[tbl.tileset_id] + out.palette_ids = [tbl.palette_ids] + var tilemap = tilemaps[tbl.tilemap_id] + if tbl.tilemap_flips_id < NUM_BATTLE_BG_FLIPSETS: # 0xFF means no flips + buffer.seek(data.ptrs_battle_background_tilemap_flips[tbl.tilemap_flips_id] + 0x140000) + apply_battle_tilemap_flips(buffer, tilemap) + out.tilemap_image = array_of_tilemappings_to_image(tilemap) + if tbl.palcycle_id < 0xFF: + buffer.seek(data.ptrs_battle_background_palette_animations[tbl.palcycle_id] + 0x140000) + add_anim_palettes(buffer, out.palette_ids) + if tbl.tilecycle_id > 0: + buffer.seek(data.ptrs_battle_background_tile_animations[tbl.tilecycle_id] + 0x140000) + out.animated_tiles = get_anim_tiles(buffer) + output.append(out) + + return output diff --git a/scripts/loaders/snes/compression.gd b/scripts/loaders/snes/compression.gd new file mode 100644 index 0000000..210a754 --- /dev/null +++ b/scripts/loaders/snes/compression.gd @@ -0,0 +1,33 @@ +static func decompress_lzss(rom: StreamPeer, uncompressed_length:=0) -> PoolByteArray: + # Algorithm from http://slickproductions.org/slickwiki/index.php/Noisecross:Final_Fantasy_V_Compression + # Reuploaded at https://www.ff6hacking.com/ff5wiki/index.php/Compression#Decompression_Type_02_.28LZSS.29 + if not uncompressed_length: + uncompressed_length = rom.get_u16() + var output := PoolByteArray() + var buffer := PoolByteArray() + buffer.resize(0x800) + buffer.fill(0) + var buffer_p := 0x07DE + while len(output) < uncompressed_length: + var bitmap_byte := rom.get_u8() + for i in range(8): + var bit := (bitmap_byte >> i) & 1 + if bit: + var b := rom.get_u8() + output.append(b) + buffer[buffer_p] = b + buffer_p = (buffer_p+1) % 0x800 + else: + # Reuse bytes from previously decompressed buffer + var b1 := rom.get_u8() # lower 8 bits of offset + var b2 := rom.get_u8() # upper 3 bits of offset, 5bit length-3 + var offset := b1 | ((b2 & 0xE0) << 3) # 11bit is [0, 0x7FF] + var length := (b2 & 0x1F) + 3 # The +3 is likely because the compression overhead makes this a sensible minimum + for j in range(length): + var b := buffer[offset] + output.append(b) + buffer[buffer_p] = b + buffer_p = (buffer_p+1) % 0x800 + offset = (offset+1) % 0x800 + output.resize(uncompressed_length) + return output diff --git a/shaders/tilemap_shader.gdshader b/shaders/tilemap_shader.gdshader new file mode 100644 index 0000000..b068ec0 --- /dev/null +++ b/shaders/tilemap_shader.gdshader @@ -0,0 +1,43 @@ +shader_type canvas_item; +uniform sampler2D tile_atlas : hint_normal; +uniform sampler2D palette : hint_normal; +uniform float palette_rows = 8.0; // 128 colours +// uniform float tile_width = 8.0; +uniform vec2 tilemap_size = vec2(32.0, 20.0); // Battle tilemaps are 32x20, zone tilemaps are larger (64x64?) +const float INDEX_SCALE = 255.0 / 16.0; + +// This shader maps from tileID texels to Tiles, and then applies 4bpp (16-colour) palette. +// tile_atlas hardcoded to 16x16 tiles for now +const float ATLAS_SIZE = 16.0; +// palette hardcoded to 16 columns of colors for now + + +void fragment() { + // GLES2 + vec2 xy = UV * tilemap_size; // Texel-space coord of our texture + vec2 t = texture(TEXTURE, UV).ra; + int tile_idx = int(t.x * 255.0); // Luminosity channel (any RGB channel works) + int palette_idx = int(t.y * 255.0); // Alpha channel + // Extract flip bits from palette_idx byte + int v_flip = (palette_idx / 128); + palette_idx = palette_idx % 128; + int h_flip = (palette_idx / 64); + palette_idx = palette_idx % 64; + // Convert tile_idx to a texel coordinate, then to a UV coordinate + ivec2 tile_xy = ivec2(tile_idx%16, tile_idx/16); + vec2 tile_uv = vec2(tile_xy)/16.0; + // Get sub-tile UV + vec2 sub_tile_uv = fract(xy); + sub_tile_uv.x = mix(sub_tile_uv.x, 1.0 - sub_tile_uv.x, float(h_flip)); // Branchless mirroring, maybe test perf against branched version at some point + sub_tile_uv.y = mix(sub_tile_uv.y, 1.0 - sub_tile_uv.y, float(v_flip)); + vec2 lut_uv = tile_uv + (sub_tile_uv/ATLAS_SIZE); + + // TODO: move cycling palette to a sampler2DArray or sampler3D rather than rebinding + float color_id = texture(tile_atlas, lut_uv).r; + float color_idx16 = color_id * INDEX_SCALE; + float pal_col = fract(color_idx16); + float pal_row = float(palette_idx); + COLOR = texture(palette, vec2(pal_col, pal_row)); + + // COLOR.a = step(0.000001, color_idx16); // Branchless transparency +} diff --git a/test_scene.gd b/test_scene.gd index 5e99579..acad149 100644 --- a/test_scene.gd +++ b/test_scene.gd @@ -1,5 +1,6 @@ extends Control const palette_mat := preload('res://palette_mat.tres') +const battle_bg_shader = preload('res://shaders/tilemap_shader.gdshader') var save_slots = [] var save_slot_dicts = [] @@ -35,6 +36,18 @@ func _ready(): t.material.set_shader_param('palette', mon.palette) monster_box.add_child(t) + var battle_bg_mat := ShaderMaterial.new() + battle_bg_mat.shader = battle_bg_shader + # var bg = SpriteLoader.battle_backgrounds[1] + for bg in SpriteLoader.battle_backgrounds.slice(0, 2): + var t := TextureRect.new() + t.material = battle_bg_mat.duplicate() + t.texture = bg.tilemap_tex + t.material.set_shader_param('palette', bg.palette_texs[0]) + t.material.set_shader_param('tile_atlas', bg.tile_atlas_texs[0]) + t.rect_scale = Vector2(8, 8) + t.name = 'BattleBG' + add_child(t) # var lbl = Label.new() # for i in 22: # lbl.text = lbl.text + '%s - %s\n' % [StringLoader.get_job_name(i), StringLoader.get_job_desc(i)]