2 Commits

Author SHA1 Message Date
sindoring d1fd8787dc варианты кастомизации 2026-08-29 14:40:38 +04:00
sindoring 8340b2c84f dbc editor vscode extentsion 2026-08-23 00:35:32 +04:00
26 changed files with 8353 additions and 36 deletions
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,8 @@ CHARACTER_FACING_INCREMENT = 2;
MAX_RACES = 10;
MAX_CLASSES_PER_RACE = 10;
NUM_CHAR_CUSTOMIZATIONS = 5;
MAX_EXTENDED_CHAR_CUSTOMIZATIONS = 20;
EXTENDED_CUSTOMIZATION_ROW_SPACING = 48;
MIN_CHAR_NAME_LENGTH = 2;
CHARACTER_CREATE_ROTATION_START_X = nil;
CHARACTER_ROTATION_INCREMENT = 90;
@@ -809,6 +811,11 @@ local function CharacterCreate_UpdatePersonalizationStep()
end
end
if CharacterCreate_UpdateExtendedCustomization then
CharacterCreate.extendedCustomizationStateKey = nil
CharacterCreate_UpdateExtendedCustomization()
end
CharacterCreate_UpdateGameModeVisibility();
end
@@ -872,6 +879,8 @@ function CharacterCreate_OnLoad(self)
_G["CharacterCustomizationButtonFrame"..i.."Text"]:SetText(_G["CHAR_CUSTOMIZATION"..i.."_DESC"])
end
self.extendedCustomizationElapsed = 0
local backdropColor = FACTION_BACKDROP_COLOR_TABLE["Alliance"]
CharacterCreateNameEdit:SetBackdropBorderColor(backdropColor[1], backdropColor[2], backdropColor[3])
CharacterCreateNameEdit:SetBackdropColor(backdropColor[4], backdropColor[5], backdropColor[6])
@@ -1117,13 +1126,30 @@ function CharacterCreateFrame_OnMouseUp(button)
end
end
function CharacterCreateFrame_OnUpdate()
function CharacterCreateFrame_OnUpdate(elapsed)
if ( CHARACTER_CREATE_ROTATION_START_X ) then
local x = GetCursorPosition();
local diff = (x - CHARACTER_CREATE_ROTATION_START_X) * CHARACTER_ROTATION_CONSTANT;
CHARACTER_CREATE_ROTATION_START_X = GetCursorPosition();
SetCharacterCreateFacing(GetCharacterCreateFacing() + diff);
end
if CharacterCreate_ProcessPendingCustomization
and CharacterCreate_ProcessPendingCustomization(elapsed or 0) then
return
end
CharacterCreate.extendedCustomizationElapsed =
(CharacterCreate.extendedCustomizationElapsed or 0) + (elapsed or 0)
if CharacterCreate.extendedCustomizationElapsed >= 0.10 then
CharacterCreate.extendedCustomizationElapsed = 0
if CharacterCreate_UpdateCustomizationAssetDebug then
local ok, errorText = pcall(CharacterCreate_UpdateCustomizationAssetDebug)
if not ok and CharacterCreate_LogExtendedCustomization then
CharacterCreate_LogExtendedCustomization("update failed: "..tostring(errorText), true)
end
end
end
end
function CharacterCreateEnumerateRaces(...)
@@ -1751,14 +1777,544 @@ function CharacterCreate_ResetState(immediate)
end
end
local EXTENDED_CUSTOMIZATION_SLOT_LABELS = {
[1] = "Цвет кожи",
[2] = "Лицо",
[3] = "Прическа",
[4] = "Цвет волос",
[5] = "Борода и усы",
}
local EXTENDED_CUSTOMIZATION_COMPONENT_LABELS = {
[1] = "Внешность",
[10] = "Цвет кожи",
[11] = "Вариант кожи",
[20] = "Лицо",
[21] = "Верхняя часть лица",
[22] = "Дополнительная особенность лица",
[30] = "Прическа",
[40] = "Цвет волос",
[41] = "Вариант окраски волос",
[50] = "Борода",
[51] = "Усы",
[52] = "Бакенбарды",
[53] = "Особенность лица 1",
[54] = "Особенность лица 2",
[55] = "Нижняя текстура лица",
[56] = "Верхняя текстура лица",
[57] = "Дополнительная текстура лица",
[59] = "Борода и усы",
}
local function CharacterCreate_GetCustomizationState()
-- The stock global is guaranteed to survive all Glue registration passes. The separately
-- registered MoonWell global may exist as a Lua function object while still failing validation,
-- so never prefer it over the verified dispatcher.
local api = GetHairCustomization
local useStockBridge = type(api) == "function"
if not useStockBridge then api = MoonWellGetCharacterCustomizationState end
if type(api) ~= "function" then
CharacterCreate.extendedCustomizationFailure = "MoonWell API недоступен"
return nil
end
local ok, skin, face, hairStyle, hairColor, facialHair, race, sex
if useStockBridge then
ok, skin, face, hairStyle, hairColor, facialHair, race, sex =
pcall(api, "__MoonWellState")
else
ok, skin, face, hairStyle, hairColor, facialHair, race, sex = pcall(api)
end
if not ok or skin == nil or face == nil or hairStyle == nil or hairColor == nil
or facialHair == nil or race == nil or sex == nil then
CharacterCreate.extendedCustomizationFailure = ok
and "Состояние персонажа не готово" or "Ошибка MoonWell API"
return nil
end
CharacterCreate.extendedCustomizationFailure = nil
local state = { skin, face, hairStyle, hairColor, facialHair }
state.race = race
state.sex = sex
return state
end
local function CharacterCreate_ValueCount(values)
local count = 0
for _ in pairs(values) do count = count + 1 end
return count
end
local function CharacterCreate_AttributesAreCoupled(catalog, leftKey, rightKey)
local leftToRight = {}
local rightToLeft = {}
for _, candidate in ipairs(catalog.candidates) do
local left = candidate.attributes[leftKey] or ""
local right = candidate.attributes[rightKey] or ""
if leftToRight[left] and leftToRight[left] ~= right then return false end
if rightToLeft[right] and rightToLeft[right] ~= left then return false end
leftToRight[left] = right
rightToLeft[right] = left
end
return true
end
local function CharacterCreate_BuildComponentGroups(catalog, slot)
local keys = {}
for key, meta in pairs(catalog.meta) do
if CharacterCreate_ValueCount(meta.values) > 1 then keys[#keys + 1] = key end
end
table.sort(keys, function(left, right)
return catalog.meta[left].order < catalog.meta[right].order
end)
local parent = {}
for _, key in ipairs(keys) do parent[key] = key end
local function root(key)
while parent[key] ~= key do
parent[key] = parent[parent[key]]
key = parent[key]
end
return key
end
local function merge(left, right)
left = root(left)
right = root(right)
if left ~= right then parent[right] = left end
end
for leftIndex=1, #keys do
for rightIndex=leftIndex + 1, #keys do
if CharacterCreate_AttributesAreCoupled(catalog, keys[leftIndex], keys[rightIndex]) then
merge(keys[leftIndex], keys[rightIndex])
end
end
end
local groupsByRoot = {}
for _, key in ipairs(keys) do
local keyRoot = root(key)
local group = groupsByRoot[keyRoot]
if not group then
group = { keys = {}, keySet = {}, order = catalog.meta[key].order,
label = catalog.meta[key].label }
groupsByRoot[keyRoot] = group
end
group.keys[#group.keys + 1] = key
group.keySet[key] = true
if catalog.meta[key].order < group.order then
group.order = catalog.meta[key].order
group.label = catalog.meta[key].label
end
end
catalog.groups = {}
for _, group in pairs(groupsByRoot) do catalog.groups[#catalog.groups + 1] = group end
table.sort(catalog.groups, function(left, right) return left.order < right.order end)
if #catalog.groups == 0 and #catalog.candidates > 1 then
catalog.groups[1] = {
keys = {}, keySet = {}, raw = true, order = 1,
label = EXTENDED_CUSTOMIZATION_SLOT_LABELS[slot],
}
end
end
local function CharacterCreate_CustomizationDependency(slot, state)
local dependency = 0
if slot == 2 then dependency = state[1]
elseif slot == 3 or slot == 5 then dependency = state[4]
elseif slot == 4 then dependency = state[3]
end
return dependency
end
local function CharacterCreate_CustomizationCatalogKey(slot, race, sex, state)
local dependency = CharacterCreate_CustomizationDependency(slot, state)
return race..":"..sex..":"..slot..":"..dependency
end
local function CharacterCreate_CallCustomizationCatalogAPI(api, useStockBridge, slot, race,
sex, dependency, index)
if useStockBridge then
if index then
return pcall(api, "__MoonWellCatalog", slot, race, sex, dependency, index)
end
return pcall(api, "__MoonWellCatalog", slot, race, sex, dependency)
end
if index then return pcall(api, slot, race, sex, dependency, index) end
return pcall(api, slot, race, sex, dependency)
end
local function CharacterCreate_BuildCustomizationCatalog(slot, race, sex, state)
CharacterCreate.extendedCustomizationCatalogs = CharacterCreate.extendedCustomizationCatalogs or {}
local key = CharacterCreate_CustomizationCatalogKey(slot, race, sex, state)
local catalog = CharacterCreate.extendedCustomizationCatalogs[slot]
if not catalog or catalog.key ~= key then
catalog = {
key = key, candidates = {}, byRaw = {}, meta = {}, ready = false,
}
CharacterCreate.extendedCustomizationCatalogs[slot] = catalog
end
if catalog.ready then return catalog end
local api = GetHairCustomization
local useStockBridge = type(api) == "function"
if not useStockBridge then api = MoonWellGetCharacterCustomizationCatalog end
if type(api) ~= "function" then return nil end
local dependency = CharacterCreate_CustomizationDependency(slot, state)
local ok, count, componentCount, kind1, kind2, kind3, kind4, kind5, kind6, kind7, kind8 =
CharacterCreate_CallCustomizationCatalogAPI(api, useStockBridge, slot, race, sex,
dependency)
if not ok or type(count) ~= "number" or type(componentCount) ~= "number" then
return nil
end
local kinds = { kind1, kind2, kind3, kind4, kind5, kind6, kind7, kind8 }
for component=1, componentCount do
local keyName = "component_"..component
local kind = kinds[component]
catalog.meta[keyName] = {
key = keyName,
label = EXTENDED_CUSTOMIZATION_COMPONENT_LABELS[kind]
or EXTENDED_CUSTOMIZATION_SLOT_LABELS[slot] or "Внешность",
order = component,
values = {},
}
end
for index=1, count do
local entryOK, raw, value1, value2, value3, value4, value5, value6, value7, value8 =
CharacterCreate_CallCustomizationCatalogAPI(api, useStockBridge, slot, race, sex,
dependency, index)
if not entryOK or raw == nil then return nil end
local values = { value1, value2, value3, value4, value5, value6, value7, value8 }
local candidate = { raw = raw, attributes = {} }
for component=1, componentCount do
local keyName = "component_"..component
local value = tostring(values[component] or 0)
candidate.attributes[keyName] = value
catalog.meta[keyName].values[value] = true
end
catalog.candidates[#catalog.candidates + 1] = candidate
catalog.byRaw[raw] = candidate
end
CharacterCreate_BuildComponentGroups(catalog, slot)
catalog.ready = true
return catalog
end
local function CharacterCreate_GroupSignature(candidate, group)
if group.raw then return tostring(candidate.raw) end
local values = {}
for _, key in ipairs(group.keys) do values[#values + 1] = candidate.attributes[key] or "" end
return table.concat(values, "|")
end
local function CharacterCreate_MatchesOutsideGroup(candidate, current, catalog, group)
if group.raw then return true end
for key in pairs(catalog.meta) do
if not group.keySet[key] and (candidate.attributes[key] or "") ~=
(current.attributes[key] or "") then
return false
end
end
return true
end
local function CharacterCreate_GetGroupChoices(catalog, group, currentRaw)
local current = catalog.byRaw[currentRaw]
if not current then return {}, 0 end
local choices = {}
local signatures = {}
local currentSignature = CharacterCreate_GroupSignature(current, group)
local currentIndex = 0
for _, candidate in ipairs(catalog.candidates) do
if CharacterCreate_MatchesOutsideGroup(candidate, current, catalog, group) then
local signature = CharacterCreate_GroupSignature(candidate, group)
if not signatures[signature] then
signatures[signature] = true
choices[#choices + 1] = candidate
if signature == currentSignature then currentIndex = #choices end
end
end
end
return choices, currentIndex
end
local function CharacterCreate_EnsureCustomizationSelectorFrames()
if CharacterCreate.customizationSelectorFrames then
return CharacterCreate.customizationSelectorFrames
end
local frames = {}
for index=1, MAX_EXTENDED_CHAR_CUSTOMIZATIONS do
local frame = _G["CharacterCustomizationButtonFrame"..index]
if not frame then
frame = CreateFrame("Frame", "MoonWellCharacterCustomizationSelector"..index,
CharacterCreateFrame, "CharacterCustomizationFrameTemplate")
frame:Hide()
end
if frame:GetParent() ~= CharacterCreateFrame then
frame:SetParent(CharacterCreateFrame)
end
frame:SetFrameLevel(CharacterCreateFrame:GetFrameLevel() + 5)
frame:SetID(index)
frames[index] = frame
end
CharacterCreate.customizationSelectorFrames = frames
return frames
end
function CharacterCreate_LogExtendedCustomization(messageText, once)
if once and CharacterCreate.extendedCustomizationLastLog == messageText then return end
if once then CharacterCreate.extendedCustomizationLastLog = messageText end
if type(GetHairCustomization) == "function" then
pcall(GetHairCustomization, "__MoonWellLog", tostring(messageText))
end
end
local function CharacterCreate_ShowCustomizationSelectors(frames, selectors)
local firstY = (#selectors - 1) * EXTENDED_CUSTOMIZATION_ROW_SPACING / 2
local visibleCount = 0
for index, frame in ipairs(frames) do
local selector = selectors[index]
if selector then
frame:ClearAllPoints()
frame:SetPoint("RIGHT", CharacterCreateFrame, "RIGHT", -80,
firstY - (index - 1) * EXTENDED_CUSTOMIZATION_ROW_SPACING)
frame:SetID(index)
local text = _G[frame:GetName().."Text"]
if selector.fallback then
text:SetText(selector.label)
else
text:SetText(string.format("%s: %d/%d",
selector.label, selector.currentIndex, #selector.choices))
end
frame:Show()
visibleCount = visibleCount + 1
else
frame:Hide()
end
end
return visibleCount
end
function CharacterCreate_UpdateExtendedCustomization(activeState)
local customizationVisible = CharacterCreate.personalizationMode
and not CharacterCreate.gameModeSelectionMode
if not customizationVisible then
CharacterCreate.pendingExtendedCustomization = nil
CharacterCreate.extendedCustomizationApplying = false
if CharacterCreate.customizationSelectorFrames then
for _, frame in ipairs(CharacterCreate.customizationSelectorFrames) do frame:Hide() end
end
CharacterCreate.extendedCustomizationStateKey = nil
return
end
local frames = CharacterCreate_EnsureCustomizationSelectorFrames()
local state = activeState or CharacterCreate_GetCustomizationState()
local race = state and state.race or 0
local sex = state and state.sex or -1
local stateKey
if state then
stateKey = string.format("%d:%d:%d:%d:%d:%d:%d", race, sex,
state[1], state[2], state[3], state[4], state[5])
if CharacterCreate.extendedCustomizationStateKey == stateKey then
CharacterCreate_ShowCustomizationSelectors(frames,
CharacterCreate.visibleCustomizationSelectors or {})
return
end
end
local selectors = {}
local catalogCount = 0
if state and race > 0 and (sex == 0 or sex == 1) then
for slot=1, NUM_CHAR_CUSTOMIZATIONS do
local slotSelectorCount = 0
local catalog = CharacterCreate_BuildCustomizationCatalog(slot, race, sex, state)
if catalog then
catalogCount = catalogCount + 1
for _, group in ipairs(catalog.groups) do
local choices, currentIndex = CharacterCreate_GetGroupChoices(
catalog, group, state[slot])
if #choices > 1 and currentIndex > 0 then
selectors[#selectors + 1] = {
slot = slot, group = group, catalog = catalog,
choices = choices, currentIndex = currentIndex,
currentRaw = state[slot],
label = group.label,
}
slotSelectorCount = slotSelectorCount + 1
end
end
end
if slotSelectorCount == 0 then
selectors[#selectors + 1] = {
slot = slot, fallback = true,
label = EXTENDED_CUSTOMIZATION_SLOT_LABELS[slot]
or _G["CHAR_CUSTOMIZATION"..slot.."_DESC"],
}
end
end
end
if #selectors == 0 then
for slot=1, NUM_CHAR_CUSTOMIZATIONS do
selectors[#selectors + 1] = {
slot = slot, fallback = true,
label = EXTENDED_CUSTOMIZATION_SLOT_LABELS[slot]
or _G["CHAR_CUSTOMIZATION"..slot.."_DESC"],
}
end
if CharacterCreate.extendedCustomizationFailure then
selectors[1].label = "MoonWell: "..CharacterCreate.extendedCustomizationFailure
end
end
CharacterCreate.visibleCustomizationSelectors = selectors
local visibleCount = CharacterCreate_ShowCustomizationSelectors(frames, selectors)
CharacterCreate.extendedCustomizationStateKey =
catalogCount == NUM_CHAR_CUSTOMIZATIONS and stateKey or nil
local firstFrame = frames[1]
CharacterCreate_LogExtendedCustomization(string.format(
"layout ready selectors=%d visible=%d catalogs=%d bounds=%.0f..%.0f root=%.0fx%.0f",
#selectors, visibleCount, catalogCount, firstFrame:GetLeft() or -1,
firstFrame:GetRight() or -1, CharacterCreateFrame:GetWidth() or -1,
CharacterCreateFrame:GetHeight() or -1), true)
end
local function CharacterCreate_PrimeMappedCustomization(slot, raw)
local api = GetHairCustomization
if type(api) ~= "function" then return false end
local ok, primed = pcall(api, "__MoonWellPrime", slot, raw)
return ok and primed == true
end
local function CharacterCreate_FinishPendingCustomization(reason)
local pending = CharacterCreate.pendingExtendedCustomization
CharacterCreate.pendingExtendedCustomization = nil
CharacterCreate.extendedCustomizationApplying = false
CharacterCreate.extendedCustomizationStateKey = nil
if reason then
CharacterCreate_LogExtendedCustomization(string.format(
"apply %s slot=%d target=%d steps=%d",
reason, pending and pending.slot or -1, pending and pending.targetRaw or -1,
pending and pending.completedSteps or 0), true)
end
CharacterCreate_UpdateExtendedCustomization()
end
function CharacterCreate_ProcessPendingCustomization(elapsed)
local pending = CharacterCreate.pendingExtendedCustomization
if not pending then return false end
pending.elapsed = (pending.elapsed or 0) + elapsed
if pending.elapsed < 0.02 then return true end
pending.elapsed = 0
local state = CharacterCreate_GetCustomizationState()
if not state then
CharacterCreate_FinishPendingCustomization("cancelled: state unavailable")
return false
end
if state[pending.slot] == pending.targetRaw then
CharacterCreate_FinishPendingCustomization("complete")
return false
end
if pending.remainingSteps <= 0 then
CharacterCreate_FinishPendingCustomization("cancelled: target unreachable")
return false
end
pending.remainingSteps = pending.remainingSteps - 1
pending.completedSteps = pending.completedSteps + 1
CycleCharCustomization(pending.slot, pending.cycleDirection)
return true
end
local function CharacterCreate_CycleExtendedCustomization(selectorIndex, direction)
local selectors = CharacterCreate.visibleCustomizationSelectors
local selector = selectors and selectors[selectorIndex]
if not selector then return false end
if CharacterCreate.pendingExtendedCustomization then return true end
PlaySound("gsCharacterCreationLook")
if selector.fallback then
CycleCharCustomization(selector.slot, direction)
return true
end
local targetIndex = selector.currentIndex + direction
if targetIndex < 1 then targetIndex = #selector.choices end
if targetIndex > #selector.choices then targetIndex = 1 end
local targetRaw = selector.choices[targetIndex].raw
-- The catalog is also a direct raw-value map. Prime the stock field with the mapped neighbour of
-- the target, then make exactly one stock cycle call. Both operations happen inside this click,
-- before a frame is rendered, while the stock call still performs its complete dependency/model
-- refresh. Keep the incremental path only as a compatibility fallback for an older native bridge.
local currentCandidateIndex
local targetCandidateIndex
for candidateIndex, candidate in ipairs(selector.catalog.candidates) do
if candidate.raw == selector.currentRaw then
currentCandidateIndex = candidateIndex
end
if candidate.raw == targetRaw then targetCandidateIndex = candidateIndex end
end
local candidateCount = #selector.catalog.candidates
local cycleDirection = direction
if currentCandidateIndex and targetCandidateIndex and candidateCount > 0 then
local forward = (targetCandidateIndex - currentCandidateIndex) % candidateCount
local backward = (currentCandidateIndex - targetCandidateIndex) % candidateCount
cycleDirection = forward <= backward and 1 or -1
end
if targetCandidateIndex and candidateCount > 1 then
local primeIndex = targetCandidateIndex - cycleDirection
if primeIndex < 1 then primeIndex = candidateCount end
if primeIndex > candidateCount then primeIndex = 1 end
local primeRaw = selector.catalog.candidates[primeIndex].raw
if CharacterCreate_PrimeMappedCustomization(selector.slot, primeRaw) then
CharacterCreate.extendedCustomizationApplying = true
CycleCharCustomization(selector.slot, cycleDirection)
CharacterCreate.extendedCustomizationApplying = false
local appliedState = CharacterCreate_GetCustomizationState()
if appliedState and appliedState[selector.slot] == targetRaw then
CharacterCreate.pendingExtendedCustomization = nil
CharacterCreate.extendedCustomizationStateKey = nil
CharacterCreate_LogExtendedCustomization(string.format(
"apply mapped slot=%d target=%d", selector.slot, targetRaw), true)
CharacterCreate_UpdateExtendedCustomization(appliedState)
return true
end
end
end
CharacterCreate.pendingExtendedCustomization = {
slot = selector.slot,
targetRaw = targetRaw,
cycleDirection = cycleDirection,
remainingSteps = candidateCount,
completedSteps = 0,
elapsed = 0.02,
}
CharacterCreate.extendedCustomizationApplying = true
return true
end
function CharacterCustomization_Left(id)
PlaySound("gsCharacterCreationLook");
CycleCharCustomization(id, -1);
if not CharacterCreate_CycleExtendedCustomization(id, -1) then
PlaySound("gsCharacterCreationLook")
CycleCharCustomization(id, -1)
end
end
function CharacterCustomization_Right(id)
PlaySound("gsCharacterCreationLook");
CycleCharCustomization(id, 1);
if not CharacterCreate_CycleExtendedCustomization(id, 1) then
PlaySound("gsCharacterCreationLook")
CycleCharCustomization(id, 1)
end
end
function CharacterCreate_UpdateCustomizationAssetDebug()
if CharacterCreate.extendedCustomizationApplying then return end
CharacterCreate_UpdateExtendedCustomization(CharacterCreate_GetCustomizationState())
end
function CharacterCreate_Randomize()
@@ -1792,6 +2348,9 @@ function CharacterCreate_UpdateHairCustomization()
CharacterCustomizationButtonFrame3Text:SetText(_G["HAIR_"..GetHairCustomization().."_STYLE"]);
CharacterCustomizationButtonFrame4Text:SetText(_G["HAIR_"..GetHairCustomization().."_COLOR"]);
CharacterCustomizationButtonFrame5Text:SetText(_G["FACIAL_HAIR_"..GetFacialHairCustomization()]);
if CharacterCreate.extendedCustomizationApplying then return end
CharacterCreate.extendedCustomizationStateKey = nil
CharacterCreate_UpdateExtendedCustomization()
end
function SetButtonDesaturated(button, desaturated, r, g, b)
@@ -72,7 +72,7 @@
<Scripts>
<OnClick>
if( self.enable ) then
CharacterClass_OnClick(self:GetID());
CharacterClass_OnClick(self:GetID());
CharacterCreateTooltip:Hide();
end
</OnClick>
@@ -127,27 +127,27 @@
</CheckButton>
<Frame name="CharacterCustomizationFrameTemplate" virtual="true">
<Size>
<AbsDimension x="230" y="32"/>
</Size>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentBackground" file="Interface\Glues\CharacterCreate\charactercreate">
<Size>
<AbsDimension x="150" y="34"/>
</Size>
<Anchors>
<Anchor point="CENTER"/>
</Anchors>
<TexCoords left="0.508300781" right="0.656738281" top="0.244140625" bottom="0.279296875"/>
</Texture>
<FontString name="$parentText" inherits="GlueFontHighlightSmall">
<Anchors>
<Anchor point="CENTER" x="0" y="2"/>
</Anchors>
</FontString>
</Layer>
</Layers>
<Size>
<AbsDimension x="230" y="32"/>
</Size>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentBackground" file="Interface\Glues\CharacterCreate\charactercreate">
<Size>
<AbsDimension x="180" y="34"/>
</Size>
<Anchors>
<Anchor point="CENTER"/>
</Anchors>
<TexCoords left="0.508300781" right="0.656738281" top="0.244140625" bottom="0.279296875"/>
</Texture>
<FontString name="$parentText" inherits="GlueFontHighlightSmall">
<Anchors>
<Anchor point="CENTER" x="0" y="2"/>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<Button name="$parentRightButton">
<Size x="24" y="24"/>
@@ -540,7 +540,6 @@
<CheckButton name="CharacterCreateClassButton10" inherits="CharacterCreateClassButtonTemplate" id="10"/>
</Frames>
</Frame>
<!-- Frames de personalización independientes -->
<Frame name="CharacterCustomizationButtonFrame1" inherits="CharacterCustomizationFrameTemplate" id="1">
<Anchors>
<Anchor point="CENTER" x="900" y="150"/>
@@ -832,7 +831,7 @@
</OnClick>
</Scripts>
</Button>
<Button name="CharCreateBackButton" inherits="GlueButtonTemplate" text="BACK">
<Size x="150" y="50"/>
<Anchors>
@@ -858,7 +857,7 @@
</OnUpdate>
</Scripts>
</Button>
</Frames>
<Scripts>
<OnMouseDown>
@@ -868,7 +867,7 @@
CharacterCreateFrame_OnMouseUp(button);
</OnMouseUp>
<OnUpdate>
CharacterCreateFrame_OnUpdate();
CharacterCreateFrame_OnUpdate(arg1);
</OnUpdate>
</Scripts>
</Frame>
+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"]
}