from phBot import *
import QtBind
import json
import os
import struct
import time
import webbrowser
pName = 'FInventoryManager'
pVersion = '3.0.4'
DISCORD_URL = 'https://discord.gg/eB9sGSMYBg'
LEGACY_CONFIG_DIRECTORY = 'InventoryManagerV1'
# Verified on the target vSRO environment by FInventoryProtocolTester.
OPCODE_INVENTORY_OPERATION = 0x7034
OPCODE_INVENTORY_RESPONSE = 0xB034
OPCODE_SELECT_ENTITY = 0x7045
OPCODE_NPC_TALK = 0x7046
OPCODE_NPC_EXIT = 0x704B
OP_INVENTORY_MOVE = 0x00
INVENTORY_PACKET_ENCRYPTED = False
DEFAULT_BAG_START_SLOT = 13
ISRO_BAG_START_SLOT = 17
ISRO_LOCALE = 18
ISRO_STORAGE_TRANSITION_SECONDS = 3.0
QUIET_PERIOD_SECONDS = 1.0
RESPONSE_TIMEOUT_SECONDS = 3.0
SNAPSHOT_TIMEOUT_SECONDS = 3.0
MAX_REPLANS = 3
STATE_IDLE = 'IDLE'
STATE_PREVIEW_READY = 'PREVIEW_READY'
STATE_PREPARING = 'PREPARING'
STATE_SORTING = 'SORTING'
STATE_WAITING_RESPONSE = 'WAITING_RESPONSE'
STATE_WAITING_SNAPSHOT = 'WAITING_SNAPSHOT'
STATE_VERIFYING = 'VERIFYING'
STATE_REPLANNING = 'REPLANNING'
STATE_DONE = 'DONE'
STATE_CANCELLED = 'CANCELLED'
STATE_ERROR = 'ERROR'
COLOR_PRIMARY = '#5b57e0'
COLOR_MUTED = '#6b7280'
COLOR_SUCCESS = '#22863a'
COLOR_WARNING = '#c98a1a'
COLOR_ERROR = '#c62828'
DEFAULT_CATEGORY_ORDER = [
'Potion', 'Pill', 'Ammo', 'Weapon', 'Armor', 'Accessory',
'Other Equipment', 'Alchemy Material', 'Elixir', 'Stone',
'Quest', 'Return Scroll', 'Scroll', 'Pet Item', 'Trade Item', 'Misc'
]
# Exact overrides are intentionally empty in V1. They provide safe extension points
# for verified custom-server items without changing classifier code.
MODEL_CATEGORY_OVERRIDES = {}
SERVERNAME_CATEGORY_OVERRIDES = {}
# Stable internal-name rules. Order matters: specific categories precede broad ones.
SERVERNAME_RULES = [
('Return Scroll', ('RETURN_SCROLL', 'SCROLL_RETURN', 'RETURNSCROLL')),
('Potion', ('_POTION_', 'POTION_HP', 'POTION_MP', 'RECOVERY_POTION',
'HP_SUPERSET', 'MP_SUPERSET')),
('Pill', ('_PILL_', 'CURE_PILL')),
('Ammo', ('_ARROW_', '_BOLT_', 'QUIVER')),
('Elixir', ('ELIXIR', 'ENHANCER')),
('Stone', ('_STONE_', 'MAGICSTONE', 'ATTRSTONE')),
('Alchemy Material', ('ITEM_ETC_ARCHEMY_', 'ALCHEMY_MATERIAL', '_ELEMENT_')),
('Quest', ('ITEM_QSP_', '_QUEST_', 'QUEST_ITEM')),
('Pet Item', ('ITEM_COS_', '_PET_', 'PET_ITEM')),
('Trade Item', ('ITEM_TRADE_', '_TRADE_')),
('Scroll', ('_SCROLL_', 'ITEM_ETC_SCROLL', 'GLOBAL_CHATTING'))
]
category_order = list(DEFAULT_CATEGORY_ORDER)
enabled_categories = dict((name, True) for name in DEFAULT_CATEGORY_ORDER)
storage_category_order = list(DEFAULT_CATEGORY_ORDER)
storage_enabled_categories = dict((name, True) for name in DEFAULT_CATEGORY_ORDER)
state = STATE_IDLE
last_snapshot = None
last_observed_fingerprint = None
last_inventory_change_time = 0.0
preview_snapshot = None
preview_plan = []
pending_operation = None
cancel_requested = False
replan_count = 0
completed_operations = 0
planned_total = 0
connected_to_game = False
inventory_quick_mode = False
current_page = 'dashboard'
OFFSCREEN_X = 3000
STORAGE_UNKNOWN = 'UNKNOWN'
STORAGE_SNAPSHOT_AVAILABLE = 'SNAPSHOT_AVAILABLE'
STORAGE_PACKET_SEEN = 'PACKET_SEEN'
STORAGE_RESPONSE_SEEN = 'RESPONSE_SEEN'
STORAGE_VERIFIED = 'VERIFIED'
STORAGE_ERROR = 'ERROR'
storage_state = STORAGE_UNKNOWN
storage_snapshot = None
storage_preview_snapshot = None
storage_preview_plan = []
storage_pending = None
storage_cancel_requested = False
storage_replan_count = 0
storage_completed_operations = 0
storage_planned_total = 0
storage_quick_mode = False
selected_entity_id = None
storage_entity_id = None
isro_storage_transition_id = None
isro_storage_transition_time = 0.0
def fixed_width_text(content, width):
return ('
') % (width, content)
def config_path():
root = get_config_dir()
if not root:
return None
return os.path.join(root, pName, 'settings.json')
def legacy_config_path():
root = get_config_dir()
if not root:
return None
return os.path.join(root, LEGACY_CONFIG_DIRECTORY, 'settings.json')
def plugin_log(message):
log('[%s][Inventory] %s' % (pName, message))
class InventoryItem(object):
def __init__(self, slot, raw_item):
self.slot = slot
self.model = int(raw_item.get('model', 0) or 0)
self.servername = str(raw_item.get('servername', '') or '')
self.name = str(raw_item.get('name', '') or '')
self.quantity = int(raw_item.get('quantity', 0) or 0)
self.plus = int(raw_item.get('plus', 0) or 0)
self.durability = int(raw_item.get('durability', 0) or 0)
metadata = get_item(self.model) or {}
self.max_stack = int(metadata.get('max_stack', 1) or 1)
self.tid1 = int(metadata.get('tid1', 0) or 0)
self.tid2 = int(metadata.get('tid2', 0) or 0)
self.tid3 = int(metadata.get('tid3', 0) or 0)
self.level = int(metadata.get('level', 0) or 0)
self.category = classify_item(self)
def fingerprint(self):
return (self.model, self.quantity, self.plus, self.durability)
def equivalent_key(self):
# Quantities are deliberately ignored for stackable items. This prevents
# cosmetic reordering of two stacks from accidentally merging them.
if self.max_stack > 1:
return ('stack', self.model)
return ('item', self.model, self.plus, self.durability)
def sort_key(self, priorities):
return (priorities.get(self.category, len(priorities)), self.model,
self.servername, self.slot)
def display_name(self):
return self.name or self.servername or ('Model %d' % self.model)
class InventorySnapshot(object):
def __init__(self, size, gold, items, bag_start):
self.size = size
self.gold = gold
self.items = items
self.bag_start = bag_start
def fingerprint(self):
values = []
for slot, item in enumerate(self.items):
values.append((slot, None) if item is None else
(slot, item.model, item.quantity, item.plus, item.durability))
return tuple(values)
def occupied_bag_count(self):
return sum(1 for item in self.items[self.bag_start:] if item is not None)
class MoveOperation(object):
def __init__(self, source, destination, quantity, kind='move'):
self.source = source
self.destination = destination
self.requested_quantity = quantity
self.kind = kind
def classify_item(item):
if item.model in MODEL_CATEGORY_OVERRIDES:
return MODEL_CATEGORY_OVERRIDES[item.model]
if item.servername in SERVERNAME_CATEGORY_OVERRIDES:
return SERVERNAME_CATEGORY_OVERRIDES[item.servername]
# tid1=1 and these tid2 branches are established by local equip logic.
if item.tid1 == 1:
if item.tid2 == 6:
return 'Weapon'
if item.tid2 in (1, 2, 3, 4, 9, 10, 11):
return 'Armor'
if item.tid2 in (5, 12):
return 'Accessory'
return 'Other Equipment'
internal = item.servername.upper()
for category, tokens in SERVERNAME_RULES:
if any(token in internal for token in tokens):
return category
return 'Misc'
def get_bag_start_slot():
try:
return ISRO_BAG_START_SLOT if get_locale() == ISRO_LOCALE else DEFAULT_BAG_START_SLOT
except Exception:
return DEFAULT_BAG_START_SLOT
def take_snapshot():
raw = get_inventory()
if not raw or not isinstance(raw.get('items'), list):
return None
items = []
for slot, raw_item in enumerate(raw['items']):
if raw_item is None or int(raw_item.get('model', 0) or 0) == 0:
items.append(None)
else:
items.append(InventoryItem(slot, raw_item))
return InventorySnapshot(int(raw.get('size', len(items)) or len(items)),
int(raw.get('gold', 0) or 0), items,
get_bag_start_slot())
def take_storage_snapshot():
raw = get_storage()
if not raw or not isinstance(raw.get('items'), list):
return None
items = []
for slot, raw_item in enumerate(raw['items']):
if raw_item is None or int(raw_item.get('model', 0) or 0) == 0:
items.append(None)
else:
items.append(InventoryItem(slot, raw_item))
return InventorySnapshot(int(raw.get('size', len(items)) or len(items)),
int(raw.get('gold', 0) or 0), items, 0)
def enabled_priority_map(order=None, enabled_map=None):
if order is None:
order = category_order
if enabled_map is None:
enabled_map = enabled_categories
enabled = [name for name in order if enabled_map.get(name, True)]
disabled = [name for name in order if not enabled_map.get(name, True)]
ordered = enabled + disabled
return dict((name, index) for index, name in enumerate(ordered))
def build_desired_layout(snapshot, order=None, enabled_map=None):
priorities = enabled_priority_map(order, enabled_map)
bag_items = [item for item in snapshot.items[snapshot.bag_start:] if item is not None]
bag_items.sort(key=lambda item: item.sort_key(priorities))
desired = list(snapshot.items[:snapshot.bag_start])
desired.extend(bag_items)
desired.extend([None] * (len(snapshot.items) - len(desired)))
return desired
def equivalent(current, desired):
if current is None or desired is None:
return current is None and desired is None
return current.equivalent_key() == desired.equivalent_key()
def create_plan(snapshot, order=None, enabled_map=None):
desired = build_desired_layout(snapshot, order, enabled_map)
working = list(snapshot.items)
operations = []
for destination in range(snapshot.bag_start, len(working)):
target = desired[destination]
if equivalent(working[destination], target):
continue
source = None
for candidate in range(destination + 1, len(working)):
if equivalent(working[candidate], target):
source = candidate
break
if source is None:
# A None target is naturally pushed backwards by earlier swaps.
if target is None:
continue
raise ValueError('Could not locate desired item for slot %d.' % destination)
source_item = working[source]
if source_item is None:
continue
operations.append(MoveOperation(source, destination, source_item.quantity))
working[source], working[destination] = working[destination], working[source]
return desired, operations
def create_storage_plan(snapshot):
"""Plan safe full-source merges first, then category-order swaps."""
working = []
for item in snapshot.items:
if item is None:
working.append(None)
else:
working.append({'item': item, 'quantity': item.quantity})
operations = []
# Consolidate matching stacks toward the lowest occupied slot. Every request carries
# the complete current source quantity; B034 decides how much fits.
for destination in range(len(working)):
target = working[destination]
if target is None or target['item'].max_stack <= 1:
continue
for source in range(destination + 1, len(working)):
candidate = working[source]
if candidate is None or candidate['item'].model != target['item'].model:
continue
capacity = target['item'].max_stack - target['quantity']
if capacity <= 0:
break
requested = candidate['quantity']
applied = min(requested, capacity)
operations.append(MoveOperation(source, destination, requested, 'merge'))
target['quantity'] += applied
candidate['quantity'] -= applied
if candidate['quantity'] == 0:
working[source] = None
priorities = enabled_priority_map(storage_category_order, storage_enabled_categories)
occupied = [entry for entry in working if entry is not None]
occupied.sort(key=lambda entry: (
priorities.get(entry['item'].category, len(priorities)),
entry['item'].model, entry['item'].servername,
-entry['quantity'], entry['item'].slot))
desired = occupied + [None] * (len(working) - len(occupied))
for destination in range(len(working)):
target = desired[destination]
current = working[destination]
current_key = None if current is None else (
current['item'].equivalent_key(), current['quantity'])
target_key = None if target is None else (
target['item'].equivalent_key(), target['quantity'])
if current_key == target_key:
continue
source = None
for candidate_slot in range(destination + 1, len(working)):
candidate = working[candidate_slot]
candidate_key = None if candidate is None else (
candidate['item'].equivalent_key(), candidate['quantity'])
if candidate_key == target_key:
source = candidate_slot
break
if source is None:
if target is None:
continue
raise ValueError('Could not locate desired storage item for slot %d.' % destination)
source_entry = working[source]
kind = 'move' if working[destination] is None else 'swap'
operations.append(MoveOperation(
source, destination, source_entry['quantity'], kind))
working[source], working[destination] = working[destination], working[source]
return desired, operations
def changed_slots(before, after):
result = []
if before is None or after is None:
return result
count = max(len(before.items), len(after.items))
for slot in range(count):
old = before.items[slot] if slot < len(before.items) else None
new = after.items[slot] if slot < len(after.items) else None
old_key = None if old is None else old.fingerprint()
new_key = None if new is None else new.fingerprint()
if old_key != new_key:
result.append(slot)
return result
def build_move_payload(operation):
return struct.pack('= 7:
return {
'success': True, 'supported': True, 'operation': raw[1],
'source': raw[2], 'destination': raw[3],
'applied_quantity': struct.unpack_from(''
def decode_observed_storage_response(data):
raw = bytes(data or b'')
if not raw:
return None
if raw[0] == 0x02:
return {'success_byte': raw[0], 'error_data': raw[1:], 'trailing': raw[1:]}
if len(raw) < 4:
return None
result = {'success_byte': raw[0], 'operation': raw[1],
'source': raw[2], 'destination': raw[3], 'trailing': raw[4:]}
if raw[0] == 0x01 and raw[1] == 0x01 and len(raw) >= 6:
result['applied_quantity'] = struct.unpack_from('%s' % (color, text), 205)
gui = QtBind.init(__name__, pName)
QtBind.createLabel(gui, 'FInventoryManager' % COLOR_PRIMARY, 12, 6)
QtBind.createLabel(gui, 'v%s' % (COLOR_MUTED, pVersion), 205, 12)
btn_dashboard_page = QtBind.createButton(gui, 'show_dashboard_page', 'Dashboard', 270, 6)
btn_rules_page = QtBind.createButton(gui, 'show_rules_page', 'Inventory', 350, 6)
btn_storage_page = QtBind.createButton(gui, 'show_storage_page', 'Storage', 420, 6)
btn_discord = QtBind.createButton(gui, 'discord_clicked', u'\U0001f4ac Discord', 485, 6)
QtBind.createLabel(gui, u'\u269c Made By FascinaTe' % COLOR_PRIMARY, 580, 11)
QtBind.createLineEdit(gui, '', 12, 30, 716, 1)
lbl_dashboard_section = QtBind.createLabel(
gui, 'QUICK SORT DASHBOARD' % COLOR_PRIMARY, 12, 48)
lbl_dashboard_help = QtBind.createLabel(
gui, fixed_width_text('One click refreshes, plans, and safely executes. Open personal storage before Storage Quick Sort.' %
COLOR_MUTED, 700), 12, 70)
lbl_dash_inventory_title = QtBind.createLabel(
gui, 'INVENTORY' % COLOR_PRIMARY, 55, 112)
lbl_dash_inventory_status = QtBind.createLabel(
gui, fixed_width_text('Ready' % COLOR_SUCCESS, 275), 55, 140)
btn_quick_inventory = QtBind.createButton(
gui, 'quick_sort_inventory_clicked', 'Quick Sort Inventory', 55, 178)
btn_open_inventory = QtBind.createButton(
gui, 'show_rules_page', 'Inventory Details', 190, 178)
line_dashboard = QtBind.createLineEdit(gui, '', 364, 105, 1, 125)
lbl_dash_storage_title = QtBind.createLabel(
gui, 'PERSONAL STORAGE' % COLOR_PRIMARY, 405, 112)
lbl_dash_storage_status = QtBind.createLabel(
gui, fixed_width_text('Open storage first' % COLOR_WARNING, 275),
405, 140)
btn_quick_storage = QtBind.createButton(
gui, 'quick_sort_storage_clicked', 'Quick Sort Storage', 405, 178)
btn_open_storage = QtBind.createButton(
gui, 'show_storage_page', 'Storage Details', 535, 178)
lbl_dashboard_last = QtBind.createLabel(
gui, fixed_width_text('Last result: Ready' % COLOR_MUTED, 680),
30, 250)
lbl_rules_section = QtBind.createLabel(
gui, 'SORTING RULES' % COLOR_PRIMARY, 12, 42)
lst_categories = QtBind.createList(gui, 12, 62, 295, 155)
btn_up = QtBind.createButton(gui, 'move_category_up', 'Move Up', 12, 225)
btn_down = QtBind.createButton(gui, 'move_category_down', 'Move Down', 82, 225)
btn_toggle = QtBind.createButton(gui, 'toggle_category', 'Enable / Disable', 167, 225)
btn_reset = QtBind.createButton(gui, 'reset_categories', 'Reset Default', 12, 255)
btn_save = QtBind.createButton(gui, 'save_categories_clicked', 'Save Rules', 105, 255)
line_columns = QtBind.createLineEdit(gui, '', 320, 42, 1, 245)
lbl_status_section = QtBind.createLabel(
gui, 'INVENTORY STATUS' % COLOR_PRIMARY, 335, 42)
lbl_items_title = QtBind.createLabel(gui, 'Items:' % COLOR_MUTED, 335, 68)
lbl_items = QtBind.createLabel(gui, fixed_width_text('0 / 0', 205), 420, 68)
lbl_state_title = QtBind.createLabel(gui, 'State:' % COLOR_MUTED, 335, 94)
lbl_state = QtBind.createLabel(gui, status_html('Ready', COLOR_SUCCESS), 420, 94)
lbl_planned_title = QtBind.createLabel(gui, 'Planned:' % COLOR_MUTED, 335, 120)
lbl_planned = QtBind.createLabel(gui, fixed_width_text('-', 205), 420, 120)
lbl_completed_title = QtBind.createLabel(gui, 'Completed:' % COLOR_MUTED, 335, 146)
lbl_completed = QtBind.createLabel(gui, fixed_width_text('-', 205), 420, 146)
lbl_current_title = QtBind.createLabel(gui, 'Current:' % COLOR_MUTED, 335, 172)
lbl_current = QtBind.createLabel(gui, fixed_width_text('-', 205), 420, 172)
lbl_requested_title = QtBind.createLabel(gui, 'Requested:' % COLOR_MUTED, 335, 198)
lbl_requested = QtBind.createLabel(gui, fixed_width_text('-', 205), 420, 198)
lbl_applied_title = QtBind.createLabel(gui, 'Applied:' % COLOR_MUTED, 335, 224)
lbl_applied = QtBind.createLabel(gui, fixed_width_text('-', 205), 420, 224)
lbl_response_title = QtBind.createLabel(gui, 'Last response:' % COLOR_MUTED, 335, 250)
lbl_response = QtBind.createLabel(gui, fixed_width_text('-', 205), 420, 250)
btn_refresh = QtBind.createButton(gui, 'refresh_clicked', 'Refresh', 335, 278)
btn_preview = QtBind.createButton(gui, 'preview_clicked', 'Preview Sort', 400, 278)
btn_sort = QtBind.createButton(gui, 'sort_clicked', 'Start Sort', 12, 265)
btn_cancel = QtBind.createButton(gui, 'cancel_clicked', 'Cancel', 90, 265)
lbl_preview_section = QtBind.createLabel(
gui, 'PREVIEW / UNCLASSIFIED ITEMS' % COLOR_PRIMARY, 12, 42)
lbl_preview_help = QtBind.createLabel(
gui, 'Preview sends no packets. Review changes before starting.' % COLOR_MUTED, 12, 62)
lst_preview = QtBind.createList(gui, 12, 82, 716, 170)
btn_storage_refresh = QtBind.createButton(gui, 'storage_refresh_clicked', 'Refresh', 12, 90)
btn_storage_preview = QtBind.createButton(gui, 'storage_preview_clicked', 'Preview Sort', 115, 90)
btn_storage_start = QtBind.createButton(gui, 'storage_sort_clicked', 'Start Sort', 250, 90)
btn_storage_cancel_sort = QtBind.createButton(gui, 'storage_cancel_clicked', 'Cancel', 335, 90)
lbl_storage_rules = QtBind.createLabel(
gui, 'STORAGE RULES' % COLOR_PRIMARY, 12, 125)
lst_storage_categories = QtBind.createList(gui, 12, 145, 295, 155)
btn_storage_up = QtBind.createButton(gui, 'storage_move_up', 'Up', 12, 258)
btn_storage_down = QtBind.createButton(gui, 'storage_move_down', 'Down', 52, 258)
btn_storage_toggle = QtBind.createButton(gui, 'storage_toggle_category', 'Enable / Disable', 102, 258)
btn_storage_reset = QtBind.createButton(gui, 'storage_reset_categories', 'Reset', 12, 288)
btn_storage_save = QtBind.createButton(gui, 'storage_save_rules', 'Save', 65, 288)
lbl_storage_items = QtBind.createLabel(
gui, 'STORAGE ITEMS / PREVIEW' % COLOR_PRIMARY, 285, 125)
lst_storage_items = QtBind.createList(gui, 285, 145, 716, 170)
lbl_storage_lock = QtBind.createLabel(
gui, fixed_width_text('Preview first, or use Quick Sort from Dashboard.' %
COLOR_MUTED, 430), 285, 296)
line_storage_columns = QtBind.createLineEdit(gui, '', 320, 42, 1, 245)
lbl_storage_status_section = QtBind.createLabel(
gui, 'PERSONAL STORAGE STATUS' % COLOR_PRIMARY, 335, 42)
lbl_storage_count_title = QtBind.createLabel(
gui, 'Items:' % COLOR_MUTED, 335, 68)
lbl_storage_count = QtBind.createLabel(gui, fixed_width_text('0 / 0', 205), 420, 68)
lbl_storage_detail_state_title = QtBind.createLabel(
gui, 'State:' % COLOR_MUTED, 335, 94)
lbl_storage_detail_state = QtBind.createLabel(
gui, status_html('Open storage first', COLOR_WARNING), 420, 94)
lbl_storage_planned_title = QtBind.createLabel(
gui, 'Planned:' % COLOR_MUTED, 335, 120)
lbl_storage_planned = QtBind.createLabel(gui, fixed_width_text('-', 205), 420, 120)
lbl_storage_completed_title = QtBind.createLabel(
gui, 'Completed:' % COLOR_MUTED, 335, 146)
lbl_storage_completed = QtBind.createLabel(gui, fixed_width_text('-', 205), 420, 146)
lbl_storage_current_title = QtBind.createLabel(
gui, 'Current:' % COLOR_MUTED, 335, 172)
lbl_storage_current = QtBind.createLabel(gui, fixed_width_text('-', 205), 420, 172)
lbl_storage_requested_title = QtBind.createLabel(
gui, 'Requested:' % COLOR_MUTED, 335, 198)
lbl_storage_requested = QtBind.createLabel(gui, fixed_width_text('-', 205), 420, 198)
lbl_storage_applied_title = QtBind.createLabel(
gui, 'Applied:' % COLOR_MUTED, 335, 224)
lbl_storage_applied = QtBind.createLabel(gui, fixed_width_text('-', 205), 420, 224)
lbl_storage_response_title = QtBind.createLabel(
gui, 'Last response:' % COLOR_MUTED, 335, 250)
lbl_storage_response = QtBind.createLabel(gui, fixed_width_text('-', 205), 420, 250)
RULES_PAGE_WIDGETS = (
(lbl_rules_section, 12, 42), (lst_categories, 12, 62),
(btn_up, 12, 225), (btn_down, 82, 225), (btn_toggle, 167, 225),
(btn_reset, 12, 255), (btn_save, 105, 255), (line_columns, 320, 42),
(lbl_status_section, 335, 42), (lbl_items_title, 335, 68), (lbl_items, 420, 68),
(lbl_state_title, 335, 94), (lbl_state, 420, 94),
(lbl_planned_title, 335, 120), (lbl_planned, 420, 120),
(lbl_completed_title, 335, 146), (lbl_completed, 420, 146),
(lbl_current_title, 335, 172), (lbl_current, 420, 172),
(lbl_requested_title, 335, 198), (lbl_requested, 420, 198),
(lbl_applied_title, 335, 224), (lbl_applied, 420, 224),
(lbl_response_title, 335, 250), (lbl_response, 420, 250),
(btn_refresh, 335, 278), (btn_preview, 400, 278)
)
DASHBOARD_PAGE_WIDGETS = (
(lbl_dashboard_section, 12, 48), (lbl_dashboard_help, 12, 70),
(lbl_dash_inventory_title, 55, 112), (lbl_dash_inventory_status, 55, 140),
(btn_quick_inventory, 55, 178), (btn_open_inventory, 190, 178),
(line_dashboard, 364, 105), (lbl_dash_storage_title, 405, 112),
(lbl_dash_storage_status, 405, 140), (btn_quick_storage, 405, 178),
(btn_open_storage, 535, 178), (lbl_dashboard_last, 30, 250)
)
PREVIEW_PAGE_WIDGETS = (
(lbl_preview_section, 12, 42), (lbl_preview_help, 12, 62),
(lst_preview, 12, 82), (btn_sort, 12, 265), (btn_cancel, 90, 265)
)
STORAGE_PAGE_WIDGETS = (
(lbl_storage_rules, 12, 42), (lst_storage_categories, 12, 62),
(btn_storage_up, 12, 225), (btn_storage_down, 82, 225),
(btn_storage_toggle, 167, 225), (btn_storage_reset, 12, 255),
(btn_storage_save, 105, 255), (line_storage_columns, 320, 42),
(lbl_storage_status_section, 335, 42),
(lbl_storage_count_title, 335, 68), (lbl_storage_count, 420, 68),
(lbl_storage_detail_state_title, 335, 94), (lbl_storage_detail_state, 420, 94),
(lbl_storage_planned_title, 335, 120), (lbl_storage_planned, 420, 120),
(lbl_storage_completed_title, 335, 146), (lbl_storage_completed, 420, 146),
(lbl_storage_current_title, 335, 172), (lbl_storage_current, 420, 172),
(lbl_storage_requested_title, 335, 198), (lbl_storage_requested, 420, 198),
(lbl_storage_applied_title, 335, 224), (lbl_storage_applied, 420, 224),
(lbl_storage_response_title, 335, 250), (lbl_storage_response, 420, 250),
(btn_storage_refresh, 335, 278), (btn_storage_preview, 440, 278)
)
STORAGE_PREVIEW_PAGE_WIDGETS = (
(lbl_storage_items, 12, 42), (lbl_storage_lock, 12, 62),
(lst_storage_items, 12, 82), (btn_storage_start, 12, 265),
(btn_storage_cancel_sort, 90, 265)
)
def move_page(widgets, visible):
for widget, x, y in widgets:
QtBind.move(gui, widget, x if visible else OFFSCREEN_X, y)
def show_rules_page():
global current_page
current_page = 'rules'
move_page(DASHBOARD_PAGE_WIDGETS, False)
move_page(PREVIEW_PAGE_WIDGETS, False)
move_page(STORAGE_PAGE_WIDGETS, False)
move_page(STORAGE_PREVIEW_PAGE_WIDGETS, False)
move_page(RULES_PAGE_WIDGETS, True)
def show_preview_page():
global current_page
current_page = 'preview'
move_page(DASHBOARD_PAGE_WIDGETS, False)
move_page(RULES_PAGE_WIDGETS, False)
move_page(STORAGE_PAGE_WIDGETS, False)
move_page(STORAGE_PREVIEW_PAGE_WIDGETS, False)
move_page(PREVIEW_PAGE_WIDGETS, True)
def show_storage_page():
global current_page
current_page = 'storage'
move_page(DASHBOARD_PAGE_WIDGETS, False)
move_page(RULES_PAGE_WIDGETS, False)
move_page(PREVIEW_PAGE_WIDGETS, False)
move_page(STORAGE_PREVIEW_PAGE_WIDGETS, False)
move_page(STORAGE_PAGE_WIDGETS, True)
def show_storage_preview_page():
global current_page
current_page = 'storage_preview'
move_page(DASHBOARD_PAGE_WIDGETS, False)
move_page(RULES_PAGE_WIDGETS, False)
move_page(PREVIEW_PAGE_WIDGETS, False)
move_page(STORAGE_PAGE_WIDGETS, False)
move_page(STORAGE_PREVIEW_PAGE_WIDGETS, True)
def show_dashboard_page():
global current_page
current_page = 'dashboard'
move_page(RULES_PAGE_WIDGETS, False)
move_page(PREVIEW_PAGE_WIDGETS, False)
move_page(STORAGE_PAGE_WIDGETS, False)
move_page(STORAGE_PREVIEW_PAGE_WIDGETS, False)
move_page(DASHBOARD_PAGE_WIDGETS, True)
show_dashboard_page()
def set_state(new_state, message=None, color=None):
global state
state = new_state
if message is None:
message = new_state
if color is None:
color = COLOR_WARNING
if new_state in (STATE_IDLE, STATE_DONE, STATE_PREVIEW_READY):
color = COLOR_SUCCESS
elif new_state in (STATE_ERROR, STATE_CANCELLED):
color = COLOR_ERROR
QtBind.setText(gui, lbl_state, status_html(message, color))
QtBind.setText(gui, lbl_dash_inventory_status,
fixed_width_text('%s' %
(color, message), 275))
busy = new_state in (STATE_PREPARING, STATE_SORTING, STATE_WAITING_RESPONSE,
STATE_WAITING_SNAPSHOT, STATE_VERIFYING, STATE_REPLANNING)
QtBind.setEnabled(gui, btn_sort, new_state == STATE_PREVIEW_READY)
QtBind.setEnabled(gui, btn_preview, not busy)
QtBind.setEnabled(gui, btn_cancel, busy)
QtBind.setEnabled(gui, btn_up, not busy)
QtBind.setEnabled(gui, btn_down, not busy)
QtBind.setEnabled(gui, btn_toggle, not busy)
QtBind.setEnabled(gui, btn_reset, not busy)
QtBind.setEnabled(gui, btn_quick_inventory, not busy and storage_pending is None)
def refresh_category_list():
QtBind.clear(gui, lst_categories)
for index, name in enumerate(category_order):
marker = '[x]' if enabled_categories.get(name, True) else '[ ]'
QtBind.append(gui, lst_categories, '%02d. %s %s' % (index + 1, marker, name))
def selected_category_index():
index = QtBind.currentIndex(gui, lst_categories)
return index if 0 <= index < len(category_order) else -1
def invalidate_preview():
global preview_snapshot, preview_plan
preview_snapshot = None
preview_plan = []
if state == STATE_PREVIEW_READY:
set_state(STATE_IDLE, 'Ready', COLOR_SUCCESS)
QtBind.setText(gui, lbl_planned, fixed_width_text('-', 205))
def move_category_up():
index = selected_category_index()
if index <= 0:
return
category_order[index - 1], category_order[index] = category_order[index], category_order[index - 1]
refresh_category_list()
invalidate_preview()
def move_category_down():
index = selected_category_index()
if index < 0 or index >= len(category_order) - 1:
return
category_order[index + 1], category_order[index] = category_order[index], category_order[index + 1]
refresh_category_list()
invalidate_preview()
def toggle_category():
index = selected_category_index()
if index < 0:
return
name = category_order[index]
enabled_categories[name] = not enabled_categories.get(name, True)
refresh_category_list()
invalidate_preview()
def reset_categories():
global category_order, enabled_categories
category_order = list(DEFAULT_CATEGORY_ORDER)
enabled_categories = dict((name, True) for name in DEFAULT_CATEGORY_ORDER)
refresh_category_list()
invalidate_preview()
def save_categories_clicked():
if save_settings():
plugin_log('Sorting rules saved.')
def storage_log(message):
log('[%s][Storage] %s' % (pName, message))
def set_storage_state(new_state, message, color):
global storage_state
storage_state = new_state
QtBind.setText(gui, lbl_storage_detail_state, status_html(message, color))
QtBind.setText(gui, lbl_dash_storage_status,
fixed_width_text('%s' %
(color, message), 275))
busy = storage_pending is not None
QtBind.setEnabled(gui, btn_storage_start,
not busy and storage_preview_snapshot is not None)
QtBind.setEnabled(gui, btn_storage_cancel_sort, busy)
QtBind.setEnabled(gui, btn_storage_preview, not busy)
QtBind.setEnabled(gui, btn_storage_refresh, not busy)
QtBind.setEnabled(gui, btn_quick_storage, not busy and pending_operation is None)
QtBind.setEnabled(gui, btn_quick_inventory, not busy and pending_operation is None)
def update_storage_status(snapshot):
if snapshot is None:
QtBind.setText(gui, lbl_storage_count, fixed_width_text('Unavailable', 205))
return
occupied = sum(1 for item in snapshot.items if item is not None)
QtBind.setText(gui, lbl_storage_count,
fixed_width_text('%d / %d' % (occupied, len(snapshot.items)), 205))
def refresh_storage_category_list():
QtBind.clear(gui, lst_storage_categories)
for index, name in enumerate(storage_category_order):
marker = '[x]' if storage_enabled_categories.get(name, True) else '[ ]'
QtBind.append(gui, lst_storage_categories, '%02d. %s %s' %
(index + 1, marker, name))
def selected_storage_category_index():
index = QtBind.currentIndex(gui, lst_storage_categories)
return index if 0 <= index < len(storage_category_order) else -1
def invalidate_storage_preview():
global storage_preview_snapshot, storage_preview_plan
storage_preview_snapshot = None
storage_preview_plan = []
def storage_move_up():
index = selected_storage_category_index()
if index <= 0:
return
storage_category_order[index - 1], storage_category_order[index] = (
storage_category_order[index], storage_category_order[index - 1])
refresh_storage_category_list()
invalidate_storage_preview()
def storage_move_down():
index = selected_storage_category_index()
if index < 0 or index >= len(storage_category_order) - 1:
return
storage_category_order[index + 1], storage_category_order[index] = (
storage_category_order[index], storage_category_order[index + 1])
refresh_storage_category_list()
invalidate_storage_preview()
def storage_toggle_category():
index = selected_storage_category_index()
if index < 0:
return
name = storage_category_order[index]
storage_enabled_categories[name] = not storage_enabled_categories.get(name, True)
refresh_storage_category_list()
invalidate_storage_preview()
def storage_reset_categories():
global storage_category_order, storage_enabled_categories
storage_category_order = list(DEFAULT_CATEGORY_ORDER)
storage_enabled_categories = dict((name, True) for name in DEFAULT_CATEGORY_ORDER)
refresh_storage_category_list()
invalidate_storage_preview()
def storage_save_rules():
if save_settings():
storage_log('Storage sorting rules saved.')
def render_storage_items(snapshot):
QtBind.clear(gui, lst_storage_items)
if snapshot is None:
QtBind.append(gui, lst_storage_items,
'Personal storage is unavailable. Open it manually, then refresh.')
return
occupied = 0
for slot, item in enumerate(snapshot.items):
if item is None:
QtBind.append(gui, lst_storage_items, '%03d | EMPTY' % slot)
else:
occupied += 1
QtBind.append(gui, lst_storage_items, '%03d | %-18s | x%-6d | %s' %
(slot, item.category[:18], item.quantity,
item.display_name()[:35]))
storage_log('Snapshot captured: %d item(s), %d slot(s).' %
(occupied, len(snapshot.items)))
def storage_refresh_clicked():
global storage_snapshot
snapshot = take_storage_snapshot()
if snapshot is None:
storage_snapshot = None
render_storage_items(None)
set_storage_state(STORAGE_UNKNOWN,
'Unavailable; open personal storage manually.', COLOR_ERROR)
update_storage_status(None)
storage_log('get_storage() is unavailable or has no loaded snapshot.')
return
storage_snapshot = snapshot
invalidate_storage_preview()
render_storage_items(snapshot)
update_storage_status(snapshot)
set_storage_state(STORAGE_SNAPSHOT_AVAILABLE,
'Snapshot available', COLOR_SUCCESS)
def storage_preview_clicked():
global storage_preview_snapshot, storage_preview_plan, storage_snapshot
global storage_planned_total
if storage_pending is not None:
return
snapshot = take_storage_snapshot()
if snapshot is None:
storage_refresh_clicked()
return
try:
_, operations = create_storage_plan(snapshot)
except ValueError as error:
set_storage_state(STORAGE_ERROR, 'Planning failed', COLOR_ERROR)
storage_log('Planning error: %s' % error)
return
storage_snapshot = snapshot
storage_preview_snapshot = snapshot
storage_preview_plan = operations
storage_planned_total = len(operations)
update_storage_status(snapshot)
QtBind.clear(gui, lst_storage_items)
QtBind.append(gui, lst_storage_items, 'Preview only - zero packets sent.')
QtBind.append(gui, lst_storage_items, 'Planned operations: %d' % len(operations))
for index, operation in enumerate(operations[:35]):
QtBind.append(gui, lst_storage_items, '%02d. %s %d -> %d (qty=%d)' %
(index + 1, operation.kind.upper(), operation.source,
operation.destination, operation.requested_quantity))
if len(operations) > 35:
QtBind.append(gui, lst_storage_items, '... %d more operation(s)' %
(len(operations) - 35))
set_storage_state(STORAGE_SNAPSHOT_AVAILABLE,
'Preview ready: %d operation(s).' % len(operations), COLOR_SUCCESS)
QtBind.setText(gui, lbl_storage_planned, fixed_width_text(str(len(operations)), 205))
QtBind.setText(gui, lbl_storage_completed, fixed_width_text('0', 205))
QtBind.setText(gui, lbl_storage_current, fixed_width_text('-', 205))
QtBind.setText(gui, lbl_storage_requested, fixed_width_text('-', 205))
QtBind.setText(gui, lbl_storage_applied, fixed_width_text('-', 205))
QtBind.setText(gui, lbl_storage_response, fixed_width_text('-', 205))
storage_log('Storage preview created: %d operation(s), zero packets sent.' %
len(operations))
show_storage_preview_page()
def finish_storage_run(final_state, message, color):
global storage_pending, storage_cancel_requested, storage_snapshot
global storage_quick_mode, storage_preview_snapshot, storage_preview_plan
was_quick = storage_quick_mode
storage_pending = None
storage_cancel_requested = False
storage_quick_mode = False
storage_preview_snapshot = None
storage_preview_plan = []
storage_snapshot = take_storage_snapshot()
update_storage_status(storage_snapshot)
set_storage_state(final_state, message, color)
storage_log(message)
QtBind.setText(gui, lbl_dashboard_last,
fixed_width_text('Last result: %s' %
(color, message), 680))
if was_quick:
show_dashboard_page()
else:
show_storage_page()
def fail_storage_run(message):
finish_storage_run(STORAGE_ERROR, message, COLOR_ERROR)
def storage_sort_clicked():
global storage_cancel_requested, storage_replan_count
global storage_completed_operations, storage_planned_total
if storage_pending is not None or storage_preview_snapshot is None:
return
if pending_operation is not None:
set_storage_state(STORAGE_ERROR, 'Inventory sorting is active.', COLOR_ERROR)
return
if storage_entity_id is None:
set_storage_state(STORAGE_ERROR, 'Open personal storage and try again.', COLOR_ERROR)
storage_log('Storage sorting blocked: no active storage NPC session.')
return
current = take_storage_snapshot()
if current is None:
fail_storage_run('Open personal storage and try again.')
return
if current.fingerprint() != storage_preview_snapshot.fingerprint():
set_storage_state(STORAGE_ERROR, 'Preview is stale; preview again.', COLOR_ERROR)
storage_log('Storage changed after preview. Create a new preview.')
return
storage_cancel_requested = False
storage_replan_count = 0
storage_completed_operations = 0
storage_planned_total = len(storage_preview_plan)
storage_log('Sorting started from preview: %d operation(s).' % storage_planned_total)
storage_replan_and_continue(current, False)
def quick_sort_storage_clicked():
global storage_preview_snapshot, storage_preview_plan, storage_snapshot
global storage_quick_mode, storage_planned_total
if pending_operation is not None or storage_pending is not None:
return
if storage_entity_id is None:
set_storage_state(STORAGE_ERROR, 'Open personal storage and try again.', COLOR_ERROR)
storage_log('Quick Sort blocked: no active storage NPC session.')
return
snapshot = take_storage_snapshot()
if snapshot is None:
set_storage_state(STORAGE_ERROR, 'Open personal storage and try again.', COLOR_ERROR)
return
try:
_, operations = create_storage_plan(snapshot)
except ValueError as error:
fail_storage_run('Storage planning failed: %s' % error)
return
storage_snapshot = snapshot
storage_preview_snapshot = snapshot
storage_preview_plan = operations
storage_planned_total = len(operations)
storage_quick_mode = True
storage_log('Quick Sort prepared: %d operation(s).' % len(operations))
if not operations:
finish_storage_run(STORAGE_VERIFIED, 'Storage already sorted.', COLOR_SUCCESS)
return
storage_sort_clicked()
def storage_cancel_clicked():
global storage_cancel_requested
if storage_pending is None:
return
storage_cancel_requested = True
storage_log('Cancel requested; no new storage operation will be sent.')
def storage_replan_and_continue(snapshot, count_replan):
global storage_replan_count, storage_preview_plan, storage_planned_total
if storage_cancel_requested:
finish_storage_run(STATE_CANCELLED, 'Storage sorting cancelled.', COLOR_ERROR)
return
if count_replan:
storage_replan_count += 1
if storage_replan_count > MAX_REPLANS:
fail_storage_run('Storage is unstable. Sorting cancelled.')
return
try:
_, operations = create_storage_plan(snapshot)
except ValueError as error:
fail_storage_run('Storage replanning failed: %s' % error)
return
storage_preview_plan = operations
if not operations:
finish_storage_run(STORAGE_VERIFIED, 'Storage sorting completed.', COLOR_SUCCESS)
return
if count_replan:
storage_planned_total = storage_completed_operations + len(operations)
storage_log('New storage plan: %d remaining operation(s).' % len(operations))
storage_send_next_operation(snapshot, operations[0])
def storage_send_next_operation(snapshot, operation):
global storage_pending
if storage_entity_id is None:
fail_storage_run('Storage session ended. Open personal storage and try again.')
return
fresh = take_storage_snapshot()
if fresh is None:
fail_storage_run('Open personal storage and try again.')
return
if fresh.fingerprint() != snapshot.fingerprint():
storage_log('Storage changed before injection; replanning without sending.')
storage_replan_and_continue(fresh, True)
return
if operation.source >= len(fresh.items) or operation.destination >= len(fresh.items):
fail_storage_run('Planned slot is outside personal storage.')
return
source_item = fresh.items[operation.source]
if source_item is None:
storage_replan_and_continue(fresh, True)
return
operation.requested_quantity = source_item.quantity
payload = (struct.pack(' %d' %
(storage_completed_operations + 1, storage_planned_total,
operation.source, operation.destination), COLOR_WARNING)
QtBind.setText(gui, lbl_storage_current,
fixed_width_text('%d -> %d' %
(operation.source, operation.destination), 205))
QtBind.setText(gui, lbl_storage_requested,
fixed_width_text(str(operation.requested_quantity), 205))
QtBind.setText(gui, lbl_storage_applied, fixed_width_text('-', 205))
QtBind.setText(gui, lbl_storage_response, fixed_width_text('Pending', 205))
QtBind.setText(gui, lbl_storage_completed,
fixed_width_text('%d / %d' %
(storage_completed_operations, storage_planned_total), 205))
storage_log('Executing %d/%d: %s %d -> %d qty=%d' %
(storage_completed_operations + 1, storage_planned_total,
operation.kind, operation.source, operation.destination,
operation.requested_quantity))
try:
inject_joymax(OPCODE_INVENTORY_OPERATION, payload, INVENTORY_PACKET_ENCRYPTED)
except Exception as error:
fail_storage_run('Storage packet injection failed: %s' % error)
def poll_storage_injection():
global storage_snapshot, storage_pending, storage_completed_operations
if storage_pending is None:
return
now = time.time()
pending = storage_pending
if pending['response'] is None:
if now - pending['sent_at'] >= RESPONSE_TIMEOUT_SECONDS:
fail_storage_run('Storage response timed out. Open storage and try again.')
return
current = take_storage_snapshot()
if current is None:
fail_storage_run('Storage became unavailable after B034.')
return
if current.fingerprint() != pending['baseline'].fingerprint():
changes = changed_slots(pending['baseline'], current)
unexpected = [slot for slot in changes
if slot not in (pending['source'], pending['destination'])]
old_source = pending['baseline'].items[pending['source']]
old_destination = pending['baseline'].items[pending['destination']]
new_source = current.items[pending['source']]
new_destination = current.items[pending['destination']]
applied = pending['response']['applied_quantity']
if pending['kind'] == 'merge':
expected_source_quantity = old_source.quantity - applied
source_matches = (
(expected_source_quantity == 0 and new_source is None) or
(expected_source_quantity > 0 and new_source is not None and
new_source.model == old_source.model and
new_source.quantity == expected_source_quantity))
destination_matches = (
old_destination is not None and new_destination is not None and
new_destination.model == old_destination.model and
new_destination.quantity == old_destination.quantity + applied)
else:
source_matches = (
(old_destination is None and new_source is None) or
(old_destination is not None and new_source is not None and
old_destination.fingerprint() == new_source.fingerprint()))
destination_matches = (
new_destination is not None and
old_source.fingerprint() == new_destination.fingerprint())
storage_pending = None
storage_snapshot = current
if not changes or not source_matches or not destination_matches:
fail_storage_run('Storage snapshot did not match the server response.')
return
storage_completed_operations += 1
QtBind.setText(gui, lbl_storage_completed,
fixed_width_text('%d / %d' %
(storage_completed_operations,
storage_planned_total), 205))
storage_log('Snapshot verified for slots %d and %d (applied=%d).' %
(pending['source'], pending['destination'], applied))
storage_replan_and_continue(current, bool(unexpected))
return
if now - pending['response_at'] >= SNAPSHOT_TIMEOUT_SECONDS:
fail_storage_run('Storage snapshot timed out after B034. Open storage and try again.')
def update_inventory_status(snapshot):
if snapshot is None:
QtBind.setText(gui, lbl_items, fixed_width_text('Unavailable', 205))
return
usable = max(0, len(snapshot.items) - snapshot.bag_start)
QtBind.setText(gui, lbl_items,
fixed_width_text('%d / %d' % (snapshot.occupied_bag_count(), usable), 205))
def populate_preview(snapshot, desired, operations):
QtBind.clear(gui, lst_preview)
counts = dict((name, 0) for name in DEFAULT_CATEGORY_ORDER)
for item in snapshot.items[snapshot.bag_start:]:
if item is not None:
counts[item.category] = counts.get(item.category, 0) + 1
QtBind.append(gui, lst_preview, '--- Category summary ---')
summary_parts = ['%s: %d' % (name, counts[name]) for name in category_order if counts.get(name, 0)]
for offset in range(0, len(summary_parts), 4):
QtBind.append(gui, lst_preview, ' | '.join(summary_parts[offset:offset + 4]))
QtBind.append(gui, lst_preview, '--- Layout changes ---')
changes = 0
for slot in range(snapshot.bag_start, len(snapshot.items)):
current = snapshot.items[slot]
target = desired[slot]
if not equivalent(current, target):
changes += 1
QtBind.append(gui, lst_preview, 'Slot %d: %s -> %s' %
(slot, safe_item_text(current), safe_item_text(target)))
if changes == 0:
QtBind.append(gui, lst_preview, 'Inventory already matches the selected category order.')
else:
QtBind.append(gui, lst_preview, '--- Planned operations: %d ---' % len(operations))
for index, operation in enumerate(operations[:25]):
QtBind.append(gui, lst_preview, '%02d. Slot %d -> %d (qty=%d)' %
(index + 1, operation.source, operation.destination,
operation.requested_quantity))
if len(operations) > 25:
QtBind.append(gui, lst_preview, '... %d more operation(s)' % (len(operations) - 25))
unknown = [item for item in snapshot.items[snapshot.bag_start:]
if item is not None and item.category == 'Misc']
if unknown:
QtBind.append(gui, lst_preview, '--- Misc / unclassified: %d ---' % len(unknown))
for item in unknown[:15]:
QtBind.append(gui, lst_preview, 'Slot %d: %s | %s | model=%d' %
(item.slot, item.name, item.servername, item.model))
def refresh_clicked():
global last_snapshot, last_observed_fingerprint, last_inventory_change_time
snapshot = take_snapshot()
if snapshot is None:
set_state(STATE_ERROR, 'Inventory unavailable', COLOR_ERROR)
update_inventory_status(None)
return
last_snapshot = snapshot
last_observed_fingerprint = snapshot.fingerprint()
last_inventory_change_time = time.time()
update_inventory_status(snapshot)
invalidate_preview()
plugin_log('Snapshot captured: %d item(s).' % snapshot.occupied_bag_count())
def preview_clicked():
global preview_snapshot, preview_plan, planned_total
if pending_operation is not None:
return
snapshot = take_snapshot()
if snapshot is None:
set_state(STATE_ERROR, 'Inventory unavailable', COLOR_ERROR)
return
try:
desired, operations = create_plan(snapshot)
except ValueError as error:
set_state(STATE_ERROR, 'Planning failed', COLOR_ERROR)
plugin_log('Planning error: %s' % error)
return
preview_snapshot = snapshot
preview_plan = operations
planned_total = len(operations)
update_inventory_status(snapshot)
populate_preview(snapshot, desired, operations)
QtBind.setText(gui, lbl_planned, fixed_width_text(str(len(operations)), 205))
QtBind.setText(gui, lbl_completed, fixed_width_text('0', 205))
QtBind.setText(gui, lbl_current, fixed_width_text('-', 205))
QtBind.setText(gui, lbl_requested, fixed_width_text('-', 205))
QtBind.setText(gui, lbl_applied, fixed_width_text('-', 205))
QtBind.setText(gui, lbl_response, fixed_width_text('-', 205))
set_state(STATE_PREVIEW_READY, 'Preview ready', COLOR_SUCCESS)
plugin_log('Sort preview created: %d operation(s).' % len(operations))
show_preview_page()
def finish_run(final_state, message, color):
global pending_operation, cancel_requested, last_snapshot, inventory_quick_mode
was_quick = inventory_quick_mode
pending_operation = None
cancel_requested = False
last_snapshot = take_snapshot()
update_inventory_status(last_snapshot)
set_state(final_state, message, color)
plugin_log(message)
QtBind.setText(gui, lbl_dashboard_last,
fixed_width_text('Last result: %s' %
(color, message), 680))
inventory_quick_mode = False
show_dashboard_page() if was_quick else show_rules_page()
def fail_run(message):
finish_run(STATE_ERROR, message, COLOR_ERROR)
def sort_clicked():
global cancel_requested, replan_count, completed_operations, planned_total
if state != STATE_PREVIEW_READY or preview_snapshot is None:
return
if storage_pending is not None:
plugin_log('Inventory sorting blocked while a storage operation is active.')
return
current = take_snapshot()
if current is None:
fail_run('Inventory unavailable. Sorting stopped.')
return
if current.fingerprint() != preview_snapshot.fingerprint():
set_state(STATE_IDLE, 'Preview is stale', COLOR_ERROR)
plugin_log('Inventory changed after preview. Create a new preview.')
return
if time.time() - last_inventory_change_time < QUIET_PERIOD_SECONDS:
set_state(STATE_PREVIEW_READY, 'Wait for inventory', COLOR_WARNING)
plugin_log('Inventory quiet period has not elapsed; sort not started.')
return
cancel_requested = False
replan_count = 0
completed_operations = 0
planned_total = len(preview_plan)
set_state(STATE_PREPARING, 'Preparing', COLOR_WARNING)
plugin_log('Sorting started from preview: %d operation(s).' % planned_total)
replan_and_continue(current, False)
def quick_sort_inventory_clicked():
global preview_snapshot, preview_plan, planned_total
global last_snapshot, last_observed_fingerprint, last_inventory_change_time
global inventory_quick_mode
if pending_operation is not None or storage_pending is not None:
return
snapshot = take_snapshot()
if snapshot is None:
set_state(STATE_ERROR, 'Inventory unavailable', COLOR_ERROR)
return
try:
_, operations = create_plan(snapshot)
except ValueError as error:
fail_run('Inventory planning failed: %s' % error)
return
last_snapshot = snapshot
last_observed_fingerprint = snapshot.fingerprint()
last_inventory_change_time = time.time() - QUIET_PERIOD_SECONDS - 0.1
preview_snapshot = snapshot
preview_plan = operations
planned_total = len(operations)
inventory_quick_mode = True
update_inventory_status(snapshot)
plugin_log('Quick Sort prepared: %d operation(s).' % len(operations))
if not operations:
finish_run(STATE_DONE, 'Inventory already sorted.', COLOR_SUCCESS)
return
set_state(STATE_PREVIEW_READY, 'Quick Sort ready', COLOR_SUCCESS)
sort_clicked()
def cancel_clicked():
global cancel_requested
if state not in (STATE_PREPARING, STATE_SORTING, STATE_WAITING_RESPONSE,
STATE_WAITING_SNAPSHOT, STATE_VERIFYING, STATE_REPLANNING):
return
cancel_requested = True
plugin_log('Cancel requested; no new operation will be sent.')
if pending_operation is None:
finish_run(STATE_CANCELLED, 'Sorting cancelled.', COLOR_ERROR)
def replan_and_continue(snapshot, count_replan):
global replan_count, preview_plan, planned_total
if cancel_requested:
finish_run(STATE_CANCELLED, 'Sorting cancelled.', COLOR_ERROR)
return
if count_replan:
replan_count += 1
if replan_count > MAX_REPLANS:
fail_run('Inventory is unstable. Sorting cancelled.')
return
set_state(STATE_REPLANNING, 'Replanning %d/%d' % (replan_count, MAX_REPLANS), COLOR_WARNING)
try:
desired, operations = create_plan(snapshot)
except ValueError as error:
fail_run('Replanning failed: %s' % error)
return
preview_plan = operations
if not operations:
finish_run(STATE_DONE, 'Inventory sorting completed.', COLOR_SUCCESS)
return
if count_replan:
planned_total = completed_operations + len(operations)
plugin_log('New plan created: %d remaining operation(s).' % len(operations))
set_state(STATE_SORTING, 'Sorting', COLOR_WARNING)
send_next_operation(snapshot, operations[0])
def send_next_operation(snapshot, operation):
global pending_operation
if pending_operation is not None:
fail_run('Internal safety lock prevented multiple pending operations.')
return
if cancel_requested:
finish_run(STATE_CANCELLED, 'Sorting cancelled.', COLOR_ERROR)
return
fresh = take_snapshot()
if fresh is None:
fail_run('Inventory became unavailable before the next operation.')
return
if fresh.fingerprint() != snapshot.fingerprint():
plugin_log('Inventory changed before packet injection; replanning without sending.')
replan_and_continue(fresh, True)
return
snapshot = fresh
if operation.source >= len(snapshot.items) or operation.destination >= len(snapshot.items):
fail_run('Planned slot is outside the inventory.')
return
source_item = snapshot.items[operation.source]
if source_item is None:
replan_and_continue(snapshot, True)
return
operation.requested_quantity = source_item.quantity
payload = build_move_payload(operation)
pending_operation = {
'move': operation, 'baseline': snapshot, 'payload': payload,
'sent_at': time.time(), 'response_at': None, 'response': None
}
set_state(STATE_WAITING_RESPONSE, 'Waiting response', COLOR_WARNING)
QtBind.setText(gui, lbl_current,
fixed_width_text('%d -> %d' % (operation.source, operation.destination), 205))
QtBind.setText(gui, lbl_requested,
fixed_width_text(str(operation.requested_quantity), 205))
QtBind.setText(gui, lbl_applied, fixed_width_text('-', 205))
QtBind.setText(gui, lbl_response, fixed_width_text('Pending', 205))
QtBind.setText(gui, lbl_completed,
fixed_width_text('%d / %d' % (completed_operations, planned_total), 205))
plugin_log('Executing %d/%d: %d -> %d qty=%d' %
(completed_operations + 1, planned_total, operation.source,
operation.destination, operation.requested_quantity))
try:
inject_joymax(OPCODE_INVENTORY_OPERATION, payload, INVENTORY_PACKET_ENCRYPTED)
except Exception as error:
fail_run('Packet injection failed: %s' % error)
def handle_silkroad(opcode, data):
global selected_entity_id, storage_entity_id
global isro_storage_transition_id, isro_storage_transition_time
raw = bytes(data or b'')
if opcode == OPCODE_SELECT_ENTITY and len(raw) >= 4:
selected_entity_id = struct.unpack_from('= 5:
entity_id = struct.unpack_from('= 4:
entity_id = struct.unpack_from('= RESPONSE_TIMEOUT_SECONDS:
if cancel_requested:
finish_run(STATE_CANCELLED, 'Sorting cancelled after pending response timeout.', COLOR_ERROR)
else:
fail_run('Inventory operation response timed out.')
return
if current_fp != pending['baseline'].fingerprint():
verify_pending_snapshot(current)
return
if now - pending['response_at'] >= SNAPSHOT_TIMEOUT_SECONDS:
if cancel_requested:
finish_run(STATE_CANCELLED, 'Sorting cancelled after pending snapshot timeout.', COLOR_ERROR)
else:
fail_run('Inventory snapshot update timed out after B034 success.')
def discord_clicked():
try:
webbrowser.open(DISCORD_URL)
set_state(state, 'Opening Discord...', COLOR_WARNING)
except Exception as error:
plugin_log('Discord link error: %s' % error)
set_state(state, 'Discord error', COLOR_ERROR)
def connected():
global connected_to_game
connected_to_game = True
def disconnected():
global connected_to_game, pending_operation, preview_snapshot, preview_plan
global storage_snapshot, storage_pending, storage_preview_snapshot, storage_preview_plan
global inventory_quick_mode, storage_quick_mode
global selected_entity_id, storage_entity_id
global isro_storage_transition_id, isro_storage_transition_time
connected_to_game = False
pending_operation = None
preview_snapshot = None
preview_plan = []
storage_snapshot = None
storage_pending = None
storage_preview_snapshot = None
storage_preview_plan = []
inventory_quick_mode = False
storage_quick_mode = False
selected_entity_id = None
storage_entity_id = None
isro_storage_transition_id = None
isro_storage_transition_time = 0.0
set_storage_state(STORAGE_UNKNOWN, 'Disconnected', COLOR_MUTED)
set_state(STATE_IDLE, 'Disconnected', COLOR_MUTED)
load_settings()
refresh_category_list()
refresh_storage_category_list()
set_state(STATE_IDLE, 'Ready', COLOR_SUCCESS)
set_storage_state(STORAGE_UNKNOWN, 'Open personal storage, then refresh.', COLOR_MUTED)
log('[%s] Loaded - ⚜ Made By FascinaTe' % pName)