Skip to content

Statics defrag, map region tools and UOP client fixes - #179

Merged
AsYlum- merged 15 commits into
masterfrom
fix/uop-map-statics-gumps
Sep 13, 2026
Merged

AsYlum- merged 15 commits into
masterfrom
fix/uop-map-statics-gumps

Conversation

@AsYlum-

@AsYlum- AsYlum- commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Added

Statics defrag — rewritten around an explicit set of filters: item id ceiling, out-of-block offsets, invalid z, negative hue, statics buried under terrain, duplicates and stacked statics. Every run produces a report naming what was removed instead of dropping tiles silently, runs on a background worker with a working Cancel, and offers a Verify pass that reads the result back and reconciles it against the source. The two blind menu items are replaced by one options dialog.

Map region copy and diff insert — both now run on clients whose maps are UOP-only; the diff tool previously refused outright. An identity copy is byte-exact, output can be mul or uop, and both dialogs gained a draggable region preview with pan and zoom, so a region is picked on the map rather than typed as coordinates. A copied region can also be shifted in z, with the dialog saying up front how far it can move before the signed byte runs out.

Save format — a per-profile option for whether art, gumpart, sound, MultiCollection and maps are written as mul or uop, following the loaded client or chosen at save time. Saving on a modern client no longer produces files that client cannot read.

TileData bulk editing — both lists are multi-select, with a leave-this-alone state for fields the selection disagrees on, Copy tile data / Paste special, and a one-level undo of the last bulk apply.

Clipboard art — Items, Land Tiles, Gumps and Textures copy and paste images directly, alpha preserved, and mark the entries edited since load.

Fixed

  • Hued gumps took the process down on UOP clients.
  • Browsing textures on older clients threw from a paint handler: unused index rows were being read as verdata patches.
  • AnimationSequence group records were skipped or walked at a fixed stride, so some bodies never resolved the action aliases the file declares.
  • The land block sentinel was sized in bytes rather than tiles, which misaligned maps written by the copy tool.
  • Diff patch bounds were wrong on TerMur, and facet 5 diff files could never be resolved.
  • Duplicate statics were keyed on hue in three of the map forms, which let stacked tiles survive a defrag.

Map.DefragStatics wrapped the rewrite in a bare catch that, on any error,
mutated its loop variables and zero-filled every remaining block. It never
checked that a block length was a whole number of records or that its
lookup was inside the file, kept out-of-block offsets and z == -128, and
keyed duplicates on hue so stacked water survived.

Ultima/Statics replaces it: every filter explicit, every anomaly a counter
on the result rather than control flow. StaticsComparer reads a run back
and compares each block as a multiset, so an unfiltered run must come back
identical and a filtered one may only be missing what the counters name.
Map.DefragStatics stays as an obsolete shim so plugins keep compiling.
The two menu items ran on the UI thread under a wait cursor, offered no
choice beyond duplicate removal, and always reported success - including
when the source files were missing and nothing had been written.

Both are replaced by one entry opening MapDefragStaticsForm: each filter a
checkbox, the resolved item id ceiling shown, the block grid compared with
the index actually on disk, and the run on a background worker with a
working Cancel. The result form saves or copies the report, and its Verify
button reads both files back and reconciles every static against the
filters.
TileMatrix allocated InvalidLandBlock as Tile[196]. 196 is the on-disk byte
size of a block, not its 64 tiles, so a consumer that enumerates the block
wrote 588 bytes instead of 192 and misaligned every block after it.

TileMatrixPatch took _blockHeight from BlockWidth, so on TerMur - the only
facet taller than it is wide - every patched block from y 160 up was
reported unpatched and its diff data dropped. Files registered the mapdif
and stadif families for indices 0 to 4 only, so facet 5 diffs could never
be resolved at all.
The only code in the tree that could write a UOP container lived in the
UopPacker plugin, which nothing in the app can reference, so the map tools
could read a UOP map and then only ever write a mul back.

LegacyMulFileConverter and its helpers move to Ultima/Uop, the container
framing splits out into UopContainerWriter, and the entry-table walk that
was private to TileMatrix becomes MapUopReader. MapSizes measures a facet
shape from the files rather than a hardcoded table, and MapUopWriter and
the block sinks let a caller emit a map one block at a time as either
container. Packing is byte-identical before and after.
The read-validate-filter-write loop over a staidx/statics pair existed in
five places, and only the defragmenter copy checked block lengths, lookup
bounds and out-of-block offsets. The three forms still keyed duplicates on
hue, which is what let stacked water survive.

StaticsBlockIo holds those pieces on their own - index reader, block writer,
per-static filter and a shared counter interface - and the defragmenter is
rewired onto them, keeping only what is its own.
Both features lived in button handlers: raw streams without using, no bounds
check on the computed block, and a progress bar stepped once per block on
the UI thread. MapDiffInsertForm also looked up map{N}.mul and nothing else,
so it refused outright on a client whose maps are UOP-only.

MapRegionCopier and MapDiffApplier take the work over and read through
TileMatrix, so both run on a modern client. Land keeps its 4-byte block
header instead of having it rewritten as zero, which makes an identity copy
byte-exact. The dialogs get exclusive bounds, normalised rectangles, a
background worker with a working Cancel, and a result form that reads the
output back and verifies it.
Both dialogs described the region only as coordinates, which says where it
is but nothing about what is there.

MapRegionPreview draws a window of a facet with the block-aligned region
outlined on it, and the region can be dragged. It keeps a view of its own,
an origin and a span in blocks, so the left button changes only the
selection, the right button pans and the wheel zooms about the block under
the pointer. Rendering goes through the existing Map.GetImage family on a
worker behind a debounce.

Reading a land block past the end of a short map file now returns empty
rather than throwing, which is what the statics side already does and what
clicking around the preview needs.
A region taken from one facet rarely sits at the right height in another.
A Z adjust beside the paste position shifts the land and the statics of the
region by the same amount and touches nothing outside it.

Both heights are a signed byte, so the dialog surveys the region on a worker
and says what the shift makes of it, and the copy refuses one that does not
fit, naming how far the region can actually move. A checkbox clamps at the
limit instead, and verification compares against the source shifted by the
same amount. The diff dialog deliberately has none of this: its records are
cut against the facet they belong to.
Art, gumpart, sound, MultiCollection and maps all ship in either container,
but only the map tools ever offered the choice, so saving on a modern client
produced files that client does not read.

SaveFormat is a per-profile option - FollowSource, Mul, Uop or Ask - answered
per file, since a client can be UOP art and MUL gumps. ClientFileSaver writes
the mul into a temporary folder and packs it, so no domain class learns about
UOP. MultiCollection needs the client housing.bin and the per-tile component
ids that live only in the UOP, so it unpacks the loaded container first and
refuses when there is none.
An unused idx row carries lookup 0 and length -1, and -1 has the same high
bit that marks a verdata patch, so every one of those rows looked like a
patch of length 0x7FFFFFFF. Browsing the textures of a 2005 client then read
that many bytes out of Stream.Null and threw from a paint handler.

The bit test now also requires the length not to be -1; a real patch never
reaches 0x7FFFFFFF, so the two cannot be confused. Gumps had its own copy of
the test. Textures.GetTexture also sized its read from the entry 64/128 flag
without checking the declared length, so a truncated file over-read the same
way.
Giving a set of items the same flags meant exporting to CSV, editing it
elsewhere and importing it back. Both tiledata lists are now multi-select and
the editor pane gains a leave-this-alone state: it blanks every box the
selection disagrees on, greys every flag that is not uniformly set or clear,
and Save Changes writes only what was actually edited.

Also adds Copy tile data and Paste special, and a one-level undo of the last
bulk apply, both through one apply engine. Fixes a bug in the code this
replaces: OnFlagItemCheckLandTiles mapped the checkbox index through a stale
switch, so toggling a land flag flipped the wrong bit.
Getting a graphic in or out of the Items, Land Tiles, Gumps or Textures tab
meant a file dialog either way. All four tabs now copy and paste images: copy
puts both a DIB and a PNG on the clipboard, PNG being the only flavour that
carries alpha, and paste reads PNG first, then the DIB, then a file. An image
with a real alpha channel is taken at its word; one without still goes
through ConvertBmp, so paste and Replace agree. Wrong sizes are refused
rather than fitted.

The second half is telling afterwards what changed. Art, Gumps and Textures
now mark every Replace and Remove, clear the marks on reload and after save,
and the draw handlers paint a corner wedge over a marked entry.
The hued GetGump overload was the last caller of the legacy FileIndex.Seek,
so on a client with gumpartlegacymul.uop it read the compressed payload as
raw RLE, derived garbage row offsets and walked the pixel pointer off the
buffer. That surfaces as an AccessViolationException, which takes the process
down rather than reaching the exception handler.

It now decodes through the regular GetGump path, which already covers the
index, removed, replaced and decompression checks, and hues a private clone.
That retires a static pixel buffer the old decoder returned a Bitmap over,
which the following call would overwrite. IsValidIndex and GetGump also guard
against a negative index.
ParseSequenceEntry walked each body group records with a fixed 72 byte
stride and skipped the walk outright when the group count was 48 or 68. Both
are wrong, and they hide each other: 72 bytes is only the record with both
trailing lists empty, and 48 and 68 are the counts of the bodies whose
records are not.

A record is 64 bytes of fixed fields followed by a counted list of 32 byte
property records and a counted list of ints, each count checked against the
bytes remaining before it is used. On 7.0.114.4 the bodies with aliases go
from 383 to 385: body 666 gains 67->66, and body 1253 gains 2->0, 3->1 and
42->38.
Bump UoFiddler to 4.24.0 and add the change log entry for this release:
the statics defrag rewrite, the map copy and diff work with the region
preview, the per-profile save format, and the texture, gump and
AnimationSequence fixes.
@AsYlum- AsYlum- changed the title Fix/uop map statics gumps Statics defrag, map region tools and UOP client fixes Sep 13, 2026
@AsYlum-
AsYlum- marked this pull request as ready for review September 13, 2026 12:38
@AsYlum-
AsYlum- merged commit 610c930 into master Sep 13, 2026
1 check passed
@AsYlum-
AsYlum- deleted the fix/uop-map-statics-gumps branch September 13, 2026 12:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant