From 5acc6188524b42223a12dc7bdbf8c10e0777ff1a Mon Sep 17 00:00:00 2001 From: Luke Hubmayer-Werner Date: Tue, 1 Aug 2023 23:22:31 +0930 Subject: [PATCH] CD Parsing and WIP ROM selector menu --- README.md | 2 + data/SNES_PSX_addresses.tsv | 24 ++-- main_menu.tscn | 20 +-- project.godot | 12 +- scripts/loaders/cd/image.gd | 171 ++++++++++++++++++++++++++ scripts/loaders/rom_loader.gd | 28 +++++ theme/ThemeElements.png | Bin 0 -> 816 bytes theme/ThemeElements.png.import | 35 ++++++ theme/icon_cart.tres | 7 ++ theme/icon_disc.tres | 7 ++ theme/icon_folder.tres | 7 ++ theme/menu_theme.tres | 46 ++++++- theme/vscroll10px_inner_stylebox.tres | 9 ++ theme/vscroll10px_stylebox.tres | 13 ++ theme/vslider10px_inner_stylebox.tres | 7 ++ theme/vslider10px_stylebox.tres | 13 ++ widgets/RomSelect.gd | 133 ++++++++++++++++++++ widgets/RomSelect.tscn | 82 ++++++++++++ 18 files changed, 594 insertions(+), 22 deletions(-) create mode 100644 scripts/loaders/cd/image.gd create mode 100644 theme/ThemeElements.png create mode 100644 theme/ThemeElements.png.import create mode 100644 theme/icon_cart.tres create mode 100644 theme/icon_disc.tres create mode 100644 theme/icon_folder.tres create mode 100644 theme/vscroll10px_inner_stylebox.tres create mode 100644 theme/vscroll10px_stylebox.tres create mode 100644 theme/vslider10px_inner_stylebox.tres create mode 100644 theme/vslider10px_stylebox.tres create mode 100644 widgets/RomSelect.gd create mode 100644 widgets/RomSelect.tscn diff --git a/README.md b/README.md index f4a3f00..e9ee4ea 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +# Chocolate Bird + # What? Interactive Final Fantasy V save editor diff --git a/data/SNES_PSX_addresses.tsv b/data/SNES_PSX_addresses.tsv index c9df8e1..130cab8 100644 --- a/data/SNES_PSX_addresses.tsv +++ b/data/SNES_PSX_addresses.tsv @@ -1,12 +1,12 @@ -Label SNES PSX Comment -locations_bg_palettes 0x03BB00 /nar/ff5_binx.bin:0x03BF80 -worldmap_blocks 0x0FF0C0 /nar/ff5_binx.bin:0x040300 -worldmap_tile_palettes 0x0FF9C0 /nar/ff5_bin3.bin:0x03FB00 -worldmap_palettes 0x0FFCC0 /nar/ff5_binx.bin:0x040000 -worldmap_tiles 0x1B8000 /nar/ff5_bin3.bin:0x039B00 -character_battle_sprite_tiles 0x120000 /mnu/men_bin.eng:0x010200 -character_battle_sprite_palettes 0x14A3C0 /btl/ff5_btl.bin:0x0273C0 Also /mnu/men_bin.eng:0x03A5C0 -character_battle_sprite_layouts 0x14B997 /btl/ff5_btl.bin:0x028997 -character_battle_sprite_disabled_palette 0x00F867 /mnu/memsave.bin:0x000034 -character_battle_sprite_stone_palette 0x00F807 N/A Also 0x199835 -tiles_fist 0x11D710 /btl/ff5_btl.bin:0x021D10 Also /mnu/men_bin.eng:0x00D910 +Label SNES PSX_file PSX_offset Comment +locations_bg_palettes 0x03BB00 /nar/ff5_binx.bin 0x03BF80 +worldmap_blocks 0x0FF0C0 /nar/ff5_binx.bin 0x040300 +worldmap_tile_palettes 0x0FF9C0 /nar/ff5_bin3.bin 0x03FB00 +worldmap_palettes 0x0FFCC0 /nar/ff5_binx.bin 0x040000 +worldmap_tiles 0x1B8000 /nar/ff5_bin3.bin 0x039B00 +character_battle_sprite_tiles 0x120000 /mnu/men_bin.eng 0x010200 +character_battle_sprite_palettes 0x14A3C0 /btl/ff5_btl.bin 0x0273C0 Also /mnu/men_bin.eng:0x03A5C0 +character_battle_sprite_layouts 0x14B997 /btl/ff5_btl.bin 0x028997 +character_battle_sprite_disabled_palette 0x00F867 /mnu/memsave.bin 0x000034 +character_battle_sprite_stone_palette 0x00F807 N/A N/A Also 0x199835 +tiles_fist 0x11D710 /btl/ff5_btl.bin 0x021D10 Also /mnu/men_bin.eng:0x00D910 diff --git a/main_menu.tscn b/main_menu.tscn index 201c434..4fc7d76 100644 --- a/main_menu.tscn +++ b/main_menu.tscn @@ -1,20 +1,22 @@ -[gd_scene load_steps=6 format=2] +[gd_scene load_steps=7 format=2] [ext_resource path="res://box.tscn" type="PackedScene" id=1] [ext_resource path="res://theme/menu_theme.tres" type="Theme" id=2] [ext_resource path="res://widgets/ColorMenu.tscn" type="PackedScene" id=3] [ext_resource path="res://theme/border_imagetexture.tres" type="Texture" id=4] [ext_resource path="res://party_menu.tscn" type="PackedScene" id=5] +[ext_resource path="res://widgets/RomSelect.tscn" type="PackedScene" id=6] [node name="main_menu" type="Control"] anchor_right = 1.0 anchor_bottom = 1.0 -margin_right = -854.0 -margin_bottom = -480.0 -rect_scale = Vector2( 3, 3 ) +margin_right = -640.0 +margin_bottom = -360.0 +rect_scale = Vector2( 2, 2 ) theme = ExtResource( 2 ) [node name="party_menu" parent="." instance=ExtResource( 5 )] +visible = false anchor_left = 1.0 anchor_top = 1.0 anchor_right = 1.0 @@ -54,8 +56,10 @@ margin_bottom = 33.0 text = "Load Save File From here" +[node name="RomSelect" parent="." instance=ExtResource( 6 )] + [node name="ColorMenu" parent="." instance=ExtResource( 3 )] -margin_left = 44.0 -margin_top = 64.0 -margin_right = 129.0 -margin_bottom = 128.0 +margin_left = 548.0 +margin_top = 288.0 +margin_right = 633.0 +margin_bottom = 352.0 diff --git a/project.godot b/project.godot index e5a5e52..204ea1c 100644 --- a/project.godot +++ b/project.godot @@ -14,8 +14,9 @@ _global_script_class_icons={ [application] -config/name="FF" +config/name="ChocolateBird" run/main_scene="res://main_menu.tscn" +config/use_custom_user_dir=true config/icon="res://icon.png" [autoload] @@ -44,6 +45,15 @@ window/dpi/allow_hidpi=true theme/use_hidpi=true +[importer_defaults] + +texture={ +"detect_3d": false, +"flags/filter": false, +"flags/srgb": 0, +"process/fix_alpha_border": false +} + [rendering] quality/driver/driver_name="GLES2" diff --git a/scripts/loaders/cd/image.gd b/scripts/loaders/cd/image.gd new file mode 100644 index 0000000..c1049e5 --- /dev/null +++ b/scripts/loaders/cd/image.gd @@ -0,0 +1,171 @@ +extends Node +#warning-ignore-all:shadowed_variable +#warning-ignore-all:return_value_discarded +const PVD_STRING := PoolByteArray([1, 0x43, 0x44, 0x30, 0x30, 0x31]) # b'\x01CD001' +const SECTOR_SIZE_ISO := 0x800 +const SECTOR_SIZE_RAW := 0x930 + +static func decode_filename(filename: PoolByteArray) -> String: + match filename[0]: + 0: + return '.' + 1: + return '..' + _: + return filename.get_string_from_ascii() + +static func parse_primary_volume_descriptor(rom: File, offset: int) -> Dictionary: + var output := {} + rom.seek(offset + 8) + output['system_identifier'] = rom.get_buffer(32).get_string_from_ascii() + output['volume_identifier'] = rom.get_buffer(32).get_string_from_ascii() + rom.seek(offset + 80) + output['volume_sector_count'] = rom.get_32() + rom.seek(offset + 120) + output['volume_set_size'] = rom.get_16() + # rom.seek(offset + 124) + # output['volume_seq_number'] = rom.get_16() + rom.seek(offset + 128) + output['logical_block_size'] = rom.get_16() + rom.seek(offset + 132) + output['path_table_size'] = rom.get_32() + rom.seek(offset + 140) + output['path_table_lba'] = rom.get_32() + return output + +static func find_primary_volume_descriptor(rom: File): + # Assume either ISO image with 0x800 bytes per sector, or raw .bin with 0x940 bytes per sector + for offset in [SECTOR_SIZE_ISO*16, SECTOR_SIZE_RAW*16, SECTOR_SIZE_RAW*16+24]: + rom.seek(offset) + if rom.get_buffer(6) == PVD_STRING: + return offset + print_debug('Primary Volume Descriptor not found') + return -1 + +static func parse_path_table(rom: File, remaining_bytes: int) -> Array: + var entry := {} + var l = rom.get_8() + entry['ea_l'] = rom.get_8() + entry['lba'] = rom.get_32() + entry['dir_num'] = rom.get_16() + entry['name'] = decode_filename(rom.get_buffer(l)) + if (l % 2) > 0: + rom.get_8() + l += 1 + remaining_bytes -= l + 8 + if remaining_bytes > 8: + return [entry] + parse_path_table(rom, remaining_bytes) + else: + return [entry] + +static func parse_directory_table(rom: File, offset: int): + var entry := {} + rom.seek(offset) + var l = rom.get_8() + if l == 0: + return null + entry['ea_l'] = rom.get_8() + entry['lba'] = rom.get_32() + rom.seek(offset+10) + entry['data_length'] = rom.get_32() + rom.seek(offset + 25) + entry['flags'] = rom.get_8() + entry['interleaved_size'] = rom.get_8() + entry['interleaved_gap'] = rom.get_8() + rom.seek(offset + 32) + var filename_length := rom.get_8() + entry['name'] = decode_filename(rom.get_buffer(filename_length)) + if (l % 2) > 0: + l += 1 + + var tail = parse_directory_table(rom, offset + l) + if tail != null: + return [entry] + tail + else: + return [entry] + +static func read_sector(rom: File, sector: int, pvd_offset: int, sector_size: int = SECTOR_SIZE_ISO, amount: int = SECTOR_SIZE_ISO) -> PoolByteArray: + var d_sector = sector - 16 + rom.seek(pvd_offset + (d_sector*sector_size)) + return rom.read_buffer(amount) + + +# Non-static +var directory: Dictionary +var pvd_offset: int +var pvd: Dictionary +var rom: File +var sector_count: int +var sector_size: int # The sector size of the File. The actual data will always be in 0x800 byte sectors, but raw image files will have 0x18 bytes of header and 0x118 bytes of footer in every sector + +func lba2offset(lba: int) -> int: + # Our offset seek strategy is to start from the PVD (guaranteed to be the start of the data region of Sector 16) + return self.pvd_offset + ((lba-16) * self.sector_size) + +func lba2bytes(lba: int, amount: int = SECTOR_SIZE_ISO) -> PoolByteArray: + self.rom.seek(self.lba2offset(lba)) + return self.rom.get_buffer(amount) + +func get_file(filename: String) -> PoolByteArray: + # Note that actual files end in ';1' + # On a multi-session CD there may be subsequent versions like ';2' but that is not of interest to this program + # Remember to ask for your filename with ';1' on the end + if not (filename in directory): + print_debug('No such file in directory: "%s"' % filename) + return PoolByteArray() + if not filename.ends_with(';1'): + print_debug('Filename must end with ";1", is this a directory? "%s"' % filename) + return PoolByteArray() + var f = directory[filename] + if (typeof(f) != typeof([])) or len(f) < 2: + print_debug('Directory entry does not have a filesize: "%s"' % filename) + return PoolByteArray() + + var lba_start: int = f[0] + var size: int = f[1] + if self.sector_size == SECTOR_SIZE_ISO: + # There are no sector headers or footers interspersed, so we can just read the whole thing at once + return self.lba2bytes(lba_start, size) + else: + # This reads in one sector at a time to be compatible with RAW images, which have sector headers and footers interspersed + var buffer := StreamPeerBuffer.new() + var lba := lba_start # Extraneous vars for debugging purposes + var bytes_remaining := size # ^ + while bytes_remaining > 0: + var amount = bytes_remaining + if bytes_remaining > SECTOR_SIZE_ISO: + amount = SECTOR_SIZE_ISO + buffer.put_data(self.lba2bytes(lba, amount)) + bytes_remaining -= amount + lba += 1 + return buffer.data_array + +func _init(rom: File): + # super() + self.rom = rom + var rom_len := self.rom.get_len() + self.pvd_offset = find_primary_volume_descriptor(self.rom) + if self.pvd_offset < 0: + print_debug('Primary Volume Descriptor not found in ROM, aborting') + return + self.pvd = parse_primary_volume_descriptor(self.rom, self.pvd_offset) + # Determine the sector size of this File + self.sector_count = self.pvd.volume_sector_count + # Use >= comparisons to allow for preamble in the image file + var f := rom_len/float(self.sector_count) + if f >= SECTOR_SIZE_RAW: + self.sector_size = SECTOR_SIZE_RAW + elif f >= SECTOR_SIZE_ISO: + self.sector_size = SECTOR_SIZE_ISO + else: + print_debug('Filesize divided by Sector Count is too low for known formats, the image file may be truncated or lying about sector count') + return + self.rom.seek(self.lba2offset(self.pvd.path_table_lba)) + var path_table := parse_path_table(self.rom, self.pvd.path_table_size) + for path_entry in path_table: + var path_name: String = '%s/' % path_entry.name + self.directory[path_name] = [path_entry.lba] + var dir_table: Array = parse_directory_table(self.rom, self.lba2offset(path_entry.lba)) + for dir_entry in dir_table: + var filename := '%s%s' % [path_name, dir_entry.name] + self.directory[filename] = [dir_entry.lba, dir_entry.data_length] diff --git a/scripts/loaders/rom_loader.gd b/scripts/loaders/rom_loader.gd index 1ea4e17..af6430e 100644 --- a/scripts/loaders/rom_loader.gd +++ b/scripts/loaders/rom_loader.gd @@ -1,5 +1,14 @@ extends Node +const loader_cd_image := preload('res://scripts/loaders/cd/image.gd') +var psx_productcode_regex := RegEx.new() +const psx_ff5_productcodes = [ + 'SLUS_008.79', # US Anthology, both 1.0 and 1.1 + 'SCES_138.40', # EU/Aus Anthology + 'SLPM_860.81', # JP + 'SCPS_452.14', # JP original, untested +] + var ROM_filename := 'FF5_SCC_WepTweaks_Inus_Dash.sfc' # 'Final Fantasy V (Japan).sfc' var GBA_filename := '2564 - Final Fantasy V Advance (U)(Independent).gba' @@ -13,7 +22,26 @@ func load_snes_rom(filename: String): MapLoader.load_snes_rom(rom_snes) var _thread_error = thread.start(SoundLoader, 'parse_rom', rom_snes) +func load_psx_folder(_dirname: String): + pass + +func load_psx_image(filename: String): + # While it would technically be possible to load everything with no temporary files, + # It is more convenient to unpack the small files we care about to the user:// directory + var rom_psx := File.new() + var error := rom_psx.open(filename, File.READ) + if error == OK: + var cd := loader_cd_image.new(rom_psx) + for key in cd.directory: + var s = key.trim_prefix('./') + var re_match := psx_productcode_regex.search(s) + if re_match: + print(re_match.get_string(0)) + print(cd.directory) + + func _ready(): + var _error := psx_productcode_regex.compile('(S[A-Z]{3}_\\d{3}\\.\\d{2});(\\d)') load_snes_rom(ROM_filename) func _exit_tree() -> void: diff --git a/theme/ThemeElements.png b/theme/ThemeElements.png new file mode 100644 index 0000000000000000000000000000000000000000..9cc03bf421e5ab2dfa88bf3c977d90bfe45409d6 GIT binary patch literal 816 zcmV-01JC@4P)yApTIU(`T~J^JTQ4Izd7qnL2R+kV&jW06R-` zC=Qri6h{urhyYOH|1&c)GwhjJtpHvYX^UNYXkA;@_Rjcuw3qeS#iJzQ2VAXGf9uxN zXC`PQDD%r!BUEsVniM}dlK_r^MD@$JVIZ?DPM4Z#*Zp@qAYFxq=}H#pSh`7j^@WZ) z-&uB9@EE~&=)ee`@Tj%=YXgRu{$=X6WOh10mFIk$4)m+9y_E6xyhjJ77+70cUhO71 zCCJVlLS$l$s4dTcP)4O59(U0&a|)3yOLV4NzkyX^u&;}N;z+Qg_}cI~cI3}ZTdfoI z`l+M{MFShNg3a?;VW_u$7lBT>qtAuaPsWnRI+w#qYO6cDY&F#fGVS;yau@sALwnDU2 zil?<+SH3|%xOny=I>G1p5FQ$ zjr`2os$*eOUfOm6Q6C23Lrf#*K%4dSx$s#QjFZxZEbuo-HvVE3)Yo$|kne`)9^WIx zpJjm_B^U}({n;v9 zJIDOi|KQZyjxHrWkm7dme&86qk?mU-OlReqj-*(EO;gL*X4Y?+@IXEwvC|j0ZN%}O u-?_V9Yp>mYJ(M2-q376A8Xg_?kCi{i!RK0yAatSt0000 String: + if n > 0x100000000: + return '%.2f GiB' % (n/float(0x40000000)) + if n > 0x400000: + return '%.1f MiB' % (n/float(0x100000)) + if n > 0x1000: + return '%.1f KiB' % (n/float(0x400)) + return '%d B' % n + +func update_view(): + var error = dir.list_dir_begin(true, true) + if error != OK: + print_debug(error) + return + + for child in folder_buttons.get_children(): + child.queue_free() + var path = ProjectSettings.globalize_path(dir.get_current_dir()) + var cur_path: Array = path.split('/') + var l := len(cur_path) + for i in l: + var subpath = '/'.join(cur_path.slice(0, i)) + var btn := Button.new() + btn.connect('pressed', self, 'activate_entry', [subpath]) + btn.text = cur_path[i] + folder_buttons.add_child(btn) + if i == 0: + btn.text += '/' + if i == l-1: + btn.icon = folder_icon + folder_buttons_scroller.set_h_scroll(9999) + folder_buttons_scroller.ensure_control_visible(folder_buttons_scroller.get_h_scrollbar()) + folder_buttons_scroller.get_h_scrollbar().update() + + itemlist.clear() + var directories := PoolStringArray() + var files := [] + while true: + var entry := dir.get_next() + if len(entry) == 0: + break + if dir.current_is_dir(): + directories.append(entry) + elif '.' in entry: + var extension = entry.rsplit('.', true, 1)[1] + if extension in allowed_exts: + files.append([entry, ext_icons.get(extension)]) + directories.sort() + files.sort() + for entry in directories: + itemlist.add_item(entry, folder_icon) + for entry in files: + itemlist.add_item(entry[0], entry[1]) + # itemlist.sort_items_by_text() + +func activate_entry(entry: String): + if dir.dir_exists(entry): + var error := dir.change_dir(entry) + if error == OK: + update_view() + else: + print_debug(error) + elif dir.file_exists(entry): + pass # Load the file + +func view_entry(entry: String): + if dir.dir_exists(entry): + file_info.text = 'Filename:\n %s/\nType: Folder' % entry + elif dir.file_exists(entry): + var file := File.new() + var error := file.open(dir.get_current_dir() + '/' + entry, File.READ) + if error != OK: + print_debug(error) + return + var size = file.get_len() + var human_size = get_human_size(size) + var ext = entry.rsplit('.', true, 1)[1].to_lower() + var type = {'iso': 'CD-ROM Image', 'bin': 'Binary', 'sfc': 'SNES ROM', 'gba': 'GBA ROM', 'srm': 'SNES Savefile'}.get(ext, 'Unknown') + var prodcode := '' + if ext in ['iso', 'bin']: + var cd := RomLoader.loader_cd_image.new(file) + for key in cd.directory: + var s = key.trim_prefix('./') + var re_match := RomLoader.psx_productcode_regex.search(s) + if re_match: + prodcode = '%s\n %s\n %s' % [cd.pvd.system_identifier.strip_edges(), cd.pvd.volume_identifier.strip_edges(), re_match.get_string(1)] + type = 'PSX CD-ROM Image' + break + var info = ('Info:\n %s' % prodcode) if prodcode else '' + file_info.text = 'Filename:\n %s\nType:\n %s\nSize:\n %s\n%s' % [entry, type, human_size, info] + +func _ready() -> void: + if OS.has_feature('windows'): + home_path = OS.get_environment('USERPROFILE') + else: + home_path = OS.get_environment('HOME') + # dir.open(OS.get_system_dir(OS.SYSTEM_DIR_DOWNLOADS)) + var error = dir.open(home_path) + if error == OK: + update_view() + print(ProjectSettings.globalize_path('user://')) + print(ProjectSettings.globalize_path(dir.get_current_dir())) + +#func _process(_delta): + #vscroller.set_margin(MARGIN_TOP, 4) + #vscroller.set_margin(MARGIN_BOTTOM, -4) + + +func _on_ItemList_item_activated(index: int) -> void: + activate_entry(itemlist.get_item_text(index)) + + +func _on_ItemList_item_selected(index: int) -> void: + view_entry(itemlist.get_item_text(index)) diff --git a/widgets/RomSelect.tscn b/widgets/RomSelect.tscn new file mode 100644 index 0000000..8d6c47c --- /dev/null +++ b/widgets/RomSelect.tscn @@ -0,0 +1,82 @@ +[gd_scene load_steps=3 format=2] + +[ext_resource path="res://theme/menu_theme.tres" type="Theme" id=1] +[ext_resource path="res://widgets/RomSelect.gd" type="Script" id=2] + +[node name="RomSelect" type="PanelContainer"] +margin_right = 8.0 +margin_bottom = 8.0 +theme = ExtResource( 1 ) +script = ExtResource( 2 ) + +[node name="VBoxContainer" type="VBoxContainer" parent="."] +margin_left = 4.0 +margin_top = 4.0 +margin_right = 352.0 +margin_bottom = 225.0 +custom_constants/separation = 4 + +[node name="Label" type="Label" parent="VBoxContainer"] +margin_right = 348.0 +margin_bottom = 14.0 +text = "Select a ROM from your device's storage" + +[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"] +margin_top = 18.0 +margin_right = 348.0 +margin_bottom = 49.0 +rect_min_size = Vector2( 0, 31 ) +scroll_vertical_enabled = false + +[node name="folder_buttons" type="HBoxContainer" parent="VBoxContainer/ScrollContainer"] +custom_constants/separation = 4 + +[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"] +margin_top = 53.0 +margin_right = 348.0 +margin_bottom = 221.0 + +[node name="PanelContainer" type="PanelContainer" parent="VBoxContainer/HBoxContainer"] +margin_right = 200.0 +margin_bottom = 168.0 +rect_min_size = Vector2( 200, 0 ) + +[node name="ItemList" type="ItemList" parent="VBoxContainer/HBoxContainer/PanelContainer"] +margin_left = 4.0 +margin_top = 4.0 +margin_right = 196.0 +margin_bottom = 164.0 +rect_min_size = Vector2( 120, 160 ) + +[node name="VBoxContainer" type="VBoxContainer" parent="VBoxContainer/HBoxContainer"] +margin_left = 208.0 +margin_right = 348.0 +margin_bottom = 168.0 + +[node name="file_info" type="Label" parent="VBoxContainer/HBoxContainer/VBoxContainer"] +margin_right = 140.0 +margin_bottom = 138.0 +rect_min_size = Vector2( 140, 0 ) +size_flags_vertical = 3 +text = "Filename: + myfile.iso +Size: + 400 KiB +Type: + CD-ROM image + +PLAYSTATION +SCEA_000.00" + +[node name="btn_ok" type="Button" parent="VBoxContainer/HBoxContainer/VBoxContainer"] +margin_left = 57.0 +margin_top = 146.0 +margin_right = 82.0 +margin_bottom = 168.0 +size_flags_horizontal = 4 +size_flags_vertical = 8 +disabled = true +text = "OK" + +[connection signal="item_activated" from="VBoxContainer/HBoxContainer/PanelContainer/ItemList" to="." method="_on_ItemList_item_activated"] +[connection signal="item_selected" from="VBoxContainer/HBoxContainer/PanelContainer/ItemList" to="." method="_on_ItemList_item_selected"]