from phBot import *
import QtBind
import struct
import webbrowser
from threading import Timer
pName = 'FPenManager'
pVersion = '1.0.0'
DISCORD_URL = 'https://discord.gg/eB9sGSMYBg'
OPCODE_PEN_REQUEST = 0x7151
OPCODE_PEN_RESPONSE = 0xB151
PEN_ITEM_NAME = 'Feather Pen of Fortune'
COLOR_PRIMARY = '#5b57e0'
COLOR_TEXT = '#2b3038'
COLOR_MUTED = '#9aa0ac'
COLOR_SUCCESS = '#1f9d63'
COLOR_WARNING = '#c98a1a'
COLOR_ERROR = '#d93a4d'
OFFSCREEN_X = 3000
ROLL_DELAY = 1.55
NEXT_ITEM_DELAY = 0.80
MAX_TARGET_VALUE = 2147483647
# Verified classic response codes used by the server. Repeated records with the
# same name are intentionally summed before targets are evaluated.
STAT_CODES = {
0x39: 'STR', 0x53: 'STR', 0x54: 'STR', 0x55: 'STR',
0x3A: 'INT', 0x56: 'INT', 0x57: 'INT', 0x58: 'INT',
0x3B: 'Durability', 0x59: 'Durability', 0x5A: 'Durability',
0x3C: 'Attack Rate', 0x5C: 'Attack Rate',
0x3D: 'Evade Block', 0x5F: 'Evade Block',
0x3E: 'Evade Critical', 0x3F: 'Parry Ratio', 0x65: 'Parry Ratio',
0x40: 'HP', 0x68: 'HP', 0x41: 'MP', 0x6B: 'MP',
0x42: 'Frostbite Resist', 0x43: 'Lightning Resist',
0x44: 'Fire Resist', 0x45: 'Poison Resist', 0x46: 'Zombie Resist',
0x47: 'Critical', 0x7D: 'Critical', 0x7E: 'Critical',
0x48: 'Block', 0x49: 'Stun Resist',
0x4A: 'HP/MP Recovery', 0x4B: 'Combustion Resist',
0x4C: 'Disease Resist', 0x4D: 'Sleep Resist', 0x4E: 'Fear Resist'
}
STAT_NAMES = []
for _stat_name in STAT_CODES.values():
if _stat_name not in STAT_NAMES:
STAT_NAMES.append(_stat_name)
selected_inventory = []
current_targets = []
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 ('
').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)), 680))
def set_result(message, color=COLOR_MUTED):
QtBind.setText(gui, lbl_result, fixed_width_text(
'%s' % (color, html_safe(message)), 470))
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']
return items[slot] if 0 <= slot < len(items) else None
def get_pen_slot():
inventory = get_inventory()
if not inventory or 'items' not in inventory:
return None
for slot, item in enumerate(inventory['items']):
if item and PEN_ITEM_NAME in str(item.get('name', '')):
return slot
return None
def selected_inventory_item():
index = QtBind.currentIndex(gui, lst_inventory)
return selected_inventory[index] if 0 <= index < len(selected_inventory) else None
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))
set_status('%d eligible inventory item(s) found' % len(selected_inventory),
COLOR_SUCCESS if selected_inventory else 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))), 350))
set_status('Item selected; add minimum total stat targets', COLOR_MUTED)
def read_target_value():
try:
value = int(QtBind.text(gui, txt_target_value).strip())
except Exception:
return None
return value if 1 <= value <= MAX_TARGET_VALUE else None
def refresh_targets():
QtBind.clear(gui, lst_targets)
for target in current_targets:
QtBind.append(gui, lst_targets, '%s >= %d total' % (target['name'], target['value']))
def add_target():
name = QtBind.text(gui, lst_stats)
value = read_target_value()
if not name:
set_status('Select a stat first', COLOR_WARNING)
return
if value is None:
set_status('Target value must be a positive integer', COLOR_ERROR)
return
for target in current_targets:
if target['name'] == name:
target['value'] = value
refresh_targets()
set_status('Updated %s target to %d total' % (name, value), COLOR_SUCCESS)
return
current_targets.append({'name': name, 'value': value})
refresh_targets()
set_status('Added %s target: %d total' % (name, value), COLOR_SUCCESS)
def remove_target():
index = QtBind.currentIndex(gui, lst_targets)
if index < 0 or index >= len(current_targets):
set_status('Select a target to remove', COLOR_WARNING)
return
removed = current_targets.pop(index)
refresh_targets()
set_status('Removed %s target' % removed['name'], COLOR_MUTED)
def add_to_queue():
item = selected_inventory_item()
if not item:
set_status('Select an inventory item first', COLOR_WARNING)
return
if not current_targets:
set_status('Add at least one stat target', COLOR_WARNING)
return
queued = {'slot': item['slot'], 'model': item['model'],
'name': item.get('name', 'Unknown'),
'targets': [dict(value) for value in current_targets], 'totals': {}}
for index, existing in enumerate(queued_items):
if existing['slot'] == queued['slot']:
queued_items[index] = queued
refresh_queue()
set_status('Queue targets updated for slot %d' % queued['slot'], COLOR_SUCCESS)
return
queued_items.append(queued)
refresh_queue()
set_status('Item added to the Pen queue', COLOR_SUCCESS)
def target_summary(item):
parts = []
for target in item['targets']:
current = item['totals'].get(target['name'])
parts.append('%s %s/%d' % (
target['name'], '?' if current is None else current, target['value']))
return ', '.join(parts)
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 ' '
QtBind.append(gui, lst_queue, '%s Slot %d | %s | %s' % (
marker, item['slot'], target_summary(item), 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
token = timer_token
def callback():
if is_running and token == timer_token:
send_pen_request()
pending_timer = Timer(delay, callback)
pending_timer.daemon = True
pending_timer.start()
def active_item():
return queued_items[current_item_index] if 0 <= current_item_index < len(queued_items) else None
def send_request_for(item):
pen_slot = get_pen_slot()
if pen_slot is None:
return False, 'Feather Pen of Fortune not found'
if item['slot'] > 255 or pen_slot > 255:
return False, 'Inventory slot is outside packet range'
inject_joymax(OPCODE_PEN_REQUEST,
b'\x02\x19\x02' + bytes([item['slot'], pen_slot]), False)
return True, ''
def send_pen_request():
global awaiting_response
if not is_running or awaiting_response:
return
item = active_item()
if not item:
stop_automation('All Pen targets reached', 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
ok, error = send_request_for(item)
if not ok:
stop_automation(error, COLOR_ERROR)
return
awaiting_response = True
set_status('Applying Feather Pen to %s...' % item['name'], COLOR_WARNING)
def test_one_roll():
global single_roll_item, awaiting_response
if is_running or awaiting_response:
set_status('Stop or wait for the current request first', COLOR_WARNING)
return
item = selected_inventory_item()
if not item:
set_status('Select an inventory item first', COLOR_WARNING)
return
single_roll_item = {'slot': item['slot'], 'model': item['model'], 'name': item.get('name', 'Unknown')}
ok, error = send_request_for(single_roll_item)
if not ok:
single_roll_item = None
set_status(error, COLOR_ERROR)
return
awaiting_response = True
set_status('One-roll Pen test sent; waiting for 0xB151...', COLOR_WARNING)
def start_automation():
global is_running, current_item_index
if is_running or awaiting_response:
set_status('Automation is running or a response is pending', 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 = find_inventory_item(item['slot'])
if not live or live.get('model') != item['model']:
set_status('Queued item is no longer at slot %d' % item['slot'], COLOR_ERROR)
return
if get_pen_slot() is None:
set_status('Feather Pen of Fortune not found', COLOR_ERROR)
return
current_item_index = 0
is_running = True
invalidate_timer()
refresh_queue()
set_status('Pen automation started', COLOR_SUCCESS)
send_pen_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_change_header(data):
# Live layout: 01 02 01 02, uint16 ASCII length, key, uint32 old/new.
if len(data) < 14 or data[:4] != b'\x01\x02\x01\x02':
return None
length = struct.unpack_from(' len(data):
return None
try:
key = bytes(data[6:end]).decode('ascii')
except Exception:
return None
return {'key': key, 'old': struct.unpack_from('= len(data) or data[internal_position] != 0x40:
return []
record_count = data[count_position]
visible_count = record_count - 1
if record_count < 2 or visible_count > 32:
return []
if records_start + (visible_count * 8) > len(data):
return []
records = []
for index in range(visible_count):
cursor = records_start + (index * 8)
group = data[cursor:cursor + 8]
if (group[1] != 0x02 or group[2] != 0 or group[3] != 0 or
group[5] != 0 or group[6] != 0 or group[7] != 0 or group[4] == 0):
return []
name = STAT_CODES.get(group[0])
if not name:
plugin_log('Unknown Pen stat code 0x%02X at response offset %d' % (
group[0], cursor))
return []
records.append({'code': group[0], 'name': name,
'value': group[4], 'offset': cursor})
return records
def aggregate_totals(records):
totals = {}
for record in records:
totals[record['name']] = totals.get(record['name'], 0) + record['value']
return totals
def format_totals(totals):
return ', '.join('%s=%d' % pair for pair in sorted(totals.items()))
def response_slot(data, header):
return data[header['end']] if header and header['end'] < len(data) else None
def decode_response(data):
header = parse_change_header(data)
if not header:
return None
records = parse_stat_records(data, header)
if not records:
return None
return header, records, aggregate_totals(records), response_slot(data, header)
def targets_met(totals, targets):
return all(totals.get(target['name'], 0) >= target['value'] for target in targets)
def log_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(data):
global single_roll_item, awaiting_response
item = single_roll_item
single_roll_item = None
awaiting_response = False
decoded = decode_response(data)
log_raw('One-roll', data)
if not item or not decoded:
set_result('0xB151 layout could not be verified; raw packet logged', COLOR_ERROR)
set_status('One-roll response rejected', COLOR_ERROR)
return
header, records, totals, slot = decoded
if slot != item['slot']:
set_status('One-roll response item slot did not match', COLOR_ERROR)
return
set_result('%s: %d -> %d | totals: %s' % (
header['key'], header['old'], header['new'], format_totals(totals)), COLOR_SUCCESS)
plugin_log('Slot %d one-roll totals: %s' % (item['slot'], format_totals(totals)))
set_status('One-roll test completed; no repeat was scheduled', COLOR_SUCCESS)
def process_response(data):
global current_item_index, awaiting_response
item = active_item()
awaiting_response = False
decoded = decode_response(data)
if not item or not decoded:
log_raw('Automation decode failure', data)
stop_automation('Response layout could not be verified; automation stopped', COLOR_ERROR)
return
header, records, totals, slot = decoded
if slot != item['slot']:
log_raw('Unexpected-slot response', data)
stop_automation('Response item slot did not match; automation stopped', COLOR_ERROR)
return
item['totals'] = totals
set_result('%s: %d -> %d | totals: %s' % (
header['key'], header['old'], header['new'], format_totals(totals)), COLOR_SUCCESS)
plugin_log('Slot %d totals: %s | targets: %s' % (
item['slot'], format_totals(totals), target_summary(item)))
if targets_met(totals, item['targets']):
current_item_index += 1
refresh_queue()
if current_item_index >= len(queued_items):
stop_automation('All Pen targets reached', COLOR_SUCCESS)
else:
set_status('Targets reached; moving to the next item', COLOR_SUCCESS)
schedule_request(NEXT_ITEM_DELAY)
else:
refresh_queue()
schedule_request(ROLL_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_PEN_RESPONSE:
if single_roll_item is not None:
process_one_roll(data)
elif is_running and awaiting_response:
process_response(data)
except Exception as error:
plugin_log('Packet processing error for 0x%04X: %s' % (opcode, error))
if opcode == OPCODE_PEN_RESPONSE and (is_running or awaiting_response):
stop_automation('Packet processing failed; automation stopped', COLOR_ERROR)
return True
def move_page(widgets, visible):
for widget, x, y in widgets:
QtBind.move(gui, widget, x if visible else OFFSCREEN_X, y)
def show_queue_page():
move_page(setup_widgets, False)
move_page(queue_widgets, True)
QtBind.move(gui, btn_queue_page, OFFSCREEN_X, 6)
QtBind.move(gui, btn_setup_page, 350, 6)
refresh_queue()
def show_setup_page():
move_page(queue_widgets, False)
move_page(setup_widgets, True)
QtBind.move(gui, btn_setup_page, OFFSCREEN_X, 6)
QtBind.move(gui, btn_queue_page, 350, 6)
gui = QtBind.init(__name__, pName)
QtBind.createLabel(gui, '✦ %s' % (COLOR_PRIMARY, pName), 12, 6)
QtBind.createLabel(gui, 'v%s' % (COLOR_MUTED, pVersion), 185, 12)
btn_queue_page = QtBind.createButton(gui, 'show_queue_page', 'Queue & Start →', 350, 6)
btn_setup_page = QtBind.createButton(gui, 'show_setup_page', '← Item Setup', OFFSCREEN_X, 6)
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)
setup_widgets = []
queue_widgets = []
lbl_inventory_header = QtBind.createLabel(gui, '1. ELIGIBLE INVENTORY' % COLOR_PRIMARY, 12, 44)
btn_refresh = QtBind.createButton(gui, 'refresh_inventory', '↻ Refresh', 12, 66)
btn_select = QtBind.createButton(gui, 'show_selected_item', 'Select Item', 105, 66)
btn_test = QtBind.createButton(gui, 'test_one_roll', 'Test One Roll', 205, 66)
lst_inventory = QtBind.createList(gui, 12, 94, 360, 150)
lbl_selected = QtBind.createLabel(gui, fixed_width_text('No item selected' % COLOR_MUTED, 350), 12, 256)
divider = QtBind.createLineEdit(gui, '', 385, 44, 1, 244)
lbl_stats_header = QtBind.createLabel(gui, '2. TOTAL STAT TARGETS' % COLOR_PRIMARY, 400, 44)
lst_stats = QtBind.createList(gui, 400, 66, 145, 142)
for _stat_name in STAT_NAMES:
QtBind.append(gui, lst_stats, _stat_name)
lst_targets = QtBind.createList(gui, 558, 66, 158, 142)
lbl_minimum_total = QtBind.createLabel(gui, 'Minimum total', 400, 222)
txt_target_value = QtBind.createLineEdit(gui, '10', 482, 218, 55, 20)
btn_add_target = QtBind.createButton(gui, 'add_target', 'Add / Update', 548, 215)
btn_remove_target = QtBind.createButton(gui, 'remove_target', 'Remove Target', 558, 246)
btn_add_queue = QtBind.createButton(gui, 'add_to_queue', 'Add Item to Queue', 400, 246)
setup_widgets.extend([
(lbl_inventory_header, 12, 44), (btn_refresh, 12, 66), (btn_select, 105, 66),
(btn_test, 205, 66), (lst_inventory, 12, 94), (lbl_selected, 12, 256),
(divider, 385, 44), (lbl_stats_header, 400, 44), (lst_stats, 400, 66),
(lst_targets, 558, 66), (lbl_minimum_total, 400, 222),
(txt_target_value, 482, 218),
(btn_add_target, 548, 215), (btn_remove_target, 558, 246),
(btn_add_queue, 400, 246)])
lbl_queue_header = QtBind.createLabel(gui, '3. PEN AUTOMATION QUEUE' % COLOR_PRIMARY, OFFSCREEN_X, 48)
lbl_queue_help = QtBind.createLabel(gui, fixed_width_text('Repeated stat rows are summed; every configured minimum must be reached.' % COLOR_MUTED, 650), OFFSCREEN_X, 70)
lst_queue = QtBind.createList(gui, OFFSCREEN_X, 94, 535, 118)
btn_remove_queue = QtBind.createButton(gui, 'remove_queued_item', 'Remove Item', OFFSCREEN_X, 94)
btn_clear_queue = QtBind.createButton(gui, 'clear_queue', 'Clear Queue', OFFSCREEN_X, 126)
btn_start = QtBind.createButton(gui, 'start_automation', '▶ START', OFFSCREEN_X, 226)
btn_stop = QtBind.createButton(gui, 'stop_automation', '■ STOP', OFFSCREEN_X, 226)
lbl_result = QtBind.createLabel(gui, fixed_width_text('No verified Pen result yet' % COLOR_MUTED, 470), OFFSCREEN_X, 232)
lbl_status = QtBind.createLabel(gui, fixed_width_text('Ready' % COLOR_MUTED, 680), OFFSCREEN_X, 272)
queue_widgets.extend([
(lbl_queue_header, 12, 48), (lbl_queue_help, 12, 70), (lst_queue, 12, 94),
(btn_remove_queue, 560, 94), (btn_clear_queue, 560, 126),
(btn_start, 12, 226), (btn_stop, 108, 226), (lbl_result, 210, 232),
(lbl_status, 12, 272)])
refresh_inventory()
set_result('Use Test One Roll to verify the first Pen response', COLOR_WARNING)
log('[%s] Loaded - ⚜ Made By FascinaTe' % pName)