@osynicite/osynic-osudb
    Preparing search index...

    @osynicite/osynic-osudb

    Logo逃走啦~

    OsynicOsudb (WebAssembly)

    🚀 高性能 · 🏗️ 结构优良 · 🌐 原生 WASM
    功能完整的 osu! 数据库解析库,支持解析 osu!.db, collection.db 与 scores.db 。

    🇨🇳 中文 · 🇺🇸 English


    📚 目录

    ✨ 特性

    • 🌐 WASM 原生支持: 编译为 WebAssembly,在浏览器中以接近原生的速度运行
    • 📱 多框架支持: 支持 Vue、React、Svelte 等流行前端框架
    • 📦 完整格式支持: osu!.db、collection.db、scores.db 所有数据库文件
    • 🎨 TypeScript 友好: 完整的类型定义,提供最佳的开发体验
    • ⚡ 零配置: 通过 npm 安装即可使用,集成简单快速
    • 🚀 高性能解析: 基于 nom8 解析器,高效可靠
    • 📊 轻量级: WASM 包体积优化,快速加载和初始化

    📦 安装

    使用 npm、yarn 或 pnpm 安装:

    # npm
    npm install @osynicite/osynic-osudb

    # yarn
    yarn add @osynicite/osynic-osudb

    # pnpm
    pnpm add @osynicite/osynic-osudb

    确保您的 Vite 项目配置支持 WebAssembly:

    // vite.config.ts
    import { defineConfig } from 'vite'
    import vue from '@vitejs/plugin-vue'
    import wasm from 'vite-plugin-wasm'
    import topLevelAwait from 'vite-plugin-top-level-await'

    export default defineConfig({
    plugins: [
    wasm(),
    topLevelAwait(),
    vue(),
    ],
    optimizeDeps: {
    exclude: ['@osynicite/osynic-osudb'],
    },
    })

    📝 如果使用 Tailwind CSS,请在 tailwind.config.js 中配置模板路径以支持动态类名

    🚀 快速开始

    import { OsuDB } from '@osynicite/osynic-osudb';
    import fs from 'fs';

    const data = fs.readFileSync('osu!.db');
    const osudb = OsuDB.from_bytes(new Uint8Array(data));

    // 遍历前3个谱面
    for (let i = 0; i < Math.min(3, osudb.beatmaps.length); i++) {
    const beatmap = osudb.beatmaps[i];
    console.log(`Song ${i + 1}: ${beatmap.artist_unicode || beatmap.artist} - ${beatmap.title_unicode || beatmap.title}`);
    console.log(`Creator: ${beatmap.creator}`);
    console.log(`Difficulty: ${beatmap.difficulty_name}`);
    console.log(`AR: ${beatmap.approach_rate.toFixed(2)}`);
    console.log(`CS: ${beatmap.circle_size.toFixed(2)}`);
    console.log(`OD: ${beatmap.overall_difficulty.toFixed(2)}`);
    console.log('---------------------------------');
    }
    <template>
    <div class="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 p-6 text-white">
    <div class="max-w-6xl mx-auto space-y-6">
    <!-- Header -->
    <div class="text-center space-y-2 mb-8">
    <h1 class="text-4xl font-bold">🎵 osu! 谱面浏览器</h1>
    <p class="text-gray-400">使用 WASM 解析您的数据库文件</p>
    </div>

    <!-- Upload Section -->
    <div class="bg-gray-800 rounded-lg p-6 border border-gray-700 space-y-3">
    <label class="block">
    <span class="text-sm font-medium mb-2 inline-block">选择 osu!.db 文件</span>
    <input
    type="file"
    @change="handleFileUpload"
    accept=".db"
    class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded hover:border-blue-500 transition file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700"
    />
    </label>
    <button
    @click="loadDatabase"
    :disabled="!fileData || loading"
    class="w-full px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg font-semibold transition duration-200"
    >
    {{ loading ? '⏳ 解析中...' : '📂 加载数据库' }}
    </button>
    </div>

    <!-- Error Message -->
    <div v-if="error" class="bg-red-900/30 border border-red-500/50 rounded-lg p-4 text-red-200">
    ❌ {{ error }}
    </div>

    <!-- Stats Summary -->
    <div v-if="beatmaps.length > 0" class="grid grid-cols-1 md:grid-cols-3 gap-4">
    <div class="bg-gray-800 border border-gray-700 rounded-lg p-4 text-center">
    <div class="text-3xl font-bold text-blue-400">{{ beatmaps.length }}</div>
    <div class="text-gray-400 text-sm">谱面总数</div>
    </div>
    <div class="bg-gray-800 border border-gray-700 rounded-lg p-4 text-center">
    <div class="text-3xl font-bold text-green-400">{{ calcTotalLength() }}</div>
    <div class="text-gray-400 text-sm">总时长</div>
    </div>
    <div class="bg-gray-800 border border-gray-700 rounded-lg p-4 text-center">
    <div class="text-3xl font-bold text-purple-400">{{ calcAvgDiff() }}</div>
    <div class="text-gray-400 text-sm">平均难度</div>
    </div>
    </div>

    <!-- Beatmaps Grid -->
    <div v-if="beatmaps.length > 0" class="grid grid-cols-1 lg:grid-cols-2 gap-4">
    <div
    v-for="(beatmap, index) in beatmaps.slice(0, 20)"
    :key="index"
    class="bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 rounded-lg p-4 hover:border-blue-500/50 transition group cursor-pointer"
    >
    <!-- Title -->
    <h3 class="text-lg font-bold text-blue-300 group-hover:text-blue-200 transition truncate">
    {{ beatmap.artist_unicode || beatmap.artist }} - {{ beatmap.title_unicode || beatmap.title }}
    </h3>
    <p class="text-sm text-gray-400 truncate">[{{ beatmap.difficulty_name }}]</p>

    <!-- Creator & File -->
    <div class="mt-3 space-y-1 text-sm text-gray-300">
    <p>👤 {{ beatmap.creator || 'Unknown' }}</p>
    <p class="truncate">📁 {{ beatmap.file_name }}</p>
    <p>🔐 {{ beatmap.hash?.substring(0, 12) }}...</p>
    </div>

    <!-- Objects -->
    <div class="mt-3 flex gap-2 text-xs font-medium">
    <span class="bg-red-500/20 text-red-300 px-2 py-1 rounded">🔴 {{ beatmap.hitcircle_count }}</span>
    <span class="bg-orange-500/20 text-orange-300 px-2 py-1 rounded">↔️ {{ beatmap.slider_count }}</span>
    <span class="bg-yellow-500/20 text-yellow-300 px-2 py-1 rounded">⚙️ {{ beatmap.spinner_count }}</span>
    </div>

    <!-- Difficulty Stats -->
    <div class="mt-3 grid grid-cols-4 gap-1 text-xs">
    <div class="bg-gray-700/50 rounded p-2 text-center">
    <div class="font-bold text-cyan-300">{{ beatmap.approach_rate.toFixed(1) }}</div>
    <div class="text-gray-400">AR</div>
    </div>
    <div class="bg-gray-700/50 rounded p-2 text-center">
    <div class="font-bold text-cyan-300">{{ beatmap.circle_size.toFixed(1) }}</div>
    <div class="text-gray-400">CS</div>
    </div>
    <div class="bg-gray-700/50 rounded p-2 text-center">
    <div class="font-bold text-cyan-300">{{ beatmap.hp_drain.toFixed(1) }}</div>
    <div class="text-gray-400">HP</div>
    </div>
    <div class="bg-gray-700/50 rounded p-2 text-center">
    <div class="font-bold text-cyan-300">{{ beatmap.overall_difficulty.toFixed(1) }}</div>
    <div class="text-gray-400">OD</div>
    </div>
    </div>

    <!-- Footer -->
    <p class="mt-3 text-xs text-gray-500">⏱️ {{ formatTime(beatmap.last_modified) }}</p>
    </div>
    </div>

    <!-- Loading State -->
    <div v-else-if="loading" class="text-center py-12">
    <div class="inline-block">
    <div class="animate-spin text-3xl mb-2"></div>
    <p class="text-gray-400">正在解析数据库...</p>
    </div>
    </div>

    <!-- Empty State -->
    <div v-else class="text-center py-12 text-gray-400">
    <div class="text-5xl mb-4">📂</div>
    <p>选择并加载您的 osu!.db 文件开始</p>
    </div>

    <!-- Footer -->
    <div v-if="beatmaps.length > 0" class="text-center text-sm text-gray-500 pt-4 border-t border-gray-700">
    显示前 20 个谱面
    </div>
    </div>
    </div>
    </template>

    <script setup lang="ts">
    import { ref } from 'vue';
    import { OsuDB } from '@osynicite/osynic-osudb';

    const fileData = ref<Uint8Array | null>(null);
    const beatmaps = ref<any[]>([]);
    const loading = ref(false);
    const error = ref('');

    const handleFileUpload = (event: Event) => {
    const file = (event.target as HTMLInputElement).files?.[0];
    if (file) {
    const reader = new FileReader();
    reader.onload = (e) => {
    fileData.value = new Uint8Array(e.target?.result as ArrayBuffer);
    error.value = '';
    };
    reader.readAsArrayBuffer(file);
    }
    };

    const loadDatabase = () => {
    if (!fileData.value) return;

    loading.value = true;
    error.value = '';
    try {
    const osudb = OsuDB.from_bytes(fileData.value);
    beatmaps.value = osudb.beatmaps || [];
    } catch (err: any) {
    error.value = `解析失败: ${err.message}`;
    beatmaps.value = [];
    } finally {
    loading.value = false;
    }
    };

    const formatTime = (timestamp: number) => {
    return new Date(timestamp).toLocaleString('zh-CN');
    };

    const calcTotalLength = () => {
    const seconds = beatmaps.value.reduce((sum, b) => sum + parseInt(b.total_length || 0), 0);
    const hours = Math.floor(seconds / 3600);
    const mins = Math.floor((seconds % 3600) / 60);
    return `${hours}h ${mins}m`;
    };

    const calcAvgDiff = () => {
    if (!beatmaps.value.length) return '0';
    const avg = beatmaps.value.reduce((sum, b) => sum + parseFloat(b.overall_difficulty || 0), 0) / beatmaps.value.length;
    return avg.toFixed(2);
    };
    </script>

    📖 使用指南

    import { OsuDB } from '@osynicite/osynic-osudb';
    import fs from 'fs';

    const data = fs.readFileSync('osu!.db');
    const db = OsuDB.from_bytes(new Uint8Array(data));

    console.log(`总谱面数: ${db.beatmaps.length}`);
    db.beatmaps.forEach(beatmap => {
    console.log(`${beatmap.artist} - ${beatmap.title} [${beatmap.difficulty_name}]`);
    console.log(` ★${beatmap.overall_difficulty.toFixed(2)}`);
    });
    import { CollectionDB } from '@osynicite/osynic-osudb';
    import fs from 'fs';

    const data = fs.readFileSync('collection.db');
    const db = CollectionDB.from_bytes(new Uint8Array(data));

    db.collections.forEach(collection => {
    console.log(`📁 ${collection.name}: ${collection.beatmap_hashes.length} 个谱面`);
    });
    import { ScoresDB } from '@osynicite/osynic-osudb';
    import fs from 'fs';

    const data = fs.readFileSync('scores.db');
    const db = ScoresDB.from_bytes(new Uint8Array(data));

    for (const [hash, scores] of Object.entries(db.scores)) {
    console.log(`谱面 ${hash}${scores.length} 个成绩`);
    scores.forEach((score, idx) => {
    console.log(` ${idx + 1}. ${score.player_name}: ${score.score} 分`);
    });
    }

    ⚠️ 特别注意

    现代浏览器均支持 WebAssembly,但请确保:

    • ✅ Chrome 57+
    • ✅ Firefox 52+
    • ✅ Safari 11+
    • ✅ Edge 79+
    • ✅ Node.js 8+

    处理大文件时可能遇到内存问题。建议:

    • 对于超过 100MB 的文件,考虑流式处理或分块读取
    • 在生产环境中,建议在 Web Worker 中处理文件解析

    A: 是的,完全支持。只需确保已安装 @osynicite/osynic-osudb 包并正确配置构建工具。

    A: 目前库主要支持读取操作。写入功能暂未绑定 WASM。

    A: WASM 版本比纯 JavaScript 实现快 10-100 倍,取决于具体操作。对于大多数数据库文件,解析时间在 100ms 以内。

    A: 使用 try-catch 捕获异常:

    try {
    const db = OsuDB.from_bytes(data);
    } catch (error) {
    console.error('Parse error:', error.message);
    }

    🤝 贡献指南

    欢迎提交 PR 或 Issue!如果您发现任何问题或有改进建议,请遵循以下规则:

    1. Fork 项目 - 在 GitHub 上 fork 该项目
    2. 创建分支 - git checkout -b feature/your-feature
    3. 提交更改 - git commit -am 'Add your feature'
    4. 推送到分支 - git push origin feature/your-feature
    5. 提交 Pull Request - 创建一个新的 Pull Request
    # 从项目根目录
    cd wasm

    # 构建 WASM 库
    wasm-pack build --release --target bundler --out-dir pkg --scope osynicite
    # 安装依赖
    npm install

    # 运行测试
    npm run test
    • 遵循 Rust 官方编码规范
    • 新增功能需附带测试用例
    • 提交前运行 cargo fmtcargo clippy
    • 更新相关文档和示例

    ❤️ 鸣谢

    本项目基于 osu-db 库重构,进行了架构优化、性能提升和版本兼容性改进。

    感谢 osu-db 的作者们!

    osu-db 项目基于 Unlicense,项目证书放置在 licenses/ 目录。

    📜 开源协议

    本项目基于 MIT License 开源,请尊重原作者的著作权。