Initial commit
30
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "artificers-scrollwork"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "artificers_scrollwork_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
anyhow = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
byteorder = "1"
|
||||
flate2 = "1"
|
||||
image = { version = "0.25", features = ["dds"] }
|
||||
once_cell = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
walkdir = "2"
|
||||
|
||||
[features]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
11
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for Artificer's Scrollwork",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:default",
|
||||
"shell:default"
|
||||
]
|
||||
}
|
||||
1
src-tauri/gen/schemas/acl-manifests.json
Normal file
1
src-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Artificer's Scrollwork","local":true,"windows":["main"],"permissions":["core:default","dialog:default","shell:default"]}}
|
||||
2678
src-tauri/gen/schemas/desktop-schema.json
Normal file
2678
src-tauri/gen/schemas/windows-schema.json
Normal file
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 394 B |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 858 B |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 108 B |
BIN
src-tauri/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
289
src-tauri/src/assets/art.rs
Normal file
@@ -0,0 +1,289 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use byteorder::{LittleEndian, ReadBytesExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::Cursor;
|
||||
use std::path::Path;
|
||||
|
||||
use super::mul_reader;
|
||||
use super::uop_reader;
|
||||
|
||||
const STATIC_OFFSET: usize = 0x4000;
|
||||
|
||||
/// RGBA pixel data for a decoded art image.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ArtImage {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Raw RGBA bytes, row-major.
|
||||
pub pixels: Vec<u8>,
|
||||
}
|
||||
|
||||
// ── BMP artwork folder ────────────────────────────────────────────────────────
|
||||
|
||||
/// Find the "UO artwork" folder, checking resource dir then walking up from the exe.
|
||||
/// In dev mode the exe is deep in target/debug/, so we walk up to the repo root.
|
||||
pub fn find_artwork_dir(app: &tauri::AppHandle) -> Option<std::path::PathBuf> {
|
||||
use tauri::Manager;
|
||||
|
||||
// 1. Try Tauri resource dir (works in production bundles)
|
||||
if let Ok(resource_dir) = app.path().resource_dir() {
|
||||
let candidate = resource_dir.join("UO artwork");
|
||||
if candidate.is_dir() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Walk up from the exe (works in dev mode where resource_dir points at src-tauri/)
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let mut dir = exe.parent().map(|p| p.to_path_buf());
|
||||
for _ in 0..8 {
|
||||
if let Some(ref d) = dir {
|
||||
let candidate = d.join("UO artwork");
|
||||
if candidate.is_dir() {
|
||||
return Some(candidate);
|
||||
}
|
||||
dir = d.parent().map(|p| p.to_path_buf());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Decode a BMP from the "UO artwork" folder by item_id.
|
||||
/// Filename pattern: "Item 0x{id:04X}.bmp"
|
||||
/// Black (0,0,0) pixels are made transparent — UO art exports use black for transparency.
|
||||
pub fn decode_static_bmp(artwork_dir: &Path, item_id: usize) -> Result<ArtImage> {
|
||||
let filename = format!("Item 0x{:04X}.bmp", item_id);
|
||||
let path = artwork_dir.join(&filename);
|
||||
if !path.exists() {
|
||||
return Err(anyhow!("no art data"));
|
||||
}
|
||||
decode_bmp_file(&path)
|
||||
}
|
||||
|
||||
fn decode_bmp_file(path: &Path) -> Result<ArtImage> {
|
||||
let img = image::open(path)
|
||||
.map_err(|e| anyhow!("BMP decode: {}", e))?;
|
||||
let mut rgba = img.to_rgba8();
|
||||
// UO art BMP exports use pure black as the transparent colour.
|
||||
for pixel in rgba.pixels_mut() {
|
||||
if pixel[0] == 0 && pixel[1] == 0 && pixel[2] == 0 {
|
||||
pixel[3] = 0;
|
||||
}
|
||||
}
|
||||
Ok(ArtImage {
|
||||
width: rgba.width(),
|
||||
height: rgba.height(),
|
||||
pixels: rgba.into_raw(),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Public decode entry points ────────────────────────────────────────────────
|
||||
|
||||
/// Try every known art source for `item_id` in priority order.
|
||||
/// Returns the first that succeeds, or Err("no art data") if none.
|
||||
pub fn decode_static_any(
|
||||
uo_root: &Path,
|
||||
item_id: usize,
|
||||
) -> Result<ArtImage> {
|
||||
let mul_path = uo_root.join("art.mul");
|
||||
let idx_path = uo_root.join("artidx.mul");
|
||||
let uop_path = uo_root.join("artLegacyMUL.uop");
|
||||
let tileart = uo_root.join("tileart.uop");
|
||||
|
||||
// 1. Classic art.mul
|
||||
if mul_path.exists() && idx_path.exists() {
|
||||
if let Ok(img) = decode_static(&mul_path, &idx_path, item_id) {
|
||||
return Ok(img);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. artLegacyMUL.uop (Classic UOP packaging)
|
||||
if uop_path.exists() {
|
||||
if let Ok(img) = decode_static_uop(&uop_path, item_id) {
|
||||
return Ok(img);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. tileart.uop (Enhanced Client) — tries multiple hash patterns
|
||||
if tileart.exists() {
|
||||
if let Ok(img) = decode_static_tileart_uop(&tileart, item_id) {
|
||||
return Ok(img);
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("no art data"))
|
||||
}
|
||||
|
||||
/// Decode from art.mul + artidx.mul (Classic Client).
|
||||
pub fn decode_static(mul_path: &Path, idx_path: &Path, item_id: usize) -> Result<ArtImage> {
|
||||
let entries = mul_reader::read_index(idx_path)?;
|
||||
let index = STATIC_OFFSET + item_id;
|
||||
|
||||
let entry = entries
|
||||
.get(index)
|
||||
.ok_or_else(|| anyhow!("no art data"))?;
|
||||
|
||||
let raw = mul_reader::read_entry(mul_path, entry)?
|
||||
.ok_or_else(|| anyhow!("no art data"))?;
|
||||
|
||||
decode_art_data(&raw)
|
||||
}
|
||||
|
||||
/// Decode from artLegacyMUL.uop.
|
||||
/// Hash pattern: "build/artlegacymul/{art_index:08}.tga"
|
||||
pub fn decode_static_uop(uop_path: &Path, item_id: usize) -> Result<ArtImage> {
|
||||
let art_index = STATIC_OFFSET + item_id;
|
||||
let pattern = format!("build/artlegacymul/{:08}.tga", art_index);
|
||||
let hash = uop_reader::uop_hash(&pattern);
|
||||
|
||||
let index = uop_reader::read_uop_index(uop_path)?;
|
||||
let entry = index
|
||||
.get(&hash)
|
||||
.ok_or_else(|| anyhow!("no art data"))?
|
||||
.clone();
|
||||
|
||||
let raw = uop_reader::read_uop_entry(uop_path, &entry)?;
|
||||
decode_art_data(&raw)
|
||||
}
|
||||
|
||||
/// Decode from tileart.uop (Enhanced Client).
|
||||
/// Tries several hash patterns; the data can be DDS or classic 16-bit art.
|
||||
pub fn decode_static_tileart_uop(uop_path: &Path, item_id: usize) -> Result<ArtImage> {
|
||||
let index = uop_reader::read_uop_index(uop_path)?;
|
||||
|
||||
// Try every plausible hash pattern the EC might use.
|
||||
let candidates = [
|
||||
format!("build/tileart/{:08}.bin", item_id + STATIC_OFFSET),
|
||||
format!("build/tileart/{:08}.bin", item_id),
|
||||
format!("build/tileart/{:08x}.bin", item_id + STATIC_OFFSET),
|
||||
format!("build/tileart/{:08x}.bin", item_id),
|
||||
format!("build/tileartlegacy/{:08}.bin", item_id + STATIC_OFFSET),
|
||||
format!("build/tileartlegacy/{:08}.bin", item_id),
|
||||
format!("build/worldart/{:08}.dds", item_id + STATIC_OFFSET),
|
||||
format!("build/worldart/{:08}.dds", item_id),
|
||||
];
|
||||
|
||||
for pattern in &candidates {
|
||||
let hash = uop_reader::uop_hash(pattern);
|
||||
if let Some(entry) = index.get(&hash) {
|
||||
let raw = uop_reader::read_uop_entry(uop_path, entry)?;
|
||||
if raw.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// DDS magic: "DDS " = 0x44 44 53 20
|
||||
if raw.starts_with(b"DDS ") {
|
||||
if let Ok(img) = decode_dds(&raw) {
|
||||
return Ok(img);
|
||||
}
|
||||
}
|
||||
// Try classic art format
|
||||
if let Ok(img) = decode_art_data(&raw) {
|
||||
return Ok(img);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("no art data"))
|
||||
}
|
||||
|
||||
// ── Format decoders ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Decode a DDS file to RGBA using the `image` crate.
|
||||
fn decode_dds(data: &[u8]) -> Result<ArtImage> {
|
||||
let cursor = Cursor::new(data);
|
||||
let img = image::load(cursor, image::ImageFormat::Dds)
|
||||
.map_err(|e| anyhow!("DDS decode: {}", e))?;
|
||||
let rgba = img.to_rgba8();
|
||||
Ok(ArtImage {
|
||||
width: rgba.width(),
|
||||
height: rgba.height(),
|
||||
pixels: rgba.into_raw(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Decode raw art.mul / UOP art data (16-bit 1-5-5-5 ARGB, run-length encoded).
|
||||
pub fn decode_art_data(data: &[u8]) -> Result<ArtImage> {
|
||||
// Minimum: 2 unknown + 2 width + 2 height = 6 bytes
|
||||
if data.len() < 6 {
|
||||
return Err(anyhow!("no art data"));
|
||||
}
|
||||
|
||||
let mut cursor = Cursor::new(data);
|
||||
let _unknown = cursor.read_u16::<LittleEndian>()?;
|
||||
let width = cursor.read_u16::<LittleEndian>()? as u32;
|
||||
let height = cursor.read_u16::<LittleEndian>()? as u32;
|
||||
|
||||
if width == 0 || height == 0 || width > 1024 || height > 1024 {
|
||||
return Err(anyhow!("no art data"));
|
||||
}
|
||||
|
||||
// Row lookup table: `height` u16 offsets. Must all fit in the buffer.
|
||||
let row_table_bytes = height as usize * 2;
|
||||
if data.len() < 6 + row_table_bytes {
|
||||
return Err(anyhow!("no art data"));
|
||||
}
|
||||
|
||||
let mut row_offsets = vec![0u32; height as usize];
|
||||
for o in row_offsets.iter_mut() {
|
||||
*o = cursor.read_u16::<LittleEndian>()? as u32;
|
||||
}
|
||||
let data_start = cursor.position() as usize;
|
||||
|
||||
let mut pixels = vec![0u8; (width * height * 4) as usize];
|
||||
|
||||
for y in 0..height as usize {
|
||||
let row_byte_offset = row_offsets[y] as usize * 2;
|
||||
let mut x = 0usize;
|
||||
let mut pos = data_start + row_byte_offset;
|
||||
|
||||
loop {
|
||||
// Need 4 bytes: 2 for run, 2 for count
|
||||
if pos + 4 > data.len() {
|
||||
break;
|
||||
}
|
||||
let run = u16::from_le_bytes([data[pos], data[pos + 1]]);
|
||||
let count = u16::from_le_bytes([data[pos + 2], data[pos + 3]]);
|
||||
pos += 4;
|
||||
|
||||
if run == 0 && count == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
x += run as usize;
|
||||
|
||||
for _ in 0..count as usize {
|
||||
if pos + 2 > data.len() || x >= width as usize {
|
||||
break;
|
||||
}
|
||||
let color = u16::from_le_bytes([data[pos], data[pos + 1]]);
|
||||
pos += 2;
|
||||
let rgba = argb16_to_rgba32(color);
|
||||
let idx = (y * width as usize + x) * 4;
|
||||
pixels[idx..idx + 4].copy_from_slice(&rgba);
|
||||
x += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ArtImage { width, height, pixels })
|
||||
}
|
||||
|
||||
/// Convert 16-bit UO ARGB (1-5-5-5) to 32-bit RGBA.
|
||||
pub fn argb16_to_rgba32(color: u16) -> [u8; 4] {
|
||||
if color == 0 {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
let r = ((color >> 10) & 0x1F) as u8;
|
||||
let g = ((color >> 5) & 0x1F) as u8;
|
||||
let b = ( color & 0x1F) as u8;
|
||||
[
|
||||
(r << 3) | (r >> 2),
|
||||
(g << 3) | (g >> 2),
|
||||
(b << 3) | (b >> 2),
|
||||
255,
|
||||
]
|
||||
}
|
||||
34
src-tauri/src/assets/cliloc.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use anyhow::Result;
|
||||
use byteorder::{LittleEndian, ReadBytesExt};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufReader, Read};
|
||||
use std::path::Path;
|
||||
|
||||
/// Read cliloc.enu and return a map from string ID to localized string.
|
||||
pub fn read_cliloc(path: &Path) -> Result<HashMap<u32, String>> {
|
||||
let file = std::fs::File::open(path)?;
|
||||
let mut reader = BufReader::new(file);
|
||||
|
||||
// Header: u32 unknown, u16 unknown
|
||||
let _header1 = reader.read_u32::<LittleEndian>()?;
|
||||
let _header2 = reader.read_u16::<LittleEndian>()?;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
|
||||
loop {
|
||||
let id = match reader.read_u32::<LittleEndian>() {
|
||||
Ok(v) => v,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let _flag = reader.read_u8()?;
|
||||
let length = reader.read_u16::<LittleEndian>()?;
|
||||
|
||||
let mut text_buf = vec![0u8; length as usize];
|
||||
reader.read_exact(&mut text_buf)?;
|
||||
let text = String::from_utf8_lossy(&text_buf).to_string();
|
||||
map.insert(id, text);
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
172
src-tauri/src/assets/gumpart.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use byteorder::{LittleEndian, ReadBytesExt};
|
||||
use std::io::Cursor;
|
||||
use std::path::Path;
|
||||
|
||||
use super::art::ArtImage;
|
||||
use super::art::argb16_to_rgba32;
|
||||
use super::mul_reader;
|
||||
use super::uop_reader;
|
||||
|
||||
// ── Gump BMP folder ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Collect all candidate "UO Gumps" directories: resource_dir + walk-up from exe.
|
||||
fn gump_dir_candidates(app: &tauri::AppHandle) -> Vec<std::path::PathBuf> {
|
||||
use tauri::Manager;
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
if let Ok(resource_dir) = app.path().resource_dir() {
|
||||
let c = resource_dir.join("UO Gumps");
|
||||
if c.is_dir() { candidates.push(c); }
|
||||
}
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let mut dir = exe.parent().map(|p| p.to_path_buf());
|
||||
for _ in 0..10 {
|
||||
if let Some(ref d) = dir {
|
||||
let c = d.join("UO Gumps");
|
||||
if c.is_dir() && !candidates.contains(&c) { candidates.push(c); }
|
||||
dir = d.parent().map(|p| p.to_path_buf());
|
||||
} else { break; }
|
||||
}
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
/// Find the "UO Gumps" folder containing gumps.xml.
|
||||
pub fn find_gump_dir(app: &tauri::AppHandle) -> Option<std::path::PathBuf> {
|
||||
// Prefer any candidate that has gumps.xml
|
||||
let candidates = gump_dir_candidates(app);
|
||||
candidates.into_iter().find(|c| c.join("gumps.xml").exists())
|
||||
}
|
||||
|
||||
/// Find the "UO Gumps" folder containing script_gumps.xml.
|
||||
/// Falls back to any UO Gumps dir if the file isn't found anywhere.
|
||||
pub fn find_gump_dir_for_scripts(app: &tauri::AppHandle) -> Option<std::path::PathBuf> {
|
||||
let candidates = gump_dir_candidates(app);
|
||||
// Prefer a candidate that actually has script_gumps.xml
|
||||
if let Some(hit) = candidates.iter().find(|c| c.join("script_gumps.xml").exists()) {
|
||||
return Some(hit.clone());
|
||||
}
|
||||
// Fallback: return first candidate at all
|
||||
candidates.into_iter().next()
|
||||
}
|
||||
|
||||
/// Decode a BMP from the "UO Gumps" folder by gump_id.
|
||||
/// Filename pattern: "Gump 0x{id:04X}.bmp"
|
||||
/// Black (0,0,0) pixels are made transparent.
|
||||
pub fn decode_gump_bmp(gump_dir: &Path, gump_id: usize) -> Result<ArtImage> {
|
||||
let filename = format!("Gump 0x{:04X}.bmp", gump_id);
|
||||
let path = gump_dir.join(&filename);
|
||||
if !path.exists() {
|
||||
return Err(anyhow!("no gump data"));
|
||||
}
|
||||
decode_gump_bmp_file(&path)
|
||||
}
|
||||
|
||||
fn decode_gump_bmp_file(path: &Path) -> Result<ArtImage> {
|
||||
let img = image::open(path)
|
||||
.map_err(|e| anyhow!("BMP decode: {}", e))?;
|
||||
let mut rgba = img.to_rgba8();
|
||||
for pixel in rgba.pixels_mut() {
|
||||
if pixel[0] == 0 && pixel[1] == 0 && pixel[2] == 0 {
|
||||
pixel[3] = 0;
|
||||
}
|
||||
}
|
||||
Ok(ArtImage {
|
||||
width: rgba.width(),
|
||||
height: rgba.height(),
|
||||
pixels: rgba.into_raw(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Decode a gump image by ID from gumpart.mul.
|
||||
pub fn decode_gump(mul_path: &Path, idx_path: &Path, gump_id: usize) -> Result<ArtImage> {
|
||||
let entries = mul_reader::read_index(idx_path)?;
|
||||
|
||||
let entry = entries
|
||||
.get(gump_id)
|
||||
.ok_or_else(|| anyhow!("Gump ID {} out of range", gump_id))?;
|
||||
|
||||
let raw = mul_reader::read_entry(mul_path, entry)?
|
||||
.ok_or_else(|| anyhow!("Gump ID {} has no data", gump_id))?;
|
||||
|
||||
// Entry extra field: width in high 16 bits, height in low 16 bits
|
||||
let width = ((entry.extra >> 16) & 0xFFFF) as u32;
|
||||
let height = (entry.extra & 0xFFFF) as u32;
|
||||
|
||||
decode_gump_data(&raw, width, height)
|
||||
}
|
||||
|
||||
/// Decode a gump image from gumpartLegacyMUL.uop (Enhanced Client).
|
||||
/// Hash pattern: "build/gumpartlegacymul/{id:08}.tga"
|
||||
/// Width and height are encoded in the UOP entry's decompressed data header.
|
||||
pub fn decode_gump_uop(uop_path: &Path, gump_id: usize) -> Result<ArtImage> {
|
||||
let pattern = format!("build/gumpartlegacymul/{:08}.tga", gump_id);
|
||||
let hash = uop_reader::uop_hash(&pattern);
|
||||
|
||||
let index = uop_reader::read_uop_index(uop_path)?;
|
||||
let entry = index
|
||||
.get(&hash)
|
||||
.ok_or_else(|| anyhow!("Gump ID {} not found in gumpartLegacyMUL.uop", gump_id))?
|
||||
.clone();
|
||||
|
||||
let raw = uop_reader::read_uop_entry(uop_path, &entry)?;
|
||||
|
||||
// In the UOP format the width/height are encoded in the extra field of the
|
||||
// index, but in gumpartLegacyMUL.uop they are stored as the first 8 bytes
|
||||
// of the decompressed data: u32 width, u32 height.
|
||||
if raw.len() < 8 {
|
||||
return Err(anyhow!("Gump UOP entry too small: {} bytes", raw.len()));
|
||||
}
|
||||
let width = u32::from_le_bytes([raw[0], raw[1], raw[2], raw[3]]);
|
||||
let height = u32::from_le_bytes([raw[4], raw[5], raw[6], raw[7]]);
|
||||
decode_gump_data(&raw[8..], width, height)
|
||||
}
|
||||
|
||||
fn decode_gump_data(data: &[u8], width: u32, height: u32) -> Result<ArtImage> {
|
||||
if width == 0 || height == 0 {
|
||||
return Err(anyhow!("Invalid gump dimensions: {}x{}", width, height));
|
||||
}
|
||||
|
||||
let mut cursor = Cursor::new(data);
|
||||
let mut pixels = vec![0u8; (width * height * 4) as usize];
|
||||
|
||||
// Row lookup: one u32 per row, offset in u32 units from start of data
|
||||
let mut row_offsets = vec![0u32; height as usize];
|
||||
for o in row_offsets.iter_mut() {
|
||||
*o = cursor.read_u32::<LittleEndian>()?;
|
||||
}
|
||||
|
||||
for y in 0..height as usize {
|
||||
let pos = (row_offsets[y] as usize) * 4;
|
||||
let mut x = 0usize;
|
||||
let mut p = pos;
|
||||
|
||||
loop {
|
||||
if p + 4 > data.len() {
|
||||
break;
|
||||
}
|
||||
let color = u16::from_le_bytes([data[p], data[p + 1]]);
|
||||
let count = u16::from_le_bytes([data[p + 2], data[p + 3]]);
|
||||
p += 4;
|
||||
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let rgba = argb16_to_rgba32(color);
|
||||
for _ in 0..count as usize {
|
||||
if x >= width as usize {
|
||||
break;
|
||||
}
|
||||
let idx = (y * width as usize + x) * 4;
|
||||
pixels[idx..idx + 4].copy_from_slice(&rgba);
|
||||
x += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ArtImage { width, height, pixels })
|
||||
}
|
||||
60
src-tauri/src/assets/hues.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use anyhow::Result;
|
||||
use byteorder::{LittleEndian, ReadBytesExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{Cursor, Read};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HueEntry {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
/// 32 colors as RGBA u32 values.
|
||||
pub colors: Vec<u32>,
|
||||
}
|
||||
|
||||
/// Read all hues from hues.mul.
|
||||
/// Format: 375 blocks × 8 hues = 3000 hues.
|
||||
/// Each hue: 32×u16 colors + u16 tableStart + u16 tableEnd + 20-byte name.
|
||||
pub fn read_hues(hues_path: &Path) -> Result<Vec<HueEntry>> {
|
||||
let data = std::fs::read(hues_path)?;
|
||||
let mut cursor = Cursor::new(&data);
|
||||
let mut hues = Vec::new();
|
||||
|
||||
for block in 0..375u32 {
|
||||
// Block header (4 bytes, skip)
|
||||
let _header = cursor.read_u32::<LittleEndian>()?;
|
||||
|
||||
for h in 0..8u32 {
|
||||
let id = block * 8 + h;
|
||||
let mut raw_colors = [0u16; 32];
|
||||
for c in raw_colors.iter_mut() {
|
||||
*c = cursor.read_u16::<LittleEndian>()?;
|
||||
}
|
||||
let _table_start = cursor.read_u16::<LittleEndian>()?;
|
||||
let _table_end = cursor.read_u16::<LittleEndian>()?;
|
||||
|
||||
let mut name_buf = [0u8; 20];
|
||||
cursor.read_exact(&mut name_buf)?;
|
||||
let name = String::from_utf8_lossy(&name_buf)
|
||||
.trim_end_matches('\0')
|
||||
.to_string();
|
||||
|
||||
let colors = raw_colors
|
||||
.iter()
|
||||
.map(|&c| {
|
||||
let r = ((c >> 10) & 0x1F) as u32;
|
||||
let g = ((c >> 5) & 0x1F) as u32;
|
||||
let b = (c & 0x1F) as u32;
|
||||
let r = (r << 3) | (r >> 2);
|
||||
let g = (g << 3) | (g >> 2);
|
||||
let b = (b << 3) | (b >> 2);
|
||||
(r << 24) | (g << 16) | (b << 8) | 0xFF
|
||||
})
|
||||
.collect();
|
||||
|
||||
hues.push(HueEntry { id, name, colors });
|
||||
}
|
||||
}
|
||||
|
||||
Ok(hues)
|
||||
}
|
||||
7
src-tauri/src/assets/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod art;
|
||||
pub mod cliloc;
|
||||
pub mod gumpart;
|
||||
pub mod hues;
|
||||
pub mod mul_reader;
|
||||
pub mod tiledata;
|
||||
pub mod uop_reader;
|
||||
75
src-tauri/src/assets/mul_reader.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use byteorder::{LittleEndian, ReadBytesExt};
|
||||
use std::fs::File;
|
||||
use std::io::{BufReader, Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
|
||||
/// A single entry from a .idx file.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct IndexEntry {
|
||||
pub offset: i32,
|
||||
pub length: i32,
|
||||
pub extra: i32,
|
||||
}
|
||||
|
||||
/// Read all index entries from a .idx file.
|
||||
pub fn read_index(idx_path: &Path) -> Result<Vec<IndexEntry>> {
|
||||
let file = File::open(idx_path)?;
|
||||
let mut reader = BufReader::new(file);
|
||||
let mut entries = Vec::new();
|
||||
|
||||
loop {
|
||||
let offset = match reader.read_i32::<LittleEndian>() {
|
||||
Ok(v) => v,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let length = reader.read_i32::<LittleEndian>()?;
|
||||
let extra = reader.read_i32::<LittleEndian>()?;
|
||||
entries.push(IndexEntry { offset, length, extra });
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Read the raw bytes for a given index entry from the .mul data file.
|
||||
/// Returns `None` if the entry is marked as non-existent or invalid.
|
||||
pub fn read_entry(mul_path: &Path, entry: &IndexEntry) -> Result<Option<Vec<u8>>> {
|
||||
// offset < 0 or length <= 0 means "no entry" per UO convention.
|
||||
// Also guard against absurdly large lengths (corrupt/sparse entries).
|
||||
if entry.offset < 0 || entry.length <= 0 || entry.length > 0x100_0000 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let file_len = std::fs::metadata(mul_path)?.len();
|
||||
let start = entry.offset as u64;
|
||||
let len = entry.length as usize;
|
||||
|
||||
// Entry extends past EOF — treat as empty rather than hard error.
|
||||
if start >= file_len || start + len as u64 > file_len {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut file = File::open(mul_path)?;
|
||||
file.seek(SeekFrom::Start(start))?;
|
||||
|
||||
let mut buf = vec![0u8; len];
|
||||
file.read_exact(&mut buf)?;
|
||||
Ok(Some(buf))
|
||||
}
|
||||
|
||||
/// Convenience: open a mul/idx pair and return the index entries.
|
||||
pub fn open_mul_pair(root: &Path, base_name: &str) -> Result<(Vec<IndexEntry>, std::path::PathBuf)> {
|
||||
let idx_path = root.join(format!("{}idx.mul", base_name));
|
||||
let mul_path = root.join(format!("{}.mul", base_name));
|
||||
|
||||
if !idx_path.exists() {
|
||||
return Err(anyhow!("Index file not found: {:?}", idx_path));
|
||||
}
|
||||
if !mul_path.exists() {
|
||||
return Err(anyhow!("Data file not found: {:?}", mul_path));
|
||||
}
|
||||
|
||||
let entries = read_index(&idx_path)?;
|
||||
Ok((entries, mul_path))
|
||||
}
|
||||
83
src-tauri/src/assets/tiledata.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use anyhow::Result;
|
||||
use byteorder::{LittleEndian, ReadBytesExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{Cursor, Read};
|
||||
use std::path::Path;
|
||||
|
||||
/// Item tile metadata from tiledata.mul.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ItemTileData {
|
||||
pub id: u32,
|
||||
pub flags: u64,
|
||||
pub weight: u8,
|
||||
pub quality: u8,
|
||||
pub anim_id: u16,
|
||||
pub height: u8,
|
||||
pub hue: u8,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Read all item tile entries from tiledata.mul.
|
||||
/// Land tiles (first 428 blocks × 32 entries) are skipped — we only care about items.
|
||||
pub fn read_item_tiles(tiledata_path: &Path) -> Result<Vec<ItemTileData>> {
|
||||
let data = std::fs::read(tiledata_path)?;
|
||||
let mut cursor = Cursor::new(&data);
|
||||
|
||||
// Skip land tiles: 512 blocks of 32 land entries
|
||||
// Each land block: 4-byte header + 32 × (8 flags + 2 texid + 20 name) = 4 + 32*30 = 964 bytes
|
||||
// Actually: 4 + 32*(4+2+20) = 4 + 832 = 836 for old format; new (HS+) uses 8-byte flags
|
||||
// Use the newer format (64-bit flags) as ServUO targets it.
|
||||
// Land block size: 4 (header) + 32 * (8 flags + 2 texid + 20 name) = 4 + 960 = 964
|
||||
let land_block_size: u64 = 4 + 32 * (8 + 2 + 20);
|
||||
let land_blocks: u64 = 512;
|
||||
cursor.set_position(land_blocks * land_block_size);
|
||||
|
||||
let mut items = Vec::new();
|
||||
let mut id = 0u32;
|
||||
|
||||
while (cursor.position() as usize) < data.len() {
|
||||
// Block header (4 bytes, skip)
|
||||
let mut header = [0u8; 4];
|
||||
if cursor.read_exact(&mut header).is_err() {
|
||||
break;
|
||||
}
|
||||
|
||||
for _ in 0..32 {
|
||||
if (cursor.position() as usize) >= data.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let flags = cursor.read_u64::<LittleEndian>()?;
|
||||
let weight = cursor.read_u8()?;
|
||||
let quality = cursor.read_u8()?;
|
||||
let _unknown1 = cursor.read_u16::<LittleEndian>()?;
|
||||
let _unknown2 = cursor.read_u8()?;
|
||||
let _quantity = cursor.read_u8()?;
|
||||
let anim_id = cursor.read_u16::<LittleEndian>()?;
|
||||
let _unknown3 = cursor.read_u8()?;
|
||||
let hue = cursor.read_u8()?;
|
||||
let _unknown4 = cursor.read_u16::<LittleEndian>()?;
|
||||
let height = cursor.read_u8()?;
|
||||
|
||||
let mut name_buf = [0u8; 20];
|
||||
cursor.read_exact(&mut name_buf)?;
|
||||
let name = String::from_utf8_lossy(&name_buf)
|
||||
.trim_end_matches('\0')
|
||||
.to_string();
|
||||
|
||||
items.push(ItemTileData {
|
||||
id,
|
||||
flags,
|
||||
weight,
|
||||
quality,
|
||||
anim_id,
|
||||
height,
|
||||
hue,
|
||||
name,
|
||||
});
|
||||
id += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
187
src-tauri/src/assets/uop_reader.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use byteorder::{LittleEndian, ReadBytesExt};
|
||||
use flate2::read::ZlibDecoder;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::{BufReader, Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
|
||||
const UOP_MAGIC: u32 = 0x0050594D;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UopEntry {
|
||||
pub data_offset: u64,
|
||||
pub header_length: u32,
|
||||
pub compressed_length: u32,
|
||||
pub decompressed_length: u32,
|
||||
pub hash: u64,
|
||||
pub compression: u16,
|
||||
}
|
||||
|
||||
/// Read the UOP file and return a map from hash → UopEntry.
|
||||
pub fn read_uop_index(path: &Path) -> Result<HashMap<u64, UopEntry>> {
|
||||
let file = File::open(path)?;
|
||||
let mut reader = BufReader::new(file);
|
||||
|
||||
// Header
|
||||
let magic = reader.read_u32::<LittleEndian>()?;
|
||||
if magic != UOP_MAGIC {
|
||||
return Err(anyhow!("Not a valid UOP file: {:?}", path));
|
||||
}
|
||||
|
||||
let _version = reader.read_u32::<LittleEndian>()?;
|
||||
let _signature = reader.read_u32::<LittleEndian>()?;
|
||||
let mut next_block = reader.read_u64::<LittleEndian>()?;
|
||||
let _max_files = reader.read_u32::<LittleEndian>()?;
|
||||
|
||||
// Skip tag (36 bytes)
|
||||
let mut tag_buf = [0u8; 36];
|
||||
reader.read_exact(&mut tag_buf)?;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
|
||||
while next_block != 0 {
|
||||
reader.seek(SeekFrom::Start(next_block))?;
|
||||
next_block = reader.read_u64::<LittleEndian>()?;
|
||||
let file_count = reader.read_u32::<LittleEndian>()?;
|
||||
|
||||
for _ in 0..file_count {
|
||||
let data_offset = reader.read_u64::<LittleEndian>()?;
|
||||
let header_length = reader.read_u32::<LittleEndian>()?;
|
||||
let compressed_length = reader.read_u32::<LittleEndian>()?;
|
||||
let decompressed_length = reader.read_u32::<LittleEndian>()?;
|
||||
let hash = reader.read_u64::<LittleEndian>()?;
|
||||
let _crc = reader.read_u32::<LittleEndian>()?;
|
||||
let compression = reader.read_u16::<LittleEndian>()?;
|
||||
|
||||
if data_offset == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
map.insert(
|
||||
hash,
|
||||
UopEntry {
|
||||
data_offset,
|
||||
header_length,
|
||||
compressed_length,
|
||||
decompressed_length,
|
||||
hash,
|
||||
compression,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Read decompressed data for a UOP entry.
|
||||
pub fn read_uop_entry(path: &Path, entry: &UopEntry) -> Result<Vec<u8>> {
|
||||
let mut file = File::open(path)?;
|
||||
let offset = entry.data_offset + entry.header_length as u64;
|
||||
file.seek(SeekFrom::Start(offset))?;
|
||||
|
||||
let mut compressed = vec![0u8; entry.compressed_length as usize];
|
||||
file.read_exact(&mut compressed)?;
|
||||
|
||||
if entry.compression == 1 {
|
||||
let mut decoder = ZlibDecoder::new(&compressed[..]);
|
||||
let mut decompressed = Vec::with_capacity(entry.decompressed_length as usize);
|
||||
decoder.read_to_end(&mut decompressed)?;
|
||||
Ok(decompressed)
|
||||
} else {
|
||||
Ok(compressed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash function for UOP filenames (per ClassicUO UOFileUop.cs).
|
||||
pub fn uop_hash(s: &str) -> u64 {
|
||||
let s = s.to_lowercase();
|
||||
let bytes = s.as_bytes();
|
||||
|
||||
let (mut eax, mut ebx, mut ecx, mut edx, mut esi, mut edi) = (
|
||||
0u32,
|
||||
bytes.len() as u32,
|
||||
0u32,
|
||||
0u32,
|
||||
0u32,
|
||||
0u32,
|
||||
);
|
||||
|
||||
ecx = 0x9E3779B9u32;
|
||||
edi = ecx;
|
||||
esi = ecx;
|
||||
|
||||
let mut i = 0usize;
|
||||
while i + 12 <= bytes.len() {
|
||||
edi = edi.wrapping_add(
|
||||
(bytes[i] as u32)
|
||||
| ((bytes[i + 1] as u32) << 8)
|
||||
| ((bytes[i + 2] as u32) << 16)
|
||||
| ((bytes[i + 3] as u32) << 24),
|
||||
);
|
||||
esi = esi.wrapping_add(
|
||||
(bytes[i + 4] as u32)
|
||||
| ((bytes[i + 5] as u32) << 8)
|
||||
| ((bytes[i + 6] as u32) << 16)
|
||||
| ((bytes[i + 7] as u32) << 24),
|
||||
);
|
||||
edx = edx
|
||||
.wrapping_add(
|
||||
(bytes[i + 8] as u32)
|
||||
| ((bytes[i + 9] as u32) << 8)
|
||||
| ((bytes[i + 10] as u32) << 16)
|
||||
| ((bytes[i + 11] as u32) << 24),
|
||||
)
|
||||
.wrapping_add(ebx);
|
||||
edx ^= (esi >> 28).wrapping_add(edi << 4);
|
||||
edx = edx.wrapping_add(esi);
|
||||
esi ^= (edx >> 26).wrapping_add(edx << 6);
|
||||
esi = esi.wrapping_add(edi);
|
||||
edi ^= (esi >> 24).wrapping_add(esi << 8);
|
||||
edi = edi.wrapping_add(edx);
|
||||
edx ^= (edi >> 16).wrapping_add(edi << 16);
|
||||
edx = edx.wrapping_add(esi);
|
||||
esi ^= (edx >> 13).wrapping_add(edx << 19);
|
||||
esi = esi.wrapping_add(edi);
|
||||
edi ^= (esi >> 28).wrapping_add(esi << 4);
|
||||
edi = edi.wrapping_add(edx);
|
||||
i += 12;
|
||||
}
|
||||
|
||||
let remaining = bytes.len() - i;
|
||||
if remaining > 0 {
|
||||
eax = 0;
|
||||
if remaining >= 11 { eax = eax.wrapping_add((bytes[i + 10] as u32) << 24); }
|
||||
if remaining >= 10 { eax = eax.wrapping_add((bytes[i + 9] as u32) << 16); }
|
||||
if remaining >= 9 { eax = eax.wrapping_add((bytes[i + 8] as u32) << 8); }
|
||||
if remaining >= 8 { edi = edi.wrapping_add((bytes[i + 7] as u32) << 24); }
|
||||
if remaining >= 7 { edi = edi.wrapping_add((bytes[i + 6] as u32) << 16); }
|
||||
if remaining >= 6 { edi = edi.wrapping_add((bytes[i + 5] as u32) << 8); }
|
||||
if remaining >= 5 { edi = edi.wrapping_add(bytes[i + 4] as u32); }
|
||||
if remaining >= 4 { esi = esi.wrapping_add((bytes[i + 3] as u32) << 24); }
|
||||
if remaining >= 3 { esi = esi.wrapping_add((bytes[i + 2] as u32) << 16); }
|
||||
if remaining >= 2 { esi = esi.wrapping_add((bytes[i + 1] as u32) << 8); }
|
||||
if remaining >= 1 { esi = esi.wrapping_add(bytes[i] as u32); }
|
||||
esi = esi.wrapping_add(eax);
|
||||
|
||||
edi ^= (esi >> 28).wrapping_add(esi << 4);
|
||||
edi = edi.wrapping_add(esi);
|
||||
esi ^= (edi >> 26).wrapping_add(edi << 6);
|
||||
esi = esi.wrapping_add(edi);
|
||||
edi ^= (esi >> 24).wrapping_add(esi << 8);
|
||||
edi = edi.wrapping_add(esi);
|
||||
esi ^= (edi >> 16).wrapping_add(edi << 16);
|
||||
esi = esi.wrapping_add(edi);
|
||||
edi ^= (esi >> 13).wrapping_add(esi << 19);
|
||||
edi = edi.wrapping_add(esi);
|
||||
esi ^= (edi >> 28).wrapping_add(edi << 4);
|
||||
esi = esi.wrapping_add(edi);
|
||||
}
|
||||
|
||||
let _ = eax;
|
||||
let _ = ecx;
|
||||
let _ = edx;
|
||||
|
||||
((edi as u64) << 32) | (esi as u64)
|
||||
}
|
||||
214
src-tauri/src/commands/asset_commands.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
use crate::assets::{art, gumpart, tiledata};
|
||||
use crate::db::DbState;
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tauri::State;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TileRow {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub flags: i64,
|
||||
pub weight: u8,
|
||||
pub quality: u8,
|
||||
pub height: u8,
|
||||
pub hue: u8,
|
||||
pub anim_id: u16,
|
||||
}
|
||||
|
||||
/// List static tiles from the SQLite index, paginated.
|
||||
#[tauri::command]
|
||||
pub fn list_static_tiles(
|
||||
offset: u32,
|
||||
limit: u32,
|
||||
search: Option<String>,
|
||||
state: State<DbState>,
|
||||
) -> Result<Vec<TileRow>, String> {
|
||||
let conn = state.0.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
let rows = if let Some(q) = search {
|
||||
let pattern = format!("%{}%", q);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, flags, weight, quality, height, hue, anim_id
|
||||
FROM static_tiles WHERE name LIKE ?1 ORDER BY id LIMIT ?2 OFFSET ?3",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows: Vec<TileRow> = stmt
|
||||
.query_map(params![pattern, limit, offset], row_to_tile)
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows
|
||||
} else {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, flags, weight, quality, height, hue, anim_id
|
||||
FROM static_tiles ORDER BY id LIMIT ?1 OFFSET ?2",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows: Vec<TileRow> = stmt
|
||||
.query_map(params![limit, offset], row_to_tile)
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows
|
||||
};
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn row_to_tile(row: &rusqlite::Row) -> rusqlite::Result<TileRow> {
|
||||
Ok(TileRow {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
flags: row.get(2)?,
|
||||
weight: row.get(3)?,
|
||||
quality: row.get(4)?,
|
||||
height: row.get(5)?,
|
||||
hue: row.get(6)?,
|
||||
anim_id: row.get(7)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a single static tile by ID.
|
||||
#[tauri::command]
|
||||
pub fn get_static_tile(id: u32, state: State<DbState>) -> Result<Option<TileRow>, String> {
|
||||
let conn = state.0.lock().map_err(|e| e.to_string())?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, flags, weight, quality, height, hue, anim_id
|
||||
FROM static_tiles WHERE id = ?1",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let result = stmt
|
||||
.query_row(params![id], row_to_tile)
|
||||
.optional()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Get art image pixels for a static tile by item ID.
|
||||
/// Priority: bundled BMP artwork folder → art.mul → artLegacyMUL.uop → tileart.uop
|
||||
#[tauri::command]
|
||||
pub fn get_tile_art(
|
||||
app: tauri::AppHandle,
|
||||
item_id: u32,
|
||||
uo_root: String,
|
||||
_state: State<DbState>,
|
||||
) -> Result<ArtImageResult, String> {
|
||||
// 1. Bundled BMP artwork (highest quality, always try first)
|
||||
if let Some(artwork_dir) = art::find_artwork_dir(&app) {
|
||||
if let Ok(img) = art::decode_static_bmp(&artwork_dir, item_id as usize) {
|
||||
return Ok(ArtImageResult {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
pixels: img.pixels,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fall back to MUL / UOP client files
|
||||
let root = Path::new(&uo_root);
|
||||
let img = art::decode_static_any(root, item_id as usize)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(ArtImageResult {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
pixels: img.pixels,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a gump image by gump ID.
|
||||
/// Tries gumpart.mul (Classic), then gumpartLegacyMUL.uop (EC / Classic UOP).
|
||||
#[tauri::command]
|
||||
pub fn get_gump_image(
|
||||
gump_id: u32,
|
||||
uo_root: String,
|
||||
) -> Result<ArtImageResult, String> {
|
||||
let root = Path::new(&uo_root);
|
||||
let mul_path = root.join("gumpart.mul");
|
||||
let idx_path = root.join("gumpartidx.mul");
|
||||
let uop_path = root.join("gumpartLegacyMUL.uop");
|
||||
|
||||
let img = if mul_path.exists() && idx_path.exists() {
|
||||
gumpart::decode_gump(&mul_path, &idx_path, gump_id as usize)
|
||||
.map_err(|e| e.to_string())?
|
||||
} else if uop_path.exists() {
|
||||
gumpart::decode_gump_uop(&uop_path, gump_id as usize)
|
||||
.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
return Err(
|
||||
"No gump art source found. Requires gumpart.mul+gumpartidx.mul \
|
||||
or gumpartLegacyMUL.uop."
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
|
||||
Ok(ArtImageResult {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
pixels: img.pixels,
|
||||
})
|
||||
}
|
||||
|
||||
/// Walk tiledata.mul and populate the static_tiles table.
|
||||
#[tauri::command]
|
||||
pub fn index_assets(uo_root: String, state: State<DbState>) -> Result<u32, String> {
|
||||
let root = Path::new(&uo_root);
|
||||
let tiledata_path = root.join("tiledata.mul");
|
||||
|
||||
if !tiledata_path.exists() {
|
||||
return Err("tiledata.mul not found in UO root".to_string());
|
||||
}
|
||||
|
||||
let tiles = tiledata::read_item_tiles(&tiledata_path).map_err(|e| e.to_string())?;
|
||||
let count = tiles.len() as u32;
|
||||
|
||||
let mut conn = state.0.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
// Wrap everything in a single transaction — without this, 39k+ individual
|
||||
// auto-committed inserts each flush to disk and take minutes to complete.
|
||||
let tx = conn.transaction().map_err(|e| e.to_string())?;
|
||||
|
||||
tx.execute("DELETE FROM static_tiles", [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
{
|
||||
let mut stmt = tx
|
||||
.prepare(
|
||||
"INSERT INTO static_tiles (id, name, flags, weight, quality, height, hue, anim_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
for tile in &tiles {
|
||||
stmt.execute(params![
|
||||
tile.id,
|
||||
tile.name,
|
||||
tile.flags as i64,
|
||||
tile.weight,
|
||||
tile.quality,
|
||||
tile.height,
|
||||
tile.hue,
|
||||
tile.anim_id,
|
||||
])
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ArtImageResult {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub pixels: Vec<u8>,
|
||||
}
|
||||
25
src-tauri/src/commands/config_commands.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use crate::config::{self, ValidationResult};
|
||||
use crate::db::DbState;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_config(key: String, state: State<DbState>) -> Result<Option<String>, String> {
|
||||
let conn = state.0.lock().map_err(|e| e.to_string())?;
|
||||
crate::db::get_config(&conn, &key).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_config(key: String, value: String, state: State<DbState>) -> Result<(), String> {
|
||||
let conn = state.0.lock().map_err(|e| e.to_string())?;
|
||||
crate::db::set_config(&conn, &key, &value).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn validate_uo_root(path: String) -> Result<Vec<ValidationResult>, String> {
|
||||
Ok(config::validate_uo_root(&path))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn validate_scripts_path(path: String) -> Result<Vec<ValidationResult>, String> {
|
||||
Ok(config::validate_scripts_path(&path))
|
||||
}
|
||||
158
src-tauri/src/commands/gump_commands.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
use crate::assets::gumpart;
|
||||
use crate::assets::art::ArtImage;
|
||||
use crate::commands::asset_commands::ArtImageResult;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
/// A single entry from gumps.xml (art gump with numeric ID).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GumpEntry {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// A single entry from script_gumps.xml (C# Gump class from ServUO Scripts).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScriptGumpEntry {
|
||||
pub class_name: String,
|
||||
pub file_path: String,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parse gumps.xml from the "UO Gumps" folder and return all entries.
|
||||
/// Supports both decimal IDs and 0x-prefixed hex IDs.
|
||||
#[tauri::command]
|
||||
pub fn list_gumps(app: tauri::AppHandle) -> Result<Vec<GumpEntry>, String> {
|
||||
let gump_dir = gumpart::find_gump_dir(&app)
|
||||
.ok_or_else(|| "UO Gumps folder not found".to_string())?;
|
||||
|
||||
let xml_path = gump_dir.join("gumps.xml");
|
||||
if !xml_path.exists() {
|
||||
return Err("gumps.xml not found in UO Gumps folder".to_string());
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&xml_path)
|
||||
.map_err(|e| format!("Failed to read gumps.xml: {}", e))?;
|
||||
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.starts_with("<Gump ") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let id = parse_attr(trimmed, "id").and_then(|s| parse_gump_id(&s));
|
||||
let name = parse_attr(trimmed, "name");
|
||||
let tags = parse_attr(trimmed, "tags");
|
||||
|
||||
if let (Some(id), Some(name)) = (id, name) {
|
||||
let tags_vec: Vec<String> = if let Some(t) = tags {
|
||||
t.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
entries.push(GumpEntry { id, name, tags: tags_vec });
|
||||
}
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn parse_attr(line: &str, attr: &str) -> Option<String> {
|
||||
let needle = format!("{}=\"", attr);
|
||||
let start = line.find(&needle)? + needle.len();
|
||||
let rest = &line[start..];
|
||||
let end = rest.find('"')?;
|
||||
Some(rest[..end].to_string())
|
||||
}
|
||||
|
||||
fn parse_gump_id(s: &str) -> Option<u32> {
|
||||
if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
|
||||
u32::from_str_radix(hex, 16).ok()
|
||||
} else {
|
||||
s.parse::<u32>().ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a gump image by gump ID.
|
||||
/// Priority: bundled BMP → gumpart.mul → gumpartLegacyMUL.uop
|
||||
#[tauri::command]
|
||||
pub fn get_gump_art(
|
||||
app: tauri::AppHandle,
|
||||
gump_id: u32,
|
||||
uo_root: String,
|
||||
) -> Result<ArtImageResult, String> {
|
||||
// 1. Bundled BMP
|
||||
if let Some(gump_dir) = gumpart::find_gump_dir(&app) {
|
||||
if let Ok(img) = gumpart::decode_gump_bmp(&gump_dir, gump_id as usize) {
|
||||
return Ok(art_image_to_result(img));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fall back to MUL / UOP client files
|
||||
let root = Path::new(&uo_root);
|
||||
let mul_path = root.join("gumpart.mul");
|
||||
let idx_path = root.join("gumpartidx.mul");
|
||||
let uop_path = root.join("gumpartLegacyMUL.uop");
|
||||
|
||||
let img = if mul_path.exists() && idx_path.exists() {
|
||||
gumpart::decode_gump(&mul_path, &idx_path, gump_id as usize)
|
||||
.map_err(|e| e.to_string())?
|
||||
} else if uop_path.exists() {
|
||||
gumpart::decode_gump_uop(&uop_path, gump_id as usize)
|
||||
.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
return Err("No gump art source found".to_string());
|
||||
};
|
||||
|
||||
Ok(art_image_to_result(img))
|
||||
}
|
||||
|
||||
/// Parse script_gumps.xml from the "UO Gumps" folder and return all script gump entries.
|
||||
#[tauri::command]
|
||||
pub fn list_script_gumps(app: tauri::AppHandle) -> Result<Vec<ScriptGumpEntry>, String> {
|
||||
let gump_dir = gumpart::find_gump_dir_for_scripts(&app)
|
||||
.ok_or_else(|| "UO Gumps folder not found".to_string())?;
|
||||
|
||||
let xml_path = gump_dir.join("script_gumps.xml");
|
||||
if !xml_path.exists() {
|
||||
return Err(format!("script_gumps.xml not found — searched: {}", gump_dir.display()));
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&xml_path)
|
||||
.map_err(|e| format!("Failed to read script_gumps.xml: {}", e))?;
|
||||
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.starts_with("<Gump ") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let class_name = parse_attr(trimmed, "class");
|
||||
let file_path = parse_attr(trimmed, "file");
|
||||
let tags = parse_attr(trimmed, "tags");
|
||||
|
||||
if let (Some(class_name), Some(file_path)) = (class_name, file_path) {
|
||||
let tags_vec: Vec<String> = if let Some(t) = tags {
|
||||
t.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
entries.push(ScriptGumpEntry { class_name, file_path, tags: tags_vec });
|
||||
}
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn art_image_to_result(img: ArtImage) -> ArtImageResult {
|
||||
ArtImageResult {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
pixels: img.pixels,
|
||||
}
|
||||
}
|
||||
4
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod asset_commands;
|
||||
pub mod config_commands;
|
||||
pub mod gump_commands;
|
||||
pub mod script_commands;
|
||||
318
src-tauri/src/commands/script_commands.rs
Normal file
@@ -0,0 +1,318 @@
|
||||
use crate::db::DbState;
|
||||
use crate::ipc::sidecar::SidecarState;
|
||||
use rusqlite::params;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use tauri::State;
|
||||
|
||||
// ── Shared data shapes ────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClassSummary {
|
||||
pub name: String,
|
||||
pub namespace: String,
|
||||
pub file_path: String,
|
||||
pub base_class: Option<String>,
|
||||
pub interfaces: Vec<String>,
|
||||
pub is_gump: bool,
|
||||
pub is_mobile: bool,
|
||||
pub is_item: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MethodSummary {
|
||||
pub id: i64,
|
||||
pub class_name: String,
|
||||
pub name: String,
|
||||
pub return_type: String,
|
||||
pub parameters: Vec<ParameterInfo>,
|
||||
pub is_override: bool,
|
||||
pub is_virtual: bool,
|
||||
pub calls_gump: bool,
|
||||
pub gump_class: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ParameterInfo {
|
||||
pub name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub ty: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClassDetail {
|
||||
#[serde(rename = "class")]
|
||||
pub class_info: ClassSummary,
|
||||
pub methods: Vec<MethodSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NamespaceTree {
|
||||
pub namespace: String,
|
||||
pub classes: Vec<ClassSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FlowNode {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub node_type: String,
|
||||
pub label: String,
|
||||
pub children: Vec<FlowNode>,
|
||||
pub fake_input_key: Option<String>,
|
||||
pub resolved_gump: Option<String>,
|
||||
pub asset_ref: Option<i64>,
|
||||
}
|
||||
|
||||
// ── Commands ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Walk the ServUO Scripts directory, build the index via the C# sidecar,
|
||||
/// and persist class/method summaries to SQLite.
|
||||
#[tauri::command]
|
||||
pub fn index_scripts(
|
||||
scripts_path: String,
|
||||
db: State<DbState>,
|
||||
sidecar: State<SidecarState>,
|
||||
) -> Result<u32, String> {
|
||||
// Ask sidecar to index
|
||||
let result = sidecar
|
||||
.send("index_scripts", json!({ "path": scripts_path }))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let classes: Vec<Value> = result["classes"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let count = classes.len() as u32;
|
||||
|
||||
// Persist to SQLite
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
conn.execute("DELETE FROM script_methods", []).map_err(|e| e.to_string())?;
|
||||
conn.execute("DELETE FROM script_classes", []).map_err(|e| e.to_string())?;
|
||||
|
||||
for cls in &classes {
|
||||
let name = cls["name"].as_str().unwrap_or("");
|
||||
let namespace = cls["namespace"].as_str().unwrap_or("");
|
||||
let file_path = cls["file_path"].as_str().unwrap_or("");
|
||||
let base_class = cls["base_class"].as_str();
|
||||
let interfaces = cls["interfaces"]
|
||||
.as_array()
|
||||
.and_then(|a| serde_json::to_string(a).ok())
|
||||
.unwrap_or_else(|| "[]".to_string());
|
||||
let attributes = cls["attributes"]
|
||||
.as_array()
|
||||
.and_then(|a| serde_json::to_string(a).ok())
|
||||
.unwrap_or_else(|| "[]".to_string());
|
||||
let is_gump = cls["is_gump"].as_bool().unwrap_or(false) as i32;
|
||||
let is_mobile = cls["is_mobile"].as_bool().unwrap_or(false) as i32;
|
||||
let is_item = cls["is_item"].as_bool().unwrap_or(false) as i32;
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO script_classes
|
||||
(name, namespace, file_path, base_class, interfaces, attributes, is_gump, is_mobile, is_item)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
|
||||
params![name, namespace, file_path, base_class, interfaces, attributes,
|
||||
is_gump, is_mobile, is_item],
|
||||
).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Return the full script namespace tree (namespaces with their class summaries).
|
||||
#[tauri::command]
|
||||
pub fn get_script_tree(db: State<DbState>) -> Result<Vec<NamespaceTree>, String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT name, namespace, file_path, base_class, interfaces, is_gump, is_mobile, is_item
|
||||
FROM script_classes ORDER BY namespace, name",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
let interfaces_json: String = row.get(4)?;
|
||||
let interfaces: Vec<String> =
|
||||
serde_json::from_str(&interfaces_json).unwrap_or_default();
|
||||
Ok(ClassSummary {
|
||||
name: row.get(0)?,
|
||||
namespace: row.get(1)?,
|
||||
file_path: row.get(2)?,
|
||||
base_class: row.get(3)?,
|
||||
interfaces,
|
||||
is_gump: row.get::<_, i32>(5)? != 0,
|
||||
is_mobile: row.get::<_, i32>(6)? != 0,
|
||||
is_item: row.get::<_, i32>(7)? != 0,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Group by namespace
|
||||
let mut map: std::collections::BTreeMap<String, Vec<ClassSummary>> =
|
||||
std::collections::BTreeMap::new();
|
||||
for cls in rows {
|
||||
map.entry(cls.namespace.clone()).or_default().push(cls);
|
||||
}
|
||||
|
||||
Ok(map
|
||||
.into_iter()
|
||||
.map(|(namespace, classes)| NamespaceTree { namespace, classes })
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Ask the sidecar for full ClassInfo (with methods) and return it.
|
||||
/// Also caches methods to SQLite on first fetch.
|
||||
#[tauri::command]
|
||||
pub fn get_class_detail(
|
||||
class_name: String,
|
||||
db: State<DbState>,
|
||||
sidecar: State<SidecarState>,
|
||||
) -> Result<ClassDetail, String> {
|
||||
let result = sidecar
|
||||
.send("get_class", json!({ "class": class_name }))
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let cls_val = &result;
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
// Parse class summary from sidecar response
|
||||
let interfaces: Vec<String> = cls_val["interfaces"]
|
||||
.as_array()
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let class = ClassSummary {
|
||||
name: cls_val["name"].as_str().unwrap_or("").to_string(),
|
||||
namespace: cls_val["namespace"].as_str().unwrap_or("").to_string(),
|
||||
file_path: cls_val["file_path"].as_str().unwrap_or("").to_string(),
|
||||
base_class: cls_val["base_class"].as_str().map(String::from),
|
||||
interfaces: interfaces.clone(),
|
||||
is_gump: cls_val["is_gump"].as_bool().unwrap_or(false),
|
||||
is_mobile: cls_val["is_mobile"].as_bool().unwrap_or(false),
|
||||
is_item: cls_val["is_item"].as_bool().unwrap_or(false),
|
||||
};
|
||||
|
||||
// Parse methods
|
||||
let methods_val = cls_val["methods"].as_array().cloned().unwrap_or_default();
|
||||
let mut methods = Vec::new();
|
||||
|
||||
// Clear existing methods for this class and re-insert
|
||||
conn.execute("DELETE FROM script_methods WHERE class_name = ?1", params![class.name])
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
for m in &methods_val {
|
||||
let params_json = serde_json::to_string(&m["parameters"]).unwrap_or_default();
|
||||
let method_name = m["name"].as_str().unwrap_or("").to_string();
|
||||
let return_type = m["return_type"].as_str().unwrap_or("void").to_string();
|
||||
let is_override = m["is_override"].as_bool().unwrap_or(false);
|
||||
let is_virtual = m["is_virtual"].as_bool().unwrap_or(false);
|
||||
let calls_gump = m["calls_gump"].as_bool().unwrap_or(false);
|
||||
let gump_class = m["gump_class"].as_str().map(String::from);
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO script_methods
|
||||
(class_name, name, return_type, parameters, is_override, is_virtual, calls_gump, gump_class)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
|
||||
params![
|
||||
class.name,
|
||||
method_name,
|
||||
return_type,
|
||||
params_json,
|
||||
is_override as i32,
|
||||
is_virtual as i32,
|
||||
calls_gump as i32,
|
||||
gump_class,
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let row_id = conn.last_insert_rowid();
|
||||
|
||||
let parameters: Vec<ParameterInfo> = m["parameters"]
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.map(|p| ParameterInfo {
|
||||
name: p["name"].as_str().unwrap_or("").to_string(),
|
||||
ty: p["type"].as_str().unwrap_or("").to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
methods.push(MethodSummary {
|
||||
id: row_id,
|
||||
class_name: class.name.clone(),
|
||||
name: m["name"].as_str().unwrap_or("").to_string(),
|
||||
return_type: m["return_type"].as_str().unwrap_or("void").to_string(),
|
||||
parameters,
|
||||
is_override,
|
||||
is_virtual,
|
||||
calls_gump,
|
||||
gump_class: m["gump_class"].as_str().map(String::from),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ClassDetail { class_info: class, methods })
|
||||
}
|
||||
|
||||
/// Full-text search across indexed class and method names.
|
||||
#[tauri::command]
|
||||
pub fn search_scripts(query: String, db: State<DbState>) -> Result<Vec<ClassSummary>, String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
let pattern = format!("%{}%", query);
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT DISTINCT c.name, c.namespace, c.file_path, c.base_class,
|
||||
c.interfaces, c.is_gump, c.is_mobile, c.is_item
|
||||
FROM script_classes c
|
||||
LEFT JOIN script_methods m ON m.class_name = c.name
|
||||
WHERE c.name LIKE ?1 OR c.namespace LIKE ?1 OR m.name LIKE ?1
|
||||
ORDER BY c.name LIMIT 100",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![pattern], |row| {
|
||||
let interfaces_json: String = row.get(4)?;
|
||||
let interfaces: Vec<String> =
|
||||
serde_json::from_str(&interfaces_json).unwrap_or_default();
|
||||
Ok(ClassSummary {
|
||||
name: row.get(0)?,
|
||||
namespace: row.get(1)?,
|
||||
file_path: row.get(2)?,
|
||||
base_class: row.get(3)?,
|
||||
interfaces,
|
||||
is_gump: row.get::<_, i32>(5)? != 0,
|
||||
is_mobile: row.get::<_, i32>(6)? != 0,
|
||||
is_item: row.get::<_, i32>(7)? != 0,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Trace the call chain for a method via the C# sidecar and return a FlowNode tree.
|
||||
#[tauri::command]
|
||||
pub fn trace_method(
|
||||
class_name: String,
|
||||
method_name: String,
|
||||
sidecar: State<SidecarState>,
|
||||
) -> Result<FlowNode, String> {
|
||||
let result = sidecar
|
||||
.send(
|
||||
"trace_method",
|
||||
json!({ "class": class_name, "method": method_name }),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
serde_json::from_value::<FlowNode>(result).map_err(|e| e.to_string())
|
||||
}
|
||||
137
src-tauri/src/config.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ValidationResult {
|
||||
pub file: String,
|
||||
pub status: ValidationStatus,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ValidationStatus {
|
||||
Ok,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// Validate a UO root directory.
|
||||
/// Returns one ValidationResult per expected file.
|
||||
pub fn validate_uo_root(path: &str) -> Vec<ValidationResult> {
|
||||
let root = Path::new(path);
|
||||
let mut results = Vec::new();
|
||||
|
||||
// Required — one of each pair must exist
|
||||
let required_pairs: &[(&str, &str)] = &[
|
||||
("art.mul", "artLegacyMUL.uop"),
|
||||
("gumpart.mul", "gumpartLegacyMUL.uop"),
|
||||
];
|
||||
|
||||
for (a, b) in required_pairs {
|
||||
let a_exists = root.join(a).exists();
|
||||
let b_exists = root.join(b).exists();
|
||||
if a_exists || b_exists {
|
||||
let found = if a_exists { a } else { b };
|
||||
results.push(ValidationResult {
|
||||
file: format!("{} / {}", a, b),
|
||||
status: ValidationStatus::Ok,
|
||||
message: Some(format!("Found: {}", found)),
|
||||
});
|
||||
} else {
|
||||
results.push(ValidationResult {
|
||||
file: format!("{} / {}", a, b),
|
||||
status: ValidationStatus::Error,
|
||||
message: Some("Neither file found".to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Strictly required singles
|
||||
for file in &["tiledata.mul", "hues.mul", "cliloc.enu", "unifont.mul"] {
|
||||
let exists = root.join(file).exists();
|
||||
results.push(ValidationResult {
|
||||
file: file.to_string(),
|
||||
status: if exists {
|
||||
ValidationStatus::Ok
|
||||
} else {
|
||||
ValidationStatus::Error
|
||||
},
|
||||
message: if exists {
|
||||
None
|
||||
} else {
|
||||
Some("File not found".to_string())
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Optional — warn if missing
|
||||
let optional: &[(&str, Option<&str>)] = &[
|
||||
("anim.mul", None),
|
||||
("multi.mul", None),
|
||||
("map0.mul", None),
|
||||
("radarcol.mul", None),
|
||||
];
|
||||
|
||||
for (file, alt) in optional {
|
||||
let exists = root.join(file).exists()
|
||||
|| alt.map(|a| root.join(a).exists()).unwrap_or(false);
|
||||
results.push(ValidationResult {
|
||||
file: file.to_string(),
|
||||
status: if exists {
|
||||
ValidationStatus::Ok
|
||||
} else {
|
||||
ValidationStatus::Warn
|
||||
},
|
||||
message: if exists {
|
||||
None
|
||||
} else {
|
||||
Some("Optional file missing — some features unavailable".to_string())
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Validate a ServUO Scripts path.
|
||||
pub fn validate_scripts_path(path: &str) -> Vec<ValidationResult> {
|
||||
let p = Path::new(path);
|
||||
if !p.exists() {
|
||||
return vec![ValidationResult {
|
||||
file: path.to_string(),
|
||||
status: ValidationStatus::Error,
|
||||
message: Some("Path does not exist".to_string()),
|
||||
}];
|
||||
}
|
||||
|
||||
// Check for at least one .cs file
|
||||
let has_cs = walkdir::WalkDir::new(p)
|
||||
.max_depth(5)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.any(|e| e.path().extension().map(|x| x == "cs").unwrap_or(false));
|
||||
|
||||
vec![ValidationResult {
|
||||
file: path.to_string(),
|
||||
status: if has_cs {
|
||||
ValidationStatus::Ok
|
||||
} else {
|
||||
ValidationStatus::Warn
|
||||
},
|
||||
message: if has_cs {
|
||||
None
|
||||
} else {
|
||||
Some("No .cs files found — is this the right folder?".to_string())
|
||||
},
|
||||
}]
|
||||
}
|
||||
|
||||
/// Detect whether the UO root uses MUL or UOP format.
|
||||
pub fn detect_asset_format(uo_root: &str) -> &'static str {
|
||||
if Path::new(uo_root).join("artLegacyMUL.uop").exists() {
|
||||
"uop"
|
||||
} else {
|
||||
"mul"
|
||||
}
|
||||
}
|
||||
99
src-tauri/src/db.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
use anyhow::Result;
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
pub struct DbState(pub Mutex<Connection>);
|
||||
|
||||
pub fn db_path(app: &AppHandle) -> PathBuf {
|
||||
app.path()
|
||||
.app_data_dir()
|
||||
.expect("Failed to get app data dir")
|
||||
.join("asw.db")
|
||||
}
|
||||
|
||||
pub fn init(app: &AppHandle) -> Result<()> {
|
||||
let path = db_path(app);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let conn = Connection::open(&path)?;
|
||||
// WAL mode: much faster for bulk writes; allows concurrent reads during writes
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")?;
|
||||
conn.execute_batch(SCHEMA)?;
|
||||
|
||||
app.manage(DbState(Mutex::new(conn)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_config(conn: &Connection, key: &str) -> Result<Option<String>> {
|
||||
let mut stmt = conn.prepare("SELECT value FROM config WHERE key = ?1")?;
|
||||
let result = stmt.query_row(params![key], |row| row.get(0)).optional()?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn set_config(conn: &Connection, key: &str, value: &str) -> Result<()> {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO config (key, value) VALUES (?1, ?2)",
|
||||
params![key, value],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const SCHEMA: &str = r#"
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS static_tiles (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
flags INTEGER NOT NULL,
|
||||
weight INTEGER NOT NULL,
|
||||
quality INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
hue INTEGER NOT NULL,
|
||||
anim_id INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mobile_entries (
|
||||
body_id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
flags INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_static_tiles_name ON static_tiles(name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS script_classes (
|
||||
name TEXT PRIMARY KEY,
|
||||
namespace TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
base_class TEXT,
|
||||
interfaces TEXT NOT NULL DEFAULT '[]',
|
||||
attributes TEXT NOT NULL DEFAULT '[]',
|
||||
is_gump INTEGER NOT NULL DEFAULT 0,
|
||||
is_mobile INTEGER NOT NULL DEFAULT 0,
|
||||
is_item INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS script_methods (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
class_name TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
return_type TEXT NOT NULL,
|
||||
parameters TEXT NOT NULL DEFAULT '[]',
|
||||
is_override INTEGER NOT NULL DEFAULT 0,
|
||||
is_virtual INTEGER NOT NULL DEFAULT 0,
|
||||
calls_gump INTEGER NOT NULL DEFAULT 0,
|
||||
gump_class TEXT,
|
||||
FOREIGN KEY (class_name) REFERENCES script_classes(name) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_script_classes_namespace ON script_classes(namespace);
|
||||
CREATE INDEX IF NOT EXISTS idx_script_classes_name ON script_classes(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_script_methods_class ON script_methods(class_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_script_methods_name ON script_methods(name);
|
||||
"#;
|
||||
1
src-tauri/src/ipc/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod sidecar;
|
||||
159
src-tauri/src/ipc/sidecar.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde_json::{json, Value};
|
||||
use std::io::{BufRead, BufReader, BufWriter, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ── Binary location ───────────────────────────────────────────────────────────
|
||||
|
||||
fn find_sidecar_binary() -> Result<PathBuf> {
|
||||
// Dev override via env var
|
||||
if let Ok(p) = std::env::var("ASW_SIDECAR_PATH") {
|
||||
let path = PathBuf::from(&p);
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
return Err(anyhow!("ASW_SIDECAR_PATH set but binary not found at: {}", p));
|
||||
}
|
||||
|
||||
// Walk upward from the current executable to find the project root,
|
||||
// then look for the sidecar in known build output locations.
|
||||
// In dev mode the exe is at: <project>/src-tauri/target/debug/<bin>
|
||||
// In production the exe is at: <install>/<bin>
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
// Collect ancestor directories up to 8 levels deep
|
||||
let mut dir = exe.parent().map(|p| p.to_path_buf());
|
||||
for _ in 0..8 {
|
||||
if let Some(ref d) = dir {
|
||||
// Production (MSI install): Tauri places sidecar next to the exe.
|
||||
// Tauri bundles with the target-triple suffix; check both forms.
|
||||
for name in &[
|
||||
"asw-sidecar-x86_64-pc-windows-msvc.exe",
|
||||
"asw-sidecar.exe",
|
||||
] {
|
||||
let candidate = d.join(name);
|
||||
if candidate.exists() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
// Dev: check binaries/ subfolder and sidecar publish output
|
||||
for suffix in &[
|
||||
"binaries/asw-sidecar-x86_64-pc-windows-msvc.exe",
|
||||
"binaries/asw-sidecar.exe",
|
||||
"sidecar/bin/Release/net8.0/win-x64/publish/asw-sidecar.exe",
|
||||
"sidecar/bin/Debug/net8.0/win-x64/asw-sidecar.exe",
|
||||
] {
|
||||
let candidate = d.join(suffix);
|
||||
if candidate.exists() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
dir = d.parent().map(|p| p.to_path_buf());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"asw-sidecar binary not found. Build it first:\n\
|
||||
cd sidecar && dotnet publish -c Release -r win-x64 --self-contained\n\
|
||||
Or set ASW_SIDECAR_PATH env var."
|
||||
))
|
||||
}
|
||||
|
||||
// ── Handle (inner, behind Mutex) ─────────────────────────────────────────────
|
||||
|
||||
struct SidecarHandle {
|
||||
_child: Child,
|
||||
stdin: BufWriter<ChildStdin>,
|
||||
stdout: BufReader<ChildStdout>,
|
||||
}
|
||||
|
||||
impl SidecarHandle {
|
||||
fn spawn() -> Result<Self> {
|
||||
let binary = find_sidecar_binary()?;
|
||||
|
||||
let mut child = Command::new(&binary)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| anyhow!("Failed to spawn sidecar {:?}: {}", binary, e))?;
|
||||
|
||||
let stdin = BufWriter::new(child.stdin.take().ok_or(anyhow!("No stdin"))?);
|
||||
let stdout = BufReader::new(child.stdout.take().ok_or(anyhow!("No stdout"))?);
|
||||
|
||||
Ok(SidecarHandle { _child: child, stdin, stdout })
|
||||
}
|
||||
|
||||
fn send(&mut self, command: &str, args: Value) -> Result<Value> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let request = json!({ "id": id, "command": command, "args": args });
|
||||
|
||||
let line = serde_json::to_string(&request)?;
|
||||
writeln!(self.stdin, "{}", line)?;
|
||||
self.stdin.flush()?;
|
||||
|
||||
// Read lines until we get the response matching this request ID
|
||||
let mut buf = String::new();
|
||||
loop {
|
||||
buf.clear();
|
||||
let n = self.stdout.read_line(&mut buf)?;
|
||||
if n == 0 {
|
||||
return Err(anyhow!("Sidecar closed stdout unexpectedly"));
|
||||
}
|
||||
let trimmed = buf.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let response: Value = match serde_json::from_str(trimmed) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if response["id"].as_str() == Some(&id) {
|
||||
return if response["ok"].as_bool().unwrap_or(false) {
|
||||
Ok(response["data"].clone())
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"{}",
|
||||
response["error"].as_str().unwrap_or("sidecar error")
|
||||
))
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public state type ─────────────────────────────────────────────────────────
|
||||
|
||||
pub struct SidecarState {
|
||||
handle: Mutex<Option<SidecarHandle>>,
|
||||
}
|
||||
|
||||
impl SidecarState {
|
||||
pub fn new() -> Self {
|
||||
SidecarState { handle: Mutex::new(None) }
|
||||
}
|
||||
|
||||
/// Ensure the sidecar is running and send a command.
|
||||
pub fn send(&self, command: &str, args: Value) -> Result<Value> {
|
||||
let mut guard = self.handle.lock().map_err(|_| anyhow!("Sidecar lock poisoned"))?;
|
||||
|
||||
// Spawn if not running
|
||||
if guard.is_none() {
|
||||
*guard = Some(SidecarHandle::spawn()?);
|
||||
}
|
||||
|
||||
guard.as_mut().unwrap().send(command, args)
|
||||
}
|
||||
|
||||
/// Kill and restart the sidecar (e.g. after a re-index).
|
||||
pub fn restart(&self) -> Result<()> {
|
||||
let mut guard = self.handle.lock().map_err(|_| anyhow!("Sidecar lock poisoned"))?;
|
||||
*guard = Some(SidecarHandle::spawn()?);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
47
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
mod assets;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod db;
|
||||
mod ipc;
|
||||
|
||||
use crate::ipc::sidecar::SidecarState;
|
||||
use tauri::Manager;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.setup(|app| {
|
||||
let app_handle = app.handle().clone();
|
||||
db::init(&app_handle).expect("Failed to initialize database");
|
||||
app.manage(SidecarState::new());
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Config
|
||||
commands::config_commands::get_config,
|
||||
commands::config_commands::set_config,
|
||||
commands::config_commands::validate_uo_root,
|
||||
commands::config_commands::validate_scripts_path,
|
||||
// Assets (Phase 1)
|
||||
commands::asset_commands::get_static_tile,
|
||||
commands::asset_commands::list_static_tiles,
|
||||
commands::asset_commands::get_tile_art,
|
||||
commands::asset_commands::get_gump_image,
|
||||
// Gumps (Phase 4)
|
||||
commands::gump_commands::list_gumps,
|
||||
commands::gump_commands::get_gump_art,
|
||||
commands::gump_commands::list_script_gumps,
|
||||
commands::asset_commands::index_assets,
|
||||
// Scripts (Phase 2)
|
||||
commands::script_commands::index_scripts,
|
||||
commands::script_commands::get_script_tree,
|
||||
commands::script_commands::get_class_detail,
|
||||
commands::script_commands::search_scripts,
|
||||
// Flow (Phase 3)
|
||||
commands::script_commands::trace_method,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Artificer's Scrollwork");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
artificers_scrollwork_lib::run()
|
||||
}
|
||||
46
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Artificer's Scrollwork",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.whitlocktech.artificers-scrollwork",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Artificer's Scrollwork",
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"minWidth": 1100,
|
||||
"minHeight": 700,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "msi",
|
||||
"externalBin": [
|
||||
"binaries/asw-sidecar"
|
||||
],
|
||||
"resources": {
|
||||
"../UO artwork": "UO artwork",
|
||||
"../UO Gumps": "UO Gumps"
|
||||
},
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||