Advertisement
Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- -- Exult SHP Format Extension for Aseprite
- local pluginName = "exult-shp"
- local pluginDir = app.fs.joinPath(app.fs.userConfigPath, "extensions", pluginName)
- local converterPath = app.fs.joinPath(pluginDir, "exult_shp")
- -- Debug system with toggle
- local debugEnabled = true -- toggle debug messages
- local function debug(message)
- if debugEnabled then
- print("[Exult SHP] " .. message)
- end
- end
- local function logError(message)
- -- Always print errors regardless of debug setting
- print("[Exult SHP ERROR] " .. message)
- end
- -- Global utility function for quoting paths with spaces
- function quoteIfNeeded(path)
- if path:find(" ") then
- return '"' .. path .. '"'
- else
- return path
- end
- end
- -- Helper to run commands with hidden output
- function executeHidden(cmd)
- -- For debugging, run raw command instead with output captured
- if debugEnabled then
- debug("Executing with output capture: " .. cmd)
- local tmpFile = app.fs.joinPath(app.fs.tempPath, "exult-shp-output-" .. os.time() .. ".txt")
- -- Add output redirection to file
- local redirectCmd
- if app.fs.pathSeparator == "\\" then
- -- Windows
- redirectCmd = cmd .. " > " .. quoteIfNeeded(tmpFile) .. " 2>&1"
- else
- -- Unix-like (macOS, Linux)
- redirectCmd = cmd .. " > " .. quoteIfNeeded(tmpFile) .. " 2>&1"
- end
- -- Execute the command
- local success = os.execute(redirectCmd)
- -- Read and log the output
- if app.fs.isFile(tmpFile) then
- local file = io.open(tmpFile, "r")
- if file then
- debug("Command output:")
- local output = file:read("*all")
- debug(output or "<no output>")
- file:close()
- end
- -- Clean up temp file (comment this out if you want to keep the logs)
- -- app.fs.removeFile(tmpFile)
- end
- return success
- else
- -- Check operating system and add appropriate redirection
- local redirectCmd
- if app.fs.pathSeparator == "\\" then
- -- Windows
- redirectCmd = cmd .. " > NUL 2>&1"
- else
- -- Unix-like (macOS, Linux)
- redirectCmd = cmd .. " > /dev/null 2>&1"
- end
- return os.execute(redirectCmd)
- end
- end
- debug("Plugin initializing...")
- debug("System Information:")
- debug("OS: " .. (app.fs.pathSeparator == "/" and "Unix-like" or "Windows"))
- debug("Temp path: " .. app.fs.tempPath)
- debug("User config path: " .. app.fs.userConfigPath)
- debug("Converter expected at: " .. converterPath)
- -- Check if converterPath exists with OS-specific extension
- if not app.fs.isFile(converterPath) then
- debug("Converter not found, checking for extensions...")
- if app.fs.isFile(converterPath..".exe") then
- converterPath = converterPath..".exe"
- debug("Found Windows converter: " .. converterPath)
- elseif app.fs.isFile(converterPath..".bin") then
- converterPath = converterPath..".bin"
- debug("Found binary converter: " .. converterPath)
- end
- end
- -- Verify converter exists at startup
- local converterExists = app.fs.isFile(converterPath)
- debug("Converter exists: " .. tostring(converterExists))
- -- Make the converter executable once at startup if needed
- if converterExists and app.fs.pathSeparator == "/" then
- local chmodCmd = "chmod +x " .. quoteIfNeeded(converterPath)
- debug("Executing chmod at plugin initialization: " .. chmodCmd)
- executeHidden(chmodCmd)
- end
- -- Error display helper
- function showError(message)
- logError(message)
- app.alert{
- title="Exult SHP Error",
- text=message
- }
- end
- -- Don't detect Animation sequences when opening files
- function disableAnimationDetection()
- -- Store the original preference value if it exists
- if app.preferences and app.preferences.open_file and app.preferences.open_file.open_sequence ~= nil then
- _G._originalSeqPref = app.preferences.open_file.open_sequence
- -- Set to 2 which means "skip the prompt without loading as animation"
- app.preferences.open_file.open_sequence = 2
- end
- end
- function restoreAnimationDetection()
- -- Restore the original preference if we saved it
- if app.preferences and app.preferences.open_file and _G._originalSeqPref ~= nil then
- app.preferences.open_file.open_sequence = _G._originalSeqPref
- end
- end
- -- File format registration function
- function registerSHPFormat()
- if not converterExists then
- showError("SHP converter tool not found at:\n" .. converterPath ..
- "\nSHP files cannot be opened until this is fixed.")
- return false
- end
- return true
- end
- function importSHP(filename)
- -- Handle direct file opening (when user opens .shp file)
- if filename then
- debug("Opening file directly: " .. filename)
- -- Create temp directory for files
- local tempDir = app.fs.joinPath(app.fs.tempPath, "exult-shp-" .. os.time())
- app.fs.makeDirectory(tempDir)
- -- Prepare output file path
- local outputBasePath = app.fs.joinPath(tempDir, "output")
- -- Use default palette and separate frames
- return processImport(filename, "", outputBasePath, true)
- end
- -- Normal dialog flow for manual import
- local dlg = Dialog("Import SHP File")
- dlg:file{
- id="shpFile",
- label="SHP File:",
- title="Select SHP File",
- open=true,
- filetypes={"shp"},
- focus=true
- }
- dlg:file{
- id="paletteFile",
- label="Palette File (optional):",
- open=true
- }
- -- Store dialog result in outer scope
- local dialogResult = false
- local importSettings = {}
- dlg:button{
- id="import",
- text="Import",
- onclick=function()
- dialogResult = true
- importSettings.shpFile = dlg.data.shpFile
- importSettings.paletteFile = dlg.data.paletteFile
- dlg:close()
- end
- }
- dlg:button{
- id="cancel",
- text="Cancel",
- onclick=function()
- dialogResult = false
- dlg:close()
- end
- }
- -- Show dialog
- dlg:show()
- -- Handle result
- if not dialogResult then return end
- if not importSettings.shpFile or importSettings.shpFile == "" then
- showError("Please select an SHP file to import")
- return
- end
- -- Create temp directory for files
- local tempDir = app.fs.joinPath(app.fs.tempPath, "exult-shp-" .. os.time())
- app.fs.makeDirectory(tempDir)
- -- Prepare output file path
- local outputBasePath = app.fs.joinPath(tempDir, "output")
- return processImport(importSettings.shpFile,
- importSettings.paletteFile or "",
- outputBasePath,
- true)
- end
- -- Global table to store layer offset data
- if not _G.exultLayerOffsets then
- _G.exultLayerOffsets = {}
- end
- -- Enhance the getLayerOffsetData function to parse from layer names when not in global table:
- function getLayerOffsetData(layer)
- -- Generate a unique key for the layer based on sprite and layer name
- local key = ""
- if layer.sprite and layer.sprite.filename then
- key = layer.sprite.filename .. ":"
- end
- -- Get clean name (without offset info)
- local cleanName = layer.name:gsub(" %[.*%]$", "")
- key = key .. cleanName -- Use clean name for lookup
- -- First check our global table
- local data = _G.exultLayerOffsets[key]
- if data then
- return data
- end
- -- If not found in table, try to extract from layer name
- local offsetX, offsetY = layer.name:match(" %[(%d+),(%d+)%]$")
- if offsetX and offsetY then
- -- Store extracted values in global table for future use
- offsetX = tonumber(offsetX)
- offsetY = tonumber(offsetY)
- _G.exultLayerOffsets[key] = {
- offsetX = offsetX,
- offsetY = offsetY
- }
- debug("Extracted offset data from layer name: " .. offsetX .. "," .. offsetY)
- return _G.exultLayerOffsets[key]
- end
- return nil
- end
- -- Also update setLayerOffsetData to store with clean name:
- function setLayerOffsetData(layer, offsetX, offsetY)
- -- Generate a unique key for the layer based on sprite and layer name
- local key = ""
- if layer.sprite and layer.sprite.filename then
- key = layer.sprite.filename .. ":"
- end
- -- Get clean name (without offset info)
- local cleanName = layer.name:gsub(" %[.*%]$", "")
- key = key .. cleanName -- Use clean name for lookup
- -- Store in global table
- _G.exultLayerOffsets[key] = {
- offsetX = offsetX,
- offsetY = offsetY
- }
- -- Also encode the offset in the layer name for visualization
- layer.name = cleanName .. " [" .. offsetX .. "," .. offsetY .. "]"
- end
- -- Modify the processImport function to use layers instead of frames
- function processImport(shpFile, paletteFile, outputBasePath, createSeparateFrames)
- if not converterExists then
- showError("SHP converter not found at: " .. converterPath)
- return false
- end
- debug("Importing SHP: " .. shpFile)
- debug("Palette: " .. (paletteFile ~= "" and paletteFile or "default"))
- debug("Output: " .. outputBasePath)
- -- Check if file exists
- if not app.fs.isFile(shpFile) then
- showError("SHP file not found: " .. shpFile)
- return false
- end
- -- Extract base filename from the SHP file (without path and extension)
- local shpBaseName = shpFile:match("([^/\\]+)%.[^.]*$") or "output"
- shpBaseName = shpBaseName:gsub("%.shp$", "")
- debug("Extracted SHP base name: " .. shpBaseName)
- -- Extract output directory from outputBasePath
- local outputDir = outputBasePath:match("(.*[/\\])") or ""
- -- Combine to get the actual path where files will be created
- local actualOutputBase = outputDir .. shpBaseName
- debug("Expected output base: " .. actualOutputBase)
- -- Create command - always use separate frames mode
- local cmd = quoteIfNeeded(converterPath) .. " import " .. quoteIfNeeded(shpFile) .. " " .. quoteIfNeeded(outputBasePath)
- -- Only add palette if it's not empty
- if paletteFile and paletteFile ~= "" then
- cmd = cmd .. " " .. quoteIfNeeded(paletteFile)
- end
- -- Always use separate frames
- cmd = cmd .. " separate"
- debug("Executing: " .. cmd)
- -- Execute command
- local success = executeHidden(cmd)
- debug("Command execution " .. (success and "succeeded" or "failed"))
- -- Check for output files - using the actual base path with SHP filename
- local firstFrame = actualOutputBase .. "_0.png"
- debug("Looking for first frame at: " .. firstFrame)
- debug("File exists: " .. tostring(app.fs.isFile(firstFrame)))
- if not app.fs.isFile(firstFrame) then
- debug("ERROR: Failed to convert SHP file")
- return false
- end
- -- Continue with loading the frames
- debug("Loading output files into Aseprite")
- -- First scan for all frames to find max dimensions for canvas
- local maxWidth, maxHeight = 0, 0
- local frameIndex = 0
- while true do
- local framePath = actualOutputBase .. "_" .. frameIndex .. ".png"
- if not app.fs.isFile(framePath) then break end
- local image = Image{fromFile=framePath}
- maxWidth = math.max(maxWidth, image.width)
- maxHeight = math.max(maxHeight, image.height)
- frameIndex = frameIndex + 1
- end
- debug("Maximum dimensions across all frames: " .. maxWidth .. "x" .. maxHeight)
- -- Now load first frame
- local firstFrame = actualOutputBase .. "_0.png"
- local firstMeta = actualOutputBase .. "_0.meta"
- if not app.fs.isFile(firstFrame) then
- showError("First frame not found: " .. firstFrame)
- return false
- end
- -- Open the first image as a sprite
- debug("Opening first frame: " .. firstFrame)
- -- Disable animation detection before opening file
- disableAnimationDetection()
- -- Open the file normally
- local sprite = app.open(firstFrame)
- -- Restore original settings
- restoreAnimationDetection()
- if not sprite then
- showError("Failed to open first frame")
- return false
- end
- -- RESIZE TO MAXIMUM DIMENSIONS - add this block
- if sprite.width < maxWidth or sprite.height < maxHeight then
- debug("Resizing sprite to maximum dimensions: " .. maxWidth .. "x" .. maxHeight)
- -- Calculate center offsets to keep the content centered
- local offsetX = math.floor((maxWidth - sprite.width) / 2)
- local offsetY = math.floor((maxHeight - sprite.height) / 2)
- -- Resize the sprite with calculated offsets
- sprite:resize(maxWidth, maxHeight, offsetX, offsetY)
- end
- -- Rename the first layer to indicate it's frame 1
- local baseLayer = sprite.layers[1]
- baseLayer.name = "Frame 1"
- -- Set pivot from metadata for first frame
- if app.fs.isFile(firstMeta) then
- local meta = io.open(firstMeta, "r")
- if meta then
- local offsetX, offsetY = 0, 0
- for line in meta:lines() do
- local key, value = line:match("(.+)=(.+)")
- if key == "offset_x" then offsetX = tonumber(value) end
- if key == "offset_y" then offsetY = tonumber(value) end
- end
- meta:close()
- -- Store offset data in the layer's user data
- setLayerOffsetData(baseLayer, offsetX, offsetY)
- end
- end
- -- Now add additional frames as layers
- local frameIndex = 1
- while true do
- local framePath = actualOutputBase .. "_" .. frameIndex .. ".png"
- local metaPath = actualOutputBase .. "_" .. frameIndex .. ".meta"
- if not app.fs.isFile(framePath) then
- debug("No more frames at index " .. frameIndex)
- break
- end
- debug("Adding frame " .. frameIndex .. " as layer")
- -- Load the image
- local frameImage = Image{fromFile=framePath}
- -- Add new layer (instead of frame)
- local newLayer = sprite:newLayer()
- newLayer.name = "Frame " .. (frameIndex + 1) -- 1-based naming
- -- Create new cel with this image (in the first frame)
- local cel = sprite:newCel(newLayer, 1, frameImage, Point(0,0))
- -- Load and set offset data from metadata
- if app.fs.isFile(metaPath) then
- local meta = io.open(metaPath, "r")
- if meta then
- local offsetX, offsetY = 0, 0
- for line in meta:lines() do
- local key, value = line:match("(.+)=(.+)")
- if key == "offset_x" then offsetX = tonumber(value) end
- if key == "offset_y" then offsetY = tonumber(value) end
- end
- meta:close()
- -- Store offset in layer user data
- setLayerOffsetData(newLayer, offsetX, offsetY)
- debug("Stored offset data for layer " .. newLayer.name .. ": " .. offsetX .. "," .. offsetY)
- end
- end
- frameIndex = frameIndex + 1
- end
- return true, sprite
- end
- -- Add this helper function to check for offset tags
- function spriteHasOffsetTags(sprite)
- if not sprite or not sprite.tags then return false end
- for _, tag in ipairs(sprite.tags) do
- -- Check for any tag starting with "offset_"
- if tag.name:match("^offset_") then
- return true
- end
- end
- return false
- end
- -- Replace the exportSHP function with this improved version:
- function exportSHP()
- -- Get active sprite
- local sprite = app.activeSprite
- if not sprite then
- showError("No active sprite to export")
- return
- end
- -- Check if sprite uses indexed color mode
- if sprite.colorMode ~= ColorMode.INDEXED then
- showError("SHP format needs an indexed palette. Convert your sprite to Indexed color mode first.")
- return
- end
- -- Default offset values for fallback
- local defaultOffsetX = math.floor(sprite.width / 2)
- local defaultOffsetY = math.floor(sprite.height / 2)
- -- Count how many layers already have offset data
- local layersWithOffsets = 0
- for _, layer in ipairs(sprite.layers) do
- if getLayerOffsetData(layer) then
- layersWithOffsets = layersWithOffsets + 1
- end
- end
- -- Show initial export dialog - simplified without offset fields
- local dlg = Dialog("Export SHP File")
- dlg:file{
- id="outFile",
- label="Output SHP File:",
- save=true,
- filetypes={"shp"},
- focus=true
- }
- -- Show informational text about layer offsets
- if layersWithOffsets == #sprite.layers then
- dlg:label{
- id="allOffsetsSet",
- text="All layers have offset data. Ready to export."
- }
- elseif layersWithOffsets > 0 then
- dlg:label{
- id="someOffsetsSet",
- text=layersWithOffsets .. " of " .. #sprite.layers .. " layers have offset data. You'll be prompted for the rest."
- }
- else
- dlg:label{
- id="noOffsets",
- text="No layers have offset data. You'll be prompted to set offsets for each layer."
- }
- end
- -- Add option to edit existing offsets
- dlg:check{
- id="editExisting",
- text="Edit existing offsets",
- selected=false
- }
- -- Store dialog result in outer scope
- local dialogResult = false
- local exportSettings = {}
- dlg:button{
- id="export",
- text="Export",
- onclick=function()
- dialogResult = true
- exportSettings.outFile = dlg.data.outFile
- exportSettings.editExisting = dlg.data.editExisting
- dlg:close()
- end
- }
- dlg:button{
- id="cancel",
- text="Cancel",
- onclick=function()
- dialogResult = false
- dlg:close()
- end
- }
- -- Show dialog
- dlg:show()
- -- Handle result
- if not dialogResult then return end
- if not exportSettings.outFile or exportSettings.outFile == "" then
- showError("Please specify an output SHP file")
- return
- end
- if not converterExists then
- showError("SHP converter not found at: " .. converterPath)
- return
- end
- -- Create temp directory for files
- local tempDir = app.fs.joinPath(app.fs.tempPath, "exult-shp-" .. os.time())
- app.fs.makeDirectory(tempDir)
- -- Prepare file paths
- local metaPath = app.fs.joinPath(tempDir, "metadata.txt")
- local basePath = app.fs.joinPath(tempDir, "frame")
- -- Create metadata file
- local meta = io.open(metaPath, "w")
- meta:write(string.format("num_frames=%d\n", #sprite.layers))
- -- Track frame offsets - the index in this array is the export frame number
- local layerOffsets = {}
- -- First pass: Check which layers need offset prompts
- for i, layer in ipairs(sprite.layers) do
- local offsetNeeded = true
- -- Check for existing offset data
- local offsetData = getLayerOffsetData(layer)
- if offsetData and not exportSettings.editExisting then
- layerOffsets[i] = {
- x = offsetData.offsetX,
- y = offsetData.offsetY,
- fromData = true
- }
- offsetNeeded = false
- debug("Using existing offset for layer " .. i .. ": " .. offsetData.offsetX .. "," .. offsetData.offsetY)
- end
- -- Mark for prompting if still needed
- if offsetNeeded then
- layerOffsets[i] = {
- needsPrompt = true
- }
- end
- end
- -- Second pass: Prompt for missing offsets
- for i, layerData in ipairs(layerOffsets) do
- if layerData.needsPrompt then
- -- Make this layer visible and others invisible for visual reference
- for j, otherLayer in ipairs(sprite.layers) do
- otherLayer.isVisible = (j == i)
- end
- -- Create prompt dialog for this specific layer
- local layerDlg = Dialog("Layer Offset")
- -- Get cleaner name (without offset info)
- local cleanName = sprite.layers[i].name:gsub(" %[.*%]$", "")
- layerDlg:label{
- id="info",
- text="Set offset for " .. cleanName .. " (" .. i .. " of " .. #sprite.layers .. ")"
- }
- -- If we have existing data, use it as default
- local existingData = getLayerOffsetData(sprite.layers[i])
- local initialX = defaultOffsetX
- local initialY = defaultOffsetY
- if existingData then
- initialX = existingData.offsetX
- initialY = existingData.offsetY
- end
- layerDlg:number{
- id="offsetX",
- label="Offset X:",
- text=tostring(initialX),
- decimals=0
- }
- layerDlg:number{
- id="offsetY",
- label="Offset Y:",
- text=tostring(initialY),
- decimals=0
- }
- local layerResult = false
- layerDlg:button{
- id="ok",
- text="OK",
- onclick=function()
- layerResult = true
- layerOffsets[i] = {
- x = layerDlg.data.offsetX,
- y = layerDlg.data.offsetY
- }
- layerDlg:close()
- end
- }
- -- Show dialog and wait for result
- layerDlg:show{wait=true}
- -- If user cancelled, use defaults or existing data
- if not layerResult then
- if existingData then
- layerOffsets[i] = {
- x = existingData.offsetX,
- y = existingData.offsetY
- }
- else
- layerOffsets[i] = {
- x = defaultOffsetX,
- y = defaultOffsetY
- }
- end
- end
- -- Store the value in the layer for future use
- setLayerOffsetData(sprite.layers[i], layerOffsets[i].x, layerOffsets[i].y)
- end
- end
- -- Restore all layers to visible
- for _, layer in ipairs(sprite.layers) do
- layer.isVisible = true
- end
- -- Now export each layer as a frame - with content-based cropping
- for i, layer in ipairs(sprite.layers) do
- local frameIndex = i - 1 -- Convert to 0-based for the SHP file
- local filepath = string.format("%s%d.png", basePath, frameIndex)
- debug("Exporting layer " .. i .. " (" .. layer.name .. ") as frame " .. frameIndex)
- -- Create a temporary copy of the sprite to crop
- local tempSprite = Sprite(sprite)
- -- Make only the target layer visible in the temp sprite
- for j, otherLayer in ipairs(tempSprite.layers) do
- otherLayer.isVisible = (j == i)
- end
- -- Get pre-crop dimensions for offset adjustment
- local originalWidth = tempSprite.width
- local originalHeight = tempSprite.height
- local originalCenterX = originalWidth / 2
- local originalCenterY = originalHeight / 2
- -- Crop to visible content
- app.command.CropSprite {
- ui=false,
- bounds="content",
- trim=true
- }
- -- Get post-crop dimensions for offset adjustment
- local croppedWidth = tempSprite.width
- local croppedHeight = tempSprite.height
- local croppedCenterX = croppedWidth / 2
- local croppedCenterY = croppedHeight / 2
- debug("Cropped from " .. originalWidth .. "x" .. originalHeight .. " to " .. croppedWidth .. "x" .. croppedHeight)
- -- Temporarily disable the file format warning
- local originalAlertPref = false
- if app.preferences and app.preferences.save_file then
- originalAlertPref = app.preferences.save_file.show_file_format_doesnt_support_alert or false
- app.preferences.save_file.show_file_format_doesnt_support_alert = false
- end
- -- Export the cropped sprite
- tempSprite:saveCopyAs(filepath)
- -- Restore original preference
- if app.preferences and app.preferences.save_file then
- app.preferences.save_file.show_file_format_doesnt_support_alert = originalAlertPref
- end
- -- Close temp sprite after saving
- tempSprite:close()
- -- Verify the file was created
- if not app.fs.isFile(filepath) then
- debug("WARNING: Failed to export frame " .. frameIndex .. " to " .. filepath)
- showError("Failed to export frame " .. frameIndex)
- meta:close()
- return
- else
- debug("Successfully exported cropped frame " .. frameIndex .. " to " .. filepath)
- end
- -- Get the offset for this layer
- local offsetX = 0
- local offsetY = 0
- -- Get offsets from layerOffsets table
- if layerOffsets[i] then
- offsetX = layerOffsets[i].x or 0
- offsetY = layerOffsets[i].y or 0
- end
- -- Adjust offsets based on cropping
- -- Original offsets are relative to sprite center, adjust for the new center
- offsetX = offsetX - (originalCenterX - croppedCenterX)
- offsetY = offsetY - (originalCenterY - croppedCenterY)
- debug("Adjusted offsets for layer " .. i .. " after cropping: " .. offsetX .. "," .. offsetY)
- meta:write(string.format("frame%d_offset_x=%d\n", frameIndex, offsetX))
- meta:write(string.format("frame%d_offset_y=%d\n", frameIndex, offsetY))
- end
- -- Close metadata file
- meta:close()
- -- Create and execute the export command
- local cmd = quoteIfNeeded(converterPath) ..
- " export " ..
- quoteIfNeeded(basePath) ..
- " " .. quoteIfNeeded(exportSettings.outFile) ..
- " 0" ..
- " " .. layerOffsets[1].x ..
- " " .. layerOffsets[1].y ..
- " " .. quoteIfNeeded(metaPath)
- debug("Executing: " .. cmd)
- -- Execute command
- local success = executeHidden(cmd)
- if success then
- app.alert("SHP file exported successfully.")
- else
- showError("Failed to export SHP file.")
- end
- end
- function init(plugin)
- debug("Initializing plugin...")
- -- Register file format first
- local formatRegistered = registerSHPFormat()
- debug("SHP format registered: " .. tostring(formatRegistered))
- -- Register commands
- plugin:newCommand{
- id="ImportSHP",
- title="Import SHP...",
- group="file_import",
- onclick=function() importSHP() end
- }
- plugin:newCommand{
- id="ExportSHP",
- title="Export SHP...",
- group="file_export",
- onclick=exportSHP
- }
- -- Register SHP file format for opening
- if formatRegistered then
- plugin:newCommand{
- id="OpenSHP",
- title="Ultima VII SHP",
- group="file_format",
- onclick=importSHP,
- file_format="shp"
- }
- end
- end
- -- Create a global table to store frame offset data across the entire plugin session
- if not _G.exultFrameData then
- _G.exultFrameData = {}
- end
- return { init=init }
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement