param( [string]$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path, [string]$ClientPath ) $ErrorActionPreference = 'Stop' function Get-EnvValue([string]$Path, [string]$Name) { $line = Get-Content -LiteralPath $Path | Where-Object { $_ -match "^$([regex]::Escape($Name))=" } | Select-Object -First 1 if ($line) { return $line.Substring($Name.Length + 1).Trim() } return $null } if (-not $ClientPath) { $ClientPath = Get-EnvValue (Join-Path $ProjectRoot '.env') 'WOW_HOME' } if (-not $ClientPath -or -not (Test-Path -LiteralPath (Join-Path $ClientPath 'Data\ruRU'))) { throw 'A valid WoW 3.3.5 client path is required.' } $reader = Join-Path $ProjectRoot 'tool\target\release\mpqread.exe' if (-not (Test-Path -LiteralPath $reader)) { throw "MPQ reader not found: $reader" } $work = Join-Path $ProjectRoot 'build\customization-compat' New-Item -ItemType Directory -Path $work -Force | Out-Null function Export-MpqFile([string]$Archive, [string]$VirtualPath, [string]$OutputName) { $output = Join-Path $work $OutputName & $reader $Archive $VirtualPath $output 2>$null if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $output)) { throw "Cannot extract $VirtualPath from $Archive" } return $output } function Read-U32([byte[]]$Bytes, [int]$Offset) { return [BitConverter]::ToUInt32($Bytes, $Offset) } function Write-U32([byte[]]$Bytes, [int]$Field, [uint32]$Value) { [BitConverter]::GetBytes($Value).CopyTo($Bytes, $Field * 4) } function Read-Wdbc([string]$Path) { [byte[]]$bytes = [IO.File]::ReadAllBytes($Path) if ($bytes.Length -lt 20 -or [Text.Encoding]::ASCII.GetString($bytes, 0, 4) -ne 'WDBC') { throw "Not a WDBC file: $Path" } $count = Read-U32 $bytes 4 $fields = Read-U32 $bytes 8 $recordSize = Read-U32 $bytes 12 $stringSize = Read-U32 $bytes 16 $recordsEnd = 20 + ($count * $recordSize) if ($recordsEnd + $stringSize -ne $bytes.Length) { throw "Invalid WDBC size: $Path" } $records = [Collections.Generic.Dictionary[uint32,byte[]]]::new() for ($index = 0; $index -lt $count; $index++) { [byte[]]$row = New-Object byte[] $recordSize [Array]::Copy($bytes, 20 + ($index * $recordSize), $row, 0, $recordSize) $records.Add((Read-U32 $row 0), $row) } [byte[]]$strings = New-Object byte[] $stringSize [Array]::Copy($bytes, $recordsEnd, $strings, 0, $stringSize) return [pscustomobject]@{ Path = $Path Fields = [uint32]$fields RecordSize = [uint32]$recordSize Records = $records Strings = $strings } } function Read-WdbcRows([string]$Path) { [byte[]]$bytes = [IO.File]::ReadAllBytes($Path) if ($bytes.Length -lt 20 -or [Text.Encoding]::ASCII.GetString($bytes, 0, 4) -ne 'WDBC') { throw "Not a WDBC file: $Path" } $count = Read-U32 $bytes 4 $fields = Read-U32 $bytes 8 $recordSize = Read-U32 $bytes 12 $stringSize = Read-U32 $bytes 16 $recordsEnd = 20 + ($count * $recordSize) if ($recordsEnd + $stringSize -ne $bytes.Length) { throw "Invalid WDBC size: $Path" } $rows = New-Object Collections.Generic.List[byte[]] for ($index = 0; $index -lt $count; $index++) { [byte[]]$row = New-Object byte[] $recordSize [Array]::Copy($bytes, 20 + ($index * $recordSize), $row, 0, $recordSize) $rows.Add($row) } [byte[]]$strings = New-Object byte[] $stringSize [Array]::Copy($bytes, $recordsEnd, $strings, 0, $stringSize) return [pscustomobject]@{ Path = $Path Fields = [uint32]$fields RecordSize = [uint32]$recordSize Rows = $rows Strings = $strings } } function Get-StringBytes($Table, [uint32]$Offset) { if ($Offset -eq 0) { return ,([byte[]]@()) } if ($Offset -ge $Table.Strings.Length) { throw "Bad string offset $Offset in $($Table.Path)" } $end = [int]$Offset while ($end -lt $Table.Strings.Length -and $Table.Strings[$end] -ne 0) { $end++ } [byte[]]$value = New-Object byte[] ($end - [int]$Offset) if ($value.Length) { [Array]::Copy($Table.Strings, $Offset, $value, 0, $value.Length) } return ,$value } function Get-RowSignature($Table, [byte[]]$Row, [int[]]$StringFields) { $parts = New-Object Collections.Generic.List[string] for ($field = 0; $field -lt $Table.Fields; $field++) { $value = Read-U32 $Row ($field * 4) if ($StringFields -contains $field) { $parts.Add('s:' + [Convert]::ToBase64String((Get-StringBytes $Table $value))) } else { $parts.Add('u:' + $value) } } return $parts -join '|' } function Select-DeltaMerge($Base, $Old, $New, [int[]]$StringFields, [string]$Label) { $ids = [Collections.Generic.HashSet[uint32]]::new() foreach ($id in $Base.Records.Keys) { [void]$ids.Add($id) } foreach ($id in $Old.Records.Keys) { [void]$ids.Add($id) } foreach ($id in $New.Records.Keys) { [void]$ids.Add($id) } $selected = [Collections.Generic.Dictionary[uint32,object]]::new() $oldOnly = 0 $newChanges = 0 $conflicts = 0 foreach ($id in $ids) { $baseRow = $null; $oldRow = $null; $newRow = $null [void]$Base.Records.TryGetValue($id, [ref]$baseRow) [void]$Old.Records.TryGetValue($id, [ref]$oldRow) [void]$New.Records.TryGetValue($id, [ref]$newRow) $baseSig = if ($baseRow) { Get-RowSignature $Base $baseRow $StringFields } else { $null } $oldSig = if ($oldRow) { Get-RowSignature $Old $oldRow $StringFields } else { $null } $newSig = if ($newRow) { Get-RowSignature $New $newRow $StringFields } else { $null } $oldChanged = $oldRow -and ($null -eq $baseRow -or $oldSig -ne $baseSig) $newChanged = $newRow -and ($null -eq $baseRow -or $newSig -ne $baseSig) if ($oldChanged -and $newChanged -and $oldSig -ne $newSig) { $conflicts++ } if ($newChanged) { $selected.Add($id, [pscustomobject]@{ Table = $New; Row = $newRow }) $newChanges++ } elseif ($oldChanged) { $selected.Add($id, [pscustomobject]@{ Table = $Old; Row = $oldRow }) $oldOnly++ } elseif ($newRow) { $selected.Add($id, [pscustomobject]@{ Table = $New; Row = $newRow }) } elseif ($oldRow) { $selected.Add($id, [pscustomobject]@{ Table = $Old; Row = $oldRow }) } else { $selected.Add($id, [pscustomobject]@{ Table = $Base; Row = $baseRow }) } } Write-Host "${Label}: rows=$($selected.Count), retained-old=$oldOnly, applied-new=$newChanges, same-id-conflicts=$conflicts" return $selected } function Select-DisplayExtraMerge($Base, $OldItem, $New) { $ids = [Collections.Generic.HashSet[uint32]]::new() foreach ($id in $OldItem.Records.Keys) { [void]$ids.Add($id) } foreach ($id in $New.Records.Keys) { [void]$ids.Add($id) } $selected = [Collections.Generic.Dictionary[uint32,object]]::new() $itemFieldsApplied = 0 foreach ($id in $ids) { $baseRow = $null; $oldRow = $null; $newRow = $null [void]$Base.Records.TryGetValue($id, [ref]$baseRow) [void]$OldItem.Records.TryGetValue($id, [ref]$oldRow) [void]$New.Records.TryGetValue($id, [ref]$newRow) if ($newRow) { [byte[]]$merged = $newRow.Clone() if ($oldRow) { for ($field = 8; $field -le 18; $field++) { $oldValue = Read-U32 $oldRow ($field * 4) $baseValue = if ($baseRow) { Read-U32 $baseRow ($field * 4) } else { 0 } if (-not $baseRow -or $oldValue -ne $baseValue) { Write-U32 $merged $field $oldValue $itemFieldsApplied++ } } } $selected.Add($id, [pscustomobject]@{ Table = $New; Row = $merged }) } else { $selected.Add($id, [pscustomobject]@{ Table = $OldItem; Row = $oldRow }) } } Write-Host "CreatureDisplayInfoExtra: rows=$($selected.Count), retained item fields from patch I=$itemFieldsApplied" return $selected } function Write-Wdbc($Template, $Selected, [int[]]$StringFields, [string]$Path) { $stringOffsets = [Collections.Generic.Dictionary[string,uint32]]::new([StringComparer]::Ordinal) $strings = New-Object Collections.Generic.List[byte] $strings.Add(0) $stringOffsets.Add('', 0) $rows = New-Object Collections.Generic.List[byte[]] foreach ($id in ($Selected.Keys | Sort-Object)) { $choice = $Selected[$id] [byte[]]$row = $choice.Row.Clone() foreach ($field in $StringFields) { $sourceOffset = Read-U32 $row ($field * 4) [byte[]]$value = Get-StringBytes $choice.Table $sourceOffset $key = [Convert]::ToBase64String($value) [uint32]$targetOffset = 0 if (-not $stringOffsets.TryGetValue($key, [ref]$targetOffset)) { $targetOffset = [uint32]$strings.Count if ($value.Length) { $strings.AddRange($value) } $strings.Add(0) $stringOffsets.Add($key, $targetOffset) } Write-U32 $row $field $targetOffset } $rows.Add($row) } $parent = Split-Path -Parent $Path New-Item -ItemType Directory -Path $parent -Force | Out-Null $stream = [IO.File]::Open($Path, [IO.FileMode]::Create, [IO.FileAccess]::Write) try { $writer = New-Object IO.BinaryWriter($stream) $writer.Write([Text.Encoding]::ASCII.GetBytes('WDBC')) $writer.Write([uint32]$rows.Count) $writer.Write([uint32]$Template.Fields) $writer.Write([uint32]$Template.RecordSize) $writer.Write([uint32]$strings.Count) foreach ($row in $rows) { $writer.Write($row) } $writer.Write($strings.ToArray()) $writer.Flush() } finally { $stream.Dispose() } } function Write-ExtendedBarberShopStyle($Base, $HairGeosets, $FacialHairStyles, [string]$Path) { if ($Base.Fields -ne 40 -or $Base.RecordSize -ne 160) { throw "Unexpected BarberShopStyle schema in $($Base.Path)" } if ($HairGeosets.Fields -ne 6 -or $HairGeosets.RecordSize -ne 24) { throw "Unexpected CharHairGeosets schema in $($HairGeosets.Path)" } if ($FacialHairStyles.Fields -ne 8 -or $FacialHairStyles.RecordSize -ne 32) { throw "Unexpected CharacterFacialHairStyles schema in $($FacialHairStyles.Path)" } $templates = [Collections.Generic.Dictionary[string,byte[]]]::new([StringComparer]::Ordinal) $existing = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) [uint32]$nextId = 1 foreach ($row in $Base.Rows) { $id = Read-U32 $row 0 if ($id -ge $nextId) { $nextId = $id + 1 } $type = Read-U32 $row 4 if ($type -ne 0 -and $type -ne 2) { continue } $race = Read-U32 $row (37 * 4) $gender = Read-U32 $row (38 * 4) $style = Read-U32 $row (39 * 4) $groupKey = "${race}:${gender}:${type}" if (-not $templates.ContainsKey($groupKey)) { $templates.Add($groupKey, $row) } [void]$existing.Add("${groupKey}:${style}") } $targets = New-Object Collections.Generic.List[object] $targetKeys = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) foreach ($row in $HairGeosets.Rows) { $race = Read-U32 $row 4 $gender = Read-U32 $row 8 $style = Read-U32 $row 12 $groupKey = "${race}:${gender}:0" $targetKey = "${groupKey}:${style}" if ($templates.ContainsKey($groupKey) -and $targetKeys.Add($targetKey)) { $targets.Add([pscustomobject]@{ Race = $race; Gender = $gender; Type = 0; Style = $style; GroupKey = $groupKey; TargetKey = $targetKey }) } } foreach ($row in $FacialHairStyles.Rows) { $race = Read-U32 $row 0 $gender = Read-U32 $row 4 $style = Read-U32 $row 8 $groupKey = "${race}:${gender}:2" $targetKey = "${groupKey}:${style}" if ($templates.ContainsKey($groupKey) -and $targetKeys.Add($targetKey)) { $targets.Add([pscustomobject]@{ Race = $race; Gender = $gender; Type = 2; Style = $style; GroupKey = $groupKey; TargetKey = $targetKey }) } } foreach ($groupKey in $templates.Keys) { if (-not ($targets | Where-Object { $_.GroupKey -eq $groupKey } | Select-Object -First 1)) { throw "No customization rows found for BarberShopStyle group $groupKey" } } $rows = New-Object Collections.Generic.List[byte[]] foreach ($row in $Base.Rows) { $rows.Add($row.Clone()) } $strings = New-Object Collections.Generic.List[byte] $strings.AddRange($Base.Strings) $newStringOffsets = [Collections.Generic.Dictionary[string,uint32]]::new([StringComparer]::Ordinal) $addedHair = 0 $addedFacial = 0 foreach ($target in ($targets | Sort-Object Race, Gender, Type, Style)) { if ($existing.Contains($target.TargetKey)) { continue } [byte[]]$row = $templates[$target.GroupKey].Clone() Write-U32 $row 0 $nextId Write-U32 $row 39 ([uint32]$target.Style) # patch-ruRU-3 stores its localized display name in locale slot 8 # (field 10). New names are intentionally generic because the source # customization archive contains no BarberShopStyle names. for ($field = 2; $field -le 17; $field++) { Write-U32 $row $field 0 } $label = if ($target.Type -eq 0) { "New hairstyle $([uint32]$target.Style + 1)" } else { "New appearance $([uint32]$target.Style + 1)" } [uint32]$nameOffset = 0 if (-not $newStringOffsets.TryGetValue($label, [ref]$nameOffset)) { $nameOffset = [uint32]$strings.Count $strings.AddRange([Text.Encoding]::UTF8.GetBytes($label)) $strings.Add(0) $newStringOffsets.Add($label, $nameOffset) } Write-U32 $row 10 $nameOffset $rows.Add($row) [void]$existing.Add($target.TargetKey) $nextId++ if ($target.Type -eq 0) { $addedHair++ } else { $addedFacial++ } } foreach ($target in $targets) { if (-not $existing.Contains($target.TargetKey)) { throw "Missing generated BarberShopStyle row $($target.TargetKey)" } } $parent = Split-Path -Parent $Path New-Item -ItemType Directory -Path $parent -Force | Out-Null $stream = [IO.File]::Open($Path, [IO.FileMode]::Create, [IO.FileAccess]::Write) try { $writer = New-Object IO.BinaryWriter($stream) $writer.Write([Text.Encoding]::ASCII.GetBytes('WDBC')) $writer.Write([uint32]$rows.Count) $writer.Write([uint32]$Base.Fields) $writer.Write([uint32]$Base.RecordSize) $writer.Write([uint32]$strings.Count) foreach ($row in $rows) { $writer.Write($row) } $writer.Write($strings.ToArray()) $writer.Flush() } finally { $stream.Dispose() } Write-Host "BarberShopStyle: rows=$($rows.Count), added-hair=$addedHair, added-appearance=$addedFacial" } $locale = Join-Path $ClientPath 'Data\ruRU' $baseArchive = Join-Path $locale 'patch-ruRU-3.MPQ' $oldA = Join-Path $locale 'patch-ruRU-A.mpq' $oldB = Join-Path $locale 'patch-ruRU-B.mpq' $oldI = Join-Path $locale 'patch-ruRU-I.mpq' $oldS = Join-Path $locale 'patch-ruRU-S.mpq' $newB = Join-Path $ProjectRoot 'new customization mpqs\patch-b-002.mpq' $baseModel = Read-Wdbc (Export-MpqFile $baseArchive 'DBFilesClient\CreatureModelData.dbc' 'base-CreatureModelData.dbc') $baseDisplay = Read-Wdbc (Export-MpqFile $baseArchive 'DBFilesClient\CreatureDisplayInfo.dbc' 'base-CreatureDisplayInfo.dbc') $baseExtra = Read-Wdbc (Export-MpqFile $baseArchive 'DBFilesClient\CreatureDisplayInfoExtra.dbc' 'base-CreatureDisplayInfoExtra.dbc') $oldModel = Read-Wdbc (Export-MpqFile $oldA 'DBFilesClient\CreatureModelData.dbc' 'old-CreatureModelData.dbc') $oldDisplay = Read-Wdbc (Export-MpqFile $oldB 'DBFilesClient\CreatureDisplayInfo.dbc' 'old-CreatureDisplayInfo.dbc') $oldExtra = Read-Wdbc (Export-MpqFile $oldI 'DBFilesClient\CreatureDisplayInfoExtra.dbc' 'old-CreatureDisplayInfoExtra.dbc') $newModel = Read-Wdbc (Export-MpqFile $newB 'DBFilesClient\CreatureModelData.dbc' 'new-CreatureModelData.dbc') $newDisplay = Read-Wdbc (Export-MpqFile $newB 'DBFilesClient\CreatureDisplayInfo.dbc' 'new-CreatureDisplayInfo.dbc') $newExtra = Read-Wdbc (Export-MpqFile $newB 'DBFilesClient\CreatureDisplayInfoExtra.dbc' 'new-CreatureDisplayInfoExtra.dbc') $spellAttach = Export-MpqFile $oldS 'DBFilesClient\SpellVisualKitModelAttach.dbc' 'SpellVisualKitModelAttach.dbc' $baseBarber = Read-WdbcRows (Export-MpqFile $baseArchive 'DBFilesClient\BarberShopStyle.dbc' 'base-BarberShopStyle.dbc') $newHairGeosets = Read-WdbcRows (Export-MpqFile $newB 'DBFilesClient\CharHairGeosets.dbc' 'new-CharHairGeosets.dbc') $newFacialHair = Read-WdbcRows (Export-MpqFile $newB 'DBFilesClient\CharacterFacialHairStyles.dbc' 'new-CharacterFacialHairStyles.dbc') $modelSelected = Select-DeltaMerge $baseModel $oldModel $newModel @(2) 'CreatureModelData' $displaySelected = Select-DeltaMerge $baseDisplay $oldDisplay $newDisplay @(6,7,8,9) 'CreatureDisplayInfo' $extraSelected = Select-DisplayExtraMerge $baseExtra $oldExtra $newExtra $mergedModel = Join-Path $work 'merged-CreatureModelData.dbc' $mergedDisplay = Join-Path $work 'merged-CreatureDisplayInfo.dbc' $mergedExtra = Join-Path $work 'merged-CreatureDisplayInfoExtra.dbc' $extendedBarber = Join-Path $work 'extended-BarberShopStyle.dbc' Write-Wdbc $newModel $modelSelected @(2) $mergedModel Write-Wdbc $newDisplay $displaySelected @(6,7,8,9) $mergedDisplay Write-Wdbc $newExtra $extraSelected @(20) $mergedExtra Write-ExtendedBarberShopStyle $baseBarber $newHairGeosets $newFacialHair $extendedBarber $baseTarget = Join-Path $ProjectRoot 'assets\dbc\3.3.5a' $compatTarget = Join-Path $ProjectRoot 'src\Data\ruRU\patch-ruRU-Z\DBFilesClient' New-Item -ItemType Directory -Path $compatTarget -Force | Out-Null Copy-Item -LiteralPath $mergedModel -Destination (Join-Path $baseTarget 'CreatureModelData.dbc') -Force Copy-Item -LiteralPath $mergedDisplay -Destination (Join-Path $baseTarget 'CreatureDisplayInfo.dbc') -Force Copy-Item -LiteralPath $mergedExtra -Destination (Join-Path $compatTarget 'CreatureDisplayInfoExtra.dbc') -Force Copy-Item -LiteralPath $spellAttach -Destination (Join-Path $compatTarget 'SpellVisualKitModelAttach.dbc') -Force Copy-Item -LiteralPath $extendedBarber -Destination (Join-Path $compatTarget 'BarberShopStyle.dbc') -Force $serverTarget = Join-Path $work 'server-dbc' New-Item -ItemType Directory -Path $serverTarget -Force | Out-Null Copy-Item -LiteralPath $extendedBarber -Destination (Join-Path $serverTarget 'BarberShopStyle.dbc') -Force Write-Host "Compatibility sources updated in $baseTarget and $compatTarget" Write-Host "Matching server DBC staged in $serverTarget"