from phBot import * import QtBind import time import webbrowser from threading import Timer pName = 'FFateManager' pVersion = '1.0.1' DISCORD_URL = 'https://discord.gg/eB9sGSMYBg' OPCODE_FATE_REQUEST = 0x7151 OPCODE_FATE_RESPONSE = 0xB151 COLOR_PRIMARY = '#5b57e0' COLOR_TEXT = '#2b3038' COLOR_MUTED = '#9aa0ac' COLOR_SUCCESS = '#1f9d63' COLOR_WARNING = '#c98a1a' COLOR_ERROR = '#d93a4d' FATE_REQUEST_DELAY = 0.55 NEXT_ITEM_DELAY = 0.85 MAX_BLUE_LINES = 32 selected_inventory = [] queued_items = [] is_running = False current_item_index = 0 awaiting_response = False single_roll_item = None pending_timer = None timer_token = 0 def plugin_log(message): log('[%s] %s' % (pName, str(message))) def fixed_width_text(content, width): return ( '' '
{1}
' ).format(width, content) def html_safe(value): return str(value).replace('&', '&').replace('<', '<').replace('>', '>') def set_status(message, color=COLOR_MUTED): QtBind.setText( gui, lbl_status, fixed_width_text( '%s' % (color, html_safe(message)), 700) ) def set_result(message, color=COLOR_MUTED): QtBind.setText( gui, lbl_result, fixed_width_text( '%s' % (color, html_safe(message)), 310) ) def discord_clicked(): try: webbrowser.open(DISCORD_URL) set_status('Opening Discord invite...', COLOR_SUCCESS) except Exception as error: plugin_log('Discord link error: %s' % error) set_status('Could not open Discord invite', COLOR_ERROR) def item_is_eligible(item): try: data = get_item(int(item.get('model', 0))) return bool(data and int(data.get('tid1', 0)) == 1) except Exception: return False def inventory_snapshot(): inventory = get_inventory() if not inventory or 'items' not in inventory: return [] result = [] for slot, item in enumerate(inventory['items']): if slot < 13 or not item or not item_is_eligible(item): continue copied = dict(item) copied['slot'] = slot result.append(copied) return result def find_inventory_item(slot): inventory = get_inventory() if not inventory or 'items' not in inventory: return None items = inventory['items'] if slot < 0 or slot >= len(items): return None return items[slot] def get_fate_slot(): inventory = get_inventory() if not inventory or 'items' not in inventory: return None for slot, item in enumerate(inventory['items']): if item and 'Wheel of Fate' in str(item.get('name', '')): return slot return None def selected_inventory_item(): index = QtBind.currentIndex(gui, lst_inventory) if index < 0 or index >= len(selected_inventory): return None return selected_inventory[index] def format_item(item): return 'Slot %d | +%s | %s' % ( item['slot'], item.get('plus', 0), item.get('name', 'Unknown')) def refresh_inventory(): global selected_inventory selected_inventory = inventory_snapshot() QtBind.clear(gui, lst_inventory) for item in selected_inventory: QtBind.append(gui, lst_inventory, format_item(item)) if selected_inventory: set_status('%d eligible inventory item(s) found' % len(selected_inventory), COLOR_SUCCESS) else: set_status('No eligible unequipped item was found', COLOR_WARNING) def show_selected_item(): item = selected_inventory_item() if not item: set_status('Select an inventory item first', COLOR_WARNING) return QtBind.setText( gui, lbl_selected, fixed_width_text( '%s' % ( COLOR_TEXT, html_safe(format_item(item))), 310) ) set_status('Item selected; choose the target blue-line count', COLOR_MUTED) def read_target_count(): try: value = int(QtBind.text(gui, txt_target_count).strip()) except Exception: return None if value < 1 or value > MAX_BLUE_LINES: return None return value def add_to_queue(): item = selected_inventory_item() if not item: set_status('Select an inventory item first', COLOR_WARNING) return target = read_target_count() if target is None: set_status('Target blue lines must be between 1 and %d' % MAX_BLUE_LINES, COLOR_ERROR) return queued = { 'slot': item['slot'], 'model': item['model'], 'name': item.get('name', 'Unknown'), 'plus': item.get('plus', 0), 'target': target, 'last_count': None } for index, existing in enumerate(queued_items): if existing['slot'] == queued['slot']: queued_items[index] = queued refresh_queue() set_status('Queue target updated for slot %d' % queued['slot'], COLOR_SUCCESS) return queued_items.append(queued) refresh_queue() set_status('Item added to the Fate queue', COLOR_SUCCESS) def refresh_queue(): QtBind.clear(gui, lst_queue) for index, item in enumerate(queued_items): marker = '>' if is_running and index == current_item_index else ' ' current = '?' if item['last_count'] is None else str(item['last_count']) QtBind.append(gui, lst_queue, '%s Slot %d | blue %s/%d | %s' % ( marker, item['slot'], current, item['target'], item['name'])) def remove_queued_item(): if is_running: set_status('Stop automation before editing the queue', COLOR_WARNING) return index = QtBind.currentIndex(gui, lst_queue) if index < 0 or index >= len(queued_items): set_status('Select a queued item first', COLOR_WARNING) return del queued_items[index] refresh_queue() set_status('Queued item removed', COLOR_MUTED) def clear_queue(): if is_running: set_status('Stop automation before clearing the queue', COLOR_WARNING) return queued_items[:] = [] refresh_queue() set_status('Queue cleared', COLOR_MUTED) def invalidate_timer(): global pending_timer, timer_token, awaiting_response timer_token += 1 awaiting_response = False if pending_timer: try: pending_timer.cancel() except Exception: pass pending_timer = None def schedule_request(delay): global pending_timer, timer_token token = timer_token def callback(): if is_running and token == timer_token: send_fate_request() pending_timer = Timer(delay, callback) pending_timer.daemon = True pending_timer.start() def active_item(): if current_item_index < 0 or current_item_index >= len(queued_items): return None return queued_items[current_item_index] def build_request(item_slot, fate_slot): return b'\x02\x19\x02' + bytes([item_slot, fate_slot]) def send_fate_request(): global awaiting_response if not is_running or awaiting_response: return item = active_item() if not item: stop_automation('All queued items completed', COLOR_SUCCESS) return live_item = find_inventory_item(item['slot']) if not live_item or live_item.get('model') != item['model']: stop_automation('Item moved or changed at slot %d' % item['slot'], COLOR_ERROR) return fate_slot = get_fate_slot() if fate_slot is None: stop_automation('Wheel of Fate not found', COLOR_ERROR) return if item['slot'] > 255 or fate_slot > 255: stop_automation('Inventory slot is outside packet range', COLOR_ERROR) return awaiting_response = True inject_joymax(OPCODE_FATE_REQUEST, build_request(item['slot'], fate_slot), False) set_status('Rolling %s for at least %d blue line(s)...' % ( item['name'], item['target']), COLOR_WARNING) def test_one_roll(): global single_roll_item, awaiting_response if is_running: set_status('Stop automation before running a one-roll test', COLOR_WARNING) return if single_roll_item is not None or awaiting_response: set_status('A one-roll test is already waiting for a response', COLOR_WARNING) return item = selected_inventory_item() if not item: set_status('Select an inventory item first', COLOR_WARNING) return live_item = find_inventory_item(item['slot']) if not live_item or live_item.get('model') != item['model']: set_status('Selected item moved or changed', COLOR_ERROR) return fate_slot = get_fate_slot() if fate_slot is None: set_status('Wheel of Fate not found', COLOR_ERROR) return if item['slot'] > 255 or fate_slot > 255: set_status('Inventory slot is outside packet range', COLOR_ERROR) return single_roll_item = { 'slot': item['slot'], 'model': item['model'], 'name': item.get('name', 'Unknown') } awaiting_response = True inject_joymax(OPCODE_FATE_REQUEST, build_request(item['slot'], fate_slot), False) set_status('One-roll Fate test sent; waiting for 0xB151...', COLOR_WARNING) def start_automation(): global is_running, current_item_index if is_running: set_status('Automation is already running', COLOR_WARNING) return if single_roll_item is not None or awaiting_response: set_status('Wait for the one-roll test response', COLOR_WARNING) return if not queued_items: set_status('Add at least one item to the queue', COLOR_WARNING) return for item in queued_items: live_item = find_inventory_item(item['slot']) if not live_item or live_item.get('model') != item['model']: set_status('Queued item is no longer at slot %d' % item['slot'], COLOR_ERROR) return if get_fate_slot() is None: set_status('Wheel of Fate not found', COLOR_ERROR) return current_item_index = 0 is_running = True invalidate_timer() refresh_queue() set_status('Fate automation started', COLOR_SUCCESS) send_fate_request() def stop_automation(message='Automation stopped', color=COLOR_WARNING): global is_running is_running = False invalidate_timer() refresh_queue() set_status(message, color) def parse_blue_line_count(data): # All supplied live Fate responses use the standard equipment layout: # slot at byte 4 and total magic-option record count at byte 26. That count # includes one internal 0x40 record which is not a visible blue line. # Stat IDs and values are irrelevant to Fate targets and are intentionally # not decoded here. packet_length = len(data) if packet_length < 35: return None record_count = data[26] if record_count < 1 or record_count > MAX_BLUE_LINES + 1: return None # The verified layout has a 35-byte item header, bounded option data, and # an 8-byte response suffix. Reject truncated packets before trusting the # header count. The internal option is represented by the leading marker; # each visible blue line contributes an 8-byte record. blue_count = record_count - 1 minimum_length = 43 + (blue_count * 8) if packet_length < minimum_length: return None return blue_count def response_slot(data): if len(data) < 5 or data[0] != 1 or data[2] != 1: return None return data[4] def log_response_raw(label, data): plugin_log('%s 0xB151 raw (%d bytes): %s%s' % ( label, len(data), ' '.join('%02X' % value for value in data[:512]), ' ...' if len(data) > 512 else '')) def process_one_roll_response(data): global single_roll_item, awaiting_response item = single_roll_item single_roll_item = None awaiting_response = False if not item: return slot = response_slot(data) count = parse_blue_line_count(data) log_response_raw('One-roll', data) if slot != item['slot']: set_result('Rejected response: unexpected item slot', COLOR_ERROR) set_status('One-roll response did not match the selected item', COLOR_ERROR) return if count is None: set_result('0xB151 received; blue-line layout was not verified', COLOR_ERROR) set_status('One-roll response could not be decoded; raw packet logged', COLOR_ERROR) return set_result('Last one-roll result: %d blue line(s)' % count, COLOR_SUCCESS) set_status('One-roll test completed; no repeat was scheduled', COLOR_SUCCESS) plugin_log('One-roll slot %d result: %d blue line(s)' % (item['slot'], count)) def process_fate_response(data): global current_item_index, awaiting_response item = active_item() awaiting_response = False if not item: return slot = response_slot(data) count = parse_blue_line_count(data) if slot != item['slot']: log_response_raw('Unexpected-slot automation response', data) stop_automation('Response item slot did not match; automation stopped', COLOR_ERROR) return if count is None: log_response_raw('Automation decode failure', data) stop_automation('Blue-line count could not be verified; automation stopped', COLOR_ERROR) return item['last_count'] = count set_result('Slot %d result: %d blue line(s)' % (item['slot'], count), COLOR_SUCCESS) plugin_log('Slot %d result: %d/%d blue line(s)' % ( item['slot'], count, item['target'])) if count >= item['target']: current_item_index += 1 refresh_queue() if current_item_index >= len(queued_items): stop_automation('All Fate targets reached', COLOR_SUCCESS) else: set_status('Target reached; moving to the next item', COLOR_SUCCESS) schedule_request(NEXT_ITEM_DELAY) else: refresh_queue() schedule_request(FATE_REQUEST_DELAY) def disconnected(): global single_roll_item single_roll_item = None if is_running or awaiting_response: stop_automation('Disconnected; automation stopped', COLOR_ERROR) else: invalidate_timer() set_status('Disconnected', COLOR_MUTED) def handle_joymax(opcode, data): try: if opcode == OPCODE_FATE_RESPONSE: if single_roll_item is not None: process_one_roll_response(data) elif is_running and awaiting_response: process_fate_response(data) except Exception as error: plugin_log('Packet processing error for 0x%04X: %s' % (opcode, error)) if opcode == OPCODE_FATE_RESPONSE and (is_running or awaiting_response): stop_automation('Packet processing failed; automation stopped', COLOR_ERROR) return True # GUI gui = QtBind.init(__name__, pName) QtBind.createLabel(gui, '✦ %s' % ( COLOR_PRIMARY, pName), 12, 6) QtBind.createLabel(gui, 'v%s' % (COLOR_MUTED, pVersion), 190, 12) QtBind.createButton(gui, 'discord_clicked', u'\U0001f4ac Discord', 462, 6) QtBind.createLabel( gui, u'⚜ Made By FascinaTe' % COLOR_PRIMARY, 565, 11) QtBind.createLineEdit(gui, '', 12, 30, 716, 1) QtBind.createLabel(gui, '1. ELIGIBLE INVENTORY' % COLOR_PRIMARY, 12, 44) QtBind.createLabel(gui, fixed_width_text( 'Select an unequipped item, then test or queue it.' % COLOR_MUTED, 350), 12, 64) btn_refresh = QtBind.createButton(gui, 'refresh_inventory', '↻ Refresh', 12, 88) btn_inspect = QtBind.createButton(gui, 'show_selected_item', 'Select Item', 105, 88) btn_test = QtBind.createButton(gui, 'test_one_roll', 'Test One Roll', 205, 88) lst_inventory = QtBind.createList(gui, 12, 116, 350, 130) QtBind.createLineEdit(gui, '', 378, 44, 1, 205) QtBind.createLabel(gui, '2. TARGET & QUEUE' % COLOR_PRIMARY, 394, 44) lbl_selected = QtBind.createLabel(gui, fixed_width_text( 'No item selected' % COLOR_MUTED, 310), 394, 65) QtBind.createLabel(gui, 'Target blue lines', 394, 92) txt_target_count = QtBind.createLineEdit(gui, '6', 494, 88, 42, 20) QtBind.createButton(gui, 'add_to_queue', 'Add / Update Queue', 548, 85) lst_queue = QtBind.createList(gui, 394, 116, 322, 100) QtBind.createButton(gui, 'remove_queued_item', 'Remove', 394, 222) QtBind.createButton(gui, 'clear_queue', 'Clear', 478, 222) QtBind.createButton(gui, 'start_automation', '▶ START', 558, 222) QtBind.createButton(gui, 'stop_automation', '■ STOP', 647, 222) lbl_result = QtBind.createLabel(gui, fixed_width_text( 'No verified Fate result yet' % COLOR_MUTED, 310), 12, 257) lbl_status = QtBind.createLabel(gui, fixed_width_text( 'Ready' % COLOR_MUTED, 700), 12, 282) refresh_inventory() set_result('Use Test One Roll to verify the first result', COLOR_WARNING) log('[%s] Loaded - ⚜ Made By FascinaTe' % pName)