dbc editor vscode extentsion
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.vsix
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
.github/**
|
||||
.vscode/**
|
||||
.gitignore
|
||||
scripts/**
|
||||
src/**
|
||||
test/**
|
||||
tsconfig.json
|
||||
esbuild.mjs
|
||||
package-lock.json
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
Generated
+4869
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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[][];
|
||||
}
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user