Files
servuo-plugins/tools/patch_client.ps1
wtclaude 0ce92152a1 feat(asset-bridge): phase 0 spike — the decoders, from inside a live shard
docs/link/v8.md §16 phase 0. §4 chose to CALL ServUO's vendored `Ultima`
rather than reimplement it, on the evidence of a PowerShell probe against a
stock client — neither the process nor the client the extractor will run in.
This runs the same decoders from inside a running ServUO 57.4 against a
client broken in 21 catalogued ways, and it found more than a crash.

Adds, all under tools/ and therefore never deployed:

  * BridgeAssetProbe.cs — the sweep, plus BridgeAssetValidator, a prototype
    of the validate-before-calling response chosen for §4.2's residual risk.
    Runs off the Core thread, snapshots Race.AllRaces on it, and writes the
    id it is ABOUT to touch to a checkpoint file before every call.
  * BridgeMythicCliloc.cs — the §9 Mythic cliloc reader in net48 C#, ported
    from UOFiddler (Beerware) with every file-derived index bounds-checked.
    Phase 2 promotes this into overlay/.
  * patch_client.ps1 — builds the patched client in five tiers. Hashes every
    file it touches in the SOURCE before and after and aborts on a change.
  * an `assetprobe` verb on BridgeRigDriver, so stock and patched can be run
    against one boot rather than two shard processes.

The findings are written up in tools/scaffolding/README.md. The four that
change what phase 1 has to build:

  * FileIndex's UOP constructor ends `MulPath = uopPath`, so artLegacyMUL.uop
    wins outright and art.mul/artidx.mul are never opened on a current
    client. A validator bounding offsets against art.mul is not approximate,
    it is nonsense — the first run refused 34,299 good statics on that
    mistake, and every refusal looked like a real finding.

  * 22,102 WRONG PICTURES on a stock, unmodified client. Empty UOP index
    slots read `lookup 0, length 0`; Seek treats that as a hit, and
    LoadStatic decodes zero bytes into a shared buffer it reuses, only ever
    grows, and fills from a Read whose return value is discarded — so the id
    renders the previously-decoded asset. The mul path does not do this
    (artidx stores -1), which is why the earlier probe counted 32,766 of
    them as "ok". A bulk import that trusted the library would have written
    22,102 duplicate images under ids that have no art.

  * The validator caught all 8 record-level defects — 7 of which the library
    rendered without raising anything, including a verdata lookup past
    verdata.mul's own end (Verdata.Seek is bounds-checked nowhere) and an
    8000x8000 bitmap allocated from two bytes in a file. It refused NOTHING
    across 49,151 statics and 16,384 land tiles on the stock client, which
    is the number that makes the boundary defensible.

  * §4.1's crash reproduces in-process: one Ultima.Gumps.GetGump(2) and the
    ServUO process disappeared — no catch reached, no console line, the
    checkpoint file the only record. "Nothing calls Ultima.Gumps" is now an
    earned safety rule.

§9 is proven: 123,490 entries in 218 ms, byte-identical to UOFiddler's own
output, with no UOFiddler installed and nothing copied to a server.

Not covered, and named as phase 1 work: the animation path has no validator
at all, and the patched wolf decoded something else in silence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 03:05:32 -05:00

581 lines
26 KiB
PowerShell

<#
.SYNOPSIS
Builds a deliberately patched UO client for the Asset Bridge phase 0 spike.
.DESCRIPTION
docs/link/v8.md section 16 phase 0 drives ServUO's vendored `Ultima` decoders "over a deliberately
patched client". Stock clients are not the interesting case: they are the case the library was
written against, and the whole reason phase 0 exists is that section 4 chose to call code that can
take the shard down if it is wrong. A shard operator's client is patched -- custom art, a
verdata.mul, a hand-edited Bodyconv.def -- and that is what has to be survived.
This copies a client and then breaks the copy in four deliberate, catalogued ways. It NEVER
writes to the source: every file it patches is hashed before and after, and a changed source
hash aborts the run.
Each defect is recorded in `patched-client.manifest.json` next to the copy, so the probe's
report can be read against what was actually done rather than against a memory of it. The
manifest is the answer to "is a nonzero REFUSED-BUT-DECODED count a bug or the point?".
.PARAMETER Source
The client to copy. Defaults to this machine's.
.PARAMETER Dest
Where to build the patched copy. Needs ~3.5 GB.
.PARAMETER Tiers
Which defects to apply. Default: all four.
verdata Author a verdata.mul, which this client does not have. Ultima consults Verdata on
EVERY art and anim lookup (Art's FileIndex is built with verdata file id 4,
Animations' with 6), so on a client with no verdata.mul that entire branch is
dead code that has never been exercised -- the largest untested surface in the
library we are about to depend on. Includes one legitimate patch and one whose
lookup points past verdata.mul's own end, because `FileIndex.Seek` bounds-checks
the mul and does not bounds-check verdata.
customart Fill unused artidx.mul slots with real records appended to art.mul, the way a
custom-art shard does. Tests that our out-of-range accounting comes from the
file rather than from a constant someone wrote down.
corrupt Rewrite index entries and record headers into the shapes that reading Art.cs
says are reachable: a lookup past EOF, a record that starts inside the file and
ends outside it, a length too small for a header, absurd dimensions, a row table
pointing outside its own record, and a land tile shorter than the fixed 2,024
bytes LoadLand always reads.
bodyconv Add Bodyconv.def lines pointing bodies at an anim file that holds nothing, and at
an index in another file that holds something unrelated -- the gargoyle-666 spider
case, reproduced on purpose. Proves the extractor takes BodyConverter.Convert's
answer and stops (v8.md section 4.3).
nouop Move artLegacyMUL.uop aside, so art is read from art.mul/artidx.mul.
This is not cosmetic and it is not optional if you want the customart or corrupt
tiers to mean anything. FileIndex's UOP constructor ends with a bare
`MulPath = uopPath`: when artLegacyMUL.uop is present it wins OUTRIGHT and
art.mul / artidx.mul are never opened. Every index-level defect below writes to
files the library does not read on a modern client, so without this tier those
two tiers are inert while still reporting that they applied.
It is also a real configuration in its own right: plenty of shards run mul-only
clients, and a custom-art shard that adds graphics to art.mul while the UOP is
still there gets nothing at all -- an operator trap worth knowing about.
.PARAMETER SkipCopy
Re-patch an existing copy without re-copying 3.5 GB. Only safe on a copy this script made and
has not patched yet -- patching twice compounds the defects and invalidates the manifest.
.EXAMPLE
.\tools\patch_client.ps1 -Dest D:\uo-patched-client
.NOTES
Test scaffolding. Never deployed. The copy contains EA's client art -- like every other
extraction in this project it stays on the machine that made it and is never committed.
#>
[CmdletBinding()]
param(
[string] $Source = 'D:\Games\Electronic Arts\Ultima Online Classic',
[Parameter(Mandatory = $true)]
[string] $Dest,
[ValidateSet('verdata', 'customart', 'corrupt', 'bodyconv', 'nouop')]
[string[]] $Tiers = @('nouop', 'verdata', 'customart', 'corrupt', 'bodyconv'),
[switch] $SkipCopy,
[switch] $Force
)
$ErrorActionPreference = 'Stop'
# Files this script may write to in the copy. Anything not on this list is a bug in the script,
# and the source-hash check at the end is what proves it.
$PatchTargets = @('artidx.mul', 'art.mul', 'verdata.mul', 'Bodyconv.def', 'artLegacyMUL.uop')
# -- Little-endian helpers (BitConverter is fine, but the intent reads better named) ----------
function Read-Int32LE([byte[]] $Bytes, [int] $Offset) {
return [BitConverter]::ToInt32($Bytes, $Offset)
}
function Write-Int32LE([byte[]] $Bytes, [int] $Offset, [int] $Value) {
[Array]::Copy([BitConverter]::GetBytes([int] $Value), 0, $Bytes, $Offset, 4)
}
function Get-ArtEntry([byte[]] $Idx, [int] $Index) {
$at = $Index * 12
return [pscustomobject]@{
Index = $Index
Lookup = Read-Int32LE $Idx $at
Length = Read-Int32LE $Idx ($at + 4)
Extra = Read-Int32LE $Idx ($at + 8)
}
}
function Set-ArtEntry([byte[]] $Idx, [int] $Index, [int] $Lookup, [int] $Length, [int] $Extra) {
$at = $Index * 12
Write-Int32LE $Idx $at $Lookup
Write-Int32LE $Idx ($at + 4) $Length
Write-Int32LE $Idx ($at + 8) $Extra
}
# The defect catalogue. Every mutation appends to this, and it is written out as the manifest.
$script:Defects = New-Object System.Collections.ArrayList
function Add-Defect([string] $Tier, [string] $Key, [string] $What, [string] $Expect) {
[void] $script:Defects.Add([pscustomobject]@{
tier = $Tier
key = $Key
what = $What
expect = $Expect
})
Write-Host (" {0,-22} {1}" -f $Key, $What)
}
# -- Preflight --------------------------------------------------------------------------------
if (-not (Test-Path -LiteralPath $Source)) {
throw "source client not found: $Source"
}
$sourceFull = (Resolve-Path -LiteralPath $Source).Path
if (Test-Path -LiteralPath $Dest) {
$destFull = (Resolve-Path -LiteralPath $Dest).Path
if ($destFull -eq $sourceFull) {
throw "Dest is the source client. Refusing -- this script destroys what it points at."
}
if (-not $SkipCopy -and -not $Force) {
throw "$Dest already exists. Pass -Force to overwrite it, or -SkipCopy to patch it in place."
}
}
Write-Host "source: $sourceFull"
Write-Host "dest: $Dest"
Write-Host "tiers: $($Tiers -join ', ')"
Write-Host ""
# Hash the source files we are about to touch, so "it never writes to the source" is checked and
# not merely asserted.
$before = @{}
foreach ($name in $PatchTargets) {
$path = Join-Path $sourceFull $name
if (Test-Path -LiteralPath $path) {
$before[$name] = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash
}
}
# -- Copy -------------------------------------------------------------------------------------
if ($SkipCopy) {
Write-Host "skipping copy (-SkipCopy)"
if (-not (Test-Path -LiteralPath $Dest)) { throw "-SkipCopy but $Dest does not exist" }
} else {
Write-Host "copying (this is ~3.5 GB; a few minutes)..."
# /MIR so a -Force re-run starts clean rather than merging into an already-patched tree.
# /NJH /NJS /NDL /NFL keep robocopy's output to the errors.
$null = robocopy $sourceFull $Dest /MIR /R:1 /W:1 /NJH /NJS /NDL /NFL /NP
# Robocopy exit codes below 8 are success; 8 and above are real failures.
if ($LASTEXITCODE -ge 8) { throw "robocopy failed with exit code $LASTEXITCODE" }
# Robocopy's "1 = files were copied" would otherwise become this script's exit code and read
# as a failure to anything checking it.
$global:LASTEXITCODE = 0
Write-Host "copied."
}
Write-Host ""
$destFull = (Resolve-Path -LiteralPath $Dest).Path
$artIdxPath = Join-Path $destFull 'artidx.mul'
$artMulPath = Join-Path $destFull 'art.mul'
if (-not (Test-Path -LiteralPath $artIdxPath)) { throw "no artidx.mul in the copy" }
$idx = [System.IO.File]::ReadAllBytes($artIdxPath)
$entryCount = [int] ($idx.Length / 12)
$artMulLength = (Get-Item -LiteralPath $artMulPath).Length
Write-Host ("artidx.mul holds {0:N0} entries; art.mul is {1:N0} bytes" -f $entryCount, $artMulLength)
Write-Host ""
# -- Tier: nouop ------------------------------------------------------------------------------
$uopPath = Join-Path $destFull 'artLegacyMUL.uop'
$uopPresent = Test-Path -LiteralPath $uopPath
if ($Tiers -contains 'nouop') {
Write-Host "tier nouop"
if (-not $uopPresent) {
Write-Host " no artLegacyMUL.uop in the copy -- already a mul-only client"
} else {
Move-Item -LiteralPath $uopPath -Destination "$uopPath.disabled" -Force
$uopPresent = $false
Add-Defect 'nouop' 'artLegacyMUL.uop' 'moved aside so art is read from art.mul/artidx.mul' `
'every index-level defect below becomes reachable; without this they are inert'
}
Write-Host ""
} elseif ($uopPresent -and (($Tiers -contains 'corrupt') -or ($Tiers -contains 'customart'))) {
Write-Host " WARNING: artLegacyMUL.uop is present and the nouop tier was not selected."
Write-Host " FileIndex prefers the UOP outright, so the corrupt and customart tiers"
Write-Host " will write to files the library never opens. Add -Tiers nouop."
Write-Host ""
}
# Static ids are offset by 0x4000 in the index; land tiles occupy 0..0x3FFF.
$StaticBase = 0x4000
# Find donor records to copy and victims to corrupt: real, modestly sized statics, so the defects
# are applied to entries that genuinely work today. Picking arbitrary ids risks landing on slots
# that are already empty, where a "defect" would prove nothing.
$donors = New-Object System.Collections.ArrayList
for ($id = 0x1000; $id -lt 0x3000 -and $donors.Count -lt 24; $id++) {
$e = Get-ArtEntry $idx ($id + $StaticBase)
if ($e.Lookup -ge 0 -and $e.Length -gt 200 -and $e.Length -lt 4000 -and ($e.Lookup + $e.Length) -le $artMulLength) {
[void] $donors.Add([pscustomobject]@{ Id = $id; Entry = $e })
}
}
if ($donors.Count -lt 12) { throw "found only $($donors.Count) usable donor statics -- the copy looks wrong" }
Write-Host "using donor statics: $(($donors | Select-Object -First 12 | ForEach-Object { $_.Id }) -join ', ')"
Write-Host ""
$idxDirty = $false
# -- Tier: customart --------------------------------------------------------------------------
if ($Tiers -contains 'customart') {
Write-Host "tier customart"
# A custom-art client does not fill spare slots -- artidx.mul is exactly sized (62,692
# entries here, not one to spare), so adding art means GROWING the index. `Art` builds its
# FileIndex with length 0x10000, so there is room for 2,844 more ids before the library stops
# looking, and the stock ceiling turns out to be nothing more than the size of a file.
$idxCeiling = 0x10000
if ($entryCount -ge $idxCeiling) {
Write-Host " artidx.mul is already at the 0x10000 ceiling -- skipping tier"
} else {
$addCount = 8
$grown = New-Object byte[] (($entryCount + $addCount) * 12)
[Array]::Copy($idx, 0, $grown, 0, $idx.Length)
$idx = $grown
# Read every donor record BEFORE opening the append handle. Append mode takes an
# exclusive lock, so reading the same file while appending to it fails outright.
$buffers = @()
$reader = [System.IO.File]::Open($artMulPath, 'Open', 'Read', 'ReadWrite')
try {
for ($n = 0; $n -lt $addCount; $n++) {
$donor = $donors[$n]
$buffer = New-Object byte[] $donor.Entry.Length
[void] $reader.Seek($donor.Entry.Lookup, 'Begin')
[void] $reader.Read($buffer, 0, $buffer.Length)
$buffers += , $buffer
}
} finally { $reader.Dispose() }
$appendAt = $artMulLength
$stream = [System.IO.File]::Open($artMulPath, 'Append', 'Write')
try {
for ($n = 0; $n -lt $addCount; $n++) {
$buffer = $buffers[$n]
$stream.Write($buffer, 0, $buffer.Length)
$slot = $entryCount + $n
$newId = $slot - $StaticBase
Set-ArtEntry $idx $slot $appendAt $buffer.Length $donors[$n].Entry.Extra
$appendAt += $buffer.Length
Add-Defect 'customart' "static/$newId" `
"custom art appended past the stock ceiling (a copy of static/$($donors[$n].Id))" `
'decodes cleanly; proves the ceiling is read from the file, not from a constant'
}
} finally { $stream.Dispose() }
$entryCount += $addCount
$idxDirty = $true
}
Write-Host ""
}
# -- Tier: corrupt ----------------------------------------------------------------------------
if ($Tiers -contains 'corrupt') {
Write-Host "tier corrupt"
$artMulLength = (Get-Item -LiteralPath $artMulPath).Length
$v = 8 # donors 0..7 may have been consumed by customart as sources; they are unmodified
# 1. A lookup past the end of art.mul. FileIndex.Seek DOES check this one
# (`Stream.Length < e.lookup`), so the library and the validator should agree.
$victim = $donors[$v++].Id
Set-ArtEntry $idx ($victim + $StaticBase) ([int]($artMulLength + 4096)) 512 0
Add-Defect 'corrupt' "static/$victim" 'lookup 4 KB past the end of art.mul' `
'refused by the validator; Seek also catches this one, so no picture'
# 2. A record that STARTS inside the file and ENDS outside it. This is the gap: Seek checks
# the start and never the end, stream.Read returns short, the decoders ignore the count,
# and m_StreamBuffer still holds the PREVIOUS asset. The expected outcome is a picture of
# something else entirely, reported as a success by every count in the library.
$victim = $donors[$v++].Id
Set-ArtEntry $idx ($victim + $StaticBase) ([int]($artMulLength - 64)) 8192 0
Add-Defect 'corrupt' "static/$victim" 'record starts 64 bytes before EOF and declares 8,192' `
'REFUSED BUT DECODED -- the stale-buffer wrong picture'
# 3. A length too small to hold even the 8-byte header.
$victim = $donors[$v++].Id
$donorEntry = $donors[$v - 1].Entry
Set-ArtEntry $idx ($victim + $StaticBase) $donorEntry.Lookup 4 0
Add-Defect 'corrupt' "static/$victim" 'declared length 4 -- smaller than the static header' `
'refused by the validator'
# 4/5/6 rewrite the record BODY, so they need their own bytes rather than an index edit.
# Appended to art.mul and pointed at, which leaves the donor's real record intact.
$stream = [System.IO.File]::Open($artMulPath, 'Append', 'Write')
try {
$appendAt = (Get-Item -LiteralPath $artMulPath).Length
# 4. Absurd dimensions. LoadStatic allocates new Bitmap(width, height) straight from two
# ushorts in the file. 8000x8000 is ~128 MB -- survivable, and the point is made; the
# same field can ask for 65535x65535, which is 8 GB from a two-byte edit.
$victim = $donors[$v++].Id
$rec = New-Object byte[] 2048
[Array]::Copy([BitConverter]::GetBytes([uint16] 8000), 0, $rec, 4, 2)
[Array]::Copy([BitConverter]::GetBytes([uint16] 8000), 0, $rec, 6, 2)
$stream.Write($rec, 0, $rec.Length)
Set-ArtEntry $idx ($victim + $StaticBase) $appendAt $rec.Length 0
$appendAt += $rec.Length
Add-Defect 'corrupt' "static/$victim" 'header declares 8000x8000 (a ~128 MB allocation from two bytes)' `
'refused by the validator; the library would allocate it'
# 5. A row-lookup table pointing outside the record. This is what LoadStatic's unbounded
# read cursor was written to walk off the end of.
$victim = $donors[$v++].Id
$rec = New-Object byte[] 512
[Array]::Copy([BitConverter]::GetBytes([uint16] 32), 0, $rec, 4, 2) # width
[Array]::Copy([BitConverter]::GetBytes([uint16] 32), 0, $rec, 6, 2) # height
for ($row = 0; $row -lt 32; $row++) {
# Each row's offset is added to (height + 4); 60000 puts every row far outside.
[Array]::Copy([BitConverter]::GetBytes([uint16] 60000), 0, $rec, (8 + $row * 2), 2)
}
$stream.Write($rec, 0, $rec.Length)
Set-ArtEntry $idx ($victim + $StaticBase) $appendAt $rec.Length 0
$appendAt += $rec.Length
Add-Defect 'corrupt' "static/$victim" 'row table points 60,000 words outside a 512-byte record' `
'refused by the validator; the library reads adjacent heap'
# 6. A well-formed row table whose run length overruns the record.
$victim = $donors[$v++].Id
$rec = New-Object byte[] 256
[Array]::Copy([BitConverter]::GetBytes([uint16] 16), 0, $rec, 4, 2)
[Array]::Copy([BitConverter]::GetBytes([uint16] 2), 0, $rec, 6, 2)
[Array]::Copy([BitConverter]::GetBytes([uint16] 0), 0, $rec, 8, 2) # row 0 offset
[Array]::Copy([BitConverter]::GetBytes([uint16] 0), 0, $rec, 10, 2) # row 1 offset
$runAt = (2 + 4) * 2 # (height + 4) words
[Array]::Copy([BitConverter]::GetBytes([uint16] 0), 0, $rec, $runAt, 2) # xOffset
[Array]::Copy([BitConverter]::GetBytes([uint16] 16), 0, $rec, ($runAt + 2), 2) # xRun, but
# the record has nowhere near 16 pixels left after this point.
$stream.Write($rec, 0, $rec.Length)
Set-ArtEntry $idx ($victim + $StaticBase) $appendAt 20 0
$appendAt += $rec.Length
Add-Defect 'corrupt' "static/$victim" 'a 16-pixel run declared in a 20-byte record' `
'refused by the validator'
} finally { $stream.Dispose() }
# 7. A land tile shorter than the 2,024 bytes LoadLand reads unconditionally.
$landVictim = 0x0100
$landEntry = Get-ArtEntry $idx $landVictim
if ($landEntry.Lookup -ge 0 -and $landEntry.Length -gt 0) {
Set-ArtEntry $idx $landVictim $landEntry.Lookup 512 0
Add-Defect 'corrupt' "land/$landVictim" 'land record declared 512 bytes; LoadLand always reads 2,024' `
'refused by the validator; the library reads past the buffer'
}
$idxDirty = $true
Write-Host ""
}
if ($idxDirty) {
[System.IO.File]::WriteAllBytes($artIdxPath, $idx)
Write-Host "wrote artidx.mul"
Write-Host ""
}
# -- Tier: verdata ----------------------------------------------------------------------------
if ($Tiers -contains 'verdata') {
Write-Host "tier verdata"
# Layout: int32 count, then count * 5 int32 (file, index, lookup, length, extra), then the
# payloads. `lookup` is an absolute offset into this file.
$entries = New-Object System.Collections.ArrayList
$payloads = New-Object System.Collections.ArrayList
$donorA = $donors[$donors.Count - 1]
$donorB = $donors[$donors.Count - 2]
$artSource = [System.IO.File]::Open($artMulPath, 'Open', 'Read', 'ReadWrite')
try {
$bufferA = New-Object byte[] $donorA.Entry.Length
[void] $artSource.Seek($donorA.Entry.Lookup, 'Begin')
[void] $artSource.Read($bufferA, 0, $bufferA.Length)
} finally { $artSource.Dispose() }
# The victims: ids whose art will now come from verdata.mul rather than art.mul.
$legitVictim = $donors[$donors.Count - 3].Id
$pastEofVictim = $donors[$donors.Count - 4].Id
# A legitimate patch -- the branch working as designed. Without this the tier only proves the
# failure case, and "verdata is broken" and "verdata is never reached" look identical.
[void] $payloads.Add($bufferA)
[void] $entries.Add([pscustomobject]@{
File = 4; Index = ($legitVictim + $StaticBase); Length = $bufferA.Length; Extra = $donorA.Entry.Extra
PayloadIndex = 0; PastEof = $false
})
# The failure case. FileIndex.Seek bounds-checks the mul stream and calls Verdata.Seek with no
# check at all; seeking a FileStream past EOF is legal, the read returns nothing, and the
# shared decode buffer still holds the previous asset.
[void] $entries.Add([pscustomobject]@{
File = 4; Index = ($pastEofVictim + $StaticBase); Length = 900; Extra = 0
PayloadIndex = -1; PastEof = $true
})
# An anim patch, so the tier covers the other file the verdata branch serves. anim.mul is
# verdata file 6; for body < 200 the record index is body*110 + action*5 + direction.
$animBody = 34 # wolf -- decodes on this client, so a patch to it is observable
$animIndex = ($animBody * 110) + (0 * 5) + 1
[void] $entries.Add([pscustomobject]@{
File = 6; Index = $animIndex; Length = 700; Extra = 0
PayloadIndex = -1; PastEof = $true
})
$headerSize = 4 + ($entries.Count * 20)
$offset = $headerSize
foreach ($entry in $entries) {
if ($entry.PayloadIndex -ge 0) {
$entry | Add-Member -NotePropertyName Lookup -NotePropertyValue $offset -Force
$offset += $payloads[$entry.PayloadIndex].Length
}
}
$totalSize = $offset
# Past-EOF lookups are resolved last, because "past the end" is only meaningful once the end
# is known.
foreach ($entry in $entries) {
if ($entry.PastEof) {
$entry | Add-Member -NotePropertyName Lookup -NotePropertyValue ($totalSize + 8192) -Force
}
}
$verdata = New-Object byte[] $totalSize
Write-Int32LE $verdata 0 $entries.Count
$at = 4
foreach ($entry in $entries) {
Write-Int32LE $verdata $at $entry.File
Write-Int32LE $verdata ($at + 4) $entry.Index
Write-Int32LE $verdata ($at + 8) $entry.Lookup
Write-Int32LE $verdata ($at + 12) $entry.Length
Write-Int32LE $verdata ($at + 16) $entry.Extra
$at += 20
}
foreach ($entry in $entries) {
if ($entry.PayloadIndex -ge 0) {
$payload = $payloads[$entry.PayloadIndex]
[Array]::Copy($payload, 0, $verdata, $entry.Lookup, $payload.Length)
}
}
[System.IO.File]::WriteAllBytes((Join-Path $destFull 'verdata.mul'), $verdata)
Add-Defect 'verdata' "static/$legitVictim" `
"legitimately patched to static/$($donorA.Id)'s art via verdata.mul" `
'decodes; the picture must CHANGE, which is how we know the branch ran'
Add-Defect 'verdata' "static/$pastEofVictim" `
'verdata entry whose lookup is 8 KB past the end of verdata.mul' `
'REFUSED BUT DECODED -- Verdata.Seek is not bounds-checked'
Add-Defect 'verdata' "body/$animBody" `
"anim.mul record $animIndex patched to a verdata offset past EOF" `
'the wolf must not silently become another creature'
Write-Host (" wrote verdata.mul: {0} entries, {1:N0} bytes" -f $entries.Count, $totalSize)
Write-Host ""
}
# -- Tier: bodyconv ---------------------------------------------------------------------------
if ($Tiers -contains 'bodyconv') {
Write-Host "tier bodyconv"
$bodyconvPath = Join-Path $destFull 'Bodyconv.def'
if (-not (Test-Path -LiteralPath $bodyconvPath)) {
Write-Host " no Bodyconv.def in the copy -- skipping tier"
} else {
# Columns are tab-separated: original, anim2, anim3, anim4, anim5. -1 means "not in that
# file". BodyConverter.Convert returns the file type of the FIRST column that is not -1,
# and the extractor must take that answer and stop.
$lines = @(
"",
"# Asset Bridge phase 0 -- deliberate defects (tools/patch_client.ps1)",
"1900`t-1`t-1`t-1`t60000",
"1901`t666`t-1`t-1`t-1"
)
Add-Content -LiteralPath $bodyconvPath -Value ($lines -join "`r`n") -Encoding ASCII
Add-Defect 'bodyconv' 'body/1900' 'mapped to anim5 index 60,000, which does not exist' `
'reports nothing -- and must NOT fall back to another anim file'
Add-Defect 'bodyconv' 'body/1901' 'mapped to anim2 index 666, where something unrelated lives' `
'decodes a picture of the WRONG creature -- the spider case, on purpose'
}
Write-Host ""
}
# -- The source must be untouched -------------------------------------------------------------
$tampered = @()
foreach ($name in $before.Keys) {
$path = Join-Path $sourceFull $name
$now = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash
if ($now -ne $before[$name]) { $tampered += $name }
}
if ($tampered.Count -gt 0) {
throw "THE SOURCE CLIENT WAS MODIFIED: $($tampered -join ', '). Restore it from the installer before doing anything else."
}
Write-Host "source client verified unchanged ($($before.Count) files hashed before and after)"
# -- Manifest ---------------------------------------------------------------------------------
$manifest = [pscustomobject]@{
built = (Get-Date).ToUniversalTime().ToString('u')
source = $sourceFull
dest = $destFull
tiers = $Tiers
defects = @($script:Defects)
}
$manifestPath = Join-Path $destFull 'patched-client.manifest.json'
$manifest | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $manifestPath -Encoding utf8
Write-Host ""
Write-Host ("{0} deliberate defects; manifest at {1}" -f $script:Defects.Count, $manifestPath)
Write-Host ""
Write-Host "Point the probe at it by adding to the shard's Config/Bridge.cfg:"
Write-Host ""
Write-Host " AssetProbeClient=$destFull"
Write-Host ""
Write-Host "then, in game or from the rig driver: [assetprobe all patched"