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

53
.gitignore vendored Normal file
View File

@@ -0,0 +1,53 @@
# ── Rust / Cargo ──────────────────────────────────────────────────────────────
target/
Cargo.lock
# ── Node / Vite ───────────────────────────────────────────────────────────────
node_modules/
dist/
.vite/
# ── C# sidecar ────────────────────────────────────────────────────────────────
sidecar/bin/
sidecar/obj/
*.user
*.suo
# ── Tauri compiled binaries ───────────────────────────────────────────────────
# The compiled sidecar exe is a build artifact — rebuild with dotnet publish
src-tauri/binaries/
# ── UO asset art (large binary files, user-supplied) ─────────────────────────
# BMPs are sourced from the user's UO client — not committed to the repo.
# The XML manifests (gumps.xml, script_gumps.xml) ARE committed.
UO Gumps/*.bmp
UO artwork/*.bmp
UO artwork/*.png
UO artwork/*.tga
UO artwork/*.dds
# ── SQLite databases ──────────────────────────────────────────────────────────
*.db
*.db-shm
*.db-wal
*.sqlite
# ── OS files ──────────────────────────────────────────────────────────────────
.DS_Store
Thumbs.db
desktop.ini
# ── Editor / IDE ──────────────────────────────────────────────────────────────
.vscode/
.idea/
*.swp
*.swo
*~
# ── Environment / secrets ─────────────────────────────────────────────────────
.env
.env.local
.env.*.local
# ── Tauri dev artifacts ───────────────────────────────────────────────────────
src-tauri/.cargo-lock

674
CLAUDE.md Normal file
View File

@@ -0,0 +1,674 @@
# Artificer's Scrollwork — CLAUDE.md
> Local UO development workbench for ServUO shard developers.
> Combines script analysis, asset reference, Gump rendering, and interactive flow tracing.
> Built with Tauri (Rust shell) + React frontend + C# sidecar.
---
## Project Identity
**Name:** Artificer's Scrollwork
**Short name / slug:** artificers-scrollwork
**Abbreviation:** ASW
**License:** Open source (MIT)
**Platform:** Windows desktop (Tauri)
**Target user:** ServUO shard developer working locally — no live shard connection required
---
## Repository Structure
```
artificers-scrollwork/
├── CLAUDE.md ← this file, always read first
├── src-tauri/ ← Rust/Tauri backend
│ ├── src/
│ │ ├── main.rs
│ │ ├── config.rs ← path config, validation
│ │ ├── db.rs ← SQLite via rusqlite
│ │ ├── assets/
│ │ │ ├── mod.rs
│ │ │ ├── mul_reader.rs ← .mul file parser
│ │ │ ├── uop_reader.rs ← .uop file parser
│ │ │ ├── art.rs ← art.mul / artLegacyMUL.uop
│ │ │ ├── gumpart.rs ← gumpart.mul / gumpartLegacyMUL.uop
│ │ │ ├── tiledata.rs ← tiledata.mul
│ │ │ ├── hues.rs ← hues.mul
│ │ │ ├── cliloc.rs ← cliloc.enu string table
│ │ │ ├── anim.rs ← animX.mul mobile sprites
│ │ │ └── multi.rs ← multi.mul structures
│ │ ├── ipc/
│ │ │ ├── mod.rs
│ │ │ └── sidecar.rs ← C# sidecar process management
│ │ └── commands/ ← Tauri command handlers (IPC to frontend)
│ │ ├── config_commands.rs
│ │ ├── asset_commands.rs
│ │ ├── script_commands.rs
│ │ └── gump_commands.rs
│ └── Cargo.toml
├── sidecar/ ← C# Roslyn sidecar
│ ├── ArtificersScrollwork.Sidecar.csproj
│ ├── Program.cs ← stdin/stdout JSON IPC entry point
│ ├── Parsing/
│ │ ├── ScriptIndexer.cs ← walks Scripts/ tree, indexes .cs files
│ │ ├── RoslynParser.cs ← Roslyn AST parsing
│ │ ├── ClassInfo.cs ← data model: class, base, props, methods
│ │ ├── MethodInfo.cs
│ │ └── CallChainTracer.cs ← traces method call chains across files
│ ├── Gumps/
│ │ ├── GumpExtractor.cs ← extracts Add* calls from Gump constructors
│ │ └── GumpDrawList.cs ← serializable draw call list → JSON
│ └── Models/
│ ├── ScriptIndex.cs
│ ├── DrawCall.cs
│ └── FlowNode.cs
├── src/ ← React frontend
│ ├── main.tsx
│ ├── App.tsx
│ ├── components/
│ │ ├── layout/
│ │ │ ├── AppShell.tsx ← three-pane shell
│ │ │ ├── LeftPanel.tsx ← browser tree
│ │ │ ├── CenterPanel.tsx ← flow / asset / gump view
│ │ │ └── RightPanel.tsx ← properties / fake data inputs
│ │ ├── asset/
│ │ │ ├── StaticBrowser.tsx ← static tile browser
│ │ │ ├── MobileBrowser.tsx ← mobile browser
│ │ │ ├── ItemPreview.tsx ← single item art + metadata
│ │ │ └── MobilePreview.tsx ← single mobile sprite
│ │ ├── script/
│ │ │ ├── ScriptTree.tsx ← class/method/hook tree
│ │ │ ├── ClassDetail.tsx ← class info panel
│ │ │ └── MethodDetail.tsx ← method info panel
│ │ ├── flow/
│ │ │ ├── FlowViewer.tsx ← call chain flow diagram
│ │ │ ├── FlowNode.tsx ← individual node in flow
│ │ │ └── FakeDataInputs.tsx ← right panel fake data widgets
│ │ ├── gump/
│ │ │ ├── GumpRenderer.tsx ← 800x600 canvas Gump renderer
│ │ │ └── GumpCanvas.tsx ← raw canvas draw call executor
│ │ └── config/
│ │ └── ConfigScreen.tsx ← UO root + ServUO Scripts path setup
│ ├── hooks/
│ │ ├── useAssets.ts
│ │ ├── useScripts.ts
│ │ └── useGump.ts
│ ├── store/
│ │ └── appStore.ts ← Zustand global state
│ └── types/
│ ├── assets.ts
│ ├── scripts.ts
│ ├── gump.ts
│ └── flow.ts
├── tauri.conf.json
├── package.json
└── README.md
```
---
## Architecture
```
┌─────────────────────────────────────────────┐
│ React Frontend │
│ LeftPanel | CenterPanel | RightPanel │
│ Asset Browser | Flow Viewer | Gump Renderer │
└────────────────────┬────────────────────────┘
│ Tauri invoke() IPC
┌────────────────────▼────────────────────────┐
│ Rust / Tauri Core │
│ config.rs | db.rs | asset parsers │
│ mul/uop readers | SQLite index │
└────────────────────┬────────────────────────┘
│ stdin/stdout JSON IPC
┌────────────────────▼────────────────────────┐
│ C# Roslyn Sidecar │
│ ScriptIndexer | RoslynParser │
│ CallChainTracer | GumpExtractor │
└─────────────────────────────────────────────┘
```
### IPC Protocol — Rust ↔ C# Sidecar
All messages are newline-delimited JSON over stdin/stdout.
**Request format:**
```json
{ "id": "uuid", "command": "parse_class", "args": { "file": "path/to/Script.cs", "class": "PaperDollGump" } }
```
**Response format:**
```json
{ "id": "uuid", "ok": true, "data": { ... } }
{ "id": "uuid", "ok": false, "error": "message" }
```
**Commands:**
- `index_scripts` — walk Scripts/ tree, build full index
- `get_class` — return ClassInfo for a named class
- `trace_method` — return FlowNode tree for a method entry point
- `extract_gump` — return GumpDrawList for a Gump class constructor
- `search` — full-text search across indexed scripts
---
## Configuration
Stored in SQLite `config` table (key/value).
| Key | Description |
|---|---|
| `uo_root` | Absolute path to UO client folder |
| `seruo_scripts` | Absolute path to ServUO Scripts/ folder |
| `index_version` | Hash/timestamp of last script index |
| `asset_format` | `mul` or `uop` (autodetected) |
### Path Validation
On config save, validate:
**UO Root — required files:**
```
art.mul OR artLegacyMUL.uop
gumpart.mul OR gumpartLegacyMUL.uop
tiledata.mul
hues.mul
cliloc.enu
unifont.mul
```
**UO Root — optional (warn if missing):**
```
anim.mul / animX.mul → mobile animations
multi.mul → structures
map0.mul → world map
radarcol.mul → minimap colors
```
**ServUO Scripts — required:**
```
<path>/ must exist and contain at least one .cs file
```
Report validation results per-file with ✅ / ⚠️ / ❌ status.
---
## Data Models
### Asset Types
```typescript
// types/assets.ts
interface TileInfo {
id: number; // item ID / static ID
name: string; // from tiledata.mul
flags: number; // tiledata flags bitmask
weight: number;
quality: number;
height: number;
hue: number;
artData: ImageData; // decoded pixel data
}
interface MobileInfo {
bodyId: number;
name: string;
flags: number;
frames: ImageData[]; // animation frames
}
interface HueInfo {
id: number;
name: string;
colors: number[]; // 32-entry color table
}
```
### Script Types
```typescript
// types/scripts.ts
interface ClassInfo {
name: string;
namespace: string;
filePath: string;
baseClass: string | null;
interfaces: string[];
properties: PropertyInfo[];
methods: MethodInfo[];
attributes: string[];
isGump: boolean;
isMobile: boolean;
isItem: boolean;
}
interface MethodInfo {
name: string;
returnType: string;
parameters: ParameterInfo[];
isOverride: boolean;
isVirtual: boolean;
callsGump: boolean; // does this method send a Gump?
gumpClass: string | null; // which Gump class if so
}
interface FlowNode {
id: string;
type: 'method_call' | 'condition' | 'gump_send' | 'return' | 'property_access';
label: string;
children: FlowNode[];
fakeInputKey?: string; // key for fake data injection if this node has an input
resolvedGump?: string; // Gump class name if type === 'gump_send'
assetRef?: number; // item/static ID if this node refs an asset
}
```
### Gump Types
```typescript
// types/gump.ts
type DrawCall =
| { type: 'background'; x: number; y: number; w: number; h: number; gumpId: number }
| { type: 'image'; x: number; y: number; gumpId: number; hue?: number }
| { type: 'label'; x: number; y: number; hue: number; text: string }
| { type: 'button'; x: number; y: number; normalId: number; pressedId: number; buttonId: number }
| { type: 'html'; x: number; y: number; w: number; h: number; text: string; hasBackground: boolean; hasScrollbar: boolean }
| { type: 'item'; x: number; y: number; itemId: number; hue?: number }
| { type: 'alpha_region'; x: number; y: number; w: number; h: number }
| { type: 'tiled_image'; x: number; y: number; w: number; h: number; gumpId: number }
| { type: 'checkbox'; x: number; y: number; inactiveId: number; activeId: number; checked: boolean; switchId: number }
| { type: 'radio'; x: number; y: number; inactiveId: number; activeId: number; checked: boolean; returnValue: number }
| { type: 'text_entry'; x: number; y: number; w: number; h: number; hue: number; entryId: number; initialText: string };
interface GumpDrawList {
className: string;
filePath: string;
isDynamic: boolean; // true if layout depends on runtime data
dynamicInputKeys: string[]; // fake data keys needed for dynamic Gumps
drawCalls: DrawCall[];
width: number; // declared width if available
height: number; // declared height if available
}
```
---
## UI Layout
### Three-Pane Shell
```
┌──────────────────────────────────────────────────────────────────┐
│ Artificer's Scrollwork [Config] [Index] │
├─────────────────┬──────────────────────────┬─────────────────────┤
│ LEFT PANEL │ CENTER PANEL │ RIGHT PANEL │
│ ~280px │ flex-grow │ ~320px │
│ │ │ │
│ [Scripts] │ ┌────────────────────┐ │ Properties │
│ [Statics] │ │ │ │ ───────────────── │
│ [Mobiles] │ │ Flow / Asset / │ │ Fake Data Inputs │
│ [Gumps] │ │ Gump View │ │ ───────────────── │
│ │ │ │ │ Asset Metadata │
│ Search box │ │ │ │ │
│ │ │ │ │ │
│ Tree │ └────────────────────┘ │ │
│ │ │ │
└─────────────────┴──────────────────────────┴─────────────────────┘
```
### Center Panel Modes
The center panel has distinct view modes, switched by what is selected in the left panel:
| Mode | Triggered By | Shows |
|---|---|---|
| `asset_static` | Static selected | Tile art + metadata |
| `asset_mobile` | Mobile selected | Mobile sprite + stats |
| `script_class` | Class selected | Class detail, inheritance tree |
| `flow_method` | Method/hook selected | Call chain flow diagram |
| `gump_render` | Gump class or method sends Gump | 800x600 Gump canvas |
### Gump Renderer
- Fixed 800x600 HTML canvas
- Dark border/frame to indicate viewport bounds
- Renders draw calls top-to-bottom in order
- Dynamic Gumps show placeholder tiles with fake data input widgets in right panel
- "Refresh Gump" button re-renders with current fake data values
---
## Asset Parsing — Rust Implementation Notes
### .mul Format
All .mul files share the same basic indexed format:
```
<name>idx.mul → index file: array of (offset: i32, length: i32, extra: i32) entries
<name>.mul → data file: raw blocks at offsets given by index
```
Entry with `offset == -1` → entry does not exist (skip).
### art.mul
- Statics (items): ID offset = 0x4000
- Each entry is a raw bitmap: `{ unknown: u16, width: u16, height: u16, lookup: [u16; height], data: [u16] }`
- Pixel format: 16-bit `0xARGB` (1-bit alpha, 5-bit RGB each)
- Transparent pixel = `0x0000`
### gumpart.mul
- Same index structure as art.mul
- Pixel format: same 16-bit `0xARGB`
- No offset for statics — IDs are direct
### tiledata.mul
- Land tiles: 428 blocks × 32 entries = 13,696 land entries
- Item tiles: blocks of 32 entries, each entry = `{ flags: u64, weight: u8, quality: u8, unknown: u16, unknown2: u8, quantity: u8, animId: u16, unknown3: u8, hue: u8, unknown4: u16, height: u8, name: [u8; 20] }`
- Block header = `u32` (skip it)
### hues.mul
- 375 blocks × 8 hues = 3,000 hues
- Each hue = `{ colors: [u16; 32], tableStart: u16, tableEnd: u16, name: [u8; 20] }`
### .uop Format
UOP is a container format used in newer clients:
```
Header: { magic: u32=0x0050594D, version: u32, signature: u32, index_offset: u64, max_files: u32, tag: [u8; 36] }
Index block: { next_block: u64, file_count: u32, entries: [UOPEntry; file_count] }
UOPEntry: { data_offset: u64, header_length: u32, compressed_length: u32, decompressed_length: u32, hash: u64, crc: u32, compression: u16 }
```
Compression: `0` = none, `1` = zlib deflate.
Hash function for UOP filenames (use to map asset IDs to UOP entries):
```rust
fn uop_hash(s: &str) -> u64 {
let s = s.to_lowercase();
let (mut eax, mut ecx, mut edx, mut ebx, mut esi, mut edi) =
(0u32, 0u32, 0u32, s.len() as u32, 0u32, 0u32);
edx = 0; eax = edx; esi = eax;
ecx = 0x9E3779B9u32;
edi = ecx; esi = ecx;
for chunk in s.as_bytes().chunks(12) {
// standard UOP hash — implement from ClassicUO source
}
((edi as u64) << 32) | (esi as u64)
}
```
Reference ClassicUO `UOFileUop.cs` for the full implementation.
---
## C# Sidecar — Implementation Notes
### Project Setup
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.*" />
<PackageReference Include="System.Text.Json" Version="8.*" />
</ItemGroup>
</Project>
```
### Roslyn Parser Notes
- Load all `.cs` files in Scripts/ into a `CSharpCompilation` for cross-file resolution
- Use `SemanticModel` for type resolution (base classes, method return types)
- `SyntaxTree` for structural traversal (Add* call extraction)
- Do NOT attempt to actually compile ServUO — use `WithOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary))` with missing reference tolerance
### GumpExtractor — Add* Call Mapping
| C# Method | DrawCall type |
|---|---|
| `AddBackground(x, y, w, h, id)` | `background` |
| `AddImage(x, y, id)` | `image` |
| `AddImage(x, y, id, hue)` | `image` with hue |
| `AddLabel(x, y, hue, text)` | `label` |
| `AddHtml(x, y, w, h, text, bg, scroll)` | `html` |
| `AddHtmlLocalized(x, y, w, h, clilocId, ...)` | `html` (resolve via cliloc) |
| `AddButton(x, y, normalId, pressedId, btnId, ...)` | `button` |
| `AddItem(x, y, itemId)` | `item` |
| `AddItem(x, y, itemId, hue)` | `item` with hue |
| `AddAlphaRegion(x, y, w, h)` | `alpha_region` |
| `AddImageTiled(x, y, w, h, id)` | `tiled_image` |
| `AddCheck(x, y, inactId, actId, checked, switchId)` | `checkbox` |
| `AddRadio(x, y, inactId, actId, checked, returnVal)` | `radio` |
| `AddTextEntry(x, y, w, h, hue, entryId, text)` | `text_entry` |
For dynamic Gumps (loop bodies, conditional Add* calls):
- Mark `isDynamic: true`
- Record the variable names that gate the dynamic calls as `dynamicInputKeys`
- Extract the static portions and emit placeholder draw calls for dynamic regions
### CallChainTracer Notes
- Entry point: a method name + class name
- Resolve method body via Roslyn
- Walk all `InvocationExpressionSyntax` nodes
- For each invocation:
- If `SendGump(new XxxGump(...))` → emit `gump_send` node, recurse into Gump constructor
- If calling another method in the same class → recurse
- If calling a method on a known ServUO base class → emit as leaf with label
- If an `if`/`else`/`switch` → emit `condition` node with branches
- Limit recursion depth to 8 to avoid infinite loops
---
## Build & Development
### Prerequisites
- Rust stable (latest)
- Node.js 20+
- .NET 8 SDK
- Tauri CLI: `cargo install tauri-cli`
### Development Commands
```bash
# Install JS deps
npm install
# Build C# sidecar
cd sidecar && dotnet publish -c Release -r win-x64 --self-contained
# Copy sidecar binary to Tauri resources
cp sidecar/bin/Release/net8.0/win-x64/publish/asw-sidecar.exe src-tauri/binaries/
# Run in dev mode
npm run tauri dev
# Build release
npm run tauri build
```
### Tauri Sidecar Config
In `tauri.conf.json`:
```json
{
"tauri": {
"bundle": {
"externalBin": ["binaries/asw-sidecar"]
}
}
}
```
---
## Development Phases
### Phase 1 — Foundation & Asset Reference
**Goal:** Working app that can browse UO assets
- [ ] Tauri project scaffold with three-pane React shell
- [ ] Config screen — UO root + ServUO Scripts path, validation
- [ ] SQLite setup — config table, asset index tables
- [ ] Rust mul/uop parsers — art, gumpart, tiledata, hues, cliloc
- [ ] Asset indexer — walk tiledata, build SQLite records for all statics and mobiles
- [ ] Static browser — paginated grid with art preview and metadata
- [ ] Mobile browser — list with sprite preview
- [ ] Item/tile detail view — full metadata panel
**Deliverable:** Browse every static and mobile in the UO client with art previews.
---
### Phase 2 — Script Browser
**Goal:** Ingest and navigate ServUO scripts
- [ ] C# sidecar project setup with Roslyn
- [ ] ScriptIndexer — walk Scripts/ tree, parse all .cs files, emit ClassInfo JSON
- [ ] Rust sidecar IPC layer — spawn sidecar, send/receive JSON commands
- [ ] SQLite script index — classes, methods, properties, file paths
- [ ] Re-index command with progress indicator
- [ ] Left panel script tree — namespaces → classes → methods
- [ ] Class detail view — inheritance chain, properties, methods list
- [ ] Method detail view — parameters, return type, attributes
- [ ] Search — cross-script full-text search
**Deliverable:** Browse the entire ServUO Scripts/ tree with structured class and method views.
---
### Phase 3 — Flow Tracer
**Goal:** Visualize call chains from any method entry point
- [ ] CallChainTracer in C# sidecar
- [ ] FlowNode JSON serialization
- [ ] FlowViewer React component — tree/graph layout of call chain
- [ ] Condition nodes with true/false branches
- [ ] Asset reference inline — when flow references an Item ID, show thumbnail
- [ ] Hook index — surface well-known hooks (OnDoubleClick, OnSingleClick, OnDeath, etc.) as quick-launch entry points
- [ ] Fake data input system — right panel widgets for condition inputs
- [ ] Flow re-evaluation when fake data changes
**Deliverable:** Click any method or hook, see the full call chain with fake data controls.
---
### Phase 4 — Gump Renderer
**Goal:** Render any Gump class on an 800x600 canvas
- [ ] GumpExtractor in C# sidecar
- [ ] GumpDrawList JSON serialization
- [ ] GumpCanvas React component — 800x600 HTML canvas
- [ ] Draw call executor — background, image, label, button, item, alpha, tiled, html, checkbox, radio, text entry
- [ ] gumpart.mul → ImageData conversion in Rust, served via Tauri command
- [ ] Hue application pipeline
- [ ] Static Gump rendering — fully static Add* calls
- [ ] Dynamic Gump rendering — fake data inputs drive dynamic draw call generation
- [ ] Gump link from flow tracer — gump_send nodes in flow open Gump renderer inline
**Deliverable:** Any Gump class renders visually. Dynamic Gumps render with fake data.
---
## Visual Design
### Theme
Dark theme. UO-adjacent aesthetic — aged parchment for text, deep slate/obsidian backgrounds, gold/amber accents. Feels like a scholar's workbench, not a generic dev tool.
**Color palette (CSS variables):**
```css
--bg-base: #0e0d0b;
--bg-panel: #161410;
--bg-elevated: #1e1b16;
--bg-hover: #272318;
--border: #3a3020;
--border-accent: #6b5a2e;
--text-primary: #d4c49a;
--text-secondary: #8a7a5a;
--text-muted: #4a4030;
--accent-gold: #c8a84b;
--accent-gold-bright: #e8c86b;
--accent-red: #8b3a3a;
--accent-green: #3a6b3a;
--scrollbar-thumb: #3a3020;
```
**Typography:**
- UI labels: `'Cinzel'` (Google Fonts) — classical, serif, fits the UO aesthetic
- Code / script content: `'JetBrains Mono'` or `'Fira Code'`
- Body / metadata: `'EB Garamond'`
### Gump Renderer Container
The 800x600 canvas sits inside a styled container:
```
┌─────────────────────────────────────┐
│ PaperDollGump │ ← class name header
│ Scripts/Gumps/PaperDollGump.cs │ ← file path
├─────────────────────────────────────┤
│ ┌───────────────────────────────┐ │
│ │ │ │
│ │ 800 × 600 canvas │ │
│ │ │ │
│ └───────────────────────────────┘ │
│ [Refresh] [Dynamic inputs →] │
└─────────────────────────────────────┘
```
---
## Claude Code Session Discipline
- **Always read this file first** before any code changes
- **One phase at a time** — do not begin Phase N+1 until Phase N deliverable is working
- **No placeholder implementations** — every function either works or is explicitly `todo!()` / `unimplemented!()`
- **Test asset parsing incrementally** — parse one file type, verify output in UI, then move to next
- **C# sidecar is a black box to Rust** — Rust only speaks JSON IPC, never references sidecar internals
- **SQLite is the source of truth** for indexed data — never re-parse files at runtime if SQLite has the data
- **Fake data state lives in React** — Zustand store, never in Rust or the sidecar
- **All Tauri commands return `Result<T, String>`** — errors surface to the frontend as readable messages
- **Asset pixel data is never stored in SQLite** — always read from .mul/.uop at request time, cached in Rust memory for the session
---
## Known Constraints & Decisions
| Decision | Rationale |
|---|---|
| Gump canvas fixed at 800×600 | Matches UO's original coordinate system; no scaling math needed |
| C# sidecar is a separate process | Roslyn requires .NET; keeping it isolated prevents Rust/C# FFI complexity |
| No live shard connection | Tool is purely local dev; no TCP/UDP to a running ServUO instance |
| Windows only (initial) | Tauri supports cross-platform but ServUO/.NET/UO client are Windows-primary |
| ServUO scripts not compiled | Roslyn used in analysis mode only; no attempt to actually build/run scripts |
| Open source MIT | Consistent with all Whitlocktech projects |
---
## Reference Resources
- **ClassicUO** (open source UO client, C#) — reference for all .mul/.uop parsers: https://github.com/ClassicUO/ClassicUO
- **UOFiddler** (open source UO asset editor, C#) — reference for tiledata, art, gump rendering: https://github.com/polserver/UOFiddler
- **ServUO** — target script codebase: https://github.com/ServUO/ServUO
- **Roslyn docs** — https://docs.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/
- **Tauri docs** — https://tauri.app/
- **mul format reference** — http://docs.polserver.com/packets/index.php (UO file format documentation)

Binary file not shown.

195
README.md Normal file
View File

@@ -0,0 +1,195 @@
# Artificer's Scrollwork
A local development workbench for ServUO shard developers. Browse UO client assets (statics, mobiles, gumps), navigate ServUO C# scripts with full class and method trees, trace method call chains as interactive flow diagrams, and render Gump classes visually — all without a live shard connection.
Built with **Tauri** (Rust shell) + **React** frontend + **C# Roslyn sidecar**.
---
## Requirements
You need the following installed before you can build or run the app.
### 1. Rust (stable)
Download and run the installer from https://rustup.rs
During installation, accept the default options. When it finishes, open a new terminal and verify:
```
rustc --version
cargo --version
```
### 2. Node.js 20 or later
Download the LTS installer from https://nodejs.org
After installing, verify:
```
node --version
npm --version
```
### 3. .NET 8 SDK
Download from https://dotnet.microsoft.com/download/dotnet/8.0
Choose the **SDK** (not Runtime) for Windows x64. After installing, verify:
```
dotnet --version
```
Should print `8.x.x` or higher.
### 4. Tauri CLI
After Rust is installed, run:
```
cargo install tauri-cli
```
This takes a few minutes. Verify with:
```
cargo tauri --version
```
### 5. WebView2 Runtime
Required by Tauri on Windows. Most Windows 10/11 machines already have it via Microsoft Edge. If the app fails to launch, download it from:
https://developer.microsoft.com/microsoft-edge/webview2/
---
## Getting Started
### Clone the repository
```
git clone https://gitea.whitlocktech.com/whitlocktech/Artificers-Scrollwork.git
cd Artificers-Scrollwork
```
### Install JavaScript dependencies
```
npm install
```
### Build the C# sidecar
The sidecar handles Roslyn script parsing. It must be compiled before running the app.
```
cd sidecar
dotnet publish -c Release -r win-x64 --self-contained
cd ..
```
Then copy the compiled binary into the Tauri binaries folder:
```
copy sidecar\bin\Release\net8.0\win-x64\publish\asw-sidecar.exe src-tauri\binaries\asw-sidecar-x86_64-pc-windows-msvc.exe
```
---
## Running in Dev Mode
Dev mode runs the app with hot reload. The Rust backend and React frontend reload automatically as you make changes.
Make sure you have completed the sidecar build step above, then run:
```
npm run tauri dev
```
The app window will open. First launch may take a minute while Rust compiles the backend.
On first run, use the Config screen (gear icon, top right) to set:
- **UO Root** — path to your Ultima Online client folder (must contain `art.mul` or `artLegacyMUL.uop`)
- **ServUO Scripts** — path to your ServUO `Scripts/` folder
---
## Building the MSI Installer
### Windows Defender note
The Tauri bundler patches the compiled `.exe` immediately after Rust finishes building it. Windows Defender or the Program Compatibility Assistant can lock the file during this window and cause the build to fail with error 1224.
**Before building**, add the target folder to Windows Defender exclusions:
1. Open **Windows Security****Virus & threat protection****Manage settings**
2. Scroll to **Exclusions****Add or remove exclusions**
3. Add this folder: `<repo root>\src-tauri\target`
If the build still fails, run this in an **Administrator** PowerShell before building:
```powershell
Stop-Service PcaSvc -Force
npm run tauri build
Start-Service PcaSvc
```
### Build steps
Make sure the sidecar is compiled and copied (see above), then:
```
npm run tauri build
```
The finished MSI will be at:
```
src-tauri\target\release\bundle\msi\Artificer's Scrollwork_0.1.0_x64_en-US.msi
```
Double-click the MSI to install. The app will appear in your Start menu as **Artificer's Scrollwork**.
---
## Project Structure
```
artificers-scrollwork/
├── src/ React frontend
├── src-tauri/ Rust / Tauri backend
│ ├── src/
│ │ ├── assets/ .mul / .uop file parsers
│ │ ├── commands/ Tauri IPC command handlers
│ │ └── ipc/ C# sidecar process management
│ └── binaries/ Compiled sidecar binary (git-ignored)
├── sidecar/ C# Roslyn sidecar
│ ├── Parsing/ Script indexer and call chain tracer
│ └── Gumps/ Gump constructor extractor
├── UO Gumps/ Bundled gump BMP art + XML manifests
└── UO artwork/ Bundled static tile BMP art
```
---
## Updating the Sidecar
If you change any C# code in `sidecar/`, you must recompile and copy the binary before the changes take effect:
```
cd sidecar
dotnet publish -c Release -r win-x64 --self-contained
cd ..
copy sidecar\bin\Release\net8.0\win-x64\publish\asw-sidecar.exe src-tauri\binaries\asw-sidecar-x86_64-pc-windows-msvc.exe
```
Then restart dev mode or rebuild the MSI.
---
## License
MIT — see LICENSE file.

935
UO Gumps/gumps.xml Normal file
View File

@@ -0,0 +1,935 @@
<?xml version="1.0" encoding="utf-8"?>
<Gumps>
<!-- id: gump index (decimal or 0x hex prefix accepted) -->
<!-- name: display name shown in the listbox -->
<!-- tags: comma-separated filter tags -->
<Gump id="60" name="backpack" tags="container" />
<Gump id="61" name="bag" tags="container" />
<Gump id="62" name="barrel" tags="container" />
<Gump id="63" name="basket" tags="container" />
<Gump id="64" name="pouch" tags="container" />
<Gump id="65" name="basket" tags="container" />
<Gump id="66" name="chest" tags="container" />
<Gump id="67" name="small chest" tags="container" />
<Gump id="68" name="crate" tags="container" />
<Gump id="71" name="cloth" tags="container" />
<Gump id="72" name="drawer" tags="container" />
<Gump id="73" name="wooden chest" tags="container" />
<Gump id="74" name="silver chest" tags="container" />
<Gump id="75" name="strong box" tags="container" />
<Gump id="76" name="hold" tags="container" />
<Gump id="77" name="bookshelf" tags="container" />
<Gump id="78" name="armoire" tags="container" />
<Gump id="79" name="armoire" tags="container" />
<Gump id="80" name="backpack icon" tags="container" />
<Gump id="81" name="drawer" tags="container" />
<Gump id="120" name="[F] savage" tags="paperdoll" />
<Gump id="121" name="[M] savage" tags="paperdoll" />
<Gump id="258" name="present box" tags="container" />
<Gump id="259" name="xmas sock" tags="container" />
<Gump id="260" name="drawer" tags="container" />
<Gump id="261" name="drawer" tags="container" />
<Gump id="262" name="drawer" tags="container" />
<Gump id="263" name="drawer" tags="container" />
<Gump id="264" name="basket" tags="container" />
<Gump id="265" name="drawer" tags="container" />
<Gump id="266" name="drawer" tags="container" />
<Gump id="267" name="drawer" tags="container" />
<Gump id="268" name="drawer" tags="container" />
<Gump id="269" name="drawer" tags="container" />
<Gump id="270" name="drawer" tags="container" />
<Gump id="50335" name="[M] Gargish Kite Shield" tags="equipment,male,two-hand" />
<Gump id="50336" name="[M] Gargish Wooden Shield" tags="equipment,male,two-hand" />
<Gump id="50337" name="[M] Large Plate Shield" tags="equipment,male,two-hand" />
<Gump id="50338" name="[M] Medium Plate Shield" tags="equipment,male,two-hand" />
<Gump id="50339" name="[M] Small Plate Shield" tags="equipment,male,two-hand" />
<Gump id="50340" name="[M] Large Stone Shield" tags="equipment,male,two-hand" />
<Gump id="50341" name="[M] Gargish Fancy Robe" tags="equipment,male,robe" />
<Gump id="50342" name="[M] Gargish Robe" tags="equipment,male,robe" />
<Gump id="50343" name="[M] Gargish Chaos Shield" tags="equipment,male,two-hand" />
<Gump id="50344" name="[M] Gargish Order Shield" tags="equipment,male,two-hand" />
<Gump id="50404" name="[M] floppy hat" tags="equipment,male,helmet" />
<Gump id="50405" name="[M] wide-brim hat" tags="equipment,male,helmet" />
<Gump id="50406" name="[M] cap" tags="equipment,male,helmet" />
<Gump id="50407" name="[M] tall straw hat" tags="equipment,male,helmet" />
<Gump id="50408" name="[M] straw hat" tags="equipment,male,helmet" />
<Gump id="50409" name="[M] wizard hat" tags="equipment,male,helmet" />
<Gump id="50410" name="[M] bonnet" tags="equipment,male,helmet" />
<Gump id="50411" name="[M] feathered hat" tags="equipment,male,helmet" />
<Gump id="50412" name="[M] tricorne hat" tags="equipment,male,helmet" />
<Gump id="50413" name="[M] jester hat" tags="equipment,male,helmet" />
<Gump id="50414" name="[M] bear mask" tags="equipment,male,helmet" />
<Gump id="50415" name="[M] deer mask" tags="equipment,male,helmet" />
<Gump id="50416" name="[M] orc mask" tags="equipment,male,helmet" />
<Gump id="50417" name="[M] tribal mask" tags="equipment,male,helmet" />
<Gump id="50418" name="[M] tribal mask" tags="equipment,male,helmet" />
<Gump id="50419" name="[M] skullcap" tags="equipment,male,helmet" />
<Gump id="50422" name="[M] backpack" tags="equipment,male,backpack" />
<Gump id="50423" name="[M] Leather Ninja Pants" tags="equipment,male,pants" />
<Gump id="50424" name="[M] Leather Ninja Mitts" tags="equipment,male,gloves" />
<Gump id="50425" name="[M] Leather Ninja Hood" tags="equipment,male,helmet" />
<Gump id="50426" name="[M] Leather Ninja Jacket" tags="equipment,male,chest-armor" />
<Gump id="50427" name="[M] MysticSpellBook East" tags="equipment,male,one-hand" />
<Gump id="50430" name="[M] short pants" tags="equipment,male,pants" />
<Gump id="50431" name="[M] long pants" tags="equipment,male,pants" />
<Gump id="50433" name="[M] Halloween Mask" tags="equipment,male,one-hand" />
<Gump id="50434" name="[M] shirt" tags="equipment,male,shirt" />
<Gump id="50435" name="[M] fancy shirt" tags="equipment,male,shirt" />
<Gump id="50436" name="[M] Gargish Bracelet" tags="equipment,male,bracelet" />
<Gump id="50437" name="[M] Gargish Earrings" tags="equipment,male,earring" />
<Gump id="50438" name="[M] Gargish Ring" tags="equipment,male,ring" />
<Gump id="50439" name="[M] Gargish Necklace" tags="equipment,male,gorget" />
<Gump id="50442" name="[M] Mail Hatsuburi" tags="equipment,male,helmet" />
<Gump id="50443" name="[M] Plate Hatsuburi" tags="equipment,male,helmet" />
<Gump id="50444" name="[M] Leather Jingasa" tags="equipment,male,helmet" />
<Gump id="50445" name="[M] Plate Jingasa" tags="equipment,male,helmet" />
<Gump id="50446" name="[M] Plate Jingasa" tags="equipment,male,helmet" />
<Gump id="50447" name="[M] plain dress" tags="equipment,male,robe" />
<Gump id="50448" name="[M] fancy dress" tags="equipment,male,robe" />
<Gump id="50449" name="[M] skirt" tags="equipment,male,skirt" />
<Gump id="50450" name="[M] Plate Jingasa" tags="equipment,male,helmet" />
<Gump id="50451" name="[M] Plate Kabuto" tags="equipment,male,helmet" />
<Gump id="50455" name="[M] kilt" tags="equipment,male,skirt" />
<Gump id="50456" name="[M] Plate Mempo" tags="equipment,male,gorget" />
<Gump id="50457" name="[M] Leather Do" tags="equipment,male,chest-armor" />
<Gump id="50458" name="[M] Studded Do" tags="equipment,male,chest-armor" />
<Gump id="50459" name="[M] Plate Do" tags="equipment,male,chest-armor" />
<Gump id="50462" name="[M] Plate Kabuto" tags="equipment,male,helmet" />
<Gump id="50463" name="[M] Plate Kabuto" tags="equipment,male,helmet" />
<Gump id="50464" name="[M] Wakizashi" tags="equipment,male,one-hand" />
<Gump id="50465" name="[M] full apron" tags="equipment,male,tunic" />
<Gump id="50466" name="[M] half apron" tags="equipment,male,waist" />
<Gump id="50467" name="[M] Bokuto" tags="equipment,male,one-hand" />
<Gump id="50468" name="[M] cloak" tags="equipment,male,cloak" />
<Gump id="50469" name="[M] robe" tags="equipment,male,robe" />
<Gump id="50470" name="[M] Daisho" tags="equipment,male,two-hand" />
<Gump id="50471" name="[M] Leather Mempo" tags="equipment,male,gorget" />
<Gump id="50472" name="[M] Studded Mempo" tags="equipment,male,gorget" />
<Gump id="50473" name="[M] Samurai Armor Arms" tags="equipment,male,sleeves" />
<Gump id="50474" name="[M] Samurai Armor Arms" tags="equipment,male,sleeves" />
<Gump id="50475" name="[M] Female Gargoyle Horn" tags="equipment,male,hair" />
<Gump id="50476" name="[M] thigh boots" tags="equipment,male,boots" />
<Gump id="50477" name="[M] boots" tags="equipment,male,boots" />
<Gump id="50478" name="[M] Samurai Armor Arms" tags="equipment,male,sleeves" />
<Gump id="50479" name="[M] sandals" tags="equipment,male,boots" />
<Gump id="50480" name="[M] shoes" tags="equipment,male,boots" />
<Gump id="50483" name="[M] Male Gargoyle Horns" tags="equipment,male,hair" />
<Gump id="50484" name="[M] fur surong" tags="equipment,male,skirt" />
<Gump id="50485" name="[M] Male Gargoyle Horns" tags="equipment,male,hair" />
<Gump id="50487" name="[M] Leather Suneate" tags="equipment,male,pants" />
<Gump id="50488" name="[M] Studded Suneate" tags="equipment,male,pants" />
<Gump id="50489" name="[M] Plate Suneate" tags="equipment,male,pants" />
<Gump id="50490" name="[M] body sash" tags="equipment,male,tunic" />
<Gump id="50491" name="[M] Leather Haidate" tags="equipment,male,pants" />
<Gump id="50492" name="[M] bandana" tags="equipment,male,helmet" />
<Gump id="50493" name="[M] necklace" tags="equipment,male,gorget" />
<Gump id="50494" name="[M] necklace" tags="equipment,male,gorget" />
<Gump id="50495" name="[M] bracelet" tags="equipment,male,bracelet" />
<Gump id="50496" name="[M] earrings" tags="equipment,male,earring" />
<Gump id="50497" name="[M] necklace" tags="equipment,male,gorget" />
<Gump id="50498" name="[M] ring" tags="equipment,male,ring" />
<Gump id="50499" name="[M] Male Gargoyle Head Horns" tags="equipment,male,hair" />
<Gump id="50500" name="[M] lantern" tags="equipment,male,two-hand" />
<Gump id="50501" name="[M] torch" tags="equipment,male,two-hand" />
<Gump id="50502" name="[M] candle" tags="equipment,male,two-hand" />
<Gump id="50503" name="[M] lantern" tags="equipment,male,two-hand" />
<Gump id="50504" name="[M] torches" tags="equipment,male,two-hand" />
<Gump id="50505" name="[M] Candle" tags="equipment,male,two-hand" />
<Gump id="50506" name="[M] Plate Haidate" tags="equipment,male,pants" />
<Gump id="50507" name="[M] silver necklace" tags="equipment,male,gorget" />
<Gump id="50508" name="[M] bracelet" tags="equipment,male,bracelet" />
<Gump id="50510" name="[M] necklace" tags="equipment,male,gorget" />
<Gump id="50511" name="[M] ring" tags="equipment,male,ring" />
<Gump id="50512" name="[M] Studded Haidate" tags="equipment,male,pants" />
<Gump id="50513" name="[M] Leather Ninja Hood" tags="equipment,male,helmet" />
<Gump id="50514" name="[M] Leather Ninja Belt" tags="equipment,male,waist" />
<Gump id="50516" name="[M] Kasa" tags="equipment,male,helmet" />
<Gump id="50517" name="[M] Kamishimo" tags="equipment,male,robe" />
<Gump id="50518" name="[M] Hakama" tags="equipment,male,skirt" />
<Gump id="50519" name="[M] No-Dachi" tags="equipment,male,two-hand" />
<Gump id="50520" name="[M] Tessen" tags="equipment,male,two-hand" />
<Gump id="50521" name="[M] studded tunic" tags="equipment,male,chest-armor" />
<Gump id="50522" name="[M] studded leggings" tags="equipment,male,pants" />
<Gump id="50523" name="[M] studded sleeves" tags="equipment,male,sleeves" />
<Gump id="50524" name="[M] studded gloves" tags="equipment,male,gloves" />
<Gump id="50525" name="[M] studded gorget" tags="equipment,male,gorget" />
<Gump id="50526" name="[M] Lajatang" tags="equipment,male,two-hand" />
<Gump id="50527" name="[M] platemail" tags="equipment,male,chest-armor" />
<Gump id="50528" name="[M] platemail arms" tags="equipment,male,sleeves" />
<Gump id="50529" name="[M] platemail legs" tags="equipment,male,pants" />
<Gump id="50530" name="[M] platemail gloves" tags="equipment,male,gloves" />
<Gump id="50531" name="[M] platemail gorget" tags="equipment,male,gorget" />
<Gump id="50532" name="[M] Fukiya" tags="equipment,male,one-hand" />
<Gump id="50533" name="[M] Tekagi" tags="equipment,male,two-hand" />
<Gump id="50534" name="[M] Horns Facial Gargoyle" tags="equipment,male,hair" />
<Gump id="50535" name="[M] Kama" tags="equipment,male,two-hand" />
<Gump id="50536" name="[M] Nunchaku" tags="equipment,male,two-hand" />
<Gump id="50537" name="[M] Sai" tags="equipment,male,two-hand" />
<Gump id="50538" name="[M] chainmail tunic" tags="equipment,male,chest-armor" />
<Gump id="50540" name="[M] chainmail leggings" tags="equipment,male,pants" />
<Gump id="50542" name="[M] leather tunic" tags="equipment,male,chest-armor" />
<Gump id="50543" name="[M] leather leggings" tags="equipment,male,pants" />
<Gump id="50544" name="[M] leather sleeves" tags="equipment,male,sleeves" />
<Gump id="50545" name="[M] Leather Gloves" tags="equipment,male,gloves" />
<Gump id="50546" name="[M] leather gorget" tags="equipment,male,gorget" />
<Gump id="50548" name="[M] ringmail tunic" tags="equipment,male,chest-armor" />
<Gump id="50549" name="[M] ringmail leggings" tags="equipment,male,pants" />
<Gump id="50550" name="[M] ringmail sleeves" tags="equipment,male,sleeves" />
<Gump id="50552" name="[M] Tattsuke-Hakama" tags="equipment,male,pants" />
<Gump id="50553" name="[M] Hakama-Shita" tags="equipment,male,robe" />
<Gump id="50554" name="[M] bone armor" tags="equipment,male,chest-armor" />
<Gump id="50555" name="[M] bone leggings" tags="equipment,male,pants" />
<Gump id="50556" name="[M] bone arms" tags="equipment,male,sleeves" />
<Gump id="50557" name="[M] bone gloves" tags="equipment,male,gloves" />
<Gump id="50558" name="[M] Male Kimono" tags="equipment,male,robe" />
<Gump id="50559" name="[M] Female Kimono" tags="equipment,male,robe" />
<Gump id="50560" name="[M] leather cap" tags="equipment,male,helmet" />
<Gump id="50561" name="[M] chainmail coif" tags="equipment,male,helmet" />
<Gump id="50562" name="[M] bone helmet" tags="equipment,male,helmet" />
<Gump id="50563" name="[M] plate helm" tags="equipment,male,helmet" />
<Gump id="50564" name="[M] bascinet" tags="equipment,male,helmet" />
<Gump id="50565" name="[M] helmet" tags="equipment,male,helmet" />
<Gump id="50566" name="[M] kabuto" tags="equipment,male,helmet" />
<Gump id="50567" name="[M] Obi" tags="equipment,male,waist" />
<Gump id="50568" name="[M] Jin-Baori" tags="equipment,male,tunic" />
<Gump id="50569" name="[M] Samurai Sandal/Tabi" tags="equipment,male,boots" />
<Gump id="50571" name="[M] Cloth Ninja Jacket" tags="equipment,male,chest-armor" />
<Gump id="50572" name="[M] Ninja Tall Tabi" tags="equipment,male,boots" />
<Gump id="50575" name="[M] Yumi" tags="equipment,male,two-hand" />
<Gump id="50576" name="[M] metal shield" tags="equipment,male,two-hand" />
<Gump id="50577" name="[M] bronze shield" tags="equipment,male,two-hand" />
<Gump id="50578" name="[M] wooden shield" tags="equipment,male,two-hand" />
<Gump id="50579" name="[M] buckler" tags="equipment,male,two-hand" />
<Gump id="50580" name="[M] kite shield" tags="equipment,male,two-hand" />
<Gump id="50581" name="[M] kite shield" tags="equipment,male,two-hand" />
<Gump id="50582" name="[M] heater shield" tags="equipment,male,two-hand" />
<Gump id="50583" name="[M] Tetsubo" tags="equipment,male,two-hand" />
<Gump id="50585" name="[M] gargoyle leather arms" tags="equipment,male,sleeves" />
<Gump id="50586" name="[M] gargoyle leather arms" tags="equipment,male,sleeves" />
<Gump id="50587" name="[M] gargoyle leather chest" tags="equipment,male,chest-armor" />
<Gump id="50588" name="[M] gargoyle leather chest" tags="equipment,male,chest-armor" />
<Gump id="50589" name="[M] gargoyle leather legs" tags="equipment,male,pants" />
<Gump id="50590" name="[M] gargoyle leather legs" tags="equipment,male,pants" />
<Gump id="50591" name="[M] gargoyle plate arms" tags="equipment,male,sleeves" />
<Gump id="50592" name="[M] gargoyle plate arms" tags="equipment,male,sleeves" />
<Gump id="50593" name="[M] gargoyle plate chest" tags="equipment,male,chest-armor" />
<Gump id="50594" name="[M] gargoyle plate chest" tags="equipment,male,chest-armor" />
<Gump id="50595" name="[M] gargoyle plate kilt" tags="equipment,male,gloves" />
<Gump id="50596" name="[M] gargoyle plate kilt" tags="equipment,male,gloves" />
<Gump id="50597" name="[M] gargoyle plate legs" tags="equipment,male,pants" />
<Gump id="50598" name="[M] gargoyle plate legs" tags="equipment,male,pants" />
<Gump id="50599" name="[M] gargoyle stone arms" tags="equipment,male,sleeves" />
<Gump id="50600" name="[M] gargoyle stone arms" tags="equipment,male,sleeves" />
<Gump id="50601" name="[M] gargoyle stone chest" tags="equipment,male,chest-armor" />
<Gump id="50602" name="[M] gargoyle stone chest" tags="equipment,male,chest-armor" />
<Gump id="50603" name="[M] gargoyle stone kilt" tags="equipment,male,gloves" />
<Gump id="50604" name="[M] gargoyle stone kilt" tags="equipment,male,gloves" />
<Gump id="50605" name="[M] scale shield" tags="equipment,male,two-hand" />
<Gump id="50609" name="[M] gargoyle stone legs" tags="equipment,male,pants" />
<Gump id="50610" name="[M] gargoyle stone legs" tags="equipment,male,pants" />
<Gump id="50611" name="[M] large battle axe" tags="equipment,male,two-hand" />
<Gump id="50612" name="[M] two handed axe" tags="equipment,male,two-hand" />
<Gump id="50613" name="[M] executioner axe" tags="equipment,male,two-hand" />
<Gump id="50614" name="[M] bardiche" tags="equipment,male,two-hand" />
<Gump id="50615" name="[M] hatchet" tags="equipment,male,two-hand" />
<Gump id="50616" name="[M] heavy crossbow" tags="equipment,male,one-hand" />
<Gump id="50617" name="[M] black staff" tags="equipment,male,two-hand" />
<Gump id="50618" name="[M] broadsword" tags="equipment,male,one-hand" />
<Gump id="50619" name="[M] cleaver" tags="equipment,male,one-hand" />
<Gump id="50620" name="[M] club" tags="equipment,male,one-hand" />
<Gump id="50621" name="[M] shepherds crook" tags="equipment,male,two-hand" />
<Gump id="50622" name="[M] dagger" tags="equipment,male,one-hand" />
<Gump id="50623" name="[M] cutlass" tags="equipment,male,one-hand" />
<Gump id="50624" name="[M] halberd" tags="equipment,male,two-hand" />
<Gump id="50625" name="[M] hammer pick" tags="equipment,male,one-hand" />
<Gump id="50626" name="[M] javelin" tags="equipment,male,two-hand" />
<Gump id="50627" name="[M] katana" tags="equipment,male,one-hand" />
<Gump id="50628" name="[M] gnarled staff" tags="equipment,male,two-hand" />
<Gump id="50629" name="[M] butcher knife" tags="equipment,male,one-hand" />
<Gump id="50630" name="[M] kryss" tags="equipment,male,one-hand" />
<Gump id="50631" name="[M] mace" tags="equipment,male,one-hand" />
<Gump id="50632" name="[M] Female Gargoyle Horn" tags="equipment,male,hair" />
<Gump id="50633" name="[M] maul" tags="equipment,male,one-hand" />
<Gump id="50634" name="[M] double axe" tags="equipment,male,two-hand" />
<Gump id="50635" name="[M] pickaxe" tags="equipment,male,one-hand" />
<Gump id="50636" name="[M] pitchfork" tags="equipment,male,two-hand" />
<Gump id="50637" name="[M] scimitar" tags="equipment,male,one-hand" />
<Gump id="50638" name="[M] skinning knife" tags="equipment,male,one-hand" />
<Gump id="50639" name="[M] short spear" tags="equipment,male,two-hand" />
<Gump id="50640" name="[M] sledge hammer" tags="equipment,male,one-hand" />
<Gump id="50641" name="[M] Long Spear" tags="equipment,male,two-hand" />
<Gump id="50642" name="[M] war mace" tags="equipment,male,one-hand" />
<Gump id="50643" name="[M] viking sword" tags="equipment,male,one-hand" />
<Gump id="50644" name="[M] war axe" tags="equipment,male,one-hand" />
<Gump id="50645" name="[M] war fork" tags="equipment,male,one-hand" />
<Gump id="50646" name="[M] war hammer" tags="equipment,male,one-hand" />
<Gump id="50647" name="[M] Horns Facial Gargoyle" tags="equipment,male,hair" />
<Gump id="50648" name="[M] quarter staff" tags="equipment,male,two-hand" />
<Gump id="50649" name="[M] bow" tags="equipment,male,one-hand" />
<Gump id="50650" name="[M] Horns Facial Gargoyle" tags="equipment,male,hair" />
<Gump id="50651" name="[M] crossbow" tags="equipment,male,one-hand" />
<Gump id="50652" name="[M] Horns Facial Gargoyle" tags="equipment,male,hair" />
<Gump id="50653" name="[M] axe" tags="equipment,male,two-hand" />
<Gump id="50655" name="[M] gargoyle clothing arms" tags="equipment,male,sleeves" />
<Gump id="50657" name="[M] gargoyle clothing chest" tags="equipment,male,chest-armor" />
<Gump id="50659" name="[M] gargoyle clothing kilt" tags="equipment,male,gloves" />
<Gump id="50661" name="[M] gargoyle clothing legs" tags="equipment,male,pants" />
<Gump id="50662" name="[M] Dual Short Axes" tags="equipment,male,two-hand" />
<Gump id="50663" name="[M] gargoyle leather kilt" tags="equipment,male,gloves" />
<Gump id="50664" name="[M] gargoyle leather kilt" tags="equipment,male,gloves" />
<Gump id="50665" name="[M] Horns Facial Gargoyle" tags="equipment,male,hair" />
<Gump id="50668" name="[M] Bloodblade" tags="equipment,male,one-hand" />
<Gump id="50669" name="[M] Boomerang" tags="equipment,male,one-hand" />
<Gump id="50670" name="[M] Stone War Sword" tags="equipment,male,one-hand" />
<Gump id="50671" name="[M] Dread Sword" tags="equipment,male,one-hand" />
<Gump id="50672" name="[M] Cyclone" tags="equipment,male,one-hand" />
<Gump id="50673" name="[M] Gargish Dagger" tags="equipment,male,one-hand" />
<Gump id="50674" name="[M] Disc Mace" tags="equipment,male,one-hand" />
<Gump id="50675" name="[M] Dual Pointed Spear" tags="equipment,male,two-hand" />
<Gump id="50676" name="[M] Glass Staff" tags="equipment,male,two-hand" />
<Gump id="50677" name="[M] Serpentstone Staff" tags="equipment,male,two-hand" />
<Gump id="50678" name="[M] Shortblade" tags="equipment,male,one-hand" />
<Gump id="50679" name="[M] dragon sleeves" tags="equipment,male,sleeves" />
<Gump id="50680" name="[M] dragon breastplate" tags="equipment,male,chest-armor" />
<Gump id="50681" name="[M] dragon gloves" tags="equipment,male,gloves" />
<Gump id="50682" name="[M] dragon helm" tags="equipment,male,helmet" />
<Gump id="50683" name="[M] dragon leggings" tags="equipment,male,pants" />
<Gump id="50684" name="[M] amazon armor" tags="equipment,male,shirt" />
<Gump id="50690" name="[M] Gargish Talwar" tags="equipment,male,two-hand" />
<Gump id="50691" name="[M] Glass Sword" tags="equipment,male,one-hand" />
<Gump id="50693" name="[M] Soul Glaive" tags="equipment,male,one-hand" />
<Gump id="50696" name="[M] Gargoyle Horns" tags="equipment,male,hair" />
<Gump id="50697" name="[M] gargoyle male Horns" tags="equipment,male,hair" />
<Gump id="50698" name="[M] Male gargoyle Horns" tags="equipment,male,hair" />
<Gump id="50699" name="[M] Gargoyle Head Horns" tags="equipment,male,hair" />
<Gump id="50701" name="[M] Long Hair" tags="equipment,male,hair" />
<Gump id="50702" name="[M] Ponytail" tags="equipment,male,hair" />
<Gump id="50713" name="[M] feathered mask" tags="equipment,male,helmet" />
<Gump id="50715" name="[M] jester pants" tags="equipment,male,pants" />
<Gump id="50800" name="[M] goatee" tags="equipment,male,facial-hair" />
<Gump id="50801" name="[M] long beard" tags="equipment,male,facial-hair" />
<Gump id="50802" name="[M] short beard" tags="equipment,male,facial-hair" />
<Gump id="50803" name="[M] mustache" tags="equipment,male,facial-hair" />
<Gump id="50843" name="[M] Horns Gargoyle Male" tags="equipment,male,hair" />
<Gump id="50866" name="[M] winged helmet" tags="equipment,male,helmet" />
<Gump id="50877" name="[M] Spell weaving book" tags="equipment,male,one-hand" />
<Gump id="50878" name="[M] book of ninjitsu" tags="equipment,male,one-hand" />
<Gump id="50879" name="[M] book of bushido" tags="equipment,male,one-hand" />
<Gump id="50880" name="[M] spellbook" tags="equipment,male,one-hand" />
<Gump id="50882" name="[M] necromancer book" tags="equipment,male,one-hand" />
<Gump id="50883" name="[M] book of arms" tags="equipment,male,one-hand" />
<Gump id="50884" name="[M] paladin spellbook" tags="equipment,male,one-hand" />
<Gump id="50886" name="[M] Lt Armor Gorget" tags="equipment,male,gorget" />
<Gump id="50890" name="[M] Elven Hair" tags="equipment,male,hair" />
<Gump id="50891" name="[M] Elven Hair" tags="equipment,male,hair" />
<Gump id="50892" name="[M] Elven Hair" tags="equipment,male,hair" />
<Gump id="50893" name="[M] Elven Hair" tags="equipment,male,hair" />
<Gump id="50894" name="[M] Elven Hair" tags="equipment,male,hair" />
<Gump id="50895" name="[M] Elven Hair" tags="equipment,male,hair" />
<Gump id="50896" name="[M] elven quiver" tags="equipment,male,cloak" />
<Gump id="50897" name="[M] elven glasses" tags="equipment,male,helmet" />
<Gump id="50898" name="[M] elven robe" tags="equipment,male,robe" />
<Gump id="50899" name="[M] elven robe" tags="equipment,male,robe" />
<Gump id="50907" name="[M] elven shirt" tags="equipment,male,shirt" />
<Gump id="50908" name="[M] elven shirt" tags="equipment,male,shirt" />
<Gump id="50909" name="[M] tunic" tags="equipment,male,tunic" />
<Gump id="50910" name="[M] surcoat" tags="equipment,male,tunic" />
<Gump id="50911" name="[M] checkered shirt" tags="equipment,male,shirt" />
<Gump id="50912" name="[M] jester suit" tags="equipment,male,tunic" />
<Gump id="50913" name="[M] doublet" tags="equipment,male,tunic" />
<Gump id="50914" name="[M] elven shirt" tags="equipment,male,shirt" />
<Gump id="50915" name="[M] elven shirt" tags="equipment,male,shirt" />
<Gump id="50916" name="[M] Elven Hair BL" tags="equipment,male,hair" />
<Gump id="50917" name="[M] hair" tags="equipment,male,hair" />
<Gump id="50918" name="[M] hair" tags="equipment,male,hair" />
<Gump id="50919" name="[M] Elven Hair BR" tags="equipment,male,hair" />
<Gump id="50920" name="[M] Elven Pants" tags="equipment,male,pants" />
<Gump id="50921" name="[M] studded armor" tags="equipment,male,chest-armor" />
<Gump id="50922" name="[M] plate armor" tags="equipment,male,chest-armor" />
<Gump id="50923" name="[M] leather armor" tags="equipment,male,chest-armor" />
<Gump id="50924" name="[M] leather bustier" tags="equipment,male,chest-armor" />
<Gump id="50925" name="[M] studded bustier" tags="equipment,male,chest-armor" />
<Gump id="50926" name="[M] leather skirt" tags="equipment,male,pants" />
<Gump id="50927" name="[M] leather shorts" tags="equipment,male,pants" />
<Gump id="50928" name="[M] close helmet" tags="equipment,male,helmet" />
<Gump id="50929" name="[M] norse helm" tags="equipment,male,helmet" />
<Gump id="50930" name="[M] scythe" tags="equipment,male,two-hand" />
<Gump id="50931" name="[M] bone harvester" tags="equipment,male,one-hand" />
<Gump id="50932" name="[M] scepter" tags="equipment,male,one-hand" />
<Gump id="50933" name="[M] bladed staff" tags="equipment,male,two-hand" />
<Gump id="50934" name="[M] pike" tags="equipment,male,two-hand" />
<Gump id="50935" name="[M] double bladed staff" tags="equipment,male,two-hand" />
<Gump id="50936" name="[M] lance" tags="equipment,male,one-hand" />
<Gump id="50937" name="[M] crescent blade" tags="equipment,male,two-hand" />
<Gump id="50938" name="[M] composite bow" tags="equipment,male,two-hand" />
<Gump id="50939" name="[M] repeating crossbow" tags="equipment,male,two-hand" />
<Gump id="50940" name="[M] paladin sword" tags="equipment,male,one-hand" />
<Gump id="50941" name="[M] Elven Boot" tags="equipment,male,boots" />
<Gump id="50942" name="[M] Elven Plate" tags="equipment,male,chest-armor" />
<Gump id="50943" name="[M] Elven Plate Belt" tags="equipment,male,waist" />
<Gump id="50944" name="[M] Elven Plate Gorget" tags="equipment,male,gorget" />
<Gump id="50945" name="[M] Elven Plate Gloves" tags="equipment,male,gloves" />
<Gump id="50946" name="[M] Elven Plate Legs" tags="equipment,male,pants" />
<Gump id="50947" name="[M] Elven Arm Plate" tags="equipment,male,sleeves" />
<Gump id="50948" name="[M] Elven Plate" tags="equipment,male,chest-armor" />
<Gump id="50950" name="[M] Elven Composite Longbow" tags="equipment,male,two-hand" />
<Gump id="50951" name="[M] Magical Shortbow" tags="equipment,male,two-hand" />
<Gump id="50952" name="[M] Elven Spellblade" tags="equipment,male,two-hand" />
<Gump id="50953" name="[M] Assassin Spike" tags="equipment,male,one-hand" />
<Gump id="50954" name="[M] Leafblade" tags="equipment,male,one-hand" />
<Gump id="50955" name="[M] War Cleaver" tags="equipment,male,one-hand" />
<Gump id="50956" name="[M] Diamond Mace" tags="equipment,male,one-hand" />
<Gump id="50957" name="[M] Wild Staff" tags="equipment,male,one-hand" />
<Gump id="50958" name="[M] Rune Blade" tags="equipment,male,two-hand" />
<Gump id="50959" name="[M] Radiant Scimitar" tags="equipment,male,one-hand" />
<Gump id="50960" name="[M] Ornate Axe" tags="equipment,male,two-hand" />
<Gump id="50961" name="[M] Elven Machete" tags="equipment,male,one-hand" />
<Gump id="50962" name="[M] circlet" tags="equipment,male,helmet" />
<Gump id="50963" name="[M] royal circlet" tags="equipment,male,helmet" />
<Gump id="50964" name="[M] gemmed circlet" tags="equipment,male,helmet" />
<Gump id="50965" name="[M] Raven Helm" tags="equipment,male,helmet" />
<Gump id="50966" name="[M] Vulture Helm" tags="equipment,male,helmet" />
<Gump id="50967" name="[M] Winged Helm" tags="equipment,male,helmet" />
<Gump id="50968" name="[M] Hide Chest" tags="equipment,male,chest-armor" />
<Gump id="50969" name="[M] Hide Gloves" tags="equipment,male,gloves" />
<Gump id="50970" name="[M] death shroud" tags="equipment,male,robe" />
<Gump id="50972" name="[M] fishing pole" tags="equipment,male,two-hand" />
<Gump id="50973" name="[M] orc helm" tags="equipment,male,helmet" />
<Gump id="50974" name="[M] Hide Gorget" tags="equipment,male,gorget" />
<Gump id="50975" name="[M] Hide Paldrons" tags="equipment,male,sleeves" />
<Gump id="50976" name="[M] Hide Pants" tags="equipment,male,pants" />
<Gump id="50977" name="[M] Hide Female Chest" tags="equipment,male,chest-armor" />
<Gump id="50978" name="[M] Lt Armor Chest" tags="equipment,male,chest-armor" />
<Gump id="50979" name="[M] Lt Armor Gloves" tags="equipment,male,gloves" />
<Gump id="50980" name="[M] wand" tags="equipment,male,one-hand" />
<Gump id="50981" name="[M] wand" tags="equipment,male,one-hand" />
<Gump id="50982" name="[M] wand" tags="equipment,male,one-hand" />
<Gump id="50983" name="[M] wand" tags="equipment,male,one-hand" />
<Gump id="50984" name="[M] spellbook" tags="equipment,male,one-hand" />
<Gump id="50987" name="[M] GM Robe" tags="equipment,male,robe" />
<Gump id="50989" name="[M] Lt Armor Paldrons" tags="equipment,male,sleeves" />
<Gump id="50992" name="[M] Order shield" tags="equipment,male,two-hand" />
<Gump id="50993" name="[M] Chaos shield" tags="equipment,male,two-hand" />
<Gump id="50995" name="[M] Lt Armor Pants" tags="equipment,male,pants" />
<Gump id="50996" name="[M] Lt Armor Kilt" tags="equipment,male,pants" />
<Gump id="50997" name="[M] LtF Chest" tags="equipment,male,chest-armor" />
<Gump id="51027" name="[M] Gargoyle Bardiche East" tags="equipment,male,two-hand" />
<Gump id="51028" name="[M] Gargoyle BattleAxe East" tags="equipment,male,two-hand" />
<Gump id="51029" name="[M] Gargoyle Scythe South" tags="equipment,male,one-hand" />
<Gump id="51030" name="[M] Gargoyle Butchers Knife" tags="equipment,male,one-hand" />
<Gump id="51032" name="[M] Gargoyle Axe East" tags="equipment,male,one-hand" />
<Gump id="51033" name="[M] Gargoyle Daisho East" tags="equipment,male,two-hand" />
<Gump id="51034" name="[M] Gargoyle Katana East" tags="equipment,male,one-hand" />
<Gump id="51035" name="[M] Gargoyle Kriss Knife" tags="equipment,male,one-hand" />
<Gump id="51036" name="[M] Gargoyle Pike South" tags="equipment,male,one-hand" />
<Gump id="51037" name="[M] Gargoyle Maul East" tags="equipment,male,one-hand" />
<Gump id="51040" name="[M] Gargoyle Pike East" tags="equipment,male,two-hand" />
<Gump id="51041" name="[M] Gargoyle Scythe East" tags="equipment,male,two-hand" />
<Gump id="51042" name="[M] Gargoyle Staff Gnarled" tags="equipment,male,two-hand" />
<Gump id="51043" name="[M] Gargoyle Tekagi East" tags="equipment,male,two-hand" />
<Gump id="51044" name="[M] Gargoyle Tessen East" tags="equipment,male,two-hand" />
<Gump id="51045" name="[M] Gargoyle War Fork East" tags="equipment,male,one-hand" />
<Gump id="51046" name="[M] Gargoyle War Hammer East" tags="equipment,male,one-hand" />
<Gump id="51047" name="[M] Gargoyle Axe East" tags="equipment,male,two-hand" />
<Gump id="51049" name="[M] Talons Leather South" tags="equipment,male,boots" />
<Gump id="51051" name="[M] Plate Talons South" tags="equipment,male,boots" />
<Gump id="51053" name="[M] Gargoyle Belt South" tags="equipment,male,waist" />
<Gump id="51054" name="[M] Gargoyle Half Apron South" tags="equipment,male,waist" />
<Gump id="51056" name="[M] Wing Armor Cloth South" tags="equipment,male,cloak" />
<Gump id="51058" name="[M] Wing Armor Leather South" tags="equipment,male,cloak" />
<Gump id="51062" name="[M] Bard Spell Book" tags="equipment,male,one-hand" />
<Gump id="51064" name="[M] Gargoyle Glasses South" tags="equipment,male,earring" />
<Gump id="51066" name="[M] Gargoyle Sash Male South" tags="equipment,male,tunic" />
<Gump id="51072" name="[M] Gargish Necklace" tags="equipment,male,gorget" />
<Gump id="51073" name="[M] Gargish Necklace" tags="equipment,male,gorget" />
<Gump id="51234" name="[M] Talisman" tags="equipment,male,talisman" />
<Gump id="51235" name="[M] Talisman" tags="equipment,male,talisman" />
<Gump id="51236" name="[M] Talisman" tags="equipment,male,talisman" />
<Gump id="51237" name="[M] Talisman" tags="equipment,male,talisman" />
<Gump id="51238" name="[M] Talisman" tags="equipment,male,talisman" />
<Gump id="51249" name="[M] 15th Ann Robe South" tags="equipment,male,robe" />
<Gump id="51251" name="[M] 15th Ann Robe Gargoyle South" tags="equipment,male,helmet" />
<Gump id="51257" name="[M] Epaulets Gargoyle South" tags="equipment,male,robe" />
<Gump id="51259" name="[M] Epaulets Human South" tags="equipment,male,robe" />
<Gump id="51261" name="[M] Robe BG South" tags="equipment,male,robe" />
<Gump id="51263" name="[M] Shield Vice" tags="equipment,male,two-hand" />
<Gump id="51265" name="[M] Shield Virtue" tags="equipment,male,two-hand" />
<Gump id="51267" name="[M] Jester Shoes" tags="equipment,male,boots" />
<Gump id="51269" name="[M] Chef Hat" tags="equipment,male,helmet" />
<Gump id="51271" name="[M] Kilt 01" tags="equipment,male,skirt" />
<Gump id="51273" name="[M] Kilt 02" tags="equipment,male,skirt" />
<Gump id="51275" name="[M] Kilt 03" tags="equipment,male,skirt" />
<Gump id="51278" name="[M] Red Dress Human Female" tags="equipment,male,robe" />
<Gump id="51284" name="[M] Wed Dress Human Female" tags="equipment,male,robe" />
<Gump id="51295" name="[M] Tiger Chest M" tags="equipment,male,chest-armor" />
<Gump id="51296" name="[M] Tiger Chest F" tags="equipment,male,chest-armor" />
<Gump id="51297" name="[M] Tiger Long Pants" tags="equipment,male,pants" />
<Gump id="51298" name="[M] Tiger Short Pants" tags="equipment,male,pants" />
<Gump id="51299" name="[M] Tiger Long Skirt" tags="equipment,male,pants" />
<Gump id="51300" name="[M] Tiger Short Skirt" tags="equipment,male,pants" />
<Gump id="51301" name="[M] Tiger Helm" tags="equipment,male,helmet" />
<Gump id="51302" name="[M] Tiger Collar" tags="equipment,male,gorget" />
<Gump id="51303" name="[M] Turtle Chest M" tags="equipment,male,chest-armor" />
<Gump id="51304" name="[M] Turtle Chest F" tags="equipment,male,chest-armor" />
<Gump id="51305" name="[M] Turtle Long Pants" tags="equipment,male,pants" />
<Gump id="51306" name="[M] Turtle Helm" tags="equipment,male,helmet" />
<Gump id="51307" name="[M] Turtle Bracers" tags="equipment,male,sleeves" />
<Gump id="51310" name="[M] Talisman tailoring" tags="equipment,male,talisman" />
<Gump id="51311" name="[M] Talisman alchemy" tags="equipment,male,talisman" />
<Gump id="51312" name="[M] Talisman cooking" tags="equipment,male,talisman" />
<Gump id="51313" name="[M] Talisman inscription" tags="equipment,male,talisman" />
<Gump id="51314" name="[M] Talisman fletching" tags="equipment,male,talisman" />
<Gump id="51315" name="[M] Talisman blacksmithing" tags="equipment,male,talisman" />
<Gump id="51316" name="[M] Talisman tinkering" tags="equipment,male,talisman" />
<Gump id="51317" name="[M] Talisman carpentry" tags="equipment,male,talisman" />
<Gump id="51411" name="[M] Wedding Dress" tags="equipment,male,robe" />
<Gump id="51412" name="[M] Wedding Top Hat" tags="equipment,male,helmet" />
<Gump id="51413" name="[M] Tux Shirt South" tags="equipment,male,shirt" />
<Gump id="51414" name="[M] Wedding Pants South" tags="equipment,male,pants" />
<Gump id="51429" name="[M] Haunted Veil South" tags="equipment,male,helmet" />
<Gump id="51430" name="[M] Haunted Wedding Hat" tags="equipment,male,helmet" />
<Gump id="51435" name="[M] Store Robe 01" tags="equipment,male,robe" />
<Gump id="51437" name="[M] Store Robe 02" tags="equipment,male,robe" />
<Gump id="51439" name="[M] Store Robe 03" tags="equipment,male,robe" />
<Gump id="51443" name="[M] Store Robe 04" tags="equipment,male,robe" />
<Gump id="51445" name="[M] Store Robe 05" tags="equipment,male,robe" />
<Gump id="51447" name="[M] Male Human Hair 01" tags="equipment,male,hair" />
<Gump id="51448" name="[M] Male Human Hair 02" tags="equipment,male,hair" />
<Gump id="51449" name="[M] Male Human Hair 03" tags="equipment,male,hair" />
<Gump id="51450" name="[M] Male Human Hair 04" tags="equipment,male,hair" />
<Gump id="51451" name="[M] Male Human Beard 01" tags="equipment,male,facial-hair" />
<Gump id="51452" name="[M] Male Human Beard 02" tags="equipment,male,facial-hair" />
<Gump id="51453" name="[M] Male Human Beard 03" tags="equipment,male,facial-hair" />
<Gump id="51454" name="[M] Male Human Beard 04" tags="equipment,male,facial-hair" />
<Gump id="51459" name="[M] Male Elf Hair 01" tags="equipment,male,hair" />
<Gump id="51460" name="[M] Male Elf Hair 02" tags="equipment,male,hair" />
<Gump id="51461" name="[M] Male Elf Hair 03" tags="equipment,male,hair" />
<Gump id="51462" name="[M] Male Elf Hair 04" tags="equipment,male,hair" />
<Gump id="51467" name="[M] Male Gargoyle Horn 01" tags="equipment,male,hair" />
<Gump id="51468" name="[M] Male Gargoyle Horn 02" tags="equipment,male,hair" />
<Gump id="51469" name="[M] Male Gargoyle Horn 03" tags="equipment,male,hair" />
<Gump id="51470" name="[M] Male Gargoyle Horn 04" tags="equipment,male,hair" />
<Gump id="51471" name="[M] Male Gargoyle Beard 01" tags="equipment,male,facial-hair" />
<Gump id="51472" name="[M] Male Gargoyle Beard 02" tags="equipment,male,facial-hair" />
<Gump id="51473" name="[M] Male Gargoyle Beard 03" tags="equipment,male,facial-hair" />
<Gump id="51474" name="[M] Male Gargoyle Beard 04" tags="equipment,male,facial-hair" />
<Gump id="51480" name="[M] Khal Ankur Mask East" tags="equipment,male,helmet" />
<Gump id="51482" name="[M] Khal Ankur Gargish Necklace" tags="equipment,male,gorget" />
<Gump id="51486" name="[M] Barbed Whip" tags="equipment,male,one-hand" />
<Gump id="51498" name="[M] Holiday Boots South" tags="equipment,male,boots" />
<Gump id="51500" name="[M] Holiday Hat South" tags="equipment,male,helmet" />
<Gump id="51502" name="[M] Holiday Gargoyle Talisman" tags="equipment,male,boots" />
<Gump id="51504" name="[M] Holiday Gargoyle Earring" tags="equipment,male,earring" />
<Gump id="51506" name="[M] Parrot Epaulets South" tags="equipment,male,robe" />
<Gump id="51513" name="[M] Pumpkin Helm" tags="equipment,male,helmet" />
<Gump id="51515" name="[M] Bottle Weapon" tags="equipment,male,two-hand" />
<Gump id="51516" name="[M] Neck Mempo" tags="equipment,male,gorget" />
<Gump id="51517" name="[M] Mace Belt" tags="equipment,male,waist" />
<Gump id="51518" name="[M] Assassin Hood" tags="equipment,male,helmet" />
<Gump id="51519" name="[M] Sword Belt" tags="equipment,male,waist" />
<Gump id="51520" name="[M] Mage Hood" tags="equipment,male,helmet" />
<Gump id="51521" name="[M] Dagger Belt" tags="equipment,male,waist" />
<Gump id="51522" name="[M] tabard" tags="equipment,male,robe" />
<Gump id="51523" name="[M] elegant quarter cloak" tags="equipment,male,cloak" />
<Gump id="51524" name="[M] Bake Kitsune Hat South" tags="equipment,male,helmet" />
<Gump id="51529" name="[M] Pirate Shield" tags="equipment,male,two-hand" />
<Gump id="51533" name="[M] Award Sash Empty" tags="equipment,male,tunic" />
<Gump id="51534" name="[M] Award Sash 1/3" tags="equipment,male,tunic" />
<Gump id="51535" name="[M] Award Sash 2/3" tags="equipment,male,tunic" />
<Gump id="51536" name="[M] Award Sash Full" tags="equipment,male,tunic" />
<Gump id="51543" name="[M] wand" tags="equipment,male,two-hand" />
<Gump id="51544" name="[M] Lantern on" tags="equipment,male,two-hand" />
<Gump id="51545" name="[M] Lantern off" tags="equipment,male,two-hand" />
<Gump id="51550" name="[M] Orb" tags="equipment,male,two-hand" />
<Gump id="51554" name="[M] Candy Cane Staff" tags="equipment,male,two-hand" />
<Gump id="51555" name="[M] Candy Cane Staff" tags="equipment,male,two-hand" />
<Gump id="51556" name="[M] Arcane Leather Gloves" tags="equipment,male,gloves" />
<Gump id="51557" name="[M] Arcane Leather Arms" tags="equipment,male,sleeves" />
<Gump id="51558" name="[M] Arcane Leather Legs" tags="equipment,male,pants" />
<Gump id="51559" name="[M] Arcane Leather Jacket" tags="equipment,male,chest-armor" />
<Gump id="51560" name="[M] Arcane Leather Jacket" tags="equipment,male,chest-armor" />
<Gump id="51561" name="[M] Ringmail Helmet" tags="equipment,male,helmet" />
<Gump id="51562" name="[M] Studded Leather Helmet" tags="equipment,male,helmet" />
<Gump id="51563" name="[M] Chainmail Gloves" tags="equipment,male,gloves" />
<Gump id="51564" name="[M] Chainmail Arms" tags="equipment,male,sleeves" />
<Gump id="51565" name="[M] Demon Belt" tags="equipment,male,waist" />
<Gump id="51571" name="[M] Hildebrandt Shield" tags="equipment,male,two-hand" />
<Gump id="51577" name="[M] Air Close Helmet" tags="equipment,male,helmet" />
<Gump id="51578" name="[M] Air Platemail Gorget" tags="equipment,male,gorget" />
<Gump id="51579" name="[M] Air Platemail Arms" tags="equipment,male,sleeves" />
<Gump id="51580" name="[M] Air Platemail Gloves" tags="equipment,male,gloves" />
<Gump id="51581" name="[M] Air Platemail Chest" tags="equipment,male,chest-armor" />
<Gump id="51582" name="[M] Air Platemail Legs" tags="equipment,male,pants" />
<Gump id="51583" name="[M] Air Cloak" tags="equipment,male,cloak" />
<Gump id="51586" name="[M] Air Kite Shield" tags="equipment,male,two-hand" />
<Gump id="51588" name="[M] Air Gargoyle Chest" tags="equipment,male,chest-armor" />
<Gump id="51589" name="[M] Air Gargoyle Legs" tags="equipment,male,pants" />
<Gump id="51590" name="[M] Air Gargoyle Kilt" tags="equipment,male,gloves" />
<Gump id="51591" name="[M] Air Gargoyle Arms" tags="equipment,male,sleeves" />
<Gump id="51592" name="[M] Water Close Helmet" tags="equipment,male,helmet" />
<Gump id="51598" name="[M] Water Cloak" tags="equipment,male,cloak" />
<Gump id="51601" name="[M] Water Kite Shield" tags="equipment,male,two-hand" />
<Gump id="51613" name="[M] Earth Cloak" tags="equipment,male,cloak" />
<Gump id="51616" name="[M] Earth Kite Shield" tags="equipment,male,two-hand" />
<Gump id="51628" name="[M] Fire Cloak" tags="equipment,male,cloak" />
<Gump id="51631" name="[M] Fire Kite Shield" tags="equipment,male,two-hand" />
<Gump id="51639" name="[M] Backpack" tags="equipment,male,cloak" />
<Gump id="51669" name="[M] Shamrock Medallion" tags="equipment,male,gorget" />
<Gump id="51671" name="[M] Dragon Robe Human" tags="equipment,male,robe" />
<Gump id="51672" name="[M] Dragon Robe Gargoyle" tags="equipment,male,robe" />
<Gump id="51673" name="[M] Dragon Dress Human" tags="equipment,male,robe" />
<Gump id="51674" name="[M] Dragon Dress Gargoyle" tags="equipment,male,robe" />
<Gump id="51675" name="[M] Dragon Helm Human" tags="equipment,male,helmet" />
<Gump id="51676" name="[M] Dragon Chest Human" tags="equipment,male,chest-armor" />
<Gump id="51677" name="[M] Dragon Arms Human" tags="equipment,male,sleeves" />
<Gump id="51678" name="[M] Dragon Legs Human" tags="equipment,male,pants" />
<Gump id="51679" name="[M] Dragon Hands Human" tags="equipment,male,gloves" />
<Gump id="51680" name="[M] Dragon Neck Human" tags="equipment,male,gorget" />
<Gump id="51681" name="[M] Mushroom Hat" tags="equipment,male,helmet" />
<Gump id="51683" name="[M] Raptor Claw Necklace" tags="equipment,male,gorget" />
<Gump id="51685" name="[M] Animal Teeth Necklace" tags="equipment,male,gorget" />
<Gump id="51687" name="[M] Seashell Necklace" tags="equipment,male,gorget" />
<Gump id="51689" name="[M] Skull Necklace" tags="equipment,male,gorget" />
<Gump id="51691" name="[M] Anchor Necklace" tags="equipment,male,gorget" />
<Gump id="59972" name="[M] Fellowship Medallion" tags="equipment,male,gorget" />
<Gump id="59974" name="[M] Fellowship Medallion" tags="equipment,male,gorget" />
<Gump id="59976" name="[M] Octopus Necklace Gargoyle" tags="equipment,male,gorget" />
<Gump id="59978" name="[M] Octopus Necklace Human" tags="equipment,male,gorget" />
<Gump id="60335" name="[F] Gargish Kite Shield" tags="equipment,female,two-hand" />
<Gump id="60336" name="[F] Gargish Wooden Shield" tags="equipment,female,two-hand" />
<Gump id="60337" name="[F] Large Plate Shield" tags="equipment,female,two-hand" />
<Gump id="60338" name="[F] Medium Plate Shield" tags="equipment,female,two-hand" />
<Gump id="60339" name="[F] Small Plate Shield" tags="equipment,female,two-hand" />
<Gump id="60340" name="[F] Large Stone Shield" tags="equipment,female,two-hand" />
<Gump id="60341" name="[F] Gargish Fancy Robe" tags="equipment,female,robe" />
<Gump id="60342" name="[F] Gargish Robe" tags="equipment,female,robe" />
<Gump id="60343" name="[F] Gargish Chaos Shield" tags="equipment,female,two-hand" />
<Gump id="60344" name="[F] Gargish Order Shield" tags="equipment,female,two-hand" />
<Gump id="60404" name="[F] floppy hat" tags="equipment,female,helmet" />
<Gump id="60405" name="[F] wide-brim hat" tags="equipment,female,helmet" />
<Gump id="60406" name="[F] cap" tags="equipment,female,helmet" />
<Gump id="60407" name="[F] tall straw hat" tags="equipment,female,helmet" />
<Gump id="60408" name="[F] straw hat" tags="equipment,female,helmet" />
<Gump id="60409" name="[F] wizard hat" tags="equipment,female,helmet" />
<Gump id="60410" name="[F] bonnet" tags="equipment,female,helmet" />
<Gump id="60411" name="[F] feathered hat" tags="equipment,female,helmet" />
<Gump id="60412" name="[F] tricorne hat" tags="equipment,female,helmet" />
<Gump id="60413" name="[F] jester hat" tags="equipment,female,helmet" />
<Gump id="60414" name="[F] bear mask" tags="equipment,female,helmet" />
<Gump id="60415" name="[F] deer mask" tags="equipment,female,helmet" />
<Gump id="60416" name="[F] orc mask" tags="equipment,female,helmet" />
<Gump id="60417" name="[F] tribal mask" tags="equipment,female,helmet" />
<Gump id="60418" name="[F] tribal mask" tags="equipment,female,helmet" />
<Gump id="60419" name="[F] skullcap" tags="equipment,female,helmet" />
<Gump id="60423" name="[F] Leather Ninja Pants" tags="equipment,female,pants" />
<Gump id="60425" name="[F] Leather Ninja Hood" tags="equipment,female,helmet" />
<Gump id="60426" name="[F] Leather Ninja Jacket" tags="equipment,female,chest-armor" />
<Gump id="60427" name="[F] MysticSpellBook East" tags="equipment,female,one-hand" />
<Gump id="60430" name="[F] short pants" tags="equipment,female,pants" />
<Gump id="60431" name="[F] long pants" tags="equipment,female,pants" />
<Gump id="60433" name="[F] Halloween Mask" tags="equipment,female,one-hand" />
<Gump id="60434" name="[F] shirt" tags="equipment,female,shirt" />
<Gump id="60435" name="[F] fancy shirt" tags="equipment,female,shirt" />
<Gump id="60436" name="[F] Gargish Bracelet" tags="equipment,female,bracelet" />
<Gump id="60437" name="[F] Gargish Earrings" tags="equipment,female,earring" />
<Gump id="60438" name="[F] Gargish Ring" tags="equipment,female,ring" />
<Gump id="60439" name="[F] Gargish Necklace" tags="equipment,female,gorget" />
<Gump id="60442" name="[F] Mail Hatsuburi" tags="equipment,female,helmet" />
<Gump id="60443" name="[F] Plate Hatsuburi" tags="equipment,female,helmet" />
<Gump id="60447" name="[F] plain dress" tags="equipment,female,robe" />
<Gump id="60448" name="[F] fancy dress" tags="equipment,female,robe" />
<Gump id="60449" name="[F] skirt" tags="equipment,female,skirt" />
<Gump id="60450" name="[F] Plate Jingasa" tags="equipment,female,helmet" />
<Gump id="60451" name="[F] Plate Kabuto" tags="equipment,female,helmet" />
<Gump id="60455" name="[F] kilt" tags="equipment,female,skirt" />
<Gump id="60456" name="[F] Plate Mempo" tags="equipment,female,gorget" />
<Gump id="60457" name="[F] Leather Do" tags="equipment,female,chest-armor" />
<Gump id="60458" name="[F] Studded Do" tags="equipment,female,chest-armor" />
<Gump id="60459" name="[F] Plate Do" tags="equipment,female,chest-armor" />
<Gump id="60462" name="[F] Plate Kabuto" tags="equipment,female,helmet" />
<Gump id="60463" name="[F] Plate Kabuto" tags="equipment,female,helmet" />
<Gump id="60465" name="[F] full apron" tags="equipment,female,tunic" />
<Gump id="60466" name="[F] half apron" tags="equipment,female,waist" />
<Gump id="60468" name="[F] cloak" tags="equipment,female,cloak" />
<Gump id="60469" name="[F] robe" tags="equipment,female,robe" />
<Gump id="60471" name="[F] Leather Mempo" tags="equipment,female,gorget" />
<Gump id="60472" name="[F] Studded Mempo" tags="equipment,female,gorget" />
<Gump id="60473" name="[F] Samurai Armor Arms" tags="equipment,female,sleeves" />
<Gump id="60474" name="[F] Samurai Armor Arms" tags="equipment,female,sleeves" />
<Gump id="60475" name="[F] Female Gargoyle Horn" tags="equipment,female,hair" />
<Gump id="60476" name="[F] thigh boots" tags="equipment,female,boots" />
<Gump id="60477" name="[F] boots" tags="equipment,female,boots" />
<Gump id="60478" name="[F] Samurai Armor Arms" tags="equipment,female,sleeves" />
<Gump id="60479" name="[F] sandals" tags="equipment,female,boots" />
<Gump id="60480" name="[F] shoes" tags="equipment,female,boots" />
<Gump id="60484" name="[F] fur surong" tags="equipment,female,skirt" />
<Gump id="60487" name="[F] Leather Suneate" tags="equipment,female,pants" />
<Gump id="60488" name="[F] Studded Suneate" tags="equipment,female,pants" />
<Gump id="60489" name="[F] Plate Suneate" tags="equipment,female,pants" />
<Gump id="60490" name="[F] body sash" tags="equipment,female,tunic" />
<Gump id="60491" name="[F] Leather Haidate" tags="equipment,female,pants" />
<Gump id="60492" name="[F] bandana" tags="equipment,female,helmet" />
<Gump id="60493" name="[F] necklace" tags="equipment,female,gorget" />
<Gump id="60494" name="[F] necklace" tags="equipment,female,gorget" />
<Gump id="60495" name="[F] bracelet" tags="equipment,female,bracelet" />
<Gump id="60496" name="[F] earrings" tags="equipment,female,earring" />
<Gump id="60497" name="[F] necklace" tags="equipment,female,gorget" />
<Gump id="60498" name="[F] ring" tags="equipment,female,ring" />
<Gump id="60506" name="[F] Plate Haidate" tags="equipment,female,pants" />
<Gump id="60507" name="[F] silver necklace" tags="equipment,female,gorget" />
<Gump id="60508" name="[F] bracelet" tags="equipment,female,bracelet" />
<Gump id="60511" name="[F] ring" tags="equipment,female,ring" />
<Gump id="60512" name="[F] Studded Haidate" tags="equipment,female,pants" />
<Gump id="60513" name="[F] Leather Ninja Hood" tags="equipment,female,helmet" />
<Gump id="60514" name="[F] Leather Ninja Belt" tags="equipment,female,waist" />
<Gump id="60517" name="[F] Kamishimo" tags="equipment,female,robe" />
<Gump id="60518" name="[F] Hakama" tags="equipment,female,skirt" />
<Gump id="60521" name="[F] studded tunic" tags="equipment,female,chest-armor" />
<Gump id="60522" name="[F] studded leggings" tags="equipment,female,pants" />
<Gump id="60523" name="[F] studded sleeves" tags="equipment,female,sleeves" />
<Gump id="60524" name="[F] studded gloves" tags="equipment,female,gloves" />
<Gump id="60525" name="[F] studded gorget" tags="equipment,female,gorget" />
<Gump id="60527" name="[F] platemail" tags="equipment,female,chest-armor" />
<Gump id="60528" name="[F] platemail arms" tags="equipment,female,sleeves" />
<Gump id="60529" name="[F] platemail legs" tags="equipment,female,pants" />
<Gump id="60530" name="[F] platemail gloves" tags="equipment,female,gloves" />
<Gump id="60531" name="[F] platemail gorget" tags="equipment,female,gorget" />
<Gump id="60534" name="[F] Horns Facial Gargoyle" tags="equipment,female,hair" />
<Gump id="60538" name="[F] chainmail tunic" tags="equipment,female,chest-armor" />
<Gump id="60540" name="[F] chainmail leggings" tags="equipment,female,pants" />
<Gump id="60541" name="[F] Female Horns" tags="equipment,female,hair" />
<Gump id="60542" name="[F] leather tunic" tags="equipment,female,chest-armor" />
<Gump id="60543" name="[F] leather leggings" tags="equipment,female,pants" />
<Gump id="60544" name="[F] leather sleeves" tags="equipment,female,sleeves" />
<Gump id="60545" name="[F] Leather Gloves" tags="equipment,female,gloves" />
<Gump id="60546" name="[F] leather gorget" tags="equipment,female,gorget" />
<Gump id="60548" name="[F] ringmail tunic" tags="equipment,female,chest-armor" />
<Gump id="60549" name="[F] ringmail leggings" tags="equipment,female,pants" />
<Gump id="60550" name="[F] ringmail sleeves" tags="equipment,female,sleeves" />
<Gump id="60552" name="[F] Tattsuke-Hakama" tags="equipment,female,pants" />
<Gump id="60553" name="[F] Hakama-Shita" tags="equipment,female,robe" />
<Gump id="60554" name="[F] bone armor" tags="equipment,female,chest-armor" />
<Gump id="60555" name="[F] bone leggings" tags="equipment,female,pants" />
<Gump id="60556" name="[F] bone arms" tags="equipment,female,sleeves" />
<Gump id="60557" name="[F] bone gloves" tags="equipment,female,gloves" />
<Gump id="60560" name="[F] leather cap" tags="equipment,female,helmet" />
<Gump id="60561" name="[F] chainmail coif" tags="equipment,female,helmet" />
<Gump id="60562" name="[F] bone helmet" tags="equipment,female,helmet" />
<Gump id="60563" name="[F] plate helm" tags="equipment,female,helmet" />
<Gump id="60564" name="[F] bascinet" tags="equipment,female,helmet" />
<Gump id="60567" name="[F] Obi" tags="equipment,female,waist" />
<Gump id="60568" name="[F] Jin-Baori" tags="equipment,female,tunic" />
<Gump id="60569" name="[F] Samurai Sandal/Tabi" tags="equipment,female,boots" />
<Gump id="60571" name="[F] Cloth Ninja Jacket" tags="equipment,female,chest-armor" />
<Gump id="60572" name="[F] Ninja Tall Tabi" tags="equipment,female,boots" />
<Gump id="60579" name="[F] buckler" tags="equipment,female,two-hand" />
<Gump id="60581" name="[F] kite shield" tags="equipment,female,two-hand" />
<Gump id="60582" name="[F] heater shield" tags="equipment,female,two-hand" />
<Gump id="60605" name="[F] scale shield" tags="equipment,female,two-hand" />
<Gump id="60632" name="[F] Female Gargoyle Horn" tags="equipment,female,hair" />
<Gump id="60653" name="[F] axe" tags="equipment,female,two-hand" />
<Gump id="60668" name="[F] Bloodblade" tags="equipment,female,one-hand" />
<Gump id="60669" name="[F] Boomerang" tags="equipment,female,one-hand" />
<Gump id="60670" name="[F] Stone War Sword" tags="equipment,female,one-hand" />
<Gump id="60671" name="[F] Dread Sword" tags="equipment,female,one-hand" />
<Gump id="60672" name="[F] Cyclone" tags="equipment,female,one-hand" />
<Gump id="60673" name="[F] Gargish Dagger" tags="equipment,female,one-hand" />
<Gump id="60674" name="[F] Disc Mace" tags="equipment,female,one-hand" />
<Gump id="60675" name="[F] Dual Pointed Spear" tags="equipment,female,two-hand" />
<Gump id="60676" name="[F] Glass Staff" tags="equipment,female,two-hand" />
<Gump id="60677" name="[F] Serpentstone Staff" tags="equipment,female,two-hand" />
<Gump id="60678" name="[F] Shortblade" tags="equipment,female,one-hand" />
<Gump id="60679" name="[F] dragon sleeves" tags="equipment,female,sleeves" />
<Gump id="60680" name="[F] dragon breastplate" tags="equipment,female,chest-armor" />
<Gump id="60681" name="[F] dragon gloves" tags="equipment,female,gloves" />
<Gump id="60682" name="[F] dragon helm" tags="equipment,female,helmet" />
<Gump id="60683" name="[F] dragon leggings" tags="equipment,female,pants" />
<Gump id="60684" name="[F] amazon armor" tags="equipment,female,shirt" />
<Gump id="60685" name="[F] amazon armor" tags="equipment,female,shirt" />
<Gump id="60686" name="[F] amazon armor" tags="equipment,female,shirt" />
<Gump id="60690" name="[F] Gargish Talwar" tags="equipment,female,two-hand" />
<Gump id="60691" name="[F] Glass Sword" tags="equipment,female,one-hand" />
<Gump id="60693" name="[F] Soul Glaive" tags="equipment,female,one-hand" />
<Gump id="60701" name="[F] Long Hair" tags="equipment,female,hair" />
<Gump id="60702" name="[F] Ponytail" tags="equipment,female,hair" />
<Gump id="60707" name="[F] Female Gargoyle Horn" tags="equipment,female,hair" />
<Gump id="60708" name="[F] Female Gargoyle Horn" tags="equipment,female,hair" />
<Gump id="60713" name="[F] feathered mask" tags="equipment,female,helmet" />
<Gump id="60715" name="[F] jester pants" tags="equipment,female,pants" />
<Gump id="60769" name="[F] Female Gargoyle Horn" tags="equipment,female,hair" />
<Gump id="60795" name="[F] Female Gargoyle Horn" tags="equipment,female,hair" />
<Gump id="60890" name="[F] Elven Hair" tags="equipment,female,hair" />
<Gump id="60891" name="[F] Elven Hair" tags="equipment,female,hair" />
<Gump id="60892" name="[F] Elven Hair" tags="equipment,female,hair" />
<Gump id="60893" name="[F] Elven Hair" tags="equipment,female,hair" />
<Gump id="60894" name="[F] Elven Hair" tags="equipment,female,hair" />
<Gump id="60895" name="[F] Elven Hair" tags="equipment,female,hair" />
<Gump id="60897" name="[F] elven glasses" tags="equipment,female,helmet" />
<Gump id="60898" name="[F] elven robe" tags="equipment,female,robe" />
<Gump id="60907" name="[F] elven shirt" tags="equipment,female,shirt" />
<Gump id="60908" name="[F] elven shirt" tags="equipment,female,shirt" />
<Gump id="60909" name="[F] tunic" tags="equipment,female,tunic" />
<Gump id="60910" name="[F] surcoat" tags="equipment,female,tunic" />
<Gump id="60911" name="[F] checkered shirt" tags="equipment,female,shirt" />
<Gump id="60912" name="[F] jester suit" tags="equipment,female,tunic" />
<Gump id="60913" name="[F] doublet" tags="equipment,female,tunic" />
<Gump id="60914" name="[F] elven shirt" tags="equipment,female,shirt" />
<Gump id="60915" name="[F] elven shirt" tags="equipment,female,shirt" />
<Gump id="60916" name="[F] Elven Hair BL" tags="equipment,female,hair" />
<Gump id="60917" name="[F] hair" tags="equipment,female,hair" />
<Gump id="60918" name="[F] hair" tags="equipment,female,hair" />
<Gump id="60919" name="[F] Elven Hair BR" tags="equipment,female,hair" />
<Gump id="60920" name="[F] Elven Pants" tags="equipment,female,pants" />
<Gump id="60921" name="[F] studded armor" tags="equipment,female,chest-armor" />
<Gump id="60922" name="[F] plate armor" tags="equipment,female,chest-armor" />
<Gump id="60923" name="[F] leather armor" tags="equipment,female,chest-armor" />
<Gump id="60924" name="[F] leather bustier" tags="equipment,female,chest-armor" />
<Gump id="60925" name="[F] studded bustier" tags="equipment,female,chest-armor" />
<Gump id="60926" name="[F] leather skirt" tags="equipment,female,pants" />
<Gump id="60927" name="[F] leather shorts" tags="equipment,female,pants" />
<Gump id="60928" name="[F] close helmet" tags="equipment,female,helmet" />
<Gump id="60929" name="[F] norse helm" tags="equipment,female,helmet" />
<Gump id="60942" name="[F] Elven Plate" tags="equipment,female,chest-armor" />
<Gump id="60943" name="[F] Elven Plate Belt" tags="equipment,female,waist" />
<Gump id="60946" name="[F] Elven Plate Legs" tags="equipment,female,pants" />
<Gump id="60947" name="[F] Elven Arm Plate" tags="equipment,female,sleeves" />
<Gump id="60948" name="[F] Elven Plate" tags="equipment,female,chest-armor" />
<Gump id="60962" name="[F] circlet" tags="equipment,female,helmet" />
<Gump id="60963" name="[F] royal circlet" tags="equipment,female,helmet" />
<Gump id="60964" name="[F] gemmed circlet" tags="equipment,female,helmet" />
<Gump id="60965" name="[F] Raven Helm" tags="equipment,female,helmet" />
<Gump id="60966" name="[F] Vulture Helm" tags="equipment,female,helmet" />
<Gump id="60967" name="[F] Winged Helm" tags="equipment,female,helmet" />
<Gump id="60968" name="[F] Hide Chest" tags="equipment,female,chest-armor" />
<Gump id="60970" name="[F] death shroud" tags="equipment,female,robe" />
<Gump id="60972" name="[F] fishing pole" tags="equipment,female,two-hand" />
<Gump id="60973" name="[F] orc helm" tags="equipment,female,helmet" />
<Gump id="60975" name="[F] Hide Paldrons" tags="equipment,female,sleeves" />
<Gump id="60976" name="[F] Hide Pants" tags="equipment,female,pants" />
<Gump id="60977" name="[F] Hide Female Chest" tags="equipment,female,chest-armor" />
<Gump id="60978" name="[F] Lt Armor Chest" tags="equipment,female,chest-armor" />
<Gump id="60980" name="[F] wand" tags="equipment,female,one-hand" />
<Gump id="60981" name="[F] wand" tags="equipment,female,one-hand" />
<Gump id="60982" name="[F] wand" tags="equipment,female,one-hand" />
<Gump id="60983" name="[F] wand" tags="equipment,female,one-hand" />
<Gump id="60984" name="[F] spellbook" tags="equipment,female,one-hand" />
<Gump id="60987" name="[F] GM Robe" tags="equipment,female,robe" />
<Gump id="60992" name="[F] Order shield" tags="equipment,female,two-hand" />
<Gump id="60993" name="[F] Chaos shield" tags="equipment,female,two-hand" />
<Gump id="61027" name="[F] Gargoyle Bardiche East" tags="equipment,female,two-hand" />
<Gump id="61028" name="[F] Gargoyle BattleAxe East" tags="equipment,female,two-hand" />
<Gump id="61029" name="[F] Gargoyle Scythe South" tags="equipment,female,one-hand" />
<Gump id="61030" name="[F] Gargoyle Butchers Knife" tags="equipment,female,one-hand" />
<Gump id="61032" name="[F] Gargoyle Axe East" tags="equipment,female,one-hand" />
<Gump id="61033" name="[F] Gargoyle Daisho East" tags="equipment,female,two-hand" />
<Gump id="61034" name="[F] Gargoyle Katana East" tags="equipment,female,one-hand" />
<Gump id="61035" name="[F] Gargoyle Kriss Knife" tags="equipment,female,one-hand" />
<Gump id="61036" name="[F] Gargoyle Pike South" tags="equipment,female,one-hand" />
<Gump id="61037" name="[F] Gargoyle Maul East" tags="equipment,female,one-hand" />
<Gump id="61040" name="[F] Gargoyle Pike East" tags="equipment,female,two-hand" />
<Gump id="61041" name="[F] Gargoyle Scythe East" tags="equipment,female,two-hand" />
<Gump id="61042" name="[F] Gargoyle Staff Gnarled" tags="equipment,female,two-hand" />
<Gump id="61043" name="[F] Gargoyle Tekagi East" tags="equipment,female,two-hand" />
<Gump id="61044" name="[F] Gargoyle Tessen East" tags="equipment,female,two-hand" />
<Gump id="61045" name="[F] Gargoyle War Fork East" tags="equipment,female,one-hand" />
<Gump id="61046" name="[F] Gargoyle War Hammer East" tags="equipment,female,one-hand" />
<Gump id="61047" name="[F] Gargoyle Axe East" tags="equipment,female,two-hand" />
<Gump id="61053" name="[F] Gargoyle Belt South" tags="equipment,female,waist" />
<Gump id="61054" name="[F] Gargoyle Half Apron South" tags="equipment,female,waist" />
<Gump id="61062" name="[F] Bard Spell Book" tags="equipment,female,one-hand" />
<Gump id="61063" name="[F] Gargoyle Glasses Female" tags="equipment,female,earring" />
<Gump id="61065" name="[F] Gargoyle Sash Female South" tags="equipment,female,tunic" />
<Gump id="61072" name="[F] Gargish Necklace" tags="equipment,female,gorget" />
<Gump id="61073" name="[F] Gargish Necklace" tags="equipment,female,gorget" />
<Gump id="61263" name="[F] Shield Vice" tags="equipment,female,two-hand" />
<Gump id="61265" name="[F] Shield Virtue" tags="equipment,female,two-hand" />
<Gump id="61278" name="[F] Red Dress Human Female" tags="equipment,female,robe" />
<Gump id="61284" name="[F] Wed Dress Human Female" tags="equipment,female,robe" />
<Gump id="61295" name="[F] Tiger Chest M" tags="equipment,female,chest-armor" />
<Gump id="61296" name="[F] Tiger Chest F" tags="equipment,female,chest-armor" />
<Gump id="61297" name="[F] Tiger Long Pants" tags="equipment,female,pants" />
<Gump id="61298" name="[F] Tiger Short Pants" tags="equipment,female,pants" />
<Gump id="61299" name="[F] Tiger Long Skirt" tags="equipment,female,pants" />
<Gump id="61300" name="[F] Tiger Short Skirt" tags="equipment,female,pants" />
<Gump id="61303" name="[F] Turtle Chest M" tags="equipment,female,chest-armor" />
<Gump id="61304" name="[F] Turtle Chest F" tags="equipment,female,chest-armor" />
<Gump id="61305" name="[F] Turtle Long Pants" tags="equipment,female,pants" />
<Gump id="61307" name="[F] Turtle Bracers" tags="equipment,female,sleeves" />
<Gump id="61411" name="[F] Wedding Dress" tags="equipment,female,robe" />
<Gump id="61412" name="[F] Wedding Top Hat" tags="equipment,female,helmet" />
<Gump id="61413" name="[F] Tux Shirt South" tags="equipment,female,shirt" />
<Gump id="61414" name="[F] Wedding Pants South" tags="equipment,female,pants" />
<Gump id="61429" name="[F] Haunted Veil South" tags="equipment,female,helmet" />
<Gump id="61430" name="[F] Haunted Wedding Hat" tags="equipment,female,helmet" />
<Gump id="61455" name="[F] Female Human Hair 01" tags="equipment,female,hair" />
<Gump id="61456" name="[F] Female Human Hair 02" tags="equipment,female,hair" />
<Gump id="61457" name="[F] Female Human Hair 03" tags="equipment,female,hair" />
<Gump id="61458" name="[F] Female Human Hair 04" tags="equipment,female,hair" />
<Gump id="61463" name="[F] Female Elf Hair 01" tags="equipment,female,hair" />
<Gump id="61464" name="[F] Female Elf Hair 02" tags="equipment,female,hair" />
<Gump id="61465" name="[F] Female Elf Hair 03" tags="equipment,female,hair" />
<Gump id="61466" name="[F] Female Elf Hair 04" tags="equipment,female,hair" />
<Gump id="61475" name="[F] Female Gargoyle Horn 01" tags="equipment,female,hair" />
<Gump id="61476" name="[F] Female Gargoyle Horn 02" tags="equipment,female,hair" />
<Gump id="61477" name="[F] Female Gargoyle Horn 03" tags="equipment,female,hair" />
<Gump id="61478" name="[F] Female Gargoyle Horn 04" tags="equipment,female,hair" />
<Gump id="61516" name="[F] Neck Mempo" tags="equipment,female,gorget" />
<Gump id="61529" name="[F] Pirate Shield" tags="equipment,female,two-hand" />
<Gump id="61533" name="[F] Award Sash Empty" tags="equipment,female,tunic" />
<Gump id="61534" name="[F] Award Sash 1/3" tags="equipment,female,tunic" />
<Gump id="61535" name="[F] Award Sash 2/3" tags="equipment,female,tunic" />
<Gump id="61536" name="[F] Award Sash Full" tags="equipment,female,tunic" />
<Gump id="61543" name="[F] wand" tags="equipment,female,two-hand" />
<Gump id="61544" name="[F] Lantern on" tags="equipment,female,two-hand" />
<Gump id="61545" name="[F] Lantern off" tags="equipment,female,two-hand" />
<Gump id="61550" name="[F] Orb" tags="equipment,female,two-hand" />
<Gump id="61554" name="[F] Candy Cane Staff" tags="equipment,female,two-hand" />
<Gump id="61555" name="[F] Candy Cane Staff" tags="equipment,female,two-hand" />
<Gump id="61556" name="[F] Arcane Leather Gloves" tags="equipment,female,gloves" />
<Gump id="61557" name="[F] Arcane Leather Arms" tags="equipment,female,sleeves" />
<Gump id="61558" name="[F] Arcane Leather Legs" tags="equipment,female,pants" />
<Gump id="61560" name="[F] Arcane Leather Jacket" tags="equipment,female,chest-armor" />
<Gump id="61561" name="[F] Ringmail Helmet" tags="equipment,female,helmet" />
<Gump id="61562" name="[F] Studded Leather Helmet" tags="equipment,female,helmet" />
<Gump id="61563" name="[F] Chainmail Gloves" tags="equipment,female,gloves" />
<Gump id="61564" name="[F] Chainmail Arms" tags="equipment,female,sleeves" />
<Gump id="61565" name="[F] Demon Belt" tags="equipment,female,waist" />
<Gump id="61571" name="[F] Hildebrandt Shield" tags="equipment,female,two-hand" />
<Gump id="61577" name="[F] Air Close Helmet" tags="equipment,female,helmet" />
<Gump id="61578" name="[F] Air Platemail Gorget" tags="equipment,female,gorget" />
<Gump id="61579" name="[F] Air Platemail Arms" tags="equipment,female,sleeves" />
<Gump id="61580" name="[F] Air Platemail Gloves" tags="equipment,female,gloves" />
<Gump id="61581" name="[F] Air Platemail Chest" tags="equipment,female,chest-armor" />
<Gump id="61582" name="[F] Air Platemail Legs" tags="equipment,female,pants" />
<Gump id="61583" name="[F] Air Cloak" tags="equipment,female,cloak" />
<Gump id="61586" name="[F] Air Kite Shield" tags="equipment,female,two-hand" />
<Gump id="61588" name="[F] Air Gargoyle Chest" tags="equipment,female,chest-armor" />
<Gump id="61589" name="[F] Air Gargoyle Legs" tags="equipment,female,pants" />
<Gump id="61590" name="[F] Air Gargoyle Kilt" tags="equipment,female,gloves" />
<Gump id="61591" name="[F] Air Gargoyle Arms" tags="equipment,female,sleeves" />
<Gump id="61592" name="[F] Water Close Helmet" tags="equipment,female,helmet" />
<Gump id="61598" name="[F] Water Cloak" tags="equipment,female,cloak" />
<Gump id="61601" name="[F] Water Kite Shield" tags="equipment,female,two-hand" />
<Gump id="61613" name="[F] Earth Cloak" tags="equipment,female,cloak" />
<Gump id="61616" name="[F] Earth Kite Shield" tags="equipment,female,two-hand" />
<Gump id="61628" name="[F] Fire Cloak" tags="equipment,female,cloak" />
<Gump id="61631" name="[F] Fire Kite Shield" tags="equipment,female,two-hand" />
<Gump id="61639" name="[F] Backpack" tags="equipment,female,cloak" />
<Gump id="61669" name="[F] Shamrock Medallion" tags="equipment,female,gorget" />
<Gump id="61671" name="[F] Dragon Robe Human" tags="equipment,female,robe" />
<Gump id="61672" name="[F] Dragon Robe Gargoyle" tags="equipment,female,robe" />
<Gump id="61673" name="[F] Dragon Dress Human" tags="equipment,female,robe" />
<Gump id="61674" name="[F] Dragon Dress Gargoyle" tags="equipment,female,robe" />
<Gump id="61675" name="[F] Dragon Helm Human" tags="equipment,female,helmet" />
<Gump id="61676" name="[F] Dragon Chest Human" tags="equipment,female,chest-armor" />
<Gump id="61677" name="[F] Dragon Arms Human" tags="equipment,female,sleeves" />
<Gump id="61678" name="[F] Dragon Legs Human" tags="equipment,female,pants" />
<Gump id="61679" name="[F] Dragon Hands Human" tags="equipment,female,gloves" />
<Gump id="61680" name="[F] Dragon Neck Human" tags="equipment,female,gorget" />
<Gump id="61681" name="[F] Mushroom Hat" tags="equipment,female,helmet" />
<Gump id="61683" name="[F] Raptor Claw Necklace" tags="equipment,female,gorget" />
<Gump id="61685" name="[F] Animal Teeth Necklace" tags="equipment,female,gorget" />
<Gump id="61687" name="[F] Seashell Necklace" tags="equipment,female,gorget" />
<Gump id="61689" name="[F] Skull Necklace" tags="equipment,female,gorget" />
<Gump id="61691" name="[F] Anchor Necklace" tags="equipment,female,gorget" />
</Gumps>

362
UO Gumps/script_gumps.xml Normal file
View File

@@ -0,0 +1,362 @@
<?xml version="1.0" encoding="utf-8"?>
<ScriptGumps>
<Gump class="AcceptBodyguardGump" file="Spells/Skill Masteries/BodyGuard.cs" tags="spells" />
<Gump class="AcceptOfficeGump" file="Services/City Loyalty System/Gumps.cs" tags="city,loyalty" />
<Gump class="AcceptProtectorGump" file="Services/Virtues/Justice.cs" tags="script" />
<Gump class="AddCustomizableMessageGump" file="Gumps/AddCustomizableMessageGump.cs" tags="general" />
<Gump class="AddDoorGump" file="Gumps/AddDoorGump.cs" tags="general" />
<Gump class="AddGump" file="Gumps/AddGump.cs" tags="general" />
<Gump class="AddonInterchangeableGump" file="Services/City Loyalty System/Items/Addons.cs" tags="city,loyalty" />
<Gump class="AddonOptionGump" file="Services/VeteranRewards/RewardOptionGump.cs" tags="veteran-rewards" />
<Gump class="AddSignGump" file="Gumps/AddSignGump.cs" tags="general" />
<Gump class="AdminGump" file="Gumps/AdminGump.cs" tags="general" />
<Gump class="AltarGump" file="Services/Revamped Dungeons/TheExodusEncounter/Items/ExodusTomeAltar.cs" tags="dungeon" />
<Gump class="AnimalFormGump" file="Spells/Ninjitsu/AnimalForm.cs" tags="spells" />
<Gump class="AnimalLoreGump" file="Skills/AnimalLore.cs" tags="skills" />
<Gump class="ApplySkillBonusGump" file="Gumps/ApplySkillBonusGump.cs" tags="general" />
<Gump class="AquariumGump" file="Items/Addons/Aquarium/AquariumGump.cs" tags="item,addon" />
<Gump class="AuctionConfirmTeleportGump" file="Services/Expansions/Time Of Legends/AuctionSafe/AuctionMap.cs" tags="time-of-legends" />
<Gump class="AuctionInfoGump" file="Services/Expansions/Time Of Legends/AuctionSafe/Gumps.cs" tags="time-of-legends" />
<Gump class="BanDurationGump" file="Gumps/BanDurationGump.cs" tags="general" />
<Gump class="BankerGump" file="Gumps/BankerGump.cs" tags="general" />
<Gump class="BarkeeperGump" file="Mobiles/NPCs/PlayerBarkeeper.cs" tags="npc" />
<Gump class="BarkeeperTitleGump" file="Mobiles/NPCs/PlayerBarkeeper.cs" tags="npc" />
<Gump class="BaseArenaGump" file="Services/PVP Arena System/Gumps.cs" tags="pvp,arena" />
<Gump class="BaseAuctionGump" file="Services/Expansions/Time Of Legends/AuctionSafe/Gumps.cs" tags="time-of-legends" />
<Gump class="BaseBazaarGump" file="Services/New Magincia/Magincia Bazaar/Gumps/BaseBazaarGump.cs" tags="magincia" />
<Gump class="BaseCasinoGump" file="Services/FireCasino/CasinoGumps.cs" tags="casino" />
<Gump class="BaseCityGump" file="Services/City Loyalty System/Gumps.cs" tags="city,loyalty" />
<Gump class="BaseConfirmGump" file="Gumps/BaseConfirmGump.cs" tags="general" />
<Gump class="BaseImageTileButtonsGump" file="Gumps/BaseImageTileButtonsGump.cs" tags="general" />
<Gump class="BaseJournalGump" file="Items/Books/BaseJournalGump.cs" tags="item,book" />
<Gump class="BaseShipGump" file="Services/Expansions/High Seas/Gumps/BaseShipGump.cs" tags="highseas" />
<Gump class="BaseTownCryerGump" file="Services/Town Cryer/Gumps/BaseTownCryerGump.cs" tags="town-cryer" />
<Gump class="BasicInfoGump" file="Gumps/BasicInfoGump.cs" tags="general" />
<Gump class="BattleStatsGump" file="Services/ViceVsVirtue/Gumps/BattleStatsGump.cs" tags="pvp,vvv" />
<Gump class="BattleWarningGump" file="Services/ViceVsVirtue/Gumps/BattleWarningGump.cs" tags="pvp,vvv" />
<Gump class="BeginQuestGump" file="Quests/Mad Scientist/Items/ClockworkMechanism.cs" tags="quests" />
<Gump class="BidHistoryGump" file="Services/Expansions/Time Of Legends/AuctionSafe/Gumps.cs" tags="time-of-legends" />
<Gump class="BoatPlacementGump" file="Services/Expansions/High Seas/Gumps/ShipPlacementGump.cs" tags="highseas" />
<Gump class="BOBFilterGump" file="Services/BulkOrders/Books/BOBFilterGump.cs" tags="crafting,bod" />
<Gump class="BOBGump" file="Services/BulkOrders/Books/BOBGump.cs" tags="crafting,bod" />
<Gump class="BODBuyGump" file="Services/BulkOrders/Books/BODBuyGump.cs" tags="crafting,bod" />
<Gump class="BountyBoardGump" file="Services/Expansions/High Seas/Gumps/BountyBoard.cs" tags="highseas" />
<Gump class="CalculationsGump" file="Services/ExploringTheDeep/Items/Scroll/CalculationsScroll.cs" tags="exploring" />
<Gump class="CancelTradeOrderGump" file="Services/City Loyalty System/Trading/TradeCrate.cs" tags="city,loyalty" />
<Gump class="CandidatesGump" file="Services/City Loyalty System/Gumps.cs" tags="city,loyalty" />
<Gump class="CannonGump" file="Services/Expansions/High Seas/Gumps/CannonGump.cs" tags="highseas" />
<Gump class="CaptainsLogGump" file="Services/ExploringTheDeep/Items/Scroll/CaptainsLogScroll.cs" tags="exploring" />
<Gump class="CategorizedAddGump" file="Gumps/CategorizedAddGump.cs" tags="general" />
<Gump class="ChampHuthwaitCompleteGump" file="Services/ExploringTheDeep/Questers/ChampHuthwait.cs" tags="exploring" />
<Gump class="ChampHuthwaitGump" file="Services/ExploringTheDeep/Questers/ChampHuthwait.cs" tags="exploring" />
<Gump class="ChangeHairHueGump" file="Mobiles/NPCs/CustomHairstylist.cs" tags="npc" />
<Gump class="ChangeHairstyleGump" file="Mobiles/NPCs/CustomHairstylist.cs" tags="npc" />
<Gump class="CharacterStatueGump" file="Services/VeteranRewards/Character Statue Maker/Gumps/CharacterStatueGump.cs" tags="veteran-rewards" />
<Gump class="ChooseMasteryGump" file="Quests/Eodon/Hawkwind/TimeForLegendsQuest.cs" tags="quests" />
<Gump class="ChooseTradeDealGump" file="Services/City Loyalty System/Gumps.cs" tags="city,loyalty" />
<Gump class="ChooseTrainingGump" file="Spells/Skill Masteries/CombatTraining.cs" tags="spells" />
<Gump class="ChylothPartyGump" file="Mobiles/NPCs/Chyloth.cs" tags="npc" />
<Gump class="CircuitTrapGump" file="Gumps/Traps/CircuitTrap.cs" tags="traps" />
<Gump class="CityInfoGump" file="Services/City Loyalty System/Gumps.cs" tags="city,loyalty" />
<Gump class="CityMessageBoardGump" file="Services/City Loyalty System/Gumps.cs" tags="city,loyalty" />
<Gump class="CityMessageGump" file="Services/City Loyalty System/Gumps.cs" tags="city,loyalty" />
<Gump class="CityStoneGump" file="Services/City Loyalty System/Gumps.cs" tags="city,loyalty" />
<Gump class="ClientGump" file="Gumps/ClientGump.cs" tags="general" />
<Gump class="CommandInfoGump" file="Commands/HelpInfo.cs" tags="admin,command" />
<Gump class="CommunityCollectionGump" file="Services/CommunityCollections/CommunityCollectionGump.cs" tags="events" />
<Gump class="CompassDirectionGump" file="Services/Underworld/Maze of Death/CompassDirectionGump.cs" tags="underworld" />
<Gump class="ConfirmBindGump" file="Items/Tools/GreaterBraceletOfBinding.cs" tags="item,tool" />
<Gump class="ConfirmCallbackGump" file="Gumps/ConfirmCallbackGump.cs" tags="general" />
<Gump class="ConfirmCartGump" file="Services/UltimaStore/UltimaStoreGump.cs" tags="store" />
<Gump class="ConfirmCommitGump" file="Multis/HouseFoundation.cs" tags="housing" />
<Gump class="ConfirmDryDockGump" file="Multis/Boats/ConfirmDryDockGump.cs" tags="housing" />
<Gump class="ConfirmEntranceGump" file="Services/Peerless/ConfirmGumps.cs" tags="peerless" />
<Gump class="ConfirmExitInstanceGump" file="Services/InstancedPeerless/ConfirmExitInstanceGump.cs" tags="peerless" />
<Gump class="ConfirmHeritageGump" file="Gumps/ConfirmHeritageGump.cs" tags="general" />
<Gump class="ConfirmHouseResize" file="Gumps/ConfirmHouseResize.cs" tags="general" />
<Gump class="ConfirmJoinInstanceGump" file="Services/InstancedPeerless/ConfirmJoinInstanceGump.cs" tags="peerless" />
<Gump class="ConfirmPartyGump" file="Services/Peerless/ConfirmGumps.cs" tags="peerless" />
<Gump class="ConfirmPurchaseGump" file="Services/UltimaStore/UltimaStoreGump.cs" tags="store" />
<Gump class="ConfirmReleaseGump" file="Gumps/ConfirmReleaseGump.cs" tags="general" />
<Gump class="ConfirmRemoveGump" file="Items/Addons/RepairBench.cs" tags="item,addon" />
<Gump class="ConfirmSignupGump" file="Services/ViceVsVirtue/Gumps/ConfirmSignupGump.cs" tags="pvp,vvv" />
<Gump class="ConfirmTeleportGump" file="Services/Vendor Searching/VendorSearchGump.cs" tags="vendor" />
<Gump class="ConfirmTransferPetGump" file="Services/CommunityCollections/ConfirmTransferPetGump.cs" tags="events" />
<Gump class="ConstellationLedgerGump" file="Services/Astronomy/ConstellationLedger.cs" tags="astronomy" />
<Gump class="CorruptedCrystalPortalGump" file="Services/VeteranRewards/CrystalPortals/CorruptedCrystalPortalGump.cs" tags="veteran-rewards" />
<Gump class="CousteauPerronCompleteGump" file="Services/ExploringTheDeep/Questers/CusteauPerron.cs" tags="exploring" />
<Gump class="CousteauPerronGump" file="Services/ExploringTheDeep/Questers/CusteauPerron.cs" tags="exploring" />
<Gump class="CousteauPerronInformationGump" file="Services/ExploringTheDeep/Items/Scroll/CousteauPerronScroll.cs" tags="exploring" />
<Gump class="CousteauPerronPlansGump" file="Services/ExploringTheDeep/Questers/CusteauPerron.cs" tags="exploring" />
<Gump class="CraftGump" file="Services/Craft/Core/CraftGump.cs" tags="crafting" />
<Gump class="CraftGumpItem" file="Services/Craft/Core/CraftGumpItem.cs" tags="crafting" />
<Gump class="CreateGuildGump" file="Gumps/Guilds/New Guild System/Create Guild Gump.cs" tags="guild" />
<Gump class="CreateWorldGump" file="Commands/CreateWorld.cs" tags="admin,command" />
<Gump class="CrystalPortalGump" file="Services/VeteranRewards/CrystalPortals/CrystalPortalGump.cs" tags="veteran-rewards" />
<Gump class="CusteauPerronNoteGump" file="Services/ExploringTheDeep/Items/Scroll/CusteauPerronNote.cs" tags="exploring" />
<Gump class="CustomHuePickerGump" file="Items/Internal/CustomHuePicker.cs" tags="item" />
<Gump class="DashboardAnimalLoreGump" file="Services/Pet Training/DashboardAnimalLoreGump.cs" tags="pets" />
<Gump class="DaviesLockerGump" file="Services/VeteranRewards/DaviesLocker.cs" tags="veteran-rewards" />
<Gump class="DawnsMusicBoxGump" file="Gumps/DawnsMusicBoxGump.cs" tags="general" />
<Gump class="DisguiseGump" file="Items/Tools/DisguiseKit.cs" tags="item,tool" />
<Gump class="DistillationGump" file="Services/New Magincia/Distillation/DistillationGump.cs" tags="magincia,crafting" />
<Gump class="EditEventGump" file="Services/Seasonal Events/SeasonalEventGump.cs" tags="seasonal" />
<Gump class="EditSkillGump" file="Gumps/SkillsGump.cs" tags="general" />
<Gump class="ElectionManagementGump" file="Services/Factions/Gumps/ElectionManagementGump.cs" tags="faction" />
<Gump class="ElectionStartTimeGump" file="Services/City Loyalty System/Gumps.cs" tags="city,loyalty" />
<Gump class="EliseTrentGump" file="Services/ExploringTheDeep/Items/Scroll/EliseTrentScroll.cs" tags="exploring" />
<Gump class="EmptyTheBowlGump" file="Services/Plants/EmptyTheBowlGump.cs" tags="gardening" />
<Gump class="EnchantSpellGump" file="Spells/Mysticism/SpellDefinitions/EnchantGump.cs" tags="spells" />
<Gump class="ExemptCitiesGump" file="Services/ViceVsVirtue/Gumps/ExemptCityGump.cs" tags="pvp,vvv" />
<Gump class="ExodusArchZealotGump" file="Services/Revamped Dungeons/TheExodusEncounter/Mobiles/ExodusArchZealot.cs" tags="dungeon" />
<Gump class="FacetGump" file="Gumps/FacetGump.cs" tags="general" />
<Gump class="FertileDirtGump" file="Services/Plants/FertileDirtGump.cs" tags="gardening" />
<Gump class="FollowerGump" file="Services/Seasonal Events/ForsakenFoes/Mobile/Follower.cs" tags="seasonal" />
<Gump class="ForgedMetalCancelGump" file="Items/Tools/ForgedMetalOfArtifacts.cs" tags="item,tool" />
<Gump class="ForgedMetalInternalGump" file="Items/Tools/ForgedMetalOfArtifacts.cs" tags="item,tool" />
<Gump class="GenderChangeConfirmGump" file="Items/StoreBought/GenderChangeToken.cs" tags="item,store" />
<Gump class="GoGump" file="Gumps/Go/GoGump.cs" tags="admin" />
<Gump class="GreaterBraceletOfBindingGump" file="Items/Tools/GreaterBraceletOfBinding.cs" tags="item,tool" />
<Gump class="GuideVertexEditGump" file="Mobiles/NPCs/AttendantGuide.cs" tags="npc" />
<Gump class="GuildChangeTypeGump" file="Gumps/Guilds/GuildChangeTypeGump.cs" tags="guild" />
<Gump class="GuildCharterGump" file="Gumps/Guilds/GuildCharterGump.cs" tags="guild" />
<Gump class="GuildGump" file="Gumps/Guilds/GuildGump.cs" tags="guild" />
<Gump class="GuildLeaderboardGump" file="Services/ViceVsVirtue/Gumps/GuildLeaderboardGump.cs" tags="pvp,vvv" />
<Gump class="GuildmasterGump" file="Gumps/Guilds/GuildmasterGump.cs" tags="guild" />
<Gump class="GuildWarAdminGump" file="Gumps/Guilds/GuildWarAdminGump.cs" tags="guild" />
<Gump class="GuildWarGump" file="Gumps/Guilds/GuildWarGump.cs" tags="guild" />
<Gump class="GumshoeItemGump" file="Services/Seasonal Events/TreasuresOfKhaldun/Gumps.cs" tags="seasonal" />
<Gump class="HairDyeConfirmGump" file="Items/StoreBought/NaturalHairDyes.cs" tags="item,store" />
<Gump class="HairDyeGump" file="Items/Consumables/HairDye.cs" tags="item,consumable" />
<Gump class="HairstylistBuyGump" file="Mobiles/NPCs/CustomHairstylist.cs" tags="npc" />
<Gump class="HelpGump" file="Services/XmlSpawner/XmlSpawner Core/XmlSpawnerGumps.cs" tags="xmlspawner,admin" />
<Gump class="HeritageTokenGump" file="Gumps/HeritageTokenGump.cs" tags="general" />
<Gump class="HolidayTreeChoiceGump" file="Items/Consumables/HolidayTreeDeed.cs" tags="item,consumable" />
<Gump class="HonorSelf" file="Gumps/HonorSelf.cs" tags="general" />
<Gump class="HouseDemolishGump" file="Gumps/HouseDemolishGump.cs" tags="general" />
<Gump class="HouseGump" file="Gumps/HouseGump.cs" tags="general" />
<Gump class="HouseGumpAOS" file="Gumps/HouseGumpAOS.cs" tags="general" />
<Gump class="HouseListGump" file="Gumps/HouseGump.cs" tags="general" />
<Gump class="HousePlacementCategoryGump" file="Multis/HousePlacementTool.cs" tags="housing" />
<Gump class="HousePlacementListGump" file="Multis/HousePlacementTool.cs" tags="housing" />
<Gump class="HouseRaffleManagementGump" file="Gumps/HouseRaffleManagementGump.cs" tags="general" />
<Gump class="HouseRemoveGump" file="Gumps/HouseGump.cs" tags="general" />
<Gump class="HouseSwapGump" file="Multis/HousePlacementTool.cs" tags="housing" />
<Gump class="HouseTeleporterTypeGump" file="Multis/HouseTeleporterTile.cs" tags="housing" />
<Gump class="HouseTransferGump" file="Gumps/HouseTransferGump.cs" tags="general" />
<Gump class="HumilityItemQuestGump" file="Quests/CloakOfHumility/Gumps/GiveItemQuestGump.cs" tags="quests" />
<Gump class="ImbueGump" file="Services/LootGeneration/Imbuing/Gumps/ImbueGump.cs" tags="loot" />
<Gump class="ImbueSelectGump" file="Services/LootGeneration/Imbuing/Gumps/ImbueSelectGump.cs" tags="loot" />
<Gump class="ImbuingGump" file="Services/LootGeneration/Imbuing/Gumps/ImbuingGump.cs" tags="loot" />
<Gump class="InfoSpecificGump" file="Services/LootGeneration/ItemPropertiesGump.cs" tags="loot" />
<Gump class="InternalTradeOrderGump" file="Services/City Loyalty System/Trading/Mobiles/TradeMinister.cs" tags="city,loyalty" />
<Gump class="ItemNameGump" file="Services/LootGeneration/RunicReforging/Gumps.cs" tags="loot" />
<Gump class="ItemPropertiesGump" file="Services/LootGeneration/ItemPropertiesGump.cs" tags="loot" />
<Gump class="JewelryBoxGump" file="Items/Functional/JewelryBox/JewelryBoxGump.cs" tags="item,functional" />
<Gump class="JosefSkimmonsCompleteGump" file="Services/ExploringTheDeep/Questers/JosefSkimmons.cs" tags="exploring" />
<Gump class="JosefSkimmonsGump" file="Services/ExploringTheDeep/Questers/JosefSkimmons.cs" tags="exploring" />
<Gump class="JosefSkimmonsPrivateGump" file="Services/ExploringTheDeep/Items/Scroll/JosefSkimmonsScroll.cs" tags="exploring" />
<Gump class="JournalEntryGump" file="Services/XmlSpawner/XmlSpawner Core/XmlQuest/XmlQuestHolderGumps.cs" tags="xmlspawner,admin" />
<Gump class="JournalGump" file="Services/ExploringTheDeep/Items/Scroll/JournalScroll.cs" tags="exploring" />
<Gump class="LargeBODAcceptGump" file="Services/BulkOrders/LargeBODs/LargeBODAcceptGump.cs" tags="crafting,bod" />
<Gump class="LargeBODGump" file="Services/BulkOrders/LargeBODs/LargeBODGump.cs" tags="crafting,bod" />
<Gump class="LedgerGump" file="Services/ExploringTheDeep/Items/Scroll/LedgerScroll.cs" tags="exploring" />
<Gump class="LiamDeFoeGump" file="Services/ExploringTheDeep/Items/Scroll/LiamDeFoeScroll.cs" tags="exploring" />
<Gump class="LockingMechanismGump" file="Quests/Eodon/Valley of One Quest/TigerCubEnclosure.cs" tags="quests" />
<Gump class="LottoTrackingGump" file="Services/New Magincia/Housing Lotto/Gumps/LottoTrackingGump.cs" tags="magincia" />
<Gump class="LoyaltyRatingGump" file="Services/LoyaltySystem/LoyaltyRatingGump.cs" tags="loyalty" />
<Gump class="MadelineHarteCompleteGump" file="Services/ExploringTheDeep/Questers/MadelineHarte.cs" tags="exploring" />
<Gump class="MadelineHarteGump" file="Services/ExploringTheDeep/Questers/MadelineHarte.cs" tags="exploring" />
<Gump class="MaginciaLottoGump" file="Services/New Magincia/Housing Lotto/Gumps/MaginciaLottoGump.cs" tags="magincia" />
<Gump class="MainPlantGump" file="Services/Plants/MainPlantGump.cs" tags="gardening" />
<Gump class="MannequinCompareGump" file="Mobiles/NPCs/Mannequin/Gumps.cs" tags="npc" />
<Gump class="MannequinStatsGump" file="Mobiles/NPCs/Mannequin/Gumps.cs" tags="npc" />
<Gump class="MasterySelectionGump" file="Spells/Skill Masteries/Core/SelectMasteryGump.cs" tags="spells" />
<Gump class="MessageSentGump" file="Services/Help/PageQueueGump.cs" tags="admin,help" />
<Gump class="MiningCooperativeGump" file="Services/Seasonal Events/ForsakenFoes/Mobile/MiningCooperativeMerchant.cs" tags="seasonal" />
<Gump class="MLConfirmHeritageGump" file="Services/MondainsLegacyQuests/Gumps/ConfirmHeritageGump.cs" tags="mondain,quests" />
<Gump class="MondainResignGump" file="Services/MondainsLegacyQuests/Gumps/MondainResignGump.cs" tags="mondain,quests" />
<Gump class="MondainsLegacyGump" file="Services/Expansions/Mondains Legacy.cs" tags="script" />
<Gump class="MoongateConfirmGump" file="Items/Internal/Moongate.cs" tags="item" />
<Gump class="MoongateGump" file="Items/Functional/PublicMoongate.cs" tags="item,functional" />
<Gump class="MTSchematicsGump" file="Services/ExploringTheDeep/Items/Scroll/MasterThinkerSchematics.cs" tags="exploring" />
<Gump class="MythicalCharacterTokenGump" file="Items/StoreBought/MythicalCharacterToken.cs" tags="item,store" />
<Gump class="NameChangeConfirmGump" file="Items/StoreBought/NameChangeToken.cs" tags="item,store" />
<Gump class="NameChangeDeedGump" file="Items/Consumables/NameChangeDeed.cs" tags="item,consumable" />
<Gump class="NewAnimalLoreGump" file="Services/Pet Training/Gumps.cs" tags="pets" />
<Gump class="NewCreateWorldGump" file="Commands/CreateWorld.cs" tags="admin,command" />
<Gump class="NewCurrencyHelpGump" file="Gumps/BankerGump.cs" tags="general" />
<Gump class="NewMaginciaMessageDetailGump" file="Services/New Magincia/Housing Lotto/Gumps/NewMaginciaMessageGump.cs" tags="magincia" />
<Gump class="NewMaginciaMessageGump" file="Services/New Magincia/Housing Lotto/Gumps/NewMaginciaMessageGump.cs" tags="magincia" />
<Gump class="NewMaginciaMessageListGump" file="Services/New Magincia/Housing Lotto/Gumps/NewMaginciaMessageGump.cs" tags="magincia" />
<Gump class="NewPlayerVendorCustomizeGump" file="Gumps/PlayerVendorGumps.cs" tags="general" />
<Gump class="NewPlayerVendorOwnerGump" file="Gumps/PlayerVendorGumps.cs" tags="general" />
<Gump class="NewPolymorphGump" file="Gumps/PolymorphGump.cs" tags="general" />
<Gump class="NoFundsGump" file="Services/UltimaStore/UltimaStoreGump.cs" tags="store" />
<Gump class="NomineesGump" file="Services/City Loyalty System/Gumps.cs" tags="city,loyalty" />
<Gump class="NoticeGump" file="Gumps/NoticeGump.cs" tags="general" />
<Gump class="OpenInventoryGump" file="Services/City Loyalty System/Gumps.cs" tags="city,loyalty" />
<Gump class="PageEntryGump" file="Services/Help/PageQueueGump.cs" tags="admin,help" />
<Gump class="PageInQueueGump" file="Services/Help/PageQueue.cs" tags="admin,help" />
<Gump class="PagePromptGump" file="Services/Help/PagePromptGump.cs" tags="admin,help" />
<Gump class="PageQueueGump" file="Services/Help/PageQueueGump.cs" tags="admin,help" />
<Gump class="PageResponseGump" file="Services/Help/PageResponseGump.cs" tags="admin,help" />
<Gump class="PenOfWisdomGump" file="Items/Tools/PenOfWisdom.cs" tags="item,tool" />
<Gump class="PetResurrectGump" file="Gumps/PetResurrectGump.cs" tags="general" />
<Gump class="PetTrainingConfirmationGump" file="Services/Pet Training/Gumps.cs" tags="pets" />
<Gump class="PetTrainingConfirmGump" file="Services/Pet Training/Gumps.cs" tags="pets" />
<Gump class="PetTrainingInfoGump" file="Services/Pet Training/Gumps.cs" tags="pets" />
<Gump class="PetTrainingOptionsGump" file="Services/Pet Training/Gumps.cs" tags="pets" />
<Gump class="PetTrainingPlanningGump" file="Services/Pet Training/Gumps.cs" tags="pets" />
<Gump class="PetTrainingProgressGump" file="Services/Pet Training/Gumps.cs" tags="pets" />
<Gump class="PetWhistleGump" file="Items/StoreBought/PetWhistle.cs" tags="item,store" />
<Gump class="PlayerBBGump" file="Items/Addons/PlayerBulletinBoards.cs" tags="item,addon" />
<Gump class="PlayerStatsGump" file="Custom/MyStats.cs" tags="custom" />
<Gump class="PlayerTitleGump" file="Services/City Loyalty System/Gumps.cs" tags="city,loyalty" />
<Gump class="PlayerVendorBuyGump" file="Gumps/PlayerVendorGumps.cs" tags="general" />
<Gump class="PlayerVendorCustomizeGump" file="Gumps/PlayerVendorGumps.cs" tags="general" />
<Gump class="PlayerVendorOwnerGump" file="Gumps/PlayerVendorGumps.cs" tags="general" />
<Gump class="PlotTrackingGump" file="Services/New Magincia/Housing Lotto/Gumps/LottoTrackingGump.cs" tags="magincia" />
<Gump class="PlotWinnerGump" file="Services/New Magincia/Housing Lotto/Gumps/PlotWinnerGump.cs" tags="magincia" />
<Gump class="PolymorphGump" file="Gumps/PolymorphGump.cs" tags="general" />
<Gump class="PredefGump" file="Services/Help/PageQueueGump.cs" tags="admin,help" />
<Gump class="PromoCodeGump" file="Services/UltimaStore/UltimaStoreGump.cs" tags="store" />
<Gump class="PropertiesGump" file="Gumps/Props/PropsGump.cs" tags="admin,props" />
<Gump class="PurchaseCasinoChipGump" file="Services/FireCasino/CasinoGumps.cs" tags="casino" />
<Gump class="PuzzleGump" file="Services/Khaldun/PuzzleChest.cs" tags="khaldun" />
<Gump class="PVPArenaSystemSetupGump" file="Services/PVP Arena System/Gumps.cs" tags="pvp,arena" />
<Gump class="PvpWarningGump" file="Items/Internal/PvPWarnTeleporter.cs" tags="item" />
<Gump class="QAndAGump" file="Services/MondainsLegacyQuests/Gumps/QAndAGump.cs" tags="mondain,quests" />
<Gump class="QueryMakersMarkGump" file="Services/Craft/Core/QueryMakersMarkGump.cs" tags="crafting" />
<Gump class="QuestNoteGump" file="Services/XmlSpawner/XmlSpawner Core/XmlItems/QuestNote.cs" tags="xmlspawner,admin" />
<Gump class="QuestRewardGump" file="Services/XmlSpawner/XmlSpawner Core/XmlQuest/QuestRewardGump.cs" tags="xmlspawner,admin" />
<Gump class="RaceChangeConfirmGump" file="Items/StoreBought/RaceChangeToken.cs" tags="item,store" />
<Gump class="RecipeBookGump" file="Items/Books/SpecialScrollBooks/RecipeBook/RecipeBookGump.cs" tags="item,book" />
<Gump class="RecipeScrollBuyGump" file="Items/Books/SpecialScrollBooks/RecipeBook/RecipeBuyGump.cs" tags="item,book" />
<Gump class="RecipeScrollFilterGump" file="Items/Books/SpecialScrollBooks/RecipeBook/RecipeFilterGump.cs" tags="item,book" />
<Gump class="ReclaimVendorGump" file="Gumps/ReclaimVendorGump.cs" tags="general" />
<Gump class="RefinementGump" file="Services/Armor Refinement/Gumps.cs" tags="crafting" />
<Gump class="RefinementHelpGump" file="Services/Armor Refinement/Gumps.cs" tags="crafting" />
<Gump class="RejoinInstanceGump" file="Services/InstancedPeerless/RejoinInstanceGump.cs" tags="peerless" />
<Gump class="ReLoginClaimGump" file="Gumps/ReLoginClaimGump.cs" tags="general" />
<Gump class="RenounceYoungGump" file="Gumps/YoungGumps.cs" tags="general" />
<Gump class="RepairBenchGump" file="Items/Addons/RepairBench.cs" tags="item,addon" />
<Gump class="ReportMurdererGump" file="Gumps/ReportMurderer.cs" tags="general" />
<Gump class="ReproductionGump" file="Services/Plants/ReproductionGump.cs" tags="gardening" />
<Gump class="ResurrectGump" file="Gumps/ResurrectGump.cs" tags="general" />
<Gump class="RewardChoiceGump" file="Services/VeteranRewards/RewardChoiceGump.cs" tags="veteran-rewards" />
<Gump class="RewardConfirmGump" file="Services/VeteranRewards/RewardConfirmGump.cs" tags="veteran-rewards" />
<Gump class="RewardDemolitionGump" file="Services/VeteranRewards/RewardDemolitionGump.cs" tags="veteran-rewards" />
<Gump class="RewardNoticeGump" file="Services/VeteranRewards/RewardNoticeGump.cs" tags="veteran-rewards" />
<Gump class="RewardOptionGump" file="Services/VeteranRewards/RewardOptionGump.cs" tags="veteran-rewards" />
<Gump class="RunebookGump" file="Gumps/RunebookGump.cs" tags="general" />
<Gump class="RunicAtlasGump" file="Items/Books/RunicAtlas.cs" tags="item,book" />
<Gump class="RunicReforgingGump" file="Services/LootGeneration/RunicReforging/Gumps.cs" tags="loot" />
<Gump class="ScoresGump" file="Services/Revamped Dungeons/Covetous Void Spawn/Gumps.cs" tags="dungeon" />
<Gump class="ScrollOfAbraxusGump" file="Items/Quest/ScrollOfAbraxus.cs" tags="item,quest" />
<Gump class="SealedLettersEntryGump" file="Services/ExploringTheDeep/Items/Scroll/SealedLettersScroll.cs" tags="exploring" />
<Gump class="SearchResultsGump" file="Services/Vendor Searching/VendorSearchGump.cs" tags="vendor" />
<Gump class="SearchWaitGump" file="Services/Vendor Searching/VendorSearchGump.cs" tags="vendor" />
<Gump class="SeasonalEventGump" file="Services/Seasonal Events/SeasonalEventGump.cs" tags="seasonal" />
<Gump class="SecretChestGump" file="Items/StoreBought/SecretChest.cs" tags="item,store" />
<Gump class="SeedBoxGump" file="Items/Functional/SeedBox/SeedBoxGump.cs" tags="item,functional" />
<Gump class="SeedInfoGump" file="Items/Functional/SeedBox/SeedBoxGump.cs" tags="item,functional" />
<Gump class="SelectTitleGump" file="Services/CommunityCollections/SelectTitleGump.cs" tags="events" />
<Gump class="SetBodyGump" file="Gumps/Props/SetBodyGump.cs" tags="admin,props" />
<Gump class="SetColorGump" file="Gumps/Props/SetColorGump.cs" tags="admin,props" />
<Gump class="SetDateTimeGump" file="Gumps/Props/SetDateTimeGump.cs" tags="admin,props" />
<Gump class="SetGump" file="Gumps/Props/SetGump.cs" tags="admin,props" />
<Gump class="SetListOptionGump" file="Gumps/Props/SetListOptionGump.cs" tags="admin,props" />
<Gump class="SetObjectGump" file="Gumps/Props/SetObjectGump.cs" tags="admin,props" />
<Gump class="SetPoint2DGump" file="Gumps/Props/SetPoint2DGump.cs" tags="admin,props" />
<Gump class="SetPoint3DGump" file="Gumps/Props/SetPoint3DGump.cs" tags="admin,props" />
<Gump class="SetSecureLevelGump" file="Gumps/SetSecureLevelGump.cs" tags="general" />
<Gump class="SetTimeSpanGump" file="Gumps/Props/SetTimeSpanGump.cs" tags="admin,props" />
<Gump class="SetToDecorativeGump" file="Services/Plants/SetToDecorativeGump.cs" tags="gardening" />
<Gump class="ShadowguardGump" file="Services/Expansions/Time Of Legends/Shadowguard/Controller.cs" tags="time-of-legends" />
<Gump class="ShardPollGump" file="Misc/ShardPoller.cs" tags="misc" />
<Gump class="ShipCannonGump" file="Services/Expansions/High Seas/Items/Cannons and Ammo/ShipCannon.cs" tags="highseas" />
<Gump class="SimpleNoteGump" file="Services/XmlSpawner/XmlSpawner Core/XmlItems/SimpleNote.cs" tags="xmlspawner,admin" />
<Gump class="SkillsGump" file="Gumps/SkillsGump.cs" tags="general" />
<Gump class="SliderTrapGump" file="Items/Functional/SliderTrapTrainingKit.cs" tags="item,functional" />
<Gump class="SmallBODAcceptGump" file="Services/BulkOrders/SmallBODs/SmallBODAcceptGump.cs" tags="crafting,bod" />
<Gump class="SmallBODGump" file="Services/BulkOrders/SmallBODs/SmallBODGump.cs" tags="crafting,bod" />
<Gump class="SouthEastGump" file="Items/Addons/SouthEastPlacementGump.cs" tags="item,addon" />
<Gump class="SpawnerGump" file="Services/Spawner/SpawnerGump.cs" tags="admin,spawner" />
<Gump class="SpecialBeardDyeGump" file="Items/Consumables/SpecialBeardDye.cs" tags="item,consumable" />
<Gump class="SpecialHairDyeGump" file="Items/Consumables/SpecialHairDye.cs" tags="item,consumable" />
<Gump class="SpecialScrollBookGump" file="Items/Books/SpecialScrollBooks/SpecialScrollBookGump.cs" tags="item,book" />
<Gump class="SpeechLogGump" file="Services/Help/SpeechLogGump.cs" tags="admin,help" />
<Gump class="SphynxGump" file="Services/Malas/Sphynx/Sphynx.cs" tags="malas" />
<Gump class="StarChartGump" file="Services/Astronomy/StarChart.cs" tags="astronomy" />
<Gump class="StatRewardGump" file="Services/VeteranRewards/StatRewardGump.cs" tags="veteran-rewards" />
<Gump class="StatusGump" file="Services/Khaldun/PuzzleChest.cs" tags="khaldun" />
<Gump class="StormLevelGump" file="Gumps/StormLevelGump.cs" tags="general" />
<Gump class="StoryGump" file="Gumps/StoryGump.cs" tags="general" />
<Gump class="StuckMenu" file="Services/Help/StuckMenu.cs" tags="admin,help" />
<Gump class="SummonFamiliarGump" file="Spells/Necromancy/SummonFamiliar.cs" tags="spells" />
<Gump class="SuspicionsGump" file="Services/ExploringTheDeep/Items/Scroll/SuspicionsScroll.cs" tags="exploring" />
<Gump class="SystemInfoGump" file="Services/City Loyalty System/Gumps.cs" tags="city,loyalty" />
<Gump class="TelescopeGump" file="Services/Astronomy/Telescope.cs" tags="astronomy" />
<Gump class="TextEntryGump" file="Services/XmlSpawner/XmlSpawner Core/XmlSpawnerGumps.cs" tags="xmlspawner,admin" />
<Gump class="TithingGump" file="Gumps/TithingGump.cs" tags="general" />
<Gump class="TitlesGump" file="Gumps/TitlesMenu.cs" tags="general" />
<Gump class="TopiaryGump" file="Services/BasketWeaving/Clippers.cs" tags="crafting" />
<Gump class="TopQuestPlayersGump" file="Services/XmlSpawner/XmlSpawner Core/XmlQuest/XmlQuestLeaders.cs" tags="xmlspawner,admin" />
<Gump class="TOSDSpawnerGump" file="Services/Seasonal Events/TreasuresOfSorceresDungeon/Spawner.cs" tags="seasonal" />
<Gump class="ToTAdminGump" file="Gumps/ToTAdminGump.cs" tags="general" />
<Gump class="TownCrierGump" file="Mobiles/NPCs/TownCrier.cs" tags="npc" />
<Gump class="TownCrierQuestCompleteGump" file="Services/Town Cryer/Gumps/TownCryerCompleteQuestGump.cs" tags="town-cryer" />
<Gump class="TrackWhatGump" file="Skills/Tracking.cs" tags="skills" />
<Gump class="TrackWhoGump" file="Skills/Tracking.cs" tags="skills" />
<Gump class="UltimaStoreGump" file="Services/UltimaStore/UltimaStoreGump.cs" tags="store" />
<Gump class="UnderworldPuzzleGump" file="Services/Underworld/Maze of Death/UnderworldPuzzle.cs" tags="underworld" />
<Gump class="UnloadFeluccaGump" file="Commands/UnloadMaps.cs" tags="admin,command" />
<Gump class="UnloadIlshenarGump" file="Commands/UnloadMaps.cs" tags="admin,command" />
<Gump class="UnloadMalasGump" file="Commands/UnloadMaps.cs" tags="admin,command" />
<Gump class="UnloadTermurGump" file="Commands/UnloadMaps.cs" tags="admin,command" />
<Gump class="UnloadTokunoGump" file="Commands/UnloadMaps.cs" tags="admin,command" />
<Gump class="UnloadTrammelGump" file="Commands/UnloadMaps.cs" tags="admin,command" />
<Gump class="VendorInventoryGump" file="Gumps/VendorInventoryGump.cs" tags="general" />
<Gump class="VendorRentalRefundGump" file="Gumps/VendorRentalGumps.cs" tags="general" />
<Gump class="VendorSearchGump" file="Services/Vendor Searching/VendorSearchGump.cs" tags="vendor" />
<Gump class="VetResurrectGump" file="Mobiles/NPCs/Veterinarian.cs" tags="npc" />
<Gump class="ViceVsVirtueLeaderboardGump" file="Services/ViceVsVirtue/Gumps/LeaderboardGump.cs" tags="pvp,vvv" />
<Gump class="ViewHousesGump" file="Gumps/ViewHousesGump.cs" tags="general" />
<Gump class="VirtueGump" file="Services/Virtues/VirtueGump.cs" tags="script" />
<Gump class="VirtueInfoGump" file="Services/Virtues/VirtueInfoGump.cs" tags="script" />
<Gump class="VirtueStatusGump" file="Services/Virtues/VirtueStatusGump.cs" tags="script" />
<Gump class="VoidPoolGump" file="Services/Revamped Dungeons/Covetous Void Spawn/Gumps.cs" tags="dungeon" />
<Gump class="VvVBattleStatusGump" file="Services/ViceVsVirtue/Gumps/VvVBattleStatusGump.cs" tags="pvp,vvv" />
<Gump class="WallSafeGump" file="Items/Functional/WallSafe.cs" tags="item,functional" />
<Gump class="WarningGump" file="Gumps/WarningGump.cs" tags="general" />
<Gump class="WhoGump" file="Gumps/WhoGump.cs" tags="general" />
<Gump class="WillemHarteGump" file="Services/ExploringTheDeep/Items/Scroll/WillemHarteScroll.cs" tags="exploring" />
<Gump class="WorkerGump" file="Services/Seasonal Events/ForsakenFoes/Mobile/Worker.cs" tags="seasonal" />
<Gump class="XmlAddGump" file="Services/XmlSpawner/XmlSpawner Core/XmlUtils/XmlAdd.cs" tags="xmlspawner,admin" />
<Gump class="XmlCategorizedAddGump" file="Services/XmlSpawner/XmlSpawner Core/XmlUtils/XmlCategorizedAddGump.cs" tags="xmlspawner,admin" />
<Gump class="XmlConfirmAddGump" file="Services/XmlSpawner/XmlSpawner Core/XmlUtils/XmlEdit.cs" tags="xmlspawner,admin" />
<Gump class="XmlConfirmBringGump" file="Services/XmlSpawner/XmlSpawner Core/XmlUtils/XmlFind.cs" tags="xmlspawner,admin" />
<Gump class="XmlConfirmDeleteGump" file="Services/XmlSpawner/XmlSpawner Core/XmlUtils/XmlFind.cs" tags="xmlspawner,admin" />
<Gump class="XmlEditDialogGump" file="Services/XmlSpawner/XmlSpawner Core/XmlUtils/XmlEdit.cs" tags="xmlspawner,admin" />
<Gump class="XmlFindGump" file="Services/XmlSpawner/XmlSpawner Core/XmlUtils/XmlFind.cs" tags="xmlspawner,admin" />
<Gump class="XmlGetAttGump" file="Services/XmlSpawner/XmlSpawner Core/XmlAttach/XmlGetAttachGump.cs" tags="xmlspawner,admin" />
<Gump class="XmlPartialCategorizedAddGump" file="Services/XmlSpawner/XmlSpawner Core/XmlUtils/XmlPartialCategorizedAddGump.cs" tags="xmlspawner,admin" />
<Gump class="XmlPlayerQuestGump" file="Services/XmlSpawner/XmlSpawner Core/XmlQuest/XmlPlayerQuestGump.cs" tags="xmlspawner,admin" />
<Gump class="XmlPropertiesGump" file="Services/XmlSpawner/XmlSpawner Core/XmlPropsGumps/XmlPropsGump.cs" tags="xmlspawner,admin" />
<Gump class="XmlQuestBookGump" file="Services/XmlSpawner/XmlSpawner Core/XmlQuest/XmlQuestBookGump.cs" tags="xmlspawner,admin" />
<Gump class="XMLQuestLogGump" file="Services/XmlSpawner/XmlSpawner Core/XmlQuest/QuestLogGump.cs" tags="xmlspawner,admin" />
<Gump class="XmlQuestStatusGump" file="Services/XmlSpawner/XmlSpawner Core/XmlQuest/XmlQuestHolderGumps.cs" tags="xmlspawner,admin" />
<Gump class="XmlSetGump" file="Services/XmlSpawner/XmlSpawner Core/XmlPropsGumps/XmlSetGump.cs" tags="xmlspawner,admin" />
<Gump class="XmlSetListOptionGump" file="Services/XmlSpawner/XmlSpawner Core/XmlPropsGumps/XmlSetListOptionGump.cs" tags="xmlspawner,admin" />
<Gump class="XmlSetObjectGump" file="Services/XmlSpawner/XmlSpawner Core/XmlPropsGumps/XmlSetObjectGump.cs" tags="xmlspawner,admin" />
<Gump class="XmlSetPoint2DGump" file="Services/XmlSpawner/XmlSpawner Core/XmlPropsGumps/XmlSetPoint2DGump.cs" tags="xmlspawner,admin" />
<Gump class="XmlSetPoint3DGump" file="Services/XmlSpawner/XmlSpawner Core/XmlPropsGumps/XmlSetPoint3DGump.cs" tags="xmlspawner,admin" />
<Gump class="XmlSetTimeSpanGump" file="Services/XmlSpawner/XmlSpawner Core/XmlPropsGumps/XmlSetTimeSpanGump.cs" tags="xmlspawner,admin" />
<Gump class="XmlSimpleGump" file="Services/XmlSpawner/XmlSpawner Core/XmlQuest/XmlQuestGumps.cs" tags="xmlspawner,admin" />
<Gump class="XmlSpawnerGump" file="Services/XmlSpawner/XmlSpawner Core/XmlSpawnerGumps.cs" tags="xmlspawner,admin" />
<Gump class="YoungDeathNotice" file="Gumps/YoungGumps.cs" tags="general" />
<Gump class="YoungDungeonWarning" file="Gumps/YoungGumps.cs" tags="general" />
<Gump class="ZaliaQuestCompleteGump" file="Services/ExploringTheDeep/Questers/GipsyGemologist.cs" tags="exploring" />
<Gump class="ZaliaQuestGump" file="Services/ExploringTheDeep/Questers/GipsyGemologist.cs" tags="exploring" />
</ScriptGumps>

19
index.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/assets/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Artificer's Scrollwork</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;500;600&family=EB+Garamond:ital,wght@0,400;0,500;1,400&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2073
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "artificers-scrollwork",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-shell": "^2",
"zustand": "^4.5.2",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.3",
"vite": "^5.3.4"
}
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<AssemblyName>asw-sidecar</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.*" />
<PackageReference Include="System.Text.Json" Version="8.*" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,13 @@
namespace ArtificersScrollwork.Sidecar.Gumps;
/// <summary>
/// Extracts Add* calls from Gump constructors and produces a GumpDrawList.
/// TODO (Phase 4): Full implementation.
/// </summary>
public class GumpExtractor
{
// Phase 4 implementation placeholder.
// Will walk the constructor body of a Gump class,
// map Add* method calls to DrawCall records,
// and detect dynamic regions (loop/conditional bodies).
}

View File

@@ -0,0 +1,22 @@
namespace ArtificersScrollwork.Sidecar.Models;
public record ClassInfo(
string Name,
string Namespace,
string FilePath,
string? BaseClass,
List<string> Interfaces,
List<PropertyInfo> Properties,
List<MethodInfo> Methods,
List<string> Attributes,
bool IsGump,
bool IsMobile,
bool IsItem
);
public record PropertyInfo(
string Name,
string Type,
bool HasGetter,
bool HasSetter
);

View File

@@ -0,0 +1,37 @@
namespace ArtificersScrollwork.Sidecar.Models;
/// <summary>Discriminated union of all Gump draw call types.</summary>
public abstract record DrawCall(string Type);
public record BackgroundDrawCall(int X, int Y, int W, int H, int GumpId)
: DrawCall("background");
public record ImageDrawCall(int X, int Y, int GumpId, int? Hue = null)
: DrawCall("image");
public record LabelDrawCall(int X, int Y, int Hue, string Text)
: DrawCall("label");
public record ButtonDrawCall(int X, int Y, int NormalId, int PressedId, int ButtonId)
: DrawCall("button");
public record HtmlDrawCall(int X, int Y, int W, int H, string Text, bool HasBackground, bool HasScrollbar)
: DrawCall("html");
public record ItemDrawCall(int X, int Y, int ItemId, int? Hue = null)
: DrawCall("item");
public record AlphaRegionDrawCall(int X, int Y, int W, int H)
: DrawCall("alpha_region");
public record TiledImageDrawCall(int X, int Y, int W, int H, int GumpId)
: DrawCall("tiled_image");
public record CheckboxDrawCall(int X, int Y, int InactiveId, int ActiveId, bool Checked, int SwitchId)
: DrawCall("checkbox");
public record RadioDrawCall(int X, int Y, int InactiveId, int ActiveId, bool Checked, int ReturnValue)
: DrawCall("radio");
public record TextEntryDrawCall(int X, int Y, int W, int H, int Hue, int EntryId, string InitialText)
: DrawCall("text_entry");

View File

@@ -0,0 +1,11 @@
namespace ArtificersScrollwork.Sidecar.Models;
public record FlowNode(
string Id,
string Type, // method_call | condition | gump_send | return | property_access
string Label,
List<FlowNode> Children,
string? FakeInputKey = null,
string? ResolvedGump = null,
int? AssetRef = null
);

View File

@@ -0,0 +1,16 @@
namespace ArtificersScrollwork.Sidecar.Models;
public record MethodInfo(
string Name,
string ReturnType,
List<ParameterInfo> Parameters,
bool IsOverride,
bool IsVirtual,
bool CallsGump,
string? GumpClass
);
public record ParameterInfo(
string Name,
string Type
);

View File

@@ -0,0 +1,17 @@
namespace ArtificersScrollwork.Sidecar.Models;
public record ScriptIndex(
int ClassCount,
int FileCount,
List<ClassSummary> Classes
);
public record ClassSummary(
string Name,
string Namespace,
string FilePath,
string? BaseClass,
bool IsGump,
bool IsMobile,
bool IsItem
);

View File

@@ -0,0 +1,365 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using ArtificersScrollwork.Sidecar.Models;
namespace ArtificersScrollwork.Sidecar.Parsing;
/// <summary>
/// Traces method call chains across ServUO scripts using Roslyn and produces a FlowNode tree.
/// Entry point: TraceMethod(className, methodName).
/// Max recursion depth: 8 levels.
/// </summary>
public class CallChainTracer
{
private const int MaxDepth = 8;
private readonly CSharpCompilation _compilation;
private readonly Dictionary<string, (ClassDeclarationSyntax Syntax, SemanticModel Model)> _classMap;
public CallChainTracer(CSharpCompilation compilation)
{
_compilation = compilation;
_classMap = BuildClassMap();
}
// ── Public entry point ────────────────────────────────────────────────────
public FlowNode TraceMethod(string className, string methodName)
{
if (!_classMap.TryGetValue(className, out var entry))
throw new KeyNotFoundException($"Class '{className}' not found");
var methodSyntax = entry.Syntax.Members
.OfType<MethodDeclarationSyntax>()
.FirstOrDefault(m => m.Identifier.Text == methodName)
?? throw new KeyNotFoundException($"Method '{methodName}' not found in '{className}'");
var rootNode = new FlowNode(
Id: NewId(),
Type: "method_call",
Label: $"{className}.{methodName}",
Children: TraceMethodBody(methodSyntax, entry.Model, depth: 0),
FakeInputKey: null,
ResolvedGump: null,
AssetRef: null
);
return rootNode;
}
// ── Internal traversal ────────────────────────────────────────────────────
private List<FlowNode> TraceMethodBody(
MethodDeclarationSyntax method,
SemanticModel model,
int depth)
{
if (depth >= MaxDepth) return [];
if (method.Body is null && method.ExpressionBody is null) return [];
SyntaxNode body = (SyntaxNode?)method.Body ?? method.ExpressionBody!;
return TraceStatements(body.ChildNodes(), model, depth);
}
private List<FlowNode> TraceStatements(
IEnumerable<SyntaxNode> nodes,
SemanticModel model,
int depth)
{
var result = new List<FlowNode>();
foreach (var node in nodes)
{
var children = TraceNode(node, model, depth);
result.AddRange(children);
}
return result;
}
private List<FlowNode> TraceNode(SyntaxNode node, SemanticModel model, int depth)
{
return node switch
{
IfStatementSyntax ifStmt => [TraceIf(ifStmt, model, depth)],
SwitchStatementSyntax sw => [TraceSwitch(sw, model, depth)],
ReturnStatementSyntax ret => [TraceReturn(ret, model, depth)],
ExpressionStatementSyntax expr => TraceExpressionStatement(expr, model, depth),
LocalDeclarationStatementSyntax local => TraceLocal(local, model, depth),
ForEachStatementSyntax forEach => [TraceForEach(forEach, model, depth)],
ForStatementSyntax forStmt => [TraceFor(forStmt, model, depth)],
BlockSyntax block => TraceStatements(block.Statements, model, depth),
_ => [],
};
}
// ── Statement handlers ────────────────────────────────────────────────────
private FlowNode TraceIf(IfStatementSyntax ifStmt, SemanticModel model, int depth)
{
var conditionText = ifStmt.Condition.ToString().Trim();
var fakeKey = ExtractFakeInputKey(ifStmt.Condition);
var trueChildren = TraceNode(ifStmt.Statement, model, depth + 1);
var falseChildren = ifStmt.Else is not null
? TraceNode(ifStmt.Else.Statement, model, depth + 1)
: [];
var branches = new List<FlowNode>();
if (trueChildren.Count > 0)
branches.Add(new FlowNode(NewId(), "branch_true", "true", trueChildren));
if (falseChildren.Count > 0)
branches.Add(new FlowNode(NewId(), "branch_false", "false", falseChildren));
return new FlowNode(
Id: NewId(),
Type: "condition",
Label: $"if ({Truncate(conditionText, 60)})",
Children: branches,
FakeInputKey: fakeKey,
ResolvedGump: null,
AssetRef: null
);
}
private FlowNode TraceSwitch(SwitchStatementSyntax sw, SemanticModel model, int depth)
{
var conditionText = sw.Expression.ToString().Trim();
var branches = new List<FlowNode>();
foreach (var section in sw.Sections)
{
var label = string.Join(", ", section.Labels.Select(l => l.ToString().Trim()));
var sectionChildren = TraceStatements(section.Statements, model, depth + 1);
if (sectionChildren.Count > 0)
branches.Add(new FlowNode(NewId(), "branch_case", label, sectionChildren));
}
return new FlowNode(
Id: NewId(),
Type: "condition",
Label: $"switch ({Truncate(conditionText, 60)})",
Children: branches,
FakeInputKey: ExtractFakeInputKey(sw.Expression),
ResolvedGump: null,
AssetRef: null
);
}
private FlowNode TraceReturn(ReturnStatementSyntax ret, SemanticModel model, int depth)
{
var label = ret.Expression is not null
? $"return {Truncate(ret.Expression.ToString(), 50)}"
: "return";
return new FlowNode(NewId(), "return", label, []);
}
private List<FlowNode> TraceExpressionStatement(
ExpressionStatementSyntax expr,
SemanticModel model,
int depth)
{
return TraceExpression(expr.Expression, model, depth);
}
private List<FlowNode> TraceLocal(
LocalDeclarationStatementSyntax local,
SemanticModel model,
int depth)
{
// Only interesting if the initializer contains a method call
var nodes = new List<FlowNode>();
foreach (var variable in local.Declaration.Variables)
{
if (variable.Initializer?.Value is InvocationExpressionSyntax invoc)
nodes.AddRange(TraceInvocation(invoc, model, depth, label: variable.Identifier.Text + " = …"));
}
return nodes;
}
private FlowNode TraceForEach(ForEachStatementSyntax forEach, SemanticModel model, int depth)
{
var label = $"foreach ({forEach.Type} {forEach.Identifier} in {Truncate(forEach.Expression.ToString(), 40)})";
var children = TraceNode(forEach.Statement, model, depth + 1);
return new FlowNode(NewId(), "condition", label, children, FakeInputKey: null);
}
private FlowNode TraceFor(ForStatementSyntax forStmt, SemanticModel model, int depth)
{
var label = $"for (…; {Truncate(forStmt.Condition?.ToString() ?? "", 40)}; …)";
var children = TraceNode(forStmt.Statement, model, depth + 1);
return new FlowNode(NewId(), "condition", label, children, FakeInputKey: null);
}
// ── Expression handlers ───────────────────────────────────────────────────
private List<FlowNode> TraceExpression(ExpressionSyntax expr, SemanticModel model, int depth)
{
return expr switch
{
InvocationExpressionSyntax invoc => TraceInvocation(invoc, model, depth),
AssignmentExpressionSyntax assign => TraceAssignment(assign, model, depth),
ConditionalExpressionSyntax ternary => TraceExpressionList([ternary.WhenTrue, ternary.WhenFalse], model, depth),
AwaitExpressionSyntax awaitExpr => TraceExpression(awaitExpr.Expression, model, depth),
_ => [],
};
}
private List<FlowNode> TraceExpressionList(IEnumerable<ExpressionSyntax> exprs, SemanticModel model, int depth)
{
return exprs.SelectMany(e => TraceExpression(e, model, depth)).ToList();
}
private List<FlowNode> TraceAssignment(AssignmentExpressionSyntax assign, SemanticModel model, int depth)
{
return TraceExpression(assign.Right, model, depth);
}
private List<FlowNode> TraceInvocation(
InvocationExpressionSyntax invoc,
SemanticModel model,
int depth,
string? label = null)
{
var invocStr = invoc.ToString();
// ── SendGump check ────────────────────────────────────────────────────
if (IsSendGump(invocStr))
{
var gumpClass = ExtractGumpClass(invoc);
return [new FlowNode(
Id: NewId(),
Type: "gump_send",
Label: label ?? invocStr.Truncate(80),
Children: [],
FakeInputKey: null,
ResolvedGump: gumpClass,
AssetRef: null
)];
}
// ── Property / member access (not a real call chain node) ────────────
if (IsPropertyAccess(invoc))
{
return [new FlowNode(NewId(), "property_access", label ?? Truncate(invocStr, 80), [])];
}
// ── Try to resolve and recurse into the called method ─────────────────
if (depth < MaxDepth)
{
var calledMethod = TryResolveMethod(invoc, model);
if (calledMethod is not null)
{
var resolvedLabel = label ?? BuildCallLabel(invoc);
var children = TraceMethodBody(calledMethod.Syntax, calledMethod.Model, depth + 1);
if (children.Count > 0)
{
return [new FlowNode(NewId(), "method_call", resolvedLabel, children)];
}
}
}
// ── Leaf call node ────────────────────────────────────────────────────
var callLabel = label ?? BuildCallLabel(invoc);
return [new FlowNode(NewId(), "method_call", callLabel, [])];
}
// ── Helpers ───────────────────────────────────────────────────────────────
private Dictionary<string, (ClassDeclarationSyntax, SemanticModel)> BuildClassMap()
{
var map = new Dictionary<string, (ClassDeclarationSyntax, SemanticModel)>(
StringComparer.OrdinalIgnoreCase);
foreach (var tree in _compilation.SyntaxTrees)
{
var model = _compilation.GetSemanticModel(tree);
foreach (var cls in tree.GetRoot().DescendantNodes().OfType<ClassDeclarationSyntax>())
{
var symbol = model.GetDeclaredSymbol(cls);
if (symbol is not null && !map.ContainsKey(symbol.Name))
map[symbol.Name] = (cls, model);
}
}
return map;
}
private record ResolvedMethod(MethodDeclarationSyntax Syntax, SemanticModel Model);
private ResolvedMethod? TryResolveMethod(InvocationExpressionSyntax invoc, SemanticModel model)
{
try
{
var symbolInfo = model.GetSymbolInfo(invoc);
var symbol = symbolInfo.Symbol as IMethodSymbol;
if (symbol is null) return null;
var containingClass = symbol.ContainingType?.Name;
if (containingClass is null) return null;
if (!_classMap.TryGetValue(containingClass, out var entry)) return null;
var methodSyntax = entry.Syntax.Members
.OfType<MethodDeclarationSyntax>()
.FirstOrDefault(m => m.Identifier.Text == symbol.Name);
return methodSyntax is not null ? new ResolvedMethod(methodSyntax, entry.Model) : null;
}
catch
{
return null;
}
}
private static bool IsSendGump(string invocStr) =>
invocStr.Contains("SendGump", StringComparison.OrdinalIgnoreCase);
private static bool IsPropertyAccess(InvocationExpressionSyntax invoc)
{
var memberAccess = invoc.Expression as MemberAccessExpressionSyntax;
return memberAccess?.Name.Identifier.Text is "ToString" or "GetType";
}
private static string? ExtractGumpClass(InvocationExpressionSyntax invoc)
{
var newExpr = invoc.DescendantNodes().OfType<ObjectCreationExpressionSyntax>().FirstOrDefault();
return newExpr?.Type.ToString();
}
private static string? ExtractFakeInputKey(ExpressionSyntax expr)
{
// If the condition references a simple identifier or member access, use it as the key
return expr switch
{
IdentifierNameSyntax id => id.Identifier.Text,
MemberAccessExpressionSyntax ma => ma.Name.Identifier.Text,
BinaryExpressionSyntax bin => ExtractFakeInputKey(bin.Left),
PrefixUnaryExpressionSyntax pre => ExtractFakeInputKey(pre.Operand),
_ => null,
};
}
private static string BuildCallLabel(InvocationExpressionSyntax invoc)
{
var name = invoc.Expression switch
{
MemberAccessExpressionSyntax ma => $"{ma.Expression}.{ma.Name}",
IdentifierNameSyntax id => id.Identifier.Text,
_ => invoc.Expression.ToString(),
};
return Truncate(name, 80);
}
private static string Truncate(string s, int max) =>
s.Length <= max ? s : s[..max] + "…";
private static int _counter;
private static string NewId() => $"n{System.Threading.Interlocked.Increment(ref _counter)}";
}
internal static class StringExtensions
{
public static string Truncate(this string s, int max) =>
s.Length <= max ? s : s[..max] + "…";
}

View File

@@ -0,0 +1,151 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using ArtificersScrollwork.Sidecar.Models;
namespace ArtificersScrollwork.Sidecar.Parsing;
/// <summary>
/// Walks a syntax tree and extracts ClassInfo for every class declaration.
/// </summary>
public class ClassWalker : CSharpSyntaxWalker
{
private readonly SemanticModel _model;
private readonly string _filePath;
public List<ClassInfo> Classes { get; } = new();
public ClassWalker(SemanticModel model, string filePath)
{
_model = model;
_filePath = filePath;
}
public override void VisitClassDeclaration(ClassDeclarationSyntax node)
{
var symbol = _model.GetDeclaredSymbol(node);
if (symbol is null)
{
base.VisitClassDeclaration(node);
return;
}
var baseClass = symbol.BaseType?.Name;
var interfaces = symbol.Interfaces.Select(i => i.Name).ToList();
var ns = symbol.ContainingNamespace?.ToDisplayString() ?? "";
var attrs = symbol.GetAttributes().Select(a => a.AttributeClass?.Name ?? "").ToList();
// Heuristics for class category
bool isGump = IsGump(symbol);
bool isMobile = IsMobile(symbol);
bool isItem = IsItem(symbol);
// Methods
var methods = node.Members
.OfType<MethodDeclarationSyntax>()
.Select(m => ParseMethod(m, symbol))
.ToList();
// Properties
var properties = node.Members
.OfType<PropertyDeclarationSyntax>()
.Select(p => new Models.PropertyInfo(
p.Identifier.Text,
p.Type.ToString(),
p.AccessorList?.Accessors.Any(a => a.IsKind(SyntaxKind.GetAccessorDeclaration)) ?? false,
p.AccessorList?.Accessors.Any(a => a.IsKind(SyntaxKind.SetAccessorDeclaration)) ?? false
))
.ToList();
Classes.Add(new ClassInfo(
symbol.Name,
ns,
_filePath,
baseClass,
interfaces,
properties,
methods,
attrs,
isGump,
isMobile,
isItem
));
base.VisitClassDeclaration(node);
}
private Models.MethodInfo ParseMethod(MethodDeclarationSyntax m, INamedTypeSymbol classSymbol)
{
var isOverride = m.Modifiers.Any(mod => mod.IsKind(SyntaxKind.OverrideKeyword));
var isVirtual = m.Modifiers.Any(mod => mod.IsKind(SyntaxKind.VirtualKeyword));
var parameters = m.ParameterList.Parameters
.Select(p => new ParameterInfo(p.Identifier.Text, p.Type?.ToString() ?? ""))
.ToList();
// Check if this method sends a Gump
bool callsGump = false;
string? gumpClass = null;
if (m.Body is not null)
{
var sendGumps = m.Body.DescendantNodes()
.OfType<InvocationExpressionSyntax>()
.Where(inv => inv.ToString().Contains("SendGump"))
.FirstOrDefault();
if (sendGumps is not null)
{
callsGump = true;
// Try to extract the Gump class name from `new XxxGump(...)`
var newExpr = sendGumps.DescendantNodes()
.OfType<ObjectCreationExpressionSyntax>()
.FirstOrDefault();
gumpClass = newExpr?.Type.ToString();
}
}
return new Models.MethodInfo(
m.Identifier.Text,
m.ReturnType.ToString(),
parameters,
isOverride,
isVirtual,
callsGump,
gumpClass
);
}
private static bool IsGump(INamedTypeSymbol symbol)
{
var current = symbol.BaseType;
while (current is not null)
{
if (current.Name is "Gump" or "GumpPlus") return true;
current = current.BaseType;
}
return false;
}
private static bool IsMobile(INamedTypeSymbol symbol)
{
var current = symbol.BaseType;
while (current is not null)
{
if (current.Name is "Mobile" or "BaseCreature" or "BaseVendor") return true;
current = current.BaseType;
}
return false;
}
private static bool IsItem(INamedTypeSymbol symbol)
{
var current = symbol.BaseType;
while (current is not null)
{
if (current.Name is "Item" or "BaseWeapon" or "BaseArmor" or "BaseClothing") return true;
current = current.BaseType;
}
return false;
}
}

View File

@@ -0,0 +1,86 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using ArtificersScrollwork.Sidecar.Models;
namespace ArtificersScrollwork.Sidecar.Parsing;
/// <summary>
/// Walks a ServUO Scripts directory, parses all .cs files with Roslyn,
/// and builds a searchable in-memory index of classes and methods.
/// </summary>
public class ScriptIndexer
{
private readonly Dictionary<string, ClassInfo> _classes =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>The Roslyn compilation built during indexing. Used by CallChainTracer.</summary>
public CSharpCompilation? Compilation { get; private set; }
public ScriptIndex IndexDirectory(string path)
{
var files = Directory
.EnumerateFiles(path, "*.cs", SearchOption.AllDirectories)
.ToList();
var syntaxTrees = files
.Select(f =>
{
try { return CSharpSyntaxTree.ParseText(File.ReadAllText(f), path: f); }
catch { return null; }
})
.Where(t => t is not null)
.Select(t => t!)
.ToList();
Compilation = CSharpCompilation.Create(
"ServUOAnalysis",
syntaxTrees,
references: null,
options: new CSharpCompilationOptions(
OutputKind.DynamicallyLinkedLibrary,
reportSuppressedDiagnostics: false
)
);
_classes.Clear();
foreach (var tree in syntaxTrees)
{
SemanticModel model;
try { model = Compilation.GetSemanticModel(tree); }
catch { continue; }
var walker = new ClassWalker(model, tree.FilePath);
try { walker.Visit(tree.GetRoot()); }
catch { continue; }
foreach (var cls in walker.Classes)
_classes[cls.Name] = cls;
}
var summaries = _classes.Values
.Select(c => new ClassSummary(
c.Name, c.Namespace, c.FilePath, c.BaseClass,
c.IsGump, c.IsMobile, c.IsItem))
.OrderBy(c => c.Namespace)
.ThenBy(c => c.Name)
.ToList();
return new ScriptIndex(_classes.Count, files.Count, summaries);
}
public ClassInfo? GetClass(string name) =>
_classes.TryGetValue(name, out var cls) ? cls : null;
public List<ClassSummary> Search(string query) =>
_classes.Values
.Where(c =>
c.Name.Contains(query, StringComparison.OrdinalIgnoreCase) ||
c.Namespace.Contains(query, StringComparison.OrdinalIgnoreCase) ||
c.Methods.Any(m => m.Name.Contains(query, StringComparison.OrdinalIgnoreCase)))
.Select(c => new ClassSummary(
c.Name, c.Namespace, c.FilePath, c.BaseClass,
c.IsGump, c.IsMobile, c.IsItem))
.Take(100)
.ToList();
}

140
sidecar/Program.cs Normal file
View File

@@ -0,0 +1,140 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using ArtificersScrollwork.Sidecar.Parsing;
using ArtificersScrollwork.Sidecar.Gumps;
/// <summary>
/// Entry point for the ASW C# sidecar.
/// Reads newline-delimited JSON requests from stdin, writes responses to stdout.
///
/// Request: { "id": "uuid", "command": "cmd_name", "args": { ... } }
/// Response: { "id": "uuid", "ok": true, "data": { ... } }
/// | { "id": "uuid", "ok": false, "error": "message" }
/// </summary>
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false,
};
ScriptIndexer? indexer = null;
CallChainTracer? tracer = null;
while (true)
{
var line = Console.ReadLine();
if (line is null) break;
JsonObject? request;
try { request = JsonSerializer.Deserialize<JsonObject>(line, options); }
catch { continue; }
if (request is null) continue;
var id = request["id"]?.GetValue<string>() ?? "";
var command = request["command"]?.GetValue<string>() ?? "";
var reqArgs = request["args"] as JsonObject ?? new JsonObject();
try
{
JsonNode? data = command switch
{
"index_scripts" => HandleIndexScripts(reqArgs),
"get_class" => HandleGetClass(reqArgs),
"trace_method" => HandleTraceMethod(reqArgs),
"extract_gump" => HandleExtractGump(reqArgs),
"search" => HandleSearch(reqArgs),
_ => throw new InvalidOperationException($"Unknown command: {command}"),
};
WriteResponse(id, true, data, null, options);
}
catch (Exception ex)
{
WriteResponse(id, false, null, ex.Message, options);
}
}
// ── Command handlers ──────────────────────────────────────────────────────────
JsonNode HandleIndexScripts(JsonObject args)
{
var path = args["path"]?.GetValue<string>()
?? throw new ArgumentException("Missing 'path' argument");
indexer = new ScriptIndexer();
var index = indexer.IndexDirectory(path);
// Build a compilation for the tracer
tracer = new CallChainTracer(indexer.Compilation
?? throw new InvalidOperationException("Compilation not available after indexing"));
return JsonSerializer.SerializeToNode(index, options)!;
}
JsonNode HandleGetClass(JsonObject args)
{
EnsureIndexer();
var className = args["class"]?.GetValue<string>()
?? throw new ArgumentException("Missing 'class' argument");
var info = indexer!.GetClass(className)
?? throw new KeyNotFoundException($"Class '{className}' not found in index");
return JsonSerializer.SerializeToNode(info, options)!;
}
JsonNode HandleTraceMethod(JsonObject args)
{
EnsureIndexer();
if (tracer is null)
throw new InvalidOperationException("Tracer not initialized — send index_scripts first");
var className = args["class"]?.GetValue<string>()
?? throw new ArgumentException("Missing 'class' argument");
var methodName = args["method"]?.GetValue<string>()
?? throw new ArgumentException("Missing 'method' argument");
var flowNode = tracer.TraceMethod(className, methodName);
return JsonSerializer.SerializeToNode(flowNode, options)!;
}
JsonNode HandleExtractGump(JsonObject args)
{
EnsureIndexer();
throw new NotImplementedException("extract_gump not yet implemented (Phase 4)");
}
JsonNode HandleSearch(JsonObject args)
{
EnsureIndexer();
var query = args["query"]?.GetValue<string>()
?? throw new ArgumentException("Missing 'query' argument");
var results = indexer!.Search(query);
return JsonSerializer.SerializeToNode(results, options)!;
}
void EnsureIndexer()
{
if (indexer is null)
throw new InvalidOperationException("Scripts not indexed — send index_scripts first");
}
static void WriteResponse(string id, bool ok, JsonNode? data, string? error, JsonSerializerOptions opts)
{
var response = new JsonObject
{
["id"] = id,
["ok"] = ok,
};
if (ok && data is not null)
response["data"] = data;
else if (!ok)
response["error"] = error ?? "Unknown error";
Console.WriteLine(JsonSerializer.Serialize(response, opts));
Console.Out.Flush();
}

30
src-tauri/Cargo.toml Normal file
View 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
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View 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"
]
}

File diff suppressed because one or more lines are too long

View 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"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 B

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

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

46
src-tauri/tauri.conf.json Normal file
View 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"
]
}
}

37
src/App.tsx Normal file
View File

@@ -0,0 +1,37 @@
import { useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { useAppStore } from './store/appStore';
import AppShell from './components/layout/AppShell';
import ConfigScreen from './components/config/ConfigScreen';
export default function App() {
const { isConfigured, setIsConfigured, setUoRoot, setServuoScripts, setCenterMode } =
useAppStore();
useEffect(() => {
async function loadConfig() {
try {
const uo = await invoke<string | null>('get_config', { key: 'uo_root' });
const scripts = await invoke<string | null>('get_config', { key: 'seruo_scripts' });
if (uo && scripts) {
setUoRoot(uo);
setServuoScripts(scripts);
setIsConfigured(true);
} else {
setCenterMode('config');
}
} catch (e) {
console.error('Failed to load config:', e);
setCenterMode('config');
}
}
loadConfig();
}, []);
if (!isConfigured) {
return <ConfigScreen />;
}
return <AppShell />;
}

View File

@@ -0,0 +1,63 @@
.preview {
display: flex;
flex-direction: column;
height: 100%;
}
.header {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg-panel);
}
.name {
font-size: 16px;
color: var(--accent-gold);
letter-spacing: 0.04em;
}
.id {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--text-muted);
}
.canvas {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-elevated);
padding: 24px;
}
.artCanvas {
image-rendering: pixelated;
max-width: 100%;
max-height: 100%;
}
.loading,
.error {
font-size: 13px;
color: var(--text-secondary);
font-style: italic;
}
.error {
color: #ff8080;
}
.noArt {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: var(--text-muted);
font-size: 12px;
letter-spacing: 0.05em;
text-transform: uppercase;
}

View File

@@ -0,0 +1,90 @@
import { useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { useAppStore } from '../../store/appStore';
import type { TileInfo } from '../../types/assets';
import styles from './ItemPreview.module.css';
interface ArtImageResult {
width: number;
height: number;
pixels: number[];
}
interface Props {
tile: TileInfo;
}
export default function ItemPreview({ tile }: Props) {
const { uoRoot } = useAppStore();
const canvasRef = useRef<HTMLCanvasElement>(null);
const [loading, setLoading] = useState(false);
// null = ok, 'none' = no art (show placeholder), string = real error
const [artState, setArtState] = useState<null | 'none' | string>(null);
useEffect(() => {
if (!uoRoot) return;
async function loadArt() {
setLoading(true);
setArtState(null);
try {
const result = await invoke<ArtImageResult>('get_tile_art', {
itemId: tile.id,
uoRoot,
});
const canvas = canvasRef.current;
if (!canvas) return;
canvas.width = result.width;
canvas.height = result.height;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const imageData = ctx.createImageData(result.width, result.height);
imageData.data.set(result.pixels);
ctx.putImageData(imageData, 0, 0);
} catch {
// Any failure (no art data, unsupported format, buffer error, etc.)
// → show placeholder silently. The art source may just not have this item.
setArtState('none');
} finally {
setLoading(false);
}
}
loadArt();
}, [tile.id, uoRoot]);
return (
<div className={styles.preview}>
<div className={styles.header}>
<span className={`${styles.name} font-cinzel`}>{tile.name || '(unnamed)'}</span>
<span className={styles.id}>#{tile.id}</span>
</div>
<div className={styles.canvas}>
{loading && <div className={styles.loading}>Loading art</div>}
{artState === 'none' && (
<div className={styles.noArt}>
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<rect x="4" y="4" width="40" height="40" rx="4"
stroke="var(--border-accent)" strokeWidth="1.5" strokeDasharray="4 3" />
<path d="M16 32 L24 16 L32 32 Z"
stroke="var(--text-muted)" strokeWidth="1.5" fill="none" />
</svg>
<span>No art data</span>
</div>
)}
{artState && artState !== 'none' && (
<div className={styles.error}>{artState}</div>
)}
<canvas
ref={canvasRef}
className={styles.artCanvas}
style={{ display: loading || artState ? 'none' : 'block' }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
.list {
display: flex;
flex-direction: column;
}
.row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
background: none;
border: none;
border-bottom: 1px solid var(--border);
text-align: left;
cursor: pointer;
transition: background 0.1s;
}
.row:hover {
background: var(--bg-hover);
}
.id {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--text-muted);
min-width: 48px;
}
.name {
font-size: 13px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.loadMore {
padding: 10px;
background: none;
border: none;
border-top: 1px solid var(--border);
color: var(--accent-gold);
font-size: 13px;
cursor: pointer;
}
.loadMore:hover {
background: var(--bg-hover);
}
.empty {
padding: 20px;
color: var(--text-muted);
font-size: 13px;
font-style: italic;
line-height: 1.6;
}

View File

@@ -0,0 +1,107 @@
import { useEffect, useState, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { useAppStore } from '../../store/appStore';
import type { TileInfo } from '../../types/assets';
import styles from './StaticBrowser.module.css';
interface TileRow {
id: number;
name: string;
flags: number;
weight: number;
quality: number;
height: number;
hue: number;
anim_id: number;
}
const PAGE_SIZE = 100;
interface Props {
search: string;
}
export default function StaticBrowser({ search }: Props) {
const { setSelectedTile, setCenterMode } = useAppStore();
const [tiles, setTiles] = useState<TileRow[]>([]);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const loadTiles = useCallback(async (newOffset: number, q: string) => {
setLoading(true);
try {
const rows = await invoke<TileRow[]>('list_static_tiles', {
offset: newOffset,
limit: PAGE_SIZE,
search: q || null,
});
if (newOffset === 0) {
setTiles(rows);
} else {
setTiles((prev) => [...prev, ...rows]);
}
setHasMore(rows.length === PAGE_SIZE);
} catch {
// Index not yet built — show empty state
setTiles([]);
setHasMore(false);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
setOffset(0);
loadTiles(0, search);
}, [search, loadTiles]);
function selectTile(row: TileRow) {
const tile: TileInfo = {
id: row.id,
name: row.name,
flags: row.flags,
weight: row.weight,
quality: row.quality,
height: row.height,
hue: row.hue,
animId: row.anim_id,
};
setSelectedTile(tile);
setCenterMode('asset_static');
}
if (!loading && tiles.length === 0) {
return (
<div className={styles.empty}>
No tiles indexed yet.
<br />
Click [Index] in the toolbar after setting up your paths.
</div>
);
}
return (
<div className={styles.list}>
{tiles.map((t) => (
<button key={t.id} className={styles.row} onClick={() => selectTile(t)}>
<span className={styles.id}>{t.id}</span>
<span className={styles.name}>{t.name || '(unnamed)'}</span>
</button>
))}
{hasMore && (
<button
className={styles.loadMore}
onClick={() => {
const next = offset + PAGE_SIZE;
setOffset(next);
loadTiles(next, search);
}}
disabled={loading}
>
{loading ? 'Loading…' : 'Load more'}
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,131 @@
.screen {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-base);
padding: 40px;
}
.card {
width: 100%;
max-width: 640px;
background: var(--bg-panel);
border: 1px solid var(--border-accent);
padding: 32px;
display: flex;
flex-direction: column;
gap: 24px;
}
.title {
font-size: 22px;
color: var(--accent-gold);
letter-spacing: 0.06em;
}
.subtitle {
font-size: 14px;
color: var(--text-secondary);
margin-top: -16px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.label {
font-family: 'Cinzel', serif;
font-size: 12px;
color: var(--text-secondary);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.row {
display: flex;
gap: 8px;
}
.pathInput {
flex: 1;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
}
.btn {
padding: 6px 14px;
background: var(--bg-elevated);
border: 1px solid var(--border);
color: var(--text-primary);
font-size: 13px;
transition: border-color 0.15s, color 0.15s;
white-space: nowrap;
}
.btn:hover {
border-color: var(--border-accent);
color: var(--accent-gold);
}
.btnPrimary {
background: var(--accent-gold);
border-color: var(--accent-gold);
color: var(--bg-base);
font-family: 'Cinzel', serif;
font-weight: 600;
}
.btnPrimary:hover {
background: var(--accent-gold-bright);
border-color: var(--accent-gold-bright);
color: var(--bg-base);
}
.btnPrimary:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btnSecondary {
color: var(--text-secondary);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.validationList {
list-style: none;
display: flex;
flex-direction: column;
gap: 3px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
}
.validationItem {
display: flex;
gap: 8px;
align-items: baseline;
}
.validationFile {
color: var(--text-secondary);
}
.validationMsg {
color: var(--text-muted);
}
.error {
background: var(--accent-red);
border: 1px solid #b04040;
padding: 10px;
font-size: 13px;
color: #ffd0d0;
}

View File

@@ -0,0 +1,132 @@
import { useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { open } from '@tauri-apps/plugin-dialog';
import { useAppStore } from '../../store/appStore';
import styles from './ConfigScreen.module.css';
interface ValidationResult {
file: string;
status: 'ok' | 'warn' | 'error';
message?: string;
}
interface Props {
onDone?: () => void;
}
export default function ConfigScreen({ onDone }: Props) {
const { uoRoot, servuoScripts, setUoRoot, setServuoScripts, setIsConfigured } = useAppStore();
const [uoResults, setUoResults] = useState<ValidationResult[]>([]);
const [scriptResults, setScriptResults] = useState<ValidationResult[]>([]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function pickUoRoot() {
const dir = await open({ directory: true, multiple: false, title: 'Select UO Client Folder' });
if (!dir || Array.isArray(dir)) return;
setUoRoot(dir);
const results = await invoke<ValidationResult[]>('validate_uo_root', { path: dir });
setUoResults(results);
}
async function pickScriptsPath() {
const dir = await open({ directory: true, multiple: false, title: 'Select ServUO Scripts Folder' });
if (!dir || Array.isArray(dir)) return;
setServuoScripts(dir);
const results = await invoke<ValidationResult[]>('validate_scripts_path', { path: dir });
setScriptResults(results);
}
async function handleSave() {
setSaving(true);
setError(null);
try {
await invoke('set_config', { key: 'uo_root', value: uoRoot });
await invoke('set_config', { key: 'seruo_scripts', value: servuoScripts });
setIsConfigured(true);
onDone?.();
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
}
const canSave =
uoRoot &&
servuoScripts &&
!uoResults.some((r) => r.status === 'error') &&
!scriptResults.some((r) => r.status === 'error');
return (
<div className={styles.screen}>
<div className={styles.card}>
<h1 className={`${styles.title} font-cinzel`}>Configuration</h1>
<p className={styles.subtitle}>Set paths to your UO client and ServUO Scripts folder.</p>
<div className={styles.field}>
<label className={styles.label}>UO Client Root</label>
<div className={styles.row}>
<input
type="text"
readOnly
value={uoRoot}
placeholder="Not set"
className={styles.pathInput}
/>
<button className={styles.btn} onClick={pickUoRoot}>Browse</button>
</div>
{uoResults.length > 0 && <ValidationList results={uoResults} />}
</div>
<div className={styles.field}>
<label className={styles.label}>ServUO Scripts Path</label>
<div className={styles.row}>
<input
type="text"
readOnly
value={servuoScripts}
placeholder="Not set"
className={styles.pathInput}
/>
<button className={styles.btn} onClick={pickScriptsPath}>Browse</button>
</div>
{scriptResults.length > 0 && <ValidationList results={scriptResults} />}
</div>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.actions}>
{onDone && (
<button className={`${styles.btn} ${styles.btnSecondary}`} onClick={onDone}>
Cancel
</button>
)}
<button
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={handleSave}
disabled={!canSave || saving}
>
{saving ? 'Saving…' : 'Save Configuration'}
</button>
</div>
</div>
</div>
);
}
function ValidationList({ results }: { results: ValidationResult[] }) {
return (
<ul className={styles.validationList}>
{results.map((r, i) => (
<li key={i} className={styles.validationItem} data-status={r.status}>
<span className={styles.validationIcon}>
{r.status === 'ok' ? '✅' : r.status === 'warn' ? '⚠️' : '❌'}
</span>
<span className={styles.validationFile}>{r.file}</span>
{r.message && <span className={styles.validationMsg}>{r.message}</span>}
</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,38 @@
.panel {
padding: 12px;
border-bottom: 1px solid var(--border);
}
.title {
font-size: 10px;
color: var(--accent-gold);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 6px;
}
.hint {
font-size: 11px;
color: var(--text-muted);
font-style: italic;
margin-bottom: 10px;
}
.row {
display: flex;
flex-direction: column;
gap: 3px;
margin-bottom: 8px;
}
.label {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--text-secondary);
}
.input {
width: 100%;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
}

View File

@@ -0,0 +1,43 @@
import { useMemo } from 'react';
import { useAppStore } from '../../store/appStore';
import type { FlowNode } from '../../types/scripts';
import styles from './FakeDataInputs.module.css';
function collectFakeKeys(node: FlowNode, keys: Set<string>) {
if (node.fakeInputKey) keys.add(node.fakeInputKey);
for (const child of node.children) collectFakeKeys(child, keys);
}
export default function FakeDataInputs() {
const { flowRoot, fakeData, setFakeData } = useAppStore();
const keys = useMemo(() => {
if (!flowRoot) return [];
const set = new Set<string>();
collectFakeKeys(flowRoot, set);
return Array.from(set).sort();
}, [flowRoot]);
if (keys.length === 0) return null;
return (
<div className={styles.panel}>
<div className={`${styles.title} font-cinzel`}>Fake Data</div>
<div className={styles.hint}>
Set values to highlight condition branches
</div>
{keys.map((key) => (
<div key={key} className={styles.row}>
<label className={styles.label}>{key}</label>
<input
type="text"
className={styles.input}
value={fakeData[key] ?? ''}
placeholder="value…"
onChange={(e) => setFakeData(key, e.target.value)}
/>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,99 @@
.wrapper {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
border-bottom: 1px solid var(--border);
background: var(--bg-panel);
flex-shrink: 0;
}
.methodLabel {
flex: 1;
font-size: 13px;
color: var(--accent-gold);
letter-spacing: 0.04em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolBtn {
padding: 4px 10px;
background: none;
border: 1px solid var(--border);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.toolBtn:hover {
border-color: var(--border-accent);
color: var(--accent-gold);
}
.canvas {
flex: 1;
overflow: hidden;
background: var(--bg-base);
cursor: grab;
position: relative;
}
.canvas:active {
cursor: grabbing;
}
.empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 14px;
font-style: italic;
padding: 40px;
text-align: center;
}
.errorWrapper {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 40px;
}
.errorTitle {
font-family: 'Cinzel', serif;
color: #cc6666;
font-size: 16px;
}
.errorMsg {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--text-secondary);
max-width: 500px;
text-align: center;
}
.retryBtn {
padding: 6px 16px;
background: none;
border: 1px solid var(--border-accent);
color: var(--accent-gold);
font-family: 'Cinzel', serif;
font-size: 13px;
cursor: pointer;
}

View File

@@ -0,0 +1,284 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { useAppStore } from '../../store/appStore';
import type { FlowNode } from '../../types/scripts';
import styles from './FlowViewer.module.css';
// ── Layout constants ──────────────────────────────────────────────────────────
const NODE_W = 220;
const NODE_H = 44;
const H_GAP = 30; // horizontal gap between sibling subtrees
const V_GAP = 60; // vertical gap between levels
// ── Layout engine ─────────────────────────────────────────────────────────────
interface LayoutNode {
node: FlowNode;
x: number;
y: number;
width: number; // subtree width
}
function layoutTree(
node: FlowNode,
depth: number,
xOffset: number
): { positioned: LayoutNode[]; subtreeWidth: number } {
if (node.children.length === 0) {
return {
positioned: [{ node, x: xOffset, y: depth * (NODE_H + V_GAP), width: NODE_W }],
subtreeWidth: NODE_W,
};
}
let childX = xOffset;
const allPositioned: LayoutNode[] = [];
let totalWidth = 0;
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i];
const result = layoutTree(child, depth + 1, childX);
allPositioned.push(...result.positioned);
childX += result.subtreeWidth + (i < node.children.length - 1 ? H_GAP : 0);
totalWidth += result.subtreeWidth + (i < node.children.length - 1 ? H_GAP : 0);
}
// Center this node over its children
const subtreeWidth = Math.max(totalWidth, NODE_W);
const nodeX = xOffset + (subtreeWidth - NODE_W) / 2;
allPositioned.push({
node,
x: nodeX,
y: depth * (NODE_H + V_GAP),
width: subtreeWidth,
});
return { positioned: allPositioned, subtreeWidth };
}
function buildEdges(positioned: LayoutNode[]): Array<{ x1: number; y1: number; x2: number; y2: number }> {
const posMap = new Map<string, LayoutNode>();
for (const p of positioned) posMap.set(p.node.id, p);
const edges: Array<{ x1: number; y1: number; x2: number; y2: number }> = [];
function walk(node: FlowNode) {
const parent = posMap.get(node.id);
if (!parent) return;
for (const child of node.children) {
const c = posMap.get(child.id);
if (c) {
edges.push({
x1: parent.x + NODE_W / 2,
y1: parent.y + NODE_H,
x2: c.x + NODE_W / 2,
y2: c.y,
});
}
walk(child);
}
}
for (const p of positioned) walk(p.node);
return edges;
}
// ── Node colors by type ───────────────────────────────────────────────────────
function nodeStyle(type: FlowNode['type']): { fill: string; stroke: string; text: string } {
switch (type) {
case 'gump_send':
return { fill: '#1a1a2e', stroke: '#8888ff', text: '#aaaaff' };
case 'condition':
return { fill: '#1e1a10', stroke: '#c8a84b', text: '#e8c86b' };
case 'branch_true':
return { fill: '#0d1a0d', stroke: '#3a6b3a', text: '#88cc88' };
case 'branch_false':
return { fill: '#1a0d0d', stroke: '#6b3a3a', text: '#cc8888' };
case 'branch_case':
return { fill: '#0d1520', stroke: '#3a5a7a', text: '#8aaabb' };
case 'return':
return { fill: '#1a1010', stroke: '#8b3a3a', text: '#cc6666' };
case 'property_access':
return { fill: '#141418', stroke: '#4a4060', text: '#8a7ab0' };
default:
return { fill: '#161410', stroke: '#3a3020', text: '#d4c49a' };
}
}
// ── Component ─────────────────────────────────────────────────────────────────
export default function FlowViewer() {
const { selectedMethod, selectedClass, flowRoot, setFlowRoot, fakeData } = useAppStore();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const svgRef = useRef<SVGSVGElement>(null);
const [scale, setScale] = useState(1);
const [pan, setPan] = useState({ x: 20, y: 20 });
const isPanning = useRef(false);
const panStart = useRef({ x: 0, y: 0 });
const traceMethod = useCallback(async () => {
if (!selectedClass || !selectedMethod) return;
setLoading(true);
setError(null);
try {
const node = await invoke<FlowNode>('trace_method', {
className: selectedClass.name,
methodName: selectedMethod.name,
});
setFlowRoot(node);
} catch (e) {
setError(String(e));
setFlowRoot(null);
} finally {
setLoading(false);
}
}, [selectedClass?.name, selectedMethod?.name]);
// Auto-trace when selection changes
useEffect(() => {
if (selectedClass && selectedMethod) {
traceMethod();
}
}, [selectedClass?.name, selectedMethod?.name]);
// SVG pan & zoom
function onWheel(e: React.WheelEvent) {
e.preventDefault();
setScale((s) => Math.min(2, Math.max(0.3, s - e.deltaY * 0.001)));
}
function onMouseDown(e: React.MouseEvent) {
isPanning.current = true;
panStart.current = { x: e.clientX - pan.x, y: e.clientY - pan.y };
}
function onMouseMove(e: React.MouseEvent) {
if (!isPanning.current) return;
setPan({ x: e.clientX - panStart.current.x, y: e.clientY - panStart.current.y });
}
function onMouseUp() {
isPanning.current = false;
}
if (!selectedClass || !selectedMethod) {
return (
<div className={styles.empty}>
Select a method from the left panel to trace its call chain.
</div>
);
}
if (loading) {
return <div className={styles.empty}>Tracing {selectedMethod.name}</div>;
}
if (error) {
return (
<div className={styles.errorWrapper}>
<div className={styles.errorTitle}>Trace failed</div>
<div className={styles.errorMsg}>{error}</div>
<button className={styles.retryBtn} onClick={traceMethod}>Retry</button>
</div>
);
}
if (!flowRoot) return null;
const { positioned } = layoutTree(flowRoot, 0, 0);
const edges = buildEdges(positioned);
const maxX = Math.max(...positioned.map((p) => p.x + NODE_W)) + 40;
const maxY = Math.max(...positioned.map((p) => p.y + NODE_H)) + 40;
return (
<div className={styles.wrapper}>
{/* Toolbar */}
<div className={styles.toolbar}>
<span className={`${styles.methodLabel} font-cinzel`}>
{selectedClass.name}.{selectedMethod.name}
</span>
<button className={styles.toolBtn} onClick={traceMethod}> Retrace</button>
<button className={styles.toolBtn} onClick={() => { setScale(1); setPan({ x: 20, y: 20 }); }}>
Reset View
</button>
</div>
{/* SVG canvas */}
<div
className={styles.canvas}
onWheel={onWheel}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseUp}
>
<svg
ref={svgRef}
width={maxX}
height={maxY}
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`, transformOrigin: '0 0' }}
>
{/* Edges */}
{edges.map((e, i) => (
<path
key={i}
d={`M${e.x1},${e.y1} C${e.x1},${(e.y1 + e.y2) / 2} ${e.x2},${(e.y1 + e.y2) / 2} ${e.x2},${e.y2}`}
stroke="var(--border-accent)"
strokeWidth={1.5}
fill="none"
opacity={0.6}
/>
))}
{/* Nodes */}
{positioned.map(({ node, x, y }) => {
const s = nodeStyle(node.type);
const isFakeActive =
node.fakeInputKey && fakeData[node.fakeInputKey] !== undefined;
return (
<g key={node.id} transform={`translate(${x},${y})`}>
<rect
width={NODE_W}
height={NODE_H}
rx={4}
fill={s.fill}
stroke={isFakeActive ? '#e8c86b' : s.stroke}
strokeWidth={isFakeActive ? 2 : 1}
/>
<foreignObject x={4} y={4} width={NODE_W - 8} height={NODE_H - 8}>
<div
style={{
color: s.text,
fontSize: '11px',
fontFamily: "'JetBrains Mono', monospace",
lineHeight: 1.3,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
padding: '2px 4px',
userSelect: 'none',
}}
title={node.label}
>
<span style={{ opacity: 0.6, fontSize: '9px' }}>{node.type} </span>
{node.label}
{node.resolvedGump && (
<div style={{ fontSize: '9px', opacity: 0.7, marginTop: 2 }}>
{node.resolvedGump}
</div>
)}
</div>
</foreignObject>
</g>
);
})}
</svg>
</div>
</div>
);
}

View File

@@ -0,0 +1,150 @@
.browser {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.subTabs {
display: flex;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.subTab {
flex: 1;
padding: 6px 0;
background: transparent;
border: none;
border-right: 1px solid var(--border);
color: var(--text-secondary);
font-family: 'Cinzel', serif;
font-size: 11px;
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.subTab:last-child {
border-right: none;
}
.subTab:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.subTabActive {
color: var(--accent-gold);
background: var(--bg-elevated);
border-bottom: 2px solid var(--accent-gold);
}
.tagBar {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px 8px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.tag {
background: var(--bg-elevated);
border: 1px solid var(--border);
color: var(--text-secondary);
font-size: 10px;
padding: 2px 7px;
border-radius: 3px;
cursor: pointer;
font-family: 'Cinzel', serif;
white-space: nowrap;
transition: color 0.15s, border-color 0.15s;
}
.tag:hover {
color: var(--text-primary);
border-color: var(--border-accent);
}
.tagActive {
background: var(--bg-hover);
border-color: var(--accent-gold);
color: var(--accent-gold);
}
.count {
font-size: 10px;
color: var(--text-muted);
padding: 4px 10px;
flex-shrink: 0;
font-family: 'Cinzel', serif;
}
.list {
flex: 1;
overflow-y: auto;
}
.item {
display: flex;
flex-direction: column;
gap: 1px;
width: 100%;
padding: 5px 10px;
background: transparent;
border: none;
border-bottom: 1px solid var(--border);
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
/* Art gump items: id + name side by side */
.item > .itemId + .itemName {
flex-direction: row;
}
.item:hover {
background: var(--bg-hover);
}
.itemId {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 10px;
color: var(--accent-gold);
flex-shrink: 0;
min-width: 48px;
}
.itemName {
font-family: 'EB Garamond', serif;
font-size: 13px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.itemPath {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 9px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
.status {
padding: 16px;
color: var(--text-secondary);
font-family: 'Cinzel', serif;
font-size: 12px;
}
.error {
padding: 16px;
color: var(--accent-red);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}

View File

@@ -0,0 +1,180 @@
import { useEffect, useState, useMemo } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { useAppStore, type GumpEntry, type ScriptGumpEntry } from '../../store/appStore';
import styles from './GumpBrowser.module.css';
interface Props {
search: string;
}
type GumpTab = 'art' | 'scripts';
export default function GumpBrowser({ search }: Props) {
const { setSelectedGump, setCenterMode, setSelectedScriptGump } = useAppStore();
const [gumpTab, setGumpTab] = useState<GumpTab>('art');
// Art gumps (from gumps.xml)
const [artEntries, setArtEntries] = useState<GumpEntry[]>([]);
const [artLoading, setArtLoading] = useState(true);
const [artError, setArtError] = useState<string | null>(null);
const [artActiveTag, setArtActiveTag] = useState<string>('all');
// Script gumps (from script_gumps.xml)
const [scriptEntries, setScriptEntries] = useState<ScriptGumpEntry[]>([]);
const [scriptLoading, setScriptLoading] = useState(true);
const [scriptError, setScriptError] = useState<string | null>(null);
const [scriptActiveTag, setScriptActiveTag] = useState<string>('all');
useEffect(() => {
invoke<GumpEntry[]>('list_gumps')
.then((data) => { setArtEntries(data); setArtLoading(false); })
.catch((e) => { setArtError(String(e)); setArtLoading(false); });
invoke<ScriptGumpEntry[]>('list_script_gumps')
.then((data) => { setScriptEntries(data); setScriptLoading(false); })
.catch((e) => { setScriptError(String(e)); setScriptLoading(false); });
}, []);
// ── Art tab ────────────────────────────────────────────────────────────────
const artTopTags = useMemo(() => {
const s = new Set<string>();
for (const e of artEntries) for (const t of e.tags) s.add(t);
return Array.from(s).sort();
}, [artEntries]);
const artFiltered = useMemo(() => {
const q = search.toLowerCase();
return artEntries.filter((e) => {
const nameMatch = !q || e.name.toLowerCase().includes(q) || String(e.id).includes(q)
|| ('0x' + e.id.toString(16)).includes(q.replace('0x', ''));
const tagMatch = artActiveTag === 'all' || e.tags.includes(artActiveTag);
return nameMatch && tagMatch;
});
}, [artEntries, search, artActiveTag]);
function selectArtGump(entry: GumpEntry) {
setSelectedGump(entry);
setCenterMode('gump_image');
}
// ── Script tab ─────────────────────────────────────────────────────────────
const scriptTopTags = useMemo(() => {
const s = new Set<string>();
for (const e of scriptEntries) for (const t of e.tags) s.add(t);
return Array.from(s).sort();
}, [scriptEntries]);
const scriptFiltered = useMemo(() => {
const q = search.toLowerCase();
return scriptEntries.filter((e) => {
const nameMatch = !q || e.class_name.toLowerCase().includes(q)
|| e.file_path.toLowerCase().includes(q);
const tagMatch = scriptActiveTag === 'all' || e.tags.includes(scriptActiveTag);
return nameMatch && tagMatch;
});
}, [scriptEntries, search, scriptActiveTag]);
function selectScriptGump(entry: ScriptGumpEntry) {
setSelectedScriptGump(entry);
setCenterMode('gump_render');
}
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div className={styles.browser}>
{/* Sub-tabs */}
<div className={styles.subTabs}>
<button
className={`${styles.subTab} ${gumpTab === 'art' ? styles.subTabActive : ''}`}
onClick={() => setGumpTab('art')}
>
Art ({artEntries.length})
</button>
<button
className={`${styles.subTab} ${gumpTab === 'scripts' ? styles.subTabActive : ''}`}
onClick={() => setGumpTab('scripts')}
>
Scripts ({scriptEntries.length})
</button>
</div>
{/* Art tab */}
{gumpTab === 'art' && (
<>
{artLoading && <div className={styles.status}>Loading art gumps</div>}
{artError && <div className={styles.error}>{artError}</div>}
{!artLoading && !artError && (
<>
<div className={styles.tagBar}>
<button
className={`${styles.tag} ${artActiveTag === 'all' ? styles.tagActive : ''}`}
onClick={() => setArtActiveTag('all')}
>All</button>
{artTopTags.map((t) => (
<button
key={t}
className={`${styles.tag} ${artActiveTag === t ? styles.tagActive : ''}`}
onClick={() => setArtActiveTag(t)}
>{t}</button>
))}
</div>
<div className={styles.count}>{artFiltered.length} gumps</div>
<div className={styles.list}>
{artFiltered.map((entry) => (
<button
key={entry.id}
className={styles.item}
onClick={() => selectArtGump(entry)}
>
<span className={styles.itemId}>
0x{entry.id.toString(16).toUpperCase().padStart(4, '0')}
</span>
<span className={styles.itemName}>{entry.name}</span>
</button>
))}
</div>
</>
)}
</>
)}
{/* Scripts tab */}
{gumpTab === 'scripts' && (
<>
{scriptLoading && <div className={styles.status}>Loading script gumps</div>}
{scriptError && <div className={styles.error}>{scriptError}</div>}
{!scriptLoading && !scriptError && (
<>
<div className={styles.tagBar}>
<button
className={`${styles.tag} ${scriptActiveTag === 'all' ? styles.tagActive : ''}`}
onClick={() => setScriptActiveTag('all')}
>All</button>
{scriptTopTags.map((t) => (
<button
key={t}
className={`${styles.tag} ${scriptActiveTag === t ? styles.tagActive : ''}`}
onClick={() => setScriptActiveTag(t)}
>{t}</button>
))}
</div>
<div className={styles.count}>{scriptFiltered.length} classes</div>
<div className={styles.list}>
{scriptFiltered.map((entry) => (
<button
key={entry.class_name}
className={styles.item}
onClick={() => selectScriptGump(entry)}
>
<span className={styles.itemName}>{entry.class_name}</span>
<span className={styles.itemPath}>{entry.file_path}</span>
</button>
))}
</div>
</>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,84 @@
.preview {
display: flex;
flex-direction: column;
height: 100%;
padding: 16px;
gap: 12px;
overflow-y: auto;
}
.header {
display: flex;
align-items: baseline;
gap: 12px;
border-bottom: 1px solid var(--border-accent);
padding-bottom: 8px;
}
.name {
font-family: 'Cinzel', serif;
font-size: 16px;
color: var(--text-primary);
}
.id {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 12px;
color: var(--accent-gold);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tag {
font-family: 'Cinzel', serif;
font-size: 10px;
color: var(--text-secondary);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 3px;
padding: 2px 7px;
}
.canvasWrap {
display: flex;
align-items: flex-start;
justify-content: flex-start;
min-height: 80px;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: 4px;
padding: 8px;
}
.loading {
color: var(--text-secondary);
font-family: 'Cinzel', serif;
font-size: 12px;
padding: 8px;
}
.noArt {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 16px;
color: var(--text-muted);
font-size: 12px;
font-family: 'Cinzel', serif;
}
.artCanvas {
image-rendering: pixelated;
max-width: 100%;
}
.meta {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 11px;
color: var(--text-muted);
}

View File

@@ -0,0 +1,103 @@
import { useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { useAppStore } from '../../store/appStore';
import type { GumpEntry } from '../../store/appStore';
import styles from './GumpPreview.module.css';
interface ArtImageResult {
width: number;
height: number;
pixels: number[];
}
interface Props {
gump: GumpEntry;
}
export default function GumpPreview({ gump }: Props) {
const { uoRoot } = useAppStore();
const canvasRef = useRef<HTMLCanvasElement>(null);
const [loading, setLoading] = useState(false);
const [artState, setArtState] = useState<null | 'none' | string>(null);
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
useEffect(() => {
setArtState(null);
setDims(null);
if (!uoRoot) return;
async function loadArt() {
setLoading(true);
try {
const result = await invoke<ArtImageResult>('get_gump_art', {
gumpId: gump.id,
uoRoot,
});
const canvas = canvasRef.current;
if (!canvas) return;
canvas.width = result.width;
canvas.height = result.height;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const imageData = ctx.createImageData(result.width, result.height);
imageData.data.set(result.pixels);
ctx.putImageData(imageData, 0, 0);
setDims({ w: result.width, h: result.height });
} catch {
setArtState('none');
} finally {
setLoading(false);
}
}
loadArt();
}, [gump.id, uoRoot]);
const hexId = `0x${gump.id.toString(16).toUpperCase().padStart(4, '0')}`;
return (
<div className={styles.preview}>
<div className={styles.header}>
<span className={`${styles.name} font-cinzel`}>{gump.name}</span>
<span className={styles.id}>{hexId}</span>
</div>
{gump.tags.length > 0 && (
<div className={styles.tags}>
{gump.tags.map((t) => (
<span key={t} className={styles.tag}>{t}</span>
))}
</div>
)}
<div className={styles.canvasWrap}>
{loading && <div className={styles.loading}>Loading</div>}
{artState === 'none' && (
<div className={styles.noArt}>
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<rect x="4" y="4" width="40" height="40" rx="4"
stroke="var(--border-accent)" strokeWidth="1.5" strokeDasharray="4 3" />
<path d="M16 32 L24 16 L32 32 Z"
stroke="var(--text-muted)" strokeWidth="1.5" fill="none" />
</svg>
<span>No gump art</span>
</div>
)}
<canvas
ref={canvasRef}
className={styles.artCanvas}
style={{ display: loading || artState ? 'none' : 'block' }}
/>
</div>
{dims && (
<div className={styles.meta}>
{dims.w} × {dims.h}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,96 @@
.container {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-base);
}
.header {
padding: 20px 24px 16px;
border-bottom: 1px solid var(--border-accent);
background: var(--bg-panel);
}
.className {
display: block;
font-family: 'Cinzel', serif;
font-size: 20px;
color: var(--accent-gold-bright);
letter-spacing: 0.04em;
margin-bottom: 4px;
}
.friendlyName {
display: block;
font-family: 'EB Garamond', serif;
font-size: 14px;
color: var(--text-secondary);
font-style: italic;
}
.body {
flex: 1;
padding: 20px 24px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 20px;
}
.section {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-family: 'Cinzel', serif;
font-size: 10px;
color: var(--text-muted);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.filepath {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 12px;
color: var(--text-secondary);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 4px;
padding: 8px 12px;
word-break: break-all;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag {
background: var(--bg-elevated);
border: 1px solid var(--border-accent);
color: var(--accent-gold);
font-family: 'Cinzel', serif;
font-size: 10px;
padding: 3px 9px;
border-radius: 3px;
}
.notes {
font-family: 'EB Garamond', serif;
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
}
.empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-family: 'Cinzel', serif;
font-size: 13px;
}

View File

@@ -0,0 +1,52 @@
import { useAppStore } from '../../store/appStore';
import styles from './ScriptGumpDetail.module.css';
export default function ScriptGumpDetail() {
const { selectedScriptGump } = useAppStore();
if (!selectedScriptGump) {
return <div className={styles.empty}>No script gump selected</div>;
}
const { class_name, file_path, tags } = selectedScriptGump;
// Derive a human-readable title from the class name
// e.g. "RunebookGump" → "Runebook Gump"
const friendlyName = class_name.replace(/([A-Z])/g, ' $1').trim();
return (
<div className={styles.container}>
<div className={styles.header}>
<span className={styles.className}>{class_name}</span>
<span className={styles.friendlyName}>{friendlyName}</span>
</div>
<div className={styles.body}>
<div className={styles.section}>
<div className={styles.label}>Source File</div>
<div className={styles.filepath}>{file_path}</div>
</div>
{tags.length > 0 && (
<div className={styles.section}>
<div className={styles.label}>Tags</div>
<div className={styles.tags}>
{tags.map((t) => (
<span key={t} className={styles.tag}>{t}</span>
))}
</div>
</div>
)}
<div className={styles.section}>
<div className={styles.label}>Notes</div>
<div className={styles.notes}>
This is a ServUO C# Gump class found in the Scripts directory.
Select it in the Scripts tab to trace its method call chain or
inspect its class details.
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
.shell {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--bg-base);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 42px;
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.title {
font-size: 15px;
color: var(--accent-gold);
letter-spacing: 0.05em;
}
.headerActions {
display: flex;
gap: 8px;
}
.headerBtn {
background: none;
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 4px 10px;
font-size: 13px;
font-family: 'Cinzel', serif;
transition: border-color 0.15s, color 0.15s;
}
.headerBtn:hover {
border-color: var(--border-accent);
color: var(--accent-gold);
}
.headerBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.panels {
display: flex;
flex: 1;
overflow: hidden;
}

View File

@@ -0,0 +1,83 @@
import { useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { useAppStore } from '../../store/appStore';
import LeftPanel from './LeftPanel';
import CenterPanel from './CenterPanel';
import RightPanel from './RightPanel';
import ConfigScreen from '../config/ConfigScreen';
import styles from './AppShell.module.css';
export default function AppShell() {
const { centerMode, setCenterMode, uoRoot, servuoScripts } = useAppStore();
const [indexingAssets, setIndexingAssets] = useState(false);
const [indexingScripts, setIndexingScripts] = useState(false);
async function handleIndexAssets() {
if (!uoRoot) return;
setIndexingAssets(true);
try {
const count = await invoke<number>('index_assets', { uoRoot });
alert(`Indexed ${count.toLocaleString()} tiles.`);
} catch (e) {
alert(`Asset index failed: ${e}`);
} finally {
setIndexingAssets(false);
}
}
async function handleIndexScripts() {
if (!servuoScripts) {
alert('Set the ServUO Scripts path in Config first.');
return;
}
setIndexingScripts(true);
try {
const count = await invoke<number>('index_scripts', { scriptsPath: servuoScripts });
alert(`Indexed ${count.toLocaleString()} classes.`);
} catch (e) {
alert(`Script index failed: ${e}`);
} finally {
setIndexingScripts(false);
}
}
return (
<div className={styles.shell}>
<header className={styles.header}>
<span className={`${styles.title} font-cinzel`}>Artificer's Scrollwork</span>
<div className={styles.headerActions}>
<button
className={styles.headerBtn}
onClick={handleIndexAssets}
disabled={indexingAssets}
>
{indexingAssets ? 'Indexing' : '[Assets]'}
</button>
<button
className={styles.headerBtn}
onClick={handleIndexScripts}
disabled={indexingScripts}
>
{indexingScripts ? 'Indexing' : '[Scripts]'}
</button>
<button
className={styles.headerBtn}
onClick={() => setCenterMode('config')}
>
[Config]
</button>
</div>
</header>
{centerMode === 'config' ? (
<ConfigScreen onDone={() => setCenterMode('empty')} />
) : (
<div className={styles.panels}>
<LeftPanel />
<CenterPanel />
<RightPanel />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,23 @@
.panel {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-base);
overflow: hidden;
}
.empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 14px;
letter-spacing: 0.05em;
}
.placeholder {
padding: 40px;
color: var(--text-muted);
font-style: italic;
}

View File

@@ -0,0 +1,35 @@
import { useAppStore } from '../../store/appStore';
import ItemPreview from '../asset/ItemPreview';
import ClassDetail from '../script/ClassDetail';
import FlowViewer from '../flow/FlowViewer';
import GumpPreview from '../gump/GumpPreview';
import ScriptGumpDetail from '../gump/ScriptGumpDetail';
import styles from './CenterPanel.module.css';
export default function CenterPanel() {
const { centerMode, selectedTile, selectedGump, selectedScriptGump } = useAppStore();
return (
<div className={styles.panel}>
{centerMode === 'empty' && (
<div className={styles.empty}>
<span className="font-cinzel">Select an item from the left panel</span>
</div>
)}
{centerMode === 'asset_static' && selectedTile && (
<ItemPreview tile={selectedTile} />
)}
{centerMode === 'script_class' && <ClassDetail />}
{centerMode === 'flow_method' && <FlowViewer />}
{centerMode === 'gump_render' && selectedScriptGump && (
<ScriptGumpDetail />
)}
{centerMode === 'gump_render' && !selectedScriptGump && (
<div className={styles.placeholder}>Gump Renderer Phase 4</div>
)}
{centerMode === 'gump_image' && selectedGump && (
<GumpPreview gump={selectedGump} />
)}
</div>
);
}

View File

@@ -0,0 +1,64 @@
.panel {
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--bg-panel);
border-right: 1px solid var(--border);
overflow: hidden;
}
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
}
.tab {
flex: 1;
padding: 8px 4px;
background: none;
border: none;
border-right: 1px solid var(--border);
color: var(--text-secondary);
font-size: 11px;
font-family: 'Cinzel', serif;
letter-spacing: 0.04em;
transition: background 0.15s, color 0.15s;
}
.tab:last-child {
border-right: none;
}
.tab:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.tab.active {
background: var(--bg-elevated);
color: var(--accent-gold);
border-bottom: 2px solid var(--accent-gold);
}
.search {
padding: 8px;
border-bottom: 1px solid var(--border);
}
.searchInput {
width: 100%;
font-size: 13px;
}
.content {
flex: 1;
overflow-y: auto;
}
.placeholder {
padding: 20px;
color: var(--text-muted);
font-size: 13px;
font-style: italic;
}

View File

@@ -0,0 +1,53 @@
import { useState } from 'react';
import { useAppStore, type LeftPanelTab } from '../../store/appStore';
import StaticBrowser from '../asset/StaticBrowser';
import ScriptTree from '../script/ScriptTree';
import GumpBrowser from '../gump/GumpBrowser';
import styles from './LeftPanel.module.css';
const TABS: { id: LeftPanelTab; label: string }[] = [
{ id: 'scripts', label: 'Scripts' },
{ id: 'statics', label: 'Statics' },
{ id: 'mobiles', label: 'Mobiles' },
{ id: 'gumps', label: 'Gumps' },
];
export default function LeftPanel() {
const { activeTab, setActiveTab } = useAppStore();
const [search, setSearch] = useState('');
return (
<div className={styles.panel}>
<div className={styles.tabs}>
{TABS.map((t) => (
<button
key={t.id}
className={`${styles.tab} ${activeTab === t.id ? styles.active : ''}`}
onClick={() => { setActiveTab(t.id); setSearch(''); }}
>
{t.label}
</button>
))}
</div>
<div className={styles.search}>
<input
type="text"
placeholder="Search…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className={styles.searchInput}
/>
</div>
<div className={styles.content}>
{activeTab === 'statics' && <StaticBrowser search={search} />}
{activeTab === 'scripts' && <ScriptTree search={search} />}
{activeTab === 'mobiles' && (
<div className={styles.placeholder}>Mobile browser Phase 1 (coming)</div>
)}
{activeTab === 'gumps' && <GumpBrowser search={search} />}
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
.panel {
width: 320px;
flex-shrink: 0;
background: var(--bg-panel);
border-left: 1px solid var(--border);
overflow-y: auto;
display: flex;
flex-direction: column;
}
.section {
padding: 12px;
border-bottom: 1px solid var(--border);
}
.sectionTitle {
font-size: 11px;
color: var(--accent-gold);
letter-spacing: 0.08em;
margin-bottom: 10px;
text-transform: uppercase;
}
.propList {
display: flex;
flex-direction: column;
gap: 4px;
}
.propRow {
display: flex;
justify-content: space-between;
font-size: 13px;
}
.propLabel {
color: var(--text-secondary);
}
.propValue {
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
}
.subTitle {
font-size: 10px;
color: var(--text-muted);
letter-spacing: 0.06em;
text-transform: uppercase;
margin: 10px 0 6px;
}
.empty {
color: var(--text-muted);
font-size: 13px;
font-style: italic;
}

View File

@@ -0,0 +1,72 @@
import { useAppStore } from '../../store/appStore';
import FakeDataInputs from '../flow/FakeDataInputs';
import styles from './RightPanel.module.css';
export default function RightPanel() {
const { selectedTile, selectedMethod, centerMode } = useAppStore();
return (
<div className={styles.panel}>
{/* Asset properties */}
{centerMode === 'asset_static' && selectedTile && (
<div className={styles.section}>
<div className={`${styles.sectionTitle} font-cinzel`}>Properties</div>
<div className={styles.propList}>
<PropRow label="ID" value={selectedTile.id.toString()} />
<PropRow label="Name" value={selectedTile.name || '(unnamed)'} />
<PropRow label="Flags" value={`0x${selectedTile.flags.toString(16).toUpperCase()}`} />
<PropRow label="Weight" value={selectedTile.weight.toString()} />
<PropRow label="Height" value={selectedTile.height.toString()} />
<PropRow label="Quality" value={selectedTile.quality.toString()} />
<PropRow label="Hue" value={selectedTile.hue.toString()} />
<PropRow label="Anim ID" value={selectedTile.animId.toString()} />
</div>
</div>
)}
{/* Method detail */}
{centerMode === 'flow_method' && selectedMethod && (
<div className={styles.section}>
<div className={`${styles.sectionTitle} font-cinzel`}>Method</div>
<div className={styles.propList}>
<PropRow label="Name" value={selectedMethod.name} />
<PropRow label="Returns" value={selectedMethod.returnType} />
<PropRow label="Override" value={selectedMethod.isOverride ? 'yes' : 'no'} />
<PropRow label="Virtual" value={selectedMethod.isVirtual ? 'yes' : 'no'} />
{selectedMethod.callsGump && (
<PropRow label="Opens Gump" value={selectedMethod.gumpClass ?? '(dynamic)'} />
)}
</div>
{selectedMethod.parameters.length > 0 && (
<>
<div className={styles.subTitle}>Parameters</div>
{selectedMethod.parameters.map((p, i) => (
<PropRow key={i} label={p.name} value={p.type} />
))}
</>
)}
</div>
)}
{/* Fake data inputs for flow conditions */}
{centerMode === 'flow_method' && <FakeDataInputs />}
{/* Default empty state */}
{centerMode === 'empty' && (
<div className={styles.section}>
<div className={styles.empty}>Nothing selected</div>
</div>
)}
</div>
);
}
function PropRow({ label, value }: { label: string; value: string }) {
return (
<div className={styles.propRow}>
<span className={styles.propLabel}>{label}</span>
<span className={styles.propValue}>{value}</span>
</div>
);
}

View File

@@ -0,0 +1,161 @@
.detail {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
}
.header {
padding: 14px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg-panel);
}
.titleRow {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.className {
font-size: 18px;
color: var(--accent-gold);
letter-spacing: 0.04em;
}
.tag {
font-size: 10px;
font-family: 'Cinzel', serif;
padding: 2px 6px;
border: 1px solid #445;
color: #8888ff;
background: #1a1a2e;
border-radius: 2px;
}
.tagMobile {
border-color: #344;
color: #88cc88;
background: #1a2e1a;
}
.tagItem {
border-color: #443;
color: var(--accent-gold);
background: #1e1a10;
}
.filePath {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.section {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.sectionTitle {
font-size: 10px;
color: var(--accent-gold);
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 8px;
}
.chain {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
flex-wrap: wrap;
}
.chainItem {
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
}
.arrow {
color: var(--text-muted);
}
.interfaces {
font-size: 12px;
color: var(--text-secondary);
margin-top: 6px;
font-style: italic;
}
.loading {
padding: 16px;
color: var(--text-muted);
font-size: 13px;
font-style: italic;
}
.methodRow {
display: flex;
width: 100%;
padding: 6px 8px;
background: none;
border: none;
border-bottom: 1px solid var(--border);
text-align: left;
cursor: pointer;
transition: background 0.1s;
}
.methodRow:hover {
background: var(--bg-hover);
}
.hookRow {
border-left: 2px solid var(--accent-gold);
padding-left: 6px;
}
.methodSig {
display: flex;
align-items: baseline;
gap: 6px;
flex-wrap: wrap;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
}
.returnType {
color: var(--text-muted);
}
.methodName {
color: var(--text-primary);
font-weight: 500;
}
.params {
color: var(--text-secondary);
font-size: 11px;
}
.modBadge {
font-size: 10px;
font-family: 'Cinzel', serif;
padding: 1px 5px;
border: 1px solid var(--border);
color: var(--text-muted);
border-radius: 2px;
}
.gumpBadge {
border-color: #445;
color: #8888ff;
background: #1a1a2e;
}

View File

@@ -0,0 +1,124 @@
import { useEffect, useState } from 'react';
import { useAppStore } from '../../store/appStore';
import { useScripts } from '../../hooks/useScripts';
import type { ClassDetail as ClassDetailType, MethodSummary } from '../../types/scripts';
import { WELL_KNOWN_HOOKS } from '../../types/scripts';
import styles from './ClassDetail.module.css';
export default function ClassDetail() {
const { selectedClass, setSelectedMethod, setCenterMode } = useAppStore();
const { getClassDetail } = useScripts();
const [detail, setDetail] = useState<ClassDetailType | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!selectedClass) return;
setLoading(true);
setDetail(null);
getClassDetail(selectedClass.name).then((d) => {
setDetail(d);
setLoading(false);
});
}, [selectedClass?.name]);
if (!selectedClass) return null;
const cls = detail?.class ?? selectedClass;
const methods = detail?.methods ?? [];
// Split well-known hooks from other methods
const hookNames = new Set(WELL_KNOWN_HOOKS.map((h) => h.method));
const hooks = methods.filter((m) => hookNames.has(m.name));
const others = methods.filter((m) => !hookNames.has(m.name));
function selectMethod(method: MethodSummary) {
setSelectedMethod(method);
setCenterMode('flow_method');
}
return (
<div className={styles.detail}>
{/* Header */}
<div className={styles.header}>
<div className={styles.titleRow}>
<span className={`${styles.className} font-cinzel`}>{cls.name}</span>
{cls.isGump && <span className={styles.tag}>Gump</span>}
{cls.isMobile && <span className={`${styles.tag} ${styles.tagMobile}`}>Mobile</span>}
{cls.isItem && <span className={`${styles.tag} ${styles.tagItem}`}>Item</span>}
</div>
<div className={styles.filePath}>{cls.filePath}</div>
</div>
{/* Inheritance */}
<section className={styles.section}>
<div className={`${styles.sectionTitle} font-cinzel`}>Inheritance</div>
<div className={styles.chain}>
{cls.baseClass ? (
<span className={styles.chainItem}>{cls.name}</span>
) : null}
{cls.baseClass && <span className={styles.arrow}></span>}
<span className={styles.chainItem}>{cls.baseClass ?? cls.name}</span>
</div>
{cls.interfaces.length > 0 && (
<div className={styles.interfaces}>
implements: {cls.interfaces.join(', ')}
</div>
)}
</section>
{loading && <div className={styles.loading}>Loading methods</div>}
{/* Well-known hooks */}
{hooks.length > 0 && (
<section className={styles.section}>
<div className={`${styles.sectionTitle} font-cinzel`}>Hooks</div>
{hooks.map((m) => (
<MethodRow key={m.id} method={m} onSelect={selectMethod} isHook />
))}
</section>
)}
{/* Other methods */}
{others.length > 0 && (
<section className={styles.section}>
<div className={`${styles.sectionTitle} font-cinzel`}>
Methods ({others.length})
</div>
{others.map((m) => (
<MethodRow key={m.id} method={m} onSelect={selectMethod} />
))}
</section>
)}
</div>
);
}
function MethodRow({
method,
onSelect,
isHook,
}: {
method: MethodSummary;
onSelect: (m: MethodSummary) => void;
isHook?: boolean;
}) {
const paramStr = method.parameters
.map((p) => `${p.type} ${p.name}`)
.join(', ');
return (
<button className={`${styles.methodRow} ${isHook ? styles.hookRow : ''}`} onClick={() => onSelect(method)}>
<div className={styles.methodSig}>
<span className={styles.returnType}>{method.returnType}</span>
<span className={styles.methodName}>{method.name}</span>
<span className={styles.params}>({paramStr})</span>
{method.isOverride && <span className={styles.modBadge}>override</span>}
{method.callsGump && (
<span className={`${styles.modBadge} ${styles.gumpBadge}`}>
{method.gumpClass ?? 'Gump'}
</span>
)}
</div>
</button>
);
}

View File

@@ -0,0 +1,129 @@
.list {
display: flex;
flex-direction: column;
}
.namespace {
border-bottom: 1px solid var(--border);
}
.nsHeader {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 6px 10px;
background: none;
border: none;
text-align: left;
cursor: pointer;
color: var(--text-secondary);
font-size: 12px;
font-family: 'Cinzel', serif;
letter-spacing: 0.04em;
transition: background 0.1s, color 0.1s;
}
.nsHeader:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.nsChevron {
font-size: 10px;
color: var(--accent-gold);
min-width: 10px;
}
.nsName {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nsCount {
font-size: 10px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
.nsClasses {
display: flex;
flex-direction: column;
}
.classRow {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px 5px 22px;
background: none;
border: none;
border-bottom: 1px solid var(--border);
text-align: left;
cursor: pointer;
transition: background 0.1s;
}
.classRow:hover {
background: var(--bg-hover);
}
.className {
flex: 1;
font-size: 13px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.classNs {
font-size: 10px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
.badge {
font-size: 9px;
font-family: 'Cinzel', serif;
padding: 1px 4px;
border-radius: 2px;
border: 1px solid;
min-width: 16px;
text-align: center;
}
.badgeGump {
color: #8888ff;
border-color: #445;
background: #1a1a2e;
}
.badgeMobile {
color: #88cc88;
border-color: #344;
background: #1a2e1a;
}
.badgeItem {
color: var(--accent-gold);
border-color: #443;
background: #1e1a10;
}
.empty {
padding: 20px;
color: var(--text-muted);
font-size: 13px;
font-style: italic;
line-height: 1.6;
}
.error {
padding: 16px;
color: #ff8080;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
}

View File

@@ -0,0 +1,147 @@
import { useEffect, useState } from 'react';
import { useAppStore } from '../../store/appStore';
import { useScripts } from '../../hooks/useScripts';
import type { ClassSummary, NamespaceTree } from '../../types/scripts';
import styles from './ScriptTree.module.css';
interface Props {
search: string;
}
export default function ScriptTree({ search }: Props) {
const { setSelectedClass, setCenterMode } = useAppStore();
const { tree, loading, error, loadTree, search: searchScripts } = useScripts();
const [searchResults, setSearchResults] = useState<ClassSummary[] | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
useEffect(() => {
loadTree();
}, []);
useEffect(() => {
if (!search.trim()) {
setSearchResults(null);
return;
}
const timer = setTimeout(async () => {
const results = await searchScripts(search);
setSearchResults(results);
}, 250);
return () => clearTimeout(timer);
}, [search]);
function toggleNamespace(ns: string) {
setExpanded((prev) => {
const next = new Set(prev);
next.has(ns) ? next.delete(ns) : next.add(ns);
return next;
});
}
function selectClass(cls: ClassSummary) {
setSelectedClass(cls);
setCenterMode('script_class');
}
if (loading && tree.length === 0) {
return <div className={styles.empty}>Loading scripts</div>;
}
if (error) {
return <div className={styles.error}>{error}</div>;
}
if (!loading && tree.length === 0) {
return (
<div className={styles.empty}>
No scripts indexed yet.
<br />
Set your Scripts path in Config and click [Index Scripts].
</div>
);
}
// Search mode
if (searchResults !== null) {
return (
<div className={styles.list}>
{searchResults.length === 0 && (
<div className={styles.empty}>No results for "{search}"</div>
)}
{searchResults.map((cls) => (
<ClassRow key={cls.name} cls={cls} onSelect={selectClass} showNamespace />
))}
</div>
);
}
return (
<div className={styles.list}>
{tree.map((ns) => (
<NamespaceGroup
key={ns.namespace}
ns={ns}
expanded={expanded.has(ns.namespace)}
onToggle={() => toggleNamespace(ns.namespace)}
onSelect={selectClass}
/>
))}
</div>
);
}
function NamespaceGroup({
ns,
expanded,
onToggle,
onSelect,
}: {
ns: NamespaceTree;
expanded: boolean;
onToggle: () => void;
onSelect: (cls: ClassSummary) => void;
}) {
const shortNs = ns.namespace.split('.').pop() ?? ns.namespace;
return (
<div className={styles.namespace}>
<button className={styles.nsHeader} onClick={onToggle}>
<span className={styles.nsChevron}>{expanded ? '▾' : '▸'}</span>
<span className={styles.nsName}>{shortNs}</span>
<span className={styles.nsCount}>{ns.classes.length}</span>
</button>
{expanded && (
<div className={styles.nsClasses}>
{ns.classes.map((cls) => (
<ClassRow key={cls.name} cls={cls} onSelect={onSelect} />
))}
</div>
)}
</div>
);
}
function ClassRow({
cls,
onSelect,
showNamespace,
}: {
cls: ClassSummary;
onSelect: (cls: ClassSummary) => void;
showNamespace?: boolean;
}) {
const badge = cls.isGump ? 'G' : cls.isMobile ? 'M' : cls.isItem ? 'I' : null;
const badgeClass = cls.isGump
? styles.badgeGump
: cls.isMobile
? styles.badgeMobile
: styles.badgeItem;
return (
<button className={styles.classRow} onClick={() => onSelect(cls)}>
{badge && <span className={`${styles.badge} ${badgeClass}`}>{badge}</span>}
<span className={styles.className}>{cls.name}</span>
{showNamespace && (
<span className={styles.classNs}>{cls.namespace.split('.').pop()}</span>
)}
</button>
);
}

40
src/hooks/useScripts.ts Normal file
View File

@@ -0,0 +1,40 @@
import { useState, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import type { ClassSummary, ClassDetail, NamespaceTree } from '../types/scripts';
export function useScripts() {
const [tree, setTree] = useState<NamespaceTree[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadTree = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await invoke<NamespaceTree[]>('get_script_tree');
setTree(result);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, []);
const search = useCallback(async (query: string): Promise<ClassSummary[]> => {
try {
return await invoke<ClassSummary[]>('search_scripts', { query });
} catch {
return [];
}
}, []);
const getClassDetail = useCallback(async (className: string): Promise<ClassDetail | null> => {
try {
return await invoke<ClassDetail>('get_class_detail', { className });
} catch {
return null;
}
}, []);
return { tree, loading, error, loadTree, search, getClassDetail };
}

84
src/index.css Normal file
View File

@@ -0,0 +1,84 @@
:root {
--bg-base: #0e0d0b;
--bg-panel: #161410;
--bg-elevated: #1e1b16;
--bg-hover: #272318;
--border: #3a3020;
--border-accent: #6b5a2e;
--text-primary: #d4c49a;
--text-secondary: #8a7a5a;
--text-muted: #4a4030;
--accent-gold: #c8a84b;
--accent-gold-bright: #e8c86b;
--accent-red: #8b3a3a;
--accent-green: #3a6b3a;
--scrollbar-thumb: #3a3020;
font-family: 'EB Garamond', Georgia, serif;
font-size: 16px;
color: var(--text-primary);
background-color: var(--bg-base);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
background-color: var(--bg-base);
color: var(--text-primary);
overflow: hidden;
}
#root {
height: 100vh;
display: flex;
flex-direction: column;
}
/* Scrollbars */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg-panel);
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-accent);
}
/* Typography helpers */
.font-cinzel {
font-family: 'Cinzel', serif;
}
.font-mono {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
/* Buttons */
button {
cursor: pointer;
font-family: inherit;
}
/* Input */
input, textarea, select {
font-family: inherit;
background: var(--bg-elevated);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 3px;
padding: 6px 10px;
outline: none;
}
input:focus, textarea:focus, select:focus {
border-color: var(--border-accent);
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

121
src/store/appStore.ts Normal file
View File

@@ -0,0 +1,121 @@
import { create } from 'zustand';
import type { TileInfo } from '../types/assets';
import type { ClassSummary, MethodSummary, FlowNode } from '../types/scripts';
import type { GumpDrawList } from '../types/gump';
export type LeftPanelTab = 'scripts' | 'statics' | 'mobiles' | 'gumps';
export type CenterMode =
| 'empty'
| 'config'
| 'asset_static'
| 'asset_mobile'
| 'script_class'
| 'flow_method'
| 'gump_render'
| 'gump_image';
export interface GumpEntry {
id: number;
name: string;
tags: string[];
}
export interface ScriptGumpEntry {
class_name: string;
file_path: string;
tags: string[];
}
interface AppState {
// Config
uoRoot: string;
servuoScripts: string;
assetFormat: 'mul' | 'uop';
isConfigured: boolean;
setUoRoot: (p: string) => void;
setServuoScripts: (p: string) => void;
setAssetFormat: (f: 'mul' | 'uop') => void;
setIsConfigured: (v: boolean) => void;
// Navigation
activeTab: LeftPanelTab;
centerMode: CenterMode;
setActiveTab: (tab: LeftPanelTab) => void;
setCenterMode: (mode: CenterMode) => void;
// Asset browser
selectedTile: TileInfo | null;
setSelectedTile: (tile: TileInfo | null) => void;
// Script browser
selectedClass: ClassSummary | null;
setSelectedClass: (c: ClassSummary | null) => void;
// Method / flow
selectedMethod: MethodSummary | null;
setSelectedMethod: (m: MethodSummary | null) => void;
// Flow tree
flowRoot: FlowNode | null;
setFlowRoot: (node: FlowNode | null) => void;
// Gump
gumpDrawList: GumpDrawList | null;
setGumpDrawList: (g: GumpDrawList | null) => void;
// Gump browser (Phase 4)
selectedGump: GumpEntry | null;
setSelectedGump: (g: GumpEntry | null) => void;
// Script gump browser
selectedScriptGump: ScriptGumpEntry | null;
setSelectedScriptGump: (g: ScriptGumpEntry | null) => void;
// Fake data for dynamic gumps / flow conditions
fakeData: Record<string, string>;
setFakeData: (key: string, value: string) => void;
clearFakeData: () => void;
}
export const useAppStore = create<AppState>((set) => ({
uoRoot: '',
servuoScripts: '',
assetFormat: 'mul',
isConfigured: false,
setUoRoot: (p) => set({ uoRoot: p }),
setServuoScripts: (p) => set({ servuoScripts: p }),
setAssetFormat: (f) => set({ assetFormat: f }),
setIsConfigured: (v) => set({ isConfigured: v }),
activeTab: 'statics',
centerMode: 'empty',
setActiveTab: (tab) => set({ activeTab: tab }),
setCenterMode: (mode) => set({ centerMode: mode }),
selectedTile: null,
setSelectedTile: (tile) => set({ selectedTile: tile }),
selectedClass: null,
setSelectedClass: (c) => set({ selectedClass: c, selectedMethod: null, flowRoot: null }),
selectedMethod: null,
setSelectedMethod: (m) => set({ selectedMethod: m }),
flowRoot: null,
setFlowRoot: (node) => set({ flowRoot: node }),
gumpDrawList: null,
setGumpDrawList: (g) => set({ gumpDrawList: g }),
selectedGump: null,
setSelectedGump: (g) => set({ selectedGump: g }),
selectedScriptGump: null,
setSelectedScriptGump: (g) => set({ selectedScriptGump: g }),
fakeData: {},
setFakeData: (key, value) =>
set((s) => ({ fakeData: { ...s.fakeData, [key]: value } })),
clearFakeData: () => set({ fakeData: {} }),
}));

24
src/types/assets.ts Normal file
View File

@@ -0,0 +1,24 @@
export interface TileInfo {
id: number;
name: string;
flags: number;
weight: number;
quality: number;
height: number;
hue: number;
animId: number;
artData?: ImageData;
}
export interface MobileInfo {
bodyId: number;
name: string;
flags: number;
frames: ImageData[];
}
export interface HueInfo {
id: number;
name: string;
colors: number[];
}

1
src/types/flow.ts Normal file
View File

@@ -0,0 +1 @@
export type { FlowNode } from './scripts';

22
src/types/gump.ts Normal file
View File

@@ -0,0 +1,22 @@
export type DrawCall =
| { type: 'background'; x: number; y: number; w: number; h: number; gumpId: number }
| { type: 'image'; x: number; y: number; gumpId: number; hue?: number }
| { type: 'label'; x: number; y: number; hue: number; text: string }
| { type: 'button'; x: number; y: number; normalId: number; pressedId: number; buttonId: number }
| { type: 'html'; x: number; y: number; w: number; h: number; text: string; hasBackground: boolean; hasScrollbar: boolean }
| { type: 'item'; x: number; y: number; itemId: number; hue?: number }
| { type: 'alpha_region'; x: number; y: number; w: number; h: number }
| { type: 'tiled_image'; x: number; y: number; w: number; h: number; gumpId: number }
| { type: 'checkbox'; x: number; y: number; inactiveId: number; activeId: number; checked: boolean; switchId: number }
| { type: 'radio'; x: number; y: number; inactiveId: number; activeId: number; checked: boolean; returnValue: number }
| { type: 'text_entry'; x: number; y: number; w: number; h: number; hue: number; entryId: number; initialText: string };
export interface GumpDrawList {
className: string;
filePath: string;
isDynamic: boolean;
dynamicInputKeys: string[];
drawCalls: DrawCall[];
width: number;
height: number;
}

69
src/types/scripts.ts Normal file
View File

@@ -0,0 +1,69 @@
export interface ParameterInfo {
name: string;
type: string;
}
export interface PropertyInfo {
name: string;
type: string;
hasGetter: boolean;
hasSetter: boolean;
}
export interface MethodSummary {
id: number;
className: string;
name: string;
returnType: string;
parameters: ParameterInfo[];
isOverride: boolean;
isVirtual: boolean;
callsGump: boolean;
gumpClass: string | null;
}
export interface ClassSummary {
name: string;
namespace: string;
filePath: string;
baseClass: string | null;
interfaces: string[];
isGump: boolean;
isMobile: boolean;
isItem: boolean;
}
export interface ClassDetail {
class: ClassSummary;
methods: MethodSummary[];
}
export interface NamespaceTree {
namespace: string;
classes: ClassSummary[];
}
export interface FlowNode {
id: string;
type: 'method_call' | 'condition' | 'branch_true' | 'branch_false' | 'branch_case'
| 'gump_send' | 'return' | 'property_access';
label: string;
children: FlowNode[];
fakeInputKey?: string;
resolvedGump?: string;
assetRef?: number;
}
// Well-known ServUO hooks surfaced as quick-launch entry points
export const WELL_KNOWN_HOOKS: { method: string; label: string }[] = [
{ method: 'OnDoubleClick', label: 'OnDoubleClick' },
{ method: 'OnSingleClick', label: 'OnSingleClick' },
{ method: 'OnDeath', label: 'OnDeath' },
{ method: 'OnDamage', label: 'OnDamage' },
{ method: 'OnDelete', label: 'OnDelete' },
{ method: 'OnMoveOver', label: 'OnMoveOver' },
{ method: 'OnGainedLevel', label: 'OnGainedLevel' },
{ method: 'OnSkillUse', label: 'OnSkillUse' },
{ method: 'OnHit', label: 'OnHit' },
{ method: 'OnMiss', label: 'OnMiss' },
];

6
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.module.css' {
const classes: Record<string, string>;
export default classes;
}

20
tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

14
vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig(async () => ({
plugins: [react()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
watch: {
ignored: ["**/src-tauri/**"],
},
},
}));