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
581 lines
26 KiB
PowerShell
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"
|