Initial commit

This commit is contained in:
2026-06-05 20:53:53 -05:00
commit f9a59e9a66
99 changed files with 15897 additions and 0 deletions

289
src-tauri/src/assets/art.rs Normal file
View 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,
]
}

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

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

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

View 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;

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

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

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

View 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>,
}

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

View 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,
}
}

View File

@@ -0,0 +1,4 @@
pub mod asset_commands;
pub mod config_commands;
pub mod gump_commands;
pub mod script_commands;

View 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
View 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
View 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
View File

@@ -0,0 +1 @@
pub mod sidecar;

View 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
View 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
View 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()
}