================================================================================ VIOLET LUA SDK - COMPLETE API REFERENCE FOR AI/LLM ================================================================================ Ultra-comprehensive plain text reference optimized for AI consumption. Maximum information density. Token-efficient format. Last Updated: 2026-09-29 (clarify Sol Janus: Dawn cooldown and charge-count behavior) Version history and diffs: https://docs.violet.cx/llm-reference/history If this copy is older than the current version, fetch the diff from there and apply it before writing scripts. ================================================================================ PRIVACY AND CONSENT ================================================================================ STRICTLY PROHIBITED: using any Violet Lua API, including core.http and core.get_scripts(), to scrape, collect, exfiltrate, or otherwise harvest a user's script data, settings, credentials, account data, or private gameplay data. Only access or send user data with clear user approval and for the specific workflow the user agreed to. ================================================================================ LANGUAGE: LUAU (not Lua 5.4) ================================================================================ Violet runs scripts on Luau (Lua 5.1 base + Luau extensions). Differences that matter when porting or generating code: GAINED over Lua 5.1: - continue inside loops - compound assignments: x += 1, s ..= "foo", etc. - string interpolation: `Hello, ${name}!` - generalized iteration: for k, v in t do ... end (no need for pairs()) - optional type annotations: local x: number = 1 - buffer type (native mutable byte array) — see buffer library MISSING vs Lua 5.2+/5.4: - no native bitwise operators (|, &, ~, <<, >>) — use bit32.* library - no string.pack / string.unpack - no utf8 library - no or variable attributes - no integer subtype — all numbers are doubles (// still does integer division) All plugins share a single global environment. Top-level globals are visible across scripts. Hooks (on_tick, on_packet_recv, etc.) from every plugin are CHAINED — each plugin's hook is stored separately and all of them fire in load order every tick/event. Defining on_tick in two plugins runs BOTH, not just the last one. Use `local` at file scope for per-script private state. For hooks with blocking semantics (on_packet_recv/send return false to drop the packet, on_dialog returns non-nil to intercept, on_wndproc returns false or a number to block input), the first plugin to block wins and later plugins don't see the event. ================================================================================ MANDATORY PLUGIN STRUCTURE ================================================================================ Every script MUST begin with this structure: plugin = { name = "Script Name", version = "1.0.0", author = "Author Name", description = "Brief description", load = true, -- Auto-load on injection changelog = "What changed in this version" -- optional; auto-recorded as a marketplace release note on publish } ================================================================================ CORE CALLBACKS ================================================================================ Scripts execute through callbacks. Define only what you need: on_tick(stage: number) - Called every ~30ms in every game stage - stage is numeric; compare it with core.stage constants, never strings or raw numbers - Continues running during login, Cash Shop, and Auction House - Field-only player, map, movement, and combat logic must require core.stage.FIELD READ-ONLY STAGE CONSTANTS: core.stage.LOGO -- Startup logo core.stage.LOGIN -- Login, world selection, or character selection core.stage.FIELD -- Active field or map core.stage.CASH_SHOP -- Cash Shop core.stage.AUCTION_HOUSE -- Auction House or Global Market core.stage.INTER_STAGE -- Intermediate transition core.stage.LOADING -- Loading core.stage.GATEWAY -- Gateway core.stage.UNKNOWN -- No active recognized stage on_map_load() - Called when loading a new map - Use for map-specific initialization - Called BEFORE on_tick for the new map on_render() - Called once per PRESENTED FRAME (after that frame's on_tick, if one ran), ONLY while the overlay can draw - The ONLY place core.render.* drawing calls are accepted; shapes live ONE frame, so draw every call - Frame rate, not tick rate: keep game logic in on_tick, store it, draw it here - A callback that raises is reported once and REMOVED until reload (no per-frame spam) - No arguments; runs in every stage, guard field-only drawing yourself on_packet_recv(packet: in_packet) -> boolean - Called when receiving packet from server - Return true to allow, false to block - Requires plugin metadata structure on_packet_send(packet: in_packet) -> boolean - Called when sending packet to server - Packet is provided as in_packet for read-only decoding (same type as on_packet_recv) - Return true to allow, false to block - Requires plugin metadata structure on_evasion(player_names: table) - Called once when evasion first triggers - Whitelisted players and party members never trigger it - player_names: array of strings (the detected players' names) - Fires BEFORE the evasion action begins (CC, logout, etc.) - Use for logging, notifications, or custom evasion logic on_gm_evasion() - Called once when Evade Potential GM (autos.evasion.evade_gm) fires - Runs AFTER the logout is requested and the 6-hour Auto Login hold is armed, so core.profiles.switch() / core.login.clear_hold() called here shape the re-login - Only fires while the Evade Potential GM checkbox is on on_skill_use(skill_id: number, time: number, handle: number | nil) - Called when the game accepts a skill used with a key (including keys Violet presses) or core.input.use_skill(), and when Auto Buff or a pet's buff skill casts a buff - skill_id: the ID pressed, passed to use_skill() or cast - time: game update time in ms when accepted, same clock as core.get_update_time() - handle: the value core.input.use_skill() returned for this use; nil for every other use; handles are unique, so another script's handle never matches yours - Fires on the game's acceptance; does not wait for the server - NOT called when the game refuses the skill (cooldown, HP/MP cost, not usable right now) - Turning off a toggle skill that is already on is normally not reported (for toggles that show as buffs, on_buff_lost reports them going off) - Calls arrive at the start of the next tick, before on_tick, one per use, in order; up to 64 uses are kept per tick, any more are dropped - Observe-only (return value ignored) on_death() - Called once when your character dies on_level_up(level: number) - Called for each level gained, with the new level; two levels at once = two calls, in order - A jump of more than 20 levels at once is not reported on_rune(event: string, x: number, y: number) - "appeared": a rune is on your map (also when entering a map that has one); x, y = its position - "solving": a rune solve started (by you or by Violet) - "solved": you activated a rune and its cooldown started - "stopped": the rune solve ended, whether or not it was solved - x and y are only passed with "appeared" on_lie_detector(event: string) - "started" when Violet starts solving a lie detector, "ended" when it stops on_buff_gained(buff_id: number) - A buff appeared on your character; buff_id matches player:get_buff(); refreshing an active buff does not call it on_buff_lost(buff_id: number) - A buff disappeared from your character (expired or cancelled) on_item_gained(item_id: number, count: number) - You have more of an item than before (pickups, rewards, purchases, storage withdrawals); count = how many more - Moving, equipping or using items does not call it; cosmetic equipment is not reported on_damage_taken(amount: number, time: number) - Your character took damage; time = game update time in ms when it landed, same clock as core.get_update_time() - Calls arrive at the start of the next tick, before on_tick on_mob_action(mob: Mob, action: table) - A mob on your map began an attack, skill or other one-off action (a boss pattern, a hit flinch); movement does not call it - action: the fields mob:get_action() returns, with started always set on_mob_action_end(mob: Mob | nil, action: table) - That action finished or another replaced it; action gets ended = game update time in ms - mob is nil when the mob left the map or started dying MOB ACTION EVENTS (on_mob_action, on_mob_action_end): - Checked once per tick while in a map; reported at the start of the next tick, before on_tick; same clock as core.get_update_time() - A mob repeating the same action with no gap in between reports it once - Actions already playing when a script loads or you enter a map are not reported as started; their end still is (started nil if the start was not seen) - Leaving the map ends every action still playing on_field_effect(path: string) - The game showed a full-screen effect on your map: stage/wave banner, clear, fail or time-over message - path names the effect (e.g. ends in "monsterPark/clear" or "quest/party/clear"); match on it - Calls arrive at the start of the next tick, before on_tick on_key_prompt(event: string, prompt: table) - A timed key prompt for your character changed (e.g. Tengu's fight in Mushroom Shrine); prompt: the fields map:get_key_prompt() returns - event: "armed" (the prompt that starts an attack can now appear: stand within 400 px of prompt.mob_id, left or right) | "opened" (press prompt.key within prompt.time_left ms) | "passed" | "failed" (another of the five keys, or out of time) - passed/failed: prompt.open = false; prompt.pressed = name of the key you pressed, nil when you pressed none - Calls arrive at the start of the next tick, before on_tick; a prompt already on screen when a script loads or you enter a map is not reported as opened, its answer still is on_ui_event(event: table) - A control in a game window reported something to its window: click, check box toggle, text change, selection, tab switch, slider move; core.game_ui actions are reported too; hovering is not - Reported once, by the control itself: window = the window directly containing it (can be a panel nested in a bigger window) - event.type: "click" | "double_click" | "right_click" | "press" (button pressed down, before its click) | "check" | "text" | "tab" | "select" | "slide" | "slide_end" | "other" - event.code: the event's number (tells "other" events apart) - event.value: check = 1/0 new state; tab = new tab index; slide/slide_end = new position; select = the chosen item's value (often, not always, its index; control:get_selected() = index) - event.control_id, event.control_handle, event.window_handle: numbers - event.window / event.control: Window / Control, or nil if already gone on_window_open(window: Window) - A game window opened on_window_close(window: table) - A window closed; table of what it was: handle, kind, and ui_type / resource_path when it had them GAME UI EVENTS (on_ui_event, on_window_open, on_window_close): - on_ui_event: collected as they happen, reported at the start of the next tick before on_tick (window/control may be gone by then); up to 128 kept between ticks, more are dropped - Open windows are compared once per tick, before on_tick; a window opening and closing within one tick is not reported - Windows already open when a script starts listening get no on_window_open, but their on_window_close is reported STATE EVENTS (on_death, on_level_up, on_rune, on_lie_detector, on_buff_gained, on_buff_lost, on_item_gained): - Checked once per tick while in a map; reported at the start of the next tick, before on_tick - Loading a script, logging in or switching characters does not report the current state, only later changes - For ~2 seconds after a channel change, Cash Shop visit or login, buff changes and the rune cooldown are not reported - A rune solve or lie detector open at logout / character switch gets its "stopped" / "ended" call on_step_start(step: table) - The Scheduler started a step this script registered (core.scheduler.register_step); only the owning script gets it - step: { id, type, params (values from the step's form), resumed (true when starting again after a pause) } - Report back with step:done() / step:fail(); see SCHEDULER MODULE on_step_stop(step: table, reason: string) - The Scheduler ended the step before the script did; stop the step's work - reason: "time" | "skipped" | "stopped" | "paused" | "safety" | "replaced" - Not called after step:done() / step:fail() CALLBACK REGISTRATION (alternative to defining globals; both styles work together): core.register_callback(event: string, fn: function) -> number - event: any callback name ("on_tick", "on_chat", "on_level_up", ...); returns a callback id - fn gets the same arguments as the global of that name; its return value is used the same way - Callbacks for an event run in registration order; a script's global of that name counts as registered when the script finishes loading; a function both registered and defined as the global runs twice - Unknown event name or non-function fn raises an error; registering inside a callback starts with the next event - Removed automatically when the script unloads core.unregister_callback(event: string, fn_or_id: function | number) -> boolean - Removes the first registered callback matching the function or id (any script); true if one was removed - Raises an error when fn_or_id is neither a function nor a number - Removing inside a callback takes effect after the current event on_wndproc(event: table) -> nil | boolean | number - Called for keyboard and mouse WndProc messages only - Non-input WndProc messages are NOT passed to Lua - WM_MOUSEMOVE is NOT delivered (fires too frequently); use core.input.get_mouse_position() - Return nil/true to allow the original WndProc - Return false to block the input message with result 0 - Return number to block the input message with that LRESULT - event fields: hwnd, msg, w_param, l_param, blockable=true - Mouse messages also include x and y decoded from l_param on_chat(sender: string, message: string, channel: string, type: number) - Called for each chat message the client displays - sender: IGN that sent it (empty string for system lines) - message: the chat text - channel: "public" | "whisper" | "party" | "buddy" | "guild" | "alliance" | "megaphone" - type: raw chat-log type code (disambiguates megaphone sub-kinds) - System notices / item-drop lines are NOT reported; your own messages are included - Observe-only (cannot block) - Requires plugin metadata structure on_dialog(dialog: table) -> nil | boolean | number | string - Intercepts NPC dialogs and blue boxes BEFORE core auto-handling - Called ONCE per dialog -- result is cached until dialog closes - Requires plugin metadata structure DIALOG TABLE FIELDS: dialog.kind string "npc" or "bluebox" dialog.type number NPC: 0=close/prev, 1=next/ok, 5=text_input, 6=list_select Bluebox: 1000=yes/no, other=notice dialog.text string The NPC message or blue box text dialog.choices table Array of {text=string, index=number} (present when dialog has list options) index = selection value for each choice RETURN VALUES: nil or true -> pass to core (auto_npc / auto_blue_boxes handles if enabled) false -> block (do nothing, leave dialog open) 6 -> click yes / ok / next 7 -> click no / close number -> select choice matching this index value (requires choices) string -> type 5: submit as text input; with choices: select by substring match NOTE: 6 and 7 are always yes/no. If a choice has index 6 or 7, use string selection instead. EXAMPLE: function on_dialog(dialog) if dialog.kind == "npc" then -- Type 6 (list): select by index or text if dialog.type == 6 and dialog.choices then return dialog.choices[1].index -- select by index -- return "Complete" -- or select by text substring end -- Type 5 (text input): return the string to enter if dialog.type == 5 then return "answer" -- submits "answer" as text input end if dialog.type == 1 then return 6 -- click next end end -- nil = let core handle it end NOTES: - Replaces queue_npc_selection for scripts that need full dialog control - queue_npc_selection still works for simple automation via core auto_npc - Result is cached per dialog -- false blocks for the dialog's lifetime - Works independently of the auto_npc / auto_blue_boxes settings ================================================================================ GUARD CLAUSE PATTERN (CRITICAL) ================================================================================ ALWAYS validate objects before use: function on_tick(stage) if stage ~= core.stage.FIELD then return end local player = core.object_manager.get_local_player() if not player or not player:is_valid() then return end local map = core.object_manager.get_current_map() if not map or not map:is_valid() then return end -- Safe to use player and map here end ================================================================================ CORE MODULE - LOGGING ================================================================================ core.log(message: string) - Log white message with [-] prefix core.log_error(message: string) - Log red message with [!] prefix core.log_warning(message: string) - Log yellow message with [?] prefix core.log_computer(message: string [, event_type: string]) - Write a line to your computer's activity log on the web dashboard (the computer view), separate from the in-client console - event_type is an optional tag shown as a label (default "script"); frequent calls are rate-limited NOTE: Use tostring() to convert numbers/booleans for logging ================================================================================ CORE MODULE - UTILITIES ================================================================================ core.util.fnv1a(value: string | number) -> string | nil, string - Standard 64-bit FNV-1a hash of value, returned as 16-char lowercase hex string - Integers hash as their base-10 text: fnv1a(123) == fnv1a("123"); fractional numbers rejected - Accepts Luau int64 values (e.g. from in_packet:decode_8) as well as plain numbers - nil + error message if value is not a string or integer - Deterministic and stable across sessions/updates - good pseudonymous key for external storage (e.g. core.util.fnv1a(player:get_account_id()) to key per-account rows in your own DB) - Non-cryptographic: keeps raw IDs out of your DB but is NOT encryption ================================================================================ CORE MODULE - GAME INFORMATION ================================================================================ core.get_world_id() -> number | nil - Returns current world/server ID - nil if not in game core.get_channel_id() -> number | nil - Returns current channel number - nil if not in game core.get_game_version() -> string - Returns the game client version string (e.g. "8.269.1.1") - The version the client is currently running; changes with each game patch core.change_channel(channel_id: number) -> boolean - Switch to specified channel - Must be out of combat - Returns true if issued, false if rejected - Excessive changes in a short period are blocked — always check the return value core.is_evading() -> boolean - Returns true if currently evading core.is_solving_rune() -> boolean - Returns true if solving rune puzzle - Use to pause automation during runes core.is_solving_lie_detector() -> boolean - Returns true while the figure-tracking lie detector (anti-macro challenge) is being solved - All automated systems (kami, rushing, skill injection, rune solving, auto attack, evasion, antimacro tracking) auto-suspend while this is true - Gate your own automation on it too: if core.is_solving_lie_detector() then return end core.is_in_cutscene() -> boolean | nil - Returns true if in cutscene/dialogue - nil if state unknown core.get_update_time() -> number - Returns current game update time in milliseconds - Use for timing operations and delays core.get_system_time() -> table - Returns system (wall clock) time as a table - Fields: year, month (1-12), day (1-31), hour (0-23), min (0-59), sec (0-59), ms (0-999), epoch (ms since Unix epoch) - Example: local t = core.get_system_time(); core.log(t.hour .. ":" .. t.min) core.get_utc_time() -> table - Returns current UTC time as a table (same fields as get_system_time, but UTC instead of local timezone) - Fields: year, month (1-12), day (1-31), hour (0-23), min (0-59), sec (0-59), ms (0-999), epoch (ms since Unix epoch) - Use for timing against server events regardless of the client's local timezone core.is_rushing() -> boolean - Returns true if currently rushing to a map core.is_auto_selling() -> boolean - Returns true while the auto-sell flow is active (rushing to shop, walking, talking to NPC, selling) - Returns false when idle - Use to gate logic while auto-sell drives the player core.use_hyper_rock(map_id: number) -> boolean - Teleport to map using Hyper Teleport Rock - Returns true on success, false otherwise core.maple_guide_teleport(map_id: number) -> boolean - Teleport to map using the Maple Guide (no Hyper Teleport Rock needed); only maps the guide offers the character are accepted - Goes through the Maple Guide's own checks, including its cooldown; the game shows its notice when it refuses - Returns true if sent, false if not in a field or the game refused the move (already on that map, cooldown) core.is_hyper_rock_ready() -> boolean - True when the Hyper Teleport Rock can be used right now - False while its cooldown is still running or no character is loaded core.logout() -> boolean - In game: log out to the character-select screen (client stays open; does NOT close the game) - At character select: jumps the login flow back to world select instead - Returns true if issued, false (no-op) otherwise (e.g. mid-transition; retry next tick) core.terminate() - Close the game client immediately; does NOT return (process exits) - Use to actually CLOSE the client; use core.logout() to return to character select instead core.set_auto_restart(enabled: boolean) -> boolean - Enable/disable auto-restart for the game profile this client was launched from - When on, the launcher re-opens the client with the same account if it closes; saved to your account, persists across launches - Returns true if the request was sent core.chat.send(message: string [, channel: string]) -> boolean [, error] - Sends a chat message - channel defaults to "public"; also "party" | "buddy" | "guild" | "alliance" - For "public", a leading "/" is sent as a chat command - Returns true, or false plus an error string core.chat.whisper(target: string, message: string) -> boolean [, error] - Whispers message to the player named target - Must be in a field; whispering yourself is ignored - Returns true, or false plus an error string (empty target/message, or not in a field) ================================================================================ CORE MODULE - SETTINGS API ================================================================================ Dynamic runtime access to game settings. Changes sync to web UI automatically. FINDING SETTING PATHS: Developer Mode (Recommended): 1. Open Settings in web menu 2. Press Ctrl+Shift+D for Developer Mode 3. Click any setting badge to copy its path Path Format: "category.subcategory.property" Examples: "autos.auto_pot.hp.enabled", "hacks.godmode", "combat.kami.enabled" core.get_setting(path: string) -> value | nil - Retrieves setting value by path - Returns boolean, number, string, or table - Array/object settings (e.g. skill injection lists) returned as Lua tables - Returns nil if setting not found Example: local auto_hp = core.get_setting("autos.auto_pot.hp.enabled") local threshold = core.get_setting("autos.auto_pot.hp.value") local skills = core.get_setting("combat.skill_injection.skills") -- skills is a table: {{id=123, delay=200, hits=1, type=0}, ...} core.get_scripts() -> table - Returns all discovered Lua scripts with enabled and loaded booleans - Each loaded script includes settings registered by that script - Each setting includes its full path in id, type, label, and current value - Use this to discover script setting paths instead of guessing them - Disabled scripts have an empty settings table because their code is not loaded - STRICTLY PROHIBITED: using this function to scrape, collect, exfiltrate, or otherwise harvest a user's script data or settings. Only use it for user-approved local discovery and configuration workflows. Return shape: { { filename = "auto_farm.lua", settings_key = "auto_farm.lua", name = "Auto Farm", description = "Automatically farms selected maps", author = "Violet", version = "1.0.0", source = "server", enabled = true, loaded = true, settings = { { id = "lua.auto_farm.enabled", type = "checkbox", label = "Enable Farming", tooltip = "Toggles Auto Farm", tab = "Lua", panel = "Auto Farm", order = 0, sourceScript = "auto_farm.lua", value = true }, { id = "lua.auto_farm.attack_delay", type = "slider_int", label = "Attack Delay", tooltip = "", tab = "Lua", panel = "Auto Farm", order = 1, sourceScript = "auto_farm.lua", min = 50, max = 1000, step = 50, value = 200 } } }, { filename = "boss_helper.lua", settings_key = "boss_helper.lua", name = "Boss Helper", description = "", author = "", version = "", source = "local", enabled = false, loaded = false, settings = {} } } Example: for _, script in ipairs(core.get_scripts()) do for _, setting in ipairs(script.settings) do core.log(setting.id .. " = " .. tostring(setting.value)) end end core.set_setting(path: string, value: boolean|number|string|table) -> boolean - Updates setting value - Value type MUST match setting's expected type - Lua tables are converted to JSON arrays/objects for array settings - Returns true on success, false on failure - Changes sync automatically to web UI Example: core.set_setting("autos.auto_pot.hp.enabled", true) core.set_setting("autos.auto_pot.hp.value", 75) core.set_setting("hacks.godmode", false) core.set_setting("combat.skill_injection.skills", { { id = 2321006, delay = 200, hits = 1, type = 0 }, { id = 2321007, delay = 150, hits = 2, type = 1 } }) TYPE MISMATCH ERROR: -- WRONG: Will throw Lua error core.set_setting("autos.auto_pot.hp.enabled", 123) -- CORRECT: core.set_setting("autos.auto_pot.hp.enabled", true) NOTE: Dropdown settings store a 0-based INDEX, not the display string. CUSTOM STORAGE (core.settings.storage) - free-form, script-owned values Key/value storage for data NOT tied to any menu setting (counters, timestamps, per-character state). Saved as part of the active profile: persists across sessions, travels with the profile, and swaps when you switch profiles. Keys use dot-paths; values can be boolean/number/string/table. Namespace keys with your script name (e.g. "my_script.runs") to avoid collisions. core.settings.storage.get(path: string) -> value | nil - Reads a stored value; nil if nothing is stored at that path core.settings.storage.set(path: string, value: boolean|number|string|table) -> boolean - Stores a value (creates the path). Any key and any type accepted (no schema) - Returns true on success core.settings.storage.delete(path: string) -> boolean - Removes a stored value; returns true if one existed core.settings.storage.all() -> table - The whole storage namespace as a table (empty if nothing stored) Example: local runs = core.settings.storage.get("my_script.runs") or 0 core.settings.storage.set("my_script.runs", runs + 1) NOTE: Storage is live immediately but only PERSISTED when the profile is saved (core.profiles.save()), like any other setting. COMMON SETTING PATHS (75 total -- see Settings Reference page for complete list): Autos: "autos.auto_pot.hp.enabled" -- boolean "autos.auto_pot.hp.value" -- number (0-100) "autos.auto_pot.hp.keybind" -- string "autos.auto_pot.mp.enabled" -- boolean "autos.auto_pot.mp.value" -- number (0-100) "autos.auto_pot.mp.keybind" -- string "autos.auto_login.enabled" -- boolean "autos.auto_login.world_id" -- number (dropdown index, see world table) "autos.auto_login.channel" -- number (dropdown: 0=random, 1-40=specific) "autos.auto_login.char_index" -- number "autos.evasion.type" -- number (dropdown: 0=Next Map CC, 1=Disable, 2=Logout, 3=Terminate, 4=Town CC; after a death on a map that revives you in place, Next Map CC and Town CC wait for the revive instead of logging out) "autos.evasion.whitelisted_igns" -- table (string array; party members are always ignored) "autos.presets.character_preset" -- number (dropdown: 0=Don't change, 1-5=Preset 1-5; switched on login and when changed) "autos.presets.inner_ability_preset" -- number (dropdown: 0=Don't change, 1-3=Preset 1-3; after the Character Preset, level 50+) Hacks: "hacks.godmode" -- boolean "hacks.bossing_godmode" -- boolean "hacks.pet_loot" -- boolean "hacks.speedy_fma" -- boolean Combat: "combat.kami.enabled" -- boolean "combat.kami.type" -- number (dropdown: 0=Closest, 1=Random, 2=Random Speedy) "combat.kami.kami_exp" -- boolean "combat.kami.kami_loot" -- boolean "combat.kami.x_offset" -- number "combat.kami.y_offset" -- number "combat.skill_injection.enabled" -- boolean "combat.skill_injection.safe_mode" -- boolean "combat.skill_injection.skills" -- table (structured array, see below) Items: "items.filter_enabled" -- boolean "items.filter_mode" -- number (dropdown: 0=blacklist, 1=whitelist) "items.filtered_items" -- table (int array) "items.meso_filter_enabled" -- boolean "items.min_meso_amount" -- number Map: "map.rush_by_level" -- table (structured array, see below) "map.spawn_points" -- table (structured array, see below) Packets: "packets.streaming_enabled" -- boolean "packets.blocked_incoming_opcodes" -- table (int array) "packets.blocked_outgoing_opcodes" -- table (int array) "packets.ignored_incoming_opcodes" -- table (int array) "packets.ignored_outgoing_opcodes" -- table (int array) Macros: "macros.list" -- table (structured array, see below) WORLD INDEX TABLE (for autos.auto_login.world_id dropdown): 0 - Scania (NA, default) 1 - Bera (NA) 2 - Kronos (NA, Reboot, 40 channels) 3 - Hyperion (NA) 4 - NA CW Heroic (10 channels) 5 - NA CW Interactive (10 channels) 6 - Luna (EU) 7 - Solis (EU) 8 - EU CW Heroic (10 channels) 9 - EU CW Interactive (10 channels) Example - Auto login to Kronos, Channel 5, Character 2: core.set_setting("autos.auto_login.world_id", 2) -- Kronos (index 2) core.set_setting("autos.auto_login.channel", 5) -- Channel 5 core.set_setting("autos.auto_login.char_index", 2) -- 3rd character ARRAY SETTING STRUCTURES: skill_injection.skills: { enabled = bool, name = string, id = int, delay = int, hits = int, type = int } enabled (default true) and name (display label, default "") are optional type: 0=Generic, 1=Melee, 2=Magic, 3=Shoot, 4=Use Skill, 5=Safe Mode map.rush_by_level: { min_level = int, max_level = int, map_id = int } map.spawn_points: { map_id = int, x = int, y = int, label = string } macros.list: { name = string, key = string, delay = int, enabled = bool } Simple arrays (int): items.filtered_items, packets.blocked_*_opcodes, packets.ignored_*_opcodes Simple arrays (string): autos.evasion.whitelisted_igns ================================================================================ MENU / UI MODULE (core.ui) - CUSTOM SETTINGS UI ================================================================================ Build settings UI from scripts. Each core.ui.* constructor takes ONE options table and returns an element HANDLE you read/mutate at runtime. Values persist and are also reachable via core.get_setting/core.set_setting. IDS & TABS: - IDs are namespaced per script: id="enabled" in my_script.lua -> "lua.my_script.enabled". An ID already starting with "lua." is kept as-is. Prefer the handle (elem:get()) so you never type the full ID. - Omit `tab` to use the shared "Lua" tab, or pass any name for your own tab. Reserved tabs rejected: autos, combat, hacks, items, map, macros, keybinds, packets, debug, settings, player, admin. - Idempotent: re-running with an existing ID returns a handle to it. COMMON OPTIONS (all constructors): id (req), label, panel (req), tab (def "Lua"), section (in-page sub-tab, see SECTIONS), tooltip, order, description, visible_when, hidden, disabled, dangerous, transient, critical (bool|string). CONSTRUCTORS (extra options in parentheses): core.ui.checkbox{...} (default:bool, disclosure?:bool=false) -> element; disclosures expand following visible_when dependents core.ui.slider_int{...} (default,min,max,step) -> element core.ui.slider_float{...} (default,min,max,step,rounding) -> element core.ui.dropdown{...} (options:string[], default:index, table?:table) -> element; value remains original 0-based option index after filtering/search table: separator (non-empty string), columns={{label, badges?={cellText=tone}}}, optional filters={{label,column=1-based,values=string[]}}, labelColumn=1-based (default 1), image={kind="minimap"|"item_icon",column=1-based} (row thumbnail from that column's map/item ID; non-ID cells show a placeholder), selectionLabel (default Selected), searchPlaceholder, description, emptyOption, emptyText. Badge tones: primary/success/warning/danger/info/muted. Each option splits into one trimmed field per column; inconsistent rows use ordinary dropdowns. emptyOption as sole option displays no selectable rows. set_options retains table description. Missing columns/labels or separator, or an invalid image, raises an error. Layout and wrapping are automatic for all menus; no per-script spacing options. core.ui.radio_group{...} (options:string[], default:index) -> element -- value=index core.ui.multi_select{...} (options:string[], default:index[]) -> element -- value=index[] core.ui.input_text{...} (default:string, flags:string[]) -> element; flags={"password"} conceals with Show/Hide; reveal does not save, profile/session changes conceal and cancel pending edits. Display-only; still saved unless transient=true. Transient text updates as you type before selections/actions. core.ui.keybind{...} (default:string) -> element core.ui.color{...} (default:{r,g,b,a} 0..1) -> element core.ui.button{...} (variant, confirm) -> element -- no value; poll elem:pressed() core.ui.static_text{...} (text, style:"muted"|"body"|"heading", markdown:bool) -> element -- display only core.ui.progress{...} (value, min, max, display:"percent"|"value", color) -> element -- read-only; elem:set_value() core.ui.image{...} (minimap:map_id | item_icon:item_id, size:"small"|"medium"|"large", show_name:bool) -> element -- display only; game art by ID, no URLs/files One source at most (both -> error); IDs must be positive whole numbers. No source = placeholder; ID without game art = "No image available". small = 48px square, medium (default) = 160px tall, large = 288px tall; medium/large span the panel. show_name adds the map/item name (beside the image at small; small placeholders are icon-only). core.ui.separator{...} () -> element -- divider; label = heading TAB ORGANIZATION (core.ui.tab) - nav metadata for YOUR tabs; returns nothing: core.ui.tab{ name=(req), icon="swords", group="Azura's Scripts", group_icon="crown", order=1 } - icon/group_icon: kebab-case lucide name from the curated set below (unknown -> default icon) - group: your tabs sharing the same label fold into ONE collapsible sidebar entry (submenu); built-in tabs can never be regrouped/re-iconed - order: sort among script tabs / within the group (lower first) - reserved tab names rejected, plus the shared "Lua" tab Icon set: activity, alarm-clock, anchor, archive, award, axe, backpack, banknote, bell, bird, bomb, book, book-open, bot, braces, brain, brush, bug, calendar, camera, car, castle, circle-dollar-sign, clock, cloud-lightning, code-2, cog, coins, compass, cpu, crosshair, crown, database, diamond, dices, dna, droplet, egg, eye, eye-off, feather, file-code, filter, fish, flag, flame, flask-conical, footprints, gamepad-2, gauge, gem, ghost, gift, glasses, hammer, hash, heart, hourglass, house, infinity, key, landmark, layers, layout-grid, leaf, link, list, lock, magnet, mail, map, map-pin, medal, message-square, moon, mountain, music, navigation, package, palette, pause, paw-print, pen, percent, pickaxe, pin, plane, play, puzzle, rabbit, refresh-cw, repeat, rocket, route, ruler, scale, scissors, scroll, scroll-text, search, send, settings, shield, shield-check, ship, shopping-cart, skull, sliders-horizontal, snowflake, sparkles, sprout, star, store, sun, sword, swords, table, target, tent, terminal, test-tube, timer, trending-up, trees, trophy, truck, umbrella, unlock, user, users, wallet, wand-2, watch, waves, wheat, wrench, zap SECTIONS (in-page sub-tabs): pass section="Buffs" on elements -> panels sharing a section render under an in-page tab strip (like Player > Inventory); panels WITHOUT a section stay pinned above the strip (put master toggles there). Sections appear in first-use order; keep all elements of one panel in the same section. Display-only: IDs, values, and profiles are unaffected. ELEMENT HANDLE METHODS (mutators return the handle for chaining): elem:get() -> any -- current value elem:set(value) -> boolean -- set value (coerced to element type) elem:id() -> string -- full namespaced ID elem:set_label(s) / elem:set_tooltip(s) elem:set_visible(bool) / elem:visible() -> bool elem:set_enabled(bool) / elem:enabled() -> bool elem:set_visible_when(other_id) elem:set_order(n) / elem:set_panel(s) / elem:set_section(s) -- "" detaches from strip elem:set_options(string[]) -- dropdown/radio_group/multi_select elem:get_index() / elem:set_index(i) -- dropdown/radio_group elem:get_selected() -- dropdown/radio: string; multi_select: index[] elem:set_selected(index[]) -- multi_select elem:set_min(n) / elem:set_max(n) / elem:set_step(n) -- sliders elem:set_text(s) -- static_text elem:set_value(n) -- progress elem:set_source{minimap=id} | {item_icon=id} | nil -- image; nil clears, a table with neither key errors; same source again is a no-op (safe every tick) elem:source() -> {minimap=id} | {item_icon=id} | nil -- image elem:pressed() -> bool -- button: true once per click, then clears elem:remove() BUTTONS: no event callbacks -- poll elem:pressed() in on_tick. Example: local enabled = core.ui.checkbox{ id="enabled", label="Enable", panel="General", default=true } local mode = core.ui.radio_group{ id="mode", label="Mode", panel="General", options={"Safe","Fast"}, default=0 } local run = core.ui.button{ id="run", label="Run Once", panel="Actions" } function on_tick(stage) if run:pressed() and enabled:get() then core.log("mode="..mode:get_selected()) end end LEGACY core.menu (still works; thin wrappers over core.ui, forced "Lua" tab, legacy "lua." naming, return boolean -- read via core.get_setting): core.menu.create_checkbox(id, label, tab, panel, default_value [, tooltip]) -> boolean core.menu.create_slider_int(id, label, tab, panel, default, min, max [, step [, tooltip]]) -> boolean core.menu.create_slider_float(id, label, tab, panel, default, min, max [, step [, tooltip [, rounding]]]) -> boolean core.menu.create_dropdown(id, label, tab, panel, options_table, default_index [, tooltip]) -> boolean core.menu.create_keybind(id, label, tab, panel [, default_key [, tooltip]]) -> boolean core.menu.create_input_text(id, label, tab, panel [, default_value [, tooltip]]) -> boolean ================================================================================ PROFILES MODULE (core.profiles) - SAVED SETTINGS PROFILES ================================================================================ Read the user's saved settings profiles and switch the session between them (the same profiles shown in the web menu's profile bar). Metadata (id + name) is kept current, and list() and get_active() are instant and safe to call every tick. Switching applies a profile's full settings and takes a moment to propagate. core.profiles.list() -> table - Array of profiles, each: { id = number, name = string, active = boolean } - active = true for the profile currently applied to this session - Returns immediately core.profiles.get_active() -> table | nil - The profile applied to this session: { id = number, name = string } - nil if no profile is active core.profiles.switch(profile: string | number) -> boolean - Switch the session to a saved profile, by name or by id - Returns true if the profile exists and the switch started (or it is already active); false if the name/id is unknown or a switch is already in progress - Asynchronous: true means accepted, not yet applied. While a switch is in flight, further switch() calls return false (reads stay instant). Poll get_active() until it reports the target to act after the swap lands. Example: if not core.profiles.switch("Bossing") then core.log_error("switch rejected (unknown profile or one already running)") end core.profiles.save([options: table]) -> boolean - Saves current settings to the ACTIVE profile (same as web menu Save button) - Returns true if the save request was sent (asynchronous, like switch()) - Full save by default. options selects a subset (everything else in the profile is left intact): sections = { "lua", ... } -- whole top-level categories keys = { "hacks.godmode", ... } -- individual setting paths Example: core.profiles.save() -- full save to active core.profiles.save({ sections = { "lua" } }) -- only the lua section core.profiles.save({ keys = { "hacks.godmode" } }) core.profiles.save_as(name: string [, options: table]) -> boolean - Creates a NEW profile from current settings (auto-deduplicates the name, e.g. "Build (1)"). Same options filter as save(). Async; returns true if sent. Example: core.profiles.save_as("Bossing Build") core.profiles.save_as("Lua Only", { sections = { "lua" } }) ================================================================================ SCHEDULER MODULE (core.scheduler) - SCRIPT STEPS AND CONDITIONS ================================================================================ Add steps and yes/no conditions to the web menu's Scheduler. Steps appear under "From your scripts" with a form built from their fields; conditions can be used in If conditions and rules. One step runs at a time: on_step_start(step) starts it, the script reports with step:done() / step:fail(), on_step_stop(step, reason) ends it early. Only the owning script gets its step's callbacks. Steps and conditions are removed when the script unloads; a step still running then fails. core.scheduler.register_step(id: string, definition: table) - Add or replace this script's step; id: 1-40 of a-z, 0-9, _ - definition: { name (default id), description, fields } - fields: up to 12; array keeps order, or map key = field (sorted by field.order, then key) - field: { key (1-32 of a-z 0-9 _), type, label (default key), default, ... } checkbox -> boolean | number -> number (min, max, step) | dropdown -> string (options: 1-32 strings) | multi -> table of strings (options) | text -> string (max_length 1-200, default 200) - Values the field doesn't accept are replaced by its default before on_step_start - Errors: invalid definition, called outside a loaded script, more than 16 steps core.scheduler.unregister_step(id: string) -> boolean - Remove this script's step; true if it existed core.scheduler.register_condition(id: string, definition: table, check: function) - Add or replace a yes/no condition for If conditions and rules; definition: { name, description } - check() runs about once a second; anything but false/nil counts as true - A check that raises counts as false (first error reported to the console); up to 16 per script core.scheduler.unregister_condition(id: string) -> boolean - Remove this script's condition; true if it existed core.scheduler.current() -> table | nil - This script's running step (same table on_step_start got), or nil step:progress([fraction: number] [, text: string]) -> boolean - fraction 0-1, text up to 120 chars shown on the run; sent at most once a second, latest wins - false if the step is no longer running step:done([text: string]) -> boolean - Finish the step; the schedule moves on; false if no longer running step:fail([reason: string]) -> boolean - End the step as failed (reason up to 200 chars); retry/skip settings apply; false if no longer running step:is_active() -> boolean - true while the Scheduler runs this step Notes: - After a "safety" stop, stop the step's work and don't restart it on your own - A script that isn't running is turned on for its step and off afterwards - A script step always has a time limit in the Scheduler; an error in on_step_start fails the step Example: core.scheduler.register_step("daily_bosses", { name = "Daily bosses", fields = { { key = "bosses", type = "multi", options = { "Zakum", "Horntail" }, default = { "Zakum" } } }, }) local active function on_step_start(step) active = step end function on_tick(stage) if not active or stage ~= core.stage.FIELD then return end active:progress(0.5, "Zakum down") -- then active:done() or active:fail("Out of potions") end function on_step_stop(step, reason) active = nil end ================================================================================ STAR FORCE MODULE (core.starforce) - AUTO STAR FORCE CONTROL ================================================================================ Controls Auto Star Force (Autos -> Auto Starforce panel). Writes through the same settings the web UI uses, so changes show up in the menu. Slot encoding: negative = equipped (e.g. -11 = weapon), positive = Equip inventory slot. Insufficient meso protection: the price is checked with the server before every attempt; if the player can't cover it, status reports "insufficient_meso" and the run stops. If the server doesn't answer a run's first price check, the rest of that run checks against estimated prices (costSource "estimate"). Run behaviour: - Server price + odds are fetched before every attempt; the server's price drives the meso check (exposed as result.quote). If the server doesn't answer the run's first price check, the rest of that run uses estimated prices, incl. for the meso check (costSource "estimate", no quote); if it stops answering later in the run, the run stops with "timeout". MVP / Sunny Sunday only affect estimates. - Pauses while the in-game equipment enhancement window is open (status "paused", reason "native_ui_open"); resumes on its own once it closes - Enhancement mode (0-3 = Level 1-4): 15-21 star attempts only; Level 4 from 18 stars; a level not available for the item at its current star count is lowered to the highest one that is; items that can't use it (e.g. Superior equipment) always use Level 1; ignored while safeguard applies - Safeguard: 15-17 star attempts only, and only when the attempt can destroy the item; while a run uses estimated prices it is requested (cost counted) on every 15-17 star attempt the item allows it on - Equipped items need 1 free Equip inventory slot on attempts that can destroy them, else the run stops with "equip_tab_full" core.starforce.start([opts: table]) -> boolean, string? - Configures and starts an Auto Star Force run - All opts fields optional; omitted fields keep their current setting - Slot must be non-zero (in opts or already configured) or returns false + error - Returns true on success, or false + error string opts fields: slot = number -- equipment slot target = number -- stop at this star level (1-30) safeguard = boolean -- protect 15-17 star attempts from destruction (extra cost; skipped when no destroy risk) sunny_sunday = boolean -- 30% Sunny Sunday discount (estimates only) mvp_tier = string|number -- "none"|"silver"|"gold"|"diamond" or 0-3 (up to 17 stars; estimates only) Example: local ok, err = core.starforce.start({ slot = -11, target = 17, safeguard = true, mvp_tier = "gold" }) core.starforce.stop() -> boolean - Disables Auto Star Force; returns true if toggle flipped - Safe to call when not running core.starforce.is_running() -> boolean - True if the Enable Auto SF checkbox is on core.starforce.get_status() -> table - Snapshot of the current run - Fields: running = boolean state = "idle"|"quoting"|"waiting_result"|"paused" -- quoting = waiting for server price/odds; waiting_result = attempt sent slot = number current_stars = number target_stars = number attempts = number -- attempts that produced a result this run spent = number -- mesos charged in this run result = table -- live status object (see below) - result fields (status/stars/attempts/spent always present; others depend on status): status = string -- see result.status values below stars = number -- star count last observed attempts = number spent = number -- sum of the price of every attempt that produced a result reason = string? -- "paused" only: "native_ui_open"|"busy" lastCost = number? -- "enhancing": price of the attempt just sent costSource = string? -- with lastCost/nextCost: "server"|"estimate" -- (estimate: rest of the run once the server didn't answer the price check) quote = table? -- "enhancing" + stops after a price check: server terms (below); -- absent while the run uses estimated prices nextCost = number? -- "insufficient_meso": price of the next attempt currentMeso = number? -- "insufficient_meso": mesos held coinsNeeded = number? -- "insufficient_coins": Shadowknight Coins needed coinsHeld = number? -- "insufficient_coins": Shadowknight Coins held requestedMode = number? -- "mode_rejected": enhancement mode requested (0-3) ackedMode = number? -- "mode_rejected": enhancement mode the server answered with response_type = number? -- "server_rejected"/"invalid_response": server reply type (bug reports) code = number? -- "server_aborted": reply code, -1 if none chargeUnverified = boolean? -- may be present on "timeout" (an attempt was sent and no result -- arrived) and on "server_rejected" after an attempt was interrupted: -- attempt may or may not have been charged; not counted in spent - result.quote fields: cost = number -- mesos charged for this attempt (discounts + safeguard applied) successPct = number -- % chance of gaining a star (1 decimal) destroyPct = number -- % chance the item is destroyed (2 decimals; 0 when safeguarded/no risk) canDrop = boolean -- a failure can lower the star count chanceTime = boolean -- attempt is guaranteed (chance time) coinCost = number? -- Shadowknight Coins, only for items that need them nextStar = number -- star count after a success - result.status values "idle" -- nothing running (stars/attempts/spent = 0) running (toggle stays on): "quoting" -- waiting for the server's price/odds for the next attempt "enhancing" -- attempt sent, waiting for its result "paused" -- reason native_ui_open = close the in-game enhancement window to continue; busy = character can't act right now (dead, request pending, throttled) stopped (toggle turned off): "done" -- target reached, or item at its maximum stars "item_not_found" -- no item at the configured slot "not_enhanceable" -- item present but Star Force can't be used on it "item_boomed" -- item was destroyed "insufficient_meso" -- can't afford the next attempt "insufficient_coins" -- item also costs Shadowknight Coins and there aren't enough "equip_tab_full" -- equipped item, attempt can destroy it, no free Equip inventory slot "mode_rejected" -- server wouldn't accept the selected enhancement mode "feature_disabled" -- Star Force disabled on this server/channel "timeout" -- no reply from the server in time "server_rejected" -- server refused or interrupted the request "server_aborted" -- server cancelled the request "invalid_response" -- a reply couldn't be read core.starforce.estimate_cost(item_level: number, current_star: number [, opts: table]) -> number - Estimated meso cost of one attempt at current_star on an item of given required level - GMS formula with discounts, safeguard surcharge and enhancement mode applied; the base price is rounded to the nearest 100 mesos first; can differ from the server's price (runs use the server's unless the server didn't answer the run's first price check) - opts omitted -> uses live settings (incl. enhancement mode) - opts fields: safeguard, sunny_sunday, mvp_tier (same encoding as start()), enhance_mode (0-3, same index as the Enhancement Mode setting; disallowed levels lowered like a run) Example: local cost = core.starforce.estimate_cost(150, 16, { safeguard = true, mvp_tier = "gold" }) local lv4 = core.starforce.estimate_cost(200, 18, { enhance_mode = 3 }) ================================================================================ CUBE MODULE (core.cube) - AUTO CUBE CONTROL ================================================================================ Controls Auto Cube (Autos -> Auto Cube panel). Writes through the same settings the web UI uses, so changes show up in the menu and persist. The frontend widget and Lua share state. Slot encoding: negative = equipped (e.g. -11 = weapon), positive = Equip slot. Matching: each roll's three potential lines are resolved to displayed strings and parsed into canonical {stat, value} pairs. A roll matches when any condition group is fully satisfied (OR-of-AND). core.cube.start([opts: table]) -> boolean, string? - Configures and starts an Auto Cube run - All opts fields optional; omitted fields keep their current setting - Slot must be non-zero (in opts or already configured) or returns false + error - Returns true on success, or false + error string opts fields: slot = number -- equipment slot cube_type = string|number -- "glowing"|"bright"|"mystical"|"hard"|"solid" or 0..4 (mystical/hard/solid cap at Epic/Unique/Legendary) min_grade = number -- 0=Common, 1=Rare, 2=Epic, 3=Unique, 4=Legendary Example: local ok, err = core.cube.start({ slot = -11, cube_type = "bright", min_grade = 3 }) core.cube.stop() -> boolean - Disables Auto Cube; returns true if toggle flipped - Safe to call when not running core.cube.is_running() -> boolean - True if the Enable Auto Cube checkbox is on core.cube.get_status() -> table - Snapshot of the current run - Fields: running = boolean state = "idle"|"sending_cube"|"waiting_result"|"checking_result" |"accepting"|"rejecting"|"waiting_reject_confirm"|"cooldown" slot = number cubes_used = number last_rolled = { p1=number, p2=number, p3=number, grade=number } result = table -- live status object pushed to the web UI core.cube.set_conditions(spec: table) -> boolean, string? - Sets the "good roll" definition. OR-of-AND: roll accepted when any group is fully satisfied; each group is a list of conditions that must all hold - Accepts two shapes: Flat (single AND group): { {stat="STR %", min=21}, {stat="Attack Power %", min=18} } OR-of-AND: { { {stat=..., min=...}, ... }, { {...}, ... } } - Each leaf is one of: { stat = "", min = } -- summed across all 3 lines { exact = "", count = } -- literal tooltip match - All Stats % automatically counts toward STR %/DEX %/INT %/LUK % thresholds Common canonical stat names (use parse_potential to discover others): STR, STR %, DEX, DEX %, INT, INT %, LUK, LUK %, All Stats, All Stats % Attack Power, Attack Power %, Magic ATT, Magic ATT %, DEF, DEF % Max HP, Max HP %, Max MP, Max MP % Boss Damage %, Critical Damage %, Critical Rate %, Damage % Ignore Defense %, Item Drop Rate %, Mesos Obtained %, EXP Obtained % Cooldown Reduction (sec), MP Cost Reduction % core.cube.get_conditions() -> table | nil - Returns stored conditions in OR-of-AND shape, or nil if module unavailable core.cube.clear_conditions() -> boolean - Removes all conditions; with none set, every roll passing min_grade is kept core.cube.resolve_potential(pot_id: number [, level: number]) -> string | nil - Resolves a raw potential ID to its displayed tooltip line (no [Grade] suffix) - If level omitted, looks up level by matching pot_id against the configured equip slot's three potentials - Returns nil for pot_id 0 or unresolvable core.cube.parse_potential(str: string) -> table | nil - Parses a displayed line into { stat = "", value = } - Returns nil for non-stat lines (e.g. proc chances) -- those need exact match core.cube.inspect_current_item() -> table | nil - Reads the equip at the configured slot and returns: grade = number -- 0=Common .. 4=Legendary potentials = table -- array of 3 entries each: { id, level, line, stat?, value? } - Returns nil if slot is 0 or no item present ================================================================================ FLAME MODULE (core.flame) - AUTO FLAME CONTROL ================================================================================ Rerolls additional options until confirmed combat power exceeds the run-start score, or a limit/failure stops the run. Target must occupy a positive Equip inventory slot. Lua and the web menu share settings/results. Target, source and limits are fixed for each active run. Inventory mode spends flame items; mesos mode pays directly without a flame item. Current cost: 3,000,000 mesos/roll, checked against game's cost and your balance. Never falls back from one payment source to another. core.flame.start([opts: table]) -> boolean, string? - Validate every option before changing any setting. Omitted/nil opts uses configured values. Reject malformed types, numeric strings, fractions, non-finite numbers, unknown options, invalid ranges and active/stopping runs. - True enables the run; does not confirm server acceptance or a paid roll. - Script-started runs always keep a better roll automatically, whatever the web menu's "When a roll is better" choice is. - While the character is busy the run waits, then rolls when it is free. A request that produced no roll and spent nothing is retried a few times. opts fields (all optional; omissions preserve configured values): slot = number -- positive integer Equip inventory slot 1..32767 source = string -- "inventory" or "mesos"; default config "inventory" item_id = number -- integer 0..5100000; 0=any usable flame, inventory only max_flames = number -- integer 0..999999 roll cap, both modes; default 10; -- 0 = no roll limit (other stop conditions still apply) max_mesos = number -- integer 3000000..29997000000 budget; default 30000000 - Nonzero item_id must be a supported flame in inventory mode; ignored for payment in mesos mode. - max_mesos is full currency, not millions; any integer in range is valid. Stop before a roll would exceed budget; unused remainder is not spent. Example: local ok, err = core.flame.start({ slot=5, source="mesos", max_flames=20, max_mesos=30000000 }) core.flame.stop() -> boolean - Request stop without another paid roll; an outstanding operation may settle. - Settle selection with strongest eligible offer, including original if offered. - True if Auto Flame is available, safe when idle; final counters remain. core.flame.is_running() -> boolean - True while enabled or settling an outstanding operation after stop. - Wait for false before starting again. core.flame.get_status() -> table | nil - Snapshot with final counters retained; nil if Auto Flame unavailable. - Check final result even when running is false. Fields: running = boolean state = "idle"|"waiting_result"|"evaluating_roll"|"waiting_commit"| "checking_result"|"awaiting_choice"|"paused"|"stopping" source = "inventory"|"mesos" slot = number flames_used = number -- rolls confirmed by matching server results run_start_cp = number -- run-start combat power score current_cp = number -- latest confirmed combat power score baseline_cp = number -- run-start score used to judge improvement mesos_spent = number -- confirmed rolls times meso_cost; 0 for inventory meso_cost = number -- this run's cost per meso roll max_mesos = number -- this run's budget result = table -- shared result, camelCase fields: status, running, state, source, slot, flamesUsed, oldCp, newCp, baselineCp, mesosSpent, mesoCost, maxMesos, maxFlames choiceOldCp = number -- optional during awaiting_choice: current item's -- Before score; compare with newCp. oldCp stays the -- run-start comparison score. Older cores omit it. Active result.status: flaming, rolling, selecting, stopping, waiting (character busy), retrying (last request rolled nothing and spent nothing), awaiting_choice (web menu "Ask me first" run waiting for Before/After), paused (player picked; waits for continue or stop from the web menu). Terminal result.status: done (confirmed improvement over run-start score), stopped, max_reached, budget_reached, insufficient_meso, no_flames, item_not_found, item_equipped, invalid_config, native_unavailable, request_rejected, response_rejected, timeout, pending_selection, target_changed, character_changed, disconnected. timeout means unconfirmed outcome, not failure/refund. No automatic retry. Counters include confirmed results only: mesos_spent=0 does not prove no charge. Inspect item and any open flame selection before starting again. ================================================================================ AUTO CHAR MODULE (core.autochar) - SCRIPTED CHARACTER CREATION ================================================================================ Wraps the Auto Char machinery (Autos -> Auto Login panel). Writes through the same settings the web UI uses (class, Burning flag, enabled state stay in sync). Two modes: - Ambient (enable): create whenever the configured Auto Login slot is empty, using generated names, unattended. - One-shot (create): a single script-initiated attempt with a terminal outcome you poll via get_status().request. Creation runs from the character-select screen forward (pick empty slot -> class -> name -> intro story). create() drives it from char select; world select stays Auto Login's job. Run at character select or with Auto Login enabled. create() lifecycle -- get_status().request is the single field a retry loop polls: "none" = no create() in play "pending" = in flight (routing/submitting/awaiting verdict); survives disconnects "finishing" = name accepted (character exists); story clicker running "created" = terminal success (latched until next create()/cancel()) "rejected" = terminal: script name refused; flow returned to char select and HOLDS there (won't log into an existing char) until retry/cancel. name.code = server reason. Retry = another create()/set_name() with a new name (class is remembered, so opts can be omitted). Names vs generator: the return-to-char-select-on-reject behavior applies ONLY to a script-supplied name. Omit name and the generator retries names in place until accepted (request goes straight pending -> created, never bounces). core.autochar.enable([opts: table]) -> boolean, string? - Ambient mode: auto-create when the configured Auto Login slot is empty - opts fields (all optional, shared with create): class = string|number -- class label (case-insensitive) or 0-based index burning = boolean -- accept a Burning prompt if it appears (default off) name = string -- specific IGN (1-12 alphanumeric); omit = generated - Returns true, or false + error (unknown class, invalid name, module not ready) core.autochar.disable() -> boolean - Turns off the ambient checkbox; a one-shot create() keeps running (use cancel) - Returns false only if the autos module isn't loaded core.autochar.create([opts: table]) -> boolean, string? - One-shot atomic attempt, independent of the checkbox (opts same as enable) - Omitting name uses the generator; request survives disconnects while pending - Returns true if started, or false + error if a creation is already in flight (pending/finishing), the class/name is invalid, or the module isn't ready. Calling from a terminal state (created/rejected) or none starts a fresh attempt - Example: core.autochar.create({ class = "Kanna", name = "IceFarmer" }) core.autochar.cancel() -> boolean - Withdraws a create() request and clears the name latch (incl. created/rejected) - Does not touch the ambient checkbox core.autochar.set_name(name: string | nil) -> boolean, string? - Arms (or clears, with nil) the script-supplied IGN; verdict via get_status().name - During a create() holding at char select after a rejection, arming a new name resumes it in place (same as calling create() again) - Name must be 1-12 alphanumeric, else returns false + error core.autochar.get_status() -> table | nil - Snapshot of Auto Char state, or nil if the module isn't loaded - Fields: enabled = boolean -- ambient checkbox on class = string -- selected class label class_index = number -- 0-based class index set_burning = boolean request = "none"|"pending"|"finishing"|"created"|"rejected" phase = "idle"|"selecting_class"|"naming"|"story" last_submitted_name = string -- most recent IGN (accepted one after success) name = { value = string, state = "none"|"pending"|"accepted"|"rejected", code = number } -- 0 accepted, else rejection code core.autochar.get_classes() -> table - Array of class label strings in dropdown order - Array index minus one = the 0-based class index enable/create accept - Labels matched case-insensitively ================================================================================ OBJECT MANAGER ================================================================================ core.object_manager.get_local_player() -> Player | nil - Returns local player object - nil if player not loaded - ALWAYS validate with is_valid() before use core.object_manager.get_current_map() -> Map | nil - Returns current map object - nil if map not loaded - ALWAYS validate with is_valid() before use ================================================================================ PLAYER OBJECT ================================================================================ player:is_valid() -> boolean - ALWAYS call before using other methods player:get_id() -> number player:get_account_id() -> number - Account ID owning this character; same across all characters on the account (see core.util.fnv1a) player:get_name() -> string player:get_health() -> number player:get_max_health() -> number player:get_mana() -> number player:get_max_mana() -> number player:get_level() -> number player:get_exp() -> number player:get_exp_percent() -> number player:get_meso() -> number player:get_sol_erda() -> number player:get_session_stats() -> table | nil - This session's earnings and rates: {runtime_sec, meso_gained, meso_per_hour, exp_gained, exp_per_hour, exp_percent_per_hour}; nil before the first sample - Rates are per hour, matching the web dashboard; divide by 60 for per-minute - Totals are live immediately; rates come off a rolling 5-minute window and read 0 until it spans at least a minute. Rates also return to 0 once the newest sample is over a minute old, so ALWAYS treat a rate of 0 as "no rate available", never as "earning nothing" - Gross totals: spending meso does not reduce meso_gained, and levelling up does not reset exp_gained - Sampled only while in a field; parking in the cash shop or auction house freezes the totals (they do not reset) and zeroes the rates, which need ~1 min of fresh samples after returning player:get_stats() -> table | nil - Snapshot of the primary stats: {str, dex, int, luk, ap}; nil if character data is not loaded - Totals as shown in the stat window (base + gear + buffs); ap is unspent ability points player:get_detailed_stats() -> table | nil - The Character Info window's detailed stats: {damage, boss_damage, final_damage, ignore_defense, critical_rate, critical_damage, star_force, arcane_power, sacred_power, exp_bonus}; nil when not in a map - Percentages are numbers and can have decimals; values include buffs - critical_rate is the rate that applies: capped at 100 even when the window shows more - exp_bonus does not include the reduction from daily remote play restrictions - Recalculates every stat on each call; keep the result instead of calling it every tick - Example: local s = player:get_detailed_stats(); if s and s.critical_rate < 100 then print(100 - s.critical_rate) end player:is_alive() -> boolean | nil - false while dead (HP 0, tombstone up); nil if character data is unavailable player:get_pets() -> table - Summoned pets; each entry {repleteness, is_active} - repleteness is the 0-100 fullness meter; it decays and the pet despawns at 0, so watch it and feed player:get_summons() -> table - Your active summons; each entry {id, skill_id, skill_level, position = {x, y}, time_remaining?} - time_remaining: milliseconds until the summon's time runs out; nil for a summon without a timer and once its time is up - Snapshot tables: call again for fresh values - Example: for _, s in ipairs(player:get_summons()) do if s.skill_id == 42111003 then print(s.position.x, s.position.y) end end player:get_job() -> number player:get_position() -> {x: number, y: number} - Returns position table - Example: local pos = player:get_position(); print(pos.x, pos.y) player:get_server_position() -> {x: number, y: number} - Position the server believes the character is at - Use when client-side and server can drift (teleport/rush automation) player:anchor() -> number | nil - Anchor handle for core.render; pass as opts.anchor so shapes follow the sprite between ticks - Valid for the CURRENT on_render() CALL ONLY; call again every frame. Pass the number, not the player object - nil if the player's position is unavailable player:get_render_position() -> {x: number, y: number} | nil - Position as drawn in the latest rendered frame (includes between-tick interpolation) - First call usually nil (registers interest); available on following frames player:get_move_action() -> number - Returns raw character move action value player:is_left() -> boolean - Returns true when the character is facing left player:get_hyperstat_sp() -> number | nil - Hyper stat points available on the active preset (= get_hyper_stats().available) - nil when no character data is available or the points haven't loaded yet player:get_hyper_stats(preset?: number) -> table | nil - Hyper stats of preset 0-2 (default: the active preset); nil without character data or for another preset value - {preset, available?, used?, total?, stats = {hyper_stat}} (stats in ascending ID order); only the active preset can be raised - hyper_stat: {id, name, level, max_level, next_cost?, can_level_up} - next_cost omitted at max_level; max_level rises after the 5th job advancement - The first read in a session loads points and costs; until then available/used/total/next_cost are omitted (if never answered: until the Hyper Stat window is opened) - can_level_up: active preset, below max_level, map allows it, available >= next_cost; stat 80000421 stays locked until the 5th job advancement player:get_hyper_skills() -> table | nil - {passive = {total, used, available}?, active = {total, used, available}?, skills = {hyper_skill}} - hyper_skill: {id, name, type ("passive"|"active"), level (0 = not learned), max_level, required_level, can_learn} - Learning a hyper skill costs 1 point of its type; passive/active omitted until points load (normally right after login), and can_learn is false until then - can_learn: required_level and prerequisites met, not learned, a point of its type left, map allows it; a few class-specific summons/transformations still make the game refuse - nil when no character data is available player:get_skill_sp(tier: number) -> number - SP available to spend on the given job-advancement tier (skill-window tab) - Most jobs use per-tier Extended SP; tier 0 = beginner, 1+ = job tabs in order (Dual Blade 1-6; Zero: its two skill columns, 1-2) - core.skill_book.get_skill_level_up_info(skill_id).tier gives the tier a skill spends from player:level_up_skill(skill_id: number, count: number) - Sends a skill-up request, spending SP to raise skill_id by count levels - Invalid requests are rejected - Hyper skills spend hyper SP and rise one level; count is ignored for them - core.skill_book.can_level_up(skill_id) says whether a point can go in first player:get_character_presets() -> table | nil - The 5 Character Preset bundles (PRESET1-5 window), 0-based index - character_preset: {index, name, equip?, hyper_stat?, ability?, union?, link_skill?, shortcut?, familiar?} - A category the bundle leaves unchanged has no field player:get_active_presets() -> table | nil - Preset currently active per category: {equip, hyper_stat, ability, union, link_skill, shortcut, familiar} - A category that can't be read right now has no field player:apply_character_preset(index: number) -> boolean - Applies bundle index without showing the swap prompt - Leaves out parts the game won't change right now (e.g. Hyper Stat below level 140, a part the map blocks), like the in-game window - false when the slot is empty, nothing would change, or the game refuses now (dead, in battle, another request pending); true only means sent, poll get_active_presets() player:change_equip_preset(index: number) -> boolean, number - Switches the equipment window's preset tab (0-2); true,0 when sent - Otherwise false + a reason code (15 already active, 12 dead, 1 blocked) player:get_arcane_symbols() -> table - Array of equipped Arcane symbols (Arcane River region order; unequipped skipped) - symbol: {index 0-5, position, level, exp, max_level (20), exp_to_next, can_level_up, is_max} player:get_sacred_symbols() -> table - Array of equipped Sacred/Authentic symbols (Grandis region order), then equipped Grand Sacred symbols (Tallahart, Geardock) - symbol: {index 0-5 (Grand Sacred 6-8), position, level, exp, max_level (11), exp_to_next, can_level_up, is_max} player:has_buff(buff_id: number) -> boolean - Returns true if buff active - More efficient than get_buff() for checking existence player:get_buff(buff_id: number) -> buff | nil - Returns buff object or nil - Buff: {id, type, name, time_remaining, stacks?} - time_remaining in milliseconds - stacks: count shown on the buff icon (e.g. Mo Xuan's Divine Fist); only some stacking buffs report it, nil otherwise player:get_buffs() -> table - Returns array of all active buffs - Buff: {id, type, name, time_remaining, stacks?} player:get_class_state() -> table | nil - Resources and forms a class keeps outside its buffs; nil if character data is not loaded; {} for any other class - Zero: {form = "alpha" | "beta"} - Ark: {form = "flora" | "specter", spectra (0-1000)} - Hoyoung: {talisman_energy (0-100), scroll_energy (0-900), heaven, earth, humanity}; each boolean is true while that attribute is lit in the current attribute combo - Sia Astelle: {stellagram = recorded marks, oldest first, each "ray" | "boom" | "pole" | "link" | "shine"}; empty when nothing is recorded - Example: local s = player:get_class_state(); if s and s.form == "specter" and s.spectra < 200 then core.log("low Spectra") end ================================================================================ MAP OBJECT ================================================================================ map:is_valid() -> boolean - ALWAYS call before using other methods map:get_id() -> number - Returns map ID (e.g., 100000000 = Henesys) map:is_town() -> boolean - Returns true if map is a town map:get_burning_stage() -> number | nil - Returns burning XP stage (0-10) - nil if not burning field map:get_drops() -> table - Returns all drops on map - Drop: {id, unique_id, position: {x, y}, type, own_type, owner_id, source_id} - id = item ID (shared by every drop of that item); unique_id = this one drop on the ground, stable for its lifetime - Already filtered to drops you may loot, so own_type says WHY it is yours: 0 = personal, 1|4 = your party's, else free-for-all - owner_id = your character ID or party ID, per own_type; source_id = mob:get_unique_id() of the mob that dropped it map:get_portals() -> table - Returns all portals - Portal: {name, position: {x, y}, type, target_map_id, target_name} map:get_mobs() -> table - Returns all mobs on map map:get_bosses() -> table - Returns all boss mobs map:get_mob_count() -> number - Returns total mob count - More efficient than iterating get_mobs() map:get_npcs() -> table - Returns all NPCs on map map:get_reactors() -> table - Returns all reactors currently in the field - reactor: {id, template_id, name, state, previous_state, pending, event, flipped, position: {x, y}} - previous_state = state before the latest change; pending = an interaction was sent and the game hasn't answered; event = index of the event behind the latest change (nil while pending) map:interact_reactor(id: number) -> boolean - Interacts with reactor id (runtime id) like the interaction key, e.g. the Afterlands' Solar Pulley - Character must be close; some quest objects only respond to the character they belong to - Quest objects that are NPCs: use npc:talk() - true = sent, false = no reactor with that id in the field - Did it work? Re-read the reactor shortly after: state changes (previous_state set) or it disappears when accepted; it drops back or stays put when refused map:get_clock() -> number | nil - Seconds left on the map's countdown clock, nil when the map has none map:get_rune() -> rune? - Returns the active rune on the map, or nil if there is none - Maps only ever have one rune at a time (single table, not a list) - rune: {position: {x, y}, solvable} - solvable is false while the rune-solved cooldown is active (a rune was solved recently) map:get_key_prompt() -> key_prompt? - Your character's timed key prompt on this map (e.g. Tengu's fight in Mushroom Shrine), nil when none is pending - key_prompt: {open, key, key_code, time_left, step, mob_id} - key: "space" | "left" | "right" | "up" | "down"; key_code: the virtual-key code, for core.input.press_key() - open = false: armed (on_key_prompt passed/failed tables also carry open = false); step 0 = the prompt that starts an attack; it appears partway through the monster's attack, only if at that moment you are within 400 px of it (left or right) and able to move - time_left: ms left to press the key, only while open; step 1 and up = each key of that attack - The first of the five keys you press decides it: the expected key passes, the other four fail, so does running out of time map:get_players() -> table - Returns all other players (remote characters) currently in the field - Excludes the local player - player: {id, name, position: {x, y}} map:get_bounds() -> bounds? - Returns the map's boundary rectangle, or nil if the map's terrain isn't loaded (map transition) - bounds: {left, top, right, bottom, width, height, center: {x, y}} - Same bounds core.input.teleport_safe clamps against; a position inside this rect is never clamped - center is the rect midpoint — where Kami idles when no mobs are alive - center includes empty airspace, so on tall maps it can sit above the floor; cross-reference get_footholds() for walkable ground map:get_footholds() -> table - Returns all platforms - Foothold: {x1, y1, x2, y2} map:get_ladders() -> table - Returns all ladders/ropes - Ladder: {x, y1, y2} ================================================================================ MOB OBJECT ================================================================================ mob:is_valid() -> boolean - ALWAYS call before using other methods mob:get_id() -> number | nil - Returns mob template ID (species; same for every mob of that type) mob:get_unique_id() -> number | nil - Returns unique instance ID of this specific spawned mob (assigned when it spawns) - Different for every mob in the map, stable for the mob's lifetime, never reused - Use to track/target one exact mob across ticks mob:get_position() -> {x: number, y: number} | nil - Returns position or nil mob:anchor() -> number | nil - Anchor handle for core.render; pass as opts.anchor so shapes follow the sprite between ticks - Valid for the CURRENT on_render() CALL ONLY; call again every frame. Pass the number, not the mob object - nil if the mob has no position mob:get_render_position() -> {x: number, y: number} | nil - Position as drawn in the latest rendered frame (includes between-tick interpolation) - First call usually nil (registers interest); available on following frames mob:get_name() -> string | nil - Returns mob name or nil mob:distance() -> number | nil - Euclidean distance from the local player to this mob; nil if the mob or player is unavailable mob:get_health_percent() -> number | nil - Returns remaining health as a whole number 0-100, or nil - Exact HP values are not available - Multi-body bosses sharing one bar all report that shared bar mob:get_health_gauge() -> number, number | nil - Returns the raw pair behind get_health_percent(): the HP bar value, and what it counts up to (NOT real max HP) - Only needed when whole percent is too coarse; a max of 1000 gives 0.1% resolution, a max of 0 means the value is already a percentage - Unlike get_health_percent(), this is the mob's own bar — it does not follow a multi-body boss to the shared bar mob:get_rank() -> number | nil - Elite Monster-system tier: 0 = not elite, 1 = elite monster, 2 = elite champion, 3 = elite boss - Use instead of is_elite() when the tiers matter; is_elite() is true for all three mob:is_elite() -> boolean | nil - Returns true if in the Elite Monster system (elite monster OR elite boss) - True for both a trivial elite and a boss-type elite; use is_boss() to tell them apart - nil if unknown mob:is_boss() -> boolean | nil - Returns true if the mob's template is flagged as a boss (field boss / boss-type elite: boss HP bar + boss mechanics) - Independent of is_elite() (a mob can be both); use to single out a boss-type elite from trivial elites on a farming map - nil if unknown mob:get_action() -> table | nil - What the mob is doing now: its attack, skill or other one-off action, otherwise its movement or current state; nil if unavailable - name: the action's name in the mob's game data ("attack1".."attack16", "attackF", "skill1".."skill16", "skillF", "stand", "move", "jump", "fly", "hit1", "regen", ...) - kind: "attack" | "skill" | "movement" (move, stand, jump, fly, rope, chase, sleep, runaway) | "other" - index: N of attackN / skillN; nil for attackF, skillF and other actions - skill_id, skill_level: the mob skill a skill action uses, when known, else nil - facing_left: boolean - started: game update time in ms when it began; nil for movement and when the start was not seen (e.g. already playing when you entered the map) - mob_id: this mob's get_unique_id() - get_id() + name identifies a boss pattern; several skills can share one skill animation, skill_id tells them apart - on_mob_action / on_mob_action_end report starts and ends mob:get_debuffs() -> table | nil - Debuffs players' skills have on this mob, one entry per skill; nil if unavailable - debuff: {id: skill ID, name: string, stacks?: number (effects that stack, e.g. Death's Blessing, Divine Judgment, Another Bite; only your own stacks), time_remaining?: ms left (when known)} - The mob's own buffs (e.g. invincibility) are not included; use get_immunity(). Some effects can't be read and never appear mob:get_debuff(skill_id: number) -> debuff | nil - That skill's debuff; nil if the mob doesn't have it or is unavailable - Where the game treats a HEXA version as the same skill, either ID finds it (63110011 finds HEXA Death's Blessing 63141015; the returned id is the one on the mob). Otherwise pass the id get_debuffs() shows (e.g. 1240001 for HEXA Divine Judgment) mob:has_debuff(skill_id: number) -> boolean | nil - true if the mob has that skill's debuff (HEXA versions matched as in get_debuff); nil if unavailable mob:can_take_damage(attack_type?: string) -> boolean | nil - false while nothing can damage the mob (invincible, health held at a floor it can't drop below yet, or immune to both physical and magic); nil if unavailable - attack_type "physical" | "magic" (the damage type your attacks deal) also counts an immunity to that type alone; any other value raises an error - Reflect doesn't count (see get_immunity); some mobs and boss mechanics block damage in ways these states don't cover and are not detected mob:get_immunity() -> table | nil - All booleans; nil if unavailable - invincible: no attack can damage it - health_locked: health held at a floor it can't drop below yet, so hits show no damage; can turn on up to ~1% before the floor - physical / magic: immune to that attack type - reflect_physical / reflect_magic: can reflect damage from that attack type back at the attacker ================================================================================ NPC OBJECT ================================================================================ npc:is_valid() -> boolean - ALWAYS call before using other methods npc:get_id() -> number | nil - Returns NPC template ID, or nil if the NPC is invalid or disabled npc:get_position() -> {x: number, y: number} | nil - Returns NPC position, or nil if the NPC is invalid or disabled npc:get_name() -> string | nil - Returns NPC name, or nil if the NPC is invalid or disabled npc:get_appearance_id() -> string | nil - Returns an ID for how the NPC looks; NPCs that look the same share it even when their NPC IDs differ - Use it to find the NPC that looks different from the others on the map - Follows the look the NPC shows you now: some NPCs change look with your quest progress, level or the date - Describes the usual pose, not the current animation - nil if the NPC is invalid or disabled, copies its look from a character, or its look cannot be read - IDs can change between updates: compare them with each other, don't save them npc:distance() -> number | nil - Distance from local player to NPC - nil if NPC or player is unavailable npc:talk() -> boolean - Attempts to talk to NPC - Returns false if NPC/player unavailable or NPC is out of interaction range ================================================================================ INPUT MODULE - GAME INTERACTION ================================================================================ core.input.use_skill(skill_id: number) -> number | nil - Cast skill by ID - Check skill_book.can_use() first (covers cooldown and cost) - Returns a handle if the game used the skill; nil if it refused it or the press only turned a toggle skill off (a few toggle skills return a handle when turned off) - on_skill_use is normally called for the use on the next tick with the same handle; handles are unique across scripts core.input.use_item(item_id: number) - Use item from inventory - Verify existence first with inventory.has_item() core.input.talk_to_npc(npc_id: number) -> boolean - Initiate NPC dialogue - Returns true if sent, false if NPC not on map or player out of range - Does NOT auto-teleport — position the player within range first core.input.enter_portal() - Enter portal at player position - Ensure player is on portal first core.input.loot() -> boolean - Picks up drops already within reach of the player (same as pressing the loot key) - Reach: x +/- 25, y from -50 to +10 around the player - Does NOT auto-walk - move the player onto the drop first; map:get_drops() gives positions - Returns false if no player or nothing in range; true if a pickup was attempted - Each drop has a 3s retry cooldown, so calling every tick is wasted core.input.press_key(key_code: number) - Simulate key press, the same as a real key tap (movement/up-jump input briefly sees it too) - PREFERRED over call_wndproc for key presses (no Win32 message building, no crash risk) - One-shot tap (single key-down), so a movement key only nudges before releasing; use hold_key/release_key to walk/climb/up-jump, or move_x/move_y for coordinate moves - Requires valid local player - See: https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes core.input.hold_key(key_code: number) - Send a key-down and keep it held until release_key is called for the same key - Because the key stays held, this is the primitive that CAN drive movement: hold an arrow to walk/climb, or hold Up before Jump to up jump - Requires valid local player core.input.release_key(key_code: number) - Send a key-up for a key held with hold_key, ending the hold - Requires valid local player core.input.call_wndproc(msg: number, wparam?: number, lparam?: number) -> number | nil - Calls the game's window procedure directly with a synthesized message - Bypasses the OS message queue - For simple key presses prefer press_key (safer, no Win32 building); use call_wndproc only for messages press_key can't send - wparam/lparam use standard Win32 layout (e.g. VK code in wparam for WM_KEYDOWN); both default to 0 - NOT for movement: cannot drive directional walk/climb; use move_x/move_y. Up jumps fail too (Up is treated as released), though a plain jump works. Fine for non-movement input (skills, pot/item hotkeys) - Returns the LRESULT, or nil if the game window is unavailable - WARNING: malformed messages can crash the client; only use valid Win32 combinations core.input.get_mouse_position() -> {x: number, y: number} | nil - Returns current cursor position relative to the game window client area - Returns nil if the game window is unavailable or cursor is outside it ================================================================================ INPUT MODULE - KEYBOARD STATE & CALLBACKS ================================================================================ core.input.is_key_down(key_code: number) -> boolean - True while the virtual key is held down - Works for modifier keys too (VK_CONTROL 0x11, VK_SHIFT 0x10, VK_MENU 0x12) for combos core.input.is_key_up(key_code: number) -> boolean - Convenience inverse of is_key_down core.input.on_key_press(key_code: number, callback: function) -> number - Registers callback to run when key_code is pressed; callback receives the key code - Only the registered key fires the callback (no per-keypress dispatch in your script) - Edge-triggered: holding the key fires once, not on OS auto-repeat - Returns a handle for remove_key_callback; auto-removed when the script unloads core.input.on_key_release(key_code: number, callback: function) -> number - Like on_key_press, but fires when the key is released core.input.remove_key_callback(handle: number) -> boolean - Unregisters a callback from on_key_press/on_key_release - Returns true if a matching callback was found and removed ================================================================================ GAME UI (core.game_ui) - GAME WINDOWS AND THEIR CONTROLS ================================================================================ Find the game's windows, read their controls (buttons, check boxes, text boxes, drop-down lists, tabs, sliders, scroll bars, labels) and operate them like a player. Window / Control objects follow ONE window or control: once it closes, is_valid() is false, getters return nil, actions return false, error_message. Every call makes a new object: compare with get_handle(), not ==. Store a handle to find the window again later. Events: on_ui_event, on_window_open, on_window_close (see CORE CALLBACKS). core.game_ui.get_windows() -> table - Every open top-level window (array of Window); nested windows via window:get_child_windows() core.game_ui.find_window(filter: table | function | nil) -> Window | nil - First matching window, or nil. nil filter = any window - Table fields (all must match): kind (string, see window:get_kind), ui_type (number), resource (text contained in get_resource_path), control_text (text contained in any control, including child windows'). Text matching ignores case - Function filter: receives each Window, return true to accept - Raises an error for a wrong field type, unknown kind, non-whole ui_type, text > 256 chars core.game_ui.find_windows(filter: table | function | nil) -> table - Every matching window (empty array when none) core.game_ui.get_window(handle: number) -> Window | nil - The window with this handle while it is open core.game_ui.get_control(handle: number) -> Control | nil - The control with this handle while it exists core.game_ui.get_focused_control() -> Control | nil - The control with keyboard focus (e.g. the text box being typed in), or nil WINDOW OBJECT: window:is_valid() -> boolean - true while the window is open window:get_handle() -> number | nil - Identifies the window while it is open window:get_kind() -> string | nil - "npc_chat" | "message_box" (notice or yes/no) | "shop" | "storage" | "trade" | "ui_window" (other standard window) | "dialog" (other dialog) | "window" (anything else) window:get_ui_type() -> number | nil - The game's number for this kind of window; nil when it has none (every ui_window has one) - Same number every time the window opens; can change when the game updates - Discover it by logging get_kind/get_ui_type/get_resource_path of core.game_ui.get_windows() window:get_resource_path() -> string | nil - Path of the window's artwork in the game's UI data; also tells windows apart window:get_position() -> {x: number, y: number} | nil - Top-left corner on the game screen window:get_size() -> {width: number, height: number} | nil window:is_enabled() -> boolean | nil window:get_controls() -> table | nil - The window's own controls (array of Control); child windows' controls not included window:get_child_windows() -> table | nil - Windows nested inside this one (array of Window) window:find_control(filter: table | function | nil) -> Control | nil - First matching control in the window, then its child windows (up to 4 levels deep) - Table fields (all must match): id (number), type (string, see control:get_type), text (contained in get_text, ignoring case), visible (true = visible controls only) - Function filter: receives each Control, return true to accept - Raises an error for a wrong field type, unknown type, non-whole id, text > 256 chars window:find_controls(filter: table | function | nil) -> table | nil - Every matching control window:click_button(id: number) -> boolean, string? - Clicks the button with this id (window or child windows) like control:click() - false, error_message when the window closed, no such button, or it is hidden/disabled CONTROL OBJECT: control:is_valid() -> boolean control:get_handle() -> number | nil control:get_id() -> number | nil - Id within its window; fixed by the window's layout, same every time the window opens control:get_type() -> string | nil - "button" | "check_box" | "combo_box" (drop-down) | "edit" (single-line text box) | "multiline_edit" | "scroll_bar" | "slider" | "static_text" (label) | "tab" (row of tabs) | "control" (anything else) control:get_window() -> Window | nil control:get_text() -> string | nil - Button/check box label, text box contents, label text, drop-down's selected item, selected tab's name; nil for other controls control:is_enabled() -> boolean | nil control:is_visible() -> boolean | nil control:get_position() -> {x: number, y: number} | nil control:get_size() -> {width: number, height: number} | nil control:is_checked() -> boolean | nil - Check box ticked / button toggled on; nil for other controls control:is_read_only() -> boolean | nil - Text box is read-only (set_text refuses it); nil for other controls control:is_password() -> boolean | nil - Single-line text box hides what is typed; get_text() still returns its contents; nil for other controls control:get_value() -> number | nil - Slider or scroll bar position; nil for other controls control:get_range() -> number, number | nil - Lowest and highest slider / scroll bar position control:get_items() -> table | nil - Drop-down items or tab names, in display order control:get_selected() -> number | nil - 0-based index of the selected item or tab ACTIONS (like a player's input; true when delivered, false, error_message when nothing was done, e.g. the control is hidden, disabled or gone): control:click() -> boolean, string? - Left-clicks the middle of the control control:right_click() -> boolean, string? control:press_key(key: number) -> boolean, string? - Presses and releases a virtual-key code (1-254) on the control; 0x0D = Enter in a text box control:set_text(text: string) -> boolean, string? - Clears a text box and types text one character at a time; the box's typing rules apply (may shorten it, skip characters a number box won't take, lower a number to its limit), so read get_text() afterwards. Max 4096 characters; non-Windows-1252 characters become the closest match or "?"; line breaks only in multi-line boxes - false, error_message for non-text boxes and read-only boxes control:set_checked(checked: boolean) -> boolean, string? - Clicks a check box when its state differs; false, error_message when it did not change (option-style check boxes can't be cleared by clicking) control:set_value(value: number) -> boolean, string? - Moves a slider or scroll bar, kept within get_range(); the window reacts like a drag and release control:select(item: number | string) -> boolean, string? - Selects a drop-down item or tab by 0-based index or exact text (ignoring case) - false, error_message for other controls, out-of-range index, unknown text, or an item the game refuses EXAMPLE: local box = core.game_ui.get_focused_control() if box and box:get_type() == "edit" then local ok, err = box:set_text("100") if ok then box:press_key(0x0D) else core.log_error(err) end end ================================================================================ LOGIN MODULE ================================================================================ Use core.login from on_tick(stage) when stage == core.stage.LOGIN to inspect the login flow and manage the character list. Character actions require an idle character-selection state. A true action result means submitted, not completed. Steps: 1=world/channel select, 2=character select, 3=new-character setup, 4=new-char name entry. Other values may appear briefly during login transitions. core.login.get_step() -> number | nil - Current login step, or nil if not on the login screen (e.g. in field) core.login.get_char_count() -> number | nil - Number of characters in the selected world's slot list - Only valid during step 2; nil otherwise core.login.get_total_slot_count() -> number | nil - Total available character slots for the current character-selection roster - Only valid during step 2; nil otherwise core.login.get_empty_slot_count() -> number | nil - Number of unoccupied character slots - Only valid during step 2; nil otherwise core.login.get_characters() -> table | nil - Array of created characters (see LOGIN_CHARACTER); nil outside step 2 core.login.move_character_to_front(character: login_character | number) -> boolean - Moves a character to the front of the displayed order core.login.soft_delete_character(character: login_character | number) -> boolean - Starts the deletion waiting period for an eligible active character core.login.hard_delete_character(character: login_character | number) -> boolean - Permanently deletes an eligible character after its waiting period core.login.restore_character(character: login_character | number) -> boolean - Restores a character that is pending deletion core.login.get_hold() -> table | nil - Why Auto Login is parked: { reason = "gm_evade" | "stay_logged_out", remaining_sec = number } - nil when nothing holds; usable from any stage core.login.clear_hold() -> boolean - Releases every Auto Login hold (same as re-ticking Auto Login); next login tick logs in - false only if the autos module is unavailable - The GM hold is per character, not per account: logging into a different character is fine; clearing without switching logs the same character straight back in - Pattern: on_gm_evasion -> core.profiles.switch(next); once core.profiles.get_active() reports it, core.login.clear_hold() so Auto Login logs into that profile's char_index Character arguments may be a get_characters() record, character ID, or zero-based display index. Prefer the record; numbers resolve as IDs first, then indices. LOGIN_CHARACTER STRUCTURE: { id = number, -- Stable character ID index = number, -- Zero-based display position name = string, -- Character name level = number, -- Character level job = number, -- Job ID ghost = boolean, -- True while deletion is pending delete_deadline = number, -- Unix timestamp; 0 when not pending deletion deletion_ready = boolean -- True when permanent deletion is eligible } ================================================================================ KEYS MODULE ================================================================================ core.keys edits the game keyboard layout for the active key preset. Virtual-key codes are translated to the game's key-map slot. IMPORTANT: a new bind works on the next keypress but is lost on character change/relog unless core.keys.save() is called. Call save() once after a batch of edits. Right-hand modifiers share a slot with their left twin: binding RShift/RCtrl/RAlt changes the LShift/LCtrl/LAlt bind. IME keys have no slot and raise an error. Mapping types (core.keys.type.*): 0=EMPTY, 1=SKILL (skill id), 2=ITEM (item id), 3=FACE_ITEM (item id), 4=MENU (menu id), 5=ACTION (action id), 6=EMOTE (100-106), 7=EFFECT_ITEM (item id), 8=MACRO (macro index from 0), 9=CASH_ITEM (item id) Action ids, type 5 (core.keys.action.*) - the only five the game implements: 50=PICK_UP (default Z), 51=SIT (default X), 52=ATTACK (default Ctrl), 53=JUMP (default Alt), 54=NPC_CHAT talk to npc / gather (default Space) Emote ids, type 6: 100-106. Defaults 100=F1, 101=F2, 102=F3, 103=F5, 104=F6, 105=F7, 106=F8. Menu ids, type 4: valid ranges are 0-49, 200-206, 300, 1000-1001; anything else is rejected. Listed ids: 0=Equipment (E), 1=Item Inventory (I), 2=Stat (S), 3=Skill (K), 5=World Map (W), 6=Messenger (C), 7=Minimap toggle (M), 8=Quest (Q), 14=Menu ([), 15=Quickslot (]), 16=Chat toggle ('), 17=Guild (G), 20=Quest Alarm toggle (L), 39=Contents Guide (U), 48=Capture Mode, 203=Emoticon Other ids inside the valid ranges are accepted but aren't listed here. Key presets: three, numbered 0-2. Every setter edits whichever preset is applied. core.keys.get(vkey: number) -> {type: number, id: number, index: number} - Returns the current game key mapping for a keyboard virtual-key code - index is the key-map slot for that key (two keys can share one) core.keys.set(vkey: number, type: number, id: number) - Sets a game key mapping to any supported type - id is validated against type, so a bad action/menu id errors instead of making a dead key - Use core.keys.unset to clear a key core.keys.set_skill(vkey: number, skill_id: number) - Sets a keyboard key to a skill core.keys.set_item(vkey: number, item_id: number) - Sets a keyboard key to an item core.keys.set_action(vkey: number, action_id: number) - Sets a keyboard key to a basic action such as jump or pick up core.keys.set_menu(vkey: number, menu_id: number) - Sets a keyboard key to a UI window or menu toggle core.keys.set_emote(vkey: number, emote_id: number) - Sets a keyboard key to a face expression, 100-106 core.keys.set_macro(vkey: number, macro_index: number) - Sets a keyboard key to a skill macro, index from 0 core.keys.unset(vkey: number) - Clears a keyboard key mapping core.keys.save() -> boolean - Saves the active preset so edits persist - Returns false when the server has key-map editing locked core.keys.get_preset() -> number - Returns the active key preset, 0-2 core.keys.set_preset(preset: number) -> boolean - Switches the active key preset, 0-2 - Returns false when the server has key-map editing locked core.keys.is_edit_blocked() -> boolean - True while the server has key-map editing locked; save and set_preset both fail then ================================================================================ INPUT MODULE - MOVEMENT ================================================================================ HOW MOVEMENT INPUT WORKS: move_x/move_y drive directional movement (walking left/right, climbing ladders/ropes up/down) by holding the arrow keys and stopping at a target coordinate -- the right tool for navigation, kiting, patrolling. MANUAL CONTROL: hold_key/release_key (and press_key) CAN drive movement -- hold an arrow to walk/climb, or hold Up before Jump to up jump. press_key only pulses briefly (a nudge), so use hold_key to sustain. EXCEPTION: call_wndproc CANNOT drive movement and an up jump through it falls back to a plain jump; use it only for non-movement, one-shot input. core.input.teleport(x: number, y: number) - Instant teleport to coordinates - WARNING: Spamming triggers anti-cheat - Use only for one-time teleports to portals/NPCs core.input.teleport_safe(x: number, y: number, x_offset?: number, y_offset?: number) - Safe teleport with foothold validation - Moves toward position over time (like Kami) - Optional x_offset and y_offset parameters core.input.is_moving() -> boolean - Returns true if player moving core.input.stop_moving() - Stop all movement immediately core.input.set_facing(left: boolean) - Turn the character in place without moving; true = face left, false = face right - The new facing is sent to the server and seen by other players - Only the facing flips (no step); most reliable while standing on a foothold - Read current facing with player:is_left() core.input.move_x(x_coord: number, force?: boolean, timeout_ms?: number) - Move to X coordinate (maintains Y) - force=false (default): holds arrow keys for natural movement (the only legit path); can be blocked by what the character is doing (e.g. mid-attack) - force=true: WILL move even when regular is blocked, but looks unnatural - timeout_ms (default 10000): caps how long the move runs; force/timeout order-independent core.input.move_y(y_coord: number, force?: boolean, timeout_ms?: number) - Move to Y coordinate (maintains X); regular movement climbs ladders/ropes via Up/Down - Same force / timeout_ms options as move_x NOTE: Movement functions don't account for manual player input NOTE: Prefer regular movement (force=false) — natural arrow-key input is the only legit path ================================================================================ RENDER MODULE (core.render) - IN-GAME OVERLAY DRAWING ================================================================================ Draws over the game window. STATELESS: every shape lives ONE FRAME, so draw every frame from on_render() - the ONLY place drawing calls are accepted (elsewhere they return nil, reason and draw nothing). Coordinates are SCREEN PIXELS (0,0 = top-left of the game window) unless opts.world = true (world units, converted on the drawn frame) or opts.anchor is set (implies world). Unloading a script removes its shapes. COLORS: {r, g, b} or {r, g, b, a}, components 0-255, alpha defaults 255. Default white. OPTS TABLE (optional last argument to every drawing function; all fields optional): color table {r,g,b[,a]} 0-255 (all) color2 table gradient end color, defaults to color (gradient) thickness number stroke px, default 1, min 0.1 (outlines, line) rounding number corner radius, default 0 (rect, rect_filled, gradient) glow number halo px, default 0, clamped to 32 (all) segments number circle segments, 0 = auto (circle, circle_filled) size number font px, 0 = overlay default (text) shadow boolean drop shadow, default false (text) centered boolean centre text on (x,y), default false (text) world boolean coords are world units, default false (all) anchor number handle from mob:anchor()/player:anchor() (all; implies world=true) shader shader handle from core.render.create_shader() (all; see SHADERS) params table up to 64 numbers, read with param(i) (shaded shapes) screen boolean give the shader a copy of the game frame (shaded shapes) blend string "alpha" (default) | "add" (shaded shapes) World-space sizes (w, h, radius) are world units and zoom with the game; thickness and font size are always pixels. World-space shapes are skipped on frames with no view available yet (first frames after inject, outside a field). DRAWING (return true; nil, reason outside on_render): core.render.line(x0, y0, x1, y1, opts?) core.render.rect(x, y, w, h, opts?) -- outline, top-left at (x,y) core.render.rect_filled(x, y, w, h, opts?) core.render.gradient(x, y, w, h, opts?) -- filled, color -> color2 core.render.circle(x, y, radius, opts?) -- radius must be >= 0 core.render.circle_filled(x, y, radius, opts?) core.render.triangle(x0, y0, x1, y1, x2, y2, opts?) core.render.triangle_filled(x0, y0, x1, y1, x2, y2, opts?) core.render.text(x, y, text: string, opts?) -- top-left at (x,y) unless centered; >512 chars truncated core.render.clear() -- drop everything THIS script queued this frame SCREEN / CAMERA (read the view for the current frame; cheap): core.render.is_ready() -> boolean - true once the overlay can draw; on_render() only runs while true core.render.screen_size() -> width: number, height: number - Overlay size in px; 0, 0 before ready core.render.measure_text(text: string, size?: number) -> width: number, height: number - nil, error while not ready core.render.camera() -> center_x: number, center_y: number - World coordinate the view is centred on; nil, error without a view core.render.viewport() -> width: number, height: number, adjust_y: number - View size in WORLD units (differs from screen_size: the view is zoomed to fill the window) + vertical adjust term; nil, error without a view core.render.view_scale() -> scale: number, origin_x: number, origin_y: number - scale = screen px per world unit; origin = world coord at window top-left - screen = (wx - origin_x) * scale, (wy - origin_y) * scale; nil, error without a view core.render.world_to_screen(world_x: number, world_y: number) -> x: number, y: number, on_screen: boolean - Coordinates stay valid off-screen (draw arrows toward off-screen targets); nil, error without a view - Uses the LAST presented frame; prefer opts.world=true or an anchor for drawing (one frame fresher) ANCHORS (drawing on moving actors): - get_position() advances once per ~30ms logic step (even read from on_render); sprites draw from a per-frame interpolated position, so a shape at get_position() trails a moving actor - a = mob:anchor() / player:anchor(); draw at get_position() with opts.anchor = a; the renderer re-reads the drawn position every frame and shifts the shape - Handle valid for the CURRENT on_render() CALL ONLY. Pass the NUMBER; a non-number is ignored - One anchor per shape; for a line between two actors use get_render_position() for both ends EXAMPLE: function on_render() local map = core.object_manager.get_current_map() if not map then return end for _, mob in ipairs(map:get_mobs()) do if mob and mob:is_valid() then local pos, a = mob:get_position(), mob:anchor() if pos and a then core.render.rect(pos.x - 30, pos.y - 80, 60, 80, { anchor = a, color = {255, 60, 60}, thickness = 2 }) local name = mob:get_name() if name then core.render.text(pos.x, pos.y - 92, name, { anchor = a, centered = true, shadow = true, size = 12 }) end end end end end ================================================================================ SHADERS (core.render.create_shader) - HLSL PIXEL SHADERS ================================================================================ Compile HLSL once (top of script), then pass shader=handle in any drawing call's opts, or draw a bare quad with core.render.shader_rect. Runs per pixel on the graphics card. WARNING: an infinite or extremely expensive shader can stall the graphics driver and take the game down. Keep loops bounded; test on a small quad. core.render.create_shader(source: string, opts?: table) -> shader | nil, string - opts: entry="main", profile="ps_4_0"|"ps_4_1"|"ps_5_0", raw=false (skip prelude) - Returns handle, or nil + compiler errors like "shader(3,12-20): error X3004: ..." (line numbers are YOUR source lines); second return on success = warnings or nil - Limits: 64 KB source, 32 shaders per script, 256 total; released on script unload - "shader compiler is unavailable" = this system has no HLSL compiler shader:is_valid() -> boolean - Also turns false when the graphics device rejects the shader (e.g. ps_5_0 on old hardware) shader:release() -> nil - Using the handle afterwards raises "render: shader is invalid or released" core.render.shader_rect(shader: shader, x: number, y: number, w: number, h: number, opts?: table) -> boolean | nil, string - Quad with input.uv 0..1 across it; same opts as other drawing calls; on_render only - color alpha 0 draws NOTHING (quad discarded before the shader); keep alpha > 0, fade in-shader - A quad whose shader cannot run is skipped, never drawn as a plain box ENTRY POINT: float4 main(PS_INPUT input) : SV_Target (straight alpha out) PRELUDE (prepended unless raw=true; #line keeps your line numbers): struct PS_INPUT { float4 pos : SV_POSITION; float4 col : COLOR0; float2 uv : TEXCOORD0; }; cbuffer OverlayParams : register(b0) { float4 bounds; // x, y, w, h of the shape in screen px float4 resolution; // w, h, 1/w, 1/h of the window float4 time; // seconds, delta seconds, frame index, 0 float4 params[16]; // opts.params packed 4 per element }; Texture2D tex0 : register(t0); SamplerState samp0 : register(s0); // font atlas / white px Texture2D screen : register(t1); SamplerState screen_sampler : register(s1); // game frame if opts.screen float param(int i); // i-th opts.params value (0-based) float2 shape_uv(float4 pos); // 0..1 inside the shape's bounds float2 screen_uv(float4 pos); // 0..1 across the window INPUTS: input.pos = screen px; input.col = opts.color as 0..1; input.uv = 0..1 on shader_rect, glyph UVs on text (multiply by tex0.Sample(samp0, input.uv)), constant on shapes. BLEND: opts.blend="add" for glows/light; default alpha blend. NOTE: "line" is a reserved HLSL word; don't use it as a variable name. EXAMPLES: local pulse = assert(core.render.create_shader([[ float4 main(PS_INPUT input) : SV_Target { float2 uv = shape_uv(input.pos); float d = distance(uv, float2(0.5, 0.5)) * 2.0; float ring = smoothstep(0.08, 0.0, abs(d - (0.6 + 0.4 * sin(time.x * 4.0)))); return float4(input.col.rgb, input.col.a * ring); } ]])) function on_render() local w, h = core.render.screen_size() core.render.shader_rect(pulse, w/2 - 100, h/2 - 100, 200, 200, { color = {255, 80, 200} }) end -- scanlines over the game frame (screen sampling) local scan = assert(core.render.create_shader([[ float4 main(PS_INPUT input) : SV_Target { float3 frame = screen.Sample(screen_sampler, screen_uv(input.pos)).rgb; float band = 0.5 + 0.5 * sin(input.pos.y * 3.14159 / max(param(0), 1.0)); return float4(frame * (1.0 - param(1) * band), 1.0); } ]])) -- in on_render: core.render.shader_rect(scan, 0, 0, w, h, { screen = true, params = { 3.0, 0.25 } }) -- rainbow text: shader on a normal drawing call core.render.text(40, 40, "Rainbow", { shader = rainbow, size = 32, shadow = true }) ================================================================================ HTTP MODULE (core.http) - ASYNC HTTP REQUESTS ================================================================================ All functions are async. Callback fires on the next tick after completion. Last argument is always the callback function. ISOLATION: Each script has its own HTTP state. Cookies (Set-Cookie) and auth state persist within a script's own requests but never leak between scripts. Two scripts hitting the same host keep separate cookie jars. Scripts can also spin up additional isolated clients via core.http.client() (see below). STRICTLY PROHIBITED: using core.http to scrape, collect, exfiltrate, or otherwise harvest a user's script data, settings, credentials, account data, or private gameplay data. Only send data with clear user approval and for the specific workflow the user agreed to. core.http.get(url, [headers,] callback) - GET request - headers: optional table of string key-value pairs core.http.post(url, body, [content_type, [headers,]] callback) - POST request - content_type defaults to "application/json" core.http.put(url, body, [content_type, [headers,]] callback) - PUT request - content_type defaults to "application/json" core.http.delete(url, [headers,] callback) - DELETE request core.http.request(method, url, [body, [headers,]] callback) - Any HTTP method RESPONSE OBJECT (passed to callback): { status = number, -- HTTP status code (200, 404, etc.) or 0 if failed body = string, -- Response body headers = table -- Response headers as key-value pairs } EXAMPLES: -- Simple GET core.http.get("https://httpbin.org/get", function(response) core.log("Status: " .. response.status) core.log("Body: " .. response.body) end) -- POST with headers core.http.post("https://api.example.com/webhook", '{"event":"test"}', "application/json", { ["Authorization"] = "Bearer token" }, function(response) core.log("Status: " .. response.status) end ) -- Discord webhook from on_evasion function on_evasion(player_names) local names = table.concat(player_names, ", ") core.http.post("https://discord.com/api/webhooks/YOUR_URL", '{"content":"Evasion: ' .. names .. '"}', "application/json", function(response) end ) end EXPLICIT CLIENTS (core.http.client()): Creates a brand-new isolated client with its own cookie jar, auth state, and connection pool. Use when one script needs multiple independent HTTP contexts (e.g. two separate auth sessions, isolated bot vs telemetry traffic). Methods mirror the free functions but use `:` instead of `.`: client:get(url, [headers,] callback) client:post(url, body, [content_type, [headers,]] callback) client:put(url, body, [content_type, [headers,]] callback) client:delete(url, [headers,] callback) client:request(method, url, [body, [headers,]] callback) EXAMPLE: local api = core.http.client() local logs = core.http.client() api:get("https://api.example.com/me", function(r) end) logs:post("https://logs.example.com/ingest", payload, function(r) end) -- cookies set on `api` are NOT sent by `logs`, and vice versa ================================================================================ SKILL BOOK ================================================================================ core.skill_book.get_skill(skill_id: number) -> skill | nil - Returns skill object or nil - Skill: {id, name, level, max_level} core.skill_book.get_skills() -> table - Returns all learned skills - Skill: {id, name, level, max_level} core.skill_book.get_skill_level(skill_id: number) -> number - Returns skill level (0 if not learned) core.skill_book.get_skill_max_level(skill_id: number) -> number | nil - Returns effective max (master) level incl. hyper/5th-job caps; nil if not in skill book core.skill_book.is_skill_on_cooldown(skill_id: number) -> boolean - Returns true if on cooldown core.skill_book.can_use(skill_id: number) -> boolean | nil, string? - Returns true if the skill can be cast right now: learned, cost payable (HP, MP, mesos, items, class gauge), off cooldown - Charge skills count as off cooldown while a charge is left, even mid-recharge - Also accounts for cast conditions the cooldown functions don't check; nil if no local player - On false, a second value names the reason: "not_learned" (or passive), "cooldown" (or no charge left), "hp", "mp" (or the class's MP substitute), "mesos", "item", "resource" (class gauge / other points), "other" - Not every cast condition is checked (seals, stuns, map restrictions, riding, weapon, ...) core.skill_book.can_level_up(skill_id: number) -> boolean | nil, string? - true when a skill point can go into the skill now, from the pool it spends from - nil when character data is unavailable; an unknown skill ID gives false, "unavailable" - On false, one reason: "unavailable" (not this character's skill, fixed level, V/HEXA), "max_level", "master_level" (needs a mastery book), "character_level", "required_skill", "no_sp", "map", "other" - "unavailable" wins over the others; "no_sp" and "map" only when nothing about the skill itself blocks the point core.skill_book.get_skill_level_up_info(skill_id: number) -> table | nil - {can_level_up, reason?, pool?, tier?, sp, level, max_level, master_level?, required_skills, required_any} - pool: "beginner" | "job" | "hyper_passive" | "hyper_active"; absent when SP can't raise the skill (then required_skills is empty and master_level is absent) - tier: what player:get_skill_sp takes, for the beginner and job pools; sp: points left in that pool - required_skills: array of {id, level, current}; required_any = one met prerequisite is enough - nil for an unknown skill ID or when character data is unavailable core.skill_book.get_skill_charges(skill_id: number) -> number | nil - Charges a charge skill has right now; nil if the skill isn't a charge skill or no local player - Sol Janus: Dawn (500001002) uses a cooldown and returns nil; summon capacity is not a charge count - While recharging, is_skill_on_cooldown / get_skill_cooldown_remaining show the recharge timer, not whether it can be cast core.skill_book.get_skill_cooldown_remaining(skill_id: number) -> number - Returns remaining cooldown in ms (0 if off cooldown); matches is_skill_on_cooldown - For charge skills this is the time until the next charge, not a block on casting ================================================================================ LINK SKILLS ================================================================================ MODULE: core.link_skills PRESET NUMBERING: - Lua presets are 0, 1, and 2 (in-game Presets 1, 2, and 3) - get_assigned() returns a normal 1-based sequential Lua array - Each preset holds at most 12 skills RETURN CONVENTIONS: - Reads return value, or nil, error_message - Actions return true after dispatch, or false, error_message - true means the client sent the request, not that the server accepted it - Confirm mutations with a later on_tick; immediate reads may be stale core.link_skills.get_active_preset() -> number | nil, string? - Returns active zero-based preset index: 0, 1, or 2 core.link_skills.get_assigned(preset?: number) -> table | nil, string? - Returns assigned skill IDs for a preset - Omit preset or pass nil to read the active preset - Empty preset returns an empty table core.link_skills.assign(skill_id: number) -> boolean, string? - Requests assignment of one skill to the active preset only - Accepts positive signed 32-bit IDs; the constants table is not a whitelist - Fails locally for a duplicate or 12-skill preset in the Link Skill state at call time core.link_skills.unassign(skill_id: number) -> boolean, string? - Requests removal of one skill from the active preset only - Accepts positive signed 32-bit IDs; the constants table is not a whitelist - Fails locally when the skill is absent from the Link Skill state at call time core.link_skills.activate_preset(preset: number) -> boolean, string? - Activates preset 0, 1, or 2 - Fails locally when that preset is already active - To edit an inactive preset: activate it, poll until active, then mutate SKILL CONSTANTS (core.link_skills.skills convenience catalog; mutable, not a whitelist): FURY_UNLEASHED=80000001 PHANTOM_INSTINCT=80000002 MOONLIT_BLADE_LEARNINGS=80000003 ELEMENTALISM=80000004 LIGHT_WASH=80000005 IRON_WILL=80000006 HYBRID_LOGIC=80000047 WILD_RAGE=80000050 CYGNUS_BLESSING=80000055 RHINNES_BLESSING=80000110 CLOSE_CALL=80000169 JUDGMENT=80000188 UNFAIR_ADVANTAGE=80000261 TIDE_OF_BATTLE=80000268 SPIRIT_OF_FREEDOM=80000329 RUNE_PERSISTENCE=80000369 COMBO_KILL_BLESSING=80000370 SOLUS=80000514 BRAVADO=80000609 ELVEN_BLESSING=80001040 KNIGHTS_WATCH=80001140 TERMS_AND_CONDITIONS=80001155 INVINCIBLE_BELIEF=80002758 EMPIRICAL_KNOWLEDGE=80002762 ADVENTURERS_CURIOSITY=80002766 THIEFS_CUNNING=80002770 PIRATE_BLESSING=80002774 NOBLESSE=80002857 TIME_TO_PREPARE=80003015 NATURES_FRIEND=80003058 INNATE_GIFT=80003224 GROUNDED_BODY=80003877 FOCUS_SPIRIT=80010006 GUIDING_STARS=80010486 QI_CULTIVATION=80011964 Prefer constants over numeric IDs. Sia source ID 80010343 and Erel source ID 80010473 normalize to the shared assignable GUIDING_STARS ID 80010486. EXAMPLE: local links = core.link_skills local assigned, err = links.get_assigned() if not assigned then core.log(err); return end for slot, skill_id in ipairs(assigned) do core.log(string.format("Link slot %d: %d", slot, skill_id)) end local ok, assign_err = links.assign(links.skills.WILD_RAGE) if not ok then core.log(assign_err) end ================================================================================ INVENTORY ================================================================================ TAB IDs: 1 = Equip 2 = Use 3 = Setup 4 = Etc 5 = Cash core.inventory.get_total_slots(tab_id: number) -> number - Returns total slots in tab core.inventory.get_free_slots(tab_id: number) -> number - Returns free slots in tab core.inventory.has_item(item_id: number) -> boolean - Returns true if item exists in any tab core.inventory.get_all_items() -> table - Returns all items across all tabs - Every entry carries is_protected / is_quest_item / is_sellable - Equip entries (tab_id=1) also carry sn / essence_id / essence_sn / cash_item_sn / equip_slots core.inventory.get_equip_items() -> table - Returns unequipped equip items in inventory (tab_id=1) - These are equips in your bag, NOT currently worn - Each entry carries sn / essence_id / essence_sn / cash_item_sn / equip_slots (Equip only) core.inventory.get_equipped_items() -> table - Returns items currently worn by the character - Position values are NEGATIVE (equipment slot IDs) - Includes potentials and star counts - Each entry carries sn / essence_id / essence_sn / cash_item_sn / equip_slots (Equip only) - Example: for _, item in ipairs(core.inventory.get_equipped_items()) do ... end core.inventory.get_use_items() -> table - Returns Use tab items (tab_id=2) core.inventory.get_etc_items() -> table - Returns Etc tab items (tab_id=3) core.inventory.get_setup_items() -> table - Returns Setup tab items (tab_id=4) core.inventory.get_cash_items() -> table - Returns Cash tab items (tab_id=5) core.inventory.get_required_level(item_id: number) -> number | nil - Returns the required level to equip/use the item - nil if item info unavailable core.inventory.can_equip(item_id: number) -> boolean | nil - Returns true if current character meets all requirements (level, job, stats) - nil if item info or character context unavailable core.inventory.get_item_combat_power(tab_id: number, position: number) -> number | nil - Estimated combat power delta from equipping the item in that tab/slot - Positive means stronger than current equipment, negative means weaker - nil if item or character context unavailable core.inventory.change_slot_position(tab_id: number, old_pos: number, new_pos: number, count?: number) - Moves an item from old_pos to new_pos within the specified tab - Can equip items by moving to negative positions (e.g. -1 = hat, -5 = top) - Use new_pos=0 to drop/discard items - count defaults to 1 core.inventory.starforce_start(equip_slot: number, target_stars: number, use_safeguard?: boolean) -> boolean - LEGACY ALIAS of core.starforce.start — prefer core.starforce (adds sunny_sunday, mvp_tier, validation, status/cost) - Configures and starts an Auto Star Force run; writes the same Autos -> Auto Starforce settings - equip_slot: negative = equipped (e.g. -11 = weapon), positive = Equip tab slot - target_stars: stop at this star level (1-30); use_safeguard optional, defaults false (safeguard: 15-17 star attempts that can destroy the item only) - Returns true on success, false if the Autos module isn't ready core.inventory.starforce_stop() -> boolean - LEGACY ALIAS of core.starforce.stop - Clears the Enable Auto SF toggle and resets state to idle; safe to call when not running - Returns true (no-op when Autos module isn't loaded) ITEM STRUCTURE: { id = number, -- Item template ID position = number, -- Slot index count = number, -- Quantity name = string, -- Display name tab_id = number, -- Tab (1-5) is_protected = boolean, -- Sealed or locked; can't be sold, traded, dropped or enhanced is_quest_item = boolean, -- Quest item is_sellable = boolean, -- NPC shops buy it (false for protected, quest, cash and unsellable items; -- some shops only buy specific items, some time-limited items are refused) potentials = table, -- Array of strings (Equip only, up to 7) current_star_count = number, -- Star force (Equip only) max_star_count = number, -- Max star force (Equip only) remaining_upgrade_count = number, -- Remaining scroll upgrade slots (Equip only) current_upgrade_count = number, -- Successful scroll upgrades applied (Equip only) sn = number, -- Unique serial number; stable identity for this equip instance (Equip only, full 64-bit precision) essence_id = number, -- Essence id, or 0 if none (Equip only) essence_sn = number, -- Essence serial number, or 0 if none (Equip only) cash_item_sn = number, -- Cash-shop serial number for cash equips, or 0 if none (Equip only) equip_slots = table, -- Array of equipment positions (negative, e.g. {-12,-13,-15,-16} for a ring) -- the item fits for the current character; empty if its gender doesn't match; -- ignores level/job/stat requirements (use can_equip) and whether a slot is -- free or unlocked (Equip only) is_dead = boolean, -- Pet past expiration date (Pet items only, false for non-pets) is_active = boolean -- Pet currently active/summoned (Pet items only, false for non-pets) } ================================================================================ SHOP (core.shop) - NPC SHOP DIALOG ================================================================================ All functions require an NPC shop dialog to be open. core.shop.is_open() -> boolean - Returns true if an NPC shop dialog is currently open core.shop.get_items() -> table | nil - Returns every item the NPC sells across all tabs (i.e. what you can buy) - nil if no shop is open - Each entry's `position` is its slot in the shop list, passed to buy() core.shop.buy(item: shop_item, count: number, price?: number) -> boolean - Buys `count` of `item` (an entry from get_items(); its position/id are read for you) - Also accepts the explicit form buy(index, item_id, count [, price]) with raw numbers - For multi-tab shops, acts on the currently open tab; `index` is the slot in it - price is the PER-UNIT price; auto-resolved from the matching shop entry when omitted - Returns true if the request was sent; false (+ error string) if no shop is open or the item is no longer at that slot in the open tab and no price was given core.shop.sell(position: number, item_id: number, count: number) -> boolean - Sells `count` of the inventory item at slot `position` - Returns false if no shop is open or a previous sell is still awaiting server ack - The server rejects/DCs on quest, not-for-sale, and cash items — only sell sellable items core.shop.close() -> boolean - Closes the open shop dialog; true if a shop was open, false otherwise SHOP_ITEM STRUCTURE: { id = number, -- Item template ID position = number, -- Item's slot in the shop list (pass as `index` to buy()) count = number, -- Stock quantity price = number, -- Unit price in mesos unit_price = number, -- Per-charge unit price for rechargeables, else 0 max_per_slot = number, -- Max quantity per slot name = string -- Display name } ================================================================================ STORAGE (core.storage) ================================================================================ Requires an open storage dialog. Actions return false when storage is unavailable, busy, or a well-formed transfer is ineligible. Invalid numeric arguments raise a Lua error. Direct-transfer true means submitted, not completed; bulk-action true means the flow started and confirmation may still be required. STORAGE_ITEM STRUCTURE: { id = number, -- Item ID count = number, -- Available quantity position = number, -- Inventory or storage position index = number, -- Zero-based list index accepted by transfer functions tab_id = number, -- Inventory category name = string? -- Omitted when unavailable } core.storage.is_open() -> boolean core.storage.is_busy() -> boolean core.storage.get_meso() -> number | nil - Meso balance held in storage core.storage.get_items() -> table | nil - Items currently held in storage core.storage.get_inventory_items() -> table | nil - Character inventory items shown by the storage interface core.storage.withdraw(item_or_index: storage_item | number, count?: number) -> boolean - Withdraws a returned storage item or zero-based index; count defaults to full stack - Invalid index/count types or ranges raise a Lua error core.storage.deposit(item_or_index: storage_item | number, count?: number) -> boolean - Deposits a returned inventory item or zero-based index; count defaults to full stack - Invalid index/count types or ranges raise a Lua error core.storage.withdraw_meso(amount: number) -> boolean core.storage.deposit_meso(amount: number) -> boolean - Transfers a positive whole-number amount after balance and limit validation - A malformed or non-positive amount raises a Lua error core.storage.withdraw_all() -> boolean core.storage.deposit_all() -> boolean - Takes no parameters; performs the storage window's bulk-transfer action, so storage must be open - False when storage is closed or a request is pending; true means started, and the game may still ask the player to confirm core.storage.close() -> boolean - Closes storage when no action is pending ================================================================================ TRADE (core.trade) ================================================================================ Targets passed to request() or request_cash() are a character name or positive ID present in the current field. True from a networked trade action means submitted, not completed. Opening the native prompt and changing the allowlist complete locally. Item, Meso, and acceptance actions apply to regular trades; cancellation supports regular and cash trades. core.trade.is_open() -> boolean - Whether a regular trade is open core.trade.is_cash_open() -> boolean core.trade.is_trading() -> boolean core.trade.is_accept_pending() -> boolean core.trade.get_state() -> number | nil - Current regular-trade stage; treat as observational core.trade.request(target: string | number) -> boolean, string? core.trade.request_cash(target: string | number) -> boolean, string? - Cash trade initiation requires its entry item core.trade.put_meso(amount: number) -> boolean, string? - Places Mesos into the open regular trade; the in-game Meso prompt does not need to be open and is never opened - amount must be positive, <= the current balance, and <= 9999999999999 core.trade.put_item(item_or_tab_id: table | number, count_or_position?: number, count?: number) -> boolean, string? - Two call forms; the position of count differs between them - put_item(item, count?): item is a core.inventory record and must have tab_id and position; count is argument 2 - put_item(tab_id, position, count?): tab_id 1 Equip, 2 Use, 3 Setup, 4 Etc, 5 Cash; position is 1-based; count is argument 3 - An omitted count uses the whole held stack; count may not exceed the held quantity or 65535 core.trade.put_item_by_id(item_id: number, count?: number) -> boolean, string? - Finds and places a matching inventory item core.trade.accept() -> boolean, string? - Accepts when another accept action is not already pending core.trade.cancel() -> boolean, string? - Cancels the current regular or cash trade core.trade.allowlist.add(target: string | number) -> boolean, string? core.trade.allowlist.remove(target: string | number) -> boolean, string? core.trade.allowlist.contains(target: string | number) -> boolean, string? core.trade.allowlist.get_all() -> { names: string[], ids: number[] } core.trade.allowlist.clear() -> boolean - Allowlisted incoming invitations are accepted automatically - Names are case-insensitive; names and IDs are stored separately - The allowlist lasts for the current Violet session ================================================================================ MESO MARKET (core.meso_market) ================================================================================ Reads require the Meso Market to be open and return nil when unavailable. A rate is Maple Points per 100,000,000 Mesos; count is a number of 100,000,000-Meso units. Valid orders use rate 100..50,000 and count 1..50. Invalid arguments raise a Lua error. True means submitted, not completed; false means the open market could not submit the otherwise valid request. core.meso_market.is_open() -> boolean core.meso_market.get_average_rate() -> number | nil - Displayed recent average in Maple Points per 100,000,000 Mesos core.meso_market.get_remaining_listings() -> number | nil - Remaining buy/sell registration count reported by the open market; not market liquidity core.meso_market.get_sell_rate() -> number | nil core.meso_market.get_buy_rate() -> number | nil - Current immediate-trade rates for each side; either may be 0 when no quote is available core.meso_market.buy(rate: number, count: number) -> boolean - Requests count * 100,000,000 Mesos for rate * count Maple Points core.meso_market.sell(rate: number, count: number) -> boolean - Offers count * 100,000,000 Mesos for rate * count Maple Points; server rules and fees still apply ================================================================================ AUCTION HOUSE (core.auction) ================================================================================ Listing actions validate the current sale state and return false when unavailable, incomplete, or busy. True means submitted, not completed. on_tick(stage) continues running here; require stage == core.stage.AUCTION_HOUSE for Auction House-specific logic. core.auction.is_open() -> boolean core.auction.enter() -> boolean - Requests entry; false when unavailable or already open core.auction.leave() -> boolean - Requests departure; false when the Auction House is not open core.auction.submit_current_listing() -> boolean - Takes no parameters; presses Submit on the listing the player prepared, so the sell view must be open with a listing filled in core.auction.cancel_selected_listing() -> boolean - Takes no parameters; presses Cancel on whichever listing the player has selected on screen ================================================================================ CASH SHOP (core.cash_shop) ================================================================================ Locker reads require an open Cash Shop; transfers additionally require the locker to be idle. Prefer passing complete item records returned by Violet rather than constructing records manually. on_tick(stage) continues running here; require stage == core.stage.CASH_SHOP for Cash Shop-specific logic. LOCKER_ITEM STRUCTURE: { cash_sn = number, -- Opaque locker identifier id = number, -- Item ID tab_id = number, -- Destination inventory category name = string?, -- Omitted when unavailable location = "locker", is_in_locker = true, is_in_inventory = false, owner_account_id = number? -- Owning account; omitted when unavailable } core.cash_shop.is_open() -> boolean core.cash_shop.enter() -> boolean - Requests entry; false outside a supported field state or when already open core.cash_shop.leave() -> boolean - Requests departure; false when the Cash Shop is closed or the locker is busy core.cash_shop.locker.is_busy() -> boolean core.cash_shop.locker.get_items() -> table | nil - Returns currently visible locker items, or nil when the locker is unavailable core.cash_shop.locker.withdraw_regular(item: locker_item, destination_slot: number) -> boolean - Withdraws an eligible returned locker item into an inventory slot - True means submitted; wait until is_busy() is false before reading or changing the locker again core.cash_shop.locker.deposit(inventory_item: table) -> boolean - Two call forms: deposit(inventory_item) or deposit(cash_sn, item_id, inventory_type, source_slot) - The record form needs id, tab_id and position from core.inventory.get_cash_items(); tab_id/inventory_type must be 5 or 6 - The four-argument form re-checks cash_sn against the serial live in source_slot and fails if it no longer matches - True means submitted; malformed, stale, or non-cash records raise a Lua error - An otherwise valid request returns false when the locker is unavailable, busy, or rejects it ================================================================================ GUILD (core.guild) ================================================================================ Guild reads return nil plus an error string when current-character data is unavailable. A character outside a guild has ID 0, an empty name, and no members. core.guild.get_id() -> number | nil, string? core.guild.get_name() -> string | nil, string? core.guild.is_in_guild() -> boolean | nil, string? core.guild.get_members() -> table | nil, string? - Returns the guild roster as `{ id = number, name = string }` records - Returns an empty table when the character is not in a guild core.guild.get_pending_applications() -> table | nil, string? - Returns outgoing `{ guild_id = number, guild_name = string }` applications - Returns nil plus an error string until the Guild search view has loaded them core.guild.move_to_castle() -> boolean, string? - Requests entry to the current guild's Castle - Returns false plus an error string when the action is unavailable ================================================================================ PARTY (core.party) - PARTY STATE & ACTIONS ================================================================================ Reads return empty/zero when not in a party. Member actions (leave/kick/promote) return false when not applicable. core.party.is_in_party() -> boolean - True if currently in a party core.party.is_party_leader() -> boolean - True if you are the party leader (boss) core.party.get_party_id() -> number - Current party ID, or 0 when not in a party core.party.get_party_name() -> string - Party name, or "" when not in a party core.party.get_party_boss_id() -> number - Leader's character ID, or 0 when not in a party core.party.get_members() -> table - Array of current members (see PARTY_MEMBER); empty when not in a party core.party.is_member(who: string | number) -> boolean - True if `who` (character name or character id) is in your party core.party.create(name?: string) -> boolean - Creates a party; name optional (server applies a default when empty) - Returns false if already in a party core.party.leave() -> boolean - Leaves the party; false if not in a party core.party.invite(name: string) -> boolean - Invites the player named `name` (leader-only; server enforced) core.party.kick(name: string) -> boolean - Kicks the member named `name`; false if not a current member core.party.promote(name: string) -> boolean - Promotes the member named `name` to leader; false if not a current member core.party.apply(party_id: number) -> boolean - Applies to a listed party by id (party search) core.party.accept_invite(inviter_id: number) - Accepts a pending invite (inviter_id from on_party_invite) core.party.decline_invite(inviter_id: number) - Declines a pending invite (inviter_id from on_party_invite) on_party_invite(inviter: string, inviter_id: number, level: number, job: number, sub_job: number) -> boolean | nil - Callback fired when a party invite arrives, BEFORE the client shows its invite dialog - Return true to accept (no dialog), false to decline (no dialog), nil/no return to let the normal dialog appear - inviter: inviter's IGN; inviter_id: their character ID (keep it to answer later via accept_invite/decline_invite) - level / job / sub_job describe the inviter - First script to return a non-nil value decides; later scripts don't see that invite - Requires plugin metadata structure PARTY_MEMBER STRUCTURE: { id = number, -- Member character ID name = string, -- Character name (IGN) level = number, -- Character level job = number, -- Job code sub_job = number, -- Sub-job code (0 for most jobs) channel = number, -- Channel the member is on field_id = number, -- Map ID the member is in is_boss = boolean -- True for the party leader } ================================================================================ PROFESSION (core.profession) - LIFE SKILL LEVELS & EXP ================================================================================ Level/EXP for each gathering and crafting profession, plus shared crafting fatigue. Every getter returns 0 when the profession isn't learned or character data isn't available yet. core.profession.get_herbalism_level() -> number core.profession.get_herbalism_exp() -> number core.profession.get_mining_level() -> number core.profession.get_mining_exp() -> number core.profession.get_smithing_level() -> number core.profession.get_smithing_exp() -> number core.profession.get_accessory_crafting_level() -> number core.profession.get_accessory_crafting_exp() -> number core.profession.get_alchemy_level() -> number core.profession.get_alchemy_exp() -> number core.profession.get_fatigue() -> number - Current crafting fatigue (shared across crafting professions) ================================================================================ ACTIVITIES (core.activities) - BOSS CLEAR LIMITS & CONTENT PROGRESS ================================================================================ Boss clear limits and daily/weekly content progress for the current character: runs left, reset times, entry requirements and costs, and what blocks a run now. Every function returns nil plus an error string when there is no current character. Counts come from what the game has recorded for this character and account and change as soon as it records a run; returned tables are snapshots, so call again to refresh. Boss limits and get_resets() follow the game's server clock: daily at the start of the server day, weekly on Thursday, monthly on the 1st. Content resets on its own schedule, given by its resets_in. Boss clears are counted per difficulty the way the game counts them; some bosses share one clear across their difficulties. core.activities.get_bosses() -> table | nil, string? - Every boss difficulty that has a clear limit, in the game's boss list order, easiest difficulty first within a boss core.activities.get_boss(boss: number | string, difficulty: string | number) -> boss_activity | nil, string? - boss: boss ID, or boss name in any case (e.g. "Lucid") - difficulty: "easy" | "normal" | "hard" | "chaos" | "extreme" (any case), or 0-4 in that order - Returns nil, "no such boss difficulty" when that boss has no such difficulty with a clear limit - Raises a Lua error when boss is not a number/string or difficulty is not one of those values core.activities.get_contents() -> table | nil, string? - Daily and weekly content the game tracks progress for (Monster Park, Monster Park Extreme, Hyperspace Cube, Maple Tour, Epic Dungeons, Arcane River weekly content such as Erda Spectrum, ...); daily entries first core.activities.get_resets() -> reset_times | nil, string? - { daily = number, weekly = number, monthly = number }: seconds until the next daily, weekly (Thursday) and monthly (1st of the month) reset BOSS_ACTIVITY STRUCTURE: { id = number, -- Boss ID name = string, -- Boss name difficulty = string, -- "easy" | "normal" | "hard" | "chaos" | "extreme" period = string, -- "daily" | "weekly" | "monthly": how often the clear limit resets clears = number, -- Clears counted this period, never more than max_clears max_clears = number, -- Clears allowed per period remaining = number, -- Clears left this period (never negative) resets_in = number, -- Seconds until the period resets min_level = number, -- Minimum level to enter map_id = number?, -- Map where the boss is entered; nil when none required_item = number?, -- Item needed to enter (the entry cost); nil when none party_limit = number, -- Largest party allowed solo_only = boolean, -- Must be fought alone blockers = table, -- Why it can't be run right now; empty when nothing blocks it blocking_quest = number? -- A prerequisite quest the character still needs; set only when blockers -- contains "quest", nil when the requirement isn't a single quest } CONTENT_ACTIVITY STRUCTURE: { name = string, -- Content name, e.g. "Monster Park" period = string, -- "daily" | "weekly" count = number, -- Progress this period (runs, clears or stages, depending on the content) limit = number, -- Progress that completes the period remaining = number, -- limit - count, never negative; 0 once completed completed = boolean, -- Whether the period's limit is reached; weekly content can also be -- completed by a weekly cap on that kind of content (e.g. Epic Dungeons) resets_in = number?, -- Seconds until progress resets; nil when it doesn't reset min_level = number, -- Minimum level required_quest = number?, -- Quest that must be completed first; nil when none map_id = number?, -- Content map; nil when none shared = string, -- Whose runs count toward the progress (SHARED below) blockers = table -- As for bosses } SHARED: "character" -- this character only "world" -- the account's characters in the current world (e.g. Monster Park, Monster Park Extreme, Maple Tour) "account" -- every character on the account, across worlds (e.g. Epic Dungeons) BLOCKERS: "level" -- below the level requirement "quest" -- a prerequisite quest is not complete "item" -- bosses only: the entry item is not in the inventory "limit" -- no clears left, or the content is completed this period "unknown" -- bosses only: the game can't check this right now (Cash Shop, map change, boss clears not loaded yet); treat it as not available and call again later EXAMPLE: for _, content in ipairs(core.activities.get_contents() or {}) do if content.name == "Monster Park" and content.remaining > 0 then core.log(("Monster Park: %d runs left"):format(content.remaining)) end end local lucid = core.activities.get_boss("Lucid", "hard") if lucid and #lucid.blockers == 0 then core.log("Hard Lucid is available") end ================================================================================ FAMILIAR ================================================================================ FAMILIAR STRUCTURE: { sn = number, -- Unique serial number (instance ID) id = number, -- Mob template ID character_id = number, -- Owner character ID name = string, -- Custom familiar name level = number, -- Current level max_level = number, -- Max level cap exp = number, -- Experience grade = number, -- 0=Common, 1=Rare, 2=Epic, 3=Unique, 4=Legendary grade_exp = number, -- Grade experience add_pad = number, -- Bonus physical attack add_pdd = number, -- Bonus physical defense option1 = number, -- Potential line 1 ID (raw) option2 = number, -- Potential line 2 ID (raw) potentials = table, -- Array of resolved potential strings (e.g. "Boss Damage +30%") locked = boolean, -- Whether familiar is locked register_date = number -- When obtained, as a Unix timestamp (seconds); 0 if unset } core.familiar.get_all() -> table - Returns all familiars owned by the character core.familiar.get_by_sn(serial_number: number) -> familiar | nil - Look up a specific familiar by serial number core.familiar.get_count() -> number - Returns total number of familiars owned core.familiar.get_summoned() -> familiar | nil - Returns currently summoned familiar, or nil if none core.familiar.get_equipped() -> table - Returns up to 3 familiars in active preset's equipped slots - Empty slots are nil core.familiar.get_badges() -> table - Returns active badge entries (non-empty slots) - Entry: {slot = number (0-7), badge_id = number} core.familiar.get_user_info() -> table | nil - Returns familiar system state - Fields: fatigue, inventory_size, summoned_sn, version, active_preset, active_badge_preset, last_fatigue_time - last_fatigue_time is a Unix timestamp (seconds); 0 if unset core.familiar.get_active_familiar_max_count() -> number - Max simultaneously-active familiars (dynamic cap: 1, 2, or 3); raised by familiar slot-expansion quests - Returns 0 if character data is unavailable core.familiar.get_preset_familiars(preset_index: number) -> table - Returns 3 familiar SNs for the specified preset (0-4) - SNs of 0 indicate empty slots core.familiar.get_preset_badges(preset_index: number) -> table - Returns 8 badge IDs for the specified badge preset (0-4) core.familiar.apply_preset(preset_index: number, sn1: number, sn2: number, sn3: number) -> boolean - Writes 3 familiar SNs into preset_index (0-4); 0 = empty slot - Does NOT change which preset is active — use set_active_preset for that - Checks each non-zero SN against your owned familiars; returns false (nothing sent) if any is not owned core.familiar.set_active_preset(preset_index: number) -> boolean - Switches which familiar preset is currently active (0-4) - Does NOT modify the preset's familiar layout core.familiar.summon() -> boolean - Summons the familiars assigned to the currently active preset; takes no SN or preset argument - If familiars are already summoned, returns true without toggling them off - Returns false if the player or summon skill is unavailable; true means already active or cast dispatched, not server-confirmed core.familiar.pull(inv_pos: number, card_item_id: number, count?: number) -> boolean - Opens familiar cards (item card_item_id) to add familiars. inv_pos is only a hint — the card is looked up by id and whichever USE slot holds it is used, so a stale/wrong inv_pos still works - count defaults to the whole stack and is clamped to the number owned - Returns false (sends nothing) if the card isn't in the USE tab, or if a pull is already pending (one at a time, throttled ~500ms) — retry next tick; pass the full count in one call rather than looping core.familiar.fuse(target_sn: number, fodder: number | table) -> boolean - Feeds fodder familiar(s) into target_sn to raise its fusion gauge (grade exp) - fodder is an SN or array of SNs (max 255/call); target + fodder must be owned, returns false on unknown SN core.familiar.rank_up(sn: number) -> boolean - Ranks the familiar up a grade (needs max level + full gauge in-game) - Rerolls the grade's potential, so reveal/lock keepers first; returns false if sn not owned core.familiar.reveal_potential(sn: number) -> boolean - Reveals hidden potential lines on an Epic+ familiar (appears in `potentials` after round-trip) - Returns false if sn not owned core.familiar.set_lock(sn: number, locked: boolean) -> boolean - Locks/unlocks a familiar; locked defaults to true. Already in that state = no-op, returns true - Returns false if sn not owned core.familiar.decompose(familiars: number | table) -> boolean - Decomposes (extracts) familiar(s) for materials; familiars is an SN or array of SNs - Checks each against your owned familiars, returns false on unknown SN ================================================================================ V MATRIX (core.vmatrix) ================================================================================ Reads return value or nil,error. Actions return true when the request is sent, or false,error when validation prevents the send. A successful send is not server confirmation. Send one mutation at a time and verify on a later on_tick; V Matrix state is not yet updated when on_packet_recv runs. IDs, counts, and levels must be positive whole numbers. Core IDs, target levels, and craft counts must fit a signed 32-bit integer. POINTS STRUCTURE: { available = number, -- Spendable V Points allocated = number, -- V Points allocated to cores total = number, -- available + allocated nodestone_cost = number, -- V Points to craft one Nodestone activation_cost = number, -- Configured special-core registration cost reset_meso_cost = number -- Mesos to reset allocated V Points } - activation_cost is informational; activate_core does not locally validate or deduct that balance NODESTONE STRUCTURE: { item_id = number, -- Nodestone item ID count = number, -- Inventory count max_use = number, -- Safe batch under inventory/V Point limits points_per_item = number -- V Points granted per item } - max_use may be capped at 1 for single-use flows; it does not bypass quest/map/client-state restrictions V MATRIX CORE STRUCTURE: { id = number, -- ID passed to all core actions level = number, max_level = number, -- 0 when unavailable type = string, -- "skill" | "boost" | "special" | "unknown" active = boolean, -- Special-core activation state expires_at = number, -- Unix timestamp; 0 = no expiration expired = boolean, extension_cost = number -- Quoted V Points; 0 = no configured extension } - Positive extension_cost is not a full eligibility/affordability check; extend_core revalidates core.vmatrix.get_points() -> table | nil, string? - Returns V Point balances plus Nodestone, activation, and reset costs core.vmatrix.get_nodestone(item_id?: number) -> table | nil, string? - Returns Nodestone inventory/use information; item_id defaults to the standard Nodestone core.vmatrix.get_cores() -> table | nil, string? - Returns an array containing every V Matrix core core.vmatrix.get_core(core_id: number) -> vmatrix_core | nil, string? - Returns one core; unknown ID returns nil without an error string core.vmatrix.get_upgrade_cost(core_id: number, target_level: number) -> number | nil, string? - Returns V Point cost to reach the absolute target level - Target must be above current level and no higher than max_level core.vmatrix.craft_nodestones(count: number) -> boolean, string? - Converts available V Points into Nodestones - Validates V Points, item data, and inventory space before sending core.vmatrix.use_nodestones(count?: number, item_id?: number) -> boolean, string? - Uses Nodestones; count defaults to 1, item_id defaults to standard Nodestone - Validates inventory, V Point cap, quests, map, and client state before sending core.vmatrix.upgrade_core(core_id: number, target_level: number) -> boolean, string? - Upgrades a core to the absolute target level after validating cost and V Points - Target must be above the current level and no higher than max_level core.vmatrix.extend_core(core_id: number) -> boolean, string? - Extends an eligible special core; revalidates eligibility and available V Points core.vmatrix.activate_core(core_id: number) -> boolean, string? - Activates a known, eligible, unexpired special core after date and character-state checks core.vmatrix.deactivate_core(core_id: number) -> boolean, string? - Deactivates a known, active, unexpired special core after date and character-state checks core.vmatrix.reset_points() -> boolean, string? - Resets allocated V Points after validating core state, point cap, character state, and Mesos - Live action with no dry-run; cost is get_points().reset_meso_cost Legacy slot/equip V Matrix calls such as get_slots(), get_equipped(), send_*_request(), and singular use_nodestone() are not registered. Use only the point/core APIs above. ================================================================================ HEXA MATRIX (core.hexa) ================================================================================ Reads return value or nil,error. Unknown IDs passed to get_core or get_stat_core return nil without an error. Actions return true when submitted successfully, or false,error when they cannot be submitted. Confirm changes by reading the affected core on a later on_tick. Core IDs and target levels must be positive whole numbers. Actions validate the current state, current-job eligibility, prerequisites, configuration, and required resources before submission. HEXA COST STRUCTURE: { sol_erda = number, fragments = number } HEXA CORE STRUCTURE: { id = number, -- ID passed to other Hexa core functions level = number, -- 0 when inactive max_level = number, type = string, -- "skill" | "mastery" | "enhancement" | "common" | "unknown" position = number, -- Display position active = boolean, skills = table, -- Associated skill IDs activation_cost? = hexa_cost, -- Present for an inactive core when available upgrade_cost? = hexa_cost -- Next-level cost when active and upgradeable } HEXA STAT LINE STRUCTURE: { type = string, -- See supported stat names below level = number } - Supported stat names: critical_damage, boss_damage, ignore_defense, damage, attack_power, magic_attack, main_stat HEXA STAT CORE STRUCTURE: { id = number, position = number, level = number, -- Combined stat-line level; 0 when inactive max_level = number, active = boolean, activation_cost? = hexa_cost, -- Present for an inactive core when available selected_slot? = number, -- Zero-based selected configuration; active only main? = hexa_stat_line, -- Active only additional? = table -- Two lines; active only } core.hexa.get_resources() -> hexa_cost | nil, string? - Returns current Sol Erda and Sol Erda Fragment balances core.hexa.get_cores() -> table | nil, string? - Returns every Hexa core available to the current job, including inactive cores core.hexa.get_core(core_id: number) -> hexa_core | nil, string? - Returns one Hexa core; unknown ID returns nil without an error string core.hexa.get_upgrade_cost(core_id: number, target_level?: number) -> hexa_cost | nil, string? - Returns the total cost from the current level to the absolute target level - target_level defaults to the next level - Requires an active core; target must be above current level and no higher than max_level core.hexa.activate_core(core_id: number) -> boolean, string? - Activates an eligible, inactive Hexa core core.hexa.upgrade_core(core_id: number, target_level?: number) -> boolean, string? - Upgrades an active Hexa core to an absolute target level - target_level defaults to the next level and may span multiple levels - Validates the complete Sol Erda and Fragment cost for the requested level span core.hexa.get_stat_cores() -> table | nil, string? - Returns every available Hexa Stat core, including inactive cores core.hexa.get_stat_core(core_id: number) -> hexa_stat_core | nil, string? - Returns one Hexa Stat core; unknown ID returns nil without an error string core.hexa.activate_stat_core(core_id: number, main_type: string, additional_type_1: string, additional_type_2: string) -> boolean, string? - Activates an eligible, inactive Hexa Stat core with the selected stat types - Names must come from the supported list; unsupported or invalid configurations are rejected Hexa Stat support currently covers inspection and activation. Upgrading, resetting, changing stat lines, and switching configurations are not exposed by core.hexa. ================================================================================ INNER ABILITY (core.inner_ability) - READ + CIRCULATE + AUTO INNER ABILITY ================================================================================ Read access to the character's Inner Ability lines for the active preset, circulate() to re-roll them once (spending Honor EXP), and the Auto Inner Ability engine (start/stop/is_running/get_status) that circulates until the lines meet your goals. core.inner_ability.get_current() -> table - Returns an array of the active preset's Inner Ability lines (up to 3) - Returns an empty table if unavailable (char not loaded) - Each line is a table: { skill_id: number, pos: number, grade: number, name: string } skill_id - option ID pos - slot index (1-3) grade - rarity: 0=Rare, 1=Epic, 2=Unique, 3=Legendary name - ability name in the client's language, e.g. "Boss Monster Damage Increase". NEVER carries the rolled value - Example: for _, line in ipairs(core.inner_ability.get_current()) do core.log(string.format("Slot %d (grade %d): %s", line.pos, line.grade, line.name)) end core.inner_ability.get_active_preset() -> number - Returns the index of the currently active Inner Ability preset (0-based), or -1 if none - This is the preset get_current() reads and the default target of circulate() core.inner_ability.get_honor_exp() -> number - Returns the character's current Honor EXP — the currency spent circulating Inner Ability (capped at 9,999,999) core.inner_ability.circulate(preset_index?: number, lock_positions?: table) -> boolean - Re-rolls (circulates) the Inner Ability lines, spending Honor EXP - preset_index (0-based) defaults to the active preset - lock_positions is an optional array of slot numbers (1-3) to keep — the rest are re-rolled; omit to re-roll all lines - Locking is only allowed once the preset is Unique grade or higher, and at least one slot must be left to re-roll - Returns true once sent. Returns false without sending while the game is still busy with another request (the previous circulate included, until ~0.5 s after its result), when the preset has no lines, or when the locks break these rules or list a slot twice - Example: -- Keep slot 1, re-roll the rest core.inner_ability.circulate(core.inner_ability.get_active_preset(), { 1 }) --- AUTO INNER ABILITY (same namespace) --- Circulates until the lines meet your goals, then stops itself. Same engine the web menu's Autos > Auto Inner Ability panel drives. core.inner_ability.start(opts?: table) -> boolean, string? - Starts the engine; returns false + an error message when opts are invalid - Every option persists until changed; omit one to keep the menu's value - opts: preset - preset index to circulate (0-2); omit or -1 = active preset min_grade - grade EVERY line must reach (0-3), default 2 (Unique) goals - array of { skill_ids, min_level, min_grade }; each goal must be met by a DIFFERENT line. Empty = grade gate alone skill_ids - ability ids the goal accepts, e.g. {70000035} = Boss Monster Damage Increase. Ids are region-invariant; prefer them over names. Some abilities share a display name (e.g. 70000005 and 70000011 are both "Increases Speed") — list every id you'd accept ability - LEGACY: case-insensitive substring of the localized line name, used only when skill_ids is absent. Breaks on non-English clients; kept for goals saved before ids Rows are validated: a row naming no ability at all, or with a wrong-typed skill_ids/min_level/min_grade, is rejected with false + a message NOTE: min_level is the line's LEVEL, not necessarily the magnitude shown in the tooltip auto_lock - keep goal-matching lines, re-roll the rest. Only lines that also clear min_grade are kept, and only on a Unique+ preset min_honor - stop once Honor EXP would fall below this (0 = spend all). Checked against the reserved price before paying, so the floor holds from the first circulate onward max_circulates - safety cap on circulates for the run (1-999), or 0 for no cap (min_honor then becomes the only backstop) roll_delay - ms between circulates (0-5000, default 350). Scripts only; deliberately not surfaced in the menu. Lower finishes sooner, but never faster than ~0.5 s after the previous result (the game's own limit). timeout = a circulate got no result within 3 s - WARNING: every circulate spends real Honor EXP. Always set max_circulates or min_honor — an unrollable goal circulates until the cap trips - Circulate cost (Honor EXP), by preset rank x locked lines (0/1/2): rare 100/400/900 epic 200/1100/2600 unique 1500/3000/5500 legendary 8000/11000/16000 Before each circulate the run reserves the LARGER of this table's price for the current rank/lock count and the cost the last circulate actually took, so min_honor holds even when the preset ranks up or locks change mid-run - Ability ids are 70000000-70000062 (63 options). Full name/id table: docs.violet.cx/inner_ability#inner-ability-reference - Example: core.inner_ability.start({ min_grade = 3, -- 70000035 = Boss Monster Damage Increase goals = { { skill_ids = { 70000035 }, min_level = 20, min_grade = 3 } }, min_honor = 50000, max_circulates = 100, }) core.inner_ability.stop() -> boolean - Stops a running Auto Inner Ability run; safe when nothing is running core.inner_ability.is_running() -> boolean - Whether an Auto Inner Ability run is currently active core.inner_ability.get_status() -> table - { running, state, preset, circulates_used, honor_spent, result } state - "idle" | "waiting_result" | "checking_result" | "cooldown" result - { status, circulatesUsed, honorSpent, honor, preset, nextCost, startLines, currentLines }; honor = the character's Honor EXP balance, nextCost = Honor EXP reserved for the next circulate status - "circulating" | "rolling" | "done" | "no_honor" | "max_reached" | "preset_not_found" | "timeout" | "character_changed" startLines/currentLines - arrays of { pos, skillId, level, grade, name } - The top-level circulates_used/honor_spent/preset describe the run IN PROGRESS and are cleared when it ends. For a finished run read result.circulatesUsed / result.honorSpent, which keep the values that stopped it ================================================================================ QUESTER ================================================================================ core.quester.get_quest_state(quest_id: number) -> number - Returns quest state: -1 = Not available 0 = Available 1 = In progress 2 = Completed core.quester.get_quest_progress(quest_id: number) -> table | nil - Read-only view of a quest's completion requirements + live progress; nil for an unknown quest or one with no requirements - Fields: id, name, state ("available"/"in_progress"/"complete"/"not_available"), objective ("kill"/"collect"/"reach"/"talk"/"condition"/"unknown"), start_npc (number|nil, offers the quest), target_npc (number|nil, takes it back), target_map (number|nil, for "reach"), min_level/max_level (number|nil), items (array), mobs (array), conditions (array) - items[i] = { id, need, have } -- collect; have = live inventory count - mobs[i] = { id, need, have, order } -- kill; id = monster id, have = live kill count (server-updated), order = quest step the requirement belongs to (1-based) - conditions[i] = { key, need, have, cond } -- record rule: record[key] need cond is "=="/">="/"<="; have = current record value story/scenario requirement: key = a scripted flag (objective = "condition") rare: some quests express kills as conditions keyed by a monster id - a condition is met when have need holds - meso cost is NOT included core.quester.get_quest_info(quest_id: number) -> table | nil - What a quest needs to be accepted and handed in, from the game's quest data; nil for an unknown quest - Fields: id, name, auto_start (starts without its NPC), auto_complete (completes itself once met), start = { npc, npc_maps, min_level, max_level, quests, quests_any, items, maps }, complete = { npc, npc_maps, items, mobs, maps, conditions } - start.npc offers the quest; complete.npc takes it back; npc_maps = maps that NPC stands in - quests[i] = { id, state } -- state that prerequisite must be in: "complete"/"in_progress"/"not_started"; quests_any = true when one of them is enough - items[i] = { id, need }; complete.mobs[i] = { id, need, maps } -- maps the monster appears in - start.maps = accept only in one of these maps; complete.maps = maps to visit - complete.conditions[i] = { key, need, cond } -- record rules, see get_quest_progress - npc_maps / maps are empty when the game data places the NPC or monster nowhere core.quester.can_start_quest(quest_id: number) -> boolean, table - Whether the character can accept the quest now + list of what is missing (empty when true) - nil while the character isn't in a map - missing entries (also returned by can_complete_quest), each with a type: { type="level", need, have } too low | { type="level", max, have } too high { type="job" } not offered to this job { type="quest", id, need, have } need/have = "not_started"/"in_progress"/"complete" { type="item", id, need, have } | { type="mob", id, need, have } { type="map", id } accept in (start) / visit (complete) this map { type="condition", key, need, have, cond } record rule { type="npc", id } hand in to this NPC instead { type="state", id, need, have } the quest itself is in the wrong state (need "not_started" to accept, "in_progress" to hand in; have = its state now) { type="blocked", id } the game has this quest turned off { type="other", code } another requirement the game reports - a quest already in progress/complete, or turned off, gets one state / blocked entry core.quester.start_quest(npc_id: number, quest_id: number) -> boolean - Start quest with NPC - Check state first with get_quest_state() - Teleports to the NPC first when it is farther than one screen - Returns false if the NPC isn't on the current map (quests that start without an NPC are exempt) or if the game could not act on the quest core.quester.complete_quest(npc_id: number, quest_id: number) -> boolean - Complete quest with NPC - Check if completable first with can_complete_quest() - Teleports to the NPC first when it is farther than one screen - Returns false if the NPC isn't on the current map (quests that complete without an NPC are exempt) or if the game could not act on the quest core.quester.can_complete_quest(npc_id: number, quest_id: number) -> boolean, table - true if the quest can be handed in to npc_id, + list of what is missing (empty when true) - nil while the character isn't in a map - all requirements met but wrong NPC -> one { type="npc", id } entry naming the right one - multi-step quests: lists only what the current step still needs core.quester.get_quest_record(quest_id: number, key?: string) -> string | nil - A quest's saved named values (step counters, clear flags, world states) - With key: that value as a string, nil when not set - Without key: table of every named value, plus the whole record as a second string value - Record 63090 = Afterlands world state: DnN ("1" at night), raining, lightning core.quester.get_quest_timers() -> table - Quest time limits counting down on screen, each { quest_id, remaining, total } in seconds - Empty when no timed quest is running or the character isn't in a map core.quester.run_line(start_id: number, end_id: number) -> boolean, string? - Starts an Auto Quest run: accepts, travels, fights and hands in every quest from start_id to end_id (inclusive, either order, at most 5000 ids), in the order prerequisites allow; same run as the web menu's Autos -> Auto Quest panel (fills in its fields); replaces a run already going - While running: travels and approaches NPCs itself, answers NPC dialogs/blue boxes like Auto NPC/ Auto Blue Boxes, turns on Kami (+ Kami Loot) and Auto Attack with your settings only while a quest needs kills or drops (when "Fight for Quests" is on), skips quests the character can never take, pauses during evasion/runes/lie detectors/conversations/cutscenes/death - Stops on its own when every doable quest is complete or a quest needs something it can't provide - Returns true when started; false + reason otherwise core.quester.stop_line() - Stops the current Auto Quest run core.quester.get_line_status() -> table - { running, step, quest_id?, quest_name?, detail?, done, total, stop_reason? } - step: "planning"|"accepting"|"traveling"|"hunting"|"visiting"|"turning_in"|"waiting"|"finished"|"stopped"|"idle" - stop_reason = why the last run ended, once it has core.quester.queue_npc_selection(selection: string) - Queue dialogue option substring - When NPC dialogue appears, option containing substring is selected - For text input, queued string is used as answer core.quester.clear_npc_selection() - Clear entire NPC selection queue ================================================================================ RUSHER ================================================================================ core.rusher.rush(map_id: number) - Auto-navigate to target map - Handles pathfinding and portals automatically core.rusher.stop() - Stop current rushing immediately core.rusher.is_rushing() -> boolean - Returns true if currently rushing core.rusher.get_status() -> table - Where the rusher is going, what it is doing now, and how the last rush ended - Fields: rushing (bool, same as is_rushing), action (string), destination (map id or nil), channel (channel being switched to by core.change_channel, or nil), next_map (next map on the route while following it, or nil), npc_id (NPC the route continues through, only with action "talking_to_npc" or "npc_required"), elapsed (seconds since the current rush started, 0 when idle), stop_reason ("arrived" | "cancelled" | "replaced" | nil), last_destination (map of that finished rush, or nil), last_channel (channel of that finished channel change, or nil) - action: "idle" | "hyper_rock" (asking for a Hyper Teleport Rock teleport) | "moving" (to the next portal) | "entering_portal" | "region_move" (crossing regions through the world map) | "changing_channel" | "paused" (character dead or the Rotation Builder running; Rush By Level trips and in-game navigation followed by Ingame Rusher also hold for evasion / a rune / the lie detector) | "waiting" (next step not ready, e.g. right after a map change) | "maple_guide" (no route from this map, so a Maple Guide move toward the destination is landing; next_map is where it lands) | "talking_to_npc" (talking to npc_id and picking the choice that names the next map or its land, e.g. "Victoria Island"; a route fare is paid) | "no_route" (no route from this map to the destination, and no Maple Guide move led on) | "interrupted" (the in-game navigation stopped before the destination, e.g. navigation started or closed by hand; rush again to continue) | "npc_required" (route continues through npc_id, and talking to it did not lead on: no choice named the next map or its land, or three talks did not move the character) - With no route from the current map it makes one Maple Guide move per rush (the destination when the guide offers it, else the closest guide map in its region) and asks for the route again where it lands; Dimensional Mirror destinations are not used - Never gives up on its own: with no_route / interrupted / npc_required it stays active until stop() or rush() again, and posts a console warning - stop_reason: "cancelled" after stop(), "replaced" when a rush to another destination took over, also "arrived" when rushing to the current map while no other rush is active; it describes the last finished rush while the next one runs - In-game navigation you started yourself and Ingame Rusher follows fills in only rushing, action, destination, next_map and npc_id ================================================================================ NAVIGATION (core.nav: travel within the current map; use core.rusher for other maps) ================================================================================ core.nav.path_to(x: number, y: number, opts?: table) -> boolean, string? - Starts travelling to map point (x, y) and returns at once; also path_to({x=, y=}, opts) - Plans over platforms, ladders, ropes and portals with the character's moves (flash jump, up jump, teleport, rope lift), plays it with human-like timing, replans when knocked off - Target snaps to the platform just below the point, or the nearest one close by - opts.seed (number): the same seed repeats the same route choices; omit for varied routes - Calling it during a trip replaces the trip - Returns true, or false + reason: "stand on a platform before navigating" | "no platform near the target" | "no jump key is bound" | "map geometry unavailable" | "Rotation Builder owns the input" - Ends on arrival, no route, stop(), map/channel change, logout, Panic Mode; after 4 replans in one trip it fails with message "too many replans: ..." core.nav.stop() - Stops the trip and lets go of the movement keys core.nav.is_active() -> boolean - true from a successful path_to() until the trip arrives, fails or is stopped core.nav.status() -> table - Fields: state (string), message (string; why it failed), step (number; step being played), steps (number), replans (number, max 4), target_x, target_y (the point given to path_to) - state: "idle" (no trip, also after stop()) | "planning" | "running" | "replanning" (left the route, new route on its way) | "arrived" | "failed" (message says why) - A finished trip keeps arrived / failed until the next path_to() core.nav.plan(x: number, y: number, seed?: number) -> table?, string? - Works out the route without moving; waits for it (can take a moment on large maps) - Returns a list of steps plus total_seconds, or nil + reason (as path_to, with "stand on a platform before planning") - Step: kind ("walk" | "jump" | "drop" | "teleport" | "climb" | "portal" | "rope"), dir (-1 left, 1 right, 0 neither), run (bool; running start), from_x, from_y, to_x, to_y, seconds (expected), actions (list of {at = seconds into the step, kind = "hold_dir" | "release_dir" | "jump" | "flash_jump" | "up_jump" | "down_jump" | "teleport" | "teleport_up" | "teleport_down" | "climb" | "portal" | "walk" | "rope_lift"}) core.nav.get_capabilities() -> table - Movement model routes are planned with; filled in from the character's job and skills the first time a route is planned - Booleans: flash_jump, up_jump, up_jump_airborne, teleport, down_jump, portals, rope_lift - flash_jump_max (count), teleport_skill, rope_lift_skill (skill ids, 0 = none) - Pixels/second: walk_speed, climb_speed, rope_speed, walk_jump, flash_jump_vx, flash_jump_vy, up_jump_vy; gravity (pixels/second^2) - Pixels: teleport_range_x, teleport_range_y, rope_range, ladder_attach_tolerance - Seconds: flash_jump_min_delay, teleport_delay, rope_delay core.nav.set_capabilities(caps: table) -> boolean - Changes only the fields passed; your values then replace the filled-in ones. Returns true core.nav.get_persona() -> table - Route style, picked at random each time Violet loads so characters route differently - alternatives (candidate routes compared), beta (preference for the fastest; higher = almost always fastest), cost_noise (random variation per move; 0 = none), flash_jump_weight / ladder_weight / teleport_weight (also rope lift) / portal_weight / drop_weight (cost multipliers: above 1 uses that move less, below 1 more), fall_damage_penalty (seconds added to drops long enough to cause fall damage) core.nav.set_persona(persona: table) -> boolean - Changes only the fields passed. Returns true core.nav.refresh() -> boolean - Reads the current map's platforms again; true on success. Rarely needed ================================================================================ IN PACKET (RECEIVING + INJECTION) ================================================================================ Two flavors: 1. Read-only view passed to on_packet_recv / on_packet_send callbacks. 2. Builder created via in_packet(opcode) for injecting synthetic packets. --- READ SIDE (callback parameter) --- packet:get_opcode() -> number - Returns packet opcode packet:decode_1() -> number - Read 1 byte (0-255) packet:decode_2() -> number - Read 2 bytes (0-65535) packet:decode_4() -> number - Read 4 bytes (0-4294967295) packet:decode_8() -> integer - Read 8 bytes as a Luau int64 (LUA_TINTEGER), NOT a regular number - int64 is a distinct numeric type — does NOT compare/arithmetic against plain numbers - For initial state, use `integer.new(0)` not `0` - Cast with `tonumber(v)` if you need a regular number (loses precision past 2^53) packet:decode_string() -> string - Read length-prefixed string packet:decode_buffer(size: number) -> buffer | nil - Read N bytes as a native Luau buffer (mutable, byte-addressable) - Access with buffer.readu8(buf, offset) — 0-indexed, NOT buf[i+1] - Returns nil if not enough data remains - Pass straight to out_packet:encode_buffer or in_packet:encode_buffer to forward bytes without copy packet:get_length() -> number - Returns total packet length in bytes packet:get_offset() -> number - Returns current read offset position packet:set_offset(offset: number) - Set read offset to specific position - Allows rewinding to re-read data packet:to_string() -> string - Whole packet as space-separated hex, opcode included ("1D 00 05 00 ...") - Same shape the packet monitor shows, so it can be pasted into the injector - Does not move the decode offset. packet:get_data() is an alias. NOTE: Decode methods read sequentially. Each call advances read position. Use set_offset() to rewind if you need to re-read data. --- INJECTION SIDE (builder) --- Constructor: in_packet(opcode: number) -> InPacketBuilder - Creates a builder for injecting a synthetic incoming packet. - Server never sees this — packet is dispatched LOCALLY as if received. - Use out_packet(...):send() if you actually want to send to the server. packet:encode_1(value: number) -> self - Encode 1 byte. Returns self for chaining. packet:encode_2(value: number) -> self - Encode 2 bytes. Returns self for chaining. packet:encode_4(value: number) -> self - Encode 4 bytes. Returns self for chaining. packet:encode_8(value: number) -> self - Encode 8 bytes. Returns self for chaining. packet:encode_string(text: string) -> self - Encode length-prefixed string. Returns self for chaining. packet:encode_buffer(data: string | buffer) -> self - Encode raw bytes from a Lua string or Luau buffer. Returns self for chaining. packet:encode_timestamp() -> self - Encode the client's current update time as 4 bytes. Returns self for chaining. - Same as encode_4(core.get_update_time()). Errors if the game is not loaded. packet:encode_hex(hex: string) -> self - Encode bytes written as hex text, e.g. "05 00 1A FF" (whitespace optional). - Errors on a non-hex character or an odd digit count instead of skipping it. - Body only — the opcode comes from in_packet(opcode). Returns self for chaining. packet:inject() - Queue the packet for dispatch on next main tick. - Safe to call from inside on_packet_recv (deferred — no re-entrancy). packet:to_string() -> string - Hex representation of contents (debug helper). packet:get_opcode() -> number - The opcode passed to in_packet(opcode). packet:get_data() -> string - Alias of to_string(). EXAMPLE (injection): in_packet(0x00A2) :encode_1(0) :encode_string("hi") :inject() EXAMPLE (forward bytes from a hook): function on_packet_recv(packet) if packet:get_opcode() == 0x1234 then local body = packet:decode_buffer(packet:get_length() - 4) in_packet(0x5678):encode_buffer(body):inject() end return true end ================================================================================ OUT PACKET (SENDING) ================================================================================ Constructor: out_packet(opcode: number) -> OutPacket packet:encode_1(value: number) -> self - Encode 1 byte (0-255) - Returns self for chaining packet:encode_2(value: number) -> self - Encode 2 bytes (0-65535) - Returns self for chaining packet:encode_4(value: number) -> self - Encode 4 bytes (0-4294967295) - Returns self for chaining packet:encode_8(value: number) -> self - Encode 8 bytes - Returns self for chaining packet:encode_string(text: string) -> self - Encode length-prefixed string - Returns self for chaining packet:encode_buffer(data: string | buffer) -> self - Encode raw binary data from either a Lua string or a Luau buffer - Use the buffer form to forward bytes from in_packet:decode_buffer - Returns self for chaining packet:encode_timestamp() -> self - Encode the client's current update time as 4 bytes — the tick most packets carry as their timestamp field - Same as encode_4(core.get_update_time()), and the same value the packet injector's {timestamp} variable expands to - Errors if the game is not loaded - Returns self for chaining packet:encode_hex(hex: string) -> self - Encode bytes written as hex text, e.g. "05 00 1A FF" (whitespace optional) - Errors on a non-hex character or an odd digit count instead of skipping it - Body only — the opcode comes from out_packet(opcode), so a captured string (which starts with the 2-byte opcode) needs its first two bytes dropped - Returns self for chaining packet:to_string() -> string - Returns hex string for debugging packet:get_opcode() -> number - Returns the packet opcode packet:get_data() -> string - Returns hex string (alias of to_string) packet:send() - Send packet to server EXAMPLE: out_packet(0x0029) :encode_1(1) :encode_4(map_id) :encode_string("test") :send() ================================================================================ COMMON PATTERNS ================================================================================ --- HP/MP MONITORING --- function on_tick(stage) if stage ~= core.stage.FIELD then return end local player = core.object_manager.get_local_player() if not player or not player:is_valid() then return end local hp_percent = (player:get_health() / player:get_max_health()) * 100 if hp_percent < 50 then core.input.use_item(2000001) -- Red Potion end end --- MOB HUNTING --- function on_tick(stage) if stage ~= core.stage.FIELD then return end local player = core.object_manager.get_local_player() if not player or not player:is_valid() then return end local map = core.object_manager.get_current_map() if not map or not map:is_valid() then return end local mobs = map:get_mobs() for _, mob in ipairs(mobs) do if mob:is_valid() then local pos = mob:get_position() if pos then core.input.teleport_safe(pos.x, pos.y) core.input.use_skill(2001004) break end end end end --- BUFF MAINTENANCE --- function on_tick(stage) if stage ~= core.stage.FIELD then return end local player = core.object_manager.get_local_player() if not player or not player:is_valid() then return end local buff_id = 2001002 if not player:has_buff(buff_id) then if not core.skill_book.is_skill_on_cooldown(buff_id) then core.input.use_skill(buff_id) end end end --- DISTANCE CALCULATION (for plain positions; actors have mob:distance() / npc:distance()) --- function distance(pos1, pos2) local dx = pos1.x - pos2.x local dy = pos1.y - pos2.y return math.sqrt(dx * dx + dy * dy) end --- COOLDOWN MANAGER --- local cooldowns = {} function can_use(key, cooldown_ms) local now = core.get_update_time() if not cooldowns[key] or now - cooldowns[key] >= cooldown_ms then cooldowns[key] = now return true end return false end --- STATE MACHINE --- local state = "idle" function on_tick(stage) if stage ~= core.stage.FIELD then return end if state == "idle" then state = "hunting" elseif state == "hunting" then -- hunting logic if condition then state = "looting" end elseif state == "looting" then -- looting logic state = "idle" end end --- MAP CHANGE DETECTION --- local last_map_id = nil function on_tick(stage) if stage ~= core.stage.FIELD then return end local map = core.object_manager.get_current_map() if not map or not map:is_valid() then return end local current_map_id = map:get_id() if last_map_id ~= current_map_id then core.log("Map changed to: " .. current_map_id) last_map_id = current_map_id -- Reset state for new map end end ================================================================================ CRITICAL SAFETY RULES ================================================================================ 1. ALWAYS validate objects with is_valid() before calling methods 2. ALWAYS check for nil returns before using values 3. NEVER assume map or player exists - validate every tick 4. Use a core.stage.FIELD guard at the start of field-only on_tick(stage) logic 5. Wrap risky operations in pcall(): local success, result = pcall(function() return operation() end) 6. Avoid infinite loops - always have exit conditions 7. Be mindful of packet manipulation - can cause disconnects 8. Test scripts in safe environments first 9. Use core.log() liberally for debugging 10. Handle nil positions from mobs/npcs gracefully ================================================================================ PERFORMANCE OPTIMIZATION ================================================================================ 1. Cache frequently accessed objects within tick 2. Limit loop iterations: for i = 1, math.min(#mobs, 10) do 3. Use early returns: if core.is_solving_rune() then return end 4. Throttle expensive operations: tick_count = tick_count + 1 if tick_count % 10 == 0 then ... end 5. Prefer has_buff() over get_buff() for existence checks 6. Use local variables for frequently accessed values 7. Break out of loops early when target found 8. Minimize packet parsing - decode only what you need 9. Batch operations when possible ================================================================================ COMMON MAP IDS ================================================================================ Henesys: 100000000 Ellinia: 101000000 Perion: 102000000 Kerning City: 103000000 Lith Harbor: 104000000 Sleepywood: 105000000 Nautilus: 120000000 Orbis: 200000000 El Nath: 211000000 Ludibrium: 220000000 Leafre: 240000000 Mu Lung: 250000000 Ariant: 260000000 ================================================================================ COMMON JOB IDS ================================================================================ Beginner: 0 Warrior: 100 Fighter: 110, Hero: 111 Page: 120, Paladin: 121 Spearman: 130, Dark Knight: 131 Magician: 200 Fire/Poison: 210, Arch Mage (F/P): 211 Ice/Lightning: 220, Arch Mage (I/L): 221 Cleric: 230, Bishop: 231 Bowman: 300 Hunter: 310, Bow Master: 311 Crossbowman: 320, Marksman: 321 Thief: 400 Assassin: 410, Night Lord: 411 Bandit: 420, Shadower: 421 Pirate: 500 Brawler: 510, Buccaneer: 511 Gunslinger: 520, Corsair: 521 ================================================================================ MODULES (require) ================================================================================ Shared modules live under libs/ in your Lua tree (managed in the web menu) and are imported with require(). require("module_name") -- loads libs/module_name.lua from your Lua tree require("combat.targeting") -- loads libs/combat/targeting.lua Module names: alphanumeric, underscores, dots only (no paths like ../). Results are CACHED -- each module executes once, subsequent calls return cached value. Cache clears when any script is toggled (enabled/disabled), so edits to libs are picked up. Modules return a table of functions/values. No plugin table needed. WRITING A MODULE: -- libs/utils.lua local M = {} function M.distance(pos1, pos2) local dx = pos1.x - pos2.x local dy = pos1.y - pos2.y return math.sqrt(dx * dx + dy * dy) end function M.find_closest_mob(map) local closest, closest_dist = nil, math.huge for _, mob in ipairs(map:get_mobs()) do if mob and mob:is_valid() then local dist = mob:distance() -- prefer this over M.distance for actors if dist and dist < closest_dist then closest_dist = dist closest = mob end end end return closest, closest_dist end return M USING A MODULE IN A SCRIPT: local utils = require("utils") plugin = {name="Hunter", version="1.0.0", author="You", description="...", load=true} function on_tick(stage) if stage ~= core.stage.FIELD then return end local player = core.object_manager.get_local_player() if not player or not player:is_valid() then return end local map = core.object_manager.get_current_map() if not map or not map:is_valid() then return end local mob = utils.find_closest_mob(map) if mob then core.input.teleport_safe(mob:get_position().x, mob:get_position().y) end end COOLDOWN MANAGER MODULE: -- libs/cooldowns.lua local M = {} local timers = {} function M.ready(key, cooldown_ms) local now = core.get_update_time() if not timers[key] or now - timers[key] >= cooldown_ms then timers[key] = now return true end return false end return M MODULE RULES: - Same sandbox: no io, os, dofile, loadfile - Place require() at script top level (outside callbacks) - Modules run in global environment (shared across all scripts) - File layout (in your Lua tree): libs/*.lua for modules, top-level *.lua for plugins ================================================================================ MARKETPLACE LIBRARIES (publishing & the @ convention) ================================================================================ Two kinds of "library": - PUBLIC library: a standalone marketplace product others require() in their own scripts (free or paid, reviewed before listing). - PRIVATE library: module files bundled INSIDE your own script's package. Not sold or listed; only that script uses them; they ship to the script's buyers. Your unique publisher HANDLE (e.g. "acme") owns the folder libs//. Both kinds live there, so nothing collides with another developer. A PUBLIC library named "combat" by handle "acme": entry file: libs/acme/combat.lua (a module: returns a table, no plugin) submodule: libs/acme/combat/targeting.lua consumers: require("@acme.combat") and require("@acme.combat.targeting") THE @ CONVENTION: require("@acme.combat") -- a marketplace (public) library require("acme.util") -- your own bundled private module The leading "@" marks marketplace code. It is OPTIONAL: require("@acme.combat") and require("acme.combat") resolve to the same file. Use "@" for marketplace libraries so third-party imports stand out. A PRIVATE library is just modules under libs// in a script's package: my_script.lua -- the runnable plugin libs/acme/my_script/helpers.lua -- private, require("acme.my_script.helpers") DEPENDENCIES: to use someone else's public library, declare it as a dependency (don't copy its files). It auto-installs for your buyers. Only FREE public libraries can be dependencies; paid libraries are bought directly. ================================================================================ LUA STANDARD LIBRARY ================================================================================ AVAILABLE: Math: math.abs, math.sqrt, math.sin, math.cos, math.floor, math.ceil, etc. String: string.format, string.sub, string.len, string.find, string.upper Table: table.insert, table.remove, table.sort Pairs/IPairs: for iteration require(name): import modules from your Lua tree's libs/ (see MODULES section) NOT AVAILABLE: os library (use core.get_update_time() for game timing, core.get_system_time() for wall clock, core.get_utc_time() for UTC) io library (file operations not supported) debug library loadfile / dofile (use require() for shared code instead) ================================================================================ DEBUGGING TECHNIQUES ================================================================================ 1. Log everything during development 2. Use descriptive error messages 3. Print table contents with pairs/ipairs 4. Track execution flow with log statements 5. Monitor packet opcodes in packet handlers 6. Measure execution time with get_update_time() 7. Test edge cases (empty arrays, nil values, zero HP) 8. Validate data types before operations 9. Use pcall for error handling ================================================================================ END OF REFERENCE ================================================================================ This reference covers 100% of documented Violet Lua SDK APIs. Optimized for AI/LLM consumption with maximum information density. For support: Violet community Discord ================================================================================