from phBot import *
import QtBind
import struct
import threading
import time
import webbrowser
pName = 'FShining'
pVersion = '1.3.0'
DISCORD_URL = 'https://discord.gg/eB9sGSMYBg'
RECIPE_ID = 29
RECIPE_NAME = 'MK_RC_TRADE_MATERIAL_LIGHTSTONE'
REQUEST_OPCODE = 0x7538
RESPONSE_OPCODE = 0xB538
SPLIT_REQUEST_OPCODE = 0x7034
SPLIT_RESPONSE_OPCODE = 0xB034
DEFAULT_DELAY_MS = 250
MIN_DELAY_MS = 100
MAX_DELAY_MS = 60000
RESPONSE_TIMEOUT_SECONDS = 30.0
SPLIT_TIMEOUT_SECONDS = 10.0
INVENTORY_RETRY_LIMIT = 30
MATERIAL_RETRY_LIMIT = 10
RETRY_DELAY_SECONDS = 0.5
COLOR_PRIMARY = '#5b57e0'
COLOR_TEXT = '#2b3038'
COLOR_MUTED = '#9aa0ac'
COLOR_SUCCESS = '#1f9d63'
COLOR_WARNING = '#c98a1a'
COLOR_ERROR = '#d93a4d'
state_lock = threading.Lock()
worker_generation = 0
active_session = None
active_split = None
active_split_job = None
runtime = {
'running': False,
'status': 'READY',
'status_color': COLOR_MUTED,
'detail': 'Enter a delay and press Start.',
'requested_delay_ms': DEFAULT_DELAY_MS,
'blue_slot': None,
'black_slot': None,
'sent_count': 0,
'response_count': 0,
'timeout_count': 0,
'last_response_ms': None,
'response_total_ms': 0.0,
'split_status': 'Ready',
'split_color': COLOR_MUTED
}
def fixed_width_text(content, width):
return (
'
'
).format(width, content)
# ______________________________ GUI ______________________________ #
gui = QtBind.init(__name__, pName)
QtBind.createLabel(
gui,
u'✨ %s' %
(COLOR_PRIMARY, pName),
12,
6
)
QtBind.createLabel(
gui,
'v%s' % (COLOR_MUTED, pVersion),
158,
12
)
discord_button = 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,
'CRAFT CONTROLS' % COLOR_PRIMARY,
12,
43
)
QtBind.createLabel(
gui,
'Sends one request at a time and waits for the server response.' %
COLOR_MUTED,
12,
64
)
QtBind.createLabel(gui, 'Requested delay', 12, 96)
delay_edit = QtBind.createLineEdit(
gui, str(DEFAULT_DELAY_MS), 132, 92, 78, 20
)
QtBind.createLabel(gui, 'ms', 216, 96)
start_button = QtBind.createButton(gui, 'start_clicked', 'Start Crafting', 12, 126)
stop_button = QtBind.createButton(gui, 'stop_clicked', 'Stop', 130, 126)
QtBind.createLineEdit(gui, '', 12, 164, 368, 1)
QtBind.createLabel(
gui,
'STACK SPLITTER' % COLOR_PRIMARY,
12,
176
)
QtBind.createLabel(gui, 'Stone type', 12, 205)
stone_type_combo = QtBind.createCombobox(gui, 88, 201, 112, 20)
QtBind.append(gui, stone_type_combo, 'Blue Stone')
QtBind.append(gui, stone_type_combo, 'Black Stone')
QtBind.setText(gui, stone_type_combo, 'Blue Stone')
QtBind.createLabel(gui, 'Amount', 216, 205)
split_amount_edit = QtBind.createLineEdit(gui, '250', 272, 201, 68, 20)
split_button = QtBind.createButton(gui, 'split_clicked', 'Split Stack', 12, 234)
split_status_label = QtBind.createLabel(
gui,
fixed_width_text('Ready' % COLOR_MUTED, 250),
128,
238
)
QtBind.createLineEdit(gui, '', 400, 43, 1, 222)
QtBind.createLabel(
gui,
'LIVE STATUS' % COLOR_PRIMARY,
420,
43
)
QtBind.createLabel(gui, 'State' % COLOR_MUTED, 420, 70)
state_label = QtBind.createLabel(gui, fixed_width_text('READY', 180), 540, 70)
QtBind.createLabel(gui, 'Material slots' % COLOR_MUTED, 420, 94)
slots_label = QtBind.createLabel(
gui, fixed_width_text('Blue: - | Black: -', 180), 540, 94
)
QtBind.createLabel(gui, 'Requested' % COLOR_MUTED, 420, 118)
requested_label = QtBind.createLabel(gui, fixed_width_text('250 ms', 180), 540, 118)
QtBind.createLabel(gui, 'Last response' % COLOR_MUTED, 420, 142)
last_response_label = QtBind.createLabel(gui, fixed_width_text('-', 180), 540, 142)
QtBind.createLabel(gui, 'Average' % COLOR_MUTED, 420, 166)
average_response_label = QtBind.createLabel(gui, fixed_width_text('-', 180), 540, 166)
QtBind.createLabel(gui, 'Effective interval' % COLOR_MUTED, 420, 190)
effective_interval_label = QtBind.createLabel(
gui, fixed_width_text('250 ms', 180), 540, 190
)
QtBind.createLabel(gui, 'Requests' % COLOR_MUTED, 420, 214)
request_count_label = QtBind.createLabel(
gui, fixed_width_text('Sent: 0 | Responses: 0', 180), 540, 214
)
QtBind.createLabel(gui, 'Timeouts' % COLOR_MUTED, 420, 238)
timeout_label = QtBind.createLabel(gui, fixed_width_text('0', 180), 540, 238)
QtBind.createLineEdit(gui, '', 12, 278, 716, 1)
notice_label = QtBind.createLabel(
gui,
fixed_width_text(
'Ready. No request has been sent.' % COLOR_MUTED,
700
),
12,
291
)
# ______________________________ Helpers ______________________________ #
def format_milliseconds(value):
if value is None:
return '-'
if value >= 1000.0:
return '{0:.3f} sec'.format(value / 1000.0)
return '{0:.0f} ms'.format(value)
def runtime_snapshot():
with state_lock:
return dict(runtime)
def update_runtime(values):
with state_lock:
runtime.update(values)
def discord_clicked():
try:
webbrowser.open(DISCORD_URL)
update_runtime({
'status': 'DISCORD',
'status_color': COLOR_SUCCESS,
'detail': 'Opening Discord invite...'
})
except Exception as error:
log('[%s] Discord link error: %s' % (pName, error))
update_runtime({
'status': 'DISCORD ERROR',
'status_color': COLOR_ERROR,
'detail': 'Could not open Discord invite.'
})
refresh_gui()
def refresh_gui():
snapshot = runtime_snapshot()
requested = float(snapshot['requested_delay_ms'])
last_response = snapshot['last_response_ms']
response_count = snapshot['response_count']
average = None
if response_count > 0:
average = snapshot['response_total_ms'] / response_count
effective = requested
if last_response is not None:
effective = max(requested, last_response)
blue_slot = snapshot['blue_slot']
black_slot = snapshot['black_slot']
blue_text = '-' if blue_slot is None else str(blue_slot)
black_text = '-' if black_slot is None else str(black_slot)
QtBind.setText(
gui,
state_label,
fixed_width_text(
'{1}'.format(
snapshot['status_color'], snapshot['status']
),
180
)
)
QtBind.setText(
gui,
slots_label,
fixed_width_text(
'Blue: {0} | Black: {1}'.format(blue_text, black_text), 180
)
)
QtBind.setText(
gui, requested_label,
fixed_width_text(format_milliseconds(requested), 180)
)
QtBind.setText(
gui, last_response_label,
fixed_width_text(format_milliseconds(last_response), 180)
)
QtBind.setText(
gui, average_response_label,
fixed_width_text(format_milliseconds(average), 180)
)
QtBind.setText(
gui, effective_interval_label,
fixed_width_text(format_milliseconds(effective), 180)
)
QtBind.setText(
gui,
request_count_label,
fixed_width_text(
'Sent: {0} | Responses: {1}'.format(
snapshot['sent_count'], response_count
),
180
)
)
QtBind.setText(
gui, timeout_label,
fixed_width_text(str(snapshot['timeout_count']), 180)
)
QtBind.setText(
gui,
split_status_label,
fixed_width_text(
'{1}'.format(
snapshot['split_color'], snapshot['split_status']
),
250
)
)
if last_response is not None and last_response > requested:
notice = (
'Server synchronization active: '
'requested {1}, server response {2}. No extra requests are sent '
'while a response is pending.'
).format(
COLOR_WARNING,
format_milliseconds(requested),
format_milliseconds(last_response)
)
else:
notice = '{1}'.format(
snapshot['status_color'], snapshot['detail']
)
QtBind.setText(gui, notice_label, fixed_width_text(notice, 700))
def read_requested_delay():
try:
delay_ms = int(QtBind.text(gui, delay_edit).strip())
except Exception:
return None
if delay_ms < MIN_DELAY_MS or delay_ms > MAX_DELAY_MS:
return None
return delay_ms
def find_material_slots():
inventory = get_inventory()
if not isinstance(inventory, dict):
return None
items = inventory.get('items')
if not isinstance(items, list):
return None
blue_slot = None
black_slot = None
for slot in range(13, len(items)):
item = items[slot]
if not isinstance(item, dict):
continue
try:
quantity = int(item.get('quantity', 0))
except Exception:
quantity = 0
if quantity <= 0:
continue
servername = str(item.get('servername') or '').upper()
if blue_slot is None and (
'BLUESTONE' in servername or 'BLUE_STONE' in servername
):
blue_slot = slot
if black_slot is None and (
'BLACKSTONE' in servername or 'BLACK_STONE' in servername
):
black_slot = slot
if blue_slot is not None and black_slot is not None:
break
return blue_slot, black_slot
def stone_matches(servername, stone_type):
name = str(servername or '').upper()
if stone_type == 'Blue Stone':
return 'BLUESTONE' in name or 'BLUE_STONE' in name
if stone_type == 'Black Stone':
return 'BLACKSTONE' in name or 'BLACK_STONE' in name
return False
def inventory_item_is_empty(item):
if not item:
return True
if not isinstance(item, dict):
return False
servername = str(item.get('servername') or '').strip()
try:
model = int(item.get('model') or 0)
except Exception:
model = 0
return model == 0 and not servername
def find_split_plan(stone_type, amount, source_slot=None):
inventory = get_inventory()
if not isinstance(inventory, dict):
return None, 'Inventory is not available.'
items = inventory.get('items')
if not isinstance(items, list):
return None, 'Inventory is not available.'
candidates = []
empty_slots = []
for slot in range(13, len(items)):
item = items[slot]
if inventory_item_is_empty(item):
empty_slots.append(slot)
continue
if not isinstance(item, dict):
continue
try:
quantity = int(item.get('quantity', 0))
except Exception:
quantity = 0
if (
quantity > amount
and stone_matches(item.get('servername'), stone_type)
and (source_slot is None or slot == source_slot)
):
candidates.append((quantity, slot))
# Some phBot builds omit trailing empty entries from the items list while
# still reporting the complete inventory capacity in the size field.
try:
inventory_size = int(inventory.get('size', len(items)))
except Exception:
inventory_size = len(items)
inventory_size = min(256, max(len(items), inventory_size))
for slot in range(max(13, len(items)), inventory_size):
empty_slots.append(slot)
if not candidates:
return None, 'No {0} stack larger than {1} was found.'.format(
stone_type, amount
)
if not empty_slots:
return None, 'No empty inventory slot is available.'
candidates.sort(reverse=True)
quantity, source_slot = candidates[0]
destination_slot = empty_slots[0]
if source_slot > 255 or destination_slot > 255:
return None, 'The selected inventory slot is outside the packet range.'
return {
'stone_type': stone_type,
'amount': amount,
'source_quantity': quantity,
'source_slot': source_slot,
'destination_slot': destination_slot
}, None
def build_split_packet(source_slot, destination_slot, amount):
return bytearray(struct.pack(
'= INVENTORY_RETRY_LIMIT:
stop_session(
'ERROR',
'Inventory could not be read.',
COLOR_ERROR,
'Stopped because inventory could not be read: {0}'.format(error),
generation,
session
)
return
session['stop_event'].wait(RETRY_DELAY_SECONDS)
continue
if slots is None:
inventory_retries += 1
update_runtime({
'status': 'WAITING FOR INVENTORY',
'status_color': COLOR_WARNING,
'detail': 'Inventory is not available ({0}/{1}).'.format(
inventory_retries, INVENTORY_RETRY_LIMIT
)
})
if inventory_retries >= INVENTORY_RETRY_LIMIT:
stop_session(
'ERROR', 'Inventory is not available.', COLOR_ERROR,
'Stopped because inventory was not available.',
generation,
session
)
return
session['stop_event'].wait(RETRY_DELAY_SECONDS)
continue
inventory_retries = 0
blue_slot, black_slot = slots
update_runtime({'blue_slot': blue_slot, 'black_slot': black_slot})
if blue_slot is None or black_slot is None:
material_retries += 1
update_runtime({
'status': 'SEARCHING MATERIALS',
'status_color': COLOR_WARNING,
'detail': 'Blue or Black Stone is missing ({0}/{1}).'.format(
material_retries, MATERIAL_RETRY_LIMIT
)
})
if material_retries >= MATERIAL_RETRY_LIMIT:
stop_session(
'MATERIALS DEPLETED',
'Blue or Black Stone could not be found.',
COLOR_WARNING,
'Stopped because Blue or Black Stone could not be found.',
generation,
session
)
return
session['stop_event'].wait(RETRY_DELAY_SECONDS)
continue
material_retries = 0
if not session_is_active(generation, session):
return
session['response_event'].clear()
session['response_time_ms'] = None
session['sent_at'] = time.monotonic()
session['awaiting_response'] = True
try:
inject_joymax(
REQUEST_OPCODE,
build_craft_packet(blue_slot, black_slot),
False
)
except Exception as error:
session['awaiting_response'] = False
stop_session(
'ERROR', 'The craft request could not be sent.', COLOR_ERROR,
'Craft request failed: {0}'.format(error),
generation,
session
)
return
with state_lock:
runtime['sent_count'] += 1
runtime['status'] = 'WAITING FOR SERVER'
runtime['status_color'] = COLOR_WARNING
runtime['detail'] = 'One request is pending. No additional packet will be sent.'
response_received = session['response_event'].wait(
RESPONSE_TIMEOUT_SECONDS
)
if not session_is_active(generation, session):
return
if not response_received or session['response_time_ms'] is None:
session['awaiting_response'] = False
with state_lock:
runtime['timeout_count'] += 1
stop_session(
'RESPONSE TIMEOUT',
'No server response was received within {0:.0f} seconds.'.format(
RESPONSE_TIMEOUT_SECONDS
),
COLOR_ERROR,
'Stopped after waiting {0:.0f} seconds for opcode 0x{1:04X}.'.format(
RESPONSE_TIMEOUT_SECONDS, RESPONSE_OPCODE
),
generation,
session
)
return
elapsed_seconds = session['response_time_ms'] / 1000.0
requested_seconds = session['requested_delay_ms'] / 1000.0
remaining_delay = max(0.0, requested_seconds - elapsed_seconds)
update_runtime({
'status': 'SYNCHRONIZED',
'status_color': COLOR_SUCCESS,
'detail': 'Server response received. Preparing the next request.'
})
if remaining_delay > 0:
session['stop_event'].wait(remaining_delay)
# ______________________________ GUI callbacks ______________________________ #
def set_split_status(message, color):
update_runtime({'split_status': message, 'split_color': color})
refresh_gui()
def send_split_plan(plan, job):
global active_split
global active_split_job
with state_lock:
if (
runtime['running']
or active_split is not None
or active_split_job is not job
):
return False
plan['sent_at'] = time.monotonic()
active_split = plan
runtime['split_status'] = 'Waiting: split #{0} | Slot {1} -> {2}'.format(
job['completed_count'] + 1,
plan['source_slot'],
plan['destination_slot']
)
runtime['split_color'] = COLOR_WARNING
try:
inject_joymax(
SPLIT_REQUEST_OPCODE,
build_split_packet(
plan['source_slot'], plan['destination_slot'], plan['amount']
),
False
)
except Exception as error:
with state_lock:
if active_split is plan:
active_split = None
if active_split_job is job:
active_split_job = None
runtime['split_status'] = 'Split request could not be sent.'
runtime['split_color'] = COLOR_ERROR
log('[%s] Split request failed: %s' % (pName, error))
return False
log(
'[%s] Split request #%d sent: %s x%d, slot %d -> %d.' % (
pName,
job['completed_count'] + 1,
plan['stone_type'],
plan['amount'],
plan['source_slot'],
plan['destination_slot']
)
)
return True
def split_clicked():
global active_split
global active_split_job
stone_type = QtBind.text(gui, stone_type_combo).strip()
if stone_type not in ('Blue Stone', 'Black Stone'):
set_split_status('Select Blue Stone or Black Stone.', COLOR_ERROR)
return
try:
amount = int(QtBind.text(gui, split_amount_edit).strip())
except Exception:
set_split_status('Amount must be a whole number.', COLOR_ERROR)
return
if amount < 1 or amount > 65535:
set_split_status('Amount must be between 1 and 65535.', COLOR_ERROR)
return
with state_lock:
if runtime['running']:
busy_message = 'Stop crafting before splitting a stack.'
elif active_split is not None or active_split_job is not None:
busy_message = 'A split operation is already active.'
else:
busy_message = None
if busy_message:
set_split_status(busy_message, COLOR_WARNING)
return
try:
plan, error_message = find_split_plan(stone_type, amount)
except Exception as error:
set_split_status('Inventory could not be read.', COLOR_ERROR)
log('[%s] Split inventory error: %s' % (pName, error))
return
if plan is None:
set_split_status(error_message, COLOR_ERROR)
return
with state_lock:
if runtime['running'] or active_split is not None or active_split_job is not None:
job = None
else:
job = {
'stone_type': stone_type,
'amount': amount,
'source_slot': plan['source_slot'],
'completed_count': 0,
'next_attempt_at': 0.0
}
active_split_job = job
if job is None:
set_split_status('Another operation started. Try again.', COLOR_WARNING)
return
send_split_plan(plan, job)
refresh_gui()
def start_clicked():
global worker_generation
global active_session
delay_ms = read_requested_delay()
if delay_ms is None:
update_runtime({
'status': 'INVALID DELAY',
'status_color': COLOR_ERROR,
'detail': 'Delay must be a whole number between {0} and {1} ms.'.format(
MIN_DELAY_MS, MAX_DELAY_MS
)
})
refresh_gui()
return
with state_lock:
if runtime['running']:
return
if active_split is not None or active_split_job is not None:
runtime['status'] = 'SPLIT IN PROGRESS'
runtime['status_color'] = COLOR_WARNING
runtime['detail'] = 'Wait for the pending split request to finish.'
refresh_required = True
else:
refresh_required = False
if refresh_required:
generation = None
session = None
else:
worker_generation += 1
generation = worker_generation
session = {
'stop_event': threading.Event(),
'response_event': threading.Event(),
'requested_delay_ms': delay_ms,
'sent_at': None,
'response_time_ms': None,
'awaiting_response': False
}
active_session = session
runtime.update({
'running': True,
'status': 'STARTING',
'status_color': COLOR_WARNING,
'detail': 'Reading inventory and locating materials.',
'requested_delay_ms': delay_ms,
'blue_slot': None,
'black_slot': None,
'sent_count': 0,
'response_count': 0,
'timeout_count': 0,
'last_response_ms': None,
'response_total_ms': 0.0
})
if refresh_required:
refresh_gui()
return
worker = threading.Thread(target=crafting_worker, args=(generation, session))
worker.daemon = True
worker.start()
log('[%s] Crafting started with a requested delay of %d ms.' % (pName, delay_ms))
refresh_gui()
def stop_clicked():
snapshot = runtime_snapshot()
if not snapshot['running']:
return
stop_session(
'STOPPED', 'Crafting was stopped by the user.', COLOR_MUTED,
'Crafting stopped. Sent: {0}, responses: {1}.'.format(
snapshot['sent_count'], snapshot['response_count']
)
)
refresh_gui()
# ______________________________ phBot events ______________________________ #
def handle_joymax(opcode, data):
global active_split
global active_split_job
if opcode == SPLIT_RESPONSE_OPCODE:
try:
response = bytearray(data)
except Exception:
return True
with state_lock:
plan = active_split
if plan is None or len(response) < 6:
return True
response_operation = response[1]
response_source = response[2]
response_destination = response[3]
response_amount = struct.unpack_from('= SPLIT_TIMEOUT_SECONDS
):
active_split = None
active_split_job = None
runtime['split_status'] = 'Split timed out after {0:.0f} seconds.'.format(
SPLIT_TIMEOUT_SECONDS
)
runtime['split_color'] = COLOR_ERROR
split_timed_out = True
else:
split_timed_out = False
if (
not split_timed_out
and active_split is None
and active_split_job is not None
and time.monotonic() >= active_split_job['next_attempt_at']
):
continuation_job = active_split_job
else:
continuation_job = None
if split_timed_out:
log('[%s] Split request timed out.' % pName)
if continuation_job is not None:
try:
next_plan, error_message = find_split_plan(
continuation_job['stone_type'],
continuation_job['amount'],
continuation_job['source_slot']
)
except Exception as error:
next_plan = None
error_message = 'Inventory could not be read.'
log('[%s] Split continuation inventory error: %s' % (pName, error))
if next_plan is not None:
send_split_plan(next_plan, continuation_job)
else:
with state_lock:
if active_split_job is continuation_job and active_split is None:
active_split_job = None
if error_message.startswith('No '):
runtime['split_status'] = (
'Completed: {0} split(s); source is now x{1} or less.'
).format(
continuation_job['completed_count'],
continuation_job['amount']
)
runtime['split_color'] = COLOR_SUCCESS
split_finished = True
else:
runtime['split_status'] = error_message
runtime['split_color'] = COLOR_ERROR
split_finished = False
else:
split_finished = False
if split_finished:
log(
'[%s] Automatic splitting completed after %d split(s).' % (
pName, continuation_job['completed_count']
)
)
refresh_gui()
def cancel_split(message):
global active_split
global active_split_job
with state_lock:
if active_split is None and active_split_job is None:
return False
active_split = None
active_split_job = None
runtime['split_status'] = message
runtime['split_color'] = COLOR_ERROR
return True
def disconnected():
cancel_split('Split cancelled: connection lost.')
if runtime_snapshot()['running']:
stop_session(
'DISCONNECTED', 'Crafting stopped because the connection was lost.',
COLOR_ERROR, 'Crafting stopped after disconnect.'
)
def finished():
cancel_split('Split cancelled: plugin unloaded.')
if runtime_snapshot()['running']:
stop_session(
'STOPPED', 'Plugin unloaded.', COLOR_MUTED,
'Crafting stopped because the plugin was unloaded.'
)
refresh_gui()
log('[%s] Loaded - ⚜ Made By FascinaTe' % pName)