Companion to #2712, which covers HireRanger's refused `EquipItem`. A sweep that built every constructible Mobile 2,000 times under each expansion found these other outfits that put two items on one layer. They don't leak, because `AddItem` skips `CheckEquip`, but the NPC wears both items and the client draws only one.
| NPC | Conflict |
|---|---|
| Miner, Ranger, Thief | Own shirt/pants/ThighBoots added on top of the random `BaseVendor.InitOutfit` (shoes doubled on every spawn) |
| MasterTheophilus | Shoes + Robe added over `EvilMageLord`'s Sandals + Robe |
| Merchant | Shirt + FancyShirt, or PlainDress + FancyDress |
| IronWorker | Bandana/JesterHat + Bascinet |
| MeerCaptain | One-handed sword + Crossbow |
| BakeKitsune | `BodyMod` isn't saved, so after a restart `RemoveDisguise` bailed and the next `Disguise()` stacked a second robe and pair of shoes |
## Changes
- `BaseCreature.ReplaceEquipment(item)` equips an item in place of whatever an inherited outfit put on its layer, and deletes the item if it still can't be equipped.
- Miner, Ranger, Thief, MasterTheophilus: their own pieces replace the inherited ones.
- Merchant and IronWorker: dropped the duplicate roll. Each kept the item the client was already drawing, which is the last one sent: the fancy set and the Bascinet.
- MeerCaptain: the sword goes into its pack as loot. **Behavior change:** `Mobile.Weapon` checks the one-handed layer first, so it was fighting with the sword despite its archer AI, its Archery skill and the bolts in its pack. It now uses the crossbow.
- BakeKitsune: `RemoveDisguise` strips the disguise without checking `IsBodyMod`.
## Testing
- I re-ran the sweep (local diagnostic, not committed; needs client tiledata) at 500 rolls × 12 expansions. Only HireRanger (#2712) and LordOaks (separate fix coming) remain.
- UOContent.Tests: 1218 passed, 2 skipped.
ShortPants and StuddedLegs both occupy Layer.Pants, so the second EquipItem on a male ranger is refused and its item stays in the world with no parent. Nothing deletes such an item, so every male ranger created leaves one behind in the save.
StuddedLegs already cover the layer and female rangers wear them, so the male ranger now skips the shorts. A test creates and deletes rangers and checks that World.Items does not grow.
Part of #2687. Magic fish and footwear roll the same in every era: magic fish get rarer with skill (5% at 0 skill, 1% at GM) and footwear has a 20% chance at 0 skill. The leviathan rolls the generic 1% for its level 5 map.
A fishing table row can now end at an expansion, so each era gets its own magic fish and footwear rows. The chances at GM come from the Fishing Council of Britannia's cast counts before and after Publish 16, which came out in parts from April to December 2002, during LBR [O14]. The leviathan gets its 25% through a `BaseCreature.TreasureMapChance` override; leviathans only come from the fabled net, which already needs AOS.
| Change | Era | Source |
|---|---|---|
| Footwear: 75% at 0 skill, 2.5% at GM | T2A to UOTD | "The beginning fisherman will find this item on his hook most of the times" [S1–S7]; 2.5% of catches [F1] |
| Footwear: 75% at 0 skill, 10% at GM | LBR on | The same quote; 10.5% of catches [F2] |
| Magic fish: 80 skill, 2.5% at GM | T2A to UOTD | 80 from July 1999 to October 2002 [S1–S6, S9]; 2.4% of catches [F1] |
| Magic fish: no minimum, 5% at GM | LBR to SA | 0 from December 2002 [S7], so the minimum went in late 2002, during LBR; 5.6% of catches [F2] |
| Magic fish: 80 skill again | HS on | Still 0 in June 2010, during SA [S13]; 80 today [O9]. HS is the first expansion after that |
| Leviathan's map: 25% | AOS | Publish 27: "It also has a 25% chance to carry a level 5 treasure map." [O5] |
- These are chances per roll. The FCB's counts leave out empty casts, so they line up once the "nothing" row #2687 lists is gone (a follow-up PR removes it); while it stays, footwear is 3.2% of a GM's catches before LBR and 12% from LBR.
- No source counts footwear below GM, so 75% at 0 skill only follows Stratics' "most of the times". The numbers in my comment were placeholders.
- 80 again from HS is the one change beyond my comment, which proposed no minimum from LBR on.
The keys are #2687's, plus:
- [O14] uo.com, the 2002 publishes: Publish 16 in parts on April 23, July 23, September 20 and December 2, 2002. https://uo.com/wiki/ultima-online-wiki/technical/previous-publishes/2002-2/
- [F1] The Fishin' Statistician, 11,101 casts from August 2001 to April 2002, before Publish 16; skill not stated, most likely GM. https://web.archive.org/web/20020609120859/http://www.thefcb.com:80/novellettes/stats.html
- [F2] The same page, 7,371 casts from July 2002 to February 2003, after Publish 16; skill not stated either. https://web.archive.org/web/20030216074120/http://www.thefcb.com:80/novellettes/stats.html
- [S13] Stratics' fishing guide, captured June 5, 2010. https://web.archive.org/web/20100605065528/http://uo.stratics.com:80/content/skills/fishing.shtml
GetDelay sets min to 6.0 - del * 0.75 and max to 6.0 - del * 1.25, so for any Magery above zero min is the larger value. The draw min + (max - min) * r still covers the right interval, but it runs downward and the names say the opposite.
Swap the two definitions so min is the lower bound. The interval and its uniform distribution do not change; for a given random draw the delay comes out mirrored.
## Summary
`SendKarmaMessage` maps the karma change to a message, and its fallback arm is "You have lost a little karma." (1019063). A change of `0` lands in that arm, so the player is told they lost karma when nothing changed.
How to reproduce on a clean build: kill a creature with 0 karma, such as a grey wolf. The kill awards 0 karma and the player sees "You have lost a little karma."
This change returns early when the change is `0`, which matches how `AwardFame` already handles fame: it sends nothing when fame doesn't change.
## Test plan
- [x] `dotnet build` succeeds
- [x] Kill a 0-karma creature: no karma message
- [x] Kill a negative-karma creature: "gained karma" message is unchanged
Fixes#2700 (with #2704)
A restart (`restart` console command, `AutoRestart`, `CrashGuard`) launches a copy of the server and exits. That copy is detached from the console it was started from: the shell takes the terminal back and console commands stop working. Under systemd or in a container it's killed as soon as the original process exits.
- New setting `server.restartExitCode` (off by default). When it's set, a restart saves and shuts down as usual and then exits with that code, so a shell loop, systemd unit or container restart policy can start the server again on the same console. Values outside 1–255 log a warning and fall back to the current behaviour.
- Fixes `CrashGuard` crash recovery never restarting the server. Since #1809, `Kill()` only records the request, and the unhandled-exception path called `DoKill()` without it.
- Documents systemd, Docker and shell-loop setups in `dev-docs/server-requirements.md`.
`Core.DoKill(true)` built the relaunch command line by putting the assembly path into a raw argument string without quotes, so `dotnet` split it at the first space. That breaks `restart`, `AutoRestart` and `CrashGuard` recovery for any server installed under a path with a space.
- Pass the assembly path through `ProcessStartInfo.ArgumentList`, which quotes it.
- Relaunch through the host that started the process (`Environment.ProcessPath`): the `ModernUO` apphost runs directly, and `dotnet` gets the assembly path. Before this, a server started from the apphost came back under `dotnet`.
- Use one code path on all platforms. `UseShellExecute = true` on Unix made no difference for a program found on PATH.
This doesn't fix the second half of #2700, where the relaunched server loses the console (and gets killed under systemd or Docker). That comes from the server spawning a copy of itself and exiting, and a follow-up PR will add an opt-in restart exit code for a supervisor to act on.
**Testing:** relaunched from a directory with spaces, started both through `dotnet x.dll` and through the apphost, on Windows. The old code fails there with the error from the issue.
## Summary
Stacked on #2676. Review that first; this PR's diff is against it.
Items players sell to NPC vendors go into the vendor's buyback (`ShopBuy`) pack. That pack was purged only when someone opened the buy menu, so a vendor that players sold to but never bought from grew without limit.
- A server operator reported 250k+ items piling up from players crafting and selling on a macro.
- Every one of those items was saved.
- Purging or deleting that vendor then sent one remove packet per item to every nearby client and disconnected them.
The behavior is inherited from RunUO.
- **`VendorBuybackPack`** replaces the plain backpack on `Layer.ShopBuy`.
- It opts in to `SkipsChildSerialization`, so buyback items are never saved.
- Its contents are private like any container's, so removing them reaches only the vendor (no client) and openers. Purges and deletes send nothing to bystanders.
- **Capacity.** The buyback holds at most `250 − buy-list entries` items, which is what the buy gump can show. The oldest items are evicted first. At capacity 0 the sold item is consumed and the seller is still paid.
- **Purge on the restock grid.**
- `Restock()` empties the buyback.
- A purge-only timer, armed on the first sale, covers vendors nobody buys from. It is due at the next restock boundary after now, so a vendor whose restock hasn't run for hours doesn't purge an item the moment it's sold.
- The timer never restocks, so stock doubling and halving is unchanged.
- The old per-item one-hour `LastMoved` check is gone.
- **Existing saves.** A legacy `ShopBuy` pack is replaced on the first tick after load. Thanks to #2676, deleting a pack of 250k+ items takes well under a second.
**Behavior changes for shard operators:**
- A buyback item lasts until the next restock boundary (at most `RestockDelay`), not one hour after its sale.
- A vendor that changes maps drops its buyback.
- A buy window that is open during a purge can show a row whose item is gone. Buying that row does nothing, and reopening shows the current list.
## Test plan
- [x] `VendorBuybackPackTests`: evicts oldest first; capacity 0 consumes the item; purge; a stale front entry can't spin eviction
- [x] `VendorBuybackTests`:
- purge due time stays on the restock grid, including a stale restock and a restock time in the future;
- partial stack sale: the split goes to buyback and the seller keeps the remainder;
- zero capacity consumes the item and still pays;
- restock purges and clears the timer, and the next sale re-arms it;
- deleting the vendor cancels the timer;
- a legacy 20k-item pack is replaced.
- [x] UOContent.Tests and Server.Tests green; schema generator clean
- [x] Manual in-game pass:
- sell and buy back, including a partial stack;
- over-capacity sale;
- purge with no buyer;
- `[restock`;
- vendor facet change;
- restart;
- legacy save with a large `ShopBuy` pack;
- mass selling with bystanders present;
- deleting a vendor with a full buyback.
## Summary
Removing an item from a container now reaches only the clients that were sent the item. This PR also adds container-scoped skip serialization, makes container deletion linear, and closes a lift-rejection resync gap. It is the groundwork for the NPC vendor buyback fix (#2677), and it is safe to merge by itself.
### Container child removal reaches only the clients that were sent the item
Adds and updates for an item inside a private container have always gone only to the root mobile, the secure-trade partner, and the container's `Openers` (`ProcessDelta`, the same as RunUO). Removes, though, were broadcast to every client in range. That too is inherited from RunUO; #326 only converted the packets. Two costs:
- **Bandwidth.** One packet per nearby client for every reagent, bandage or arrow stack that runs out, and every container-to-container move.
- **Disclosure.** It sent the serial and timing of items moved in someone else's pack to every nearby client.
Now:
- **`Item.SendRemovePacket`:** a private container child's remove goes only to the root mobile, both secure-trade parties (found by walking up to the trade container, as `ProcessDelta` does), and the container's `Openers`. Each recipient is deduplicated and must be on the same map and in range.
- **`Container.IsChildPublic(Item child)`** (default `IsPublicContainer`) marks children that were sent to every client in range. Their removal still broadcasts. Overrides can only widen: a public container's children always broadcast.
- **`Corpse`** marks its worn gear public on human corpses. `SendInfoTo` shows that gear to everyone who sees the corpse, so bystanders still see looted gear disappear. Loot stays private, and corpses are not made public.
- **`IsPublicContainer` is unchanged** and remains the escape hatch.
- **Unchanged:** items equipped on mobiles, children of non-container items (spellbooks, bulletin boards), and ground items.
- **`Item.Map` no longer sends removes for contained items.** A contained item only changes map along with its parent or root, and that ancestor's own remove already clears the subtree for everyone who knew it. The owner keeps their nested bags across a facet change, as before, and bystanders no longer get a remove per nested item.
**For forks:** a custom container that sends its contents to clients other than the root, trade parties and openers must override `IsChildPublic` or `IsPublicContainer`. Otherwise those clients keep the item until they reopen the container, it leaves their range, or they log out.
- **`Item.MoveToWorld` no longer sends a second remove for an item that had a parent.** It used to detach through the parent's `RemoveItem` (correctly scoped), then broadcast another remove from the old location. Every lift goes through `Internalize()`, so every lift from a pack or container broadcast the item's serial. Ground items are unchanged.
- **Property-only updates** (`ItemDelta.Properties` without `Update`) of a private child now send the object property list revision only to the same recipients, not every client in range. Gump tooltips that reference such an item without the client holding it no longer get the revision nudge; their cached tooltip refreshes when re-requested.
### Lift rejection resyncs only items the requester was sent
A rejected lift re-sent the item's full data to the requester without checking that their client had ever been sent it. `Item.IsSentTo(Mobile)` now applies the same rule as the adds, including `CanSee`:
- **Private container children:** the root mobile, the trade parties, or the openers.
- **Everything else:** visible and in update range.
`Mobile.Lift` still always sends the lift-reject packet, but resyncs the item only when `IsSentTo` holds.
### Container content packets stay within the protocol limit
`SendContainerContent` stack-allocated `items.Count × 20` bytes, which overflowed the stack for a container with hundreds of thousands of items. The `0x3C` length and count fields are 16-bit anyway. It now caps entries so the packet fits (3448 entries, or 3276 with grid lines), and rents a pooled buffer above a small size. Output is byte-identical for normal containers.
### Container-scoped skip serialization
**`Container.SkipsChildSerialization`** (default `false`). When a container returns true:
- its direct children are left out of world saves (the base `Item.SkipSerialization` checks the parent);
- it writes no child list, so a skipped child's serial can never resolve to an unrelated item at load;
- an item that loads naming such a container as its parent is deleted by the existing missing-parent path.
**Known limitation:** `SkipSerialization` is read while the save files are written, after the world unfreezes. An item that enters an opted-in container in that window can be left out of that save. The next save is correct. Moving the decision into the freeze is a follow-up.
### Linear container deletion
`Item.Delete` removes children back to front, but `RemoveItem` searched from the front with `List.Remove`, so deleting a container of *n* items was O(n²). `RemoveItem` now checks the last entry first.
- 100,000 children took 8.0s before and 162ms after.
- Other removals cost the same as before.
**Save format unchanged for every existing type** (the schema generator produces no diff).
## Test plan
- [x] `ContainerChildRemovalTests`:
- a private child's removal isn't broadcast;
- openers, the root owner's client and a trade party (who never opened the pouch) still receive it;
- public containers and per-child `IsChildPublic` overrides still broadcast;
- an override can't narrow a public container;
- no duplicate packets when root and opener overlap;
- deleting the owner sends no remove per child;
- a facet change sends no remove for nested children to the owner or bystanders;
- lifting from a backpack or moving a contained item to the ground sends no remove to bystanders;
- equipped items and ground items keep their broadcast.
- [x] `PrivateChildPropertiesTests`: a property-only update of a private child reaches the owner and openers, not bystanders; public-container children and ground items still broadcast.
- [x] `CorpseChildPublicTests`: human corpse worn gear is public and loot isn't, bones and non-human bodies aren't; end to end, worn gear leaving the corpse reaches a bystander and deleted loot doesn't.
- [x] `LiftRejectResyncTests`:
- a bystander and an out-of-range requester get no resync;
- the owner, an opener and a trade party do;
- an invisible item isn't resynced to a non-staff owner, but is to staff;
- equipment on a hidden mobile and an invisible ground item aren't resynced.
- [x] `ContainerPacketTests`: large containers are capped at the protocol limit (with and without grid lines) and the pooled-buffer path is byte-identical.
- [x] `ContainerChildSkipTests`, `ContainerBulkRemovalTests`.
- [x] Server.Tests (944) and UOContent.Tests (1203) green on current `main`; schema generator clean.
- [x] Manual in-game pass with three clients:
- consumption;
- snooping;
- co-opened containers;
- bank;
- trade with and without opening the pouch;
- facet change with nested bags;
- pack animals;
- player vendors;
- human corpse looting by drag and by script;
- every lift-rejection reason.
Verified with a ClassicUO build instrumented to count:
- removes for unknown serials;
- adds dropped for an unknown parent;
- items learned inside containers the client never opened;
- lift resyncs of items the client never had.
A corpse's worn-gear list was fixed at creation, and clients learn worn gear only from the 0x89 packet sent on first sight. A piece lifted and dropped back showed as loose loot to everyone watching, but came back "worn" after a relog. Lifting now takes the piece off the list, so every view agrees it is loot.
Programmatic moves (the duel gather-and-re-equip in `DuelContext`) don't go through `OnItemLifted` and still see the original list.