26 KiB
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:
{ "id": "uuid", "command": "parse_class", "args": { "file": "path/to/Script.cs", "class": "PaperDollGump" } }
Response format:
{ "id": "uuid", "ok": true, "data": { ... } }
{ "id": "uuid", "ok": false, "error": "message" }
Commands:
index_scripts— walk Scripts/ tree, build full indexget_class— return ClassInfo for a named classtrace_method— return FlowNode tree for a method entry pointextract_gump— return GumpDrawList for a Gump class constructorsearch— 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
// 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
// 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
// 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):
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
<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
.csfiles in Scripts/ into aCSharpCompilationfor cross-file resolution - Use
SemanticModelfor type resolution (base classes, method return types) SyntaxTreefor 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
InvocationExpressionSyntaxnodes - For each invocation:
- If
SendGump(new XxxGump(...))→ emitgump_sendnode, 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→ emitconditionnode with branches - Limit recursion depth to 8 to avoid infinite loops
- If
Build & Development
Prerequisites
- Rust stable (latest)
- Node.js 20+
- .NET 8 SDK
- Tauri CLI:
cargo install tauri-cli
Development Commands
# 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:
{
"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):
--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)