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

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