-- 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 }
Comments
0 B
|0 👍
/0 👎
0 B
|👍
/👎