-- 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 "") 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 }