ModernUO/Distribution/Data/packets.html
Kamron Batman f0b65fecb7
feat: Add interactive packet documentation system
- Create JSON-based packet documentation for 145 packets (66 incoming, 79 outgoing)
- Add HTML template with search, filtering, and navigation
- Support enum/bitfield field display with values and descriptions
- Support packet variants for version-specific structures
- Include 0xBF extended command subpackets documentation
- Add PowerShell build script to generate final HTML

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 11:47:31 -08:00

735 lines
28 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="description" content="Network packet documentation for the Ultima Online Server Emulator ModernUO">
<meta name="author" content="ModernUO Development Team">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="https://modernuo.com/branding/favicon.png">
<title>ModernUO Packet Documentation</title>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&family=Roboto+Mono:wght@400;500&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com/3.4.1?plugins=typography@0.5.10"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
mono: ['Roboto Mono', 'monospace'],
},
colors: {
"modern-black": "#212121",
"modern-black-hover": "#2F2F2F",
"modern-dark-black": "#1A1A1A",
"incoming": "#3b82f6",
"outgoing": "#22c55e",
"both": "#a855f7",
gold: {
50: "#fbf3ee",
100: "#f8e9dd",
200: "#f0d9bd",
300: "#e8cea1",
400: "#dfc688",
500: "#d5bf74",
600: "#cab664",
700: "#bdab58",
800: "#b09f4f",
900: "#a29147",
},
},
},
},
}
</script>
<style>
.packet-list::-webkit-scrollbar {
width: 8px;
}
.packet-list::-webkit-scrollbar-track {
background: #1A1A1A;
}
.packet-list::-webkit-scrollbar-thumb {
background: #404040;
border-radius: 4px;
}
.packet-list::-webkit-scrollbar-thumb:hover {
background: #525252;
}
.field-row:nth-child(even) {
background-color: rgba(255, 255, 255, 0.02);
}
.loop-field {
border-left: 2px solid #d5bf74;
margin-left: 1rem;
padding-left: 0.75rem;
}
</style>
</head>
<body class="bg-modern-dark-black text-gold-300 font-sans">
<div id="app" class="min-h-screen flex flex-col">
<!-- Header -->
<header class="bg-modern-black p-4 shadow-lg border-b border-gold-500/30">
<div class="max-w-7xl mx-auto flex items-center justify-between">
<div class="flex items-center space-x-4">
<img src="https://modernuo.com/branding/logo.svg" class="h-12 w-12">
<div>
<h1 class="text-white text-xl font-medium">ModernUO Packet Documentation</h1>
<p class="text-gold-500/70 text-sm">Ultima Online Network Protocol Reference</p>
</div>
</div>
<div class="flex items-center space-x-2">
<span id="packetCount" class="text-gold-500/70 text-sm"></span>
</div>
</div>
</header>
<!-- Main Content -->
<div class="flex-1 flex overflow-hidden">
<!-- Left Panel: Packet List -->
<aside id="sidebar" class="w-80 bg-modern-black border-r border-gold-500/30 flex flex-col">
<!-- Search & Filter -->
<div class="p-3 border-b border-gold-500/20 space-y-2">
<input
type="text"
id="searchInput"
placeholder="Search packets (ID, name, field)..."
class="w-full bg-modern-dark-black text-gold-300 placeholder-gold-500/50 px-3 py-2 rounded border border-gold-500/30 focus:outline-none focus:border-gold-500 text-sm"
>
<div class="flex space-x-1">
<button data-filter="all" class="filter-btn flex-1 px-2 py-1 text-xs rounded bg-gold-500 text-modern-black font-medium">All</button>
<button data-filter="incoming" class="filter-btn flex-1 px-2 py-1 text-xs rounded bg-modern-dark-black text-incoming border border-incoming/50 hover:bg-incoming/20">In</button>
<button data-filter="outgoing" class="filter-btn flex-1 px-2 py-1 text-xs rounded bg-modern-dark-black text-outgoing border border-outgoing/50 hover:bg-outgoing/20">Out</button>
<button data-filter="both" class="filter-btn flex-1 px-2 py-1 text-xs rounded bg-modern-dark-black text-both border border-both/50 hover:bg-both/20">Both</button>
</div>
<select id="categoryFilter" class="w-full bg-modern-dark-black text-gold-300 px-3 py-2 rounded border border-gold-500/30 focus:outline-none focus:border-gold-500 text-sm">
<option value="">All Categories</option>
</select>
</div>
<!-- Packet List -->
<div id="packetList" class="packet-list flex-1 overflow-y-auto">
<!-- Populated by JavaScript -->
</div>
</aside>
<!-- Right Panel: Packet Details -->
<main class="flex-1 overflow-y-auto bg-modern-dark-black">
<div id="packetDetails" class="p-6 max-w-4xl mx-auto">
<!-- Welcome message when no packet selected -->
<div id="welcomeMessage" class="text-center py-16">
<div class="text-gold-500/50 text-6xl mb-4">&#x1F4E6;</div>
<h2 class="text-gold-500 text-xl mb-2">Select a Packet</h2>
<p class="text-gold-500/70">Choose a packet from the list to view its documentation.</p>
<p class="text-gold-500/50 text-sm mt-4">Use the search box to find packets by ID (0x02), name, or field names.</p>
</div>
<!-- Packet detail content (hidden by default) -->
<div id="packetContent" class="hidden">
<!-- Header -->
<div class="flex items-start justify-between mb-6">
<div>
<div class="flex items-center space-x-3 mb-2">
<span id="detailId" class="font-mono text-2xl text-white"></span>
<span id="detailSubId" class="font-mono text-lg text-gold-500/70 hidden"></span>
<span id="detailDirection" class="px-2 py-0.5 rounded text-xs font-medium uppercase"></span>
</div>
<h2 id="detailName" class="text-xl text-gold-500 font-medium"></h2>
</div>
<a id="sourceLink" href="#" target="_blank" class="flex items-center space-x-1 text-sm text-gold-500/70 hover:text-gold-500 transition-colors">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
<span>View Source</span>
</a>
</div>
<!-- Description -->
<p id="detailDescription" class="text-gold-300/90 mb-6 leading-relaxed"></p>
<!-- Size Info -->
<div class="bg-modern-black rounded-lg p-4 mb-6 border border-gold-500/20">
<div class="flex items-center space-x-6">
<div>
<span class="text-gold-500/70 text-sm">Size:</span>
<span id="detailSize" class="text-white font-mono ml-2"></span>
</div>
<div id="detailDynamic" class="hidden">
<span class="px-2 py-0.5 bg-gold-500/20 text-gold-500 rounded text-xs">Dynamic Length</span>
</div>
</div>
</div>
<!-- Client Version Requirements -->
<div id="clientVersionSection" class="bg-modern-black rounded-lg p-4 mb-6 border border-gold-500/20 hidden">
<h3 class="text-gold-500 font-medium mb-2">Client Version Requirements</h3>
<div id="clientVersionContent" class="text-gold-300/80 text-sm"></div>
</div>
<!-- Fields Table -->
<div class="mb-6">
<h3 class="text-gold-500 font-medium mb-3">Packet Structure</h3>
<div class="bg-modern-black rounded-lg border border-gold-500/20 overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="bg-gold-500/10 text-gold-500">
<th class="px-4 py-2 text-left font-medium">Offset</th>
<th class="px-4 py-2 text-left font-medium">Name</th>
<th class="px-4 py-2 text-left font-medium">Type</th>
<th class="px-4 py-2 text-left font-medium">Size</th>
<th class="px-4 py-2 text-left font-medium">Description</th>
</tr>
</thead>
<tbody id="fieldsTable">
<!-- Populated by JavaScript -->
</tbody>
</table>
</div>
</div>
<!-- Related Packets -->
<div id="relatedSection" class="mb-6 hidden">
<h3 class="text-gold-500 font-medium mb-3">Related Packets</h3>
<div id="relatedPackets" class="space-y-2">
<!-- Populated by JavaScript -->
</div>
</div>
<!-- Packet Variants -->
<div id="variantsSection" class="mb-6 hidden">
<h3 class="text-gold-500 font-medium mb-3">Packet Variants</h3>
<div id="variantsList" class="space-y-4">
<!-- Populated by JavaScript -->
</div>
</div>
<!-- Notes -->
<div id="notesSection" class="hidden">
<h3 class="text-gold-500 font-medium mb-2">Notes</h3>
<p id="detailNotes" class="text-gold-300/80 text-sm bg-modern-black rounded-lg p-4 border border-gold-500/20"></p>
</div>
</div>
</div>
</main>
</div>
</div>
<!-- Hidden data containers -->
<span id="packets" style="display:none; visibility: hidden">
<!-- PACKETS -->
</span>
<script>
// Parse packet data
let allPackets = { incoming: [], outgoing: [] };
try {
allPackets = JSON.parse(document.getElementById('packets').innerText.trim() || '{"incoming":[],"outgoing":[]}');
} catch (e) {
console.error('Failed to parse packet data:', e);
}
// Flatten all packets into a single array with direction info preserved
const packets = [
...allPackets.incoming.map(p => ({ ...p, direction: p.direction || 'incoming' })),
...allPackets.outgoing.map(p => ({ ...p, direction: p.direction || 'outgoing' }))
];
// Build packet index by ID for quick lookups
const packetIndex = new Map();
packets.forEach(p => {
const key = p.subId ? `${p.id}/${p.subId}` : p.id;
packetIndex.set(key, p);
packetIndex.set(p.id, p); // Also index by just ID for basic lookups
});
// State
let currentFilter = 'all';
let currentCategory = '';
let currentSearch = '';
let selectedPacket = null;
// DOM elements
const searchInput = document.getElementById('searchInput');
const categoryFilter = document.getElementById('categoryFilter');
const packetList = document.getElementById('packetList');
const packetCount = document.getElementById('packetCount');
const welcomeMessage = document.getElementById('welcomeMessage');
const packetContent = document.getElementById('packetContent');
// Extract unique categories
const categories = [...new Set(packets.map(p => p.category).filter(Boolean))].sort();
categories.forEach(cat => {
const option = document.createElement('option');
option.value = cat;
option.textContent = cat;
categoryFilter.appendChild(option);
});
// Update packet count
function updatePacketCount() {
const filtered = getFilteredPackets();
packetCount.textContent = `${filtered.length} of ${packets.length} packets`;
}
// Get direction color class
function getDirectionClass(direction) {
switch(direction) {
case 'incoming': return 'text-incoming';
case 'outgoing': return 'text-outgoing';
case 'both': return 'text-both';
default: return 'text-gold-500';
}
}
function getDirectionBgClass(direction) {
switch(direction) {
case 'incoming': return 'bg-incoming';
case 'outgoing': return 'bg-outgoing';
case 'both': return 'bg-both';
default: return 'bg-gold-500';
}
}
// Filter packets
function getFilteredPackets() {
return packets.filter(p => {
// Direction filter
if (currentFilter !== 'all' && p.direction !== currentFilter) return false;
// Category filter
if (currentCategory && p.category !== currentCategory) return false;
// Search filter
if (currentSearch) {
const search = currentSearch.toLowerCase();
const matchId = p.id.toLowerCase().includes(search) ||
(p.subId && p.subId.toLowerCase().includes(search));
const matchName = p.name.toLowerCase().includes(search);
const matchDesc = p.description && p.description.toLowerCase().includes(search);
const matchFields = p.fields && p.fields.some(f =>
f.name.toLowerCase().includes(search) ||
(f.description && f.description.toLowerCase().includes(search))
);
// Also search decimal ID
const decimalId = parseInt(p.id, 16);
const matchDecimal = !isNaN(decimalId) && decimalId.toString() === search;
if (!matchId && !matchName && !matchDesc && !matchFields && !matchDecimal) return false;
}
return true;
});
}
// Render packet list
function renderPacketList() {
const filtered = getFilteredPackets();
// Sort by ID
filtered.sort((a, b) => {
const aId = parseInt(a.id, 16);
const bId = parseInt(b.id, 16);
if (aId !== bId) return aId - bId;
// If same ID, sort by subId
if (a.subId && b.subId) {
return parseInt(a.subId, 16) - parseInt(b.subId, 16);
}
return a.subId ? 1 : -1;
});
packetList.innerHTML = filtered.map(p => {
const displayId = p.subId ? `${p.id}/${p.subId}` : p.id;
const isSelected = selectedPacket &&
selectedPacket.id === p.id &&
selectedPacket.subId === p.subId;
return `
<div
class="packet-item px-3 py-2 cursor-pointer border-b border-gold-500/10 hover:bg-modern-black-hover transition-colors ${isSelected ? 'bg-gold-500/20' : ''}"
data-id="${p.id}"
data-subid="${p.subId || ''}"
>
<div class="flex items-center space-x-2">
<span class="font-mono text-sm ${getDirectionClass(p.direction)}">${displayId}</span>
<span class="text-gold-300/90 text-sm truncate">${p.name}</span>
</div>
<div class="text-gold-500/50 text-xs mt-0.5">${p.category || 'Uncategorized'}</div>
</div>
`;
}).join('');
// Add click handlers
document.querySelectorAll('.packet-item').forEach(el => {
el.addEventListener('click', () => {
const id = el.dataset.id;
const subId = el.dataset.subid;
const packet = packets.find(p => p.id === id && (p.subId || '') === subId);
if (packet) selectPacket(packet);
});
});
updatePacketCount();
}
// Calculate field offsets
function calculateOffsets(fields) {
let offset = 0;
return fields.map(f => {
const fieldOffset = offset;
if (f.size && typeof f.size === 'number') {
offset += f.size;
}
return { ...f, offset: fieldOffset };
});
}
// Render enum values
function renderEnumValues(f) {
if (!f.values || f.values.length === 0) return '';
const enumType = f.enumType === 'bitfield' || f.type === 'bitfield' ? 'bitfield' : 'enum';
const typeLabel = enumType === 'bitfield' ? 'Flags' : 'Values';
return `
<div class="mt-2 text-xs">
<div class="text-gold-500/70 mb-1">${typeLabel}:</div>
<div class="grid grid-cols-1 gap-1 pl-2 border-l-2 border-gold-500/30">
${f.values.map(v => `
<div class="flex items-start">
<span class="font-mono text-gold-400 w-16 flex-shrink-0">${typeof v.value === 'number' ? (enumType === 'bitfield' ? '0x' + v.value.toString(16).toUpperCase().padStart(2, '0') : v.value) : v.value}</span>
<span class="text-gold-300 font-medium">${v.name}</span>
${v.description ? `<span class="text-gold-500/60 ml-2">- ${v.description}</span>` : ''}
</div>
`).join('')}
</div>
</div>
`;
}
// Render bitfield flags
function renderBitfieldFlags(f) {
if (!f.flags || f.flags.length === 0) return '';
return `
<div class="mt-2 text-xs">
<div class="text-gold-500/70 mb-1">Flags:</div>
<div class="grid grid-cols-1 gap-1 pl-2 border-l-2 border-purple-500/30">
${f.flags.map(flag => `
<div class="flex items-start">
<span class="font-mono text-purple-400 w-16 flex-shrink-0">${flag.bit}</span>
<span class="text-gold-300 font-medium">${flag.name}</span>
${flag.description ? `<span class="text-gold-500/60 ml-2">- ${flag.description}</span>` : ''}
</div>
`).join('')}
</div>
</div>
`;
}
// Render fields table
function renderFields(fields, parentOffset = 0) {
const fieldsWithOffsets = calculateOffsets(fields);
return fieldsWithOffsets.map(f => {
const offset = parentOffset + f.offset;
const isEnum = f.type === 'enum' || f.values;
const isBitfield = f.type === 'bitfield' || f.flags;
const typeDisplay = isEnum ? `enum (${f.size || 1} byte${(f.size || 1) > 1 ? 's' : ''})` :
isBitfield ? `bitfield (${f.size || 1} byte${(f.size || 1) > 1 ? 's' : ''})` :
f.type;
let row = `
<tr class="field-row border-b border-gold-500/10">
<td class="px-4 py-2 font-mono text-gold-500/70">${f.size ? offset : '-'}</td>
<td class="px-4 py-2 font-mono text-white">${f.name}</td>
<td class="px-4 py-2 font-mono ${isEnum ? 'text-green-400' : isBitfield ? 'text-purple-400' : 'text-gold-400'}">${typeDisplay}</td>
<td class="px-4 py-2 font-mono text-gold-500/70">${f.size || 'var'}</td>
<td class="px-4 py-2 text-gold-300/80">
${f.description || ''}
${f.value ? `<span class="text-gold-500/70 ml-1">(${f.value})</span>` : ''}
${f.condition ? `<span class="text-gold-400/70 ml-1 text-xs italic">[${f.condition}]</span>` : ''}
${isEnum ? renderEnumValues(f) : ''}
${isBitfield ? renderBitfieldFlags(f) : ''}
</td>
</tr>
`;
// Handle loop fields
if (f.loop && f.loop.fields) {
row += `
<tr class="field-row border-b border-gold-500/10 bg-gold-500/5">
<td colspan="5" class="px-4 py-2">
<div class="loop-field">
<div class="text-gold-500/70 text-xs mb-2">Repeats for each ${f.loop.countField || 'item'}:</div>
<table class="w-full text-sm">
<tbody>
${renderFields(f.loop.fields, 0)}
</tbody>
</table>
</div>
</td>
</tr>
`;
}
return row;
}).join('');
}
// Select and display a packet
function selectPacket(packet) {
selectedPacket = packet;
// Update list selection
document.querySelectorAll('.packet-item').forEach(el => {
const isMatch = el.dataset.id === packet.id && (el.dataset.subid || '') === (packet.subId || '');
el.classList.toggle('bg-gold-500/20', isMatch);
});
// Show content, hide welcome
welcomeMessage.classList.add('hidden');
packetContent.classList.remove('hidden');
// Populate header
document.getElementById('detailId').textContent = packet.id;
const subIdEl = document.getElementById('detailSubId');
if (packet.subId) {
subIdEl.textContent = `/ ${packet.subId}`;
subIdEl.classList.remove('hidden');
} else {
subIdEl.classList.add('hidden');
}
document.getElementById('detailName').textContent = packet.name;
const directionEl = document.getElementById('detailDirection');
directionEl.textContent = packet.direction;
directionEl.className = `px-2 py-0.5 rounded text-xs font-medium uppercase ${getDirectionBgClass(packet.direction)} text-white`;
// Description
document.getElementById('detailDescription').textContent = packet.description || 'No description available.';
// Size
const sizeText = typeof packet.size === 'number'
? `${packet.size} bytes`
: packet.size || 'Variable';
document.getElementById('detailSize').textContent = sizeText;
document.getElementById('detailDynamic').classList.toggle('hidden', !packet.isDynamic);
// Client version
const versionSection = document.getElementById('clientVersionSection');
const versionContent = document.getElementById('clientVersionContent');
if (packet.clientVersion) {
versionSection.classList.remove('hidden');
let versionText = '';
if (packet.clientVersion.min) versionText += `Min: ${packet.clientVersion.min} `;
if (packet.clientVersion.max) versionText += `Max: ${packet.clientVersion.max} `;
if (packet.clientVersion.feature) versionText += `Feature: ${packet.clientVersion.feature} `;
if (packet.clientVersion.notes) versionText += `<br>${packet.clientVersion.notes}`;
versionContent.innerHTML = versionText || 'Version-specific behavior';
} else {
versionSection.classList.add('hidden');
}
// Fields
const fieldsTable = document.getElementById('fieldsTable');
if (packet.fields && packet.fields.length > 0) {
fieldsTable.innerHTML = renderFields(packet.fields);
} else {
fieldsTable.innerHTML = '<tr><td colspan="5" class="px-4 py-4 text-center text-gold-500/50">No field documentation available</td></tr>';
}
// Related packets
const relatedSection = document.getElementById('relatedSection');
const relatedPackets = document.getElementById('relatedPackets');
if (packet.related && packet.related.length > 0) {
relatedSection.classList.remove('hidden');
relatedPackets.innerHTML = packet.related.map(r => {
const relatedPacket = packetIndex.get(r.id);
const relationshipColors = {
'request': 'bg-blue-500/20 text-blue-400',
'response': 'bg-green-500/20 text-green-400',
'ack': 'bg-green-500/20 text-green-400',
'rej': 'bg-red-500/20 text-red-400',
'variant': 'bg-purple-500/20 text-purple-400'
};
const colorClass = relationshipColors[r.relationship] || 'bg-gold-500/20 text-gold-400';
return `
<div class="flex items-center justify-between bg-modern-black rounded-lg p-3 border border-gold-500/20 cursor-pointer hover:border-gold-500/40 transition-colors related-packet" data-id="${r.id}">
<div class="flex items-center space-x-3">
<span class="font-mono text-white">${r.id}</span>
<span class="text-gold-300/90">${relatedPacket?.name || 'Unknown'}</span>
</div>
<div class="flex items-center space-x-2">
<span class="px-2 py-0.5 rounded text-xs ${colorClass}">${r.relationship}</span>
${r.note ? `<span class="text-gold-500/50 text-sm">${r.note}</span>` : ''}
</div>
</div>
`;
}).join('');
// Add click handlers to related packets
document.querySelectorAll('.related-packet').forEach(el => {
el.addEventListener('click', () => {
const id = el.dataset.id;
const relatedPacket = packetIndex.get(id);
if (relatedPacket) selectPacket(relatedPacket);
});
});
} else {
relatedSection.classList.add('hidden');
}
// Variants
const variantsSection = document.getElementById('variantsSection');
const variantsList = document.getElementById('variantsList');
if (packet.variants && packet.variants.length > 0) {
variantsSection.classList.remove('hidden');
variantsList.innerHTML = packet.variants.map((v, index) => `
<div class="bg-modern-black rounded-lg border border-gold-500/20 overflow-hidden">
<div class="bg-gold-500/10 px-4 py-2 flex items-center justify-between">
<div>
<span class="text-gold-500 font-medium">${v.name || 'Variant ' + (index + 1)}</span>
${v.version ? `<span class="text-gold-500/70 ml-2 text-sm">(${v.version})</span>` : ''}
</div>
<div class="flex items-center space-x-3 text-sm">
${v.size ? `<span class="text-gold-500/70">Size: <span class="text-white font-mono">${v.size} bytes</span></span>` : ''}
${v.condition ? `<span class="text-gold-400/70 italic">${v.condition}</span>` : ''}
</div>
</div>
${v.fields && v.fields.length > 0 ? `
<table class="w-full text-sm">
<thead>
<tr class="bg-gold-500/5 text-gold-500 text-xs">
<th class="px-4 py-1 text-left font-medium">Offset</th>
<th class="px-4 py-1 text-left font-medium">Name</th>
<th class="px-4 py-1 text-left font-medium">Type</th>
<th class="px-4 py-1 text-left font-medium">Size</th>
<th class="px-4 py-1 text-left font-medium">Description</th>
</tr>
</thead>
<tbody>
${renderFields(v.fields)}
</tbody>
</table>
` : ''}
</div>
`).join('');
} else {
variantsSection.classList.add('hidden');
}
// Notes
const notesSection = document.getElementById('notesSection');
const notesEl = document.getElementById('detailNotes');
if (packet.notes) {
notesSection.classList.remove('hidden');
notesEl.textContent = packet.notes;
} else {
notesSection.classList.add('hidden');
}
// Source link
const sourceLink = document.getElementById('sourceLink');
if (packet.source && packet.source.file) {
sourceLink.href = `https://github.com/modernuo/ModernUO/blob/main/${packet.source.file}#L${packet.source.line || 1}`;
sourceLink.classList.remove('hidden');
} else {
sourceLink.classList.add('hidden');
}
// Update URL hash
const hash = packet.subId ? `${packet.id}-${packet.subId}` : packet.id;
history.replaceState(null, '', `#${hash}`);
}
// Filter button handlers
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
currentFilter = btn.dataset.filter;
// Update button styles
document.querySelectorAll('.filter-btn').forEach(b => {
const isActive = b.dataset.filter === currentFilter;
if (b.dataset.filter === 'all') {
b.classList.toggle('bg-gold-500', isActive);
b.classList.toggle('text-modern-black', isActive);
b.classList.toggle('bg-modern-dark-black', !isActive);
b.classList.toggle('text-gold-500', !isActive);
} else {
const color = b.dataset.filter;
b.classList.toggle(`bg-${color}/20`, isActive);
b.classList.toggle('bg-modern-dark-black', !isActive);
}
});
renderPacketList();
});
});
// Search handler
let searchTimeout;
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
currentSearch = e.target.value;
renderPacketList();
}, 150);
});
// Category filter handler
categoryFilter.addEventListener('change', (e) => {
currentCategory = e.target.value;
renderPacketList();
});
// Keyboard navigation
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
const filtered = getFilteredPackets();
if (filtered.length === 0) return;
let currentIndex = selectedPacket
? filtered.findIndex(p => p.id === selectedPacket.id && p.subId === selectedPacket.subId)
: -1;
if (e.key === 'ArrowDown') {
e.preventDefault();
currentIndex = Math.min(currentIndex + 1, filtered.length - 1);
selectPacket(filtered[currentIndex]);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
currentIndex = Math.max(currentIndex - 1, 0);
selectPacket(filtered[currentIndex]);
}
});
// Handle URL hash on load
function loadFromHash() {
const hash = window.location.hash.slice(1);
if (hash) {
// Try to find packet by hash (format: 0x02 or 0xBF-0x0026)
const parts = hash.split('-');
const id = parts[0];
const subId = parts[1];
const packet = packets.find(p =>
p.id.toLowerCase() === id.toLowerCase() &&
(p.subId || '').toLowerCase() === (subId || '').toLowerCase()
);
if (packet) selectPacket(packet);
}
}
// Initialize
renderPacketList();
loadFromHash();
// Handle hash changes
window.addEventListener('hashchange', loadFromHash);
</script>
</body>
</html>