dbc editor vscode extentsion

This commit is contained in:
2026-08-23 00:35:32 +04:00
parent 8b13588294
commit 8340b2c84f
23 changed files with 6735 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.vsix
+9
View File
@@ -0,0 +1,9 @@
.github/**
.vscode/**
.gitignore
scripts/**
src/**
test/**
tsconfig.json
esbuild.mjs
package-lock.json
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2026 MoonWell contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+55
View File
@@ -0,0 +1,55 @@
# MoonWell DBC Editor
A VS Code Custom Editor for World of Warcraft 3.3.5a `WDBC` files, targeting client
build `12340`.
## Features
- Opens `.dbc` files as a paged table instead of loading the whole grid into the Webview.
- Includes 246 build-specific schemas with named and typed columns.
- Edits signed/unsigned integers, floats, UTF-8 strings, arrays and localized strings.
- Supports add, duplicate and delete row operations.
- Integrates with VS Code undo/redo, Save, Save As, Revert and hot-exit backups.
- Rebuilds and deduplicates the WDBC string block when saving.
- Creates `<file>.dbc.bak` before an in-place save by default.
- Falls back to raw 32-bit columns when a schema is missing or its record size differs.
The editor intentionally supports `WDBC` only. Later `DB2`, `WDB` and cache formats are
outside the current build-12340 scope.
## Development
```powershell
cd vscode-dbc-editor
npm install
npm run check
```
Open this directory in VS Code and press `F5` to launch an Extension Development Host.
Open any `.dbc` file there; the custom editor is registered as the default editor.
To produce an installable VSIX:
```powershell
npm run package
code --install-extension .\moonwell-dbc-editor-0.1.0.vsix
```
## Editing notes
- Click a row number to select it for duplicate/delete operations.
- Cell values can be edited inline. The lower text area is useful for long strings;
press `Ctrl+Enter` or click **Apply cell**.
- Search is performed on the ID plus ordinary, `enUS`, and `ruRU` string columns.
- An unknown or structurally different table opens in raw mode to avoid applying the
wrong string offsets or field types.
- ID edits are rejected when they would create a duplicate key.
## Schema provenance
The schema bundle is derived from WoWDBDefs under CC BY-SA 4.0. See
`THIRD_PARTY_NOTICES.md` and `schemas/README.md`.
WDBXEditor was used as a behavioral reference only. Its source and XML definitions are
not redistributed here.
+20
View File
@@ -0,0 +1,20 @@
# Third-party notices
## WoWDBDefs
The build `3.3.5.12340` schema bundle in `schemas/12340.json` is derived from
[WoWDBDefs](https://github.com/wowdev/WoWDBDefs) by the WoWDBDefs contributors.
WoWDBDefs definition data is licensed under
[Creative Commons Attribution-ShareAlike 4.0 International](https://creativecommons.org/licenses/by-sa/4.0/).
The source commit used to generate the bundle is recorded inside `schemas/12340.json`.
The transformation retains only the build 12340 layouts and expands arrays and localized
strings into physical WDBC columns. The resulting schema data remains available under
CC BY-SA 4.0.
## WDBXEditor
[WDBXEditor](https://github.com/WowDevTools/WDBXEditor) was used as a behavioral
reference for the definition-driven editor workflow and WDBC string-table handling.
No WDBXEditor source code or definition files are included in this extension.
+22
View File
@@ -0,0 +1,22 @@
import * as esbuild from "esbuild";
const watch = process.argv.includes("--watch");
const options = {
entryPoints: ["src/extension.ts"],
bundle: true,
outfile: "dist/extension.js",
external: ["vscode"],
format: "cjs",
platform: "node",
target: "node20",
sourcemap: true,
logLevel: "info"
};
if (watch) {
const context = await esbuild.context(options);
await context.watch();
} else {
await esbuild.build(options);
}
+210
View File
@@ -0,0 +1,210 @@
:root {
color-scheme: light dark;
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
color: var(--vscode-editor-foreground);
background: var(--vscode-editor-background);
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
}
body {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
}
button,
input,
textarea {
color: var(--vscode-input-foreground);
background: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border, transparent);
font: inherit;
}
button {
min-height: 26px;
padding: 3px 10px;
color: var(--vscode-button-foreground);
background: var(--vscode-button-background);
border: none;
cursor: pointer;
}
button:hover:not(:disabled) {
background: var(--vscode-button-hoverBackground);
}
button:disabled {
cursor: default;
opacity: 0.5;
}
.toolbar {
padding: 8px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.file-info,
.actions {
display: flex;
align-items: center;
gap: 8px;
}
.file-info {
margin-bottom: 7px;
}
#file-meta,
#row-count {
color: var(--vscode-descriptionForeground);
}
#search {
width: min(420px, 35vw);
min-height: 26px;
padding: 3px 7px;
}
.separator {
align-self: stretch;
border-left: 1px solid var(--vscode-panel-border);
}
.warning {
margin-top: 7px;
padding: 6px 8px;
color: var(--vscode-inputValidation-warningForeground);
background: var(--vscode-inputValidation-warningBackground);
border: 1px solid var(--vscode-inputValidation-warningBorder);
}
main {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
min-height: 0;
}
.grid {
overflow: auto;
}
table {
border-collapse: separate;
border-spacing: 0;
min-width: 100%;
}
th,
td {
height: 29px;
padding: 0;
border-right: 1px solid var(--vscode-panel-border);
border-bottom: 1px solid var(--vscode-panel-border);
white-space: nowrap;
}
thead th {
position: sticky;
top: 0;
z-index: 2;
height: 30px;
padding: 0 7px;
text-align: left;
color: var(--vscode-editorGroupHeader-tabsBackground);
background: var(--vscode-editorGroupHeader-tabsBackground);
color: var(--vscode-editor-foreground);
}
.row-number {
position: sticky;
left: 0;
z-index: 1;
min-width: 58px;
padding: 0 7px;
text-align: right;
color: var(--vscode-descriptionForeground);
background: var(--vscode-editorGutter-background);
cursor: pointer;
}
thead .row-number {
z-index: 3;
}
tbody tr.selected .row-number,
tbody tr.selected td {
background: var(--vscode-list-activeSelectionBackground);
}
td input {
width: 150px;
height: 28px;
padding: 3px 6px;
background: transparent;
border: 1px solid transparent;
}
td input:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
background: var(--vscode-input-background);
}
.id-column {
color: var(--vscode-symbolIcon-keyForeground, var(--vscode-editor-foreground));
}
.cell-editor {
display: grid;
grid-template-columns: minmax(180px, 1fr) auto;
gap: 6px;
padding: 7px 8px;
border-top: 1px solid var(--vscode-panel-border);
}
.cell-editor label {
grid-column: 1 / -1;
color: var(--vscode-descriptionForeground);
}
.cell-editor textarea {
min-height: 52px;
padding: 5px 7px;
resize: vertical;
}
footer {
display: flex;
align-items: center;
gap: 10px;
min-height: 38px;
padding: 6px 8px;
border-top: 1px solid var(--vscode-panel-border);
}
#page-number {
width: 64px;
padding: 3px 5px;
}
#status {
margin-left: auto;
color: var(--vscode-descriptionForeground);
}
#status.error {
color: var(--vscode-errorForeground);
}
+201
View File
@@ -0,0 +1,201 @@
const vscode = acquireVsCodeApi();
const state = {
columns: [],
page: 0,
totalPages: 1,
query: "",
rowIndices: [],
rows: [],
selectedRow: null,
selectedCell: null
};
const elements = {
fileName: document.getElementById("file-name"),
fileMeta: document.getElementById("file-meta"),
warning: document.getElementById("warning"),
search: document.getElementById("search"),
grid: document.getElementById("grid"),
previous: document.getElementById("previous"),
next: document.getElementById("next"),
pageNumber: document.getElementById("page-number"),
pageCount: document.getElementById("page-count"),
rowCount: document.getElementById("row-count"),
status: document.getElementById("status"),
duplicateRow: document.getElementById("duplicate-row"),
deleteRow: document.getElementById("delete-row"),
cellLabel: document.getElementById("cell-label"),
cellValue: document.getElementById("cell-value"),
applyCell: document.getElementById("apply-cell")
};
document.getElementById("find").addEventListener("click", () => requestPage(0, elements.search.value));
document.getElementById("clear-search").addEventListener("click", () => {
elements.search.value = "";
requestPage(0, "");
});
elements.search.addEventListener("keydown", (event) => {
if (event.key === "Enter") requestPage(0, elements.search.value);
});
elements.previous.addEventListener("click", () => requestPage(state.page - 1, state.query));
elements.next.addEventListener("click", () => requestPage(state.page + 1, state.query));
elements.pageNumber.addEventListener("change", () => {
requestPage(Number(elements.pageNumber.value) - 1, state.query);
});
document.getElementById("add-row").addEventListener("click", () => vscode.postMessage({ type: "addRow" }));
elements.duplicateRow.addEventListener("click", () => {
if (state.selectedRow !== null) vscode.postMessage({ type: "duplicateRow", rowIndex: state.selectedRow });
});
elements.deleteRow.addEventListener("click", () => {
if (state.selectedRow !== null && window.confirm(`Delete row ${state.selectedRow + 1}?`)) {
vscode.postMessage({ type: "deleteRow", rowIndex: state.selectedRow });
}
});
elements.applyCell.addEventListener("click", applyInspectorValue);
elements.cellValue.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") applyInspectorValue();
});
window.addEventListener("message", (event) => {
const message = event.data;
if (message.type === "init") {
state.columns = message.columns;
elements.fileName.textContent = message.fileName;
elements.fileMeta.textContent = `${message.schemaName} · WDBC · ${message.header.recordCount.toLocaleString()} rows · ${message.header.fieldCount} fields · ${message.schemaCount} schemas`;
if (message.warning) {
elements.warning.hidden = false;
elements.warning.textContent = message.warning;
}
} else if (message.type === "page") {
Object.assign(state, {
page: message.page,
totalPages: message.totalPages,
query: message.query,
rowIndices: message.rowIndices,
rows: message.rows,
selectedRow: null,
selectedCell: null
});
renderGrid();
renderFooter(message);
clearInspector();
} else if (message.type === "error") {
elements.status.textContent = message.message;
elements.status.className = "error";
if (
state.selectedCell &&
state.selectedCell.rowIndex === message.rowIndex &&
state.selectedCell.columnIndex === message.columnIndex &&
message.currentValue !== undefined
) {
state.selectedCell.input.value = message.currentValue;
elements.cellValue.value = message.currentValue;
}
}
});
function requestPage(page, query) {
elements.status.textContent = "Loading…";
elements.status.className = "";
vscode.postMessage({ type: "page", page, query });
}
function renderGrid() {
const table = document.createElement("table");
const head = document.createElement("thead");
const headRow = document.createElement("tr");
const rowHeader = document.createElement("th");
rowHeader.textContent = "#";
rowHeader.className = "row-number";
headRow.append(rowHeader);
state.columns.forEach((column) => {
const th = document.createElement("th");
th.textContent = column.name;
th.title = `${column.kind}${column.bits}${column.isId ? " · ID" : ""}${column.locale ? ` · ${column.locale}` : ""}`;
if (column.isId) th.classList.add("id-column");
headRow.append(th);
});
head.append(headRow);
table.append(head);
const body = document.createElement("tbody");
state.rows.forEach((row, pageRowIndex) => {
const absoluteRowIndex = state.rowIndices[pageRowIndex];
const tr = document.createElement("tr");
tr.dataset.row = String(absoluteRowIndex);
const selector = document.createElement("th");
selector.className = "row-number";
selector.textContent = String(absoluteRowIndex + 1);
selector.addEventListener("click", () => selectRow(absoluteRowIndex, tr));
tr.append(selector);
row.forEach((value, columnIndex) => {
const td = document.createElement("td");
const input = document.createElement("input");
input.type = "text";
input.value = value;
input.title = value;
input.setAttribute("aria-label", `${state.columns[columnIndex].name}, row ${absoluteRowIndex + 1}`);
input.addEventListener("focus", () => selectCell(absoluteRowIndex, columnIndex, input));
input.addEventListener("change", () => editCell(absoluteRowIndex, columnIndex, input.value));
td.append(input);
tr.append(td);
});
body.append(tr);
});
table.append(body);
elements.grid.replaceChildren(table);
}
function renderFooter(message) {
elements.pageNumber.value = String(message.page + 1);
elements.pageNumber.max = String(message.totalPages);
elements.pageCount.textContent = String(message.totalPages);
elements.previous.disabled = message.page <= 0;
elements.next.disabled = message.page >= message.totalPages - 1;
elements.rowCount.textContent = `${message.totalRows.toLocaleString()} matching rows`;
elements.status.textContent = "";
elements.status.className = "";
}
function selectRow(rowIndex, rowElement) {
state.selectedRow = rowIndex;
document.querySelectorAll("tbody tr.selected").forEach((row) => row.classList.remove("selected"));
rowElement.classList.add("selected");
elements.duplicateRow.disabled = false;
elements.deleteRow.disabled = false;
}
function selectCell(rowIndex, columnIndex, input) {
state.selectedCell = { rowIndex, columnIndex, input };
state.selectedRow = rowIndex;
elements.cellLabel.textContent = `${state.columns[columnIndex].name} · row ${rowIndex + 1} · ${state.columns[columnIndex].kind}${state.columns[columnIndex].bits}`;
elements.cellValue.value = input.value;
elements.cellValue.disabled = false;
elements.applyCell.disabled = false;
}
function clearInspector() {
elements.cellLabel.textContent = "Select a cell";
elements.cellValue.value = "";
elements.cellValue.disabled = true;
elements.applyCell.disabled = true;
elements.duplicateRow.disabled = true;
elements.deleteRow.disabled = true;
}
function applyInspectorValue() {
if (!state.selectedCell) return;
state.selectedCell.input.value = elements.cellValue.value;
editCell(state.selectedCell.rowIndex, state.selectedCell.columnIndex, elements.cellValue.value);
}
function editCell(rowIndex, columnIndex, value) {
elements.status.textContent = "Applying edit…";
elements.status.className = "";
vscode.postMessage({ type: "edit", rowIndex, columnIndex, value });
}
vscode.postMessage({ type: "ready" });
+4869
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
{
"name": "moonwell-dbc-editor",
"displayName": "MoonWell DBC Editor",
"description": "Table editor for World of Warcraft 3.3.5a (build 12340) WDBC files.",
"version": "0.1.0",
"publisher": "moonwell",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://git.moon-well.online/sindoring/moonwell-client.git",
"directory": "vscode-dbc-editor"
},
"engines": {
"vscode": "^1.90.0"
},
"categories": [
"Other"
],
"keywords": [
"wow",
"wotlk",
"dbc",
"wdbc"
],
"activationEvents": [
"onCustomEditor:moonwell.dbcEditor"
],
"main": "./dist/extension.js",
"contributes": {
"customEditors": [
{
"viewType": "moonwell.dbcEditor",
"displayName": "MoonWell DBC Editor",
"selector": [
{
"filenamePattern": "*.dbc"
}
],
"priority": "default"
}
],
"configuration": {
"title": "MoonWell DBC Editor",
"properties": {
"moonwellDbc.pageSize": {
"type": "number",
"default": 100,
"minimum": 25,
"maximum": 500,
"description": "Number of DBC records shown on one editor page."
},
"moonwellDbc.createBackupOnSave": {
"type": "boolean",
"default": true,
"description": "Copy the previous file contents to <name>.dbc.bak before saving."
}
}
}
},
"scripts": {
"compile": "node esbuild.mjs",
"watch": "node esbuild.mjs --watch",
"typecheck": "tsc --noEmit",
"test": "tsx --test test/**/*.test.ts",
"check": "npm run typecheck && npm test && npm run compile",
"package": "npm run check && vsce package",
"schemas": "node scripts/build-schema-bundle.mjs"
},
"devDependencies": {
"@types/node": "^22.15.0",
"@types/vscode": "^1.90.0",
"@vscode/vsce": "^3.6.0",
"esbuild": "^0.25.0",
"tsx": "^4.20.0",
"typescript": "^5.8.0"
}
}
File diff suppressed because one or more lines are too long
+12
View File
@@ -0,0 +1,12 @@
# Build 12340 schemas
`12340.json` is a build-specific transformation of the WoWDBDefs database definitions.
- Source: https://github.com/wowdev/WoWDBDefs
- Data license: Creative Commons Attribution-ShareAlike 4.0 International
- Transformation: only the definition active for `3.3.5.12340` is retained; arrays and
pre-Cataclysm localized strings are expanded to physical WDBC columns.
The schema data in this directory is distributed under CC BY-SA 4.0:
https://creativecommons.org/licenses/by-sa/4.0/
@@ -0,0 +1,121 @@
import fs from "node:fs";
import path from "node:path";
const inputDirectory = process.argv[2];
const outputFile = process.argv[3] ?? path.resolve("schemas", "12340.json");
const sourceCommit = process.argv[4];
if (!inputDirectory) {
throw new Error(
"Usage: node scripts/build-schema-bundle.mjs <DBDefsConverter JSON directory> [output file] [source commit]"
);
}
const target = { expansion: 3, major: 3, minor: 5, build: 12340 };
const locales = [
"enUS",
"enGB",
"koKR",
"frFR",
"deDE",
"enCN",
"zhCN",
"enTW",
"zhTW",
"esES",
"esMX",
"ruRU",
"ptPT",
"ptBR",
"itIT",
"Unk"
];
const bundle = {
build: 12340,
version: "3.3.5.12340",
source: {
project: "WoWDBDefs",
url: "https://github.com/wowdev/WoWDBDefs",
license: "CC BY-SA 4.0",
...(sourceCommit ? { commit: sourceCommit } : {})
},
tables: {}
};
for (const fileName of fs.readdirSync(inputDirectory).filter((name) => name.endsWith(".json"))) {
const definition = JSON.parse(fs.readFileSync(path.join(inputDirectory, fileName), "utf8"));
const version = definition.versionDefinitions.find((entry) => matches(entry, target));
if (!version) continue;
const tableName = path.basename(fileName, ".json");
const columns = [];
let offset = 0;
for (const field of version.definitions) {
if (field.isNonInline) continue;
const columnDefinition = definition.columnDefinitions[field.name];
if (!columnDefinition) {
throw new Error(`${tableName}: missing column definition '${field.name}'.`);
}
const arrayLength = field.arrLength || 1;
for (let arrayIndex = 0; arrayIndex < arrayLength; arrayIndex += 1) {
const baseName = arrayLength > 1 ? `${field.name}_${arrayIndex + 1}` : field.name;
if (columnDefinition.type === "locstring") {
for (const locale of locales) {
columns.push({ name: `${baseName}_${locale}`, kind: "string", bits: 32, offset, locale });
offset += 4;
}
columns.push({ name: `${baseName}_Mask`, kind: "uint", bits: 32, offset });
offset += 4;
continue;
}
const column = makeColumn(baseName, columnDefinition.type, field, offset);
if (field.isID) column.isId = true;
columns.push(column);
offset += column.bits / 8;
}
}
bundle.tables[tableName.toLowerCase()] = {
name: tableName,
build: 12340,
recordSize: offset,
columns,
source: "WoWDBDefs"
};
}
fs.mkdirSync(path.dirname(outputFile), { recursive: true });
fs.writeFileSync(outputFile, `${JSON.stringify(bundle)}\n`);
process.stdout.write(`Wrote ${Object.keys(bundle.tables).length} schemas to ${outputFile}\n`);
function makeColumn(name, type, field, offset) {
if (type === "string") return { name, kind: "string", bits: 32, offset };
if (type === "float") return { name, kind: "float", bits: 32, offset };
if (type !== "int") throw new Error(`Unsupported DBD type '${type}' for '${name}'.`);
const bits = field.size || 32;
if (![8, 16, 32, 64].includes(bits)) {
throw new Error(`Unsupported integer size ${bits} for '${name}'.`);
}
return { name, kind: field.isSigned ? "int" : "uint", bits, offset };
}
function matches(version, build) {
if (version.builds.some((candidate) => compareBuilds(candidate, build) === 0)) return true;
return version.buildRanges.some(
(range) => compareBuilds(range.minBuild, build) <= 0 && compareBuilds(build, range.maxBuild) <= 0
);
}
function compareBuilds(left, right) {
for (const key of ["expansion", "major", "minor", "build"]) {
const difference = left[key] - right[key];
if (difference !== 0) return difference;
}
return 0;
}
+179
View File
@@ -0,0 +1,179 @@
import { DbcColumn, WdbcHeader } from "./types";
const HEADER_SIZE = 20;
const textDecoder = new TextDecoder("utf-8");
const textEncoder = new TextEncoder();
export function parseHeader(bytes: Uint8Array): WdbcHeader {
if (bytes.byteLength < HEADER_SIZE) {
throw new Error("The file is too small to contain a WDBC header.");
}
const magic = textDecoder.decode(bytes.subarray(0, 4));
if (magic !== "WDBC") {
throw new Error(`Unsupported DBC signature '${magic}'. Only WDBC files are supported.`);
}
const view = dataView(bytes);
const header: WdbcHeader = {
magic: "WDBC",
recordCount: view.getUint32(4, true),
fieldCount: view.getUint32(8, true),
recordSize: view.getUint32(12, true),
stringBlockSize: view.getUint32(16, true)
};
const recordsEnd = HEADER_SIZE + header.recordCount * header.recordSize;
const expectedSize = recordsEnd + header.stringBlockSize;
if (!Number.isSafeInteger(recordsEnd) || expectedSize !== bytes.byteLength) {
throw new Error(
`Invalid WDBC size: header expects ${expectedSize} bytes, file contains ${bytes.byteLength}.`
);
}
if (header.recordSize === 0 && header.recordCount > 0) {
throw new Error("Invalid WDBC header: record size is zero.");
}
return header;
}
export function readCell(
bytes: Uint8Array,
header: WdbcHeader,
originalRow: number,
column: DbcColumn
): string {
const recordOffset = HEADER_SIZE + originalRow * header.recordSize + column.offset;
const view = dataView(bytes);
if (column.kind === "string") {
const stringOffset = view.getUint32(recordOffset, true);
return readString(bytes, header, stringOffset);
}
if (column.kind === "float") {
const value = view.getFloat32(recordOffset, true);
return Object.is(value, -0) ? "-0" : String(value);
}
if (column.bits === 64) {
return column.kind === "int"
? view.getBigInt64(recordOffset, true).toString()
: view.getBigUint64(recordOffset, true).toString();
}
if (column.kind === "int") {
if (column.bits === 8) return String(view.getInt8(recordOffset));
if (column.bits === 16) return String(view.getInt16(recordOffset, true));
return String(view.getInt32(recordOffset, true));
}
if (column.bits === 8) return String(view.getUint8(recordOffset));
if (column.bits === 16) return String(view.getUint16(recordOffset, true));
return String(view.getUint32(recordOffset, true));
}
export function normalizeCellInput(column: DbcColumn, rawValue: unknown): string {
const value = String(rawValue ?? "");
if (column.kind === "string") {
if (value.includes("\0")) throw new Error("DBC strings cannot contain a NUL character.");
return value;
}
if (column.kind === "float") {
const normalized = value.trim();
if (["NaN", "Infinity", "+Infinity", "-Infinity"].includes(normalized)) {
return normalized === "+Infinity" ? "Infinity" : normalized;
}
const parsed = Number(normalized);
if (!Number.isFinite(parsed)) {
throw new Error(`'${value}' is not a valid 32-bit floating-point value.`);
}
const float32 = Math.fround(parsed);
if (!Number.isFinite(float32)) {
throw new Error(`'${value}' is outside the finite 32-bit floating-point range.`);
}
return Object.is(float32, -0) ? "-0" : String(float32);
}
const normalized = value.trim();
if (!/^[+-]?\d+$/.test(normalized)) {
throw new Error(`'${value}' is not a valid integer.`);
}
const parsed = BigInt(normalized);
const bits = BigInt(column.bits);
const min = column.kind === "int" ? -(1n << (bits - 1n)) : 0n;
const max = column.kind === "int" ? (1n << (bits - 1n)) - 1n : (1n << bits) - 1n;
if (parsed < min || parsed > max) {
throw new Error(`Value must be between ${min} and ${max}.`);
}
return parsed.toString();
}
export function writeCell(
target: Uint8Array,
absoluteOffset: number,
column: DbcColumn,
value: string
): void {
if (column.kind === "string") {
throw new Error("String columns must be written as string-table offsets.");
}
const view = dataView(target);
if (column.kind === "float") {
view.setFloat32(absoluteOffset, Number(value), true);
return;
}
const integer = BigInt(value);
if (column.bits === 64) {
if (column.kind === "int") view.setBigInt64(absoluteOffset, integer, true);
else view.setBigUint64(absoluteOffset, integer, true);
return;
}
const numeric = Number(integer);
if (column.kind === "int") {
if (column.bits === 8) view.setInt8(absoluteOffset, numeric);
else if (column.bits === 16) view.setInt16(absoluteOffset, numeric, true);
else view.setInt32(absoluteOffset, numeric, true);
} else if (column.bits === 8) {
view.setUint8(absoluteOffset, numeric);
} else if (column.bits === 16) {
view.setUint16(absoluteOffset, numeric, true);
} else {
view.setUint32(absoluteOffset, numeric, true);
}
}
export function writeUint32(target: Uint8Array, offset: number, value: number): void {
dataView(target).setUint32(offset, value, true);
}
export function encodeString(value: string): Uint8Array {
return textEncoder.encode(value);
}
export function headerSize(): number {
return HEADER_SIZE;
}
function readString(bytes: Uint8Array, header: WdbcHeader, offset: number): string {
if (offset >= header.stringBlockSize) {
return `<invalid string offset ${offset}>`;
}
const start = HEADER_SIZE + header.recordCount * header.recordSize + offset;
const blockEnd = start + (header.stringBlockSize - offset);
let end = start;
while (end < blockEnd && bytes[end] !== 0) end += 1;
return textDecoder.decode(bytes.subarray(start, end));
}
function dataView(bytes: Uint8Array): DataView {
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
}
+70
View File
@@ -0,0 +1,70 @@
import * as vscode from "vscode";
import { SchemaRegistry } from "./schema";
import { DbcTable } from "./table";
export class DbcDocument implements vscode.CustomDocument {
private readonly disposeEmitter = new vscode.EventEmitter<void>();
private readonly changeEmitter = new vscode.EventEmitter<void>();
public table: DbcTable;
public readonly onDidDispose = this.disposeEmitter.event;
public readonly onDidChangeContent = this.changeEmitter.event;
private constructor(
public readonly uri: vscode.Uri,
table: DbcTable,
private readonly schemas: SchemaRegistry
) {
this.table = table;
}
public static async create(
uri: vscode.Uri,
backupId: string | undefined,
schemas: SchemaRegistry
): Promise<DbcDocument> {
const sourceUri = backupId ? vscode.Uri.parse(backupId) : uri;
const bytes = await vscode.workspace.fs.readFile(sourceUri);
return new DbcDocument(uri, createTable(uri, bytes, schemas), schemas);
}
public async reload(): Promise<void> {
const bytes = await vscode.workspace.fs.readFile(this.uri);
this.table = createTable(this.uri, bytes, this.schemas);
this.notifyChanged();
}
public notifyChanged(): void {
this.changeEmitter.fire();
}
public dispose(): void {
this.disposeEmitter.fire();
this.disposeEmitter.dispose();
this.changeEmitter.dispose();
}
}
function createTable(uri: vscode.Uri, bytes: Uint8Array, schemas: SchemaRegistry): DbcTable {
const header = parseHeaderForSchema(bytes);
const resolved = schemas.resolve(uri.path, header);
return new DbcTable(bytes, resolved.schema, resolved.warning);
}
function parseHeaderForSchema(bytes: Uint8Array) {
// Kept local to avoid exposing a partially initialized document if parsing fails.
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (bytes.byteLength < 20) throw new Error("The file is too small to contain a WDBC header.");
const magic = new TextDecoder("utf-8").decode(bytes.subarray(0, 4));
if (magic !== "WDBC") {
throw new Error(`Unsupported DBC signature '${magic}'. Only WDBC files are supported.`);
}
return {
magic: "WDBC" as const,
recordCount: view.getUint32(4, true),
fieldCount: view.getUint32(8, true),
recordSize: view.getUint32(12, true),
stringBlockSize: view.getUint32(16, true)
};
}
+55
View File
@@ -0,0 +1,55 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { DbcColumn, DbcSchema, DbcSchemaBundle, WdbcHeader } from "./types";
export class SchemaRegistry {
private readonly bundle: DbcSchemaBundle;
public constructor(extensionPath: string) {
const schemaPath = path.join(extensionPath, "schemas", "12340.json");
this.bundle = JSON.parse(fs.readFileSync(schemaPath, "utf8")) as DbcSchemaBundle;
}
public resolve(fileName: string, header: WdbcHeader): { schema: DbcSchema; warning?: string } {
const tableName = path.basename(fileName, path.extname(fileName));
const known = this.bundle.tables[tableName.toLowerCase()];
if (known && known.recordSize === header.recordSize) {
return { schema: known };
}
const warning = known
? `Schema '${known.name}' expects ${known.recordSize} bytes per record, but this file uses ${header.recordSize}. Raw columns are shown to prevent data corruption.`
: `No build 12340 schema was found for '${tableName}'. Raw 32-bit columns are shown.`;
return { schema: rawSchema(tableName, header.recordSize), warning };
}
public get tableCount(): number {
return Object.keys(this.bundle.tables).length;
}
}
export function rawSchema(name: string, recordSize: number): DbcSchema {
const columns: DbcColumn[] = [];
const fullFields = Math.floor(recordSize / 4);
for (let index = 0; index < fullFields; index += 1) {
columns.push({
name: `Field_${String(index).padStart(3, "0")}`,
kind: "uint",
bits: 32,
offset: index * 4
});
}
for (let offset = fullFields * 4; offset < recordSize; offset += 1) {
columns.push({
name: `Byte_${String(offset).padStart(3, "0")}`,
kind: "uint",
bits: 8,
offset
});
}
return { name, build: 12340, recordSize, columns, source: "raw fallback" };
}
+232
View File
@@ -0,0 +1,232 @@
import {
encodeString,
headerSize,
normalizeCellInput,
parseHeader,
readCell,
writeCell,
writeUint32
} from "./codec";
import { DbcColumn, DbcSchema, PageData, RowRef, WdbcHeader } from "./types";
export class DbcTable {
public readonly header: WdbcHeader;
public readonly schema: DbcSchema;
public readonly warning?: string;
public readonly rows: RowRef[];
private readonly source: Uint8Array;
public constructor(source: Uint8Array, schema: DbcSchema, warning?: string) {
this.header = parseHeader(source);
if (schema.recordSize !== this.header.recordSize) {
throw new Error(
`Schema record size ${schema.recordSize} does not match WDBC record size ${this.header.recordSize}.`
);
}
this.source = source;
this.schema = schema;
this.warning = warning;
this.rows = Array.from({ length: this.header.recordCount }, (_, originalIndex) => ({
originalIndex,
edits: new Map<number, string>()
}));
}
public getCell(rowIndex: number, columnIndex: number): string {
const row = this.requireRow(rowIndex);
const column = this.requireColumn(columnIndex);
const edited = row.edits.get(columnIndex);
if (edited !== undefined) return edited;
if (row.originalIndex === null) return defaultValue(column);
return readCell(this.source, this.header, row.originalIndex, column);
}
public setCell(rowIndex: number, columnIndex: number, rawValue: unknown): { oldValue: string; value: string } {
const row = this.requireRow(rowIndex);
const column = this.requireColumn(columnIndex);
const oldValue = this.getCell(rowIndex, columnIndex);
const value = normalizeCellInput(column, rawValue);
if (column.isId && value !== oldValue) this.assertUniqueId(rowIndex, columnIndex, value);
const baseValue =
row.originalIndex === null
? defaultValue(column)
: readCell(this.source, this.header, row.originalIndex, column);
if (value === baseValue) row.edits.delete(columnIndex);
else row.edits.set(columnIndex, value);
return { oldValue, value };
}
public appendRow(): number {
const row: RowRef = { originalIndex: null, edits: new Map<number, string>() };
const idColumn = this.schema.columns.findIndex((column) => column.isId);
if (idColumn >= 0) {
const column = this.schema.columns[idColumn]!;
row.edits.set(idColumn, normalizeCellInput(column, this.nextId(idColumn)));
}
this.rows.push(row);
return this.rows.length - 1;
}
public duplicateRow(rowIndex: number): number {
this.requireRow(rowIndex);
const duplicate: RowRef = { originalIndex: null, edits: new Map<number, string>() };
for (let columnIndex = 0; columnIndex < this.schema.columns.length; columnIndex += 1) {
duplicate.edits.set(columnIndex, this.getCell(rowIndex, columnIndex));
}
const idColumn = this.schema.columns.findIndex((column) => column.isId);
if (idColumn >= 0) {
const column = this.schema.columns[idColumn]!;
duplicate.edits.set(idColumn, normalizeCellInput(column, this.nextId(idColumn)));
}
this.rows.splice(rowIndex + 1, 0, duplicate);
return rowIndex + 1;
}
public deleteRow(rowIndex: number): RowRef {
const [removed] = this.rows.splice(rowIndex, 1);
if (!removed) throw new Error(`Row ${rowIndex + 1} does not exist.`);
return removed;
}
public insertRow(rowIndex: number, row: RowRef): void {
if (rowIndex < 0 || rowIndex > this.rows.length) {
throw new Error(`Cannot insert row at index ${rowIndex}.`);
}
this.rows.splice(rowIndex, 0, row);
}
public getPage(page: number, pageSize: number, query = ""): PageData {
const safePageSize = Math.max(1, Math.min(500, Math.trunc(pageSize)));
const matchingRows = query.trim() ? this.search(query) : undefined;
const totalRows = matchingRows?.length ?? this.rows.length;
const totalPages = Math.max(1, Math.ceil(totalRows / safePageSize));
const safePage = Math.max(0, Math.min(Math.trunc(page), totalPages - 1));
const start = safePage * safePageSize;
const rowIndices = matchingRows
? matchingRows.slice(start, start + safePageSize)
: Array.from(
{ length: Math.max(0, Math.min(safePageSize, totalRows - start)) },
(_, index) => start + index
);
return {
page: safePage,
pageSize: safePageSize,
totalRows,
totalPages,
rowIndices,
rows: rowIndices.map((rowIndex) =>
this.schema.columns.map((_, columnIndex) => this.getCell(rowIndex, columnIndex))
)
};
}
public serialize(): Uint8Array {
const recordBytes = Buffer.alloc(this.rows.length * this.header.recordSize);
const stringParts: Buffer[] = [Buffer.from([0])];
const stringOffsets = new Map<string, number>([["", 0]]);
let stringBlockSize = 1;
const recordsStart = headerSize();
for (let rowIndex = 0; rowIndex < this.rows.length; rowIndex += 1) {
const row = this.rows[rowIndex]!;
const targetRecordOffset = rowIndex * this.header.recordSize;
if (row.originalIndex !== null) {
const sourceStart = recordsStart + row.originalIndex * this.header.recordSize;
recordBytes.set(
this.source.subarray(sourceStart, sourceStart + this.header.recordSize),
targetRecordOffset
);
}
for (let columnIndex = 0; columnIndex < this.schema.columns.length; columnIndex += 1) {
const column = this.schema.columns[columnIndex]!;
const absoluteOffset = targetRecordOffset + column.offset;
if (column.kind === "string") {
const value = this.getCell(rowIndex, columnIndex);
let stringOffset = stringOffsets.get(value);
if (stringOffset === undefined) {
const encoded = encodeString(value);
stringOffset = stringBlockSize;
stringOffsets.set(value, stringOffset);
stringParts.push(Buffer.from(encoded), Buffer.from([0]));
stringBlockSize += encoded.byteLength + 1;
}
writeUint32(recordBytes, absoluteOffset, stringOffset);
} else if (row.originalIndex === null || row.edits.has(columnIndex)) {
writeCell(recordBytes, absoluteOffset, column, this.getCell(rowIndex, columnIndex));
}
}
}
const header = Buffer.alloc(headerSize());
header.write("WDBC", 0, "ascii");
writeUint32(header, 4, this.rows.length);
writeUint32(header, 8, this.header.fieldCount);
writeUint32(header, 12, this.header.recordSize);
writeUint32(header, 16, stringBlockSize);
return Buffer.concat([header, recordBytes, ...stringParts]);
}
private search(rawQuery: string): number[] {
const query = rawQuery.toLocaleLowerCase();
let columns = this.schema.columns
.map((column, index) => ({ column, index }))
.filter(
({ column }) =>
column.isId ||
(column.kind === "string" &&
(column.locale === undefined || column.locale === "ruRU" || column.locale === "enUS"))
)
.map(({ index }) => index);
if (columns.length === 0) columns = this.schema.columns.map((_, index) => index);
const matches: number[] = [];
for (let rowIndex = 0; rowIndex < this.rows.length; rowIndex += 1) {
if (columns.some((columnIndex) => this.getCell(rowIndex, columnIndex).toLocaleLowerCase().includes(query))) {
matches.push(rowIndex);
}
}
return matches;
}
private nextId(columnIndex: number): string {
let max = -1n;
for (let rowIndex = 0; rowIndex < this.rows.length; rowIndex += 1) {
const value = BigInt(this.getCell(rowIndex, columnIndex));
if (value > max) max = value;
}
return (max + 1n).toString();
}
private assertUniqueId(rowIndex: number, columnIndex: number, value: string): void {
for (let index = 0; index < this.rows.length; index += 1) {
if (index !== rowIndex && this.getCell(index, columnIndex) === value) {
throw new Error(`ID ${value} is already used by row ${index + 1}.`);
}
}
}
private requireRow(rowIndex: number): RowRef {
const row = this.rows[rowIndex];
if (!row) throw new Error(`Row ${rowIndex + 1} does not exist.`);
return row;
}
private requireColumn(columnIndex: number): DbcColumn {
const column = this.schema.columns[columnIndex];
if (!column) throw new Error(`Column ${columnIndex + 1} does not exist.`);
return column;
}
}
function defaultValue(column: DbcColumn): string {
return column.kind === "string" ? "" : "0";
}
+68
View File
@@ -0,0 +1,68 @@
export type IntegerBits = 8 | 16 | 32 | 64;
export type DbcColumn =
| {
name: string;
kind: "int" | "uint";
bits: IntegerBits;
offset: number;
isId?: boolean;
}
| {
name: string;
kind: "float";
bits: 32;
offset: number;
isId?: boolean;
}
| {
name: string;
kind: "string";
bits: 32;
offset: number;
locale?: string;
isId?: boolean;
};
export interface DbcSchema {
name: string;
build: 12340;
recordSize: number;
columns: DbcColumn[];
source?: string;
}
export interface DbcSchemaBundle {
build: 12340;
version: "3.3.5.12340";
source: {
project: string;
url: string;
license: string;
commit?: string;
};
tables: Record<string, DbcSchema>;
}
export interface WdbcHeader {
magic: "WDBC";
recordCount: number;
fieldCount: number;
recordSize: number;
stringBlockSize: number;
}
export interface RowRef {
originalIndex: number | null;
edits: Map<number, string>;
}
export interface PageData {
page: number;
pageSize: number;
totalRows: number;
totalPages: number;
rowIndices: number[];
rows: string[][];
}
+322
View File
@@ -0,0 +1,322 @@
import * as path from "node:path";
import * as vscode from "vscode";
import { DbcDocument } from "./dbc/document";
import { RowRef } from "./dbc/types";
import { SchemaRegistry } from "./dbc/schema";
interface ViewState {
page: number;
query: string;
}
interface WebviewMessage {
type?: string;
page?: number;
query?: string;
rowIndex?: number;
columnIndex?: number;
value?: unknown;
}
export class DbcEditorProvider implements vscode.CustomEditorProvider<DbcDocument> {
public static readonly viewType = "moonwell.dbcEditor";
private readonly changeEmitter = new vscode.EventEmitter<
vscode.CustomDocumentEditEvent<DbcDocument>
>();
public readonly onDidChangeCustomDocument = this.changeEmitter.event;
private readonly schemas: SchemaRegistry;
public constructor(private readonly context: vscode.ExtensionContext) {
this.schemas = new SchemaRegistry(context.extensionPath);
}
public async openCustomDocument(
uri: vscode.Uri,
openContext: vscode.CustomDocumentOpenContext,
_token: vscode.CancellationToken
): Promise<DbcDocument> {
return DbcDocument.create(uri, openContext.backupId, this.schemas);
}
public async resolveCustomEditor(
document: DbcDocument,
webviewPanel: vscode.WebviewPanel,
_token: vscode.CancellationToken
): Promise<void> {
const mediaRoot = vscode.Uri.joinPath(this.context.extensionUri, "media");
webviewPanel.webview.options = {
enableScripts: true,
localResourceRoots: [mediaRoot]
};
webviewPanel.webview.html = this.html(webviewPanel.webview, mediaRoot);
const state: ViewState = { page: 0, query: "" };
const refresh = () => this.postPage(document, webviewPanel.webview, state);
const changeSubscription = document.onDidChangeContent(refresh);
webviewPanel.onDidDispose(() => changeSubscription.dispose());
webviewPanel.webview.onDidReceiveMessage(async (message: WebviewMessage) => {
try {
switch (message.type) {
case "ready":
await this.postInit(document, webviewPanel.webview, state);
break;
case "page":
state.page = message.page ?? 0;
state.query = message.query ?? "";
await refresh();
break;
case "edit":
await this.editCell(document, message);
break;
case "addRow":
state.query = "";
state.page = Number.MAX_SAFE_INTEGER;
this.addRow(document);
break;
case "duplicateRow":
this.duplicateRow(document, requireIndex(message.rowIndex, "row"));
break;
case "deleteRow":
this.deleteRow(document, requireIndex(message.rowIndex, "row"));
break;
}
} catch (error) {
const text = error instanceof Error ? error.message : String(error);
const rowIndex = message.rowIndex;
const columnIndex = message.columnIndex;
const currentValue =
Number.isInteger(rowIndex) &&
Number.isInteger(columnIndex) &&
rowIndex !== undefined &&
columnIndex !== undefined &&
rowIndex >= 0 &&
columnIndex >= 0 &&
rowIndex < document.table.rows.length &&
columnIndex < document.table.schema.columns.length
? document.table.getCell(rowIndex, columnIndex)
: undefined;
await webviewPanel.webview.postMessage({
type: "error",
message: text,
rowIndex,
columnIndex,
currentValue
});
}
});
}
public async saveCustomDocument(
document: DbcDocument,
_cancellation: vscode.CancellationToken
): Promise<void> {
const createBackup = vscode.workspace
.getConfiguration("moonwellDbc")
.get<boolean>("createBackupOnSave", true);
if (createBackup && document.uri.scheme === "file") {
const backupUri = document.uri.with({ path: `${document.uri.path}.bak` });
await vscode.workspace.fs.copy(document.uri, backupUri, { overwrite: true });
}
await vscode.workspace.fs.writeFile(document.uri, document.table.serialize());
}
public async saveCustomDocumentAs(
document: DbcDocument,
destination: vscode.Uri,
_cancellation: vscode.CancellationToken
): Promise<void> {
await vscode.workspace.fs.writeFile(destination, document.table.serialize());
}
public async revertCustomDocument(
document: DbcDocument,
_cancellation: vscode.CancellationToken
): Promise<void> {
await document.reload();
}
public async backupCustomDocument(
document: DbcDocument,
context: vscode.CustomDocumentBackupContext,
_cancellation: vscode.CancellationToken
): Promise<vscode.CustomDocumentBackup> {
await vscode.workspace.fs.writeFile(context.destination, document.table.serialize());
return {
id: context.destination.toString(),
delete: async () => {
try {
await vscode.workspace.fs.delete(context.destination);
} catch {
// VS Code may already have removed an obsolete hot-exit backup.
}
}
};
}
private async postInit(
document: DbcDocument,
webview: vscode.Webview,
state: ViewState
): Promise<void> {
await webview.postMessage({
type: "init",
fileName: path.basename(document.uri.fsPath || document.uri.path),
schemaName: document.table.schema.name,
warning: document.table.warning,
schemaCount: this.schemas.tableCount,
header: document.table.header,
columns: document.table.schema.columns.map((column) => ({
name: column.name,
kind: column.kind,
bits: column.bits,
isId: Boolean(column.isId),
locale: column.kind === "string" ? column.locale : undefined
}))
});
await this.postPage(document, webview, state);
}
private async postPage(
document: DbcDocument,
webview: vscode.Webview,
state: ViewState
): Promise<void> {
const pageSize = vscode.workspace.getConfiguration("moonwellDbc").get<number>("pageSize", 100);
const page = document.table.getPage(state.page, pageSize, state.query);
state.page = page.page;
await webview.postMessage({ type: "page", query: state.query, ...page });
}
private async editCell(document: DbcDocument, message: WebviewMessage): Promise<void> {
const rowIndex = requireIndex(message.rowIndex, "row");
const columnIndex = requireIndex(message.columnIndex, "column");
const result = document.table.setCell(rowIndex, columnIndex, message.value);
if (result.oldValue === result.value) {
document.notifyChanged();
return;
}
const columnName = document.table.schema.columns[columnIndex]?.name ?? `column ${columnIndex + 1}`;
this.changeEmitter.fire({
document,
label: `Edit ${columnName}`,
undo: async () => {
document.table.setCell(rowIndex, columnIndex, result.oldValue);
document.notifyChanged();
},
redo: async () => {
document.table.setCell(rowIndex, columnIndex, result.value);
document.notifyChanged();
}
});
document.notifyChanged();
}
private addRow(document: DbcDocument): void {
const rowIndex = document.table.appendRow();
const row = document.table.rows[rowIndex]!;
this.emitRowEdit(document, "Add DBC row", rowIndex, row, true);
document.notifyChanged();
}
private duplicateRow(document: DbcDocument, sourceIndex: number): void {
const rowIndex = document.table.duplicateRow(sourceIndex);
const row = document.table.rows[rowIndex]!;
this.emitRowEdit(document, "Duplicate DBC row", rowIndex, row, true);
document.notifyChanged();
}
private deleteRow(document: DbcDocument, rowIndex: number): void {
const row = document.table.deleteRow(rowIndex);
this.emitRowEdit(document, "Delete DBC row", rowIndex, row, false);
document.notifyChanged();
}
private emitRowEdit(
document: DbcDocument,
label: string,
rowIndex: number,
row: RowRef,
inserted: boolean
): void {
this.changeEmitter.fire({
document,
label,
undo: async () => {
if (inserted) document.table.deleteRow(rowIndex);
else document.table.insertRow(rowIndex, row);
document.notifyChanged();
},
redo: async () => {
if (inserted) document.table.insertRow(rowIndex, row);
else document.table.deleteRow(rowIndex);
document.notifyChanged();
}
});
}
private html(webview: vscode.Webview, mediaRoot: vscode.Uri): string {
const nonce = getNonce();
const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, "editor.js"));
const styleUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, "editor.css"));
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource}; script-src 'nonce-${nonce}';">
<link rel="stylesheet" href="${styleUri}">
<title>DBC Editor</title>
</head>
<body>
<header class="toolbar">
<div class="file-info">
<strong id="file-name">DBC Editor</strong>
<span id="file-meta"></span>
</div>
<div class="actions">
<input id="search" type="search" placeholder="Search ID, enUS and ruRU strings">
<button id="find">Find</button>
<button id="clear-search">Clear</button>
<span class="separator"></span>
<button id="add-row">Add row</button>
<button id="duplicate-row" disabled>Duplicate</button>
<button id="delete-row" disabled>Delete</button>
</div>
<div id="warning" class="warning" hidden></div>
</header>
<main>
<section id="grid" class="grid" aria-label="DBC records"></section>
<section class="cell-editor">
<label id="cell-label" for="cell-value">Select a cell</label>
<textarea id="cell-value" rows="3" disabled></textarea>
<button id="apply-cell" disabled>Apply cell</button>
</section>
</main>
<footer>
<button id="previous">Previous</button>
<span>Page <input id="page-number" type="number" min="1" value="1"> of <span id="page-count">1</span></span>
<button id="next">Next</button>
<span id="row-count"></span>
<span id="status" role="status"></span>
</footer>
<script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>`;
}
}
function requireIndex(value: number | undefined, label: string): number {
if (!Number.isInteger(value) || value === undefined || value < 0) {
throw new Error(`Invalid ${label} index.`);
}
return value;
}
function getNonce(): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
return Array.from({ length: 32 }, () => chars.charAt(Math.floor(Math.random() * chars.length))).join("");
}
+15
View File
@@ -0,0 +1,15 @@
import * as vscode from "vscode";
import { DbcEditorProvider } from "./editorProvider";
export function activate(context: vscode.ExtensionContext): void {
const provider = new DbcEditorProvider(context);
context.subscriptions.push(
vscode.window.registerCustomEditorProvider(DbcEditorProvider.viewType, provider, {
webviewOptions: { retainContextWhenHidden: false },
supportsMultipleEditorsPerDocument: false
})
);
}
export function deactivate(): void {}
+81
View File
@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DbcTable } from "../src/dbc/table";
import { DbcSchema } from "../src/dbc/types";
const schema: DbcSchema = {
name: "Synthetic",
build: 12340,
recordSize: 12,
columns: [
{ name: "ID", kind: "int", bits: 32, offset: 0, isId: true },
{ name: "Scale", kind: "float", bits: 32, offset: 4 },
{ name: "Name", kind: "string", bits: 32, offset: 8 }
]
};
test("reads, edits and writes WDBC records and strings", () => {
const table = new DbcTable(makeFixture(), schema);
assert.equal(table.header.recordCount, 2);
assert.equal(table.getCell(0, 0), "1");
assert.equal(table.getCell(0, 1), "1.5");
assert.equal(table.getCell(0, 2), "hello");
assert.equal(table.getCell(1, 2), "world");
table.setCell(0, 1, "2.25");
table.setCell(0, 2, "Привет");
table.setCell(1, 0, "42");
const reparsed = new DbcTable(table.serialize(), schema);
assert.equal(reparsed.getCell(0, 1), "2.25");
assert.equal(reparsed.getCell(0, 2), "Привет");
assert.equal(reparsed.getCell(1, 0), "42");
assert.equal(reparsed.getCell(1, 2), "world");
});
test("adds, duplicates and deletes rows with unique generated IDs", () => {
const table = new DbcTable(makeFixture(), schema);
const added = table.appendRow();
assert.equal(added, 2);
assert.equal(table.getCell(added, 0), "3");
assert.equal(table.getCell(added, 2), "");
const duplicate = table.duplicateRow(0);
assert.equal(duplicate, 1);
assert.equal(table.getCell(duplicate, 0), "4");
assert.equal(table.getCell(duplicate, 2), "hello");
const removed = table.deleteRow(duplicate);
assert.equal(table.rows.length, 3);
table.insertRow(duplicate, removed);
assert.equal(table.getCell(duplicate, 0), "4");
const reparsed = new DbcTable(table.serialize(), schema);
assert.equal(reparsed.header.recordCount, 4);
});
test("validates integer ranges and duplicate IDs", () => {
const table = new DbcTable(makeFixture(), schema);
assert.throws(() => table.setCell(0, 0, "2"), /already used/);
assert.throws(() => table.setCell(0, 0, "2147483648"), /between/);
assert.throws(() => table.setCell(0, 1, "not-a-float"), /floating-point/);
});
function makeFixture(): Uint8Array {
const strings = Buffer.from("\0hello\0world\0", "utf8");
const bytes = Buffer.alloc(20 + 24 + strings.length);
bytes.write("WDBC", 0, "ascii");
bytes.writeUInt32LE(2, 4);
bytes.writeUInt32LE(3, 8);
bytes.writeUInt32LE(12, 12);
bytes.writeUInt32LE(strings.length, 16);
bytes.writeInt32LE(1, 20);
bytes.writeFloatLE(1.5, 24);
bytes.writeUInt32LE(1, 28);
bytes.writeInt32LE(2, 32);
bytes.writeFloatLE(-3.25, 36);
bytes.writeUInt32LE(7, 40);
strings.copy(bytes, 44);
return bytes;
}
@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import { parseHeader } from "../src/dbc/codec";
import { DbcTable } from "../src/dbc/table";
import { DbcSchemaBundle } from "../src/dbc/types";
const extensionRoot = path.resolve(__dirname, "..");
const projectRoot = path.resolve(extensionRoot, "..");
const bundle = JSON.parse(
fs.readFileSync(path.join(extensionRoot, "schemas", "12340.json"), "utf8")
) as DbcSchemaBundle;
const projectDbcs = [
"assets/dbc/3.3.5a/CreatureModelData.dbc",
"assets/dbc/3.3.5a/CreatureDisplayInfo.dbc",
"src/Data/ruRU/patch-ruRU-Z/DBFilesClient/SpellVisualKitModelAttach.dbc",
"src/Data/ruRU/patch-ruRU-Z/DBFilesClient/Spell.dbc",
"src/Data/ruRU/patch-ruRU-Z/DBFilesClient/CreatureDisplayInfoExtra.dbc",
"src/Data/ruRU/patch-ruRU-Z/DBFilesClient/BarberShopStyle.dbc",
"src/Data/ruRU/patch-ruRU-5/DBFilesClient/Achievement.dbc",
"src/Data/patch-Z/DBFilesClient/LFGDungeons.dbc",
"src/Data/patch-Z/DBFilesClient/ItemDisplayInfo.dbc",
"src/Data/patch-Z/DBFilesClient/Item.dbc"
];
test("bundled build 12340 schemas match every project DBC record size", () => {
for (const relativePath of projectDbcs) {
const fullPath = path.join(projectRoot, ...relativePath.split("/"));
const bytes = fs.readFileSync(fullPath);
const header = parseHeader(bytes);
const tableName = path.basename(relativePath, ".dbc").toLowerCase();
const schema = bundle.tables[tableName];
assert.ok(schema, `Missing schema for ${relativePath}`);
assert.equal(schema.recordSize, header.recordSize, `Record size mismatch for ${relativePath}`);
}
});
test("opens every project DBC with named columns", () => {
for (const relativePath of projectDbcs) {
const fullPath = path.join(projectRoot, ...relativePath.split("/"));
const bytes = fs.readFileSync(fullPath);
const tableName = path.basename(relativePath, ".dbc").toLowerCase();
const schema = bundle.tables[tableName]!;
const table = new DbcTable(bytes, schema);
const page = table.getPage(0, 1);
assert.equal(page.rows.length, table.header.recordCount > 0 ? 1 : 0);
assert.equal(page.rows[0]?.length ?? schema.columns.length, schema.columns.length);
}
});
test("round-trips project numeric and localized-string tables", () => {
for (const relativePath of [projectDbcs[2]!, projectDbcs[5]!]) {
const fullPath = path.join(projectRoot, ...relativePath.split("/"));
const bytes = fs.readFileSync(fullPath);
const tableName = path.basename(relativePath, ".dbc").toLowerCase();
const schema = bundle.tables[tableName]!;
const source = new DbcTable(bytes, schema);
const reparsed = new DbcTable(source.serialize(), schema);
assert.equal(reparsed.header.recordCount, source.header.recordCount);
for (const rowIndex of [0, Math.floor(source.rows.length / 2), source.rows.length - 1]) {
for (let columnIndex = 0; columnIndex < schema.columns.length; columnIndex += 1) {
assert.equal(
reparsed.getCell(rowIndex, columnIndex),
source.getCell(rowIndex, columnIndex),
`${relativePath}, row ${rowIndex + 1}, ${schema.columns[columnIndex]!.name}`
);
}
}
}
});
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"lib": ["ES2022", "DOM"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node", "vscode"],
"resolveJsonModule": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}