Compare commits
39 Commits
v1.0.0
...
2539764cf7
| Author | SHA1 | Date | |
|---|---|---|---|
| 2539764cf7 | |||
| 936a922487 | |||
| 13b6fc02a4 | |||
| 577688b993 | |||
| a9bd18e48e | |||
| b68aac41c6 | |||
| 1be1f24562 | |||
| 452be696df | |||
| efbd45685c | |||
| c71712c734 | |||
| 64c0ec00b1 | |||
| c79a2a3b2b | |||
| cbdbc9fe5c | |||
| 73b07eed22 | |||
| e87c103406 | |||
| c89e818dbf | |||
| 1b7edebd31 | |||
| 0ce92152a1 | |||
| a740365131 | |||
| 6061fe39ce | |||
| 41ac2ccaaa | |||
| f6a86ff8c2 | |||
| 54149fb481 | |||
| 0182732d63 | |||
| b9a27a2de5 | |||
| 63a7dc4374 | |||
| d2a12c46e2 | |||
| 7aa7bc8032 | |||
| 827de04471 | |||
| 28b878c850 | |||
| 1dd490b483 | |||
| a144c12c46 | |||
| b818f6cf37 | |||
| badc1702de | |||
| fc4ebf0f5a | |||
| 8cf995f27f | |||
| 158c0596d8 | |||
| 0d9ac7fda8 | |||
| 8195454201 |
@@ -176,6 +176,39 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Orphan sweep ────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# The check above is VERSION-SCOPED: it only ever asks about the one
|
||||||
|
# version this run computed. That is enough to recover an orphan on
|
||||||
|
# the very next run, and useless afterwards — once any releasable
|
||||||
|
# commit lands, the next run computes a NEW version, never looks at
|
||||||
|
# the old tag again, and the orphan becomes permanent and silent.
|
||||||
|
#
|
||||||
|
# servuo-plugins v0.1.0 is the proof, and the proof is pointed: the
|
||||||
|
# commit that ADDED the recovery above was itself typed
|
||||||
|
# `fix(release): ... recover the orphaned v0.1.0 tag`, so it bumped to
|
||||||
|
# v0.1.1 — and the run that introduced the recovery stepped straight
|
||||||
|
# past the tag it was written to rescue. That tag is still orphaned.
|
||||||
|
#
|
||||||
|
# So every v* tag is checked, and anything missing a release is
|
||||||
|
# WARNED about. Deliberately not recovered: publishing an old version
|
||||||
|
# would mean building today's tree and shipping it under a tag whose
|
||||||
|
# tree it is not, which is worse than the inconsistency it fixes.
|
||||||
|
# A human decides whether to recover or drop it.
|
||||||
|
#
|
||||||
|
# Never fails the run. A sweep that can break a good release is a
|
||||||
|
# sweep someone will delete.
|
||||||
|
ORPHANS=""
|
||||||
|
for T in $(git tag -l 'v*' --sort=-v:refname); do
|
||||||
|
T_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
|
||||||
|
-H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \
|
||||||
|
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/${T}" || echo 000)"
|
||||||
|
[ "$T_HTTP" = "404" ] && ORPHANS="${ORPHANS} ${T}"
|
||||||
|
done
|
||||||
|
if [ -n "${ORPHANS}" ]; then
|
||||||
|
echo "::warning::Tags with no release:${ORPHANS} — a run failed after tagging. Publish or delete them; this job will not do either."
|
||||||
|
fi
|
||||||
|
|
||||||
# Changelog range. A recovery run has nothing after the tag, so
|
# Changelog range. A recovery run has nothing after the tag, so
|
||||||
# summarize what the tag itself contains rather than emitting an empty
|
# summarize what the tag itself contains rather than emitting an empty
|
||||||
# list: the range that produced it, i.e. previous-tag..this-tag.
|
# list: the range that produced it, i.e. previous-tag..this-tag.
|
||||||
@@ -477,18 +510,73 @@ jobs:
|
|||||||
# corrupt the Authorization header.
|
# corrupt the Authorization header.
|
||||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||||
|
|
||||||
REL_ID="$(curl -sSf -X POST "${API}/releases" \
|
PAYLOAD="$(jq -n --arg tag "$TAG" --arg body "$BODY" \
|
||||||
-H "Authorization: token ${CI_TOKEN}" \
|
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')"
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$(jq -n --arg tag "$TAG" --arg body "$BODY" \
|
# installer#22's release run failed exactly here: it landed one second
|
||||||
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
|
# after the tag push and Gitea answered 500, having not finished
|
||||||
| jq -r '.id')"
|
# processing the pushed tag. Re-running published the same artifacts
|
||||||
|
# untouched, so it was a race, not a bad request — but the tag sat
|
||||||
|
# orphaned until a human noticed.
|
||||||
|
#
|
||||||
|
# Two things made that worse than it needed to be.
|
||||||
|
#
|
||||||
|
# 1. `curl -sSf` prints NO response body on an error status, so all the
|
||||||
|
# log carried was "curl: (22) ... error: 500" and the cause had to be
|
||||||
|
# inferred from timestamps. Capture the body and print it.
|
||||||
|
# 2. Nothing retried, so a transient 5xx became a permanent orphan.
|
||||||
|
#
|
||||||
|
# 4xx is deliberately NOT retried: a bad token or a malformed body does
|
||||||
|
# not improve by being sent again, and retrying only turns a clear
|
||||||
|
# failure into a slow one.
|
||||||
|
REL_ID=""
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
HTTP="$(curl -s -o /tmp/rel.json -w '%{http_code}' -X POST "${API}/releases" \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "${PAYLOAD}" || echo 000)"
|
||||||
|
|
||||||
|
if [ "$HTTP" = "201" ] || [ "$HTTP" = "200" ]; then
|
||||||
|
REL_ID="$(jq -r '.id' /tmp/rel.json)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "::warning::POST /releases attempt ${attempt} returned HTTP ${HTTP}"
|
||||||
|
echo "--- response body ---"
|
||||||
|
cat /tmp/rel.json || true
|
||||||
|
echo
|
||||||
|
echo "---------------------"
|
||||||
|
|
||||||
|
case "$HTTP" in
|
||||||
|
4*) echo "::error::HTTP ${HTTP} is a client error - not retrying."; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "$attempt" = 5 ]; then
|
||||||
|
echo "::error::POST /releases still failing after 5 attempts. Tag ${TAG} is pushed but has no release."
|
||||||
|
echo "::error::Re-run this workflow - the plan step detects the orphan tag and republishes it."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep $(( attempt * 5 ))
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$REL_ID" ] || [ "$REL_ID" = "null" ]; then
|
||||||
|
echo "::error::Release created but no id came back; refusing to upload assets blind."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo "Created release ${TAG} (id=${REL_ID})"
|
echo "Created release ${TAG} (id=${REL_ID})"
|
||||||
|
|
||||||
for f in "${TARBALL}" SHA256SUMS; do
|
for f in "${TARBALL}" SHA256SUMS; do
|
||||||
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
|
# Same treatment. An upload that fails quietly leaves a release whose
|
||||||
|
# SHA256SUMS does not cover every artifact it advertises, which is
|
||||||
|
# worse than no release at all -- that file is the trust anchor.
|
||||||
|
HTTP="$(curl -s -o /tmp/asset.json -w '%{http_code}' -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
|
||||||
-H "Authorization: token ${CI_TOKEN}" \
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
-F "attachment=@dist/${f}" >/dev/null
|
-F "attachment=@dist/${f}" || echo 000)"
|
||||||
|
if [ "$HTTP" != "201" ] && [ "$HTTP" != "200" ]; then
|
||||||
|
echo "::error::uploading ${f} returned HTTP ${HTTP}"
|
||||||
|
cat /tmp/asset.json || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo " uploaded ${f}"
|
echo " uploaded ${f}"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,9 @@
|
|||||||
# manual duty: when the protocol changes, bump it here in the same PR that
|
# manual duty: when the protocol changes, bump it here in the same PR that
|
||||||
# changes the emitters, exactly as link bumps PROTOCOL_VERSION.
|
# changes the emitters, exactly as link bumps PROTOCOL_VERSION.
|
||||||
#
|
#
|
||||||
# Current: 4 — see docs/link/v4.md (guild.roster, guild.leave).
|
# Current: 8 — see docs/link/v8.md (the Asset Bridge: client assets over the loopback link
|
||||||
protocol = 4
|
# instead of a converter on somebody's desktop).
|
||||||
|
protocol = 8
|
||||||
|
|
||||||
# ── ServUO compatibility ─────────────────────────────────────────────────────
|
# ── ServUO compatibility ─────────────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -199,6 +199,171 @@ RequireIpForCreate=true
|
|||||||
AccountNameMaxLength=16
|
AccountNameMaxLength=16
|
||||||
AccountPasswordMaxLength=30
|
AccountPasswordMaxLength=30
|
||||||
|
|
||||||
|
# ── The event plane (docs/link/v6.md 8) ──────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Leases and the participation ledger: the website holding a live config value for a bounded
|
||||||
|
# time, and this shard counting who took part in a run. Both are driven on a SCHEDULE, by an
|
||||||
|
# event the website starts unattended.
|
||||||
|
#
|
||||||
|
# This is deliberately NOT AdminWriteEnabled. Turning the admin plane on is consenting to
|
||||||
|
# staff moderation driven from a screen a human is looking at; turning this on is consenting
|
||||||
|
# to the website changing and watching your world at four in the morning. One switch could
|
||||||
|
# not honestly express both.
|
||||||
|
#
|
||||||
|
# A lease always carries its own deadline and this shard restores the baseline when it
|
||||||
|
# passes, whether or not the website is ever heard from again -- and a lease is never written
|
||||||
|
# to disk, so a restart puts every leased value back too.
|
||||||
|
EventsEnabled=false
|
||||||
|
|
||||||
|
# The longest this shard will hold a lease, whatever the website asks for. Thirty days.
|
||||||
|
# A longer request is REFUSED rather than shortened: a silently-clamped lease would leave the
|
||||||
|
# two halves disagreeing about when the world comes back.
|
||||||
|
LeaseMaxDurationSec=2592000
|
||||||
|
|
||||||
|
# How long a finished lease stays listed after its deadline restored it, so a teardown that
|
||||||
|
# arrives late still gets a definite verdict instead of finding nothing.
|
||||||
|
LeaseGraceSec=86400
|
||||||
|
|
||||||
|
# How often the participation sweep credits everyone standing in a run's area, and what one
|
||||||
|
# kill inside it is worth against one minute of being there.
|
||||||
|
ParticipationSweepSeconds=30
|
||||||
|
ParticipationKillWeight=5.0
|
||||||
|
|
||||||
|
# Bounds. Runs counted at once, members per run, and the widest area an event may declare.
|
||||||
|
ParticipationMaxRuns=8
|
||||||
|
ParticipationMaxMembers=2000
|
||||||
|
ParticipationMaxRadius=300
|
||||||
|
|
||||||
|
# How long a closed run's tally stays readable before this shard forgets it, and how many
|
||||||
|
# members one snapshot resolves before yielding the Core thread.
|
||||||
|
ParticipationGraceSec=86400
|
||||||
|
ParticipationSnapshotChunk=100
|
||||||
|
|
||||||
|
# ---- The world verbs (protocol 7) ----------------------------------------------------
|
||||||
|
# What an event may PLACE in the world, all of it owned by the run that placed it and
|
||||||
|
# deleted when the run tears down. Every ceiling here REFUSES rather than clamps: this
|
||||||
|
# shard's bound exists for the case where the website is wrong, and a quiet clamp would
|
||||||
|
# leave the two halves disagreeing about what was actually placed.
|
||||||
|
#
|
||||||
|
# The defaults are the EM Program's published quotas, because they are the only numbers
|
||||||
|
# anyone has defended in public.
|
||||||
|
|
||||||
|
# Per CALL: creatures, enhanced "boss" variants, oracle NPCs and decoration items.
|
||||||
|
EventsMaxCreatures=30
|
||||||
|
EventsMaxBosses=4
|
||||||
|
EventsMaxNpcs=5
|
||||||
|
EventsMaxDecor=60
|
||||||
|
|
||||||
|
# The longest a temporary gate may stand. The shard closes it on its own when the time
|
||||||
|
# passes, whether or not the website is ever heard from again.
|
||||||
|
EventsMaxGateMinutes=240
|
||||||
|
|
||||||
|
# Per RUN, across every verb above. The per-call ceilings bound one request; this bounds
|
||||||
|
# a run that calls a verb in a loop, which is the shape a runaway schedule takes.
|
||||||
|
EventsMaxOwnedPerRun=200
|
||||||
|
|
||||||
|
# How far from the chosen spot things may be scattered.
|
||||||
|
EventsMaxSpread=40
|
||||||
|
|
||||||
|
# How much harder than normal a "boss" may be made. EVENTS.md calls it an enhanced
|
||||||
|
# regular mob, so this is low enough that the result is still the creature that was
|
||||||
|
# picked.
|
||||||
|
EventsMaxBossMultiplier=10.0
|
||||||
|
|
||||||
|
# The oracle NPC: how many keyword lines it answers to, how close a player must be to be
|
||||||
|
# greeted and to be heard, and how often it will speak to the same player.
|
||||||
|
EventsOracleMaxLines=5
|
||||||
|
EventsOracleGreetRange=4
|
||||||
|
EventsOracleSpeechRange=8
|
||||||
|
EventsOracleGreetCooldownSec=60
|
||||||
|
EventsOracleAnswerCooldownSec=5
|
||||||
|
|
||||||
|
# How often expired gates are collected and rows for objects the world has already lost
|
||||||
|
# are pruned.
|
||||||
|
EventsSweepSeconds=30
|
||||||
|
|
||||||
|
# Item grants (Phase 12b). The first bounds how many characters one grant may reach --
|
||||||
|
# the run's participation ledger is the recipient list, so this is a bound on the size of
|
||||||
|
# an event rather than on a number somebody typed. The second bounds one hand.
|
||||||
|
# Both REFUSE rather than clamp: the website records what was handed out.
|
||||||
|
EventsMaxGrantPerRun=200
|
||||||
|
EventsMaxGrantStack=1000
|
||||||
|
|
||||||
|
# The shortest gap between world saves, counted from the last save by anybody --
|
||||||
|
# ServUO's own autosave included. A save stops the world, so this is a rate limit rather
|
||||||
|
# than a cap, and a save asked for too soon is refused rather than queued: a queued save
|
||||||
|
# would land at a moment nobody chose. Set to 0 to allow a save at any time.
|
||||||
|
EventsMinSaveIntervalSec=300
|
||||||
|
|
||||||
|
# The asset plane (docs/link/v8.md, protocol 8). Its own switch, deliberately: turning
|
||||||
|
# this on is consenting to the website reading this host's UO CLIENT FILES -- art,
|
||||||
|
# animations, the string table -- over the link. Nothing on this plane writes anything.
|
||||||
|
AssetsEnabled=true
|
||||||
|
|
||||||
|
# The largest reply the asset plane will build, in encoded bytes. Not an item count:
|
||||||
|
# the ceiling it lives inside is the sidecar's 1 MiB inbound line cap, and base64 adds
|
||||||
|
# 33% to every payload. Clamped to [64 KiB, 512 KiB] -- half the wire cap, so that a
|
||||||
|
# single oversized item (always admitted, or its family could never make progress)
|
||||||
|
# still fits.
|
||||||
|
AssetBatchBytes=524288
|
||||||
|
|
||||||
|
# How many ServUO class names one `assets.bodies` request may carry (phase 3). The only
|
||||||
|
# bound on this plane counted in items rather than bytes, because what it bounds is not
|
||||||
|
# reply size -- it is constructing and deleting that many real mobiles ON THE CORE
|
||||||
|
# THREAD, between two ticks of the world. A larger request is refused, never truncated.
|
||||||
|
# Clamped to [1, 500].
|
||||||
|
AssetBodyBatch=100
|
||||||
|
|
||||||
|
# How many keys one `assets.fetch` request may name. The byte budget above still decides
|
||||||
|
# where a page is cut; this only bounds how large a request the shard will parse at all.
|
||||||
|
# Clamped to [1, 10000].
|
||||||
|
AssetFetchKeys=2000
|
||||||
|
|
||||||
|
# The wall-clock budget for one catalogue page, in milliseconds. The catalogue's manifest
|
||||||
|
# rows are ~90 bytes so the byte budget never stops it -- but building them means
|
||||||
|
# decoding hundreds of animations, and the sidecar waits 10 s for a reply. Kept well
|
||||||
|
# under that, because the page still has to be serialised and written afterwards.
|
||||||
|
# Clamped to [250, 5000].
|
||||||
|
AssetScanMs=3000
|
||||||
|
|
||||||
|
# Which direction the catalogue renders. NOT part of the asset key: five directions
|
||||||
|
# would five-fold every count in the working set to express a choice nobody varies.
|
||||||
|
#
|
||||||
|
# The split was found by RENDERING all five, not from a table. 0 is head-on, facing the
|
||||||
|
# viewer -- what a character portrait wants, and the least legible view there is of a
|
||||||
|
# four-legged creature (a wolf seen from the front is a dark blob). 1 is the front
|
||||||
|
# three-quarter, where the same wolf is unmistakably a wolf.
|
||||||
|
#
|
||||||
|
# Which bodies count as player bodies is asked of the shard (every registered race's
|
||||||
|
# male/female/ghost ids), never hardcoded. Clamped to [0, 4]: 5-7 are the client
|
||||||
|
# mirroring 1-3 through a decode branch this overlay has not verified.
|
||||||
|
AssetPlayerDirection=0
|
||||||
|
AssetCreatureDirection=1
|
||||||
|
|
||||||
|
# The tree plane (docs/link/v8.md §10, phase 7). A THIRD switch, for a third consent:
|
||||||
|
# the asset switch above is about this host's UO client, which came from EA. This one is
|
||||||
|
# about the shard's own configuration -- Spawns/*.xml, Data/Regions.xml,
|
||||||
|
# Data/Locations/*.xml, Config/ChampionSpawns.xml and Data/Decoration/**.cfg -- which is
|
||||||
|
# the operator's own work and is what the website's spawn atlas is built from. Before
|
||||||
|
# protocol 8 the website read those files off a shared filesystem; that was the one place
|
||||||
|
# the platform's own rule (only the sidecar bridges the shard) was broken, and broken by
|
||||||
|
# the component that faces the internet. Turning this off closes the bridge route and
|
||||||
|
# leaves that shared-filesystem path as the only way an atlas can be built.
|
||||||
|
#
|
||||||
|
# Reads only, and only those five groups. Nothing here joins a path the website sent: a
|
||||||
|
# request names a label this shard itself enumerated, or it is refused.
|
||||||
|
TreeEnabled=true
|
||||||
|
|
||||||
|
# How much of a tree file one chunk carries, BEFORE compression. Chunking is not an
|
||||||
|
# optimisation here, it is what makes a spawn file transferable: a stock trammel.xml is
|
||||||
|
# 4.03 MB, the sidecar discards any inbound line over 1 MiB, and the whole file as one
|
||||||
|
# base64 row would time out and be re-requested forever with no error anywhere. Each
|
||||||
|
# chunk is gzipped (a spawn file compresses ~18x, so a chunk is typically 40 KB on the
|
||||||
|
# wire), but the BOUND comes from the chunk rather than the compression, because nothing
|
||||||
|
# guarantees input compresses at all. Clamped to [64 KiB, 512 KiB]: at the ceiling a
|
||||||
|
# worst-case incompressible chunk is ~683 KiB of base64, which still fits the wire.
|
||||||
|
TreeChunkBytes=524288
|
||||||
|
|
||||||
# The test scaffolding in tools/scaffolding/ reads its own flags from this file
|
# The test scaffolding in tools/scaffolding/ reads its own flags from this file
|
||||||
# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
|
# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
|
||||||
# Config.Get returns the default of false when a key is missing, so a deployed
|
# Config.Get returns the default of false when a key is missing, so a deployed
|
||||||
|
|||||||
842
overlay/Scripts/Custom/Bridge/BridgeArt.cs
Normal file
842
overlay/Scripts/Custom/Bridge/BridgeArt.cs
Normal file
@@ -0,0 +1,842 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
using Ultima;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// **Item and land art, on demand** (docs/link/v8.md §5, §11 — protocol 8, phase 5).
|
||||||
|
///
|
||||||
|
/// The body catalogue is a *set*: 1,022 sprites, enumerated, hashed and imported in one
|
||||||
|
/// pass because a bestiary needs all of them. This is the opposite shape. This client
|
||||||
|
/// addresses **49,152 static ids** and has real art for **39,189** of them, plus 4,244 land
|
||||||
|
/// tiles of 16,384 — and then there are hues, which multiply the statics by three thousand.
|
||||||
|
/// Nothing enumerates that. So there is no manifest here and no scan: the website asks for
|
||||||
|
/// the handful of keys its own data actually names, and this answers them.
|
||||||
|
///
|
||||||
|
/// (49,152 rather than the 81,884 entries `artidx.mul` declares: <c>FileIndex</c> sizes its
|
||||||
|
/// table from the **length argument it is constructed with**, `0x10000`, not from the idx
|
||||||
|
/// file — so the addressable range is `0x10000 - 0x4000`. Reading the ceiling off the file
|
||||||
|
/// instead would invent 16,348 ids, every one of them answered out of an array nobody
|
||||||
|
/// bounded.)
|
||||||
|
///
|
||||||
|
/// ── **The keys** (§5) ──
|
||||||
|
///
|
||||||
|
/// <code>
|
||||||
|
/// static/3922 one item graphic, as the client files hold it
|
||||||
|
/// static/3922/h33 the same graphic with hue 33 applied
|
||||||
|
/// land/3 one land tile
|
||||||
|
/// </code>
|
||||||
|
///
|
||||||
|
/// ── **Why the hue is applied HERE and not on the website** ──
|
||||||
|
///
|
||||||
|
/// Because it cannot be applied correctly anywhere else, and the incorrect version looks
|
||||||
|
/// fine.
|
||||||
|
///
|
||||||
|
/// A hue is not a tint. It is a 32-entry colour ramp out of `hues.mul` indexed by a
|
||||||
|
/// pixel's own red channel — and whether it replaces *every* pixel or only the grey ones
|
||||||
|
/// is decided by the <c>PartialHue</c> flag in <c>tiledata.mul</c>, per item id. On this
|
||||||
|
/// client **13,259 of 65,536 item ids carry that flag**. Get it wrong on one of them and
|
||||||
|
/// you do not get an error: item 597 is a wooden screen with painted flowers, and hued red
|
||||||
|
/// the right way the flowers turn red, the wrong way the whole screen turns red. Both
|
||||||
|
/// decode. Both are the right size. One is wrong.
|
||||||
|
///
|
||||||
|
/// The website has neither file and never will — shipping `Hues.mul` semantics and a
|
||||||
|
/// 65,536-row flag table into Node to answer a question the shard can answer for free is
|
||||||
|
/// the same trade §2.1 already refused. So hue is part of the key, and the key is resolved
|
||||||
|
/// where the files are.
|
||||||
|
///
|
||||||
|
/// ── **The trap this phase existed to find** ──
|
||||||
|
///
|
||||||
|
/// <c>Art.GetStatic</c> memoises into a static <c>Bitmap[0xFFFF]</c> and returns **the same
|
||||||
|
/// instance** every time; <c>Hue.ApplyTo</c> repaints a bitmap **in place**. Hue a static
|
||||||
|
/// once and the library's own copy is hued from then on — the plain key comes back hued,
|
||||||
|
/// and the next hue stacks on the last. It is §4.5's failure mode (a confident, plausible,
|
||||||
|
/// wrong picture that every success count agrees with) reached through a door §4.5 never
|
||||||
|
/// looked at, because phase 0 was auditing *records* and this is the library's *cache*.
|
||||||
|
///
|
||||||
|
/// <see cref="BridgeAssets.Initialize"/> turns <c>Files.CacheData</c> off for the life of
|
||||||
|
/// the process, which makes every bitmap this file receives its own. That invariant is
|
||||||
|
/// load-bearing enough that <see cref="Render"/> **re-checks it** before applying a hue and
|
||||||
|
/// refuses rather than risk it: an invariant nothing verifies is a comment.
|
||||||
|
///
|
||||||
|
/// ── **What is validated, and against what** ──
|
||||||
|
///
|
||||||
|
/// Everything §4.5 built, reused as-is. An index entry is judged before the id is handed to
|
||||||
|
/// <c>Ultima</c> (<see cref="BridgeAssetValidator.CheckEntry"/>), a static's record header
|
||||||
|
/// and row table are walked bounded (<c>StaticSane</c>), a land record is checked against
|
||||||
|
/// the 2,024 bytes <c>LoadLand</c> reads whatever the length says (<c>LandLengthSane</c>),
|
||||||
|
/// and the bound is taken against **whichever file <c>FileIndex</c> actually opened** —
|
||||||
|
/// <c>artLegacyMUL.uop</c> on every current client, never <c>art.mul</c> (§4.6).
|
||||||
|
///
|
||||||
|
/// Two of §4.5's measurements are this family's, not the catalogue's, and they are the
|
||||||
|
/// reason all of it is here: on a **stock** client **9,963 static ids and 12,140 land ids**
|
||||||
|
/// have an index entry reading `lookup 0, length 0`, which <c>FileIndex.Seek</c> treats as
|
||||||
|
/// a hit and the decoder answers with whatever was decoded last. Measured through this
|
||||||
|
/// reader over the whole range, those are the ONLY refusals — every one of the 39,189
|
||||||
|
/// statics and 4,244 land tiles that carries art is served, which is the half of the
|
||||||
|
/// measurement that says the boundary is in the right place (§4.5).
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeArt
|
||||||
|
{
|
||||||
|
/// <summary>Item graphics. <c>static/<id></c>, optionally <c>/h<hue></c>.</summary>
|
||||||
|
private const string StaticFamily = "static";
|
||||||
|
|
||||||
|
/// <summary>Land tiles. <c>land/<id></c>, and no hue segment — see <see cref="TryParseKey"/>.</summary>
|
||||||
|
private const string LandFamily = "land";
|
||||||
|
|
||||||
|
/// <summary>The art index addresses land at its own id and statics at <c>0x4000 + id</c>.</summary>
|
||||||
|
private const int StaticBase = 0x4000;
|
||||||
|
|
||||||
|
/// <summary>Land is addressed with <c>index & 0x3FFF</c> by the library itself.</summary>
|
||||||
|
private const int LandCount = 0x4000;
|
||||||
|
|
||||||
|
/// <summary><c>hues.mul</c> holds 3,000 slots; the wire's hue 1 is slot 0.</summary>
|
||||||
|
private const int MaxHue = 3000;
|
||||||
|
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
if (!BridgeConfig.Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
BridgeAssets.RegisterFamily(StaticFamily, ReplyFetch);
|
||||||
|
BridgeAssets.RegisterFamily(LandFamily, ReplyFetch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the cache (§11) ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private sealed class Rendered
|
||||||
|
{
|
||||||
|
public string Key;
|
||||||
|
public string Status;
|
||||||
|
public string Reason;
|
||||||
|
public string Sha256;
|
||||||
|
public byte[] Png;
|
||||||
|
public int Width;
|
||||||
|
public int Height;
|
||||||
|
public int Hue;
|
||||||
|
public bool PartialHue;
|
||||||
|
public string Source;
|
||||||
|
|
||||||
|
public int Weight
|
||||||
|
{
|
||||||
|
get { return Png == null ? 128 : Png.Length + 128; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Cache
|
||||||
|
{
|
||||||
|
public string Id;
|
||||||
|
|
||||||
|
public readonly Dictionary<string, Rendered> ByKey =
|
||||||
|
new Dictionary<string, Rendered>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Insertion order, for eviction. See <see cref="Remember"/>.</summary>
|
||||||
|
public readonly Queue<string> Order = new Queue<string>();
|
||||||
|
|
||||||
|
public long Bytes;
|
||||||
|
public DateTime LastUsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly object _sync = new object();
|
||||||
|
private static Cache _cache;
|
||||||
|
|
||||||
|
private static readonly TimeSpan IdleFor = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
// ── assets.fetch, the static and land half ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Both families' answer to <c>assets.fetch</c>. The correlation id, the operator's
|
||||||
|
/// consent, the key ceiling and the family decision were made by
|
||||||
|
/// <see cref="BridgeAssets.OnFetch"/>; every key here belongs to this reader.
|
||||||
|
///
|
||||||
|
/// The paging envelope, the byte budget and the `catalog` guard are §3.4's and
|
||||||
|
/// phase 3's, unchanged — a caller that already walks the body catalogue walks this
|
||||||
|
/// with the same loop.
|
||||||
|
/// </summary>
|
||||||
|
private static void ReplyFetch(string reqId, List<string> keys, string expected, string cursor)
|
||||||
|
{
|
||||||
|
string imagingReason;
|
||||||
|
|
||||||
|
if (!BridgeAssets.ImagingOk(out imagingReason))
|
||||||
|
{
|
||||||
|
// §17.9: a flat refusal, not a partial answer. Every picture in this family needs
|
||||||
|
// a decoder that goes through GDI+, so there is no half of it to serve.
|
||||||
|
BridgeAssets.Fail(reqId, "UNAVAILABLE",
|
||||||
|
"this shard host cannot render images - Mono's System.Drawing needs "
|
||||||
|
+ "libgdiplus. (" + imagingReason + ")");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string id = SourceId();
|
||||||
|
|
||||||
|
if (BridgeAssets.CatalogMismatch(expected, id))
|
||||||
|
{
|
||||||
|
BridgeAssets.Fail(reqId, "UNREADABLE",
|
||||||
|
"the shard's client files changed since that catalogue was read (catalog "
|
||||||
|
+ expected + " is now " + id + "); ask again");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Cache cache;
|
||||||
|
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (_cache == null || _cache.Id != id)
|
||||||
|
_cache = new Cache { Id = id };
|
||||||
|
|
||||||
|
cache = _cache;
|
||||||
|
cache.LastUsed = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
int from = ParseKeyCursor(cursor);
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("assets.fetch.ok");
|
||||||
|
|
||||||
|
sb.Str("reqId", reqId)
|
||||||
|
.Str("family", BridgeAssets.FamilyOfKey(keys[0]))
|
||||||
|
.Str("catalog", cache.Id)
|
||||||
|
.Num("extractorVersion", BridgeAssets.EXTRACTOR_VERSION)
|
||||||
|
.Num("asked", keys.Count)
|
||||||
|
.Num("from", from);
|
||||||
|
|
||||||
|
var page = new BridgeAssets.PageBuilder(sb, "rows", BridgeConfig.AssetBatchBytes);
|
||||||
|
|
||||||
|
using (var readers = new Readers())
|
||||||
|
{
|
||||||
|
for (int i = from; i < keys.Count; i++)
|
||||||
|
{
|
||||||
|
string row = Row(cache, readers, keys[i]);
|
||||||
|
|
||||||
|
if (!page.TryAdd(row, "k:" + (i + 1).ToString(CultureInfo.InvariantCulture)))
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
page.Close();
|
||||||
|
|
||||||
|
sb.Num("sent", page.Count);
|
||||||
|
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
|
||||||
|
Sweep();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One key to one JSON row.
|
||||||
|
///
|
||||||
|
/// A key this shard cannot serve is a **row**, never a failed request: an item id with
|
||||||
|
/// no art must not cost the other three hundred on the page. The three outcomes are the
|
||||||
|
/// ones phase 3 defined, and this family adds a `reason` beside them — additive, and
|
||||||
|
/// the only way an operator learns that eight of their records are damaged rather than
|
||||||
|
/// simply absent, which is a difference §4.5 spent a whole phase establishing.
|
||||||
|
/// </summary>
|
||||||
|
private static string Row(Cache cache, Readers readers, string key)
|
||||||
|
{
|
||||||
|
Rendered item = Resolve(cache, readers, key);
|
||||||
|
|
||||||
|
var sb = new StringBuilder(2048);
|
||||||
|
|
||||||
|
sb.Append("{\"key\":");
|
||||||
|
BridgeJson.Text(sb, key);
|
||||||
|
|
||||||
|
sb.Append(",\"status\":\"").Append(item.Status).Append('"');
|
||||||
|
|
||||||
|
if (item.Reason != null)
|
||||||
|
{
|
||||||
|
sb.Append(",\"reason\":");
|
||||||
|
BridgeJson.Text(sb, item.Reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Status != "ok")
|
||||||
|
{
|
||||||
|
sb.Append('}');
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(",\"sha256\":\"").Append(item.Sha256).Append('"');
|
||||||
|
sb.Append(",\"bytes\":").Append(item.Png.Length.ToString(CultureInfo.InvariantCulture));
|
||||||
|
sb.Append(",\"width\":").Append(item.Width.ToString(CultureInfo.InvariantCulture));
|
||||||
|
sb.Append(",\"height\":").Append(item.Height.ToString(CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
|
if (item.Hue > 0)
|
||||||
|
{
|
||||||
|
sb.Append(",\"hue\":").Append(item.Hue.ToString(CultureInfo.InvariantCulture));
|
||||||
|
sb.Append(",\"partialHue\":").Append(item.PartialHue ? "true" : "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(",\"source\":\"").Append(item.Source).Append('"');
|
||||||
|
sb.Append(",\"png\":\"").Append(Convert.ToBase64String(item.Png)).Append("\"}");
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Rendered Resolve(Cache cache, Readers readers, string key)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
Rendered cached;
|
||||||
|
|
||||||
|
if (cache.ByKey.TryGetValue(key, out cached))
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rendered item;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
item = Render(readers, key);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] art: {0}: {1}: {2}", key, e.GetType().Name, e.Message);
|
||||||
|
|
||||||
|
item = new Rendered
|
||||||
|
{
|
||||||
|
Key = key,
|
||||||
|
Status = "absent",
|
||||||
|
Reason = e.GetType().Name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Status == "ok")
|
||||||
|
Remember(cache, item);
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Holds the encoded bytes against a byte budget, evicting oldest-first.
|
||||||
|
///
|
||||||
|
/// **Oldest-first rather than least-recently-used, deliberately.** The access pattern
|
||||||
|
/// this serves is a warm pass: the website asks for the keys it has never held, stores
|
||||||
|
/// them permanently, and does not ask again. What this cache is actually for is the
|
||||||
|
/// second page of a batch, a retry after a 425, and the same picture appearing in two
|
||||||
|
/// of a page's rows — all of which insertion order serves exactly as well as recency,
|
||||||
|
/// and with no bookkeeping on the hot path. A cache whose hit pattern has no recency in
|
||||||
|
/// it should not pretend to rank by it.
|
||||||
|
///
|
||||||
|
/// Only successes are held. An absent key costs one index lookup, which is cheaper than
|
||||||
|
/// the dictionary entry that would remember it.
|
||||||
|
/// </summary>
|
||||||
|
private static void Remember(Cache cache, Rendered item)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (cache.ByKey.ContainsKey(item.Key))
|
||||||
|
return;
|
||||||
|
|
||||||
|
cache.ByKey[item.Key] = item;
|
||||||
|
cache.Order.Enqueue(item.Key);
|
||||||
|
cache.Bytes += item.Weight;
|
||||||
|
|
||||||
|
while (cache.Bytes > BridgeConfig.AssetArtCacheBytes && cache.Order.Count > 0)
|
||||||
|
{
|
||||||
|
string oldest = cache.Order.Dequeue();
|
||||||
|
|
||||||
|
Rendered evicted;
|
||||||
|
|
||||||
|
if (!cache.ByKey.TryGetValue(oldest, out evicted))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
cache.ByKey.Remove(oldest);
|
||||||
|
cache.Bytes -= evicted.Weight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── decode ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validate, decode, hue, encode. In that order, and the order is the point.
|
||||||
|
/// </summary>
|
||||||
|
private static Rendered Render(Readers readers, string key)
|
||||||
|
{
|
||||||
|
bool land;
|
||||||
|
int id, hue;
|
||||||
|
|
||||||
|
if (!TryParseKey(key, out land, out id, out hue))
|
||||||
|
return Unsupported(key, "not a key this shard serves");
|
||||||
|
|
||||||
|
FileIndex index = readers.Index;
|
||||||
|
|
||||||
|
if (index == null || index.Index == null)
|
||||||
|
return Absent(key, "this shard has no art file");
|
||||||
|
|
||||||
|
int at = land ? id : StaticBase + id;
|
||||||
|
|
||||||
|
if (at < 0 || at >= index.Index.Length)
|
||||||
|
return Unsupported(key, "id " + id + " is past the end of this client's art index");
|
||||||
|
|
||||||
|
string reason;
|
||||||
|
|
||||||
|
BridgeAssetValidator.Verdict verdict =
|
||||||
|
BridgeAssetValidator.CheckEntry(index, at, readers.DataLength, readers.VerdataLength, out reason);
|
||||||
|
|
||||||
|
if (verdict == BridgeAssetValidator.Verdict.Absent)
|
||||||
|
{
|
||||||
|
// The 9,962 statics and 12,140 land tiles of §4.5: an index entry that reads
|
||||||
|
// `lookup 0, length 0`, which the library treats as a hit and answers with the
|
||||||
|
// previous asset's pixels. Absent is the true answer and the only safe one.
|
||||||
|
return Absent(key, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verdict != BridgeAssetValidator.Verdict.Ok)
|
||||||
|
{
|
||||||
|
// A damaged record rather than a missing one. Still absent to the website — there
|
||||||
|
// is no picture either way — but the reason is worth carrying, because this one an
|
||||||
|
// operator can act on.
|
||||||
|
Console.WriteLine("[Bridge] art: {0} refused: {1}", key, reason);
|
||||||
|
return Absent(key, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (land)
|
||||||
|
{
|
||||||
|
if (!BridgeAssetValidator.LandLengthSane(index, at, out reason))
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] art: {0} refused: {1}", key, reason);
|
||||||
|
return Absent(key, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (readers.Reader == null || !readers.Reader.StaticSane(index, at, out reason))
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] art: {0} refused: {1}",
|
||||||
|
key, reason ?? "the art record could not be read");
|
||||||
|
|
||||||
|
return Absent(key, reason ?? "the art record could not be read");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A hue is resolved BEFORE anything is decoded, so a bad one costs no pixels and, more
|
||||||
|
// to the point, cannot half-apply to a picture that then gets cached and served.
|
||||||
|
Ultima.Hue applied = null;
|
||||||
|
bool partial = false;
|
||||||
|
|
||||||
|
if (hue > 0)
|
||||||
|
{
|
||||||
|
if (!TryHue(id, hue, out applied, out partial, out reason))
|
||||||
|
return Unsupported(key, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
Bitmap bitmap = land
|
||||||
|
? Art.GetLand(id)
|
||||||
|
// `checkmaxid: false` on purpose (§4.5): the default maps an out-of-range id to 0
|
||||||
|
// and returns ITEM ZERO'S PICTURE. The id is already bounded against the index
|
||||||
|
// that was actually opened, so this can only be loud.
|
||||||
|
: Art.GetStatic(id, false);
|
||||||
|
|
||||||
|
// **Whether this bitmap is ours to dispose is the same question as whether it is ours
|
||||||
|
// to hue**, and it has the same answer. With the library's cache off — which
|
||||||
|
// `BridgeAssets.Initialize` guarantees and `TryHue` re-checks — every call decodes a
|
||||||
|
// fresh instance that nothing else holds, so not disposing it would leak one bitmap per
|
||||||
|
// fetched key. With the cache on, that instance is the library's own copy and disposing
|
||||||
|
// it would leave a disposed `Bitmap` in a static array for the next caller to fault on.
|
||||||
|
// Both mistakes are silent; the flag decides, once, here.
|
||||||
|
bool owned = !Files.CacheData;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (bitmap == null || bitmap.Width <= 0 || bitmap.Height <= 0)
|
||||||
|
return Absent(key, "the decoder returned no picture");
|
||||||
|
|
||||||
|
if (applied != null)
|
||||||
|
applied.ApplyTo(bitmap, partial);
|
||||||
|
|
||||||
|
byte[] png = BridgeAssets.BitmapToPng(bitmap);
|
||||||
|
|
||||||
|
if (png == null)
|
||||||
|
return Absent(key, "the picture could not be encoded");
|
||||||
|
|
||||||
|
return new Rendered
|
||||||
|
{
|
||||||
|
Key = key,
|
||||||
|
Status = "ok",
|
||||||
|
Sha256 = BridgeAssets.Sha256Hex(png),
|
||||||
|
Png = png,
|
||||||
|
Width = bitmap.Width,
|
||||||
|
Height = bitmap.Height,
|
||||||
|
Hue = hue,
|
||||||
|
PartialHue = partial,
|
||||||
|
Source = readers.Source
|
||||||
|
};
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (owned && bitmap != null)
|
||||||
|
bitmap.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves one wire hue onto a ramp, and decides whether it repaints the whole sprite
|
||||||
|
/// or only its grey pixels.
|
||||||
|
///
|
||||||
|
/// Four things have to hold, and every one of them has a way of not holding that
|
||||||
|
/// produces a picture rather than an error:
|
||||||
|
///
|
||||||
|
/// **The library's cache is off.** Re-checked here because <c>ApplyTo</c> repaints in
|
||||||
|
/// place: with the cache on, this would edit the copy <c>Art</c> hands to everyone
|
||||||
|
/// else. <see cref="BridgeAssets.Initialize"/> turns it off at boot and this refuses
|
||||||
|
/// if it somehow did not, because the failure is invisible and permanent.
|
||||||
|
///
|
||||||
|
/// **`hues.mul` is present.** When it is missing <c>Hues.Initialize</c> does not throw
|
||||||
|
/// — it fills all 3,000 slots with a <c>new Hue(index)</c> whose ramp is **all zeroes**,
|
||||||
|
/// and applying one of those paints the sprite black. An all-zero ramp is therefore
|
||||||
|
/// refused whatever the reason for it; on this client there are none.
|
||||||
|
///
|
||||||
|
/// **The index is in range.** The wire's hue is 1-based — <c>Ultima.Map</c> does the
|
||||||
|
/// same <c>GetHue(hue - 1)</c> at line 450 — and <c>GetHue</c> itself masks with
|
||||||
|
/// `0x3FFF` and falls back to slot 0 rather than failing, so an out-of-range hue would
|
||||||
|
/// silently become a different colour. Bound it here instead.
|
||||||
|
///
|
||||||
|
/// **The <c>PartialHue</c> flag decides the mode**, per item id, out of
|
||||||
|
/// <c>tiledata.mul</c>. This is the one that is invisible: both modes decode, both are
|
||||||
|
/// the right size, and 13,259 of this client's item ids need the grey-only one.
|
||||||
|
/// **Land has no such flag**, which is why <see cref="TryParseKey"/> does not accept a
|
||||||
|
/// hue on a land key at all rather than guessing a mode for it.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryHue(int id, int hue, out Ultima.Hue applied, out bool partial, out string reason)
|
||||||
|
{
|
||||||
|
applied = null;
|
||||||
|
partial = false;
|
||||||
|
reason = null;
|
||||||
|
|
||||||
|
if (Files.CacheData)
|
||||||
|
{
|
||||||
|
reason = "this shard's art cache is on, so a hue cannot be applied safely";
|
||||||
|
Console.WriteLine("[Bridge] art: refusing hue {0}: {1}", hue, reason);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hue < 1 || hue > MaxHue)
|
||||||
|
{
|
||||||
|
reason = "hue " + hue + " is outside 1-" + MaxHue;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ultima.Hue[] list = Ultima.Hues.List;
|
||||||
|
|
||||||
|
if (list == null || hue - 1 >= list.Length || list[hue - 1] == null)
|
||||||
|
{
|
||||||
|
reason = "this client has no hue table";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ultima.Hue candidate = list[hue - 1];
|
||||||
|
|
||||||
|
if (candidate.Colors == null || AllZero(candidate.Colors))
|
||||||
|
{
|
||||||
|
reason = "hue " + hue + " has no colours in this client's hues.mul";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryPartialHue(id, out partial, out reason))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
applied = candidate;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool AllZero(short[] colors)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < colors.Length; i++)
|
||||||
|
{
|
||||||
|
if (colors[i] != 0)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <c>PartialHue</c> flag for one item id.
|
||||||
|
///
|
||||||
|
/// Refuses rather than defaults when <c>tiledata.mul</c> cannot be read. Defaulting
|
||||||
|
/// either way would be a coin flip on 13,259 ids, and the losing side of it is a
|
||||||
|
/// picture that looks deliberate.
|
||||||
|
///
|
||||||
|
/// **Every type here is spelled <c>Ultima.</c> on purpose, and it is not style.**
|
||||||
|
/// ServUO declares its own <c>Server.TileData</c>, <c>Server.ItemData</c> and
|
||||||
|
/// <c>Server.TileFlag</c> — with a <c>PartialHue</c> member — in
|
||||||
|
/// <c>Server/TileData.cs</c>. This file lives in <c>Server.Custom.Bridge</c>, so the
|
||||||
|
/// enclosing namespace beats the <c>using Ultima;</c> and the unqualified spelling
|
||||||
|
/// silently binds to the *server's* table: it compiles, the flag exists, and the answer
|
||||||
|
/// comes from a file resolved through <c>Core.DataDirectories</c> rather than through
|
||||||
|
/// <c>Ultima.Files</c>, which is the one thing §4.6 says never to do — decide a picture
|
||||||
|
/// with a file other than the one the pixels came out of. The first run of this reader
|
||||||
|
/// did exactly that and refused every hued key with a <c>TypeInitializationException</c>
|
||||||
|
/// from a class this code never meant to name.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryPartialHue(int id, out bool partial, out string reason)
|
||||||
|
{
|
||||||
|
partial = false;
|
||||||
|
reason = null;
|
||||||
|
|
||||||
|
Ultima.ItemData[] table;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
table = Ultima.TileData.ItemTable;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
reason = "this client's tiledata could not be read (" + e.GetType().Name + ")";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (table == null || id < 0 || id >= table.Length)
|
||||||
|
{
|
||||||
|
reason = "this client's tiledata does not describe item " + id;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
partial = (table[id].Flags & Ultima.TileFlag.PartialHue) != 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Rendered Absent(string key, string reason)
|
||||||
|
{
|
||||||
|
return new Rendered { Key = key, Status = "absent", Reason = reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Rendered Unsupported(string key, string reason)
|
||||||
|
{
|
||||||
|
return new Rendered { Key = key, Status = "unsupported", Reason = reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── keys, cursors and the source id ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>static/<id></c>, <c>static/<id>/h<hue></c> and
|
||||||
|
/// <c>land/<id></c>.
|
||||||
|
///
|
||||||
|
/// **A land key takes no hue segment.** The client can hue a land tile, but the mode
|
||||||
|
/// that decides how is an *item* flag and land has no equivalent — so the honest answer
|
||||||
|
/// to `land/3/h33` is that this shard does not serve it, rather than a picture produced
|
||||||
|
/// by guessing. Nothing on the wire carries a hued land tile today; if something ever
|
||||||
|
/// does, it arrives with a reason to choose.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryParseKey(string key, out bool land, out int id, out int hue)
|
||||||
|
{
|
||||||
|
land = false;
|
||||||
|
id = 0;
|
||||||
|
hue = 0;
|
||||||
|
|
||||||
|
if (key == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
string[] parts = key.Split('/');
|
||||||
|
|
||||||
|
if (parts.Length < 2 || parts.Length > 3)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (parts[0] == LandFamily)
|
||||||
|
land = true;
|
||||||
|
else if (parts[0] != StaticFamily)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!Int32.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out id))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (id < 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (land && id >= LandCount)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (parts.Length == 2)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (land)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
string segment = parts[2];
|
||||||
|
|
||||||
|
if (segment.Length < 2 || segment[0] != 'h')
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!Int32.TryParse(segment.Substring(1), NumberStyles.None,
|
||||||
|
CultureInfo.InvariantCulture, out hue))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// **`h0` is not a key.** Hue 0 on the wire means "this item is not hued", so the plain
|
||||||
|
// key already names its picture. Accepting `static/3922/h0` as a synonym would have
|
||||||
|
// the website store the identical PNG twice under two names, diff them separately on
|
||||||
|
// every Update, and show whichever row it happened to join against -- for a distinction
|
||||||
|
// that does not exist. The caller drops the segment instead.
|
||||||
|
return hue > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ParseKeyCursor(string cursor)
|
||||||
|
{
|
||||||
|
if (cursor == null)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
int value;
|
||||||
|
|
||||||
|
if (cursor.StartsWith("k:", StringComparison.Ordinal)
|
||||||
|
&& Int32.TryParse(cursor.Substring(2), NumberStyles.None, CultureInfo.InvariantCulture, out value))
|
||||||
|
return Math.Max(0, value);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Everything that decides these bytes, hashed into one short id — the same guard
|
||||||
|
/// phase 3 built, over this family's inputs.
|
||||||
|
///
|
||||||
|
/// Four files, and each earns its place: the art data file holds the pixels,
|
||||||
|
/// `hues.mul` holds the ramps, `tiledata.mul` decides which of the two hue modes an
|
||||||
|
/// item gets, and `verdata.mul` can patch any record in any of them. Leaving
|
||||||
|
/// `tiledata.mul` out would be the subtle one — a client patch that only flipped
|
||||||
|
/// <c>PartialHue</c> flags changes no pixel in any source file and every hued picture
|
||||||
|
/// derived from them.
|
||||||
|
/// </summary>
|
||||||
|
private static string SourceId()
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder(256);
|
||||||
|
|
||||||
|
sb.Append(BridgeAssets.EXTRACTOR_VERSION);
|
||||||
|
|
||||||
|
foreach (string path in new[]
|
||||||
|
{
|
||||||
|
BridgeAssetValidator.ArtDataPath(),
|
||||||
|
FilePath("hues.mul"),
|
||||||
|
FilePath("tiledata.mul"),
|
||||||
|
FilePath("verdata.mul")
|
||||||
|
})
|
||||||
|
{
|
||||||
|
sb.Append('|');
|
||||||
|
|
||||||
|
if (path == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var info = new FileInfo(path);
|
||||||
|
|
||||||
|
if (!info.Exists)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
sb.Append(info.Length).Append(',').Append(info.LastWriteTimeUtc.Ticks);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// An unreadable file is itself a state, and one that must not change from page
|
||||||
|
// to page without being noticed. Leaving the slot empty does that.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return BridgeAssets.Sha256Hex(Encoding.UTF8.GetBytes(sb.ToString())).Substring(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FilePath(string name)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Files.GetFilePath(name);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── shared plumbing ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The art index and its record reader, opened for one reply and closed with it — the
|
||||||
|
/// same lifetime rule phase 3's <c>Readers</c> follows, and for the same reason: a page
|
||||||
|
/// decodes hundreds of sprites through them and opening them is microseconds, so
|
||||||
|
/// holding handles on the operator's client files for the life of a cache buys nothing.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class Readers : IDisposable
|
||||||
|
{
|
||||||
|
public readonly FileIndex Index;
|
||||||
|
public readonly BridgeAssetValidator.RecordReader Reader;
|
||||||
|
public readonly long DataLength;
|
||||||
|
public readonly long VerdataLength;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which file the pixels came out of — `uop` or `legacy` — carried on every row
|
||||||
|
/// beside the body catalogue's own `source` (§4.9). On this plane it answers §4.6's
|
||||||
|
/// operator question: art added to `art.mul` while `artLegacyMUL.uop` is present is
|
||||||
|
/// never read, and a row that says `uop` is what says so.
|
||||||
|
/// </summary>
|
||||||
|
public readonly string Source;
|
||||||
|
|
||||||
|
public Readers()
|
||||||
|
{
|
||||||
|
string data = BridgeAssetValidator.ArtDataPath();
|
||||||
|
string verdata = FilePath("verdata.mul");
|
||||||
|
|
||||||
|
DataLength = BridgeAssetValidator.MulLength(data);
|
||||||
|
VerdataLength = BridgeAssetValidator.MulLength(verdata);
|
||||||
|
|
||||||
|
Source = data != null && data.EndsWith(".uop", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "uop"
|
||||||
|
: "legacy";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Index = BridgeAssetValidator.OpenArtIndex();
|
||||||
|
|
||||||
|
if (data != null)
|
||||||
|
Reader = new BridgeAssetValidator.RecordReader(data, verdata);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] art: could not open the art files: {0}", e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Reader == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Reader.Dispose();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Closing a read-only handle. Nothing useful is left to do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lets the held pictures go once nothing has asked for one in five minutes. The id is
|
||||||
|
/// derived from the client files rather than minted per build, so a walk that spans the
|
||||||
|
/// drop resumes against the same catalogue instead of starting over.
|
||||||
|
/// </summary>
|
||||||
|
private static void Sweep()
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (_cache == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (DateTime.UtcNow - _cache.LastUsed > IdleFor)
|
||||||
|
_cache = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Status()
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (_cache == null)
|
||||||
|
return "art(empty)";
|
||||||
|
|
||||||
|
return String.Format("art(id={0} held={1} bytes={2} cap={3})",
|
||||||
|
_cache.Id, _cache.ByKey.Count, _cache.Bytes, BridgeConfig.AssetArtCacheBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
863
overlay/Scripts/Custom/Bridge/BridgeAssetValidator.cs
Normal file
863
overlay/Scripts/Custom/Bridge/BridgeAssetValidator.cs
Normal file
@@ -0,0 +1,863 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
using Ultima;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// **Validate before calling** (docs/link/v8.md §4.5) — the boundary between this protocol
|
||||||
|
/// and ServUO's vendored <c>Ultima</c> decoders. Phase 0 prototyped it in
|
||||||
|
/// <c>tools/scaffolding/BridgeAssetProbe.cs</c> and measured it both ways; phase 1 promoted
|
||||||
|
/// it here, into the overlay, and extended it to animations.
|
||||||
|
///
|
||||||
|
/// The principle: `Ultima`'s decoders take their bounds from the file they are reading, so
|
||||||
|
/// the extractor must decide whether a record is worth handing over *before* handing it
|
||||||
|
/// over. Every check below is against the index entry and the record header — cheap, and
|
||||||
|
/// enough to turn an uncatchable corrupted-state exception into a skipped asset.
|
||||||
|
///
|
||||||
|
/// **The failure this exists for is a wrong picture, not a crash.** `LoadStatic`,
|
||||||
|
/// `LoadLand` and `GetAnimation` all decode out of a shared <c>m_StreamBuffer</c> that is
|
||||||
|
/// reused, only ever grown, and filled by a <c>stream.Read</c> whose return value is
|
||||||
|
/// discarded. A record that is short, absent or out of bounds therefore renders **whatever
|
||||||
|
/// the previously-decoded asset left behind**, reports success, and is undetectable by
|
||||||
|
/// anything downstream. On the stock client on the machine phase 0 ran on that is 22,102
|
||||||
|
/// ids whose index entry reads <c>lookup 0, length 0</c>.
|
||||||
|
///
|
||||||
|
/// It cannot be complete and does not claim to be. It closes the shapes that reading the
|
||||||
|
/// source showed are reachable. What says the boundary is in the right place is the second
|
||||||
|
/// measurement rather than the first: against a client patched 21 ways it refused all eight
|
||||||
|
/// record-level defects, and against the **stock** client it refused **nothing** across
|
||||||
|
/// 49,151 statics and 16,384 land tiles. A checker that refuses real art would be worse
|
||||||
|
/// than no checker.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeAssetValidator
|
||||||
|
{
|
||||||
|
public enum Verdict
|
||||||
|
{
|
||||||
|
/// <summary>Nothing at this id, and the index says so honestly.</summary>
|
||||||
|
Absent,
|
||||||
|
|
||||||
|
/// <summary>The entry is self-consistent and inside its file.</summary>
|
||||||
|
Ok,
|
||||||
|
|
||||||
|
/// <summary>The entry claims something the file cannot support. Do not decode it.</summary>
|
||||||
|
Refused
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Land tiles decode a fixed 44×44 diamond: 2 × (2+4+…+44) ushorts.</summary>
|
||||||
|
public const int LandRecordBytes = 2024;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A ceiling on decoded art dimensions. `LoadStatic` allocates
|
||||||
|
/// <c>new Bitmap(width, height)</c> straight from two ushorts in the record, so a
|
||||||
|
/// corrupt header asks for up to 65535×65535 — an 8 GB allocation, from a file. Real
|
||||||
|
/// art is a couple of hundred pixels at most.
|
||||||
|
/// </summary>
|
||||||
|
public const int MaxArtDimension = 1024;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds our own index over the same files, with the same constructor arguments
|
||||||
|
/// <c>Art</c> uses — including <c>hasExtra: false</c>, which is the whole reason the
|
||||||
|
/// art path is safe where the gump path is not (§4.1).
|
||||||
|
/// </summary>
|
||||||
|
public static FileIndex OpenArtIndex()
|
||||||
|
{
|
||||||
|
if (ArtDataPath() == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new FileIndex("Artidx.mul", "Art.mul", "artLegacyMUL.uop", 0x10000, 4, ".tga", 0x13FDC, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The file an art index entry's <c>lookup</c> is an offset **into** — which is not
|
||||||
|
/// <c>art.mul</c> on any current client.
|
||||||
|
///
|
||||||
|
/// This cost a whole probe run to learn and it is the single most important thing
|
||||||
|
/// phase 1 must not get wrong. <c>FileIndex</c>'s UOP constructor ends with a bare
|
||||||
|
/// <c>MulPath = uopPath</c>: **when <c>artLegacyMUL.uop</c> exists it wins outright**,
|
||||||
|
/// and <c>art.mul</c> / <c>artidx.mul</c> are never opened at all. A validator that
|
||||||
|
/// bounds offsets against <c>art.mul</c> while the index holds UOP offsets is not
|
||||||
|
/// merely approximate, it is nonsense — the first run of this probe refused 34,299
|
||||||
|
/// perfectly good statics for "declaring 10533x2085" because it was reading UOP
|
||||||
|
/// offsets into the wrong file.
|
||||||
|
///
|
||||||
|
/// So the resolution order here mirrors <c>FileIndex</c>'s exactly, and anything that
|
||||||
|
/// needs the bytes behind an entry must ask this rather than assume.
|
||||||
|
/// </summary>
|
||||||
|
public static string ArtDataPath()
|
||||||
|
{
|
||||||
|
var uop = Files.GetFilePath("artlegacymul.uop");
|
||||||
|
|
||||||
|
if (uop != null)
|
||||||
|
return uop;
|
||||||
|
|
||||||
|
return Files.GetFilePath("art.mul");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long MulLength(string path)
|
||||||
|
{
|
||||||
|
if (path == null)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new FileInfo(path).Length;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Judges one index entry.
|
||||||
|
///
|
||||||
|
/// The check <c>FileIndex.Seek</c> is missing is the last one: it tests
|
||||||
|
/// <c>Stream.Length < e.lookup</c> — that the record *starts* inside the file — and
|
||||||
|
/// never that it *ends* inside it. A record that begins two bytes before EOF and
|
||||||
|
/// declares a length of 4,000 passes, and <c>stream.Read</c> then returns a short count
|
||||||
|
/// that the decoders discard, leaving the previous asset's bytes in the shared buffer.
|
||||||
|
/// </summary>
|
||||||
|
public static Verdict CheckEntry(FileIndex index, int at, long mulLength, long verdataLength, out string reason)
|
||||||
|
{
|
||||||
|
reason = null;
|
||||||
|
|
||||||
|
if (index == null || index.Index == null || at < 0 || at >= index.Index.Length)
|
||||||
|
{
|
||||||
|
reason = "index " + at + " out of range";
|
||||||
|
return Verdict.Absent;
|
||||||
|
}
|
||||||
|
|
||||||
|
Entry3D e = index.Index[at];
|
||||||
|
|
||||||
|
if (e.lookup < 0)
|
||||||
|
{
|
||||||
|
reason = "lookup " + e.lookup;
|
||||||
|
return Verdict.Absent;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool patched = (e.length & (1 << 31)) != 0;
|
||||||
|
int length = e.length & 0x7FFFFFFF;
|
||||||
|
|
||||||
|
if (!patched && e.length < 0)
|
||||||
|
{
|
||||||
|
reason = "length " + e.length;
|
||||||
|
return Verdict.Absent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (length == 0)
|
||||||
|
{
|
||||||
|
reason = "lookup " + e.lookup + ", length 0";
|
||||||
|
return Verdict.Absent;
|
||||||
|
}
|
||||||
|
|
||||||
|
long ceiling = patched ? verdataLength : mulLength;
|
||||||
|
|
||||||
|
if (ceiling <= 0)
|
||||||
|
{
|
||||||
|
reason = (patched ? "verdata.mul" : "the art data file") + " has no length";
|
||||||
|
return Verdict.Refused;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.lookup >= ceiling)
|
||||||
|
{
|
||||||
|
reason = "lookup " + e.lookup + " past the end of "
|
||||||
|
+ (patched ? "verdata.mul" : "the mul") + " (" + ceiling + ")";
|
||||||
|
return Verdict.Refused;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The missing check. A short read is silent, and its consequence is the PREVIOUS
|
||||||
|
// asset's picture served under this id.
|
||||||
|
if (e.lookup + (long)length > ceiling)
|
||||||
|
{
|
||||||
|
reason = "record runs " + (e.lookup + (long)length - ceiling) + " bytes past the end of "
|
||||||
|
+ (patched ? "verdata.mul" : "the mul");
|
||||||
|
return Verdict.Refused;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Verdict.Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// `LoadLand` reads 2,024 bytes regardless of the declared length, so a shorter record
|
||||||
|
/// reads past the end of a buffer sized from that length.
|
||||||
|
/// </summary>
|
||||||
|
public static bool LandLengthSane(FileIndex index, int at, out string reason)
|
||||||
|
{
|
||||||
|
reason = null;
|
||||||
|
|
||||||
|
if (index == null || index.Index == null || at < 0 || at >= index.Index.Length)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
int length = index.Index[at].length & 0x7FFFFFFF;
|
||||||
|
|
||||||
|
if (length > 0 && length < LandRecordBytes)
|
||||||
|
{
|
||||||
|
reason = "land record is " + length + " bytes; LoadLand always reads " + LandRecordBytes;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Walks a static record's own row table the way <c>LoadStatic</c> will, and refuses
|
||||||
|
/// it if that walk would read outside the record.
|
||||||
|
///
|
||||||
|
/// This is the check with teeth. <c>LoadStatic</c>'s inner loop guards the write into
|
||||||
|
/// the bitmap (<c>xOffset > delta</c>, <c>xOffset + xRun > delta</c>) and does
|
||||||
|
/// nothing at all about the read cursor, which advances until it happens to find a
|
||||||
|
/// zero pair — potentially far outside a pinned array. Simulating the same walk with
|
||||||
|
/// a bound is the cheapest way to know whether handing the id over is safe.
|
||||||
|
/// </summary>
|
||||||
|
public static bool StaticRecordSane(byte[] record, int length, out string reason)
|
||||||
|
{
|
||||||
|
reason = null;
|
||||||
|
|
||||||
|
if (length < 8)
|
||||||
|
{
|
||||||
|
reason = "record is " + length + " bytes; a static header needs 8";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int words = length / 2;
|
||||||
|
int width = ReadUInt16(record, 4);
|
||||||
|
int height = ReadUInt16(record, 6);
|
||||||
|
|
||||||
|
// LoadStatic returns null for these rather than misbehaving, so it is not a refusal.
|
||||||
|
if (width <= 0 || height <= 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (width > MaxArtDimension || height > MaxArtDimension)
|
||||||
|
{
|
||||||
|
reason = "declares " + width + "x" + height + ", past the " + MaxArtDimension + "px ceiling";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The row-lookup table: height ushorts starting at word 4.
|
||||||
|
if (4 + height > words)
|
||||||
|
{
|
||||||
|
reason = "row table (" + height + " entries) does not fit in a " + length + "-byte record";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int start = height + 4;
|
||||||
|
|
||||||
|
for (int y = 0; y < height; y++)
|
||||||
|
{
|
||||||
|
int cursor = start + ReadUInt16(record, (4 + y) * 2);
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
// Two ushorts for the run header, and they must both be inside the record.
|
||||||
|
if (cursor < 0 || cursor + 1 >= words)
|
||||||
|
{
|
||||||
|
reason = "row " + y + " reads at word " + cursor + ", past the record's " + words;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int xOffset = ReadUInt16(record, cursor * 2);
|
||||||
|
int xRun = ReadUInt16(record, (cursor + 1) * 2);
|
||||||
|
cursor += 2;
|
||||||
|
|
||||||
|
if (xOffset + xRun == 0)
|
||||||
|
break;
|
||||||
|
|
||||||
|
// LoadStatic stops the row here, so the read cursor stops with it.
|
||||||
|
if (xOffset > width || xOffset + xRun > width)
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (cursor + xRun > words)
|
||||||
|
{
|
||||||
|
reason = "row " + y + " declares a " + xRun + "-pixel run running past the record";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor += xRun;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── animations (phase 1) ─────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Phase 0 measured the art path and left this half unbuilt, and then proved it was
|
||||||
|
// needed: the patched client's verdata entry for body 34 points past verdata.mul's own
|
||||||
|
// end, and the wolf still "decoded" — counted among the 1,144 successes while rendering
|
||||||
|
// something else entirely. `GetAnimation` has every weakness `LoadStatic` has and one
|
||||||
|
// more, because the buffer it decodes from is longer than the record it read.
|
||||||
|
|
||||||
|
/// <summary>The palette every animation record opens with: 0x100 ushorts.</summary>
|
||||||
|
public const int AnimPaletteBytes = 0x100 * 2;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A ceiling on an animation's declared frame count. <c>GetAnimation</c> does
|
||||||
|
/// <c>new int[frameCount]</c> straight from four bytes in the file, before it has
|
||||||
|
/// looked at anything else. Real actions are tens of frames.
|
||||||
|
/// </summary>
|
||||||
|
public const int MaxAnimFrames = 1024;
|
||||||
|
|
||||||
|
/// <summary>The xor <c>Frame</c> applies to every run header before decoding it.</summary>
|
||||||
|
private const int DoubleXor = (0x200 << 22) | (0x200 << 12);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <c>anim*.mul</c> an animation index entry's <c>lookup</c> is an offset into.
|
||||||
|
///
|
||||||
|
/// Unlike art (§4.6) there is no UOP precedence to get wrong here, and that is not
|
||||||
|
/// luck: <c>Animations</c> constructs its five <c>FileIndex</c>es with the four-argument
|
||||||
|
/// constructor, which passes <c>uopFile: null</c>. It never reads
|
||||||
|
/// <c>AnimationFrame*.uop</c> at all — which is the same fact that leaves six of the
|
||||||
|
/// twelve player-character bodies undecodable until §4.3's reader lands in phase 4.
|
||||||
|
/// </summary>
|
||||||
|
public static string AnimDataPath(int fileType)
|
||||||
|
{
|
||||||
|
switch (fileType)
|
||||||
|
{
|
||||||
|
case 1: return Files.GetFilePath("anim.mul");
|
||||||
|
case 2: return Files.GetFilePath("anim2.mul");
|
||||||
|
case 3: return Files.GetFilePath("anim3.mul");
|
||||||
|
case 4: return Files.GetFilePath("anim4.mul");
|
||||||
|
case 5: return Files.GetFilePath("anim5.mul");
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds our own index over one anim file, with the same constructor arguments
|
||||||
|
/// <c>Animations</c> uses — the entry lengths especially, since they decide how far
|
||||||
|
/// into the file an index runs.
|
||||||
|
/// </summary>
|
||||||
|
public static FileIndex OpenAnimIndex(int fileType)
|
||||||
|
{
|
||||||
|
if (AnimDataPath(fileType) == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
switch (fileType)
|
||||||
|
{
|
||||||
|
case 1: return new FileIndex("Anim.idx", "Anim.mul", 0x40000, 6);
|
||||||
|
case 2: return new FileIndex("Anim2.idx", "Anim2.mul", 0x10000, -1);
|
||||||
|
case 3: return new FileIndex("Anim3.idx", "Anim3.mul", 0x20000, -1);
|
||||||
|
case 4: return new FileIndex("Anim4.idx", "Anim4.mul", 0x20000, -1);
|
||||||
|
case 5: return new FileIndex("Anim5.idx", "Anim5.mul", 0x20000, -1);
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where a body's animation actually lives: which anim file, and which index in it.
|
||||||
|
///
|
||||||
|
/// **This is the never-sweep-file-types rule, written as code** (§4.3). It asks
|
||||||
|
/// <c>BodyConverter.Convert</c> once, takes its answer, and if that answer leads
|
||||||
|
/// nowhere it reports nowhere. There is deliberately no loop here and no fallback,
|
||||||
|
/// because asking the *other* anim files for an index they do not own does not fail —
|
||||||
|
/// it returns 175 decodable action/direction combinations of **a giant spider** for
|
||||||
|
/// gargoyle 666, and misaligned colour fragments for the other two. Every one of those
|
||||||
|
/// reads reports success, and nothing downstream can tell them from art.
|
||||||
|
///
|
||||||
|
/// A false return with <paramref name="reason"/> set is the ordinary, expected answer
|
||||||
|
/// for a body this client has no art for — the caller reports absent, not an error.
|
||||||
|
/// </summary>
|
||||||
|
public static bool ResolveAnimation(
|
||||||
|
int body, int action, int direction, out int fileType, out int index, out string reason)
|
||||||
|
{
|
||||||
|
reason = null;
|
||||||
|
fileType = 0;
|
||||||
|
index = -1;
|
||||||
|
|
||||||
|
if (body <= 0 || action < 0)
|
||||||
|
{
|
||||||
|
reason = "body " + body + " action " + action + " is not addressable";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directions 5-7 are the client mirroring 1-3, and `Frame` decodes them through its
|
||||||
|
// flip branch — different pointer arithmetic, which nothing below has checked.
|
||||||
|
// §5.1 fixed this protocol at direction 0 or 1, so refusing the rest costs nothing
|
||||||
|
// and keeps the validator honest about what it has actually verified.
|
||||||
|
if (direction < 0 || direction > 4)
|
||||||
|
{
|
||||||
|
reason = "direction " + direction + " is mirrored; this protocol reads 0-4 only";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int translated = body;
|
||||||
|
int hue = 0;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Exactly what GetAnimation(..., preserveHue: false, ...) does first.
|
||||||
|
Animations.Translate(ref translated, ref hue);
|
||||||
|
fileType = BodyConverter.Convert(ref translated);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
reason = "body.def/bodyconv.def lookup failed: " + e.GetType().Name;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AnimDataPath(fileType) == null)
|
||||||
|
{
|
||||||
|
// Gargoyle 666 lands here: Bodyconv.def maps it to anim5, and this client has no
|
||||||
|
// anim5. Absent is the correct answer and the ONLY safe one.
|
||||||
|
reason = "bodyconv sends body " + body + " to file type " + fileType
|
||||||
|
+ ", which this client does not have";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int actions = ActionsOf(translated, fileType);
|
||||||
|
|
||||||
|
if (action >= actions)
|
||||||
|
{
|
||||||
|
// §4.10, measured in phase 6: this is the never-sweep rule again, one axis over.
|
||||||
|
// A body's slots are contiguous and the next body's begin immediately after them,
|
||||||
|
// so `index + action * 5` past the ceiling addresses ANOTHER BODY'S action — a
|
||||||
|
// real record, at a real offset, that every check below passes. Measured on this
|
||||||
|
// client: of 795 legacy bodies, 643 return a fully validated picture one action
|
||||||
|
// past their band and **452 of those are byte-identical to body+1's action 0**.
|
||||||
|
// Body 1 action 22 is an ettin; body 3 action 22 is an imp. Nothing downstream
|
||||||
|
// can tell, which is why the refusal has to be here.
|
||||||
|
reason = "body " + body + " has " + actions + " actions in file type " + fileType
|
||||||
|
+ "; action " + action + " belongs to the next body";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
index = AnimIndexOf(translated, fileType) + (action * 5) + direction;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many actions the index reserves for a body — the only safe ceiling, and it is
|
||||||
|
/// the banding rather than the library's own answer.
|
||||||
|
///
|
||||||
|
/// <c>Animations.GetAnimLength</c> exists and looks like the right source. It is not:
|
||||||
|
/// for a body reaching file type 5 as id 34 it answers **22** while
|
||||||
|
/// <see cref="AnimIndexOf"/> puts that body in the 65-slot band, which is **13**. The
|
||||||
|
/// two disagree on exactly one body of this client (reached by translation from body
|
||||||
|
/// 276), and taking the larger number is nine actions of somebody else's art. So the
|
||||||
|
/// count is derived from the same arithmetic that produces the offset, in the same
|
||||||
|
/// file, where the two cannot drift apart.
|
||||||
|
/// </summary>
|
||||||
|
public static bool ActionCount(int body, out int actions, out int fileType, out string reason)
|
||||||
|
{
|
||||||
|
reason = null;
|
||||||
|
actions = 0;
|
||||||
|
fileType = 0;
|
||||||
|
|
||||||
|
if (body <= 0)
|
||||||
|
{
|
||||||
|
reason = "body " + body + " is not addressable";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int translated = body;
|
||||||
|
int hue = 0;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Animations.Translate(ref translated, ref hue);
|
||||||
|
fileType = BodyConverter.Convert(ref translated);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
reason = "body.def/bodyconv.def lookup failed: " + e.GetType().Name;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AnimDataPath(fileType) == null)
|
||||||
|
{
|
||||||
|
reason = "bodyconv sends body " + body + " to file type " + fileType
|
||||||
|
+ ", which this client does not have";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
actions = ActionsOf(translated, fileType);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The banding of <see cref="AnimIndexOf"/>, read as an action count: a body's slots
|
||||||
|
/// are five directions per action, so the band size divided by five is how many
|
||||||
|
/// actions it owns.
|
||||||
|
/// </summary>
|
||||||
|
private static int ActionsOf(int body, int fileType)
|
||||||
|
{
|
||||||
|
return SlotsOf(body, fileType) / 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many index slots <see cref="AnimIndexOf"/>'s arithmetic gives this body. The
|
||||||
|
/// bands are transcribed there and their sizes here, from the same source and in the
|
||||||
|
/// same order, because a ceiling that disagrees with an offset is worse than no
|
||||||
|
/// ceiling at all.
|
||||||
|
/// </summary>
|
||||||
|
private static int SlotsOf(int body, int fileType)
|
||||||
|
{
|
||||||
|
switch (fileType)
|
||||||
|
{
|
||||||
|
case 2:
|
||||||
|
return body < 200 ? 110 : 65;
|
||||||
|
|
||||||
|
case 3:
|
||||||
|
if (body < 300)
|
||||||
|
return 65;
|
||||||
|
|
||||||
|
return body < 400 ? 110 : 175;
|
||||||
|
|
||||||
|
case 5:
|
||||||
|
// Body 34's exclusion again — it is in the second band here, so it owns 13
|
||||||
|
// actions and not 22. This is the one body `GetAnimLength` is wrong about.
|
||||||
|
if (body < 200 && body != 34)
|
||||||
|
return 110;
|
||||||
|
|
||||||
|
return body < 400 ? 65 : 175;
|
||||||
|
|
||||||
|
default: // 1 and 4 share their banding
|
||||||
|
if (body < 200)
|
||||||
|
return 110;
|
||||||
|
|
||||||
|
return body < 400 ? 65 : 175;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>Animations.GetFileIndex</c>'s own arithmetic, which is private. The banding is
|
||||||
|
/// per file type and the boundaries differ between them, so this is transcribed rather
|
||||||
|
/// than generalised — an index that disagrees with the library's by one is a picture
|
||||||
|
/// of the wrong creature, validated.
|
||||||
|
/// </summary>
|
||||||
|
private static int AnimIndexOf(int body, int fileType)
|
||||||
|
{
|
||||||
|
switch (fileType)
|
||||||
|
{
|
||||||
|
case 2:
|
||||||
|
return body < 200 ? body * 110 : 22000 + ((body - 200) * 65);
|
||||||
|
|
||||||
|
case 3:
|
||||||
|
if (body < 300)
|
||||||
|
return body * 65;
|
||||||
|
|
||||||
|
return body < 400 ? 33000 + ((body - 300) * 110) : 35000 + ((body - 400) * 175);
|
||||||
|
|
||||||
|
case 5:
|
||||||
|
// "looks strange, though it works" — the library's own comment. Body 34 is
|
||||||
|
// excluded from the first band here and nowhere else.
|
||||||
|
if (body < 200 && body != 34)
|
||||||
|
return body * 110;
|
||||||
|
|
||||||
|
return body < 400 ? 22000 + ((body - 200) * 65) : 35000 + ((body - 400) * 175);
|
||||||
|
|
||||||
|
default: // 1 and 4 share their banding
|
||||||
|
if (body < 200)
|
||||||
|
return body * 110;
|
||||||
|
|
||||||
|
return body < 400 ? 22000 + ((body - 200) * 65) : 35000 + ((body - 400) * 175);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Walks an animation record the way <c>GetAnimation</c> and <c>Frame</c> will, and
|
||||||
|
/// refuses it if that walk would read outside the record or write outside the bitmap.
|
||||||
|
///
|
||||||
|
/// Two things make this stricter than the static walk, and both come from the library:
|
||||||
|
///
|
||||||
|
/// <c>GetAnimation</c> decodes through <c>new MemoryStream(m_StreamBuffer, false)</c> —
|
||||||
|
/// the whole shared buffer, not the <c>length</c> bytes it just read into it. So a
|
||||||
|
/// truncated record does not hit end-of-stream and throw; the reader sails on into the
|
||||||
|
/// **previous** animation's bytes and returns a plausible frame. Bounding against
|
||||||
|
/// <paramref name="length"/> rather than against the buffer is the entire point.
|
||||||
|
///
|
||||||
|
/// And <c>Frame</c>'s run loop is a *write* through a <c>LockBits</c> pointer whose
|
||||||
|
/// origin comes from two signed shorts in the file (<c>xCenter</c>, <c>yCenter</c>),
|
||||||
|
/// with no bound of any kind. <c>LoadStatic</c> at least guards its writes; this does
|
||||||
|
/// not, so the destination of every run is checked against the bitmap it locked.
|
||||||
|
///
|
||||||
|
/// <paramref name="maxFrames"/> is how many frames the caller will actually decode —
|
||||||
|
/// 1 for the catalogue's thumbnail (<c>FirstFrame: true</c>), 0 for all of them.
|
||||||
|
/// Checking frames nobody decodes would invent refusals, which §4.5 costs more than
|
||||||
|
/// it saves.
|
||||||
|
/// </summary>
|
||||||
|
public static bool AnimationRecordSane(byte[] record, int length, int maxFrames, out string reason)
|
||||||
|
{
|
||||||
|
reason = null;
|
||||||
|
|
||||||
|
if (length < AnimPaletteBytes + 4)
|
||||||
|
{
|
||||||
|
reason = "record is " + length + " bytes; an animation needs "
|
||||||
|
+ (AnimPaletteBytes + 4) + " for its palette and frame count";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int start = AnimPaletteBytes;
|
||||||
|
int frameCount = ReadInt32(record, start);
|
||||||
|
|
||||||
|
if (frameCount <= 0)
|
||||||
|
{
|
||||||
|
reason = "declares " + frameCount + " frames";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frameCount > MaxAnimFrames)
|
||||||
|
{
|
||||||
|
reason = "declares " + frameCount + " frames, past the " + MaxAnimFrames + " ceiling";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The lookup table is read in full whatever FirstFrame says, so it is bounded in full.
|
||||||
|
long tableEnd = (long)start + 4 + ((long)frameCount * 4);
|
||||||
|
|
||||||
|
if (tableEnd > length)
|
||||||
|
{
|
||||||
|
reason = "frame table (" + frameCount + " entries) does not fit in a "
|
||||||
|
+ length + "-byte record";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int check = maxFrames > 0 && maxFrames < frameCount ? maxFrames : frameCount;
|
||||||
|
|
||||||
|
for (int i = 0; i < check; i++)
|
||||||
|
{
|
||||||
|
int at = start + ReadInt32(record, start + 4 + (i * 4));
|
||||||
|
|
||||||
|
if (!FrameSane(record, length, at, i, out reason))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool FrameSane(byte[] record, int length, int at, int frame, out string reason)
|
||||||
|
{
|
||||||
|
reason = null;
|
||||||
|
|
||||||
|
if (at < 0 || at + 8 > length)
|
||||||
|
{
|
||||||
|
reason = "frame " + frame + " starts at " + at + ", outside the "
|
||||||
|
+ length + "-byte record";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int xCenter = ReadInt16(record, at);
|
||||||
|
int yCenter = ReadInt16(record, at + 2);
|
||||||
|
int width = ReadUInt16(record, at + 4);
|
||||||
|
int height = ReadUInt16(record, at + 6);
|
||||||
|
|
||||||
|
// Frame's constructor returns before locking anything for these, so they are empty
|
||||||
|
// rather than dangerous — and an empty frame is a real thing in this format.
|
||||||
|
if (width == 0 || height == 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (width > MaxArtDimension || height > MaxArtDimension)
|
||||||
|
{
|
||||||
|
reason = "frame " + frame + " declares " + width + "x" + height + ", past the "
|
||||||
|
+ MaxArtDimension + "px ceiling";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings.PixelFormat is 16bpp and GDI+ pads each scanline to four bytes, so a row
|
||||||
|
// is `delta` ushorts wide and the locked region is height*delta of them. This is the
|
||||||
|
// same `bd.Stride >> 1` Frame computes.
|
||||||
|
int delta = (((width * 2) + 3) & ~3) >> 1;
|
||||||
|
long pixels = (long)height * delta;
|
||||||
|
|
||||||
|
long origin = (xCenter - 0x200) + ((long)((yCenter + height) - 0x200) * delta);
|
||||||
|
int cursor = at + 8;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (cursor + 4 > length)
|
||||||
|
{
|
||||||
|
reason = "frame " + frame
|
||||||
|
+ " runs off the end of the record looking for its terminator";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int header = ReadInt32(record, cursor);
|
||||||
|
cursor += 4;
|
||||||
|
|
||||||
|
if (header == 0x7FFF7FFF)
|
||||||
|
break;
|
||||||
|
|
||||||
|
header ^= DoubleXor;
|
||||||
|
|
||||||
|
long dy = (header >> 12) & 0x3FF;
|
||||||
|
long dx = (header >> 22) & 0x3FF;
|
||||||
|
int run = header & 0xFFF;
|
||||||
|
|
||||||
|
long first = origin + (dy * delta) + dx;
|
||||||
|
|
||||||
|
if (first < 0 || first + run > pixels)
|
||||||
|
{
|
||||||
|
reason = "frame " + frame + " writes pixels " + first + ".." + (first + run)
|
||||||
|
+ " outside its own " + pixels + "-pixel bitmap";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One palette byte per pixel, read straight out of the record.
|
||||||
|
if (cursor + run > length)
|
||||||
|
{
|
||||||
|
reason = "frame " + frame + " declares a " + run
|
||||||
|
+ "-pixel run running past the record";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor += run;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReadUInt16(byte[] b, int at)
|
||||||
|
{
|
||||||
|
return b[at] | (b[at + 1] << 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReadInt16(byte[] b, int at)
|
||||||
|
{
|
||||||
|
return (short)(b[at] | (b[at + 1] << 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReadInt32(byte[] b, int at)
|
||||||
|
{
|
||||||
|
return b[at] | (b[at + 1] << 8) | (b[at + 2] << 16) | (b[at + 3] << 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a record's actual bytes so <see cref="StaticRecordSane"/> or
|
||||||
|
/// <see cref="AnimationRecordSane"/> can walk it.
|
||||||
|
///
|
||||||
|
/// Holds its own handles rather than borrowing the library's, because <c>FileIndex</c>
|
||||||
|
/// hands out the stream it decodes from and moving that stream's position underneath
|
||||||
|
/// the decoder would be its own bug. Opened <c>FileShare.ReadWrite</c> to match how
|
||||||
|
/// <c>FileIndex</c> opens the same files.
|
||||||
|
///
|
||||||
|
/// One reader serves one data file, so an animation sweep wants one per file type,
|
||||||
|
/// built from <see cref="AnimDataPath"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RecordReader : IDisposable
|
||||||
|
{
|
||||||
|
private readonly FileStream _mul;
|
||||||
|
private readonly FileStream _verdata;
|
||||||
|
private byte[] _scratch = new byte[64 * 1024];
|
||||||
|
|
||||||
|
public RecordReader(string mulPath, string verdataPath)
|
||||||
|
{
|
||||||
|
_mul = Open(mulPath);
|
||||||
|
_verdata = Open(verdataPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FileStream Open(string path)
|
||||||
|
{
|
||||||
|
if (path == null || !File.Exists(path))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the record at <paramref name="at"/> is safe to hand to
|
||||||
|
/// <c>Art.GetStatic</c>. A record that cannot be read at all is reported sane —
|
||||||
|
/// <see cref="CheckEntry"/> has already judged the entry, and this must not
|
||||||
|
/// invent a second reason to refuse.
|
||||||
|
/// </summary>
|
||||||
|
public bool StaticSane(FileIndex index, int at, out string reason)
|
||||||
|
{
|
||||||
|
int length = ReadRecord(index, at, out reason);
|
||||||
|
|
||||||
|
if (length < 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (length == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return StaticRecordSane(_scratch, length, out reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the record at <paramref name="at"/> is safe to hand to
|
||||||
|
/// <c>Animations.GetAnimation</c>. <paramref name="maxFrames"/> is how many frames
|
||||||
|
/// the caller will decode — 1 for a <c>FirstFrame</c> call, 0 for all of them.
|
||||||
|
/// </summary>
|
||||||
|
public bool AnimationSane(FileIndex index, int at, int maxFrames, out string reason)
|
||||||
|
{
|
||||||
|
int length = ReadRecord(index, at, out reason);
|
||||||
|
|
||||||
|
if (length < 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (length == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return AnimationRecordSane(_scratch, length, maxFrames, out reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads one record into <see cref="_scratch"/>. Returns its length, 0 for a
|
||||||
|
/// failure (with <paramref name="reason"/> set), or -1 when there is nothing to
|
||||||
|
/// read at all — <see cref="CheckEntry"/> has already judged the entry, and this
|
||||||
|
/// must not invent a second reason to refuse.
|
||||||
|
/// </summary>
|
||||||
|
private int ReadRecord(FileIndex index, int at, out string reason)
|
||||||
|
{
|
||||||
|
reason = null;
|
||||||
|
|
||||||
|
if (index == null || index.Index == null || at < 0 || at >= index.Index.Length)
|
||||||
|
return -1;
|
||||||
|
|
||||||
|
Entry3D e = index.Index[at];
|
||||||
|
bool patched = (e.length & (1 << 31)) != 0;
|
||||||
|
int length = e.length & 0x7FFFFFFF;
|
||||||
|
|
||||||
|
var stream = patched ? _verdata : _mul;
|
||||||
|
|
||||||
|
if (stream == null || length <= 0 || e.lookup < 0)
|
||||||
|
return -1;
|
||||||
|
|
||||||
|
if (_scratch.Length < length)
|
||||||
|
_scratch = new byte[length];
|
||||||
|
|
||||||
|
int read;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
stream.Seek(e.lookup, SeekOrigin.Begin);
|
||||||
|
read = stream.Read(_scratch, 0, length);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
reason = "cannot read the record: " + ex.GetType().Name;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The short read the decoders discard. Refusing here is the whole point: the
|
||||||
|
// library would decode whatever the shared buffer happened to hold.
|
||||||
|
if (read < length)
|
||||||
|
{
|
||||||
|
reason = "short read — " + read + " of " + length + " bytes available";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_mul != null)
|
||||||
|
_mul.Dispose();
|
||||||
|
|
||||||
|
if (_verdata != null)
|
||||||
|
_verdata.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1305
overlay/Scripts/Custom/Bridge/BridgeAssets.cs
Normal file
1305
overlay/Scripts/Custom/Bridge/BridgeAssets.cs
Normal file
File diff suppressed because it is too large
Load Diff
254
overlay/Scripts/Custom/Bridge/BridgeBodies.cs
Normal file
254
overlay/Scripts/Custom/Bridge/BridgeBodies.cs
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
using Server.Mobiles;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// **Slug to body id — the part only the shard can do** (docs/link/v8.md §8, protocol 8,
|
||||||
|
/// phase 3).
|
||||||
|
///
|
||||||
|
/// The spawn atlas knows a creature by a **slug** derived from the class name it found in
|
||||||
|
/// `Spawns/*.xml` ("giant-spider"). The client knows the same creature by a **body id**
|
||||||
|
/// (28). Nothing in the ServUO tree declares that mapping as data. Today an operator
|
||||||
|
/// bridges it by hand, grepping `Scripts/Mobiles/Normal/<Name>.cs` for `Body =`,
|
||||||
|
/// which appears as a decimal, as hex (`0xD1`), as `Utility.RandomList(35, 36)` and as an
|
||||||
|
/// `m_IDs[]` table — a parse that is wrong on the shard's own custom creatures, which is
|
||||||
|
/// precisely the set an operator most wants pictures for.
|
||||||
|
///
|
||||||
|
/// Inside ServUO the problem does not exist: construct the type, read `Body.BodyID`,
|
||||||
|
/// delete it. <c>BridgeWorld.cs</c> already does exactly that for a different feature.
|
||||||
|
///
|
||||||
|
/// **This is the one asset-plane family that does NOT run on the asset worker**, and the
|
||||||
|
/// reason is the whole point of §8. Constructing and deleting a mobile is world mutation,
|
||||||
|
/// so it must happen on the Core thread — while the decode in <see cref="BridgeCatalog"/>
|
||||||
|
/// must happen off it, because it reads hundreds of megabytes and would stop the world for
|
||||||
|
/// every player on the shard. That split is why body resolution is its own request kind
|
||||||
|
/// rather than a step inside asset extraction.
|
||||||
|
///
|
||||||
|
/// Two consequences follow from answering on the Core thread, and both are bounds:
|
||||||
|
///
|
||||||
|
/// **The batch is small and the shard enforces the cap itself.** Every type constructed
|
||||||
|
/// here runs a real constructor — packing items, rolling skills, starting AI timers — and
|
||||||
|
/// all of that happens between two ticks of the world. The website chunks its own list;
|
||||||
|
/// a request over <see cref="BridgeConfig.AssetBodyBatch"/> names is **refused** rather
|
||||||
|
/// than truncated, so the two sides cannot quietly disagree about what was answered.
|
||||||
|
///
|
||||||
|
/// **It does not take the asset plane's single slot.** The slot exists to stop several
|
||||||
|
/// large replies queueing at once (§3.2); this reply is a few kilobytes and the work is
|
||||||
|
/// not on the worker, so claiming the slot would only make a body pass and a catalogue
|
||||||
|
/// page refuse each other for no benefit.
|
||||||
|
///
|
||||||
|
/// **A creature whose constructor randomises its body reports one of its variants**, not
|
||||||
|
/// an error and not a set. Constructing twice to detect that would double every side
|
||||||
|
/// effect above to learn something the bestiary does not render differently — both ids are
|
||||||
|
/// the same creature. The answer is stable enough to cache and cheap enough to redo.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeBodies
|
||||||
|
{
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
if (!BridgeConfig.Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
BridgeBoot.RegisterHandler("assets.bodies", OnBodies);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the request ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static void OnBodies(Dictionary<string, object> o)
|
||||||
|
{
|
||||||
|
var reqId = BridgeJson.GetString(o, "reqId");
|
||||||
|
|
||||||
|
if (reqId == null)
|
||||||
|
{
|
||||||
|
// Rule 1 of the asset plane: without a correlation id this reply lands on the
|
||||||
|
// event path, is persisted to the sidecar's store and broadcast to every
|
||||||
|
// subscriber. Refuse rather than answer.
|
||||||
|
BridgeAssets.Fail(null, "BAD_REQUEST", "assets.bodies requires a reqId");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!BridgeConfig.AssetsEnabled)
|
||||||
|
{
|
||||||
|
BridgeAssets.Fail(reqId, "DISABLED", "asset extraction is disabled on this shard");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var types = BridgeJson.GetStringList(o, "types");
|
||||||
|
|
||||||
|
if (types.Count == 0)
|
||||||
|
{
|
||||||
|
BridgeAssets.Fail(reqId, "BAD_REQUEST",
|
||||||
|
"assets.bodies requires a non-empty `types` array of ServUO class names");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (types.Count > BridgeConfig.AssetBodyBatch)
|
||||||
|
{
|
||||||
|
// Refuse, never truncate. A silently shortened answer looks identical to a
|
||||||
|
// complete one from the website's side, and the types that fell off the end would
|
||||||
|
// be recorded as "asked and unanswerable" rather than "never asked".
|
||||||
|
BridgeAssets.Fail(reqId, "BAD_REQUEST",
|
||||||
|
"assets.bodies takes at most " + BridgeConfig.AssetBodyBatch
|
||||||
|
+ " types per request (asked for " + types.Count + "); send them in chunks");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Reply(reqId, types);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Core thread. Constructs each type once, reads its body, deletes it.
|
||||||
|
///
|
||||||
|
/// Every outcome is a **row**, never a failed request: a shard is expected to be asked
|
||||||
|
/// about types it does not have (an atlas built from a tree that has since changed, a
|
||||||
|
/// spawn file naming a creature from a script package the operator removed), and a
|
||||||
|
/// status screen that fails the whole pass over one of those teaches an operator to
|
||||||
|
/// stop pressing the button.
|
||||||
|
/// </summary>
|
||||||
|
private static void Reply(string reqId, List<string> types)
|
||||||
|
{
|
||||||
|
var sb = BridgeJson.Begin("assets.bodies.ok");
|
||||||
|
|
||||||
|
sb.Str("reqId", reqId)
|
||||||
|
.Num("extractorVersion", BridgeAssets.EXTRACTOR_VERSION)
|
||||||
|
.Num("asked", types.Count);
|
||||||
|
|
||||||
|
// The envelope is shared with every other family (§3.4) even though this one never
|
||||||
|
// pages: the website drives the chunking, so `more` is always false and `cut` always
|
||||||
|
// "end". Writing it anyway means one reader shape on the other side rather than two.
|
||||||
|
var page = new BridgeAssets.PageBuilder(sb, "rows", BridgeConfig.AssetBatchBytes);
|
||||||
|
|
||||||
|
int resolved = 0;
|
||||||
|
|
||||||
|
foreach (var name in types)
|
||||||
|
{
|
||||||
|
string status;
|
||||||
|
int body;
|
||||||
|
|
||||||
|
Resolve(name, out body, out status);
|
||||||
|
|
||||||
|
if (status == "ok")
|
||||||
|
resolved++;
|
||||||
|
|
||||||
|
var item = new StringBuilder(96);
|
||||||
|
|
||||||
|
item.Append("{\"type\":");
|
||||||
|
BridgeJson.Text(item, name);
|
||||||
|
item.Append(",\"status\":\"").Append(status).Append('"');
|
||||||
|
|
||||||
|
if (status == "ok")
|
||||||
|
item.Append(",\"body\":").Append(body.ToString(CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
|
item.Append('}');
|
||||||
|
|
||||||
|
// A chunk this small cannot spend the budget — the cap above is a hundred names
|
||||||
|
// and the budget is half a megabyte — but the check costs nothing and the day
|
||||||
|
// someone raises `AssetBodyBatch` it is the difference between a short page and a
|
||||||
|
// line the sidecar drops.
|
||||||
|
if (!page.TryAdd(item.ToString(), null))
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
page.Close();
|
||||||
|
|
||||||
|
sb.Num("resolved", resolved);
|
||||||
|
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One type name to one body id.
|
||||||
|
///
|
||||||
|
/// `status` is the field the website records, and the four values are four different
|
||||||
|
/// things an operator can act on:
|
||||||
|
///
|
||||||
|
/// <c>ok</c> — constructed, body read.
|
||||||
|
/// <c>unknown</c> — no such type on this shard. The spawn file names something the
|
||||||
|
/// scripts do not define, which is a real drift an operator wants to see.
|
||||||
|
/// <c>notCreature</c> — the type exists but is not a `BaseCreature`. Spawn files
|
||||||
|
/// legitimately name items and static decorations; those have no body and never will,
|
||||||
|
/// so this is a permanent answer rather than a retryable failure.
|
||||||
|
/// <c>failed</c> — the constructor threw, or the type has none that takes no
|
||||||
|
/// arguments. Caught per type, because one creature whose constructor depends on a
|
||||||
|
/// script package the operator removed must not cost the other ninety-nine.
|
||||||
|
/// </summary>
|
||||||
|
private static void Resolve(string name, out int body, out string status)
|
||||||
|
{
|
||||||
|
body = 0;
|
||||||
|
status = "failed";
|
||||||
|
|
||||||
|
Type type;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// `true` is ignoreCase — spawn files are hand-edited and their casing drifts from
|
||||||
|
// the class it names far more often than the name itself does.
|
||||||
|
type = ScriptCompiler.FindTypeByName(name, true);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
status = "failed";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type == null)
|
||||||
|
{
|
||||||
|
status = "unknown";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!typeof(BaseCreature).IsAssignableFrom(type) || type.IsAbstract)
|
||||||
|
{
|
||||||
|
status = "notCreature";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseCreature creature = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
creature = Activator.CreateInstance(type) as BaseCreature;
|
||||||
|
|
||||||
|
if (creature == null)
|
||||||
|
{
|
||||||
|
status = "failed";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
body = creature.Body.BodyID;
|
||||||
|
status = body > 0 ? "ok" : "failed";
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] assets.bodies: {0}: {1}: {2}",
|
||||||
|
name, e.GetType().Name, e.Message);
|
||||||
|
|
||||||
|
status = "failed";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (creature != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Deleting the mobile deletes the items it packed — `Mobile.Delete` walks
|
||||||
|
// `Items`, and `Item.Delete` walks what each contains — and stops its AI
|
||||||
|
// timer. A creature left alive here is a creature standing at (0,0,0) on
|
||||||
|
// the internal map forever, saved with the world, once per import.
|
||||||
|
creature.Delete();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Nothing useful is left to do, and throwing out of `finally` would lose
|
||||||
|
// whatever the try block was already reporting.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,7 +133,42 @@ namespace Server.Custom.Bridge
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
handler(obj);
|
// Protocol 6. A command may carry an `idempotencyKey`, and one that does is executed at
|
||||||
|
// most once: a repeat is answered with the original reply rather than re-run. The gate
|
||||||
|
// is here rather than in each handler so it covers every inbound kind — including the
|
||||||
|
// ones a later protocol adds, which is the half that is easy to forget. A command with
|
||||||
|
// no key behaves exactly as it did before, which is what keeps the admin screens (which
|
||||||
|
// send none) unchanged.
|
||||||
|
var idempotencyKey = BridgeJson.GetString(obj, "idempotencyKey");
|
||||||
|
|
||||||
|
if (idempotencyKey == null)
|
||||||
|
{
|
||||||
|
handler(obj);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BridgeIdempotency.Intercept(idempotencyKey, obj))
|
||||||
|
return; // already answered: a replay of the original reply, or bridge.busy
|
||||||
|
|
||||||
|
string error = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
handler(obj);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Swallowed deliberately, and only on the keyed path: the key must be closed out
|
||||||
|
// with a definite answer (see BridgeIdempotency's header) rather than left in
|
||||||
|
// flight by an exception unwinding past Finish. Unkeyed commands still throw the
|
||||||
|
// way they always have.
|
||||||
|
error = ex.Message;
|
||||||
|
Console.WriteLine("[Bridge] handler for '{0}' threw: {1}", kind, ex);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
BridgeIdempotency.Finish(idempotencyKey, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void OnPing(Dictionary<string, object> o)
|
private static void OnPing(Dictionary<string, object> o)
|
||||||
@@ -167,6 +202,9 @@ namespace Server.Custom.Bridge
|
|||||||
BridgeHousing.Rearm();
|
BridgeHousing.Rearm();
|
||||||
BridgePoints.Rearm();
|
BridgePoints.Rearm();
|
||||||
BridgeMarket.Rearm();
|
BridgeMarket.Rearm();
|
||||||
|
BridgeParticipation.Rearm();
|
||||||
|
BridgeLeases.Rearm();
|
||||||
|
BridgeWorld.Rearm();
|
||||||
// Not a sweep, so it has nothing to re-arm — but an operator who just edited a
|
// Not a sweep, so it has nothing to re-arm — but an operator who just edited a
|
||||||
// .cfg wants the change on the site now, not after a shard restart.
|
// .cfg wants the change on the site now, not after a shard restart.
|
||||||
BridgeRuleset.Emit();
|
BridgeRuleset.Emit();
|
||||||
@@ -188,6 +226,7 @@ namespace Server.Custom.Bridge
|
|||||||
BridgeHousing.SweepOnce();
|
BridgeHousing.SweepOnce();
|
||||||
BridgePoints.SweepOnce();
|
BridgePoints.SweepOnce();
|
||||||
BridgeMarket.SweepOnce();
|
BridgeMarket.SweepOnce();
|
||||||
|
BridgeParticipation.SweepOnce();
|
||||||
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
|
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
||||||
@@ -197,6 +236,7 @@ namespace Server.Custom.Bridge
|
|||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgePoints.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgePoints.Status());
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeMarket.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeMarket.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgeParticipation.Status());
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -215,6 +255,15 @@ namespace Server.Custom.Bridge
|
|||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeMarket.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeMarket.Status());
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeRuleset.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeRuleset.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgeIdempotency.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgeLeases.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgeParticipation.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgeWorld.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgeOneShots.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgeAssets.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgeCatalog.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgeArt.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgeTree.Status());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1137
overlay/Scripts/Custom/Bridge/BridgeCatalog.cs
Normal file
1137
overlay/Scripts/Custom/Bridge/BridgeCatalog.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,17 @@ namespace Server.Custom.Bridge
|
|||||||
// Item and Mobile serials occupy disjoint ranges, so one map safely spans all three families.
|
// Item and Mobile serials occupy disjoint ranges, so one map safely spans all three families.
|
||||||
private static readonly Dictionary<Serial, string> _last = new Dictionary<Serial, string>();
|
private static readonly Dictionary<Serial, string> _last = new Dictionary<Serial, string>();
|
||||||
|
|
||||||
private static long _sweeps, _emitted, _removed;
|
// Protocol 6. Which spawn a live champion belongs to, refreshed by the sweep. The kill itself
|
||||||
|
// is detected by TYPE (see OnCreatureDeath), so this map only ever supplies CONTEXT — which
|
||||||
|
// altar, at what level. A boss that popped and died inside one sweep interval is still
|
||||||
|
// reported; it simply arrives without its spawn.
|
||||||
|
private static readonly Dictionary<Serial, Serial> _bossOf = new Dictionary<Serial, Serial>();
|
||||||
|
|
||||||
|
// How many damage entries a kill reports. Deep enough that a real champion fight's meaningful
|
||||||
|
// contributors are all present, shallow enough that the frame stays one line on the wire.
|
||||||
|
private const int MaxDamagers = 20;
|
||||||
|
|
||||||
|
private static long _sweeps, _emitted, _removed, _bossKills;
|
||||||
|
|
||||||
public static void Initialize()
|
public static void Initialize()
|
||||||
{
|
{
|
||||||
@@ -56,12 +66,24 @@ namespace Server.Custom.Bridge
|
|||||||
// Re-emit the full board whenever the sidecar (re)connects, so a sidecar that restarted
|
// Re-emit the full board whenever the sidecar (re)connects, so a sidecar that restarted
|
||||||
// independently of the shard rebuilds its state within one sweep.
|
// independently of the shard rebuilds its state within one sweep.
|
||||||
BridgeLink.Connected_Core += OnConnected;
|
BridgeLink.Connected_Core += OnConnected;
|
||||||
|
|
||||||
|
// Protocol 6. A boss defeat was previously only INFERABLE — champ.update going bossUp
|
||||||
|
// true then false, correlated against a mob.killed nearby — and that inference is both
|
||||||
|
// fragile and silent about who did the work. It is a real moment in a shard's week and
|
||||||
|
// an event's phase condition wants to name it, so it becomes a kind of its own.
|
||||||
|
EventSink.CreatureDeath += OnCreatureDeath;
|
||||||
|
|
||||||
Rearm();
|
Rearm();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void OnConnected()
|
private static void OnConnected()
|
||||||
{
|
{
|
||||||
_last.Clear();
|
_last.Clear();
|
||||||
|
|
||||||
|
// _bossOf is deliberately NOT cleared. It is a fact about the world, not a diff cache:
|
||||||
|
// dropping it on a sidecar reconnect would lose the spawn attribution for a boss that is
|
||||||
|
// up right now, and it refills from the sweep only if that boss's record happens to
|
||||||
|
// change again before it dies.
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
||||||
@@ -82,8 +104,160 @@ namespace Server.Custom.Bridge
|
|||||||
|
|
||||||
public static string Status()
|
public static string Status()
|
||||||
{
|
{
|
||||||
return String.Format("champs(sweeps={0} emitted={1} removed={2} tracked={3})",
|
return String.Format("champs(sweeps={0} emitted={1} removed={2} tracked={3} bossKills={4} bossesUp={5})",
|
||||||
_sweeps, _emitted, _removed, _last.Count);
|
_sweeps, _emitted, _removed, _last.Count, _bossKills, _bossOf.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- champ.boss.killed (Protocol 6) ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fires for every creature death on the shard, so the first thing it does is decide
|
||||||
|
/// this is not one. Detection is by TYPE — <c>BaseChampion</c>, which
|
||||||
|
/// <c>BaseSeaChampion</c> derives from, so one check covers both families — with the
|
||||||
|
/// sweep's map used only to name the altar. A boss that popped and died between two
|
||||||
|
/// sweeps is therefore still reported; it simply arrives without a spawn.
|
||||||
|
///
|
||||||
|
/// The damage table is read here and nowhere else, because it exists here and nowhere
|
||||||
|
/// else: ServUO discards a creature's damage entries with the creature, and the shard is
|
||||||
|
/// the only party that ever sees them. Entries are reported whether or not ServUO
|
||||||
|
/// considers them expired — expiry governs LOOTING RIGHTS, and someone who fought the
|
||||||
|
/// first two thirds of a champion fight and then died took part in it regardless of what
|
||||||
|
/// they are owed from the corpse.
|
||||||
|
/// </summary>
|
||||||
|
private static void OnCreatureDeath(CreatureDeathEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var boss = e.Creature;
|
||||||
|
|
||||||
|
if (boss == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Serial spawnSerial;
|
||||||
|
bool attributed = _bossOf.TryGetValue(boss.Serial, out spawnSerial);
|
||||||
|
|
||||||
|
if (!(boss is BaseChampion) && !attributed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_bossOf.Remove(boss.Serial);
|
||||||
|
_bossKills++;
|
||||||
|
|
||||||
|
var spawn = attributed ? World.FindItem(spawnSerial) as ChampionSpawn : null;
|
||||||
|
var name = String.IsNullOrEmpty(boss.Name) ? boss.GetType().Name : boss.Name;
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("champ.boss.killed")
|
||||||
|
.Str("category", boss is BaseSeaChampion ? "sea" : "champion")
|
||||||
|
.Ser("bossSerial", boss.Serial)
|
||||||
|
.Str("boss", name)
|
||||||
|
.Str("bossType", boss.GetType().Name)
|
||||||
|
.Str("map", boss.Map == null ? null : boss.Map.Name)
|
||||||
|
.Num("x", boss.X).Num("y", boss.Y).Num("z", boss.Z);
|
||||||
|
|
||||||
|
// The altar's own record, when the kill could be attributed to one. `serial` is the
|
||||||
|
// SPAWN here, matching champ.update, so a consumer can join the two without a rule
|
||||||
|
// about which of two serials on the frame means what.
|
||||||
|
if (spawn != null)
|
||||||
|
{
|
||||||
|
sb.Ser("serial", spawn.Serial)
|
||||||
|
.Str("type", spawn.Type.ToString())
|
||||||
|
.Num("level", spawn.Level);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A named region is what a phase condition can actually match on ("the boss in
|
||||||
|
// Yew"); coordinates are not. Emitted alongside the coordinates rather than
|
||||||
|
// instead, because large stretches of the map belong to no named region at all.
|
||||||
|
//
|
||||||
|
// **The innermost region here is ANONYMOUS, and the rig is the only thing that was
|
||||||
|
// ever going to say so.** A champion killed in the middle of Britain produced a
|
||||||
|
// frame with no region at all, because an active `ChampionSpawn` registers a
|
||||||
|
// `ChampionSpawnRegion` over its own spawn area — constructed with a null name and
|
||||||
|
// with the town region as its PARENT (`ChampionSpawn.cs`, its constructor). So the
|
||||||
|
// most specific region containing a champion boss is, by construction, the one
|
||||||
|
// region on the map guaranteed to have no name.
|
||||||
|
//
|
||||||
|
// It also explains why this looked fine for twenty seconds: region registration is
|
||||||
|
// deferred, so a lookup immediately after the altar is placed still answers
|
||||||
|
// "Britain" and one at the kill does not. A first read at spawn time would have
|
||||||
|
// confirmed a bug into the design.
|
||||||
|
//
|
||||||
|
// Walking to the nearest NAMED ancestor is the general answer rather than a special
|
||||||
|
// case for champions: a house region, a dungeon sub-region and a guarded-zone
|
||||||
|
// overlay are all anonymous children of somewhere a player would name.
|
||||||
|
var region = NamedRegionAt(boss.Location, boss.Map);
|
||||||
|
|
||||||
|
if (region != null)
|
||||||
|
sb.Str("region", region);
|
||||||
|
|
||||||
|
if (e.Killer != null)
|
||||||
|
sb.Actor("killer", e.Killer);
|
||||||
|
|
||||||
|
sb.Damagers("damagers", TopDamagers(boss), MaxDamagers);
|
||||||
|
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A death handler must never be the thing that breaks a death.
|
||||||
|
Console.WriteLine("[Bridge] champ.boss.killed threw: {0}", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Player damage against this creature, highest first. Totals are summed per damager
|
||||||
|
/// rather than trusted to be one entry each: ServUO's own registration folds repeat
|
||||||
|
/// damage into an existing entry, but an entry that expired and was re-created leaves
|
||||||
|
/// two, and a table that listed the same player twice would be read as two participants.
|
||||||
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// The nearest NAMED region containing a point, walking outward from the most specific
|
||||||
|
/// one, or null when nothing on the way out has a name.
|
||||||
|
///
|
||||||
|
/// Null rather than "" so the caller can leave the field off the frame entirely: a
|
||||||
|
/// consumer reading `region: ""` cannot tell "outdoors, nowhere in particular" from
|
||||||
|
/// "somewhere, but the shard would not say", and only one of those is true here.
|
||||||
|
///
|
||||||
|
/// The map's own default region terminates the walk with its parentless empty name, so
|
||||||
|
/// a point in open countryside answers null without a special case.
|
||||||
|
/// </summary>
|
||||||
|
private static string NamedRegionAt(Point3D p, Map map)
|
||||||
|
{
|
||||||
|
if (map == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
for (var region = Region.Find(p, map); region != null; region = region.Parent)
|
||||||
|
{
|
||||||
|
if (!String.IsNullOrEmpty(region.Name))
|
||||||
|
return region.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<KeyValuePair<Mobile, int>> TopDamagers(Mobile boss)
|
||||||
|
{
|
||||||
|
var totals = new Dictionary<Mobile, int>();
|
||||||
|
|
||||||
|
var entries = boss.DamageEntries;
|
||||||
|
|
||||||
|
if (entries != null)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < entries.Count; i++)
|
||||||
|
{
|
||||||
|
var de = entries[i];
|
||||||
|
|
||||||
|
if (de == null || de.Damager == null || de.Damager.Deleted || !de.Damager.Player)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
int running;
|
||||||
|
totals.TryGetValue(de.Damager, out running);
|
||||||
|
totals[de.Damager] = running + de.DamageGiven;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ranked = totals.ToList();
|
||||||
|
ranked.Sort((a, b) => b.Value.CompareTo(a.Value));
|
||||||
|
|
||||||
|
return ranked;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||||
@@ -107,6 +281,15 @@ namespace Server.Custom.Bridge
|
|||||||
{
|
{
|
||||||
if (s.Deleted)
|
if (s.Deleted)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
// Protocol 6. Remember which altar a live champion belongs to so its death can
|
||||||
|
// name one. Recorded here rather than looked up at death because the lookup
|
||||||
|
// would be a scan of World.Items on every creature death on the shard.
|
||||||
|
var champion = s.Champion;
|
||||||
|
|
||||||
|
if (champion != null && !champion.Deleted)
|
||||||
|
_bossOf[champion.Serial] = s.Serial;
|
||||||
|
|
||||||
Track(seen, s.Serial, SigChampion(s), WriteChampion(s));
|
Track(seen, s.Serial, SigChampion(s), WriteChampion(s));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +316,19 @@ namespace Server.Custom.Bridge
|
|||||||
BridgeLink.Emit(BridgeJson.Begin("champ.remove").Ser("serial", serial).End());
|
BridgeLink.Emit(BridgeJson.Begin("champ.remove").Ser("serial", serial).End());
|
||||||
_removed++;
|
_removed++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A defeated champion's attribution is consumed by OnCreatureDeath, but one deleted
|
||||||
|
// by a GM or lost to a world reload never dies, so the map is swept too. Cheap: it
|
||||||
|
// holds at most one entry per altar with a boss currently up.
|
||||||
|
if (_bossOf.Count > 0)
|
||||||
|
{
|
||||||
|
var vanished = _bossOf.Keys
|
||||||
|
.Where(k => { var m = World.FindMobile(k); return m == null || m.Deleted; })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var k in vanished)
|
||||||
|
_bossOf.Remove(k);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
772
overlay/Scripts/Custom/Bridge/BridgeCliloc.cs
Normal file
772
overlay/Scripts/Custom/Bridge/BridgeCliloc.cs
Normal file
@@ -0,0 +1,772 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
using Ultima;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// **The cliloc table, over the bridge** (docs/link/v8.md §9 — protocol 8, phase 2).
|
||||||
|
///
|
||||||
|
/// A "cliloc" is UO's localization table: an integer id mapped to a display string. Items
|
||||||
|
/// on the wire carry a `LabelNumber`, never a name, so without this table the website can
|
||||||
|
/// only render `id 1023721` where the game renders "quarter staff". The number was never
|
||||||
|
/// the missing piece; the table was.
|
||||||
|
///
|
||||||
|
/// Until this phase the operator supplied it by hand: install UOFiddler, build a converter
|
||||||
|
/// against its `Ultima.dll`, run it over their own `Cliloc.enu`, copy a 5 MB file to the
|
||||||
|
/// web host and point a setting at it. That whole pipeline existed for one reason — the
|
||||||
|
/// file is compressed and **nothing in this stack could read it**. ServUO's own bundled
|
||||||
|
/// `Ultima.StringList` implements the plain layout only and throws on a modern client's
|
||||||
|
/// file, which is also why the shard's `VendorSearch.GetItemName` has always been inert.
|
||||||
|
///
|
||||||
|
/// So this class is the one decoder protocol 8 **writes** rather than calls (§4): a port
|
||||||
|
/// of UOFiddler's Mythic decompressor into the overlay, after which the shard can read its
|
||||||
|
/// own client's table and hand it to the website over the same request/reply path as
|
||||||
|
/// everything else. The operator installs nothing.
|
||||||
|
///
|
||||||
|
/// **Attribution.** The decompression below is a port of `Ultima/Helpers/MythicDecompress`
|
||||||
|
/// and `MoveToFront` from UOFiddler (https://github.com/polserver/UOFiddler), which is
|
||||||
|
/// released under the **Beerware** licence — compatible with this tree's GPL-3.0-or-later.
|
||||||
|
/// It is rewritten for .NET Framework 4.8: the original is written against `Span<T>`,
|
||||||
|
/// `ArrayPool<T>` and `BinaryPrimitives`, none of which ServUO's `net48` target has.
|
||||||
|
///
|
||||||
|
/// **What is NOT here, deliberately.** Shard-added items carry cliloc ids no client table
|
||||||
|
/// contains, and ServUO has no server-side notion of a custom cliloc — there is nothing in
|
||||||
|
/// the tree to read. That gap is in the *game*, not in this pipeline, so the website keeps
|
||||||
|
/// its `custom/` overlay directory and merges it over whatever arrives here. This class
|
||||||
|
/// answers exactly one question: what does the client's own table say.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeCliloc
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Languages this can serve.
|
||||||
|
///
|
||||||
|
/// Not an arbitrary code: <c>Ultima.Files</c> resolves only the names in its own file
|
||||||
|
/// table, and cliloc files are represented there by these four. Asking for anything
|
||||||
|
/// else cannot resolve to a path however the client is laid out, so it is refused by
|
||||||
|
/// name rather than answered with an empty table.
|
||||||
|
///
|
||||||
|
/// `custom1` / `custom2` are the *client-side* custom cliloc files a shard ships to
|
||||||
|
/// its players. Nothing on the website imports them today — its `custom/` overlay
|
||||||
|
/// directory is the supported answer — but they are the shard's files and they are
|
||||||
|
/// readable, so they are not artificially excluded.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] Languages = { "enu", "deu", "custom1", "custom2" };
|
||||||
|
|
||||||
|
private const string DefaultLanguage = "enu";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How long a decoded table is kept in memory after its last page.
|
||||||
|
///
|
||||||
|
/// A stock `Cliloc.enu` decodes to ~67,000 live strings; holding that forever on a
|
||||||
|
/// shard that imports once a month is rude, and decoding it again costs about a
|
||||||
|
/// second. So it is cached only for as long as an import is plausibly still running:
|
||||||
|
/// freed when the last page is served, and expired on the next request if one never
|
||||||
|
/// comes (an import abandoned halfway leaves nothing behind).
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeSpan CacheIdle = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
private static readonly object _sync = new object();
|
||||||
|
private static Table _cached;
|
||||||
|
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
if (!BridgeConfig.Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
BridgeBoot.RegisterHandler("cliloc.table", OnTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the request plane ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Core thread. Validates, then hands the decode to the asset worker — reading and
|
||||||
|
/// decompressing five megabytes is emphatically not something to do while the world
|
||||||
|
/// is waiting, and <see cref="BridgeAssets"/>'s single slot is what keeps the shard's
|
||||||
|
/// outbound queue at a depth of about one while it happens.
|
||||||
|
/// </summary>
|
||||||
|
private static void OnTable(Dictionary<string, object> o)
|
||||||
|
{
|
||||||
|
var reqId = BridgeJson.GetString(o, "reqId");
|
||||||
|
|
||||||
|
if (reqId == null)
|
||||||
|
{
|
||||||
|
// Without a correlation id this reply would land on the event path, be persisted
|
||||||
|
// to the sidecar's store and broadcast to every subscriber — a megabyte of
|
||||||
|
// strings to every connected client, forever. Refuse instead (§3.1).
|
||||||
|
BridgeAssets.Fail(null, "BAD_REQUEST", "cliloc.table requires a reqId");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!BridgeConfig.AssetsEnabled)
|
||||||
|
{
|
||||||
|
BridgeAssets.Fail(reqId, "DISABLED", "asset extraction is disabled on this shard");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lang = BridgeJson.GetString(o, "lang");
|
||||||
|
|
||||||
|
if (String.IsNullOrEmpty(lang))
|
||||||
|
lang = DefaultLanguage;
|
||||||
|
|
||||||
|
lang = lang.ToLowerInvariant();
|
||||||
|
|
||||||
|
if (Array.IndexOf(Languages, lang) < 0)
|
||||||
|
{
|
||||||
|
BridgeAssets.Fail(reqId, "NOT_FOUND",
|
||||||
|
"no cliloc file for language '" + lang + "' (this shard can serve: "
|
||||||
|
+ String.Join(", ", Languages) + ")");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cursor is this family's own resume point and it is a cliloc NUMBER, not an
|
||||||
|
// offset into anything. That matters: the cache behind it can be dropped and rebuilt
|
||||||
|
// between two pages of the same import (idle expiry, a second import, a restart), and
|
||||||
|
// an index into a list would silently mean something different afterwards. "Resume
|
||||||
|
// after id N" survives all of it, because the table is served in id order.
|
||||||
|
int after = -1;
|
||||||
|
var cursor = BridgeJson.GetString(o, "cursor");
|
||||||
|
|
||||||
|
if (!String.IsNullOrEmpty(cursor))
|
||||||
|
{
|
||||||
|
if (!TryParseCursor(cursor, out after))
|
||||||
|
{
|
||||||
|
BridgeAssets.Fail(reqId, "BAD_REQUEST", "malformed cursor: " + cursor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string language = lang;
|
||||||
|
int resumeAfter = after;
|
||||||
|
|
||||||
|
BridgeAssets.Accept(reqId, "cliloc.table", () => ReplyTable(reqId, language, resumeAfter));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseCursor(string cursor, out int after)
|
||||||
|
{
|
||||||
|
after = -1;
|
||||||
|
|
||||||
|
if (!cursor.StartsWith("n:", StringComparison.Ordinal))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return Int32.TryParse(
|
||||||
|
cursor.Substring(2), NumberStyles.Integer, CultureInfo.InvariantCulture, out after);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asset worker. Decodes (or reuses) the table and writes one page of it.
|
||||||
|
/// </summary>
|
||||||
|
private static void ReplyTable(string reqId, string lang, int after)
|
||||||
|
{
|
||||||
|
string path = ResolvePath(lang);
|
||||||
|
|
||||||
|
if (path == null)
|
||||||
|
{
|
||||||
|
BridgeAssets.Fail(reqId, "NOT_FOUND",
|
||||||
|
"this shard's client has no cliloc." + lang + " (looked where ServUO's own "
|
||||||
|
+ "data path points)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Table table;
|
||||||
|
string code, reason;
|
||||||
|
|
||||||
|
if (!TryLoad(lang, path, out table, out code, out reason))
|
||||||
|
{
|
||||||
|
BridgeAssets.Fail(reqId, code, reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("cliloc.table.ok");
|
||||||
|
|
||||||
|
sb.Str("reqId", reqId)
|
||||||
|
.Str("lang", lang)
|
||||||
|
.Num("extractorVersion", BridgeAssets.EXTRACTOR_VERSION)
|
||||||
|
.Str("file", Path.GetFileName(path))
|
||||||
|
// The website pages this table over several round trips and must be able to tell
|
||||||
|
// that the file changed underneath it — an operator patching their client mid-import
|
||||||
|
// would otherwise produce one table stitched from two, with no error anywhere. It
|
||||||
|
// compares these two fields across pages and starts over if they move.
|
||||||
|
.Num("size", table.Size)
|
||||||
|
.Num("mtime", table.MTime)
|
||||||
|
.Num("total", table.Count)
|
||||||
|
.Bool("compressed", table.Compressed);
|
||||||
|
|
||||||
|
// Before the page opens, not after it closes: PageBuilder reserves room for the
|
||||||
|
// envelope it still has to write, and a field appended past Close() is spent outside
|
||||||
|
// that reserve. It fits today by a wide margin, and it is the kind of thing the next
|
||||||
|
// family copies.
|
||||||
|
int start = table.IndexAfter(after);
|
||||||
|
sb.Num("from", start);
|
||||||
|
|
||||||
|
var page = new BridgeAssets.PageBuilder(sb, "rows", BridgeConfig.AssetBatchBytes);
|
||||||
|
|
||||||
|
int i = start;
|
||||||
|
|
||||||
|
for (; i < table.Count; i++)
|
||||||
|
{
|
||||||
|
var item = new StringBuilder(96);
|
||||||
|
|
||||||
|
item.Append("{\"n\":").Append(table.Numbers[i].ToString(CultureInfo.InvariantCulture));
|
||||||
|
item.Append(",\"f\":").Append(table.Flags[i].ToString(CultureInfo.InvariantCulture));
|
||||||
|
item.Append(",\"t\":");
|
||||||
|
BridgeJson.Text(item, table.Texts[i]);
|
||||||
|
item.Append('}');
|
||||||
|
|
||||||
|
if (!page.TryAdd(item.ToString(), "n:" + table.Numbers[i].ToString(CultureInfo.InvariantCulture)))
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
page.Close();
|
||||||
|
|
||||||
|
bool finished = i >= table.Count;
|
||||||
|
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
|
||||||
|
// The last page is also the end of the import, so let the strings go. A retry of that
|
||||||
|
// page re-decodes, which costs a second and happens approximately never; holding ~67k
|
||||||
|
// strings against that is the wrong trade.
|
||||||
|
if (finished)
|
||||||
|
Release(lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolvePath(string lang)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// ServUO's own `Scripts/Misc/DataPath.cs` calls `Files.SetMulPath` for every
|
||||||
|
// configured data directory at Configure time, so this resolves against the
|
||||||
|
// client the SHARD is running on — including on Linux, where `Ultima.Files`'s
|
||||||
|
// registry lookup finds nothing on its own.
|
||||||
|
return Files.GetFilePath("cliloc." + lang);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the decoded table ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private sealed class Table
|
||||||
|
{
|
||||||
|
public string Lang;
|
||||||
|
public long Size;
|
||||||
|
public long MTime;
|
||||||
|
public bool Compressed;
|
||||||
|
public int[] Numbers;
|
||||||
|
public byte[] Flags;
|
||||||
|
public string[] Texts;
|
||||||
|
public DateTime LastUsed;
|
||||||
|
|
||||||
|
public int Count { get { return Numbers.Length; } }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Index of the first row with a number greater than <paramref name="after"/>.
|
||||||
|
/// Binary search, because the rows are in id order by construction and a page
|
||||||
|
/// deep into the table would otherwise walk everything before it.
|
||||||
|
/// </summary>
|
||||||
|
public int IndexAfter(int after)
|
||||||
|
{
|
||||||
|
if (after < 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
int lo = 0, hi = Numbers.Length;
|
||||||
|
|
||||||
|
while (lo < hi)
|
||||||
|
{
|
||||||
|
int mid = lo + ((hi - lo) >> 1);
|
||||||
|
|
||||||
|
if (Numbers[mid] <= after)
|
||||||
|
lo = mid + 1;
|
||||||
|
else
|
||||||
|
hi = mid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return lo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryLoad(string lang, string path, out Table table, out string code, out string reason)
|
||||||
|
{
|
||||||
|
code = null;
|
||||||
|
reason = null;
|
||||||
|
|
||||||
|
long size, mtime;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var info = new FileInfo(path);
|
||||||
|
size = info.Length;
|
||||||
|
mtime = (long)(info.LastWriteTimeUtc - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc))
|
||||||
|
.TotalMilliseconds;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
table = null;
|
||||||
|
code = "UNREADABLE";
|
||||||
|
reason = "cannot stat " + Path.GetFileName(path) + ": " + e.Message;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (_cached != null)
|
||||||
|
{
|
||||||
|
bool stale = _cached.Lang != lang
|
||||||
|
|| _cached.Size != size
|
||||||
|
|| _cached.MTime != mtime
|
||||||
|
|| DateTime.UtcNow - _cached.LastUsed > CacheIdle;
|
||||||
|
|
||||||
|
if (stale)
|
||||||
|
_cached = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_cached != null)
|
||||||
|
{
|
||||||
|
_cached.LastUsed = DateTime.UtcNow;
|
||||||
|
table = _cached;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] raw;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
raw = File.ReadAllBytes(path);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
table = null;
|
||||||
|
code = "UNREADABLE";
|
||||||
|
reason = "cannot read " + Path.GetFileName(path) + ": " + e.Message;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool compressed = IsCompressed(raw);
|
||||||
|
byte[] plain;
|
||||||
|
|
||||||
|
if (compressed)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
plain = Mythic.Decompress(raw);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
table = null;
|
||||||
|
code = "UNREADABLE";
|
||||||
|
reason = "cannot decompress " + Path.GetFileName(path) + ": " + e.Message;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
plain = raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
var built = new Table
|
||||||
|
{
|
||||||
|
Lang = lang,
|
||||||
|
Size = size,
|
||||||
|
MTime = mtime,
|
||||||
|
Compressed = compressed,
|
||||||
|
LastUsed = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!TryParseRecords(plain, built, out reason))
|
||||||
|
{
|
||||||
|
table = null;
|
||||||
|
code = "UNREADABLE";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
_cached = built;
|
||||||
|
}
|
||||||
|
|
||||||
|
table = built;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Release(string lang)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (_cached != null && _cached.Lang == lang)
|
||||||
|
_cached = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every compressed cliloc begins with a DWORD whose high byte is <c>0x8E</c> — the
|
||||||
|
/// top byte of UOFiddler's `HeaderXorKey`, showing through because the value it hides
|
||||||
|
/// (a length) is far smaller than the key. That single byte is what tells a modern
|
||||||
|
/// client's file from the pre-2010 plain layout, and both are accepted here: a shard
|
||||||
|
/// running an old or hand-built client is not a broken shard.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsCompressed(byte[] buffer)
|
||||||
|
{
|
||||||
|
return buffer.Length >= 4 && buffer[3] == 0x8E;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the plain layout ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private const int HeaderBytes = 6; // int32 version + int16 language marker
|
||||||
|
private const int RecordHeaderBytes = 7; // int32 number + byte flag + uint16 length
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the plain layout into the sorted, blank-free arrays the wire wants.
|
||||||
|
///
|
||||||
|
/// **Strict about truncation**, and that strictness is the point: a half-decoded table
|
||||||
|
/// is indistinguishable from a complete one downstream — you would simply see some
|
||||||
|
/// items named and some not, which is exactly what "no table at all" looks like. So a
|
||||||
|
/// record running past the end of the buffer is an error naming its offset, never a
|
||||||
|
/// short table.
|
||||||
|
///
|
||||||
|
/// **Blanks are dropped here rather than on the website.** Roughly 56,000 of a stock
|
||||||
|
/// table's 123,490 entries are empty strings the client reserves and never uses, the
|
||||||
|
/// website discards them at import already, and a row that resolves to no name is
|
||||||
|
/// indistinguishable from no row at all to every caller. Dropping them halves what
|
||||||
|
/// crosses the wire for data that would be thrown away on arrival.
|
||||||
|
///
|
||||||
|
/// **A repeated id is resolved last-wins**, matching the client's own loader (its
|
||||||
|
/// dictionary assignment overwrites). The plain format permits it, so a file the game
|
||||||
|
/// itself would load happily must not fail here.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryParseRecords(byte[] data, Table into, out string reason)
|
||||||
|
{
|
||||||
|
reason = null;
|
||||||
|
|
||||||
|
if (data.Length < HeaderBytes)
|
||||||
|
{
|
||||||
|
reason = "cliloc file is shorter than its 6-byte header";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var byNumber = new Dictionary<int, Entry>(140000);
|
||||||
|
int offset = HeaderBytes;
|
||||||
|
int read = 0;
|
||||||
|
|
||||||
|
while (offset < data.Length)
|
||||||
|
{
|
||||||
|
if (offset + RecordHeaderBytes > data.Length)
|
||||||
|
{
|
||||||
|
reason = "truncated record header at byte " + offset + " (" + read + " entries read)";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int number = ReadInt32(data, offset);
|
||||||
|
byte flag = data[offset + 4];
|
||||||
|
// Unsigned: reading this signed (as ServUO's own SDK does) turns any string over
|
||||||
|
// 32 KB into a negative length. Real tables top out around 12 KB, so it changes
|
||||||
|
// nothing today and costs nothing to get right.
|
||||||
|
int length = data[offset + 5] | (data[offset + 6] << 8);
|
||||||
|
|
||||||
|
offset += RecordHeaderBytes;
|
||||||
|
|
||||||
|
if (offset + length > data.Length)
|
||||||
|
{
|
||||||
|
reason = "truncated record body at byte " + offset + " (" + read + " entries read)";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string text;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
text = Encoding.UTF8.GetString(data, offset, length);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
reason = "entry " + number + " at byte " + offset + " is not valid UTF-8: " + e.Message;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += length;
|
||||||
|
read++;
|
||||||
|
|
||||||
|
byNumber[number] = new Entry { Flag = flag, Text = text };
|
||||||
|
}
|
||||||
|
|
||||||
|
var numbers = new List<int>(byNumber.Count);
|
||||||
|
|
||||||
|
foreach (var pair in byNumber)
|
||||||
|
{
|
||||||
|
if (IsBlank(pair.Value.Text))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
numbers.Add(pair.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
numbers.Sort();
|
||||||
|
|
||||||
|
into.Numbers = numbers.ToArray();
|
||||||
|
into.Flags = new byte[numbers.Count];
|
||||||
|
into.Texts = new string[numbers.Count];
|
||||||
|
|
||||||
|
for (int i = 0; i < numbers.Count; i++)
|
||||||
|
{
|
||||||
|
var entry = byNumber[numbers[i]];
|
||||||
|
|
||||||
|
into.Flags[i] = entry.Flag;
|
||||||
|
into.Texts[i] = entry.Text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Entry
|
||||||
|
{
|
||||||
|
public byte Flag;
|
||||||
|
public string Text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsBlank(string text)
|
||||||
|
{
|
||||||
|
if (String.IsNullOrEmpty(text))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
for (int i = 0; i < text.Length; i++)
|
||||||
|
{
|
||||||
|
if (!Char.IsWhiteSpace(text[i]))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReadInt32(byte[] data, int at)
|
||||||
|
{
|
||||||
|
return data[at] | (data[at + 1] << 8) | (data[at + 2] << 16) | (data[at + 3] << 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the Mythic container ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The decompressor, ported from UOFiddler (Beerware; see this class's summary).
|
||||||
|
///
|
||||||
|
/// The container is two stages over the plain cliloc bytes, undone in reverse:
|
||||||
|
///
|
||||||
|
/// 1. A 4-byte header holding the decompressed length, XORed with `0x8E2C9A3D` —
|
||||||
|
/// which is where the `0x8E` sniff byte comes from.
|
||||||
|
/// 2. A **move-to-front** coding of…
|
||||||
|
/// 3. …a Burrows-Wheeler-style transform whose 1 KB frequency header (256 little-endian
|
||||||
|
/// counts, one per byte value) is both the table sizes and the total output length.
|
||||||
|
///
|
||||||
|
/// Rewritten against plain arrays: the upstream is `Span<T>`/`ArrayPool<T>`
|
||||||
|
/// code and ServUO targets `net48`, which has neither without a package this tree does
|
||||||
|
/// not vendor. The algorithm is unchanged, including the parts that read oddly — the
|
||||||
|
/// three-region `partial` table (counts, cursors, ends) and the symbol-table shifts are
|
||||||
|
/// the original's, deliberately, because this is a format decoder and a tidier
|
||||||
|
/// rewrite is a chance to be subtly wrong about someone else's bytes.
|
||||||
|
/// </summary>
|
||||||
|
private static class Mythic
|
||||||
|
{
|
||||||
|
private const uint HeaderXorKey = 0x8E2C9A3D;
|
||||||
|
private const int FrequencyHeaderSize = 1024; // 256 little-endian ints
|
||||||
|
|
||||||
|
public static byte[] Decompress(byte[] source)
|
||||||
|
{
|
||||||
|
if (source.Length < 4)
|
||||||
|
throw new InvalidDataException("compressed cliloc is shorter than its header");
|
||||||
|
|
||||||
|
uint declared = (uint)ReadInt32(source, 0) ^ HeaderXorKey;
|
||||||
|
|
||||||
|
if (declared == 0 || declared > Int32.MaxValue)
|
||||||
|
throw new InvalidDataException("compressed cliloc declares an impossible length");
|
||||||
|
|
||||||
|
var mtf = new byte[source.Length - 4];
|
||||||
|
MoveToFrontDecode(source, 4, mtf);
|
||||||
|
|
||||||
|
var output = new byte[(int)declared];
|
||||||
|
int written = InverseTransform(mtf, output);
|
||||||
|
|
||||||
|
if (written != (int)declared)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException(
|
||||||
|
"decompressed length " + written + " does not match the declared " + declared);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MoveToFrontDecode(byte[] input, int from, byte[] output)
|
||||||
|
{
|
||||||
|
var symbols = new byte[256];
|
||||||
|
|
||||||
|
for (int i = 0; i < 256; i++)
|
||||||
|
symbols[i] = (byte)i;
|
||||||
|
|
||||||
|
for (int i = 0; i < output.Length; i++)
|
||||||
|
{
|
||||||
|
int index = input[from + i];
|
||||||
|
byte symbol = symbols[index];
|
||||||
|
|
||||||
|
output[i] = symbol;
|
||||||
|
|
||||||
|
for (int j = index; j > 0; j--)
|
||||||
|
symbols[j] = symbols[j - 1];
|
||||||
|
|
||||||
|
symbols[0] = symbol;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int InverseTransform(byte[] input, byte[] destination)
|
||||||
|
{
|
||||||
|
if (input.Length < FrequencyHeaderSize)
|
||||||
|
throw new InvalidDataException("compressed cliloc is smaller than its frequency header");
|
||||||
|
|
||||||
|
// Three regions of 256: [0..255] the counts read from the header, [256..511] a
|
||||||
|
// moving cursor per symbol, [512..767] where that symbol's run ends.
|
||||||
|
var partial = new int[256 * 3];
|
||||||
|
|
||||||
|
for (int i = 0; i < 256; i++)
|
||||||
|
partial[i] = ReadInt32(input, i * 4);
|
||||||
|
|
||||||
|
int sum = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < 256; i++)
|
||||||
|
{
|
||||||
|
if (partial[i] < 0)
|
||||||
|
throw new InvalidDataException("compressed cliloc has a negative symbol count");
|
||||||
|
|
||||||
|
sum += partial[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sum == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
if (destination.Length < sum)
|
||||||
|
throw new InvalidDataException("compressed cliloc's frequency header outruns its declared length");
|
||||||
|
|
||||||
|
int nonZero = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < 256; i++)
|
||||||
|
{
|
||||||
|
if (partial[i] != 0)
|
||||||
|
nonZero++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var frequency = new byte[256];
|
||||||
|
Frequency(partial, frequency);
|
||||||
|
|
||||||
|
var symbols = new byte[256];
|
||||||
|
|
||||||
|
for (int i = 0; i < 256; i++)
|
||||||
|
symbols[i] = (byte)i;
|
||||||
|
|
||||||
|
for (int i = 0, m = 0; i < nonZero; ++i)
|
||||||
|
{
|
||||||
|
byte freq = frequency[i];
|
||||||
|
|
||||||
|
Need(input, m + FrequencyHeaderSize);
|
||||||
|
|
||||||
|
symbols[input[m + FrequencyHeaderSize]] = freq;
|
||||||
|
partial[freq + 256] = m + 1;
|
||||||
|
m += partial[freq];
|
||||||
|
partial[freq + 512] = m;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte val = symbols[0];
|
||||||
|
int count = 0;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
destination[count] = val;
|
||||||
|
|
||||||
|
if (partial[val + 256] < partial[val + 512])
|
||||||
|
{
|
||||||
|
Need(input, partial[val + 256] + FrequencyHeaderSize);
|
||||||
|
|
||||||
|
byte idx = input[partial[val + 256] + FrequencyHeaderSize];
|
||||||
|
partial[val + 256]++;
|
||||||
|
|
||||||
|
if (idx != 0)
|
||||||
|
{
|
||||||
|
ShiftLeft(symbols, idx);
|
||||||
|
|
||||||
|
symbols[idx] = val;
|
||||||
|
val = symbols[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (nonZero-- > 0)
|
||||||
|
{
|
||||||
|
ShiftLeft(symbols, nonZero);
|
||||||
|
|
||||||
|
val = symbols[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
while (count < sum);
|
||||||
|
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The upstream indexes the payload without bounds-checking it, which is safe for
|
||||||
|
/// a file the client wrote and is not safe for a file this shard was handed. A
|
||||||
|
/// truncated or hand-edited container would otherwise read whatever follows the
|
||||||
|
/// buffer in memory — or, on .NET, throw an `IndexOutOfRangeException` from inside
|
||||||
|
/// a decoder, which says nothing useful to an operator. This turns both into one
|
||||||
|
/// named, reportable failure.
|
||||||
|
/// </summary>
|
||||||
|
private static void Need(byte[] input, int at)
|
||||||
|
{
|
||||||
|
if (at < 0 || at >= input.Length)
|
||||||
|
throw new InvalidDataException("compressed cliloc ends mid-stream (wanted byte " + at + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Symbol values ordered by descending count — the order the coder assigned its
|
||||||
|
/// runs in. Repeated max-finding rather than a sort, as upstream: 256 passes over
|
||||||
|
/// 256 entries is nothing, and it reproduces the original's tie-breaking (the
|
||||||
|
/// lowest index wins), which a comparison sort would not.
|
||||||
|
/// </summary>
|
||||||
|
private static void Frequency(int[] counts, byte[] output)
|
||||||
|
{
|
||||||
|
var tmp = new int[256];
|
||||||
|
Array.Copy(counts, tmp, 256);
|
||||||
|
|
||||||
|
for (int i = 0; i < 256; i++)
|
||||||
|
{
|
||||||
|
int value = 0;
|
||||||
|
byte index = 0;
|
||||||
|
|
||||||
|
for (int j = 0; j < 256; j++)
|
||||||
|
{
|
||||||
|
if (tmp[j] > value)
|
||||||
|
{
|
||||||
|
index = (byte)j;
|
||||||
|
value = tmp[j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value == 0)
|
||||||
|
break;
|
||||||
|
|
||||||
|
output[i] = index;
|
||||||
|
tmp[index] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ShiftLeft(byte[] symbols, int upTo)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < upTo; ++i)
|
||||||
|
symbols[i] = symbols[i + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReadInt32(byte[] data, int at)
|
||||||
|
{
|
||||||
|
return data[at] | (data[at + 1] << 8) | (data[at + 2] << 16) | (data[at + 3] << 24);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,123 @@ namespace Server.Custom.Bridge
|
|||||||
public static int AdminReasonMaxLength { get; private set; }
|
public static int AdminReasonMaxLength { get; private set; }
|
||||||
public static int AdminBanMaxDurationSec { get; private set; }
|
public static int AdminBanMaxDurationSec { get; private set; }
|
||||||
|
|
||||||
|
// ---- the event plane (docs/link/v6.md §8, EVENTS_PLAN.md Phase 11b) ----
|
||||||
|
//
|
||||||
|
// **Its own gate, deliberately not AdminWriteEnabled** (org lead, 2026-09-04). Enabling the
|
||||||
|
// admin plane is an operator consenting to staff moderation driven from the website - a
|
||||||
|
// human pressing kick or ban on a screen. A lease and a participation ledger are the
|
||||||
|
// website changing and watching the world on a SCHEDULE, unattended, at four in the
|
||||||
|
// morning. Those are different consents, and one switch cannot express both.
|
||||||
|
public static bool EventsEnabled { get; private set; }
|
||||||
|
|
||||||
|
// ---- the asset plane (docs/link/v8.md §3, protocol 8) ----
|
||||||
|
//
|
||||||
|
// Its own gate again, and for the same reason the event plane got one: enabling this is
|
||||||
|
// an operator consenting to the WEBSITE READING THEIR CLIENT FILES -- art, animations and
|
||||||
|
// the string table, off the host's disk, over the link. That is a different consent from
|
||||||
|
// publishing world state, and one switch cannot express both. Reads only: nothing on this
|
||||||
|
// plane writes anything, anywhere.
|
||||||
|
public static bool AssetsEnabled { get; private set; }
|
||||||
|
public static int AssetBatchBytes { get; private set; }
|
||||||
|
|
||||||
|
// How many types one `assets.bodies` request may name (§8, phase 3). This is the ONLY
|
||||||
|
// asset-plane bound counted in items rather than bytes, and deliberately so: the cost it
|
||||||
|
// bounds is not the size of the reply, it is constructing and deleting that many real
|
||||||
|
// mobiles ON THE CORE THREAD, between two ticks of the world.
|
||||||
|
public static int AssetBodyBatch { get; private set; }
|
||||||
|
|
||||||
|
// How many keys one `assets.fetch` request may name. Bytes still cut the page; this only
|
||||||
|
// bounds how large a request the shard will parse and walk at all.
|
||||||
|
public static int AssetFetchKeys { get; private set; }
|
||||||
|
|
||||||
|
// The wall-clock budget for one catalogue page (§4.8, phase 3). The catalogue's rows are
|
||||||
|
// ninety bytes, so the byte budget never stops it -- but building them means decoding
|
||||||
|
// hundreds of animations, and the sidecar gives a reply ten seconds. Kept well under that,
|
||||||
|
// because the reply still has to be built, serialised and cross the wire afterwards.
|
||||||
|
public static int AssetScanMs { get; private set; }
|
||||||
|
|
||||||
|
// Which direction the catalogue renders (§5.1). Both are settings and neither is in the
|
||||||
|
// asset key, because five directions would five-fold every count in §11 to express a
|
||||||
|
// choice nobody is going to vary.
|
||||||
|
//
|
||||||
|
// The split is not arbitrary and was found by RENDERING all five rather than from a table:
|
||||||
|
// index 0 is head-on, which is what a character portrait wants and the least legible view
|
||||||
|
// there is of a four-legged creature. A wolf seen from the front is a dark blob; at index
|
||||||
|
// 1, the front three-quarter, it is unmistakably a wolf.
|
||||||
|
public static int AssetPlayerDirection { get; private set; }
|
||||||
|
public static int AssetCreatureDirection { get; private set; }
|
||||||
|
|
||||||
|
// ---- the tree plane (docs/link/v8.md §10, phase 7) ----
|
||||||
|
//
|
||||||
|
// Its OWN gate, and the third one on this link for the third kind of consent. The asset
|
||||||
|
// gate above is the operator agreeing that the website may read THEIR UO CLIENT -- art
|
||||||
|
// and animations and a string table that came from EA. This one is the operator agreeing
|
||||||
|
// that it may read THE SHARD'S OWN CONFIGURATION: the spawn files, the region and
|
||||||
|
// location definitions, the champion table, the decoration lists. Those are the
|
||||||
|
// operator's own work rather than a licensed client, and they are what the spawn atlas is
|
||||||
|
// built out of -- so a shard that declines to serve client art must still be able to
|
||||||
|
// publish where its creatures live. One switch could not have expressed both, and the
|
||||||
|
// atlas would have been the thing that silently disappeared.
|
||||||
|
//
|
||||||
|
// Reads only, and only the five labelled groups SPAWN_ATLAS.md already names. Nothing
|
||||||
|
// here joins a path the website sent: a request names a label this shard enumerated, or
|
||||||
|
// it is refused.
|
||||||
|
public static bool TreeEnabled { get; private set; }
|
||||||
|
|
||||||
|
// How much of a tree file one chunk carries, BEFORE compression (§10). The chunk is the
|
||||||
|
// thing that makes this transferable at all: a stock Spawns/trammel.xml is 4.03 MB and
|
||||||
|
// the sidecar discards any inbound line over 1 MiB, so the file as a single base64 row
|
||||||
|
// could never arrive -- it would time out and be re-requested forever, which is a failure
|
||||||
|
// with no error in it anywhere.
|
||||||
|
//
|
||||||
|
// Compression is what makes it cheap (a spawn file gzips ~18x, so a chunk is typically
|
||||||
|
// 40 KB on the wire) and the chunk is what makes it BOUNDED: gzip cannot be relied on to
|
||||||
|
// shrink anything, so the ceiling has to hold for input that does not compress at all.
|
||||||
|
// At 512 KiB a worst-case incompressible chunk is ~683 KiB of base64, which still fits
|
||||||
|
// the wire under AssetBatchBytes' deliberate factor of two.
|
||||||
|
public static int TreeChunkBytes { get; private set; }
|
||||||
|
|
||||||
|
// How many bytes of rendered item and land art the shard holds between requests (§11,
|
||||||
|
// phase 5). This is a convenience, not a store: the website keeps every picture it fetches
|
||||||
|
// and does not ask twice, so what this actually buys is the second page of a batch, a
|
||||||
|
// retry after a 425, and the same item appearing in two rows of one page. Sized so a
|
||||||
|
// full 512 KB batch and the one before it both fit with room over.
|
||||||
|
public static int AssetArtCacheBytes { get; private set; }
|
||||||
|
|
||||||
|
public static int LeaseMaxDurationSec { get; private set; }
|
||||||
|
public static int LeaseGraceSec { get; private set; }
|
||||||
|
|
||||||
|
public static int ParticipationSweepSeconds { get; private set; }
|
||||||
|
public static double ParticipationKillWeight { get; private set; }
|
||||||
|
public static int ParticipationMaxRuns { get; private set; }
|
||||||
|
public static int ParticipationMaxMembers { get; private set; }
|
||||||
|
public static int ParticipationMaxRadius { get; private set; }
|
||||||
|
public static int ParticipationGraceSec { get; private set; }
|
||||||
|
public static int ParticipationSnapshotChunk { get; private set; }
|
||||||
|
|
||||||
|
// The world verbs (protocol 7, EVENTS_PLAN.md Phase 12a). Each of these is the shard's
|
||||||
|
// OWN ceiling rather than a mirror of the module's budget dimension, and each REFUSES
|
||||||
|
// rather than clamps -- BridgeLeases' argument for LeaseMaxDurationSec, unchanged: the
|
||||||
|
// bound exists for the case where the website is wrong, and a quiet clamp would leave the
|
||||||
|
// two halves disagreeing about what was actually placed.
|
||||||
|
public static int EventsMaxCreatures { get; private set; }
|
||||||
|
public static int EventsMaxBosses { get; private set; }
|
||||||
|
public static int EventsMaxNpcs { get; private set; }
|
||||||
|
public static int EventsMaxDecor { get; private set; }
|
||||||
|
public static int EventsMaxGateMinutes { get; private set; }
|
||||||
|
public static int EventsMaxOwnedPerRun { get; private set; }
|
||||||
|
public static int EventsMaxSpread { get; private set; }
|
||||||
|
public static double EventsMaxBossMultiplier { get; private set; }
|
||||||
|
public static int EventsOracleMaxLines { get; private set; }
|
||||||
|
public static int EventsOracleGreetRange { get; private set; }
|
||||||
|
public static int EventsOracleSpeechRange { get; private set; }
|
||||||
|
public static int EventsOracleGreetCooldownSec { get; private set; }
|
||||||
|
public static int EventsOracleAnswerCooldownSec { get; private set; }
|
||||||
|
public static int EventsSweepSeconds { get; private set; }
|
||||||
|
public static int EventsMaxGrantPerRun { get; private set; }
|
||||||
|
public static int EventsMaxGrantStack { get; private set; }
|
||||||
|
public static int EventsMinSaveIntervalSec { get; private set; }
|
||||||
|
|
||||||
// ---- account provisioning (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part A) ----
|
// ---- account provisioning (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part A) ----
|
||||||
public static SignupMode Signup { get; private set; }
|
public static SignupMode Signup { get; private set; }
|
||||||
public static bool AccountCreateEnabled { get; private set; }
|
public static bool AccountCreateEnabled { get; private set; }
|
||||||
@@ -101,6 +218,70 @@ namespace Server.Custom.Bridge
|
|||||||
Port = Config.Get("Bridge.Port", 7788);
|
Port = Config.Get("Bridge.Port", 7788);
|
||||||
QueueCap = Config.Get("Bridge.QueueCap", 10000);
|
QueueCap = Config.Get("Bridge.QueueCap", 10000);
|
||||||
|
|
||||||
|
AssetsEnabled = Config.Get("Bridge.AssetsEnabled", true);
|
||||||
|
|
||||||
|
// The largest reply this plane will build, in ENCODED bytes -- not items, because the
|
||||||
|
// ceiling it has to live inside is a byte ceiling. Clamped to half the sidecar's 1 MiB
|
||||||
|
// inbound line cap, and the halving is load-bearing rather than cautious: a page
|
||||||
|
// always admits its first item even when that item alone exceeds the budget (the
|
||||||
|
// alternative is an oversized item being skipped forever and its family never making
|
||||||
|
// progress), so the wire must still have room for one such overshoot.
|
||||||
|
AssetBatchBytes = Config.Get("Bridge.AssetBatchBytes", 512 * 1024);
|
||||||
|
if (AssetBatchBytes < 64 * 1024)
|
||||||
|
AssetBatchBytes = 64 * 1024;
|
||||||
|
if (AssetBatchBytes > 512 * 1024)
|
||||||
|
AssetBatchBytes = 512 * 1024;
|
||||||
|
|
||||||
|
AssetBodyBatch = Config.Get("Bridge.AssetBodyBatch", 100);
|
||||||
|
if (AssetBodyBatch < 1)
|
||||||
|
AssetBodyBatch = 1;
|
||||||
|
if (AssetBodyBatch > 500)
|
||||||
|
AssetBodyBatch = 500;
|
||||||
|
|
||||||
|
AssetFetchKeys = Config.Get("Bridge.AssetFetchKeys", 2000);
|
||||||
|
if (AssetFetchKeys < 1)
|
||||||
|
AssetFetchKeys = 1;
|
||||||
|
if (AssetFetchKeys > 10000)
|
||||||
|
AssetFetchKeys = 10000;
|
||||||
|
|
||||||
|
AssetScanMs = Config.Get("Bridge.AssetScanMs", 3000);
|
||||||
|
if (AssetScanMs < 250)
|
||||||
|
AssetScanMs = 250;
|
||||||
|
// Half the sidecar's 10 s reply timeout, so the page still has time to be serialised
|
||||||
|
// and written after the scan stops. A budget set at the timeout would produce replies
|
||||||
|
// that are always thrown away.
|
||||||
|
if (AssetScanMs > 5000)
|
||||||
|
AssetScanMs = 5000;
|
||||||
|
|
||||||
|
// Clamped to 0-4: 5-7 are the client MIRRORING 1-3, which `Frame` decodes through a
|
||||||
|
// different pointer-arithmetic branch that nothing in BridgeAssetValidator has
|
||||||
|
// checked. Accepting one would hand an unverified write path a bitmap to fill.
|
||||||
|
AssetPlayerDirection = Clamp(Config.Get("Bridge.AssetPlayerDirection", 0), 0, 4);
|
||||||
|
AssetCreatureDirection = Clamp(Config.Get("Bridge.AssetCreatureDirection", 1), 0, 4);
|
||||||
|
|
||||||
|
// The floor is one batch: a cache that cannot hold the page being built evicts rows
|
||||||
|
// while they are still being written, which is a cache that costs and never pays. The
|
||||||
|
// ceiling is a game server's memory, and 64 MB of PNG is already ~34,000 sprites --
|
||||||
|
// most of this client's art, held for a working set that is measured in hundreds.
|
||||||
|
AssetArtCacheBytes = Config.Get("Bridge.AssetArtCacheBytes", 16 * 1024 * 1024);
|
||||||
|
if (AssetArtCacheBytes < AssetBatchBytes)
|
||||||
|
AssetArtCacheBytes = AssetBatchBytes;
|
||||||
|
if (AssetArtCacheBytes > 64 * 1024 * 1024)
|
||||||
|
AssetArtCacheBytes = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
TreeEnabled = Config.Get("Bridge.TreeEnabled", true);
|
||||||
|
|
||||||
|
// Floor and ceiling both matter. Below 64 KiB a stock tree is thousands of chunks and
|
||||||
|
// the per-row overhead starts to dominate the payload; above 512 KiB an incompressible
|
||||||
|
// chunk stops fitting inside the sidecar's inbound line cap, which is the one bound
|
||||||
|
// this number exists to respect. Kept equal to AssetBatchBytes' own ceiling so the two
|
||||||
|
// budgets cannot drift into disagreeing about the same wire.
|
||||||
|
TreeChunkBytes = Config.Get("Bridge.TreeChunkBytes", 512 * 1024);
|
||||||
|
if (TreeChunkBytes < 64 * 1024)
|
||||||
|
TreeChunkBytes = 64 * 1024;
|
||||||
|
if (TreeChunkBytes > 512 * 1024)
|
||||||
|
TreeChunkBytes = 512 * 1024;
|
||||||
|
|
||||||
StatSweepSeconds = Config.Get("Bridge.StatSweepSeconds", 30);
|
StatSweepSeconds = Config.Get("Bridge.StatSweepSeconds", 30);
|
||||||
DecaySweepSeconds = Config.Get("Bridge.DecaySweepSeconds", 60);
|
DecaySweepSeconds = Config.Get("Bridge.DecaySweepSeconds", 60);
|
||||||
EconomySweepSeconds = Config.Get("Bridge.EconomySweepSeconds", 300);
|
EconomySweepSeconds = Config.Get("Bridge.EconomySweepSeconds", 300);
|
||||||
@@ -235,6 +416,147 @@ namespace Server.Custom.Bridge
|
|||||||
AdminReasonMaxLength = Config.Get("Bridge.AdminReasonMaxLength", 400);
|
AdminReasonMaxLength = Config.Get("Bridge.AdminReasonMaxLength", 400);
|
||||||
AdminBanMaxDurationSec = Config.Get("Bridge.AdminBanMaxDurationSec", 31536000);
|
AdminBanMaxDurationSec = Config.Get("Bridge.AdminBanMaxDurationSec", 31536000);
|
||||||
|
|
||||||
|
// The event plane. Off until an operator says otherwise - see the field block above for
|
||||||
|
// why this is not AdminWriteEnabled.
|
||||||
|
EventsEnabled = Config.Get("Bridge.EventsEnabled", false);
|
||||||
|
|
||||||
|
// Thirty days, matching core's own MAX_LEASE_MS. This is the shard's INDEPENDENT
|
||||||
|
// ceiling rather than a mirror of it: the website bounds what it will ask for, and a
|
||||||
|
// shard that trusted the asking would have no bound of its own at the one moment it
|
||||||
|
// matters, which is when the website is wrong.
|
||||||
|
LeaseMaxDurationSec = Config.Get("Bridge.LeaseMaxDurationSec", 2592000);
|
||||||
|
if (LeaseMaxDurationSec < 1)
|
||||||
|
LeaseMaxDurationSec = 1;
|
||||||
|
|
||||||
|
// How long a finished lease stays listed after its deadline restored it, so teardown
|
||||||
|
// still gets a definite verdict rather than finding nothing and having to guess.
|
||||||
|
LeaseGraceSec = Config.Get("Bridge.LeaseGraceSec", 86400);
|
||||||
|
if (LeaseGraceSec < 0)
|
||||||
|
LeaseGraceSec = 0;
|
||||||
|
|
||||||
|
ParticipationSweepSeconds = Config.Get("Bridge.ParticipationSweepSeconds", 30);
|
||||||
|
if (ParticipationSweepSeconds < 1)
|
||||||
|
ParticipationSweepSeconds = 1;
|
||||||
|
|
||||||
|
// What one kill inside the area is worth against one minute of standing in it. Both
|
||||||
|
// halves live on the shard because the score IS the shard's number: core stores an
|
||||||
|
// opaque decimal it never interprets, so a weight core could edit would be a weight
|
||||||
|
// nobody could explain from either side.
|
||||||
|
ParticipationKillWeight = Config.Get("Bridge.ParticipationKillWeight", 5.0);
|
||||||
|
if (ParticipationKillWeight < 0.0)
|
||||||
|
ParticipationKillWeight = 0.0;
|
||||||
|
|
||||||
|
ParticipationMaxRuns = Config.Get("Bridge.ParticipationMaxRuns", 8);
|
||||||
|
if (ParticipationMaxRuns < 1)
|
||||||
|
ParticipationMaxRuns = 1;
|
||||||
|
|
||||||
|
ParticipationMaxMembers = Config.Get("Bridge.ParticipationMaxMembers", 2000);
|
||||||
|
if (ParticipationMaxMembers < 1)
|
||||||
|
ParticipationMaxMembers = 1;
|
||||||
|
|
||||||
|
// A radius, not a rectangle, and bounded: an area big enough to cover a facet makes
|
||||||
|
// "took part" meaningless and the sweep expensive in the same stroke.
|
||||||
|
ParticipationMaxRadius = Config.Get("Bridge.ParticipationMaxRadius", 300);
|
||||||
|
if (ParticipationMaxRadius < 1)
|
||||||
|
ParticipationMaxRadius = 1;
|
||||||
|
|
||||||
|
ParticipationGraceSec = Config.Get("Bridge.ParticipationGraceSec", 86400);
|
||||||
|
if (ParticipationGraceSec < 0)
|
||||||
|
ParticipationGraceSec = 0;
|
||||||
|
|
||||||
|
// How many members one snapshot resolves before yielding the Core thread. See
|
||||||
|
// BridgeParticipation: this is what makes the handler DEFER, which is what makes
|
||||||
|
// `bridge.busy` reachable at all.
|
||||||
|
ParticipationSnapshotChunk = Config.Get("Bridge.ParticipationSnapshotChunk", 100);
|
||||||
|
if (ParticipationSnapshotChunk < 1)
|
||||||
|
ParticipationSnapshotChunk = 1;
|
||||||
|
|
||||||
|
// The world verbs. PEC's published quotas are the defaults, because they are the only
|
||||||
|
// numbers anyone has ever defended in public: 30 creatures, a handful of bosses, five
|
||||||
|
// NPCs of five lines each, a four-hour gate. See EVENTS.md's PEC section.
|
||||||
|
EventsMaxCreatures = Config.Get("Bridge.EventsMaxCreatures", 30);
|
||||||
|
if (EventsMaxCreatures < 1)
|
||||||
|
EventsMaxCreatures = 1;
|
||||||
|
|
||||||
|
EventsMaxBosses = Config.Get("Bridge.EventsMaxBosses", 4);
|
||||||
|
if (EventsMaxBosses < 1)
|
||||||
|
EventsMaxBosses = 1;
|
||||||
|
|
||||||
|
EventsMaxNpcs = Config.Get("Bridge.EventsMaxNpcs", 5);
|
||||||
|
if (EventsMaxNpcs < 1)
|
||||||
|
EventsMaxNpcs = 1;
|
||||||
|
|
||||||
|
EventsMaxDecor = Config.Get("Bridge.EventsMaxDecor", 60);
|
||||||
|
if (EventsMaxDecor < 1)
|
||||||
|
EventsMaxDecor = 1;
|
||||||
|
|
||||||
|
EventsMaxGateMinutes = Config.Get("Bridge.EventsMaxGateMinutes", 240);
|
||||||
|
if (EventsMaxGateMinutes < 1)
|
||||||
|
EventsMaxGateMinutes = 1;
|
||||||
|
|
||||||
|
// The whole run, across every verb. The per-verb ceilings above bound one CALL; this
|
||||||
|
// bounds a run that calls a verb in a loop, which is the shape a runaway schedule
|
||||||
|
// actually takes.
|
||||||
|
EventsMaxOwnedPerRun = Config.Get("Bridge.EventsMaxOwnedPerRun", 200);
|
||||||
|
if (EventsMaxOwnedPerRun < 1)
|
||||||
|
EventsMaxOwnedPerRun = 1;
|
||||||
|
|
||||||
|
EventsMaxSpread = Config.Get("Bridge.EventsMaxSpread", 40);
|
||||||
|
if (EventsMaxSpread < 0)
|
||||||
|
EventsMaxSpread = 0;
|
||||||
|
|
||||||
|
// "An enhanced regular mob", per EVENTS.md's boss row -- so a ceiling low enough that
|
||||||
|
// the result is still recognisably the creature the author picked.
|
||||||
|
EventsMaxBossMultiplier = Config.Get("Bridge.EventsMaxBossMultiplier", 10.0);
|
||||||
|
if (EventsMaxBossMultiplier < 1.0)
|
||||||
|
EventsMaxBossMultiplier = 1.0;
|
||||||
|
|
||||||
|
EventsOracleMaxLines = Config.Get("Bridge.EventsOracleMaxLines", 5);
|
||||||
|
if (EventsOracleMaxLines < 1)
|
||||||
|
EventsOracleMaxLines = 1;
|
||||||
|
|
||||||
|
EventsOracleGreetRange = Config.Get("Bridge.EventsOracleGreetRange", 4);
|
||||||
|
if (EventsOracleGreetRange < 1)
|
||||||
|
EventsOracleGreetRange = 1;
|
||||||
|
|
||||||
|
EventsOracleSpeechRange = Config.Get("Bridge.EventsOracleSpeechRange", 8);
|
||||||
|
if (EventsOracleSpeechRange < 1)
|
||||||
|
EventsOracleSpeechRange = 1;
|
||||||
|
|
||||||
|
EventsOracleGreetCooldownSec = Config.Get("Bridge.EventsOracleGreetCooldownSec", 60);
|
||||||
|
if (EventsOracleGreetCooldownSec < 0)
|
||||||
|
EventsOracleGreetCooldownSec = 0;
|
||||||
|
|
||||||
|
EventsOracleAnswerCooldownSec = Config.Get("Bridge.EventsOracleAnswerCooldownSec", 5);
|
||||||
|
if (EventsOracleAnswerCooldownSec < 0)
|
||||||
|
EventsOracleAnswerCooldownSec = 0;
|
||||||
|
|
||||||
|
// How often expired gates are collected and dead ownership rows pruned. Gates are a
|
||||||
|
// minutes-scale deadline, so one slow sweep beats a timer per object.
|
||||||
|
EventsSweepSeconds = Config.Get("Bridge.EventsSweepSeconds", 30);
|
||||||
|
if (EventsSweepSeconds < 1)
|
||||||
|
EventsSweepSeconds = 1;
|
||||||
|
|
||||||
|
// Phase 12b. How many characters one grant may reach, and how many of one item may go
|
||||||
|
// into one hand. Both refuse rather than clamp, on `LeaseMaxDurationSec`'s argument:
|
||||||
|
// the website records what was handed out, and a silent clamp would make its ledger a
|
||||||
|
// description of a grant that did not happen.
|
||||||
|
EventsMaxGrantPerRun = Config.Get("Bridge.EventsMaxGrantPerRun", 200);
|
||||||
|
if (EventsMaxGrantPerRun < 0)
|
||||||
|
EventsMaxGrantPerRun = 0;
|
||||||
|
|
||||||
|
EventsMaxGrantStack = Config.Get("Bridge.EventsMaxGrantStack", 1000);
|
||||||
|
if (EventsMaxGrantStack < 1)
|
||||||
|
EventsMaxGrantStack = 1;
|
||||||
|
|
||||||
|
// A save stops the world, so this one is a rate limit rather than a cap. It counts from
|
||||||
|
// the last save by ANYBODY -- ServUO's own autosave included -- because an event save
|
||||||
|
// thirty seconds after the hourly one is the same freeze twice, and this shard is the
|
||||||
|
// only half that can see both.
|
||||||
|
EventsMinSaveIntervalSec = Config.Get("Bridge.EventsMinSaveIntervalSec", 300);
|
||||||
|
if (EventsMinSaveIntervalSec < 0)
|
||||||
|
EventsMinSaveIntervalSec = 0;
|
||||||
|
|
||||||
// Account provisioning. An absent SignupMode defaults to Hybrid; a *present but
|
// Account provisioning. An absent SignupMode defaults to Hybrid; a *present but
|
||||||
// unrecognized* value falls back to Game (the safest — no website creation), so a
|
// unrecognized* value falls back to Game (the safest — no website creation), so a
|
||||||
// typo can never accidentally open provisioning.
|
// typo can never accidentally open provisioning.
|
||||||
@@ -310,12 +632,21 @@ namespace Server.Custom.Bridge
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int Clamp(int value, int min, int max)
|
||||||
|
{
|
||||||
|
if (value < min)
|
||||||
|
return min;
|
||||||
|
|
||||||
|
return value > max ? max : value;
|
||||||
|
}
|
||||||
|
|
||||||
public static string Describe()
|
public static string Describe()
|
||||||
{
|
{
|
||||||
return String.Format(
|
return String.Format(
|
||||||
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9}) signup={10}(create={11})",
|
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9}) signup={10}(create={11}) events={12}",
|
||||||
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
|
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
|
||||||
ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor, Signup, AccountCreateEnabled);
|
ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor, Signup, AccountCreateEnabled,
|
||||||
|
EventsEnabled);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,9 +170,53 @@ namespace Server.Custom.Bridge
|
|||||||
.Str("acct", e.Username)
|
.Str("acct", e.Username)
|
||||||
.Str("ip", address)
|
.Str("ip", address)
|
||||||
.End());
|
.End());
|
||||||
|
|
||||||
|
EmitLoginResult(e, address);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Protocol 5. The RESULT of the login above, which the attempt itself cannot carry.
|
||||||
|
///
|
||||||
|
/// Why a second kind rather than two more fields: PacketHandlers.AccountLogin invokes this
|
||||||
|
/// sink and only THEN branches on e.Accepted, and the decision is made by the handlers
|
||||||
|
/// themselves -- Server.Misc.AccountHandler is the one that validates the password and
|
||||||
|
/// sets Accepted/RejectReason. Inside our own handler the verdict therefore does not exist
|
||||||
|
/// yet: Accepted is still its constructor default of `true` for a password that is about
|
||||||
|
/// to be rejected. Anything built on the attempt alone fires on every SUCCESSFUL login
|
||||||
|
/// too, which is the wrong way round for a security notice -- it would tell a player
|
||||||
|
/// "someone tried to get into your account" every time they logged in themselves.
|
||||||
|
///
|
||||||
|
/// Reading it one Core slice later, via DelayCall(Zero), is what makes the verdict final
|
||||||
|
/// without a core patch and without depending on handler subscription ORDER, which
|
||||||
|
/// ServUO does not define and which a shard's own scripts can change.
|
||||||
|
///
|
||||||
|
/// On holding the args object: it carries the plaintext Password, so it is deliberately
|
||||||
|
/// alive for one extra slice and no longer, and exactly two properties are read off it.
|
||||||
|
/// The password is never read, never logged and never emitted -- the same rule the
|
||||||
|
/// attempt emitter above states.
|
||||||
|
/// </summary>
|
||||||
|
private static void EmitLoginResult(AccountLoginEventArgs e, string address)
|
||||||
|
{
|
||||||
|
// The NetState is disposed by AccountLogin_ReplyRej before this runs, which is why the
|
||||||
|
// address is passed in already resolved rather than re-read from e.State.
|
||||||
|
Timer.DelayCall(TimeSpan.Zero, () =>
|
||||||
|
Guard("account.login.result", () =>
|
||||||
|
{
|
||||||
|
var sb = BridgeJson.Begin("account.login.result")
|
||||||
|
.Str("acct", e.Username)
|
||||||
|
.Str("ip", address)
|
||||||
|
.Bool("accepted", e.Accepted);
|
||||||
|
|
||||||
|
// ALRReason is only meaningful on a rejection; on an accept it is still the
|
||||||
|
// enum's zero value (Invalid), which would read as a failure reason if emitted.
|
||||||
|
if (!e.Accepted)
|
||||||
|
sb.Str("reason", e.RejectReason.ToString());
|
||||||
|
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- economy ----
|
// ---- economy ----
|
||||||
|
|
||||||
private static void OnGoldChange(AccountGoldChangeEventArgs e)
|
private static void OnGoldChange(AccountGoldChangeEventArgs e)
|
||||||
|
|||||||
541
overlay/Scripts/Custom/Bridge/BridgeIdempotency.cs
Normal file
541
overlay/Scripts/Custom/Bridge/BridgeIdempotency.cs
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Protocol 6. Makes a repeated command safe.
|
||||||
|
///
|
||||||
|
/// The website's event runner retries a step that did not come back, and until now a command
|
||||||
|
/// whose acknowledgement was lost was indistinguishable from one that never applied. There
|
||||||
|
/// was no way to tell the difference from either end, so every world-writing verb had to be
|
||||||
|
/// declared un-retryable — a lost announcement being cheaper than a doubled one. That is not
|
||||||
|
/// a position you can hold once an event can spawn creatures or lease a config value.
|
||||||
|
///
|
||||||
|
/// So a command may now carry an `idempotencyKey`, and the shard promises: **a key is
|
||||||
|
/// executed at most once.** A repeat is never re-run. It is answered with the original
|
||||||
|
/// reply — the same acknowledgement the caller lost — under the repeat's own correlation id.
|
||||||
|
///
|
||||||
|
/// ── Reserve on receipt, not on completion ──────────────────────────────────────────────
|
||||||
|
///
|
||||||
|
/// The key is recorded BEFORE the handler is dispatched, not after it returns. A handler that
|
||||||
|
/// finishes inside its own inbound call can never see a repeat (the Core thread processes one
|
||||||
|
/// line at a time), but a handler that defers — a lease that arms a timer, a spawn that
|
||||||
|
/// waits for a save — completes long after `OnInboundLine` has returned, and that is exactly
|
||||||
|
/// the window a lost ack opens. Reserving late would leave it uncovered.
|
||||||
|
///
|
||||||
|
/// A repeat of a key that is still in flight is answered `bridge.busy`: it runs nothing and
|
||||||
|
/// tells the caller to come back. `bridge.busy` is deliberately not an error — the work is
|
||||||
|
/// happening, and the module classifies it retryable.
|
||||||
|
///
|
||||||
|
/// ── A key that has begun is never released — EXCEPT on a refusal ──────────────────────
|
||||||
|
///
|
||||||
|
/// Not when the handler throws. Releasing it would let a retry re-run a command that may have
|
||||||
|
/// applied half of itself, which is precisely the failure this file exists to prevent. A
|
||||||
|
/// handler that throws stores a `bridge.error` reply instead, so the retry gets a definite
|
||||||
|
/// answer and the step fails once rather than looping.
|
||||||
|
///
|
||||||
|
/// A REFUSAL is the third case, and it was missing until the Phase 16 acceptance walk. A
|
||||||
|
/// handler that ran to completion and answered `*.error` did not do anything: every refusal
|
||||||
|
/// on this plane is a guard — a missing runId, an unknown item, a cap, a rate limit, a write
|
||||||
|
/// that failed and left the value alone. Remembering it froze the answer for ever, so a
|
||||||
|
/// refusal that WAITING FIXES could never be retried past. `uo.world.save` is the case that
|
||||||
|
/// found it: the shard saves at most every 300 seconds, the module says in as many words that
|
||||||
|
/// this is "the one refusal on this plane that waiting fixes", and six attempts over four
|
||||||
|
/// minutes all replayed one frozen sentence — "the last save was 227 seconds ago" — because
|
||||||
|
/// the number was the first reply's, not the clock's.
|
||||||
|
///
|
||||||
|
/// So a refusal releases the key: nothing happened, and the caller is free to ask again. The
|
||||||
|
/// refusal is still EMITTED to the caller, which is what ends the attempt; it is simply not
|
||||||
|
/// remembered as this key's answer. The safety argument is that "nothing happened" is a
|
||||||
|
/// property of every `*.error` reply here, and it is a property this file cannot verify — so
|
||||||
|
/// it is a rule handlers must keep: **do not answer `*.error` after changing the world.**
|
||||||
|
/// Report a partial change in an `ok` reply, as the item grant does with `granted`/`missed`
|
||||||
|
/// and the despawn with `removed`/`gone`/`refused`.
|
||||||
|
///
|
||||||
|
/// ── The one hole, and why it is loud ──────────────────────────────────────────────────
|
||||||
|
///
|
||||||
|
/// The set is bounded, so an evicted key's repeat WOULD be applied a second time. The bounds
|
||||||
|
/// are chosen to put that far outside reach — an hour, against core's fifteen-minute step
|
||||||
|
/// lease — and an eviction that drops a key which had not yet expired prints a warning naming
|
||||||
|
/// the count. If the guarantee is ever actually breached, an operator sees it here rather
|
||||||
|
/// than discovering a doubled spawn in the world.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeIdempotency
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// How long a key is remembered. Core's step lease is 15 minutes and its retry backoff
|
||||||
|
/// is bounded well inside that, so an hour is not a tuned number — it is a margin wide
|
||||||
|
/// enough that expiry should never be the thing that ends a key's life.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeSpan Ttl = TimeSpan.FromHours(1.0);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hard bound on remembered keys, in the same spirit as BridgeLink's outbound queue cap:
|
||||||
|
/// the Core thread never holds an unbounded collection. At command rates this is days of
|
||||||
|
/// traffic, so reaching it means something is wrong — hence the warning on eviction.
|
||||||
|
/// </summary>
|
||||||
|
private const int Cap = 4096;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The correlation fields the sidecar routes replies on, in the order `rpc.rs` tries
|
||||||
|
/// them. A command carries exactly one; the reply echoes it. A replay must be stamped
|
||||||
|
/// with the REPEAT's value, not the original's — the sidecar's `reqId` is a fresh
|
||||||
|
/// per-process counter, so the retry is waiting on an id the first attempt never used.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] CorrFields = { "reqId", "code", "id" };
|
||||||
|
|
||||||
|
private sealed class Entry
|
||||||
|
{
|
||||||
|
public DateTime Reserved; // when the key was first seen
|
||||||
|
public bool Done; // the handler has finished (successfully or not)
|
||||||
|
public string Reply; // the correlated reply line, verbatim; null if there was none
|
||||||
|
public string Corr; // the correlation value the original reply carries
|
||||||
|
public string CorrField; // which of CorrFields that value sits in
|
||||||
|
public string Kind; // for diagnostics only
|
||||||
|
public bool Refused; // the reply was a `*.error`: nothing happened, so do not keep the key
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Is this reply a refusal — a handler that ran and deliberately did nothing?
|
||||||
|
///
|
||||||
|
/// Every refusal on this plane is emitted as a `kind` ending in `.error`
|
||||||
|
/// (`world.error`, `lease.error`, `oneshot.error`, `participation.error`, …). Matched on
|
||||||
|
/// the suffix rather than a list, so a handler family added later is covered without
|
||||||
|
/// anyone remembering to extend an enumeration here.
|
||||||
|
///
|
||||||
|
/// `bridge.error` is deliberately EXCLUDED: that is the reply this file writes itself
|
||||||
|
/// when a handler THREW, and a throw is exactly the case whose key must be kept.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsRefusal(string replyLine)
|
||||||
|
{
|
||||||
|
if (replyLine == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var parsed = BridgeJson.Parse(replyLine);
|
||||||
|
|
||||||
|
if (parsed == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var kind = BridgeJson.GetString(parsed, "kind");
|
||||||
|
|
||||||
|
if (kind == null || String.Equals(kind, "bridge.error", StringComparison.Ordinal))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return kind.EndsWith(".error", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, Entry> _byKey =
|
||||||
|
new Dictionary<string, Entry>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// Insertion order, so the cap evicts oldest-first without sorting the dictionary.
|
||||||
|
private static readonly Queue<string> _order = new Queue<string>();
|
||||||
|
|
||||||
|
// ---- capture state; Core thread only, one keyed command at a time ----
|
||||||
|
|
||||||
|
private static Entry _open;
|
||||||
|
private static string _openCorr;
|
||||||
|
private static string _openCorrField;
|
||||||
|
|
||||||
|
private static long _seen, _replayed, _busy, _evicted, _uncorrelated, _refusals;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True while a keyed command's handler is running. BridgeLink.Emit checks this on every
|
||||||
|
/// emit, so it is a plain bool read rather than anything that costs the sweep path.
|
||||||
|
/// </summary>
|
||||||
|
public static bool Capturing
|
||||||
|
{
|
||||||
|
get { return _open != null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Status()
|
||||||
|
{
|
||||||
|
return String.Format(
|
||||||
|
"idem(keys={0} seen={1} replayed={2} busy={3} evicted={4} uncorrelated={5} refused={6})",
|
||||||
|
_byKey.Count, _seen, _replayed, _busy, _evicted, _uncorrelated, _refusals);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called by BridgeBoot for every inbound command that carries an `idempotencyKey`,
|
||||||
|
/// before the handler runs.
|
||||||
|
///
|
||||||
|
/// Returns TRUE when the command must not be executed — this call has already emitted the
|
||||||
|
/// answer (a replay of the original reply, or `bridge.busy`). Returns FALSE when the key
|
||||||
|
/// is new: the key is now reserved and capture is open, and the caller MUST pair this
|
||||||
|
/// with <see cref="Finish"/> in a finally.
|
||||||
|
/// </summary>
|
||||||
|
public static bool Intercept(string key, Dictionary<string, object> command)
|
||||||
|
{
|
||||||
|
_seen++;
|
||||||
|
Sweep();
|
||||||
|
|
||||||
|
string corrField = null;
|
||||||
|
string corr = null;
|
||||||
|
|
||||||
|
for (int i = 0; i < CorrFields.Length; i++)
|
||||||
|
{
|
||||||
|
var v = BridgeJson.GetString(command, CorrFields[i]);
|
||||||
|
|
||||||
|
if (v != null)
|
||||||
|
{
|
||||||
|
corrField = CorrFields[i];
|
||||||
|
corr = v;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Entry prior;
|
||||||
|
|
||||||
|
if (_byKey.TryGetValue(key, out prior))
|
||||||
|
{
|
||||||
|
if (prior.Done)
|
||||||
|
Replay(key, prior, corrField, corr);
|
||||||
|
else
|
||||||
|
Busy(key, prior, corrField, corr);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = new Entry
|
||||||
|
{
|
||||||
|
Reserved = DateTime.UtcNow,
|
||||||
|
Done = false,
|
||||||
|
Kind = BridgeJson.GetString(command, "kind"),
|
||||||
|
};
|
||||||
|
|
||||||
|
Remember(key, entry);
|
||||||
|
|
||||||
|
_open = entry;
|
||||||
|
_openCorr = corr;
|
||||||
|
_openCorrField = corrField;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called by BridgeBoot in a finally, once the handler has returned. Closes capture and
|
||||||
|
/// marks the key done. `error` is non-null when the handler threw.
|
||||||
|
///
|
||||||
|
/// A handler that deferred its work calls <see cref="Hold"/> first; this then leaves the
|
||||||
|
/// key reserved and in flight, and the handler completes it later.
|
||||||
|
/// </summary>
|
||||||
|
public static void Finish(string key, string error)
|
||||||
|
{
|
||||||
|
var entry = _open;
|
||||||
|
|
||||||
|
_open = null;
|
||||||
|
var corr = _openCorr;
|
||||||
|
var corrField = _openCorrField;
|
||||||
|
_openCorr = null;
|
||||||
|
_openCorrField = null;
|
||||||
|
|
||||||
|
if (entry == null || entry.Done)
|
||||||
|
return; // Hold() released it to its own completion, or there was nothing open
|
||||||
|
|
||||||
|
if (error != null)
|
||||||
|
{
|
||||||
|
// The handler threw. The key stays claimed — see the class header — and the stored
|
||||||
|
// answer is the failure, so the retry ends the step instead of re-running a command
|
||||||
|
// that may have applied part of itself.
|
||||||
|
var sb = BridgeJson.Begin("bridge.error");
|
||||||
|
|
||||||
|
if (corrField != null)
|
||||||
|
sb.Str(corrField, corr);
|
||||||
|
|
||||||
|
sb.Str("reason", "handler threw: " + error)
|
||||||
|
.Str("idempotencyKey", key);
|
||||||
|
|
||||||
|
entry.Reply = sb.End();
|
||||||
|
entry.Corr = corr;
|
||||||
|
entry.CorrField = corrField;
|
||||||
|
entry.Done = true;
|
||||||
|
|
||||||
|
Console.WriteLine("[Bridge] idempotency: {0} threw under key {1}; the retry will be answered with the failure",
|
||||||
|
entry.Kind, key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.Reply == null)
|
||||||
|
{
|
||||||
|
// Nothing the sidecar could have correlated was emitted. That is a defect in the
|
||||||
|
// handler rather than a state to model: the FIRST attempt has already timed out at
|
||||||
|
// the sidecar, and the retry would time out identically forever. Store a definite
|
||||||
|
// answer so the retry terminates, and say so.
|
||||||
|
_uncorrelated++;
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("bridge.error");
|
||||||
|
|
||||||
|
if (corrField != null)
|
||||||
|
sb.Str(corrField, corr);
|
||||||
|
|
||||||
|
sb.Str("reason", "the original command produced no correlated reply")
|
||||||
|
.Str("idempotencyKey", key);
|
||||||
|
|
||||||
|
entry.Reply = sb.End();
|
||||||
|
entry.Corr = corr;
|
||||||
|
entry.CorrField = corrField;
|
||||||
|
|
||||||
|
Console.WriteLine("[Bridge] idempotency: {0} under key {1} emitted no reply the sidecar could correlate",
|
||||||
|
entry.Kind, key);
|
||||||
|
}
|
||||||
|
else if (entry.Refused)
|
||||||
|
{
|
||||||
|
// The handler ran and refused, so nothing happened and this key is not spent. The
|
||||||
|
// refusal has already gone out to the caller; it just is not remembered as the
|
||||||
|
// answer. Without this, a refusal that waiting fixes could never be retried past —
|
||||||
|
// see the class header.
|
||||||
|
Release(key);
|
||||||
|
_refusals++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.Done = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// For a handler that finishes AFTER its inbound call returns. It keeps the key reserved
|
||||||
|
/// (so a repeat is answered `bridge.busy` rather than executed) and takes on the duty of
|
||||||
|
/// calling <see cref="Complete"/> with the reply it eventually emits.
|
||||||
|
///
|
||||||
|
/// 11a built this door and had nothing to walk through it. `participation.snapshot` is
|
||||||
|
/// the first: above a threshold it walks its members in chunks across Core ticks, so it
|
||||||
|
/// completes long after its inbound call returned, and a repeat arriving in between is
|
||||||
|
/// the first `bridge.busy` this shard can actually produce.
|
||||||
|
/// </summary>
|
||||||
|
public static void Hold(string key)
|
||||||
|
{
|
||||||
|
var entry = _open;
|
||||||
|
|
||||||
|
if (entry == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// The caller must be holding the key it was dispatched under. A mismatch would leave
|
||||||
|
// the OPEN key marked done by Finish while the named one stayed in flight forever, so
|
||||||
|
// it is refused rather than honoured: capture stays open and the ordinary path runs.
|
||||||
|
if (key == null || !_byKey.ContainsKey(key))
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] idempotency: Hold called with an unknown key '{0}'; ignoring", key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close capture without marking done: the key stays in flight until Complete.
|
||||||
|
_open = null;
|
||||||
|
_openCorr = null;
|
||||||
|
_openCorrField = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Completes a key a handler previously held. `replyLine` is the line the handler emits
|
||||||
|
/// as its answer; it is stored so a later repeat replays it.
|
||||||
|
/// </summary>
|
||||||
|
public static void Complete(string key, string replyLine)
|
||||||
|
{
|
||||||
|
Entry entry;
|
||||||
|
|
||||||
|
if (key == null || !_byKey.TryGetValue(key, out entry) || entry.Done)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var parsed = replyLine == null ? null : BridgeJson.Parse(replyLine);
|
||||||
|
|
||||||
|
if (parsed != null)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < CorrFields.Length; i++)
|
||||||
|
{
|
||||||
|
var v = BridgeJson.GetString(parsed, CorrFields[i]);
|
||||||
|
|
||||||
|
if (v != null)
|
||||||
|
{
|
||||||
|
entry.CorrField = CorrFields[i];
|
||||||
|
entry.Corr = v;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A deferred handler can refuse too — a lease whose target vanished while the timer was
|
||||||
|
// armed answers `lease.error` here rather than from inside the inbound call. Same rule:
|
||||||
|
// nothing happened, so the key is not spent.
|
||||||
|
if (IsRefusal(replyLine))
|
||||||
|
{
|
||||||
|
Release(key);
|
||||||
|
_refusals++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.Reply = replyLine;
|
||||||
|
entry.Done = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Give a key back, as though it had never been seen.
|
||||||
|
///
|
||||||
|
/// Only ever called for a refusal — see the class header. It removes the entry from the
|
||||||
|
/// lookup; the stale key left in `_order` is harmless, because eviction re-reads
|
||||||
|
/// `_byKey` and skips what is no longer there.
|
||||||
|
/// </summary>
|
||||||
|
private static void Release(string key)
|
||||||
|
{
|
||||||
|
if (key != null)
|
||||||
|
_byKey.Remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every line a keyed handler emits passes through here. Only the one the sidecar would
|
||||||
|
/// correlate with THIS command is kept: an `admin.audit` broadcast that happens to be
|
||||||
|
/// emitted alongside the reply is a fact about the world and must not be replayed, while
|
||||||
|
/// the reply is an answer to a caller and must be.
|
||||||
|
/// </summary>
|
||||||
|
public static void Observe(string line)
|
||||||
|
{
|
||||||
|
var entry = _open;
|
||||||
|
|
||||||
|
if (entry == null || line == null || _openCorrField == null || _openCorr == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Cheap reject before parsing: the correlation value is a string field on the reply, so
|
||||||
|
// if it does not appear in the line at all this cannot be the reply.
|
||||||
|
if (line.IndexOf(_openCorr, StringComparison.Ordinal) < 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var parsed = BridgeJson.Parse(line);
|
||||||
|
|
||||||
|
if (parsed == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!String.Equals(BridgeJson.GetString(parsed, _openCorrField), _openCorr, StringComparison.Ordinal))
|
||||||
|
return;
|
||||||
|
|
||||||
|
entry.Reply = line;
|
||||||
|
entry.Corr = _openCorr;
|
||||||
|
entry.CorrField = _openCorrField;
|
||||||
|
entry.Refused = IsRefusal(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- internals ----
|
||||||
|
|
||||||
|
private static void Replay(string key, Entry prior, string corrField, string corr)
|
||||||
|
{
|
||||||
|
_replayed++;
|
||||||
|
|
||||||
|
// A repeat with no correlation field is nobody's outstanding call. Re-emitting the
|
||||||
|
// original reply would put a stale answer on the event feed, where a subscriber would
|
||||||
|
// read it as a fresh one, so the repeat is absorbed silently instead.
|
||||||
|
if (corrField == null || corr == null)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] idempotency: absorbed an uncorrelated repeat of key {0} ({1})",
|
||||||
|
key, prior.Kind);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamp the repeat's correlation id over the original's. The sidecar is waiting on the
|
||||||
|
// id IT sent this time; replaying the first attempt's id would leave the call hanging
|
||||||
|
// until the reply timeout, which is the very failure being answered.
|
||||||
|
string line = null;
|
||||||
|
|
||||||
|
if (prior.Reply != null && String.Equals(corrField, prior.CorrField, StringComparison.Ordinal))
|
||||||
|
line = BridgeJson.RewriteStringField(prior.Reply, corrField, corr);
|
||||||
|
|
||||||
|
if (line == null)
|
||||||
|
{
|
||||||
|
// Either the original produced no reply to replay, or the repeat correlates on a
|
||||||
|
// different field than the original did. Nothing sensible can be replayed under an
|
||||||
|
// id the caller is not waiting on, so answer plainly rather than hang the call.
|
||||||
|
BridgeLink.Emit(BridgeJson.Begin("bridge.error")
|
||||||
|
.Str(corrField, corr)
|
||||||
|
.Str("reason", "the original reply for this idempotency key cannot be replayed")
|
||||||
|
.Str("idempotencyKey", key)
|
||||||
|
.End());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
line = BridgeJson.WithTrueFlag(line, "replayed");
|
||||||
|
|
||||||
|
Console.WriteLine("[Bridge] idempotency: replaying the original reply for key {0} ({1})",
|
||||||
|
key, prior.Kind);
|
||||||
|
|
||||||
|
BridgeLink.Emit(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Busy(string key, Entry prior, string corrField, string corr)
|
||||||
|
{
|
||||||
|
_busy++;
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("bridge.busy");
|
||||||
|
|
||||||
|
if (corrField != null)
|
||||||
|
sb.Str(corrField, corr);
|
||||||
|
|
||||||
|
// **`busyKind`, not `kind`, and the name is the whole bug.** `Begin` has already
|
||||||
|
// written this frame's own `kind` as `bridge.busy`, so a second `kind` field made the
|
||||||
|
// object carry two -- and every JSON parser worth the name takes the LAST. The sidecar
|
||||||
|
// matches `bridge.busy` to decide on a 425, read `participation.snapshot` instead, and
|
||||||
|
// answered an ordinary 200 with a body saying nothing had happened.
|
||||||
|
//
|
||||||
|
// It shipped in 11a and could not be seen there: with only synchronous handlers a
|
||||||
|
// repeat can never arrive mid-flight, so this arm was unreachable on a live shard and
|
||||||
|
// the unit test that covers the sidecar's mapping was, correctly, feeding it a frame
|
||||||
|
// built by hand. The first deferring handler produced it on its first collision.
|
||||||
|
sb.Str("idempotencyKey", key)
|
||||||
|
.Str("busyKind", prior.Kind)
|
||||||
|
.Str("reason", "a command with this idempotency key is still in flight");
|
||||||
|
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Remember(string key, Entry entry)
|
||||||
|
{
|
||||||
|
_byKey[key] = entry;
|
||||||
|
_order.Enqueue(key);
|
||||||
|
|
||||||
|
while (_order.Count > Cap)
|
||||||
|
{
|
||||||
|
var oldest = _order.Dequeue();
|
||||||
|
|
||||||
|
Entry dropped;
|
||||||
|
|
||||||
|
if (!_byKey.TryGetValue(oldest, out dropped))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
_byKey.Remove(oldest);
|
||||||
|
|
||||||
|
// Expired keys leave silently; they are supposed to. A key evicted while still
|
||||||
|
// inside its TTL is the guarantee's one hole, so it never leaves quietly.
|
||||||
|
if (DateTime.UtcNow - dropped.Reserved < Ttl)
|
||||||
|
{
|
||||||
|
_evicted++;
|
||||||
|
Console.WriteLine(
|
||||||
|
"[Bridge] idempotency: evicted key {0} ({1}) while still live — the cap of {2} was reached, so a repeat of it WOULD be applied again ({3} so far)",
|
||||||
|
oldest, dropped.Kind, Cap, _evicted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Drops keys past their TTL. Runs on the command path, which is human-rate.</summary>
|
||||||
|
private static void Sweep()
|
||||||
|
{
|
||||||
|
if (_order.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var cutoff = DateTime.UtcNow - Ttl;
|
||||||
|
|
||||||
|
while (_order.Count > 0)
|
||||||
|
{
|
||||||
|
var oldest = _order.Peek();
|
||||||
|
|
||||||
|
Entry entry;
|
||||||
|
|
||||||
|
if (!_byKey.TryGetValue(oldest, out entry))
|
||||||
|
{
|
||||||
|
_order.Dequeue();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.Reserved > cutoff)
|
||||||
|
return; // insertion-ordered, so nothing behind this is older
|
||||||
|
|
||||||
|
_order.Dequeue();
|
||||||
|
_byKey.Remove(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,6 +139,53 @@ namespace Server.Custom.Bridge
|
|||||||
return sb;
|
return sb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A named array of actor objects each carrying a damage total — a boss kill's damage
|
||||||
|
/// table (Protocol 6), and the first actor array whose entries are ranked rather than
|
||||||
|
/// merely listed.
|
||||||
|
///
|
||||||
|
/// The pairs are written in the order given, so the CALLER owns the sort. That is
|
||||||
|
/// deliberate: "the top damagers" is a judgement about a fight, and the shard's job is
|
||||||
|
/// to report the numbers it holds rather than to decide what counts as a contribution.
|
||||||
|
///
|
||||||
|
/// Each entry is the standard actor object plus `damage`, which means it carries `acct`
|
||||||
|
/// and `webId` and is therefore governed by the website's locked-field rule exactly as
|
||||||
|
/// every other actor is. A shard that considers the whole table too revealing hides it
|
||||||
|
/// with one field rule rather than by dropping the kind.
|
||||||
|
/// </summary>
|
||||||
|
public static StringBuilder Damagers(
|
||||||
|
this StringBuilder sb, string name, IList<KeyValuePair<Mobile, int>> pairs, int count)
|
||||||
|
{
|
||||||
|
sb.Append(",\"").Append(name).Append("\":[");
|
||||||
|
|
||||||
|
if (pairs != null)
|
||||||
|
{
|
||||||
|
var end = Math.Min(count, pairs.Count);
|
||||||
|
bool first = true;
|
||||||
|
|
||||||
|
for (int i = 0; i < end; i++)
|
||||||
|
{
|
||||||
|
var m = pairs[i].Key;
|
||||||
|
|
||||||
|
if (m == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!first)
|
||||||
|
sb.Append(',');
|
||||||
|
|
||||||
|
sb.Append('{');
|
||||||
|
WriteActorFields(sb, m);
|
||||||
|
sb.Append(",\"damage\":").Append(pairs[i].Value);
|
||||||
|
sb.Append('}');
|
||||||
|
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(']');
|
||||||
|
return sb;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A roster member: the standard actor object plus the member's rank in their guild.
|
/// A roster member: the standard actor object plus the member's rank in their guild.
|
||||||
///
|
///
|
||||||
@@ -260,6 +307,19 @@ namespace Server.Custom.Bridge
|
|||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a bare JSON string value, or `null`, with no leading comma and no field name.
|
||||||
|
/// For the hand-built arrays the event plane emits, where <see cref="Escape"/> would
|
||||||
|
/// throw on the null a nullable field is entitled to be.
|
||||||
|
/// </summary>
|
||||||
|
public static void Text(StringBuilder sb, string value)
|
||||||
|
{
|
||||||
|
if (value == null)
|
||||||
|
sb.Append("null");
|
||||||
|
else
|
||||||
|
Escape(sb, value);
|
||||||
|
}
|
||||||
|
|
||||||
public static void Escape(StringBuilder sb, string value)
|
public static void Escape(StringBuilder sb, string value)
|
||||||
{
|
{
|
||||||
sb.Append('"');
|
sb.Append('"');
|
||||||
@@ -289,6 +349,75 @@ namespace Server.Custom.Bridge
|
|||||||
sb.Append('"');
|
sb.Append('"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- rewriting an already-built line (protocol 6) ----
|
||||||
|
//
|
||||||
|
// BridgeIdempotency replays a stored reply under the REPEAT's correlation id. It could
|
||||||
|
// parse the line, edit the dictionary and re-serialize, but a round trip through
|
||||||
|
// JavaScriptSerializer would silently renormalise every number and string in a reply this
|
||||||
|
// file went to the trouble of writing by hand. These two edit the text instead, so a
|
||||||
|
// replayed reply is byte-for-byte the original apart from the field that had to change.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces the value of a top-level STRING field, honouring backslash escapes when
|
||||||
|
/// finding the value's end. Returns null if the field is not present as a string —
|
||||||
|
/// never a half-rewritten line.
|
||||||
|
/// </summary>
|
||||||
|
public static string RewriteStringField(string line, string name, string value)
|
||||||
|
{
|
||||||
|
if (line == null || name == null || value == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// The leading comma is part of the needle: every top-level field is written by Str()
|
||||||
|
// after Begin() has already emitted `t` and `kind`, so a real one always has one. It
|
||||||
|
// is the cheapest thing that stops the search matching the same text inside a value.
|
||||||
|
var needle = ",\"" + name + "\":\"";
|
||||||
|
int at = line.IndexOf(needle, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
if (at < 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
int valueStart = at + needle.Length;
|
||||||
|
int i = valueStart;
|
||||||
|
|
||||||
|
while (i < line.Length)
|
||||||
|
{
|
||||||
|
char c = line[i];
|
||||||
|
|
||||||
|
if (c == '\\')
|
||||||
|
{
|
||||||
|
i += 2; // an escape consumes the next character, whatever it is
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c == '"')
|
||||||
|
break;
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i >= line.Length)
|
||||||
|
return null; // unterminated: refuse rather than guess
|
||||||
|
|
||||||
|
var sb = new StringBuilder(line.Length + value.Length);
|
||||||
|
sb.Append(line, 0, valueStart - 1); // up to and excluding the opening quote
|
||||||
|
Escape(sb, value);
|
||||||
|
sb.Append(line, i + 1, line.Length - i - 1);
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends `"name":true` to an already-closed object. Returns the line unchanged if it
|
||||||
|
/// is not one, so a malformed reply is passed through rather than corrupted further.
|
||||||
|
/// </summary>
|
||||||
|
public static string WithTrueFlag(string line, string name)
|
||||||
|
{
|
||||||
|
if (String.IsNullOrEmpty(line) || line[line.Length - 1] != '}')
|
||||||
|
return line;
|
||||||
|
|
||||||
|
return line.Substring(0, line.Length - 1) + ",\"" + name + "\":true}";
|
||||||
|
}
|
||||||
|
|
||||||
// ---- inbound ----
|
// ---- inbound ----
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -355,6 +484,42 @@ namespace Server.Custom.Bridge
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts a JSON array of OBJECTS, as a list of dictionaries.
|
||||||
|
///
|
||||||
|
/// `JavaScriptSerializer` already materializes a nested object as another
|
||||||
|
/// `Dictionary<string, object>` when the target is `object`, so this needs no
|
||||||
|
/// parser work -- only the same defensive walk `GetStringList` does. Anything in the
|
||||||
|
/// array that is not an object is skipped rather than failing the whole field: a
|
||||||
|
/// malformed row in an oracle's dialogue should cost that row, not the NPC.
|
||||||
|
///
|
||||||
|
/// Returns an empty list for a missing or non-array value, never null.
|
||||||
|
/// </summary>
|
||||||
|
public static List<Dictionary<string, object>> GetObjectList(
|
||||||
|
Dictionary<string, object> o, string key)
|
||||||
|
{
|
||||||
|
var result = new List<Dictionary<string, object>>();
|
||||||
|
|
||||||
|
object v;
|
||||||
|
if (o == null || !o.TryGetValue(key, out v) || v == null)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
var enumerable = v as System.Collections.IEnumerable;
|
||||||
|
|
||||||
|
if (enumerable == null || v is string)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
foreach (var item in enumerable)
|
||||||
|
{
|
||||||
|
var row = item as Dictionary<string, object>;
|
||||||
|
|
||||||
|
if (row != null)
|
||||||
|
result.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public static int GetInt(Dictionary<string, object> o, string key, int fallback)
|
public static int GetInt(Dictionary<string, object> o, string key, int fallback)
|
||||||
{
|
{
|
||||||
object v;
|
object v;
|
||||||
@@ -371,5 +536,50 @@ namespace Server.Custom.Bridge
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Epoch milliseconds and lease durations do not fit an int, and JavaScriptSerializer
|
||||||
|
/// hands a large JSON number back as a long or a decimal depending on its magnitude, so
|
||||||
|
/// the conversion is done rather than the cast attempted.
|
||||||
|
/// </summary>
|
||||||
|
public static long GetLong(Dictionary<string, object> o, string key, long fallback)
|
||||||
|
{
|
||||||
|
object v;
|
||||||
|
|
||||||
|
if (o == null || !o.TryGetValue(key, out v) || v == null)
|
||||||
|
return fallback;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Convert.ToInt64(v, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A lease VALUE arrives as text on the wire whatever its declared type (see
|
||||||
|
/// BridgeLeases), so this exists for the numbers that are genuinely numbers - a radius,
|
||||||
|
/// a weight. InvariantCulture throughout: a shard running under a comma-decimal locale
|
||||||
|
/// must read the same bytes the same way as one that is not.
|
||||||
|
/// </summary>
|
||||||
|
public static double GetDouble(Dictionary<string, object> o, string key, double fallback)
|
||||||
|
{
|
||||||
|
object v;
|
||||||
|
|
||||||
|
if (o == null || !o.TryGetValue(key, out v) || v == null)
|
||||||
|
return fallback;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Convert.ToDouble(v, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
708
overlay/Scripts/Custom/Bridge/BridgeLeaseTargets.cs
Normal file
708
overlay/Scripts/Custom/Bridge/BridgeLeaseTargets.cs
Normal file
@@ -0,0 +1,708 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
using Server.Engines.SeasonalEvents;
|
||||||
|
using Server.Mobiles;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Protocol 7, part b. The two lease planes whose value lives on something that is
|
||||||
|
/// <b>already in the world</b> — a property on an existing object, and a seasonal event's
|
||||||
|
/// status.
|
||||||
|
///
|
||||||
|
/// `BridgeLeases` owns the wire, the deadline, the compare-and-set and the bookkeeping;
|
||||||
|
/// this file owns everything that is specific to ServUO, which is the same split the config
|
||||||
|
/// plane has had since 11b. What is new is that both planes here are <b>targeted</b>: a
|
||||||
|
/// lease names a key AND the thing it applies to, because `Spawner.MaxCount` is one
|
||||||
|
/// capability over thousands of spawners rather than one value.
|
||||||
|
///
|
||||||
|
/// ── Why a lease here must be PERSISTED, and the config plane's must not ────────────────
|
||||||
|
///
|
||||||
|
/// 11b's config lease is deliberately memory-only, and its header states the reason: a lease
|
||||||
|
/// that never reaches disk makes a shard restart a *free* restore. That argument depends
|
||||||
|
/// entirely on the leased value being memory-only too, and here it is not.
|
||||||
|
///
|
||||||
|
/// A spawner is an `Item`. It is in the world save. A seasonal entry is written to
|
||||||
|
/// `Saves/Misc/SeasonalEvents.bin` by ServUO's own `EventSink.WorldSave`. So a restart does
|
||||||
|
/// not put either of them back — it puts the CHANGE back and throws away the deadline timer
|
||||||
|
/// that was going to undo it. The world is then stuck at the leased value with nothing on
|
||||||
|
/// this shard remembering that it is borrowed, which is the exact failure the lease framing
|
||||||
|
/// exists to make impossible.
|
||||||
|
///
|
||||||
|
/// So the hold is persisted, in the Bridge's <b>third</b> save file, beside 11b's
|
||||||
|
/// `Participation.bin` and 12a's `Owned.bin` — and, like both of those, written by the same
|
||||||
|
/// `EventSink.WorldSave` that writes what it describes, so it cannot get out of step with
|
||||||
|
/// it. The deadline is re-armed on load, from the stored absolute time.
|
||||||
|
///
|
||||||
|
/// **A deadline that has already passed while the shard was down fires at once**, rather
|
||||||
|
/// than being dropped or extended. The promise the website was given is "back at baseline by
|
||||||
|
/// then"; a shard that was off for the whole hold has not kept it, and restoring immediately
|
||||||
|
/// is the only reading of it that is still true.
|
||||||
|
///
|
||||||
|
/// ── Reflection, bounded by an allowlist ───────────────────────────────────────────────
|
||||||
|
///
|
||||||
|
/// Properties are read and written through reflection, and the allowlist below is what makes
|
||||||
|
/// that defensible rather than `[set` with extra steps. A pair not named here does not exist
|
||||||
|
/// as far as this plane is concerned, whatever a caller sends; every entry additionally
|
||||||
|
/// requires the property to carry `CommandProperty`, so nothing internal is reachable even
|
||||||
|
/// if a pair were added carelessly. Reflection rather than a hand-written switch is what
|
||||||
|
/// lets the boot self-check (§N10) actually verify a pair — a switch would compile happily
|
||||||
|
/// against a property ServUO had renamed.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeLeaseTargets
|
||||||
|
{
|
||||||
|
// ---- the object-property allowlist ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One allowlisted property, and every type it may be applied to.
|
||||||
|
///
|
||||||
|
/// **`Spawner` and `XmlSpawner` share all four names**, which is a fact about this tree
|
||||||
|
/// rather than a convenience: the shard's own `Spawns/*.xml` load as XmlSpawners and
|
||||||
|
/// `[add spawner` makes the native one, so a catalog that named only one of them would
|
||||||
|
/// work on a shard until the day it did not. They also share the semantics — `MaxCount`
|
||||||
|
/// is the ceiling the next tick spawns up to on both.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class PropEntry
|
||||||
|
{
|
||||||
|
public string Key;
|
||||||
|
public string Label;
|
||||||
|
public string Property;
|
||||||
|
public string[] Types;
|
||||||
|
public BridgeLeases.LeaseType Type;
|
||||||
|
public double Min;
|
||||||
|
public double Max;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the CLR property is a `TimeSpan` and the wire carries seconds.
|
||||||
|
///
|
||||||
|
/// The lease type vocabulary is int/float/bool/string and there is no duration in
|
||||||
|
/// it, so a respawn window has to cross as a number. Seconds rather than minutes
|
||||||
|
/// because the spawn files' own `DelayInSec` flag proves both are in use, and a unit
|
||||||
|
/// that cannot express five seconds cannot express the shard's own data.
|
||||||
|
/// </summary>
|
||||||
|
public bool Seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly string[] SpawnerTypes =
|
||||||
|
{
|
||||||
|
"Server.Mobiles.Spawner",
|
||||||
|
"Server.Mobiles.XmlSpawner",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly PropEntry[] Props =
|
||||||
|
{
|
||||||
|
new PropEntry
|
||||||
|
{
|
||||||
|
Key = "Spawner.MaxCount",
|
||||||
|
Label = "Spawner: how many at once",
|
||||||
|
Property = "MaxCount",
|
||||||
|
Types = SpawnerTypes,
|
||||||
|
Type = BridgeLeases.LeaseType.Int,
|
||||||
|
Min = 0.0,
|
||||||
|
Max = 100.0,
|
||||||
|
},
|
||||||
|
new PropEntry
|
||||||
|
{
|
||||||
|
Key = "Spawner.MinDelay",
|
||||||
|
Label = "Spawner: shortest respawn wait",
|
||||||
|
Property = "MinDelay",
|
||||||
|
Types = SpawnerTypes,
|
||||||
|
Type = BridgeLeases.LeaseType.Int,
|
||||||
|
Min = 0.0,
|
||||||
|
Max = 86400.0,
|
||||||
|
Seconds = true,
|
||||||
|
},
|
||||||
|
new PropEntry
|
||||||
|
{
|
||||||
|
Key = "Spawner.MaxDelay",
|
||||||
|
Label = "Spawner: longest respawn wait",
|
||||||
|
Property = "MaxDelay",
|
||||||
|
Types = SpawnerTypes,
|
||||||
|
Type = BridgeLeases.LeaseType.Int,
|
||||||
|
Min = 0.0,
|
||||||
|
Max = 86400.0,
|
||||||
|
Seconds = true,
|
||||||
|
},
|
||||||
|
new PropEntry
|
||||||
|
{
|
||||||
|
Key = "Spawner.Running",
|
||||||
|
Label = "Spawner: running",
|
||||||
|
Property = "Running",
|
||||||
|
Types = SpawnerTypes,
|
||||||
|
Type = BridgeLeases.LeaseType.Bool,
|
||||||
|
Min = 0.0,
|
||||||
|
Max = 0.0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- the seasonal allowlist ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The seasonal events an event may hold, and the one it may not.
|
||||||
|
///
|
||||||
|
/// **`TreasuresOfTokuno` is excluded, and its exclusion is the whole argument for §N10's
|
||||||
|
/// self-check made concrete.** `SeasonalEventEntry.IsActive()` special-cases it and reads
|
||||||
|
/// `TreasuresOfTokuno.DropEra` instead of `Status`, so setting its status writes a field
|
||||||
|
/// that nothing consults. The write succeeds, the value reads back, a compare-and-set
|
||||||
|
/// restore would pass — every mechanism in this file would report a working lease over a
|
||||||
|
/// capability that does nothing at all. That is the failure N10 names ("a capability that
|
||||||
|
/// lies"), and no runtime probe can catch this one, so it is caught by reading the source
|
||||||
|
/// and excluded here by name.
|
||||||
|
///
|
||||||
|
/// The remaining eight are real, and six of them do MORE than flip a flag:
|
||||||
|
/// `OnStatusChange()` calls a `CheckEnabled()` that generates or removes world content
|
||||||
|
/// for Doom, Khaldun, Sorcerer's Dungeon, Krampus, Rising Tide and Fellowship. §G called
|
||||||
|
/// this toggle "small and safe"; it is safe, because ServUO does it to itself from a
|
||||||
|
/// staff gump, but it is not small, and an author scheduling one should be told so. The
|
||||||
|
/// label says it.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly EventType[] SeasonalExcluded =
|
||||||
|
{
|
||||||
|
EventType.TreasuresOfTokuno,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>The status values a seasonal lease may hold. `EventStatus` has exactly three.</summary>
|
||||||
|
public static readonly string[] SeasonalValues = { "Inactive", "Active", "Seasonal" };
|
||||||
|
|
||||||
|
public const string SeasonalKey = "Seasonal.Status";
|
||||||
|
|
||||||
|
// ---- what the catalog offers ----
|
||||||
|
|
||||||
|
/// <summary>Every targeted key this shard offers, in `lease.list` order.</summary>
|
||||||
|
public static IEnumerable<BridgeLeases.Catalog> Catalog()
|
||||||
|
{
|
||||||
|
for (int i = 0; i < Props.Length; i++)
|
||||||
|
{
|
||||||
|
var p = Props[i];
|
||||||
|
|
||||||
|
if (_dropped.Contains(p.Key))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
yield return new BridgeLeases.Catalog
|
||||||
|
{
|
||||||
|
Key = p.Key,
|
||||||
|
Label = p.Label,
|
||||||
|
Kind = BridgeLeases.LeaseKind.ObjectProperty,
|
||||||
|
Type = p.Type,
|
||||||
|
Min = p.Min,
|
||||||
|
Max = p.Max,
|
||||||
|
Default = p.Type == BridgeLeases.LeaseType.Bool ? "true" : "0",
|
||||||
|
TargetLabel = "Which spawner",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_dropped.Contains(SeasonalKey))
|
||||||
|
{
|
||||||
|
yield return new BridgeLeases.Catalog
|
||||||
|
{
|
||||||
|
Key = SeasonalKey,
|
||||||
|
Label = "Seasonal event status",
|
||||||
|
Kind = BridgeLeases.LeaseKind.Seasonal,
|
||||||
|
Type = BridgeLeases.LeaseType.Text,
|
||||||
|
Default = "Inactive",
|
||||||
|
Values = SeasonalValues,
|
||||||
|
TargetLabel = "Which seasonal event",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The seasonal events an author may name, for the module's option source.</summary>
|
||||||
|
public static IEnumerable<string> SeasonalTargets()
|
||||||
|
{
|
||||||
|
foreach (EventType type in Enum.GetValues(typeof(EventType)))
|
||||||
|
{
|
||||||
|
if (Array.IndexOf(SeasonalExcluded, type) >= 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (SeasonalEventSystem.GetEntry(type) == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
yield return type.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- reading and writing ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a targeted key, or answers null when the target cannot be resolved.
|
||||||
|
///
|
||||||
|
/// **Null is "I could not find it", never a value**, and the caller turns it into a
|
||||||
|
/// refusal. A missing spawner answered as `0` would let a lease be taken over nothing,
|
||||||
|
/// record `0` as the baseline, and restore that baseline onto whatever object later
|
||||||
|
/// claimed the serial.
|
||||||
|
/// </summary>
|
||||||
|
public static string Read(BridgeLeases.Catalog entry, string target, out string why)
|
||||||
|
{
|
||||||
|
why = null;
|
||||||
|
|
||||||
|
if (entry.Kind == BridgeLeases.LeaseKind.Seasonal)
|
||||||
|
{
|
||||||
|
var seasonal = SeasonalEntry(target, out why);
|
||||||
|
return seasonal == null ? null : seasonal.Status.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
var prop = Lookup(entry.Key);
|
||||||
|
|
||||||
|
if (prop == null)
|
||||||
|
{
|
||||||
|
why = "no lease is offered for key '" + entry.Key + "'";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
object obj = Resolve(target, prop, out why);
|
||||||
|
|
||||||
|
if (obj == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var info = Info(obj.GetType(), prop, out why);
|
||||||
|
|
||||||
|
if (info == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var raw = info.GetValue(obj, null);
|
||||||
|
return Render(prop, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes a targeted key. Answers false with a reason rather than throwing.</summary>
|
||||||
|
public static bool Write(BridgeLeases.Catalog entry, string target, string canonical, out string why)
|
||||||
|
{
|
||||||
|
why = null;
|
||||||
|
|
||||||
|
if (entry.Kind == BridgeLeases.LeaseKind.Seasonal)
|
||||||
|
{
|
||||||
|
var seasonal = SeasonalEntry(target, out why);
|
||||||
|
|
||||||
|
if (seasonal == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
EventStatus status;
|
||||||
|
|
||||||
|
if (!TryParseStatus(canonical, out status))
|
||||||
|
{
|
||||||
|
why = "'" + canonical + "' is not one of " + String.Join(", ", SeasonalValues);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The setter fires `OnStatusChange()`, which for six of the eight generates or
|
||||||
|
// removes world content. That is ServUO's own behaviour from its own staff gump and
|
||||||
|
// is exactly what makes the toggle worth having; it is noted here so nobody reads
|
||||||
|
// this line as a field assignment.
|
||||||
|
seasonal.Status = status;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var prop = Lookup(entry.Key);
|
||||||
|
|
||||||
|
if (prop == null)
|
||||||
|
{
|
||||||
|
why = "no lease is offered for key '" + entry.Key + "'";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
object obj = Resolve(target, prop, out why);
|
||||||
|
|
||||||
|
if (obj == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var info = Info(obj.GetType(), prop, out why);
|
||||||
|
|
||||||
|
if (info == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
object value;
|
||||||
|
|
||||||
|
if (!Parse(prop, canonical, out value, out why))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
info.SetValue(obj, value, null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- target resolution ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds the object a target names.
|
||||||
|
///
|
||||||
|
/// **Two ways to name one, and both are needed.** A serial is what `[props` shows a GM
|
||||||
|
/// and what a rig can type; an `XmlSpawner.UniqueId` is what the shard's own
|
||||||
|
/// `Spawns/*.xml` carry, which is the only naming the website can offer from the atlas
|
||||||
|
/// without the shard being up. A dropdown built from serials is impossible — they are
|
||||||
|
/// assigned when the world is built, and nothing off-shard knows them.
|
||||||
|
///
|
||||||
|
/// The UniqueId lookup is a scan of `World.Items`, and it stays a scan on purpose: it
|
||||||
|
/// runs once per lease apply, which is a rare, human-scheduled operation, and a cache
|
||||||
|
/// would be a second copy of the world to keep correct across `[add` and deletion.
|
||||||
|
/// </summary>
|
||||||
|
private static object Resolve(string target, PropEntry prop, out string why)
|
||||||
|
{
|
||||||
|
why = null;
|
||||||
|
|
||||||
|
if (String.IsNullOrEmpty(target))
|
||||||
|
{
|
||||||
|
why = "this lease needs a target";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Item item = null;
|
||||||
|
int serial;
|
||||||
|
|
||||||
|
if (TryParseSerial(target, out serial))
|
||||||
|
{
|
||||||
|
item = World.FindItem((Serial)serial);
|
||||||
|
|
||||||
|
if (item == null)
|
||||||
|
{
|
||||||
|
why = "nothing on this shard has serial " + target;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
item = World.Items.Values
|
||||||
|
.OfType<XmlSpawner>()
|
||||||
|
.FirstOrDefault(s => String.Equals(s.UniqueId, target, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (item == null)
|
||||||
|
{
|
||||||
|
why = "no spawner on this shard carries the id '" + target + "'";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Deleted)
|
||||||
|
{
|
||||||
|
why = "that object has been deleted";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// **The allowlist is checked against the object's OWN type, not against what was
|
||||||
|
// asked for.** This is the sentence the whole plane rests on: a serial is a number a
|
||||||
|
// caller chooses, so the only thing standing between `Spawner.MaxCount` and any item on
|
||||||
|
// the shard is this check.
|
||||||
|
var name = item.GetType().FullName;
|
||||||
|
var ok = false;
|
||||||
|
|
||||||
|
for (int i = 0; i < prop.Types.Length && !ok; i++)
|
||||||
|
{
|
||||||
|
// Assignable rather than equal, so a shard's own subclass of Spawner is leasable —
|
||||||
|
// an operator who derived from it has not changed what `MaxCount` means.
|
||||||
|
var declared = ScriptCompiler.FindTypeByFullName(prop.Types[i]);
|
||||||
|
ok = declared != null && declared.IsInstanceOfType(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
why = String.Format("{0} is a {1}, and this lease applies to {2}",
|
||||||
|
target, name, String.Join(" or ", prop.Types));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SeasonalEventEntry SeasonalEntry(string target, out string why)
|
||||||
|
{
|
||||||
|
why = null;
|
||||||
|
|
||||||
|
if (String.IsNullOrEmpty(target))
|
||||||
|
{
|
||||||
|
why = "this lease needs a target";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
EventType type;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
type = (EventType)Enum.Parse(typeof(EventType), target, true);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
why = "'" + target + "' is not a seasonal event on this shard";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.IndexOf(SeasonalExcluded, type) >= 0)
|
||||||
|
{
|
||||||
|
why = target + " reads its own era rather than this status, so leasing it would do nothing";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = SeasonalEventSystem.GetEntry(type);
|
||||||
|
|
||||||
|
if (entry == null)
|
||||||
|
{
|
||||||
|
why = "this shard has no entry for " + target;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the boot self-check (EVENTS.md N10) ----
|
||||||
|
|
||||||
|
private static readonly HashSet<string> _dropped = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drops any targeted key that cannot possibly work, and says so on the console.
|
||||||
|
///
|
||||||
|
/// **It cannot be the config plane's check, and that is a property of the thing rather
|
||||||
|
/// than a shortcut.** A config key is probed by writing to it and reading it back,
|
||||||
|
/// because there is exactly one of it. A property has thousands of instances and no
|
||||||
|
/// canonical one; probing would mean picking somebody's spawner at boot and writing to
|
||||||
|
/// it. So what is verified here is everything that can be verified without touching the
|
||||||
|
/// world: the type still resolves, the property still exists on it, it is still public
|
||||||
|
/// and settable, it still carries `CommandProperty`, and its CLR type is still the one
|
||||||
|
/// this file knows how to render. That is precisely the failure N10 was written for — a
|
||||||
|
/// property that a later ServUO renamed or made read-only — and it catches it at boot
|
||||||
|
/// rather than at 3am inside an unattended run.
|
||||||
|
/// </summary>
|
||||||
|
public static void SelfCheck()
|
||||||
|
{
|
||||||
|
_dropped.Clear();
|
||||||
|
|
||||||
|
for (int i = 0; i < Props.Length; i++)
|
||||||
|
{
|
||||||
|
var prop = Props[i];
|
||||||
|
string why;
|
||||||
|
|
||||||
|
if (Verify(prop, out why))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
_dropped.Add(prop.Key);
|
||||||
|
Console.WriteLine("[Bridge] lease {0}: DROPPED from the catalog -- {1}", prop.Key, why);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The seasonal plane's own check is the one thing it can verify without writing: that
|
||||||
|
// this shard has entries at all. `SeasonalEventSystem.LoadEntries()` runs in
|
||||||
|
// `Configure()`, so an empty list here means an operator has removed the system rather
|
||||||
|
// than that the check ran too early.
|
||||||
|
if (!SeasonalTargets().Any())
|
||||||
|
{
|
||||||
|
_dropped.Add(SeasonalKey);
|
||||||
|
Console.WriteLine("[Bridge] lease {0}: DROPPED from the catalog -- this shard has no seasonal events", SeasonalKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Verify(PropEntry prop, out string why)
|
||||||
|
{
|
||||||
|
why = null;
|
||||||
|
var found = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < prop.Types.Length; i++)
|
||||||
|
{
|
||||||
|
var type = ScriptCompiler.FindTypeByFullName(prop.Types[i]);
|
||||||
|
|
||||||
|
if (type == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
string detail;
|
||||||
|
var info = Info(type, prop, out detail);
|
||||||
|
|
||||||
|
if (info == null)
|
||||||
|
{
|
||||||
|
why = prop.Types[i] + ": " + detail;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
found++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (found == 0)
|
||||||
|
{
|
||||||
|
why = "none of " + String.Join(", ", prop.Types) + " exists on this shard";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The property, if it is one this plane may touch. Null with a reason otherwise.
|
||||||
|
///
|
||||||
|
/// `CommandProperty` is required and is not decoration: it is ServUO's own marker for
|
||||||
|
/// "a staff member may set this", so requiring it means this plane can never reach
|
||||||
|
/// further into an object than `[set` could — which is the bound §G draws, kept even
|
||||||
|
/// though the allowlist already makes it unreachable.
|
||||||
|
/// </summary>
|
||||||
|
private static PropertyInfo Info(Type type, PropEntry prop, out string why)
|
||||||
|
{
|
||||||
|
why = null;
|
||||||
|
|
||||||
|
var info = type.GetProperty(prop.Property, BindingFlags.Public | BindingFlags.Instance);
|
||||||
|
|
||||||
|
if (info == null)
|
||||||
|
{
|
||||||
|
why = "no property named " + prop.Property;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!info.CanRead || !info.CanWrite)
|
||||||
|
{
|
||||||
|
why = prop.Property + " is not both readable and writable";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.GetCustomAttributes(typeof(CommandPropertyAttribute), true).Length == 0)
|
||||||
|
{
|
||||||
|
why = prop.Property + " is not a CommandProperty";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Matches(prop, info.PropertyType))
|
||||||
|
{
|
||||||
|
why = prop.Property + " is a " + info.PropertyType.Name + ", which this lease cannot carry";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Matches(PropEntry prop, Type clr)
|
||||||
|
{
|
||||||
|
if (prop.Seconds)
|
||||||
|
return clr == typeof(TimeSpan);
|
||||||
|
|
||||||
|
switch (prop.Type)
|
||||||
|
{
|
||||||
|
case BridgeLeases.LeaseType.Int: return clr == typeof(int);
|
||||||
|
case BridgeLeases.LeaseType.Float: return clr == typeof(double);
|
||||||
|
case BridgeLeases.LeaseType.Bool: return clr == typeof(bool);
|
||||||
|
default: return clr == typeof(string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- value rendering ----
|
||||||
|
|
||||||
|
private static string Render(PropEntry prop, object raw)
|
||||||
|
{
|
||||||
|
if (raw == null)
|
||||||
|
return "";
|
||||||
|
|
||||||
|
if (prop.Seconds)
|
||||||
|
return ((long)((TimeSpan)raw).TotalSeconds).ToString(CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
switch (prop.Type)
|
||||||
|
{
|
||||||
|
case BridgeLeases.LeaseType.Int:
|
||||||
|
return Convert.ToInt64(raw, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
case BridgeLeases.LeaseType.Float:
|
||||||
|
return Convert.ToDouble(raw, CultureInfo.InvariantCulture).ToString("R", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
case BridgeLeases.LeaseType.Bool:
|
||||||
|
return ((bool)raw) ? "true" : "false";
|
||||||
|
|
||||||
|
default:
|
||||||
|
return Convert.ToString(raw, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Parse(PropEntry prop, string canonical, out object value, out string why)
|
||||||
|
{
|
||||||
|
value = null;
|
||||||
|
why = null;
|
||||||
|
|
||||||
|
if (prop.Seconds)
|
||||||
|
{
|
||||||
|
double seconds;
|
||||||
|
|
||||||
|
if (!Double.TryParse(canonical, NumberStyles.Float, CultureInfo.InvariantCulture, out seconds))
|
||||||
|
{
|
||||||
|
why = "'" + canonical + "' is not a number of seconds";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = TimeSpan.FromSeconds(seconds);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (prop.Type)
|
||||||
|
{
|
||||||
|
case BridgeLeases.LeaseType.Int:
|
||||||
|
{
|
||||||
|
double n;
|
||||||
|
|
||||||
|
if (!Double.TryParse(canonical, NumberStyles.Float, CultureInfo.InvariantCulture, out n))
|
||||||
|
{
|
||||||
|
why = "'" + canonical + "' is not a number";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = (int)n;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case BridgeLeases.LeaseType.Bool:
|
||||||
|
{
|
||||||
|
value = String.Equals(canonical, "true", StringComparison.OrdinalIgnoreCase) || canonical == "1";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
{
|
||||||
|
double d;
|
||||||
|
|
||||||
|
if (!Double.TryParse(canonical, NumberStyles.Float, CultureInfo.InvariantCulture, out d))
|
||||||
|
{
|
||||||
|
why = "'" + canonical + "' is not a number";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = d;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private static PropEntry Lookup(string key)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < Props.Length; i++)
|
||||||
|
{
|
||||||
|
if (String.Equals(Props[i].Key, key, StringComparison.Ordinal))
|
||||||
|
return Props[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseSerial(string raw, out int serial)
|
||||||
|
{
|
||||||
|
serial = 0;
|
||||||
|
|
||||||
|
if (String.IsNullOrEmpty(raw))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Int32.TryParse(raw.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out serial);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bare decimal is a serial too, but a UniqueId is a GUID and never all digits, so
|
||||||
|
// there is nothing to disambiguate.
|
||||||
|
return Int32.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out serial);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseStatus(string raw, out EventStatus status)
|
||||||
|
{
|
||||||
|
status = EventStatus.Inactive;
|
||||||
|
|
||||||
|
for (int i = 0; i < SeasonalValues.Length; i++)
|
||||||
|
{
|
||||||
|
if (!String.Equals(SeasonalValues[i], raw, StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
status = (EventStatus)Enum.Parse(typeof(EventStatus), SeasonalValues[i], false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1405
overlay/Scripts/Custom/Bridge/BridgeLeases.cs
Normal file
1405
overlay/Scripts/Custom/Bridge/BridgeLeases.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -107,7 +107,17 @@ namespace Server.Custom.Bridge
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static void Emit(string line)
|
public static void Emit(string line)
|
||||||
{
|
{
|
||||||
if (!_running || line == null)
|
if (line == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Protocol 6. While a keyed command's handler runs — Core thread, one at a time — every
|
||||||
|
// line it emits is offered to the recent-key store so the correlated reply can be
|
||||||
|
// replayed to a retry later. Deliberately BEFORE the `_running` check: a reply the link
|
||||||
|
// was too dead to deliver is precisely the one a retry will come back for.
|
||||||
|
if (BridgeIdempotency.Capturing)
|
||||||
|
BridgeIdempotency.Observe(line);
|
||||||
|
|
||||||
|
if (!_running)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Drop-oldest. Bound first, then enqueue, so the queue can transiently sit one over
|
// Drop-oldest. Bound first, then enqueue, so the queue can transiently sit one over
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
|
using Server.Accounting;
|
||||||
using Server.Items;
|
using Server.Items;
|
||||||
using Server.Mobiles;
|
using Server.Mobiles;
|
||||||
using Server.Multis;
|
using Server.Multis;
|
||||||
@@ -455,6 +456,20 @@ namespace Server.Custom.Bridge
|
|||||||
sb.Append(vendor.Map == null ? "" : vendor.Map.Name).Append('|');
|
sb.Append(vendor.Map == null ? "" : vendor.Map.Name).Append('|');
|
||||||
sb.Append(vendor.X).Append(',').Append(vendor.Y).Append('|');
|
sb.Append(vendor.X).Append(',').Append(vendor.Y).Append('|');
|
||||||
|
|
||||||
|
// The FEE STATE, and it belongs here for a reason found on a live rig: a vendor
|
||||||
|
// quietly running out of gold changes none of the fields above, so without this the
|
||||||
|
// sweep sees no change, emits nothing, and `uo.vendor.expiring` -- the warning whose
|
||||||
|
// entire subject is a vendor running out of gold -- can only fire by coincidence,
|
||||||
|
// when somebody happens to reprice an item on a shop that is already broke.
|
||||||
|
//
|
||||||
|
// The DERIVED values, not the raw ones. `periodsRemaining` is an integer division, so
|
||||||
|
// it moves only when the shard's own answer to "is this vendor in danger" moves --
|
||||||
|
// near-zero extra frame volume -- while `HoldGold` changes on every sale and
|
||||||
|
// `NextPayTime` on every tick, which would re-emit a fat listing frame for a shop
|
||||||
|
// whose listings did not change. Protocol-neutral: the fields already ship in
|
||||||
|
// `AppendFees`, and this changes only WHEN a frame is sent.
|
||||||
|
AppendFeeSignature(sb, vendor);
|
||||||
|
|
||||||
var limit = Math.Min(_items.Count, BridgeConfig.MarketMaxListings);
|
var limit = Math.Min(_items.Count, BridgeConfig.MarketMaxListings);
|
||||||
|
|
||||||
sb.Append(_items.Count).Append('|');
|
sb.Append(_items.Count).Append('|');
|
||||||
@@ -506,8 +521,16 @@ namespace Server.Custom.Bridge
|
|||||||
{
|
{
|
||||||
sb.Ser("ownerSerial", owner.Serial);
|
sb.Ser("ownerSerial", owner.Serial);
|
||||||
sb.Str("ownerName", owner.Name);
|
sb.Str("ownerName", owner.Name);
|
||||||
|
|
||||||
|
// Protocol 5. Without this the listing names an owner the website cannot resolve to
|
||||||
|
// a person: ownerName is a character name, and only the account is the link key.
|
||||||
|
var acct = owner.Account as Account;
|
||||||
|
if (acct != null)
|
||||||
|
sb.Str("ownerAcct", acct.Username);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppendFees(sb, vendor);
|
||||||
|
|
||||||
sb.Append(",\"location\":{\"map\":");
|
sb.Append(",\"location\":{\"map\":");
|
||||||
Text(sb, vendor.Map == null ? null : vendor.Map.Name);
|
Text(sb, vendor.Map == null ? null : vendor.Map.Name);
|
||||||
sb.Append(",\"x\":").Append(vendor.X);
|
sb.Append(",\"x\":").Append(vendor.X);
|
||||||
@@ -587,5 +610,96 @@ namespace Server.Custom.Bridge
|
|||||||
|
|
||||||
return sb.End();
|
return sb.End();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Protocol 5. The vendor's fee state, which is what makes "your vendor is about to be
|
||||||
|
/// dismissed" a thing the website can say BEFORE it happens instead of after.
|
||||||
|
///
|
||||||
|
/// The dismissal rule is PlayerVendor.PayTimer.OnTick: at every tick the charge is
|
||||||
|
/// compared with the funds, and `if (pay > totalGold) Destroy()`. Both halves of that
|
||||||
|
/// comparison differ between ServUO's two vendor systems, so both are resolved here
|
||||||
|
/// rather than left for the sidecar or the website to guess at:
|
||||||
|
///
|
||||||
|
/// | charge | funds | interval
|
||||||
|
/// NewVendorSystem | ChargePerRealWorldDay | HoldGold | 1 real day
|
||||||
|
/// old system | ChargePerDay | BankAccount + HoldGold | 1 UO day
|
||||||
|
///
|
||||||
|
/// Two consequences worth stating, because both are easy to get wrong downstream:
|
||||||
|
///
|
||||||
|
/// * A field called `daysRemaining` would be WRONG on an old-system shard, where a pay
|
||||||
|
/// period is a UO day (Clock.MinutesPerUODay, roughly two real hours) rather than a
|
||||||
|
/// real one. So this emits `periodsRemaining` plus the interval that gives it meaning,
|
||||||
|
/// and resolves the arithmetic into `dismissalAt` -- an instant, which needs no units.
|
||||||
|
/// * A commission vendor (IsCommission) has no PayTimer at all and is never dismissed
|
||||||
|
/// for fees. It reports exempt:true and no schedule, rather than a misleading
|
||||||
|
/// "infinite days".
|
||||||
|
///
|
||||||
|
/// `dismissalAt` assumes no further sales or deposits, exactly as a bank balance
|
||||||
|
/// projection does. Unlike a dynamic-decay house, though, there is no randomness in it:
|
||||||
|
/// given the current funds it is the exact tick the vendor is destroyed on.
|
||||||
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// The fee state as the change-detector sees it: exempt, and how many pay ticks the
|
||||||
|
/// vendor survives. Kept beside `AppendFees` so the two cannot drift -- a fee field
|
||||||
|
/// that becomes decision-relevant has to be added in both places, and this comment is
|
||||||
|
/// where the next person is told so.
|
||||||
|
/// </summary>
|
||||||
|
private static void AppendFeeSignature(StringBuilder sb, PlayerVendor vendor)
|
||||||
|
{
|
||||||
|
if (vendor == null || vendor.IsCommission)
|
||||||
|
{
|
||||||
|
sb.Append("exempt|");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int charge = BaseHouse.NewVendorSystem ? vendor.ChargePerRealWorldDay : vendor.ChargePerDay;
|
||||||
|
int funds = BaseHouse.NewVendorSystem ? vendor.HoldGold : vendor.BankAccount + vendor.HoldGold;
|
||||||
|
|
||||||
|
// Mirrors AppendFees: a free vendor never runs out, and reports no periods at all.
|
||||||
|
sb.Append(charge > 0 ? (funds / charge).ToString() : "free").Append('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendFees(StringBuilder sb, PlayerVendor vendor)
|
||||||
|
{
|
||||||
|
sb.Append(",\"fees\":{");
|
||||||
|
|
||||||
|
if (vendor.IsCommission)
|
||||||
|
{
|
||||||
|
sb.Append("\"exempt\":true}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool newSystem = BaseHouse.NewVendorSystem;
|
||||||
|
|
||||||
|
int charge = newSystem ? vendor.ChargePerRealWorldDay : vendor.ChargePerDay;
|
||||||
|
int funds = newSystem ? vendor.HoldGold : vendor.BankAccount + vendor.HoldGold;
|
||||||
|
|
||||||
|
sb.Append("\"exempt\":false");
|
||||||
|
sb.Append(",\"newVendorSystem\":").Append(newSystem ? "true" : "false");
|
||||||
|
sb.Append(",\"chargePerPeriod\":").Append(charge);
|
||||||
|
sb.Append(",\"funds\":").Append(funds);
|
||||||
|
sb.Append(",\"holdGold\":").Append(vendor.HoldGold);
|
||||||
|
sb.Append(",\"bankAccount\":").Append(vendor.BankAccount);
|
||||||
|
|
||||||
|
var interval = newSystem ? TimeSpan.FromDays(1.0) : TimeSpan.FromMinutes(Clock.MinutesPerUODay);
|
||||||
|
sb.Append(",\"payIntervalSec\":").Append((long)interval.TotalSeconds);
|
||||||
|
|
||||||
|
var nextPay = vendor.NextPayTime.ToUniversalTime();
|
||||||
|
sb.Append(",\"nextPayAt\":");
|
||||||
|
Text(sb, nextPay.ToString("o"));
|
||||||
|
|
||||||
|
// A free vendor (no priced stock under the old system can reach charge 0) never runs out.
|
||||||
|
if (charge > 0)
|
||||||
|
{
|
||||||
|
// Ticks it survives before the one that finds pay > totalGold.
|
||||||
|
long periods = funds / charge;
|
||||||
|
sb.Append(",\"periodsRemaining\":").Append(periods);
|
||||||
|
|
||||||
|
sb.Append(",\"dismissalAt\":");
|
||||||
|
Text(sb, nextPay.AddSeconds(periods * interval.TotalSeconds).ToString("o"));
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append('}');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
459
overlay/Scripts/Custom/Bridge/BridgeOneShots.cs
Normal file
459
overlay/Scripts/Custom/Bridge/BridgeOneShots.cs
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
using Server.Items;
|
||||||
|
using Server.Misc;
|
||||||
|
using Server.Mobiles;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Protocol 7, part b. The two verbs that are neither owned nor borrowed: an item put into
|
||||||
|
/// someone's hands, and a world save.
|
||||||
|
///
|
||||||
|
/// EVENTS_PLAN.md Phase 12b. Everything else the event plane does is a thing this shard can
|
||||||
|
/// take back — a creature it deletes, a value it restores. These two are not, and they are
|
||||||
|
/// in the same file because that is what they have in common: <b>done is done</b>.
|
||||||
|
///
|
||||||
|
/// ── The grant, and why §8's exclusion of it was reopened separately ────────────────────
|
||||||
|
///
|
||||||
|
/// `ADMIN_CONTROLS.md` §8 cut item grants along with world creation, and §N1 reopened both —
|
||||||
|
/// deliberately as two reversals rather than one, because permitting an event to create a
|
||||||
|
/// creature says nothing about permitting it to hand out loot. What makes this grant a
|
||||||
|
/// different proposition from the one §8 refused is four properties it did not have then,
|
||||||
|
/// and all four are visible in this file:
|
||||||
|
///
|
||||||
|
/// - **Declared, not typed.** The allowlist below is the shard's, and an item not on it
|
||||||
|
/// cannot be granted however the request is spelled. There is no free-text type name
|
||||||
|
/// reaching `Activator.CreateInstance` — that is `[add`, which §G excludes.
|
||||||
|
/// - **Bounded.** `EventsMaxGrantPerRun` bounds the whole run and
|
||||||
|
/// `EventsMaxGrantStack` bounds one hand; both refuse rather than clamp.
|
||||||
|
/// - **Attributable.** The run id rides on every grant and is logged with it.
|
||||||
|
/// - **Idempotent.** Protocol 6's key means a lost acknowledgement cannot double a
|
||||||
|
/// reward, which is the failure that made §G call the grant un-retryable when it was
|
||||||
|
/// written. It is retryable now, and 11a is the whole reason.
|
||||||
|
///
|
||||||
|
/// ── Who receives it is answered HERE, and that is the interesting decision ─────────────
|
||||||
|
///
|
||||||
|
/// A grant needs a list of people, and the website has one — `event_run_participants`. It
|
||||||
|
/// would have had to reach through core to get it, because a module cannot read core's
|
||||||
|
/// tables, so the alternative was a new core surface handing participants to a module's
|
||||||
|
/// `perform()`.
|
||||||
|
///
|
||||||
|
/// It is not needed: **this shard already has the list**, in 11b's run-scoped participation
|
||||||
|
/// ledger, keyed by the same character serials the website's `member_key` holds. So the
|
||||||
|
/// grant names a run and the recipients are resolved from the ledger the run has been
|
||||||
|
/// keeping all along — no new core surface, no participant list crossing the wire twice,
|
||||||
|
/// and no window in which the two disagree.
|
||||||
|
///
|
||||||
|
/// A run with no open ledger grants to nobody and says so, rather than granting to
|
||||||
|
/// everybody online. "Everyone present" is not a thing this file will guess at.
|
||||||
|
///
|
||||||
|
/// ── The save ──────────────────────────────────────────────────────────────────────────
|
||||||
|
///
|
||||||
|
/// `ADMIN_CONTROLS.md` §3.6 catalogued it Tier B and it was never built. It is useful as a
|
||||||
|
/// phase boundary — the point in an event after which what has happened is safe from a
|
||||||
|
/// crash — and `world.save.before` / `world.save.after` are already on the wire, so the
|
||||||
|
/// acknowledgement it needs exists.
|
||||||
|
///
|
||||||
|
/// **A save stops the world**, so unlike every other verb here it is rate-limited by the
|
||||||
|
/// shard rather than only capped: `EventsMinSaveIntervalSec` refuses a save that comes too
|
||||||
|
/// soon after the last one, whether the last one was an event's or ServUO's own autosave.
|
||||||
|
/// Refuses, never queues — a queued save would arrive at a moment nobody chose.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeOneShots
|
||||||
|
{
|
||||||
|
// ---- the grant allowlist ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One grantable item: what an author names it, and what this shard builds.
|
||||||
|
///
|
||||||
|
/// **The list is short and boring on purpose.** Every entry is a thing an event
|
||||||
|
/// plausibly hands out and nothing here is equipment with rolled properties — an
|
||||||
|
/// artifact generator behind an unattended schedule is a different proposition and one
|
||||||
|
/// nobody has asked for. An operator who wants more edits this array, which is a
|
||||||
|
/// deployment they control rather than a field on a web form.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class GrantEntry
|
||||||
|
{
|
||||||
|
public string Key;
|
||||||
|
public string Label;
|
||||||
|
public string Type;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly GrantEntry[] Grants =
|
||||||
|
{
|
||||||
|
new GrantEntry { Key = "gold", Label = "Gold", Type = "Server.Items.Gold" },
|
||||||
|
new GrantEntry { Key = "cloak", Label = "Cloak", Type = "Server.Items.Cloak" },
|
||||||
|
new GrantEntry { Key = "sandals", Label = "Sandals", Type = "Server.Items.Sandals" },
|
||||||
|
new GrantEntry { Key = "candle", Label = "Candle", Type = "Server.Items.Candle" },
|
||||||
|
new GrantEntry { Key = "earrings", Label = "Silver earrings", Type = "Server.Items.SilverEarrings" },
|
||||||
|
new GrantEntry { Key = "fireworks", Label = "Fireworks wand", Type = "Server.Items.FireworksWand" },
|
||||||
|
new GrantEntry { Key = "bottle", Label = "Message in a bottle", Type = "Server.Items.MessageInABottle" },
|
||||||
|
};
|
||||||
|
|
||||||
|
private static long _granted, _saves, _refused;
|
||||||
|
|
||||||
|
private static long _lastSaveMs;
|
||||||
|
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
if (!BridgeConfig.Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
BridgeBoot.RegisterHandler("item.grant", OnGrant);
|
||||||
|
BridgeBoot.RegisterHandler("item.catalog", OnCatalog);
|
||||||
|
BridgeBoot.RegisterHandler("world.save", OnSave);
|
||||||
|
|
||||||
|
// Counted whoever asked for it, so the interval below also covers ServUO's own
|
||||||
|
// autosave. An event save landing thirty seconds after the hourly one is the same
|
||||||
|
// freeze twice, and the shard is the only half that can see both.
|
||||||
|
EventSink.WorldSave += (e) => { _lastSaveMs = BridgeJson.NowMs(); };
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Status()
|
||||||
|
{
|
||||||
|
return String.Format("oneshots(granted={0} saves={1} refused={2})", _granted, _saves, _refused);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- item.catalog ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What this shard is willing to grant.
|
||||||
|
///
|
||||||
|
/// A read, so the website's option source can offer real choices — and the module holds
|
||||||
|
/// the same list, so the dropdown still works with the shard down. Two copies of a
|
||||||
|
/// short allowlist, exactly like the lease bounds: the module's is what makes a bad
|
||||||
|
/// value a refusal on a form, and this one is what is true when the website is wrong.
|
||||||
|
/// </summary>
|
||||||
|
private static void OnCatalog(Dictionary<string, object> o)
|
||||||
|
{
|
||||||
|
var reqId = BridgeJson.GetString(o, "reqId");
|
||||||
|
|
||||||
|
if (!Ready(reqId, "catalog"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("item.catalog.ok");
|
||||||
|
if (reqId != null) sb.Str("reqId", reqId);
|
||||||
|
|
||||||
|
sb.Append(",\"items\":[");
|
||||||
|
|
||||||
|
for (int i = 0; i < Grants.Length; i++)
|
||||||
|
{
|
||||||
|
if (i > 0)
|
||||||
|
sb.Append(',');
|
||||||
|
|
||||||
|
sb.Append("{\"key\":");
|
||||||
|
BridgeJson.Text(sb, Grants[i].Key);
|
||||||
|
sb.Append(",\"label\":");
|
||||||
|
BridgeJson.Text(sb, Grants[i].Label);
|
||||||
|
sb.Append(",\"stackable\":").Append(Stackable(Grants[i]) ? "true" : "false");
|
||||||
|
sb.Append('}');
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(']');
|
||||||
|
sb.Append(",\"maxPerRun\":").Append(BridgeConfig.EventsMaxGrantPerRun);
|
||||||
|
sb.Append(",\"maxStack\":").Append(BridgeConfig.EventsMaxGrantStack);
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- item.grant ----
|
||||||
|
|
||||||
|
private static void OnGrant(Dictionary<string, object> o)
|
||||||
|
{
|
||||||
|
var reqId = BridgeJson.GetString(o, "reqId");
|
||||||
|
|
||||||
|
if (!Ready(reqId, "grant"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var runId = BridgeJson.GetString(o, "runId");
|
||||||
|
|
||||||
|
if (String.IsNullOrEmpty(runId))
|
||||||
|
{
|
||||||
|
Err(reqId, "grant", "a grant needs a runId");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = LookupGrant(BridgeJson.GetString(o, "item"));
|
||||||
|
|
||||||
|
if (entry == null)
|
||||||
|
{
|
||||||
|
Err(reqId, "grant", "this shard does not grant '" + BridgeJson.GetString(o, "item") + "'");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var amount = (int)BridgeJson.GetLong(o, "amount", 1L);
|
||||||
|
|
||||||
|
if (amount < 1)
|
||||||
|
{
|
||||||
|
Err(reqId, "grant", "a grant needs a positive amount");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refused rather than clamped, on `LeaseMaxDurationSec`'s argument from 11b: the
|
||||||
|
// website is the half that records what was handed out, and a silent clamp would make
|
||||||
|
// its ledger a description of a grant that did not happen.
|
||||||
|
if (amount > BridgeConfig.EventsMaxGrantStack)
|
||||||
|
{
|
||||||
|
Err(reqId, "grant", String.Format(CultureInfo.InvariantCulture,
|
||||||
|
"this shard grants at most {0} at a time, and {1} were asked for",
|
||||||
|
BridgeConfig.EventsMaxGrantStack, amount));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var toBank = String.Equals(BridgeJson.GetString(o, "where"), "bank", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var serials = BridgeParticipation.MemberSerials(runId);
|
||||||
|
|
||||||
|
if (serials == null)
|
||||||
|
{
|
||||||
|
Err(reqId, "grant", "run " + runId + " has no participation ledger open on this shard");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serials.Count == 0)
|
||||||
|
{
|
||||||
|
// Not a refusal: a run whose event nobody attended is a real outcome, and the
|
||||||
|
// website needs to record a grant that reached nobody rather than a failed step it
|
||||||
|
// will retry against the same empty ledger.
|
||||||
|
var none = BridgeJson.Begin("item.grant.ok");
|
||||||
|
if (reqId != null) none.Str("reqId", reqId);
|
||||||
|
none.Str("runId", runId).Str("item", entry.Key);
|
||||||
|
none.Append(",\"granted\":0,\"missed\":[]");
|
||||||
|
BridgeLink.Emit(none.End());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serials.Count > BridgeConfig.EventsMaxGrantPerRun)
|
||||||
|
{
|
||||||
|
Err(reqId, "grant", String.Format(CultureInfo.InvariantCulture,
|
||||||
|
"that run has {0} participants and this shard grants to at most {1}",
|
||||||
|
serials.Count, BridgeConfig.EventsMaxGrantPerRun));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hue = (int)BridgeJson.GetLong(o, "hue", 0L);
|
||||||
|
var name = BridgeJson.GetString(o, "name");
|
||||||
|
|
||||||
|
if (name != null && name.Length > 40)
|
||||||
|
name = name.Substring(0, 40);
|
||||||
|
|
||||||
|
var granted = 0;
|
||||||
|
var missed = new List<string>();
|
||||||
|
|
||||||
|
for (int i = 0; i < serials.Count; i++)
|
||||||
|
{
|
||||||
|
var mobile = World.FindMobile((Serial)serials[i]) as PlayerMobile;
|
||||||
|
|
||||||
|
if (mobile == null || mobile.Deleted)
|
||||||
|
{
|
||||||
|
missed.Add(Hex(serials[i]) + ": no such character");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string why;
|
||||||
|
|
||||||
|
if (Give(mobile, entry, amount, hue, name, toBank, out why))
|
||||||
|
granted++;
|
||||||
|
else
|
||||||
|
missed.Add(Hex(serials[i]) + ": " + why);
|
||||||
|
}
|
||||||
|
|
||||||
|
_granted += granted;
|
||||||
|
|
||||||
|
Console.WriteLine("[Bridge] grant {0} x{1} to run {2}: {3} of {4}",
|
||||||
|
entry.Key, amount, runId, granted, serials.Count);
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("item.grant.ok");
|
||||||
|
if (reqId != null) sb.Str("reqId", reqId);
|
||||||
|
sb.Str("runId", runId).Str("item", entry.Key);
|
||||||
|
sb.Append(",\"granted\":").Append(granted);
|
||||||
|
sb.Append(",\"missed\":[");
|
||||||
|
|
||||||
|
for (int i = 0; i < missed.Count; i++)
|
||||||
|
{
|
||||||
|
if (i > 0) sb.Append(',');
|
||||||
|
BridgeJson.Text(sb, missed[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(']');
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds and hands over one grant, or says why it could not.
|
||||||
|
///
|
||||||
|
/// **A grant that cannot be delivered is deleted rather than dropped on the floor.**
|
||||||
|
/// `AddItem` failing on a full backpack would otherwise leave the item in the world at
|
||||||
|
/// (0,0) — a real ServUO trap — and an event that quietly littered the map with
|
||||||
|
/// undeliverable rewards would be worse than one that reported a miss.
|
||||||
|
/// </summary>
|
||||||
|
private static bool Give(PlayerMobile mobile, GrantEntry entry, int amount, int hue, string name, bool toBank, out string why)
|
||||||
|
{
|
||||||
|
why = null;
|
||||||
|
|
||||||
|
var container = toBank ? (Container)mobile.BankBox : mobile.Backpack;
|
||||||
|
|
||||||
|
if (container == null || container.Deleted)
|
||||||
|
{
|
||||||
|
why = toBank ? "no bank box" : "no backpack";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Item item;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
item = Build(entry);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
why = "could not be created (" + e.Message + ")";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item == null)
|
||||||
|
{
|
||||||
|
why = "could not be created";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.Stackable)
|
||||||
|
{
|
||||||
|
item.Amount = amount;
|
||||||
|
}
|
||||||
|
else if (amount > 1)
|
||||||
|
{
|
||||||
|
// A non-stackable granted in quantity would be N items, and N items is N chances to
|
||||||
|
// overflow a backpack halfway through with no way to report which half landed.
|
||||||
|
// One is what an event means by "a commemorative cloak" anyway.
|
||||||
|
item.Delete();
|
||||||
|
why = "is not stackable, so it can only be granted one at a time";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hue > 0)
|
||||||
|
item.Hue = hue;
|
||||||
|
|
||||||
|
if (!String.IsNullOrEmpty(name))
|
||||||
|
item.Name = name;
|
||||||
|
|
||||||
|
if (!container.TryDropItem(mobile, item, false))
|
||||||
|
{
|
||||||
|
item.Delete();
|
||||||
|
why = toBank ? "bank box is full" : "backpack is full";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Item Build(GrantEntry entry)
|
||||||
|
{
|
||||||
|
var type = ScriptCompiler.FindTypeByFullName(entry.Type);
|
||||||
|
|
||||||
|
if (type == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return Activator.CreateInstance(type) as Item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Stackable(GrantEntry entry)
|
||||||
|
{
|
||||||
|
Item probe = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
probe = Build(entry);
|
||||||
|
return probe != null && probe.Stackable;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (probe != null)
|
||||||
|
probe.Delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- world.save ----
|
||||||
|
|
||||||
|
private static void OnSave(Dictionary<string, object> o)
|
||||||
|
{
|
||||||
|
var reqId = BridgeJson.GetString(o, "reqId");
|
||||||
|
|
||||||
|
if (!Ready(reqId, "save"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var since = BridgeJson.NowMs() - _lastSaveMs;
|
||||||
|
var minimum = (long)BridgeConfig.EventsMinSaveIntervalSec * 1000L;
|
||||||
|
|
||||||
|
if (_lastSaveMs > 0L && since < minimum)
|
||||||
|
{
|
||||||
|
// **Refused, not queued.** A queued save would land at a moment nobody chose, in the
|
||||||
|
// middle of whatever the next step is doing. Refusing tells the website exactly what
|
||||||
|
// happened, and a save skipped because one just happened has cost nothing.
|
||||||
|
Err(reqId, "save", String.Format(CultureInfo.InvariantCulture,
|
||||||
|
"this shard saves at most every {0} seconds, and the last save was {1} seconds ago",
|
||||||
|
BridgeConfig.EventsMinSaveIntervalSec, since / 1000L));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `world.save.before` and `world.save.after` are emitted by `BridgeEvents` from ServUO's
|
||||||
|
// own hooks, so the acknowledgement of what actually happened rides those rather than
|
||||||
|
// being asserted here. This reply says only that the save was STARTED.
|
||||||
|
_saves++;
|
||||||
|
AutoSave.Save();
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("world.save.ok");
|
||||||
|
if (reqId != null) sb.Str("reqId", reqId);
|
||||||
|
sb.Bool("started", true);
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private static GrantEntry LookupGrant(string key)
|
||||||
|
{
|
||||||
|
if (key == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
for (int i = 0; i < Grants.Length; i++)
|
||||||
|
{
|
||||||
|
if (String.Equals(Grants[i].Key, key, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Grants[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Hex(int serial)
|
||||||
|
{
|
||||||
|
return "0x" + serial.ToString("X", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Ready(string reqId, string action)
|
||||||
|
{
|
||||||
|
if (!BridgeConfig.EventsEnabled)
|
||||||
|
{
|
||||||
|
Err(reqId, action, "the event plane is disabled on this shard (Bridge.EventsEnabled)");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Err(string reqId, string action, string reason)
|
||||||
|
{
|
||||||
|
_refused++;
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("oneshot.error");
|
||||||
|
if (reqId != null) sb.Str("reqId", reqId);
|
||||||
|
sb.Str("action", action).Str("reason", reason);
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
293
overlay/Scripts/Custom/Bridge/BridgeOracle.cs
Normal file
293
overlay/Scripts/Custom/Bridge/BridgeOracle.cs
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
using Server.Items;
|
||||||
|
using Server.Mobiles;
|
||||||
|
using Server.Network;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// An event-owned NPC that answers questions. Protocol 7, EVENTS_PLAN.md Phase 12a.
|
||||||
|
///
|
||||||
|
/// EVENTS.md §G rates this the capability most worth having and gives the reason in one
|
||||||
|
/// line: *"this is literally a web form"*. An oracle is a greeting and a handful of
|
||||||
|
/// "when a player says X, reply Y" rows, and that is a form an event author can fill in
|
||||||
|
/// without knowing anything about Ultima Online — which is more than can be said for
|
||||||
|
/// choosing a spawn point.
|
||||||
|
///
|
||||||
|
/// ── Why this is ours and not `XmlDialog` ───────────────────────────────────────────────
|
||||||
|
///
|
||||||
|
/// ServUO already ships a complete dialogue engine in `XmlSpawner2.XmlDialog`, and its
|
||||||
|
/// `SpeechEntry` is the evidence that the shape below is right rather than invented: `Text`
|
||||||
|
/// plus a comma-separated `Keywords` list, an entry with no keywords being the one that
|
||||||
|
/// fires automatically, a proximity range (`defProximityRange = 3`), a conversation lock so
|
||||||
|
/// two players cannot talk over each other.
|
||||||
|
///
|
||||||
|
/// It is also exactly why this verb must not be built on it. `SpeechEntry` carries an
|
||||||
|
/// `Action` string — XmlSpawner's command-scripting language — and routing authored
|
||||||
|
/// dialogue through XmlDialog would leave an arbitrary-command field one field away from
|
||||||
|
/// an event author on the website. That is the `[set` that §G excludes, arriving through
|
||||||
|
/// the back door, in a subsystem this overlay does not own and an operator can switch off.
|
||||||
|
///
|
||||||
|
/// What the verb actually needs are two native virtuals on `Server.Mobile`:
|
||||||
|
/// `OnMovement`, which is delivered to **every** mobile in range (the `HandlesOnMovement`
|
||||||
|
/// filter applies only to Items — `Server/Mobile.cs:3369` against `:3375`), and
|
||||||
|
/// `HandlesOnSpeech`/`OnSpeech`. Nothing on the wire is executable: keywords and text.
|
||||||
|
///
|
||||||
|
/// ── It cannot be killed, moved or looted ───────────────────────────────────────────────
|
||||||
|
///
|
||||||
|
/// `CanBeDamaged()` is false, as `TownCrier`'s is, and it is `Blessed`, `Frozen` and
|
||||||
|
/// `CantWalk`. An event NPC that a player can drag out of the venue or kill for its robe is
|
||||||
|
/// an event NPC that stops being where the run's ledger says it is, and teardown deleting
|
||||||
|
/// something that has wandered two screens away is a worse outcome than it not moving.
|
||||||
|
/// </summary>
|
||||||
|
public class BridgeOracle : Mobile
|
||||||
|
{
|
||||||
|
/// <summary>One row of the form: what a player has to say, and what it answers.</summary>
|
||||||
|
public sealed class Line
|
||||||
|
{
|
||||||
|
/// <summary>Lower-cased, already trimmed. Matched as substrings of what was said.</summary>
|
||||||
|
public string[] Keywords;
|
||||||
|
|
||||||
|
public string Text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string m_Greeting;
|
||||||
|
private List<Line> m_Lines;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When each player was last spoken to, so an oracle cannot be farmed for spam.
|
||||||
|
///
|
||||||
|
/// Deliberately not serialized. It is a rate limiter, not state anybody is owed across
|
||||||
|
/// a restart, and a restart is exactly the moment a fresh greeting is *correct* — the
|
||||||
|
/// player is arriving at the venue again as far as the world is concerned.
|
||||||
|
/// </summary>
|
||||||
|
private readonly Dictionary<Mobile, DateTime> m_Greeted = new Dictionary<Mobile, DateTime>();
|
||||||
|
|
||||||
|
private readonly Dictionary<Mobile, DateTime> m_Answered = new Dictionary<Mobile, DateTime>();
|
||||||
|
|
||||||
|
[CommandProperty(AccessLevel.GameMaster, true)]
|
||||||
|
public string Greeting { get { return m_Greeting; } set { m_Greeting = value; } }
|
||||||
|
|
||||||
|
public List<Line> Lines
|
||||||
|
{
|
||||||
|
get { return m_Lines ?? (m_Lines = new List<Line>()); }
|
||||||
|
set { m_Lines = value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public BridgeOracle()
|
||||||
|
: this(null, null, 0, false)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public BridgeOracle(string name, string title, int hue, bool female)
|
||||||
|
{
|
||||||
|
Name = String.IsNullOrEmpty(name) ? "the oracle" : name;
|
||||||
|
Title = title;
|
||||||
|
Female = female;
|
||||||
|
Body = female ? 0x191 : 0x190;
|
||||||
|
Hue = hue > 0 ? hue : Utility.RandomSkinHue();
|
||||||
|
|
||||||
|
InitStats(100, 100, 25);
|
||||||
|
|
||||||
|
AddItem(new Robe(Utility.RandomNeutralHue()));
|
||||||
|
AddItem(new Sandals());
|
||||||
|
|
||||||
|
// See the class header. A run's ledger records where this NPC is; letting the world
|
||||||
|
// move it would make that record a lie within a minute of a curious player arriving.
|
||||||
|
Blessed = true;
|
||||||
|
Frozen = true;
|
||||||
|
CantWalk = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BridgeOracle(Serial serial)
|
||||||
|
: base(serial)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool CanBeDamaged()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool ClickTitle { get { return false; } }
|
||||||
|
|
||||||
|
// ---- the greeting ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Greet a player who has just come into range.
|
||||||
|
///
|
||||||
|
/// "Just come into range" rather than "is in range": `OnMovement` fires on every step,
|
||||||
|
/// so greeting on proximity alone would have the oracle shouting at anyone who walked
|
||||||
|
/// past it. The old location is compared as well as the new one, which makes this fire
|
||||||
|
/// once per approach, and the per-player cooldown catches the player who paces the line.
|
||||||
|
/// </summary>
|
||||||
|
public override void OnMovement(Mobile m, Point3D oldLocation)
|
||||||
|
{
|
||||||
|
base.OnMovement(m, oldLocation);
|
||||||
|
|
||||||
|
if (String.IsNullOrEmpty(m_Greeting) || m == this || m.Deleted || Deleted)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!(m is PlayerMobile) || !m.Alive || m.Map != Map)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var range = BridgeConfig.EventsOracleGreetRange;
|
||||||
|
|
||||||
|
if (!InRange(m, range) || InRange(oldLocation, range))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!Recent(m_Greeted, m, BridgeConfig.EventsOracleGreetCooldownSec))
|
||||||
|
return;
|
||||||
|
|
||||||
|
m_Greeted[m] = DateTime.UtcNow;
|
||||||
|
Direction = GetDirectionTo(m);
|
||||||
|
Say(m_Greeting);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the keyword lines ----
|
||||||
|
|
||||||
|
public override bool HandlesOnSpeech(Mobile from)
|
||||||
|
{
|
||||||
|
return m_Lines != null && m_Lines.Count > 0 && from.Alive && from is PlayerMobile &&
|
||||||
|
InRange(from, BridgeConfig.EventsOracleSpeechRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnSpeech(SpeechEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnSpeech(e);
|
||||||
|
|
||||||
|
if (e.Handled || m_Lines == null || m_Lines.Count == 0 || Deleted)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var from = e.Mobile;
|
||||||
|
|
||||||
|
if (from == null || !from.Alive || !(from is PlayerMobile) ||
|
||||||
|
!InRange(from, BridgeConfig.EventsOracleSpeechRange))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var said = (e.Speech ?? "").ToLower(CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
if (said.Length == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var line = Match(said);
|
||||||
|
|
||||||
|
if (line == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// The cooldown is consulted only once something actually matched. Checking it first
|
||||||
|
// would let a player burn their own cooldown on an unrelated sentence and then find
|
||||||
|
// the oracle mute when they finally said the word.
|
||||||
|
if (!Recent(m_Answered, from, BridgeConfig.EventsOracleAnswerCooldownSec))
|
||||||
|
return;
|
||||||
|
|
||||||
|
m_Answered[from] = DateTime.UtcNow;
|
||||||
|
Direction = GetDirectionTo(from);
|
||||||
|
Say(line.Text);
|
||||||
|
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Line Match(string said)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < m_Lines.Count; i++)
|
||||||
|
{
|
||||||
|
var line = m_Lines[i];
|
||||||
|
|
||||||
|
if (line == null || line.Keywords == null || String.IsNullOrEmpty(line.Text))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
for (int j = 0; j < line.Keywords.Length; j++)
|
||||||
|
{
|
||||||
|
var keyword = line.Keywords[j];
|
||||||
|
|
||||||
|
if (!String.IsNullOrEmpty(keyword) && said.IndexOf(keyword, StringComparison.Ordinal) >= 0)
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether enough time has passed to speak to this player again, pruning as it goes.
|
||||||
|
///
|
||||||
|
/// The prune matters: without it a busy venue leaves one dictionary entry per player
|
||||||
|
/// who ever walked past, held by a strong reference to a `Mobile` that may since have
|
||||||
|
/// been deleted, for as long as the NPC exists.
|
||||||
|
/// </summary>
|
||||||
|
private static bool Recent(Dictionary<Mobile, DateTime> seen, Mobile m, int cooldownSec)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var cooldown = TimeSpan.FromSeconds(cooldownSec);
|
||||||
|
|
||||||
|
if (seen.Count > 64)
|
||||||
|
{
|
||||||
|
List<Mobile> stale = null;
|
||||||
|
|
||||||
|
foreach (var pair in seen)
|
||||||
|
{
|
||||||
|
if (pair.Key == null || pair.Key.Deleted || now - pair.Value > cooldown)
|
||||||
|
(stale ?? (stale = new List<Mobile>())).Add(pair.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stale != null)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < stale.Count; i++)
|
||||||
|
seen.Remove(stale[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime last;
|
||||||
|
|
||||||
|
return !seen.TryGetValue(m, out last) || now - last >= cooldown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- persistence ----
|
||||||
|
|
||||||
|
public override void Serialize(GenericWriter writer)
|
||||||
|
{
|
||||||
|
base.Serialize(writer);
|
||||||
|
|
||||||
|
writer.Write(0); // version
|
||||||
|
|
||||||
|
writer.Write(m_Greeting ?? "");
|
||||||
|
|
||||||
|
var lines = m_Lines ?? new List<Line>();
|
||||||
|
writer.Write(lines.Count);
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
writer.Write(String.Join(",", line.Keywords ?? new string[0]));
|
||||||
|
writer.Write(line.Text ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Deserialize(GenericReader reader)
|
||||||
|
{
|
||||||
|
base.Deserialize(reader);
|
||||||
|
|
||||||
|
reader.ReadInt(); // version
|
||||||
|
|
||||||
|
m_Greeting = reader.ReadString();
|
||||||
|
|
||||||
|
var count = reader.ReadInt();
|
||||||
|
m_Lines = new List<Line>(count);
|
||||||
|
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
var keywords = reader.ReadString() ?? "";
|
||||||
|
var text = reader.ReadString();
|
||||||
|
|
||||||
|
m_Lines.Add(new Line
|
||||||
|
{
|
||||||
|
Keywords = keywords.Length == 0 ? new string[0] : keywords.Split(','),
|
||||||
|
Text = text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
968
overlay/Scripts/Custom/Bridge/BridgeParticipation.cs
Normal file
968
overlay/Scripts/Custom/Bridge/BridgeParticipation.cs
Normal file
@@ -0,0 +1,968 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
using Server.Mobiles;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Protocol 6, part b. The run-scoped participation ledger: who took part in an event, and
|
||||||
|
/// how much.
|
||||||
|
///
|
||||||
|
/// EVENTS.md §G rates participation attribution as the largest remaining piece of new UO
|
||||||
|
/// work, and says why nothing composed out of the existing streams can stand in for it:
|
||||||
|
/// `region.enter` plus `mob.killed` is loosely composable and **not trustworthy enough to
|
||||||
|
/// publish results on**. Nothing scopes a kill or an arrival to a run, nothing separates a
|
||||||
|
/// passer-by from an attendee, and nothing survives a relog. Results and a leaderboard on
|
||||||
|
/// top of that would be a table of confident numbers that were not true.
|
||||||
|
///
|
||||||
|
/// So participation is measured here, where the world is, and reported as one opaque number
|
||||||
|
/// per member. **The plugin computes the score; core stores a decimal it never interprets.**
|
||||||
|
/// That split is what keeps the event engine game-agnostic: "one minute present plus five a
|
||||||
|
/// kill" is a sentence about Ultima Online, and the sentence has to live on the Ultima
|
||||||
|
/// Online side of the seam.
|
||||||
|
///
|
||||||
|
/// ── Keyed by character serial ──────────────────────────────────────────────────────────
|
||||||
|
///
|
||||||
|
/// Which matches `module-uo`'s existing Teams `memberKey` (`teamProvider.model.js`), so one
|
||||||
|
/// module speaks one member vocabulary and a participant can be joined to a roster without a
|
||||||
|
/// translation table. A player who attends on two characters is two members, and that is the
|
||||||
|
/// same answer Teams already gives.
|
||||||
|
///
|
||||||
|
/// ── Persisted in the world save, which is a first ──────────────────────────────────────
|
||||||
|
///
|
||||||
|
/// Nothing in this bridge has ever persisted anything. A ledger has to, because a run spans
|
||||||
|
/// hours and a restart mid-event is an ordinary Tuesday: an in-memory tally would silently
|
||||||
|
/// regress every attendee's score to whatever they earned after the restart. The only ways
|
||||||
|
/// to paper over that from the other side are a high-water rule in core — which must stay
|
||||||
|
/// game-agnostic and cannot have one — or a per-run offset in the module, which is the same
|
||||||
|
/// bug with more moving parts.
|
||||||
|
///
|
||||||
|
/// `Server.Persistence` plus `EventSink.WorldSave` writes a companion file beside the world
|
||||||
|
/// save rather than a persistence ITEM. No world object, no serial, nothing for a GM to find
|
||||||
|
/// and delete by accident, and a wipe of custom items leaves the ledger intact.
|
||||||
|
///
|
||||||
|
/// **The save/load hooks are attached unconditionally**, before the enabled gate is
|
||||||
|
/// consulted. An operator who switches the plane off for an afternoon must not come back to
|
||||||
|
/// a truncated file where a run's tally used to be.
|
||||||
|
///
|
||||||
|
/// ── The first handler that defers ──────────────────────────────────────────────────────
|
||||||
|
///
|
||||||
|
/// `participation.snapshot` resolves every member serial to a mobile and an account, so a
|
||||||
|
/// well-attended run is hundreds of world lookups in one inbound call — exactly the kind of
|
||||||
|
/// work the Core thread must not be handed in one piece. Above
|
||||||
|
/// `Bridge.ParticipationSnapshotChunk` members it walks in chunks across ticks.
|
||||||
|
///
|
||||||
|
/// That makes it the first handler in the bridge to complete AFTER its inbound call returns,
|
||||||
|
/// and therefore the first that can genuinely answer `bridge.busy` — protocol 6 built the
|
||||||
|
/// door in 11a with `BridgeIdempotency.Hold`/`Complete` and had nothing to walk through it.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeParticipation
|
||||||
|
{
|
||||||
|
private static readonly string SavePath = Path.Combine("Saves", "Bridge", "Participation.bin");
|
||||||
|
|
||||||
|
private const int SaveVersion = 1;
|
||||||
|
|
||||||
|
/// <summary>One character's part in one run.</summary>
|
||||||
|
private sealed class Member
|
||||||
|
{
|
||||||
|
public int Serial;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Last seen name, kept only so the console and the snapshot can say something
|
||||||
|
/// useful about a character that has since been deleted. The website resolves its
|
||||||
|
/// own names from the serial and never reads this.
|
||||||
|
/// </summary>
|
||||||
|
public string Name;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Accrued presence in SECONDS, not in sample counts.
|
||||||
|
///
|
||||||
|
/// A sample count would have to be multiplied by the sweep interval to mean
|
||||||
|
/// anything, and the interval is a config key an operator may change halfway
|
||||||
|
/// through a five-hour run — which would silently rewrite the first half of the
|
||||||
|
/// tally. Accruing the interval as it is actually used makes history immutable.
|
||||||
|
/// </summary>
|
||||||
|
public long Seconds;
|
||||||
|
|
||||||
|
public int Kills;
|
||||||
|
public long FirstMs;
|
||||||
|
public long LastMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One run's declared area and its members.</summary>
|
||||||
|
private sealed class Run
|
||||||
|
{
|
||||||
|
public string RunId;
|
||||||
|
public string MapName;
|
||||||
|
public int MapIndex;
|
||||||
|
public int X;
|
||||||
|
public int Y;
|
||||||
|
public int Radius;
|
||||||
|
|
||||||
|
public long OpenedMs;
|
||||||
|
public long UntilMs;
|
||||||
|
public long ClosedMs;
|
||||||
|
public bool Closed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Frozen at open, for the same reason presence is accrued in seconds: a weight the
|
||||||
|
/// operator retunes mid-run must not retroactively re-score the kills that already
|
||||||
|
/// happened under the old one.
|
||||||
|
/// </summary>
|
||||||
|
public double KillWeight;
|
||||||
|
|
||||||
|
/// <summary>Members the cap turned away. Reported, because a truncated tally that says so is usable and one that does not is a lie.</summary>
|
||||||
|
public long Refused;
|
||||||
|
|
||||||
|
public Dictionary<int, Member> Members = new Dictionary<int, Member>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, Run> _runs = new Dictionary<string, Run>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
private static Timer _timer;
|
||||||
|
|
||||||
|
private static long _sweeps, _opened, _closed, _snapshots, _kills, _deferred, _refused;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attaches persistence. Runs before `World.Load()`, which is when `EventSink.WorldLoad`
|
||||||
|
/// fires, so this cannot be deferred to Initialize.
|
||||||
|
/// </summary>
|
||||||
|
[CallPriority(900)]
|
||||||
|
public static void Configure()
|
||||||
|
{
|
||||||
|
EventSink.WorldSave += OnWorldSave;
|
||||||
|
EventSink.WorldLoad += OnWorldLoad;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
if (!BridgeConfig.Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
BridgeBoot.RegisterHandler("participation.open", OnOpen);
|
||||||
|
BridgeBoot.RegisterHandler("participation.snapshot", OnSnapshot);
|
||||||
|
BridgeBoot.RegisterHandler("participation.close", OnClose);
|
||||||
|
|
||||||
|
EventSink.CreatureDeath += OnCreatureDeath;
|
||||||
|
EventSink.ServerStarted += OnServerStarted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnServerStarted()
|
||||||
|
{
|
||||||
|
Rearm();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stops and recreates the sweep timer from current config. Called by `[bridge reload`.</summary>
|
||||||
|
public static void Rearm()
|
||||||
|
{
|
||||||
|
Stop();
|
||||||
|
|
||||||
|
_timer = Timer.DelayCall(
|
||||||
|
TimeSpan.FromSeconds(BridgeConfig.ParticipationSweepSeconds),
|
||||||
|
TimeSpan.FromSeconds(BridgeConfig.ParticipationSweepSeconds),
|
||||||
|
Sweep);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Stop()
|
||||||
|
{
|
||||||
|
if (_timer != null)
|
||||||
|
{
|
||||||
|
_timer.Stop();
|
||||||
|
_timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Status()
|
||||||
|
{
|
||||||
|
int members = 0;
|
||||||
|
|
||||||
|
foreach (var run in _runs.Values)
|
||||||
|
members += run.Members.Count;
|
||||||
|
|
||||||
|
return String.Format(
|
||||||
|
"participation(runs={0} members={1} sweeps={2} opened={3} closed={4} snapshots={5} kills={6} deferred={7} refused={8})",
|
||||||
|
_runs.Count, members, _sweeps, _opened, _closed, _snapshots, _kills, _deferred, _refused);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every character serial this run has recorded, or null when the run is unknown here.
|
||||||
|
///
|
||||||
|
/// Added by Phase 12b for the item grant, which needs a list of people and would
|
||||||
|
/// otherwise have had to reach through core for one — the website's
|
||||||
|
/// `event_run_participants` holds the same serials, but a module cannot read core's
|
||||||
|
/// tables and adding a core surface to hand them over would have been a second copy of
|
||||||
|
/// a list this shard has been keeping all along.
|
||||||
|
///
|
||||||
|
/// **Null and empty are different answers.** Null is "no ledger is open for that run",
|
||||||
|
/// which is a refusal; empty is "the ledger is open and nobody came", which is a real
|
||||||
|
/// outcome a grant has to be able to report rather than retry.
|
||||||
|
///
|
||||||
|
/// A closed run still answers: closing stops the counting, and a reward handed out
|
||||||
|
/// after the event has ended is the ordinary case rather than an edge one.
|
||||||
|
/// </summary>
|
||||||
|
public static List<int> MemberSerials(string runId)
|
||||||
|
{
|
||||||
|
Run run;
|
||||||
|
|
||||||
|
if (runId == null || !_runs.TryGetValue(runId, out run))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var serials = new List<int>(run.Members.Count);
|
||||||
|
|
||||||
|
foreach (var member in run.Members.Values)
|
||||||
|
serials.Add(member.Serial);
|
||||||
|
|
||||||
|
return serials;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- participation.open ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Declares a run's area and starts counting.
|
||||||
|
///
|
||||||
|
/// The area is a map, a point and a radius (org lead, 2026-09-04). Not a region name:
|
||||||
|
/// protocol 6's own live walk established that the most specific region containing an
|
||||||
|
/// event is routinely anonymous, so a region-named area would be undeclarable for
|
||||||
|
/// exactly the venues events use. Not a rectangle either — an author picks the spot the
|
||||||
|
/// event happens at, not two opposite corners of it.
|
||||||
|
/// </summary>
|
||||||
|
private static void OnOpen(Dictionary<string, object> o)
|
||||||
|
{
|
||||||
|
var reqId = BridgeJson.GetString(o, "reqId");
|
||||||
|
|
||||||
|
if (!Ready(reqId, "open"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var runId = BridgeJson.GetString(o, "runId");
|
||||||
|
|
||||||
|
if (String.IsNullOrEmpty(runId))
|
||||||
|
{
|
||||||
|
Err(reqId, "open", "a run id is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mapName = BridgeJson.GetString(o, "map");
|
||||||
|
var map = MapByName(mapName);
|
||||||
|
|
||||||
|
if (map == null)
|
||||||
|
{
|
||||||
|
Err(reqId, "open", "unknown map '" + (mapName ?? "") + "'");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var radius = BridgeJson.GetInt(o, "radius", 0);
|
||||||
|
|
||||||
|
if (radius < 1 || radius > BridgeConfig.ParticipationMaxRadius)
|
||||||
|
{
|
||||||
|
Err(reqId, "open",
|
||||||
|
String.Format(CultureInfo.InvariantCulture,
|
||||||
|
"radius must be 1 to {0} tiles, and {1} was asked for",
|
||||||
|
BridgeConfig.ParticipationMaxRadius, radius));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var x = BridgeJson.GetInt(o, "x", -1);
|
||||||
|
var y = BridgeJson.GetInt(o, "y", -1);
|
||||||
|
|
||||||
|
if (x < 0 || y < 0)
|
||||||
|
{
|
||||||
|
Err(reqId, "open", "an area needs an x and a y");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Run existing;
|
||||||
|
|
||||||
|
if (_runs.TryGetValue(runId, out existing))
|
||||||
|
{
|
||||||
|
// Re-opening the same area is the ordinary consequence of a step being re-authored
|
||||||
|
// or a run being resumed, and answering it as an error would fail a run for doing
|
||||||
|
// nothing. Re-opening a DIFFERENT area is an authoring mistake, and silently
|
||||||
|
// moving the venue mid-run would make the tally describe two places at once.
|
||||||
|
if (existing.MapIndex != map.MapIndex || existing.X != x || existing.Y != y ||
|
||||||
|
existing.Radius != radius)
|
||||||
|
{
|
||||||
|
Err(reqId, "open", "run " + runId + " is already counting a different area");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.Closed = false;
|
||||||
|
Ok(reqId, "open", existing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_runs.Count >= BridgeConfig.ParticipationMaxRuns)
|
||||||
|
{
|
||||||
|
Err(reqId, "open",
|
||||||
|
String.Format(CultureInfo.InvariantCulture,
|
||||||
|
"this shard counts at most {0} runs at once", BridgeConfig.ParticipationMaxRuns));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var holdMs = BridgeJson.GetLong(o, "holdMs", 0L);
|
||||||
|
var now = BridgeJson.NowMs();
|
||||||
|
|
||||||
|
var run = new Run
|
||||||
|
{
|
||||||
|
RunId = runId,
|
||||||
|
MapName = map.Name,
|
||||||
|
MapIndex = map.MapIndex,
|
||||||
|
X = x,
|
||||||
|
Y = y,
|
||||||
|
Radius = radius,
|
||||||
|
OpenedMs = now,
|
||||||
|
UntilMs = holdMs > 0L ? now + holdMs : 0L,
|
||||||
|
KillWeight = BridgeConfig.ParticipationKillWeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
_runs[runId] = run;
|
||||||
|
_opened++;
|
||||||
|
|
||||||
|
Console.WriteLine("[Bridge] participation: run {0} counting {1} tiles around {2} ({3}, {4})",
|
||||||
|
runId, radius, map.Name, x, y);
|
||||||
|
|
||||||
|
Ok(reqId, "open", run);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- participation.close ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops counting. The tally stays readable through the grace window, because the run
|
||||||
|
/// that closes an event and the step that collects its results are two different steps
|
||||||
|
/// and either can be retried.
|
||||||
|
/// </summary>
|
||||||
|
private static void OnClose(Dictionary<string, object> o)
|
||||||
|
{
|
||||||
|
var reqId = BridgeJson.GetString(o, "reqId");
|
||||||
|
|
||||||
|
if (!Ready(reqId, "close"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var runId = BridgeJson.GetString(o, "runId");
|
||||||
|
|
||||||
|
Run run;
|
||||||
|
|
||||||
|
if (runId == null || !_runs.TryGetValue(runId, out run))
|
||||||
|
{
|
||||||
|
// Not an error. A close of a run this shard has already forgotten — a restart, a
|
||||||
|
// second teardown attempt — has the same meaning as one it honoured: nothing is
|
||||||
|
// being counted for that run any more.
|
||||||
|
var gone = BridgeJson.Begin("participation.ok");
|
||||||
|
if (reqId != null) gone.Str("reqId", reqId);
|
||||||
|
gone.Str("action", "close").Str("runId", runId).Bool("closed", true).Bool("known", false);
|
||||||
|
BridgeLink.Emit(gone.End());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!run.Closed)
|
||||||
|
{
|
||||||
|
// One last sweep before the books shut, so the people standing there when the event
|
||||||
|
// ended are credited for the interval they were standing there in.
|
||||||
|
SweepRun(run, BridgeConfig.ParticipationSweepSeconds);
|
||||||
|
|
||||||
|
run.Closed = true;
|
||||||
|
run.ClosedMs = BridgeJson.NowMs();
|
||||||
|
_closed++;
|
||||||
|
|
||||||
|
Console.WriteLine("[Bridge] participation: run {0} closed with {1} member(s)",
|
||||||
|
run.RunId, run.Members.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(reqId, "close", run);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- participation.snapshot ----
|
||||||
|
|
||||||
|
/// <summary>One snapshot in progress. See the class header for why this exists at all.</summary>
|
||||||
|
private sealed class Job
|
||||||
|
{
|
||||||
|
public string ReqId;
|
||||||
|
public string IdempotencyKey;
|
||||||
|
public Run Run;
|
||||||
|
public List<Member> Members;
|
||||||
|
public int Index;
|
||||||
|
public StringBuilder Sb;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this job took the key out of the inbound call's hands.
|
||||||
|
///
|
||||||
|
/// Recorded rather than re-derived from the chunk size, because the chunk size is a
|
||||||
|
/// config key an operator may change between the Hold and the Complete — and a
|
||||||
|
/// Complete that did not happen leaves every retry answered `bridge.busy` until the
|
||||||
|
/// store evicts the key an hour later.
|
||||||
|
/// </summary>
|
||||||
|
public bool Held;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnSnapshot(Dictionary<string, object> o)
|
||||||
|
{
|
||||||
|
var reqId = BridgeJson.GetString(o, "reqId");
|
||||||
|
|
||||||
|
if (!Ready(reqId, "snapshot"))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var runId = BridgeJson.GetString(o, "runId");
|
||||||
|
|
||||||
|
Run run;
|
||||||
|
|
||||||
|
if (runId == null || !_runs.TryGetValue(runId, out run))
|
||||||
|
{
|
||||||
|
Err(reqId, "snapshot", "this shard is not counting run '" + (runId ?? "") + "'");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Copied, not iterated in place.** A sweep or a kill landing between two chunks would
|
||||||
|
// otherwise mutate the dictionary the walk is enumerating, and a snapshot is a
|
||||||
|
// point-in-time answer in any case: the run it describes is the run as it was when the
|
||||||
|
// question was asked.
|
||||||
|
var members = new List<Member>(run.Members.Values);
|
||||||
|
|
||||||
|
var job = new Job
|
||||||
|
{
|
||||||
|
ReqId = reqId,
|
||||||
|
IdempotencyKey = BridgeJson.GetString(o, "idempotencyKey"),
|
||||||
|
Run = run,
|
||||||
|
Members = members,
|
||||||
|
Index = 0,
|
||||||
|
Sb = OpenSnapshot(reqId, run, members.Count),
|
||||||
|
};
|
||||||
|
|
||||||
|
_snapshots++;
|
||||||
|
|
||||||
|
if (members.Count <= BridgeConfig.ParticipationSnapshotChunk)
|
||||||
|
{
|
||||||
|
// Small enough to answer in the inbound call. Deliberately NOT deferred anyway: the
|
||||||
|
// idempotency store captures a reply emitted inside the handler for free, and
|
||||||
|
// holding a key we did not need to hold would put an ordinary command through the
|
||||||
|
// in-flight path for no reason.
|
||||||
|
Step(job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deferring. The key must be HELD before this call returns, or a repeat arriving while
|
||||||
|
// the walk is still running would be executed a second time rather than answered
|
||||||
|
// `bridge.busy` — which is the entire failure protocol 6 exists to prevent, and it is
|
||||||
|
// reachable for the first time right here.
|
||||||
|
if (job.IdempotencyKey != null)
|
||||||
|
{
|
||||||
|
BridgeIdempotency.Hold(job.IdempotencyKey);
|
||||||
|
job.Held = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_deferred++;
|
||||||
|
Timer.DelayCall(TimeSpan.Zero, () => Step(job));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One chunk of a snapshot. Re-arms itself until the walk is done.</summary>
|
||||||
|
private static void Step(Job job)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var end = Math.Min(job.Index + BridgeConfig.ParticipationSnapshotChunk, job.Members.Count);
|
||||||
|
|
||||||
|
for (; job.Index < end; job.Index++)
|
||||||
|
WriteMember(job.Sb, job.Run, job.Members[job.Index], job.Index > 0);
|
||||||
|
|
||||||
|
if (job.Index < job.Members.Count)
|
||||||
|
{
|
||||||
|
Timer.DelayCall(TimeSpan.Zero, () => Step(job));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
job.Sb.Append(']');
|
||||||
|
var line = job.Sb.End();
|
||||||
|
|
||||||
|
BridgeLink.Emit(line);
|
||||||
|
|
||||||
|
// Only a HELD key needs completing. An inline snapshot was captured by the
|
||||||
|
// idempotency store on its way through Emit, and completing it twice would replace
|
||||||
|
// a correlated reply with one this method has no correlation information for.
|
||||||
|
if (job.Held)
|
||||||
|
BridgeIdempotency.Complete(job.IdempotencyKey, line);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] participation snapshot threw: {0}", ex.Message);
|
||||||
|
|
||||||
|
// A held key whose walk died must still be closed out, or every retry of this step
|
||||||
|
// gets `bridge.busy` until the store's TTL evicts it an hour later.
|
||||||
|
if (job.Held)
|
||||||
|
{
|
||||||
|
var sb = BridgeJson.Begin("participation.error");
|
||||||
|
if (job.ReqId != null) sb.Str("reqId", job.ReqId);
|
||||||
|
sb.Str("action", "snapshot").Str("reason", "the snapshot failed: " + ex.Message);
|
||||||
|
var line = sb.End();
|
||||||
|
|
||||||
|
BridgeLink.Emit(line);
|
||||||
|
BridgeIdempotency.Complete(job.IdempotencyKey, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StringBuilder OpenSnapshot(string reqId, Run run, int count)
|
||||||
|
{
|
||||||
|
var sb = BridgeJson.Begin("participation.snapshot.ok");
|
||||||
|
|
||||||
|
if (reqId != null)
|
||||||
|
sb.Str("reqId", reqId);
|
||||||
|
|
||||||
|
sb.Str("runId", run.RunId)
|
||||||
|
.Str("map", run.MapName)
|
||||||
|
.Num("x", run.X)
|
||||||
|
.Num("y", run.Y)
|
||||||
|
.Num("radius", run.Radius)
|
||||||
|
.Bool("closed", run.Closed)
|
||||||
|
.Num("openedMs", run.OpenedMs)
|
||||||
|
.Num("killWeight", run.KillWeight)
|
||||||
|
.Num("members", count)
|
||||||
|
.Num("refused", run.Refused);
|
||||||
|
|
||||||
|
sb.Append(",\"participants\":[");
|
||||||
|
return sb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One member, with the score this shard computed and the two components it came from.
|
||||||
|
///
|
||||||
|
/// The components ride along because core stores the score opaquely and could never
|
||||||
|
/// explain it: a results table that can say "forty minutes and three kills" beside a
|
||||||
|
/// number is a table an operator can argue with, and one that shows only the number is
|
||||||
|
/// one they can only believe or not.
|
||||||
|
/// </summary>
|
||||||
|
private static void WriteMember(StringBuilder sb, Run run, Member member, bool comma)
|
||||||
|
{
|
||||||
|
if (comma)
|
||||||
|
sb.Append(',');
|
||||||
|
|
||||||
|
var minutes = member.Seconds / 60.0;
|
||||||
|
var score = minutes + run.KillWeight * member.Kills;
|
||||||
|
|
||||||
|
sb.Append("{\"serial\":\"0x").Append(((uint)member.Serial).ToString("X")).Append('"');
|
||||||
|
|
||||||
|
// Resolved now rather than at sweep time, and the mobile is looked up whether or not
|
||||||
|
// its owner is online: a character that took part and logged out is still in the world,
|
||||||
|
// so its account — and the linked website user with it — is still readable.
|
||||||
|
var mobile = World.FindMobile((Serial)member.Serial);
|
||||||
|
|
||||||
|
sb.Append(",\"name\":");
|
||||||
|
BridgeJson.Text(sb, mobile != null && !String.IsNullOrEmpty(mobile.Name) ? mobile.Name : member.Name);
|
||||||
|
|
||||||
|
var acct = mobile == null ? null : mobile.Account as Accounting.Account;
|
||||||
|
|
||||||
|
if (acct != null)
|
||||||
|
{
|
||||||
|
sb.Append(",\"acct\":");
|
||||||
|
BridgeJson.Text(sb, acct.Username);
|
||||||
|
|
||||||
|
var webId = BridgeAccountLink.WebIdFor(acct);
|
||||||
|
|
||||||
|
if (webId != null)
|
||||||
|
{
|
||||||
|
sb.Append(",\"webId\":");
|
||||||
|
BridgeJson.Text(sb, webId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(",\"seconds\":").Append(member.Seconds);
|
||||||
|
sb.Append(",\"minutes\":").Append(minutes.ToString("F2", CultureInfo.InvariantCulture));
|
||||||
|
sb.Append(",\"kills\":").Append(member.Kills);
|
||||||
|
sb.Append(",\"score\":").Append(score.ToString("F4", CultureInfo.InvariantCulture));
|
||||||
|
sb.Append(",\"firstMs\":").Append(member.FirstMs);
|
||||||
|
sb.Append(",\"lastMs\":").Append(member.LastMs);
|
||||||
|
sb.Append('}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- counting ----
|
||||||
|
|
||||||
|
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||||
|
public static void SweepOnce()
|
||||||
|
{
|
||||||
|
Sweep();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Sweep()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_sweeps++;
|
||||||
|
|
||||||
|
if (_runs.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var seconds = BridgeConfig.ParticipationSweepSeconds;
|
||||||
|
var now = BridgeJson.NowMs();
|
||||||
|
List<string> expired = null;
|
||||||
|
|
||||||
|
foreach (var run in _runs.Values)
|
||||||
|
{
|
||||||
|
if (run.Closed)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// The run's own deadline, honoured here for the reason a lease's is honoured on
|
||||||
|
// the shard: a website that stopped talking must not leave this shard counting
|
||||||
|
// an event that ended days ago.
|
||||||
|
if (run.UntilMs > 0L && now >= run.UntilMs)
|
||||||
|
{
|
||||||
|
SweepRun(run, seconds);
|
||||||
|
run.Closed = true;
|
||||||
|
run.ClosedMs = now;
|
||||||
|
_closed++;
|
||||||
|
|
||||||
|
Console.WriteLine("[Bridge] participation: run {0} passed its deadline and stopped counting",
|
||||||
|
run.RunId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
SweepRun(run, seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
var cutoff = now - (long)BridgeConfig.ParticipationGraceSec * 1000L;
|
||||||
|
|
||||||
|
foreach (var run in _runs.Values)
|
||||||
|
{
|
||||||
|
if (!run.Closed || run.ClosedMs > cutoff)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (expired == null)
|
||||||
|
expired = new List<string>();
|
||||||
|
|
||||||
|
expired.Add(run.RunId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expired == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (int i = 0; i < expired.Count; i++)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] participation: forgetting run {0}, closed longer than the grace window",
|
||||||
|
expired[i]);
|
||||||
|
|
||||||
|
_runs.Remove(expired[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] participation sweep threw: {0}", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Credits every online player standing in one run's area with one interval.</summary>
|
||||||
|
private static void SweepRun(Run run, int seconds)
|
||||||
|
{
|
||||||
|
var map = Map.Maps[run.MapIndex];
|
||||||
|
|
||||||
|
if (map == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var now = BridgeJson.NowMs();
|
||||||
|
|
||||||
|
foreach (var m in World.Mobiles.Values)
|
||||||
|
{
|
||||||
|
var pm = m as PlayerMobile;
|
||||||
|
|
||||||
|
if (pm == null || pm.NetState == null || pm.Deleted)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!Inside(run, pm))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var member = Touch(run, pm, now);
|
||||||
|
|
||||||
|
if (member == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
member.Seconds += seconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Kill credit, and it goes to every damager standing in the area rather than to the
|
||||||
|
/// killer alone.
|
||||||
|
///
|
||||||
|
/// A last hit is a poor description of who fought something: the player who held it for
|
||||||
|
/// four minutes and died to it took part more than the one who happened to land the blow
|
||||||
|
/// that finished it. `Mobile.DamageEntries` is already populated and is readable here
|
||||||
|
/// because a `CreatureDeath` handler runs before the creature is disposed of — the same
|
||||||
|
/// fact protocol 6's damage table rests on.
|
||||||
|
///
|
||||||
|
/// The presence check is applied to the DAMAGER, not only to the corpse. Someone
|
||||||
|
/// shooting into the venue from outside it is not attending the event, and someone who
|
||||||
|
/// fought there and has since walked away is no longer accruing anything either.
|
||||||
|
/// </summary>
|
||||||
|
private static void OnCreatureDeath(CreatureDeathEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_runs.Count == 0 || e == null || e.Creature == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var creature = e.Creature;
|
||||||
|
|
||||||
|
if (creature.Player)
|
||||||
|
return; // a player death is not a kill anybody is credited for
|
||||||
|
|
||||||
|
var now = BridgeJson.NowMs();
|
||||||
|
|
||||||
|
foreach (var run in _runs.Values)
|
||||||
|
{
|
||||||
|
if (run.Closed || !Inside(run, creature))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var entries = creature.DamageEntries;
|
||||||
|
|
||||||
|
if (entries == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Summed into a set first: ServUO folds repeat damage into an existing entry,
|
||||||
|
// but an entry that expired and was re-created leaves two, and crediting per
|
||||||
|
// entry would pay a long fight twice. Expiry governs looting rights, not
|
||||||
|
// whether somebody was there.
|
||||||
|
var credited = new HashSet<Mobile>();
|
||||||
|
|
||||||
|
for (int i = 0; i < entries.Count; i++)
|
||||||
|
{
|
||||||
|
var de = entries[i];
|
||||||
|
|
||||||
|
if (de == null || de.Damager == null || de.Damager.Deleted || !de.Damager.Player)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!credited.Add(de.Damager))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!Inside(run, de.Damager))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var member = Touch(run, de.Damager, now);
|
||||||
|
|
||||||
|
if (member == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
member.Kills++;
|
||||||
|
_kills++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A death handler must never be the thing that breaks a death.
|
||||||
|
Console.WriteLine("[Bridge] participation kill credit threw: {0}", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Inside(Run run, Mobile m)
|
||||||
|
{
|
||||||
|
if (m == null || m.Map == null || m.Map.MapIndex != run.MapIndex)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// A circle, and squared so the check costs no square root. `Radius` is in tiles and the
|
||||||
|
// z axis is deliberately ignored: a venue is a place on the map, and a player one floor
|
||||||
|
// up in a tower over the square is at the event.
|
||||||
|
var dx = m.X - run.X;
|
||||||
|
var dy = m.Y - run.Y;
|
||||||
|
|
||||||
|
return (dx * dx) + (dy * dy) <= run.Radius * run.Radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds or creates a member row, or answers null when the cap turned it away.
|
||||||
|
///
|
||||||
|
/// The cap counts a refusal rather than swallowing it, and the count rides on every
|
||||||
|
/// snapshot: a truncated tally that says it is truncated is usable, and one that does
|
||||||
|
/// not is a leaderboard with people missing from it for no stated reason.
|
||||||
|
/// </summary>
|
||||||
|
private static Member Touch(Run run, Mobile m, long now)
|
||||||
|
{
|
||||||
|
var serial = m.Serial.Value;
|
||||||
|
|
||||||
|
Member member;
|
||||||
|
|
||||||
|
if (run.Members.TryGetValue((int)serial, out member))
|
||||||
|
{
|
||||||
|
member.LastMs = now;
|
||||||
|
member.Name = m.Name ?? member.Name;
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (run.Members.Count >= BridgeConfig.ParticipationMaxMembers)
|
||||||
|
{
|
||||||
|
run.Refused++;
|
||||||
|
_refused++;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
member = new Member
|
||||||
|
{
|
||||||
|
Serial = (int)serial,
|
||||||
|
Name = m.Name ?? "",
|
||||||
|
FirstMs = now,
|
||||||
|
LastMs = now,
|
||||||
|
};
|
||||||
|
|
||||||
|
run.Members[member.Serial] = member;
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- persistence ----
|
||||||
|
|
||||||
|
private static void OnWorldSave(WorldSaveEventArgs e)
|
||||||
|
{
|
||||||
|
Persistence.Serialize(
|
||||||
|
SavePath,
|
||||||
|
writer =>
|
||||||
|
{
|
||||||
|
writer.Write(SaveVersion);
|
||||||
|
writer.Write(_runs.Count);
|
||||||
|
|
||||||
|
foreach (var run in _runs.Values)
|
||||||
|
{
|
||||||
|
writer.Write(run.RunId ?? "");
|
||||||
|
writer.Write(run.MapName ?? "");
|
||||||
|
writer.Write(run.MapIndex);
|
||||||
|
writer.Write(run.X);
|
||||||
|
writer.Write(run.Y);
|
||||||
|
writer.Write(run.Radius);
|
||||||
|
writer.Write(run.OpenedMs);
|
||||||
|
writer.Write(run.UntilMs);
|
||||||
|
writer.Write(run.ClosedMs);
|
||||||
|
writer.Write(run.Closed);
|
||||||
|
writer.Write(run.KillWeight);
|
||||||
|
writer.Write(run.Refused);
|
||||||
|
|
||||||
|
writer.Write(run.Members.Count);
|
||||||
|
|
||||||
|
foreach (var member in run.Members.Values)
|
||||||
|
{
|
||||||
|
writer.Write(member.Serial);
|
||||||
|
writer.Write(member.Name ?? "");
|
||||||
|
writer.Write(member.Seconds);
|
||||||
|
writer.Write(member.Kills);
|
||||||
|
writer.Write(member.FirstMs);
|
||||||
|
writer.Write(member.LastMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnWorldLoad()
|
||||||
|
{
|
||||||
|
Persistence.Deserialize(
|
||||||
|
SavePath,
|
||||||
|
reader =>
|
||||||
|
{
|
||||||
|
var version = reader.ReadInt();
|
||||||
|
|
||||||
|
if (version < 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var runs = reader.ReadInt();
|
||||||
|
|
||||||
|
for (int i = 0; i < runs; i++)
|
||||||
|
{
|
||||||
|
var run = new Run
|
||||||
|
{
|
||||||
|
RunId = reader.ReadString(),
|
||||||
|
MapName = reader.ReadString(),
|
||||||
|
MapIndex = reader.ReadInt(),
|
||||||
|
X = reader.ReadInt(),
|
||||||
|
Y = reader.ReadInt(),
|
||||||
|
Radius = reader.ReadInt(),
|
||||||
|
OpenedMs = reader.ReadLong(),
|
||||||
|
UntilMs = reader.ReadLong(),
|
||||||
|
ClosedMs = reader.ReadLong(),
|
||||||
|
Closed = reader.ReadBool(),
|
||||||
|
KillWeight = reader.ReadDouble(),
|
||||||
|
Refused = reader.ReadLong(),
|
||||||
|
};
|
||||||
|
|
||||||
|
var members = reader.ReadInt();
|
||||||
|
|
||||||
|
for (int j = 0; j < members; j++)
|
||||||
|
{
|
||||||
|
var member = new Member
|
||||||
|
{
|
||||||
|
Serial = reader.ReadInt(),
|
||||||
|
Name = reader.ReadString(),
|
||||||
|
Seconds = reader.ReadLong(),
|
||||||
|
Kills = reader.ReadInt(),
|
||||||
|
FirstMs = reader.ReadLong(),
|
||||||
|
LastMs = reader.ReadLong(),
|
||||||
|
};
|
||||||
|
|
||||||
|
run.Members[member.Serial] = member;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!String.IsNullOrEmpty(run.RunId))
|
||||||
|
_runs[run.RunId] = run;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_runs.Count > 0)
|
||||||
|
Console.WriteLine("[Bridge] participation: {0} run(s) restored from the world save", _runs.Count);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private static Map MapByName(string name)
|
||||||
|
{
|
||||||
|
if (String.IsNullOrEmpty(name))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
for (int i = 0; i < Map.Maps.Length; i++)
|
||||||
|
{
|
||||||
|
var map = Map.Maps[i];
|
||||||
|
|
||||||
|
if (map != null && String.Equals(map.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Ready(string reqId, string action)
|
||||||
|
{
|
||||||
|
if (!BridgeConfig.EventsEnabled)
|
||||||
|
{
|
||||||
|
Err(reqId, action, "the event plane is disabled on this shard (Bridge.EventsEnabled)");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Ok(string reqId, string action, Run run)
|
||||||
|
{
|
||||||
|
var sb = BridgeJson.Begin("participation.ok");
|
||||||
|
|
||||||
|
if (reqId != null)
|
||||||
|
sb.Str("reqId", reqId);
|
||||||
|
|
||||||
|
sb.Str("action", action)
|
||||||
|
.Str("runId", run.RunId)
|
||||||
|
.Str("map", run.MapName)
|
||||||
|
.Num("x", run.X)
|
||||||
|
.Num("y", run.Y)
|
||||||
|
.Num("radius", run.Radius)
|
||||||
|
.Bool("closed", run.Closed)
|
||||||
|
.Bool("known", true)
|
||||||
|
.Num("members", run.Members.Count)
|
||||||
|
.Num("refused", run.Refused)
|
||||||
|
.Num("untilMs", run.UntilMs);
|
||||||
|
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Err(string reqId, string action, string reason)
|
||||||
|
{
|
||||||
|
var sb = BridgeJson.Begin("participation.error");
|
||||||
|
|
||||||
|
if (reqId != null)
|
||||||
|
sb.Str("reqId", reqId);
|
||||||
|
|
||||||
|
sb.Str("action", action).Str("reason", reason);
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
231
overlay/Scripts/Custom/Bridge/BridgePng.cs
Normal file
231
overlay/Scripts/Custom/Bridge/BridgePng.cs
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// **A PNG encoder that does not go through GDI+** (docs/link/v8.md §4.4, §4.9 — phase 4).
|
||||||
|
///
|
||||||
|
/// <see cref="BridgeUop"/> decodes into a <c>ushort[]</c> of ARGB1555 rather than into a
|
||||||
|
/// <c>Bitmap</c>, which is the whole point of §4.4's note that the UOP reader is written
|
||||||
|
/// without <c>System.Drawing</c>: libgdiplus was archived in March 2025, and every line of
|
||||||
|
/// extraction that does not depend on it is a line that survives its absence. That leaves
|
||||||
|
/// the encode, and <c>Bitmap.Save(…, ImageFormat.Png)</c> is GDI+ too — so this is the
|
||||||
|
/// other half.
|
||||||
|
///
|
||||||
|
/// It is deliberately the smallest thing that produces a correct file: 8-bit RGBA, one
|
||||||
|
/// IDAT, filter type 0 on every row. No interlacing, no palette, no colour-type choice, no
|
||||||
|
/// filter heuristics. A sprite is a few hundred pixels across and the bytes go straight
|
||||||
|
/// into a base64 field; the compression difference between this and a tuned encoder is a
|
||||||
|
/// rounding error against the wire, and every knob not turned is a way this cannot be
|
||||||
|
/// subtly wrong.
|
||||||
|
///
|
||||||
|
/// Phase 3's <c>BridgeCatalog.ToPng</c> is left exactly as it is. It is measured, shipped,
|
||||||
|
/// and its input really is a <c>Bitmap</c> from the vendored decoder — a path that needs
|
||||||
|
/// GDI+ to produce the pixels in the first place, so encoding them without it buys nothing.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgePng
|
||||||
|
{
|
||||||
|
private static readonly byte[] Signature =
|
||||||
|
{
|
||||||
|
0x89, (byte)'P', (byte)'N', (byte)'G', 0x0D, 0x0A, 0x1A, 0x0A
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly uint[] CrcTable = BuildCrcTable();
|
||||||
|
|
||||||
|
private static readonly byte[] Empty = new byte[0];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ARGB1555 to an RGBA8 PNG with a transparent background.
|
||||||
|
///
|
||||||
|
/// The expansion is the same one <c>BridgeCatalog.ToPng</c> documents and for the same
|
||||||
|
/// reason: alpha bit clear is fully transparent, and each 5-bit channel is widened by
|
||||||
|
/// repeating its high bits — <c>(c << 3) | (c >> 2)</c>, not a plain shift,
|
||||||
|
/// which would cap white at 248 and tint every sprite.
|
||||||
|
/// </summary>
|
||||||
|
public static byte[] FromArgb1555(ushort[] pixels, int width, int height)
|
||||||
|
{
|
||||||
|
if (pixels == null || width <= 0 || height <= 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if ((long)width * height > pixels.Length)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// One filter byte per row, then RGBA per pixel. This is the PNG "raw" stream, the
|
||||||
|
// thing that gets deflated. Bounded by the caller's dimension ceiling
|
||||||
|
// (BridgeAssetValidator.MaxArtDimension), so the arithmetic cannot overflow an int —
|
||||||
|
// the check is here anyway, because that ceiling lives in another file.
|
||||||
|
long size = (((long)width * 4) + 1) * height;
|
||||||
|
|
||||||
|
if (size > Int32.MaxValue / 2)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var raw = new byte[size];
|
||||||
|
|
||||||
|
int at = 0;
|
||||||
|
|
||||||
|
for (int y = 0; y < height; y++)
|
||||||
|
{
|
||||||
|
raw[at++] = 0; // filter: None
|
||||||
|
|
||||||
|
int row = y * width;
|
||||||
|
|
||||||
|
for (int x = 0; x < width; x++)
|
||||||
|
{
|
||||||
|
int p = pixels[row + x];
|
||||||
|
|
||||||
|
if ((p & 0x8000) == 0)
|
||||||
|
{
|
||||||
|
at += 4; // already zero: transparent black
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int r = (p >> 10) & 0x1F;
|
||||||
|
int g = (p >> 5) & 0x1F;
|
||||||
|
int b = p & 0x1F;
|
||||||
|
|
||||||
|
raw[at++] = (byte)((r << 3) | (r >> 2));
|
||||||
|
raw[at++] = (byte)((g << 3) | (g >> 2));
|
||||||
|
raw[at++] = (byte)((b << 3) | (b >> 2));
|
||||||
|
raw[at++] = 0xFF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var ms = new MemoryStream(raw.Length / 2))
|
||||||
|
{
|
||||||
|
ms.Write(Signature, 0, Signature.Length);
|
||||||
|
|
||||||
|
var header = new byte[13];
|
||||||
|
|
||||||
|
WriteBigEndian(header, 0, (uint)width);
|
||||||
|
WriteBigEndian(header, 4, (uint)height);
|
||||||
|
|
||||||
|
header[8] = 8; // bit depth
|
||||||
|
header[9] = 6; // colour type: truecolour with alpha
|
||||||
|
header[10] = 0; // compression: deflate
|
||||||
|
header[11] = 0; // filter method 0
|
||||||
|
header[12] = 0; // no interlace
|
||||||
|
|
||||||
|
WriteChunk(ms, "IHDR", header, 0, header.Length);
|
||||||
|
|
||||||
|
byte[] deflated = Zlib(raw);
|
||||||
|
|
||||||
|
WriteChunk(ms, "IDAT", deflated, 0, deflated.Length);
|
||||||
|
WriteChunk(ms, "IEND", Empty, 0, 0);
|
||||||
|
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A zlib stream around .NET Framework's raw-deflate-only <c>DeflateStream</c>: the
|
||||||
|
/// two-byte header PNG requires, the deflate data, and the adler32 trailer computed
|
||||||
|
/// here because nothing in the framework will do it. Written by hand for exactly the
|
||||||
|
/// same reason <see cref="BridgeUop"/> reads one by hand — net48 exposes deflate and
|
||||||
|
/// calls it zlib, and the two are not the same format.
|
||||||
|
/// </summary>
|
||||||
|
private static byte[] Zlib(byte[] data)
|
||||||
|
{
|
||||||
|
using (var ms = new MemoryStream(data.Length / 2))
|
||||||
|
{
|
||||||
|
// CMF 0x78 (deflate, 32K window) and FLG 0x9C (default level, no dictionary):
|
||||||
|
// 0x789C is the pair whose value is divisible by 31, which is the check a decoder
|
||||||
|
// applies.
|
||||||
|
ms.WriteByte(0x78);
|
||||||
|
ms.WriteByte(0x9C);
|
||||||
|
|
||||||
|
using (var deflate = new DeflateStream(ms, CompressionMode.Compress, true))
|
||||||
|
deflate.Write(data, 0, data.Length);
|
||||||
|
|
||||||
|
uint adler = Adler32(data);
|
||||||
|
|
||||||
|
ms.WriteByte((byte)(adler >> 24));
|
||||||
|
ms.WriteByte((byte)(adler >> 16));
|
||||||
|
ms.WriteByte((byte)(adler >> 8));
|
||||||
|
ms.WriteByte((byte)adler);
|
||||||
|
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteChunk(Stream to, string type, byte[] data, int offset, int length)
|
||||||
|
{
|
||||||
|
var head = new byte[8];
|
||||||
|
|
||||||
|
WriteBigEndian(head, 0, (uint)length);
|
||||||
|
|
||||||
|
head[4] = (byte)type[0];
|
||||||
|
head[5] = (byte)type[1];
|
||||||
|
head[6] = (byte)type[2];
|
||||||
|
head[7] = (byte)type[3];
|
||||||
|
|
||||||
|
to.Write(head, 0, head.Length);
|
||||||
|
|
||||||
|
if (length > 0)
|
||||||
|
to.Write(data, offset, length);
|
||||||
|
|
||||||
|
// The CRC covers the type and the data, and not the length.
|
||||||
|
uint crc = Crc32(head, 4, 4, 0xFFFFFFFF);
|
||||||
|
|
||||||
|
if (length > 0)
|
||||||
|
crc = Crc32(data, offset, length, crc);
|
||||||
|
|
||||||
|
crc ^= 0xFFFFFFFF;
|
||||||
|
|
||||||
|
var tail = new byte[4];
|
||||||
|
|
||||||
|
WriteBigEndian(tail, 0, crc);
|
||||||
|
|
||||||
|
to.Write(tail, 0, tail.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteBigEndian(byte[] into, int at, uint value)
|
||||||
|
{
|
||||||
|
into[at] = (byte)(value >> 24);
|
||||||
|
into[at + 1] = (byte)(value >> 16);
|
||||||
|
into[at + 2] = (byte)(value >> 8);
|
||||||
|
into[at + 3] = (byte)value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint[] BuildCrcTable()
|
||||||
|
{
|
||||||
|
var table = new uint[256];
|
||||||
|
|
||||||
|
for (uint n = 0; n < 256; n++)
|
||||||
|
{
|
||||||
|
uint c = n;
|
||||||
|
|
||||||
|
for (int k = 0; k < 8; k++)
|
||||||
|
c = (c & 1) != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1;
|
||||||
|
|
||||||
|
table[n] = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint Crc32(byte[] data, int offset, int length, uint crc)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < length; i++)
|
||||||
|
crc = CrcTable[(crc ^ data[offset + i]) & 0xFF] ^ (crc >> 8);
|
||||||
|
|
||||||
|
return crc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint Adler32(byte[] data)
|
||||||
|
{
|
||||||
|
const uint Mod = 65521;
|
||||||
|
|
||||||
|
uint a = 1, b = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < data.Length; i++)
|
||||||
|
{
|
||||||
|
a = (a + data[i]) % Mod;
|
||||||
|
b = (b + a) % Mod;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (b << 16) | a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -215,11 +215,14 @@ namespace Server.Custom.Bridge
|
|||||||
if (owner != null)
|
if (owner != null)
|
||||||
{
|
{
|
||||||
sb.Ser("ownerSerial", owner.Serial);
|
sb.Ser("ownerSerial", owner.Serial);
|
||||||
|
sb.Str("ownerName", owner.Name);
|
||||||
var acct = owner.Account as Account;
|
var acct = owner.Account as Account;
|
||||||
if (acct != null)
|
if (acct != null)
|
||||||
sb.Str("ownerAcct", acct.Username);
|
sb.Str("ownerAcct", acct.Username);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppendDecaySchedule(sb, house, to);
|
||||||
|
|
||||||
// Where a player would physically stand to see it.
|
// Where a player would physically stand to see it.
|
||||||
var ban = house.BanLocation;
|
var ban = house.BanLocation;
|
||||||
sb.Append(",\"ban\":{\"x\":").Append(ban.X)
|
sb.Append(",\"ban\":{\"x\":").Append(ban.X)
|
||||||
@@ -232,6 +235,67 @@ namespace Server.Custom.Bridge
|
|||||||
return sb.End();
|
return sb.End();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Protocol 5. The three scheduling fields, and the reason they are not all always present.
|
||||||
|
///
|
||||||
|
/// ServUO has two decay implementations and they differ in how KNOWABLE the future is:
|
||||||
|
///
|
||||||
|
/// * Dynamic decay (DynamicDecay.Enabled, i.e. Core.ML) draws each stage's duration at
|
||||||
|
/// RANDOM when the stage is entered (BaseHouse.SetDynamicDecay ->
|
||||||
|
/// DynamicDecay.GetRandomDuration). So NextDecayStage is exact for the NEXT transition
|
||||||
|
/// and nothing beyond it is known at all. Collapse becomes exact only once the house is
|
||||||
|
/// already at IDOC, because then the next transition IS the collapse.
|
||||||
|
/// * Static decay (GetOldDecayLevel) is a pure function of LastRefreshed and DecayPeriod,
|
||||||
|
/// so collapse is exact at EVERY stage -- there is no randomness to wait out.
|
||||||
|
///
|
||||||
|
/// Emitting estimatedCollapse from a dynamic-decay house at, say, Fairly would therefore be
|
||||||
|
/// publishing a guess as a fact, which on the website's side becomes a dated promise in a
|
||||||
|
/// player's mail. It is omitted rather than approximated: the website's `required: false`
|
||||||
|
/// declaration already permits its absence, and an absent field is honest where a wrong
|
||||||
|
/// date is not.
|
||||||
|
/// </summary>
|
||||||
|
private static void AppendDecaySchedule(StringBuilder sb, BaseHouse house, DecayLevel to)
|
||||||
|
{
|
||||||
|
// ONE nested object rather than four sibling keys, for the same reason vendor.listing
|
||||||
|
// nests `location`: the website's visibility projection matches literal JSON keys, so a
|
||||||
|
// nested group is one admin rule that can hide the whole schedule, where four flat keys
|
||||||
|
// would be four rules that drift apart.
|
||||||
|
sb.Append(",\"schedule\":{");
|
||||||
|
|
||||||
|
// The stage clock. Only dynamic decay keeps one; static decay leaves it at MinValue.
|
||||||
|
bool dynamic = DynamicDecay.Enabled;
|
||||||
|
var next = house.NextDecayStage;
|
||||||
|
|
||||||
|
sb.Append("\"dynamicDecay\":").Append(dynamic ? "true" : "false");
|
||||||
|
|
||||||
|
if (dynamic && next > DateTime.MinValue)
|
||||||
|
sb.Str("nextStage", next.ToUniversalTime().ToString("o"));
|
||||||
|
|
||||||
|
// Total seconds from a full refresh to collapse. Constant per house type, but it is what
|
||||||
|
// lets a reader turn lastRefreshed into a percentage without knowing ServUO's tables.
|
||||||
|
var period = house.DecayPeriod;
|
||||||
|
if (period > TimeSpan.Zero)
|
||||||
|
sb.Num("decayPeriodSec", (long)period.TotalSeconds);
|
||||||
|
|
||||||
|
DateTime collapse;
|
||||||
|
bool knowable = true;
|
||||||
|
|
||||||
|
if (!dynamic)
|
||||||
|
collapse = house.LastRefreshed.ToUniversalTime() + period;
|
||||||
|
else if (to == DecayLevel.IDOC && next > DateTime.MinValue)
|
||||||
|
collapse = next.ToUniversalTime();
|
||||||
|
else
|
||||||
|
{
|
||||||
|
collapse = DateTime.MinValue;
|
||||||
|
knowable = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (knowable)
|
||||||
|
sb.Str("estimatedCollapse", collapse.ToString("o"));
|
||||||
|
|
||||||
|
sb.Append('}');
|
||||||
|
}
|
||||||
|
|
||||||
// ---- economy supply ----
|
// ---- economy supply ----
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
775
overlay/Scripts/Custom/Bridge/BridgeTree.cs
Normal file
775
overlay/Scripts/Custom/Bridge/BridgeTree.cs
Normal file
@@ -0,0 +1,775 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// **The shard's own configuration, over the bridge** (docs/link/v8.md §10 — protocol 8,
|
||||||
|
/// phase 7).
|
||||||
|
///
|
||||||
|
/// Everything else on the asset plane reads the operator's UO CLIENT. This family reads
|
||||||
|
/// the shard's own files: the spawn tables, the region and location definitions, the
|
||||||
|
/// champion list and the decoration lists. The website parses those into its spawn atlas —
|
||||||
|
/// where every creature lives, which regions exist, what this shard calls scenery — and
|
||||||
|
/// until protocol 8 it did so by **reading the ServUO tree off a shared filesystem**:
|
||||||
|
/// same host, a bind mount, or a shared volume.
|
||||||
|
///
|
||||||
|
/// That was the one place the platform's own rule was broken, and broken by the component
|
||||||
|
/// that faces the internet. This closes it. The parsers do not move — `spawnAtlasParse.js`
|
||||||
|
/// is pure, fs-free and covered by CI without a ServUO tree anywhere near it, and every
|
||||||
|
/// quirk it handles stays exactly where it is. The shard sends bytes; the website still
|
||||||
|
/// decides what they mean.
|
||||||
|
///
|
||||||
|
/// ── What phase 7 measured, and the shape it forced ────────────────────────────────
|
||||||
|
///
|
||||||
|
/// §10 said "the shard serves `tree/<label>` → bytes". Measured against a stock 57.4
|
||||||
|
/// tree, it cannot: `Spawns/trammel.xml` is **4.03 MB**, the sidecar discards any inbound
|
||||||
|
/// line over **1 MiB** (`shard.rs` `MAX_INBOUND_LINE_BYTES`), and that file as a single
|
||||||
|
/// base64 row is 5.4 MiB. It would never arrive — the reply would be discarded, the
|
||||||
|
/// request would time out, and the import would retry forever with no error anywhere in
|
||||||
|
/// it. Two files on a *stock* tree are in that state; a shard with hand-built spawn tables
|
||||||
|
/// has more.
|
||||||
|
///
|
||||||
|
/// So a file crosses as **chunks, each gzipped**:
|
||||||
|
///
|
||||||
|
/// <code>
|
||||||
|
/// tree/Spawns/trammel.xml the manifest row — size, hash, chunk count
|
||||||
|
/// tree/Spawns/trammel.xml/c0 the first 512 KiB of it, gzipped
|
||||||
|
/// tree/Spawns/trammel.xml/c1 the next
|
||||||
|
/// </code>
|
||||||
|
///
|
||||||
|
/// which is §5's depth scheme at work a second time, exactly as `body/400/a0/f0` is —
|
||||||
|
/// and, as there, nothing about it needed a protocol change.
|
||||||
|
///
|
||||||
|
/// **The chunk is the bound and the compression is the saving**, and it matters which is
|
||||||
|
/// which. Compression is what makes this cheap: the stock tree is 11.34 MB and gzips to
|
||||||
|
/// 927 KB, so the whole atlas source arrives in about three pages instead of thirty-one.
|
||||||
|
/// But nothing guarantees that an operator's files compress at all, so the ceiling has to
|
||||||
|
/// hold when they do not — and it does, because a 512 KiB chunk that refuses to compress
|
||||||
|
/// is still only ~683 KiB of base64, inside the wire cap that
|
||||||
|
/// <see cref="BridgeConfig.AssetBatchBytes"/>' deliberate factor of two leaves room for.
|
||||||
|
/// A design that leaned on the ratio would work on every tree anyone tested and fail on
|
||||||
|
/// the first one nobody did.
|
||||||
|
///
|
||||||
|
/// ── Two rules that are not negotiable here ────────────────────────────────────────
|
||||||
|
///
|
||||||
|
/// **1. The label set is this shard's, never the caller's.** This is the only family on
|
||||||
|
/// this link whose keys look like paths, and the website is the internet-facing component.
|
||||||
|
/// So nothing here joins a path that arrived on the wire: a fetch resolves its label
|
||||||
|
/// against the set <see cref="Enumerate"/> itself produced, and a label that is not in it
|
||||||
|
/// is refused — before any file is opened, and whatever it spells. The five groups are
|
||||||
|
/// fixed in code, the extensions are fixed in code, and the resolved path is checked to be
|
||||||
|
/// under the tree root even after all of that.
|
||||||
|
///
|
||||||
|
/// **2. A row re-declares its own address.** Each chunk carries its label, its index, its
|
||||||
|
/// byte offset and the hash of its own (uncompressed) bytes, and the manifest carries the
|
||||||
|
/// hash of the whole file. That is the §4.10 lesson on a fourth axis: a reassembly that
|
||||||
|
/// silently put chunk 3 where chunk 4 belongs would produce a file that parses — XML is
|
||||||
|
/// forgiving about what it skips — and a spawn atlas subtly missing a facet. Per-chunk
|
||||||
|
/// hashes make it a named error instead.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeTree
|
||||||
|
{
|
||||||
|
/// <summary>The §5 key family this serves.</summary>
|
||||||
|
private const string Family = "tree";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The five labelled groups `spawnAtlasSource.js` reads, and nothing else.
|
||||||
|
///
|
||||||
|
/// Fixed in code rather than configured, because a configurable list is a way for the
|
||||||
|
/// website to ask for a file this shard never meant to publish. An operator who wants
|
||||||
|
/// a different tree served wants a different feature.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] SingleFiles =
|
||||||
|
{
|
||||||
|
"Data/Regions.xml",
|
||||||
|
"Config/ChampionSpawns.xml"
|
||||||
|
};
|
||||||
|
|
||||||
|
private const string LocationsDir = "Data/Locations";
|
||||||
|
private const string SpawnsDir = "Spawns";
|
||||||
|
private const string DecorationDir = "Data/Decoration";
|
||||||
|
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
if (!BridgeConfig.Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Its own consent, not the asset plane's (§10, phase 7). An operator who declines to
|
||||||
|
// serve their UO client still gets a spawn atlas, because these are their own files.
|
||||||
|
BridgeAssets.RegisterFamily(Family, ReplyFetch, ReplyManifest,
|
||||||
|
() => BridgeConfig.TreeEnabled,
|
||||||
|
"the shard's configuration tree is not served (Bridge.TreeEnabled is off)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the file set ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private sealed class TreeFile
|
||||||
|
{
|
||||||
|
public string Label;
|
||||||
|
public string Path;
|
||||||
|
public long Bytes;
|
||||||
|
public long MTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every atlas source file this shard has, tree-relative and forward-slashed.
|
||||||
|
///
|
||||||
|
/// The labels are `spawnAtlasSource.js`'s own, character for character, because they
|
||||||
|
/// are what the website keys its stored fingerprint on: the same tree read here and
|
||||||
|
/// read there has to produce the same label or every import looks like a change.
|
||||||
|
/// Forward slashes for the same reason — a Windows shard and a Linux one must agree.
|
||||||
|
/// </summary>
|
||||||
|
private static List<TreeFile> Enumerate()
|
||||||
|
{
|
||||||
|
string root = Core.BaseDirectory;
|
||||||
|
var files = new List<TreeFile>();
|
||||||
|
|
||||||
|
foreach (string label in SingleFiles)
|
||||||
|
Add(files, root, label);
|
||||||
|
|
||||||
|
foreach (string label in ListByExtension(root, LocationsDir, ".xml"))
|
||||||
|
Add(files, root, label);
|
||||||
|
|
||||||
|
foreach (string label in ListByExtension(root, SpawnsDir, ".xml"))
|
||||||
|
Add(files, root, label);
|
||||||
|
|
||||||
|
foreach (string label in ListTree(root, DecorationDir, ".cfg"))
|
||||||
|
Add(files, root, label);
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Add(List<TreeFile> files, string root, string label)
|
||||||
|
{
|
||||||
|
string path = Resolve(root, label);
|
||||||
|
|
||||||
|
if (path == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var info = new FileInfo(path);
|
||||||
|
|
||||||
|
if (!info.Exists)
|
||||||
|
return;
|
||||||
|
|
||||||
|
files.Add(new TreeFile
|
||||||
|
{
|
||||||
|
Label = label,
|
||||||
|
Path = path,
|
||||||
|
Bytes = info.Length,
|
||||||
|
MTime = ToUnixMs(info.LastWriteTimeUtc)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
// A file the shard cannot stat is a file it cannot serve. Say so once, here,
|
||||||
|
// rather than as a refused row on every import pass forever.
|
||||||
|
Console.WriteLine("[Bridge] tree: cannot read {0}: {1}", label, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One directory's files with the given extension, sorted, as labels.</summary>
|
||||||
|
private static List<string> ListByExtension(string root, string dir, string extension)
|
||||||
|
{
|
||||||
|
var labels = new List<string>();
|
||||||
|
string full = Path.Combine(root, dir.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(full))
|
||||||
|
return labels;
|
||||||
|
|
||||||
|
foreach (string path in Directory.GetFiles(full))
|
||||||
|
{
|
||||||
|
string name = Path.GetFileName(path);
|
||||||
|
|
||||||
|
if (name.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
|
||||||
|
labels.Add(dir + "/" + name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] tree: cannot list {0}: {1}", dir, e.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
labels.Sort(StringComparer.Ordinal);
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One directory tree's files with the given extension, recursively.
|
||||||
|
///
|
||||||
|
/// Recursive because `Data/Decoration` nests two deep in places (`Magincia/Trammel`,
|
||||||
|
/// `Stygian Abyss/Ter Mur`, `Old/Britannia`), and the website's own reader says why
|
||||||
|
/// that matters: a flat read indexes a third of what the shard has, and the failure is
|
||||||
|
/// an authoring dropdown quietly missing whole expansions rather than an error anyone
|
||||||
|
/// would notice.
|
||||||
|
/// </summary>
|
||||||
|
private static List<string> ListTree(string root, string dir, string extension)
|
||||||
|
{
|
||||||
|
var labels = new List<string>();
|
||||||
|
string full = Path.Combine(root, dir.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(full))
|
||||||
|
return labels;
|
||||||
|
|
||||||
|
foreach (string path in Directory.GetFiles(full, "*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
if (!path.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
string rel = path.Substring(full.Length).Replace('\\', '/').TrimStart('/');
|
||||||
|
|
||||||
|
if (rel.Length > 0)
|
||||||
|
labels.Add(dir + "/" + rel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] tree: cannot walk {0}: {1}", dir, e.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
labels.Sort(StringComparer.Ordinal);
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A label to a path on this host, or null if it is not one this shard serves.
|
||||||
|
///
|
||||||
|
/// Rule 1 of the class doc lives here. The label has already been matched against the
|
||||||
|
/// enumerated set by the time a fetch calls this, and this still refuses anything with
|
||||||
|
/// a traversal segment, a drive or a root in it, and still checks that what
|
||||||
|
/// <c>Path.GetFullPath</c> produced is under the tree root. Three checks for one rule
|
||||||
|
/// because the cost of being wrong once is the website reading an arbitrary file off a
|
||||||
|
/// game server's disk.
|
||||||
|
/// </summary>
|
||||||
|
private static string Resolve(string root, string label)
|
||||||
|
{
|
||||||
|
if (String.IsNullOrEmpty(label) || label.IndexOf('\\') >= 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
string[] segments = label.Split('/');
|
||||||
|
|
||||||
|
foreach (string segment in segments)
|
||||||
|
{
|
||||||
|
if (segment.Length == 0 || segment == "." || segment == "..")
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Path.IsPathRooted(label))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string rootFull = Path.GetFullPath(root);
|
||||||
|
string full = Path.GetFullPath(Path.Combine(rootFull,
|
||||||
|
label.Replace('/', Path.DirectorySeparatorChar)));
|
||||||
|
|
||||||
|
if (!rootFull.EndsWith(Path.DirectorySeparatorChar.ToString(CultureInfo.InvariantCulture),
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
rootFull += Path.DirectorySeparatorChar;
|
||||||
|
}
|
||||||
|
|
||||||
|
return full.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase) ? full : null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the fingerprint ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the whole tree currently is, in sixteen hex characters.
|
||||||
|
///
|
||||||
|
/// The same job <c>BridgeCatalog.SourceId</c> does for client files, and the same
|
||||||
|
/// reason: it goes on every page of a walk, and a page whose id differs from the
|
||||||
|
/// first's means the operator edited a spawn file while it was being read. Half of
|
||||||
|
/// what arrived then describes a tree that no longer exists and nothing later can tell
|
||||||
|
/// which half, so the website refuses the import outright rather than stitching one.
|
||||||
|
///
|
||||||
|
/// Built from (label, size, mtime) rather than from content hashes, because it is
|
||||||
|
/// computed on every page and hashing the tree's contents each time would spend a
|
||||||
|
/// tenth of a second per page to answer a question (size, mtime) answers for free.
|
||||||
|
/// The CONTENT hashes are still sent — once, per file, on the manifest — which is
|
||||||
|
/// where the website's own drift gate reads them from.
|
||||||
|
/// </summary>
|
||||||
|
private static string FingerprintOf(List<TreeFile> files)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder(256);
|
||||||
|
|
||||||
|
sb.Append(files.Count);
|
||||||
|
|
||||||
|
foreach (TreeFile file in files)
|
||||||
|
{
|
||||||
|
sb.Append('|').Append(file.Label)
|
||||||
|
.Append(':').Append(file.Bytes.ToString(CultureInfo.InvariantCulture))
|
||||||
|
.Append(':').Append(file.MTime.ToString(CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
|
||||||
|
return BridgeAssets.Sha256Hex(Encoding.UTF8.GetBytes(sb.ToString())).Substring(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── assets.manifest, for this family ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Worker thread. Every file this shard would serve, with its size, its content hash
|
||||||
|
/// and how many chunks it takes — and no bytes.
|
||||||
|
///
|
||||||
|
/// That separation is what makes the normal case free. The website stores these
|
||||||
|
/// hashes; on the next import it asks for this list again, compares, and fetches
|
||||||
|
/// nothing at all when nothing moved — which on a shard whose maps are not being
|
||||||
|
/// edited is every import.
|
||||||
|
///
|
||||||
|
/// A stock tree is 141 rows and fits in one page comfortably. It pages anyway, by the
|
||||||
|
/// same envelope as every other family, because the day a shard has three thousand
|
||||||
|
/// decoration files is not the day to discover this was the one walk that could not
|
||||||
|
/// end.
|
||||||
|
/// </summary>
|
||||||
|
private static void ReplyManifest(string reqId, string cursor)
|
||||||
|
{
|
||||||
|
List<TreeFile> files = Enumerate();
|
||||||
|
string fingerprint = FingerprintOf(files);
|
||||||
|
|
||||||
|
int from = ParseCursor(cursor);
|
||||||
|
|
||||||
|
if (from < 0 || from > files.Count)
|
||||||
|
from = 0;
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("assets.manifest.ok");
|
||||||
|
|
||||||
|
sb.Str("reqId", reqId)
|
||||||
|
.Str("family", Family)
|
||||||
|
.Str("catalog", fingerprint)
|
||||||
|
.Num("chunkBytes", BridgeConfig.TreeChunkBytes)
|
||||||
|
.Num("total", files.Count)
|
||||||
|
.Num("from", from);
|
||||||
|
|
||||||
|
var page = new BridgeAssets.PageBuilder(sb, "rows", BridgeConfig.AssetBatchBytes);
|
||||||
|
|
||||||
|
int i = from;
|
||||||
|
|
||||||
|
for (; i < files.Count; i++)
|
||||||
|
{
|
||||||
|
TreeFile file = files[i];
|
||||||
|
string hash = HashFile(file.Path);
|
||||||
|
|
||||||
|
var item = new StringBuilder(256);
|
||||||
|
|
||||||
|
item.Append("{\"key\":");
|
||||||
|
BridgeJson.Text(item, Family + "/" + file.Label);
|
||||||
|
item.Append(",\"label\":");
|
||||||
|
BridgeJson.Text(item, file.Label);
|
||||||
|
item.Append(",\"bytes\":").Append(file.Bytes.ToString(CultureInfo.InvariantCulture));
|
||||||
|
item.Append(",\"mtime\":").Append(file.MTime.ToString(CultureInfo.InvariantCulture));
|
||||||
|
item.Append(",\"chunks\":").Append(
|
||||||
|
ChunkCount(file.Bytes).ToString(CultureInfo.InvariantCulture));
|
||||||
|
item.Append(",\"sha256\":");
|
||||||
|
BridgeJson.Text(item, hash);
|
||||||
|
item.Append('}');
|
||||||
|
|
||||||
|
if (!page.TryAdd(item.ToString(), "t:" + (i + 1).ToString(CultureInfo.InvariantCulture)))
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
page.Close();
|
||||||
|
|
||||||
|
sb.Num("sent", page.Count);
|
||||||
|
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many chunks a file of this size takes.
|
||||||
|
///
|
||||||
|
/// **An empty file is one chunk, not none.** `Data/Locations` can legitimately hold an
|
||||||
|
/// empty file, and zero chunks would make it a manifest row the website could never
|
||||||
|
/// fetch: it would wait for content that has no address, and report the import
|
||||||
|
/// incomplete forever.
|
||||||
|
/// </summary>
|
||||||
|
private static int ChunkCount(long bytes)
|
||||||
|
{
|
||||||
|
long chunk = BridgeConfig.TreeChunkBytes;
|
||||||
|
long count = (bytes + chunk - 1) / chunk;
|
||||||
|
|
||||||
|
return count < 1 ? 1 : (int)count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── assets.fetch, for this family ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Worker thread. The bytes for an explicit list of chunk keys.
|
||||||
|
///
|
||||||
|
/// Chunks are read with a seek rather than by holding the file, so the memory this
|
||||||
|
/// costs a running game server is one chunk regardless of how large an operator's
|
||||||
|
/// spawn tables are. A 4 MB file served eight times over is eight seeks and eight
|
||||||
|
/// 512 KiB reads — cheaper than caching it would be, and with no cache to invalidate
|
||||||
|
/// when the operator edits it mid-pass.
|
||||||
|
/// </summary>
|
||||||
|
private static void ReplyFetch(string reqId, List<string> keys, string expected, string cursor)
|
||||||
|
{
|
||||||
|
List<TreeFile> files = Enumerate();
|
||||||
|
string fingerprint = FingerprintOf(files);
|
||||||
|
|
||||||
|
// Shared with every other family on this plane, because an absent fingerprint and an
|
||||||
|
// empty one have to mean the same thing here and there — see
|
||||||
|
// `BridgeAssets.CatalogMismatch` for what treating them differently costs.
|
||||||
|
if (BridgeAssets.CatalogMismatch(expected, fingerprint))
|
||||||
|
{
|
||||||
|
// The tree moved between the manifest and this fetch. The same refusal the
|
||||||
|
// catalogue makes for a patched client, and for the same reason: these keys were
|
||||||
|
// chosen against a listing that no longer describes what is on disk.
|
||||||
|
BridgeAssets.Fail(reqId, "UNREADABLE",
|
||||||
|
"the shard's configuration tree changed since that manifest was read (catalog "
|
||||||
|
+ expected + " is now " + fingerprint + "); start the import again");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var byLabel = new Dictionary<string, TreeFile>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (TreeFile file in files)
|
||||||
|
byLabel[file.Label] = file;
|
||||||
|
|
||||||
|
int from = ParseCursor(cursor);
|
||||||
|
|
||||||
|
if (from < 0 || from > keys.Count)
|
||||||
|
from = 0;
|
||||||
|
|
||||||
|
var sb = BridgeJson.Begin("assets.fetch.ok");
|
||||||
|
|
||||||
|
sb.Str("reqId", reqId)
|
||||||
|
.Str("family", Family)
|
||||||
|
.Str("catalog", fingerprint)
|
||||||
|
.Num("chunkBytes", BridgeConfig.TreeChunkBytes)
|
||||||
|
.Num("asked", keys.Count)
|
||||||
|
.Num("from", from);
|
||||||
|
|
||||||
|
var page = new BridgeAssets.PageBuilder(sb, "rows", BridgeConfig.AssetBatchBytes);
|
||||||
|
|
||||||
|
int i = from;
|
||||||
|
|
||||||
|
for (; i < keys.Count; i++)
|
||||||
|
{
|
||||||
|
string item = Render(byLabel, keys[i]);
|
||||||
|
|
||||||
|
if (!page.TryAdd(item, "t:" + (i + 1).ToString(CultureInfo.InvariantCulture)))
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
page.Close();
|
||||||
|
|
||||||
|
sb.Num("sent", page.Count);
|
||||||
|
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One key to one row.
|
||||||
|
///
|
||||||
|
/// A key this shard cannot serve is a row rather than a failed request, exactly as in
|
||||||
|
/// every other family, and `status` keeps the two kinds apart: `absent` is a file this
|
||||||
|
/// shard does not have (a tree with no `ChampionSpawns.xml` is a normal tree), and
|
||||||
|
/// `unsupported` is a key shape this family does not serve — which is a website bug,
|
||||||
|
/// and is counted separately so it cannot hide inside the expected gaps.
|
||||||
|
/// </summary>
|
||||||
|
private static string Render(Dictionary<string, TreeFile> byLabel, string key)
|
||||||
|
{
|
||||||
|
string label;
|
||||||
|
int chunk;
|
||||||
|
|
||||||
|
if (!ParseKey(key, out label, out chunk))
|
||||||
|
return Refusal(key, "unsupported", "not a tree chunk key (tree/<label>/c<n>)");
|
||||||
|
|
||||||
|
TreeFile file;
|
||||||
|
|
||||||
|
if (!byLabel.TryGetValue(label, out file))
|
||||||
|
{
|
||||||
|
// Rule 1: the label has to be one THIS shard enumerated. Anything else is refused
|
||||||
|
// here, before a path is built out of it, whatever it spells.
|
||||||
|
return Refusal(key, "absent", "this shard does not serve that file");
|
||||||
|
}
|
||||||
|
|
||||||
|
int chunks = ChunkCount(file.Bytes);
|
||||||
|
|
||||||
|
if (chunk < 0 || chunk >= chunks)
|
||||||
|
{
|
||||||
|
return Refusal(key, "unsupported",
|
||||||
|
"chunk " + chunk.ToString(CultureInfo.InvariantCulture) + " of "
|
||||||
|
+ chunks.ToString(CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
|
||||||
|
long offset = (long)chunk * BridgeConfig.TreeChunkBytes;
|
||||||
|
byte[] raw;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
raw = ReadChunk(file.Path, offset, BridgeConfig.TreeChunkBytes);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] tree: cannot read {0} chunk {1}: {2}", label, chunk, e.Message);
|
||||||
|
return Refusal(key, "absent", e.GetType().Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] packed;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
packed = Gzip(raw);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] tree: cannot compress {0} chunk {1}: {2}", label, chunk, e.Message);
|
||||||
|
return Refusal(key, "absent", e.GetType().Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
var item = new StringBuilder(packed.Length * 2);
|
||||||
|
|
||||||
|
item.Append("{\"key\":");
|
||||||
|
BridgeJson.Text(item, key);
|
||||||
|
item.Append(",\"status\":\"ok\",\"label\":");
|
||||||
|
BridgeJson.Text(item, label);
|
||||||
|
item.Append(",\"chunk\":").Append(chunk.ToString(CultureInfo.InvariantCulture));
|
||||||
|
item.Append(",\"chunks\":").Append(chunks.ToString(CultureInfo.InvariantCulture));
|
||||||
|
item.Append(",\"offset\":").Append(offset.ToString(CultureInfo.InvariantCulture));
|
||||||
|
item.Append(",\"bytes\":").Append(raw.Length.ToString(CultureInfo.InvariantCulture));
|
||||||
|
item.Append(",\"sha256\":");
|
||||||
|
BridgeJson.Text(item, BridgeAssets.Sha256Hex(raw));
|
||||||
|
item.Append(",\"gzip\":");
|
||||||
|
BridgeJson.Text(item, Convert.ToBase64String(packed));
|
||||||
|
item.Append('}');
|
||||||
|
|
||||||
|
return item.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Refusal(string key, string status, string reason)
|
||||||
|
{
|
||||||
|
var item = new StringBuilder(128);
|
||||||
|
|
||||||
|
item.Append("{\"key\":");
|
||||||
|
BridgeJson.Text(item, key);
|
||||||
|
item.Append(",\"status\":");
|
||||||
|
BridgeJson.Text(item, status);
|
||||||
|
item.Append(",\"reason\":");
|
||||||
|
BridgeJson.Text(item, reason);
|
||||||
|
item.Append('}');
|
||||||
|
|
||||||
|
return item.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// `tree/<label>/c<n>` into its label and chunk index.
|
||||||
|
///
|
||||||
|
/// The label itself contains slashes, so the chunk segment is taken off the END rather
|
||||||
|
/// than by counting segments from the front. That is unambiguous here and not by
|
||||||
|
/// luck: every label this family serves ends in `.xml` or `.cfg`, so no label's last
|
||||||
|
/// segment can be spelled `c` followed by digits.
|
||||||
|
/// </summary>
|
||||||
|
private static bool ParseKey(string key, out string label, out int chunk)
|
||||||
|
{
|
||||||
|
label = null;
|
||||||
|
chunk = -1;
|
||||||
|
|
||||||
|
if (String.IsNullOrEmpty(key))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
string prefix = Family + "/";
|
||||||
|
|
||||||
|
if (!key.StartsWith(prefix, StringComparison.Ordinal))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
int slash = key.LastIndexOf('/');
|
||||||
|
|
||||||
|
if (slash <= prefix.Length - 1)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
string last = key.Substring(slash + 1);
|
||||||
|
|
||||||
|
if (last.Length < 2 || last[0] != 'c')
|
||||||
|
return false;
|
||||||
|
|
||||||
|
for (int i = 1; i < last.Length; i++)
|
||||||
|
{
|
||||||
|
if (last[i] < '0' || last[i] > '9')
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Int32.TryParse(last.Substring(1), NumberStyles.None, CultureInfo.InvariantCulture, out chunk))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
label = key.Substring(prefix.Length, slash - prefix.Length);
|
||||||
|
|
||||||
|
return label.Length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ParseCursor(string cursor)
|
||||||
|
{
|
||||||
|
if (String.IsNullOrEmpty(cursor) || !cursor.StartsWith("t:", StringComparison.Ordinal))
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
int value;
|
||||||
|
|
||||||
|
return Int32.TryParse(cursor.Substring(2), NumberStyles.None,
|
||||||
|
CultureInfo.InvariantCulture, out value) ? value : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── bytes ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static byte[] ReadChunk(string path, long offset, int length)
|
||||||
|
{
|
||||||
|
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read,
|
||||||
|
FileShare.ReadWrite, 1 << 16))
|
||||||
|
{
|
||||||
|
long remaining = stream.Length - offset;
|
||||||
|
|
||||||
|
if (remaining < 0)
|
||||||
|
remaining = 0;
|
||||||
|
|
||||||
|
if (remaining > length)
|
||||||
|
remaining = length;
|
||||||
|
|
||||||
|
var buffer = new byte[remaining];
|
||||||
|
|
||||||
|
stream.Seek(offset, SeekOrigin.Begin);
|
||||||
|
|
||||||
|
int filled = 0;
|
||||||
|
|
||||||
|
while (filled < buffer.Length)
|
||||||
|
{
|
||||||
|
int read = stream.Read(buffer, filled, buffer.Length - filled);
|
||||||
|
|
||||||
|
// A short read is not the end of the file here — the length was taken from the
|
||||||
|
// stream itself. Stopping on one would hand back a chunk whose declared length
|
||||||
|
// and real length disagree, which the website would only see as a hash
|
||||||
|
// mismatch on a file it cannot name a cause for.
|
||||||
|
if (read <= 0)
|
||||||
|
break;
|
||||||
|
|
||||||
|
filled += read;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filled == buffer.Length)
|
||||||
|
return buffer;
|
||||||
|
|
||||||
|
var exact = new byte[filled];
|
||||||
|
Buffer.BlockCopy(buffer, 0, exact, 0, filled);
|
||||||
|
return exact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A complete gzip member for exactly one empty chunk.
|
||||||
|
///
|
||||||
|
/// **`GZipStream` writes NOTHING for zero bytes of input**, on .NET Framework and on
|
||||||
|
/// Mono: the gzip header is emitted lazily on the first write, so a stream that is
|
||||||
|
/// opened and closed without one produces a zero-length buffer rather than the 20-byte
|
||||||
|
/// empty member. That is not a valid gzip stream, and the reader at the other end says
|
||||||
|
/// so — `zlib: unexpected end of file`.
|
||||||
|
///
|
||||||
|
/// It is not a hypothetical: **stock ServUO 57.4 ships two empty decoration files**
|
||||||
|
/// (`Felucca/ambitious solen queen quest.cfg` and
|
||||||
|
/// `Tokuno/terrible hatchlings quest.cfg`), so every import off an untouched tree hit
|
||||||
|
/// it. Worth knowing how it was found, because it says something about probes: an
|
||||||
|
/// offline harness reassembled all 141 files and reported success, since .NET's own
|
||||||
|
/// decompressor treats an empty stream as empty data and the chunk's declared length
|
||||||
|
/// (0) and hash (of nothing) both agreed with that. Only the live walk, through a
|
||||||
|
/// reader on a different runtime, disagreed.
|
||||||
|
///
|
||||||
|
/// The alternative — letting an empty chunk carry an empty payload and teaching the
|
||||||
|
/// reader to expect it — was rejected: it puts a special case on the wire, where every
|
||||||
|
/// future reader has to know it, instead of in the one place that builds the bytes.
|
||||||
|
/// Header (magic, deflate, no flags, no mtime, no XFL, unknown OS), one empty stored
|
||||||
|
/// block, then CRC32 and ISIZE of nothing.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly byte[] EmptyGzip =
|
||||||
|
{
|
||||||
|
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff,
|
||||||
|
0x03, 0x00,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
|
||||||
|
};
|
||||||
|
|
||||||
|
private static byte[] Gzip(byte[] raw)
|
||||||
|
{
|
||||||
|
if (raw.Length == 0)
|
||||||
|
return EmptyGzip;
|
||||||
|
|
||||||
|
using (var ms = new MemoryStream())
|
||||||
|
{
|
||||||
|
using (var gz = new GZipStream(ms, CompressionMode.Compress, true))
|
||||||
|
gz.Write(raw, 0, raw.Length);
|
||||||
|
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The content hash of one file, streamed.
|
||||||
|
///
|
||||||
|
/// Streamed rather than <c>File.ReadAllBytes</c> because this runs once per file per
|
||||||
|
/// manifest, and a stock tree's spawn files are 10 MB between them: reading them whole
|
||||||
|
/// would put that much through a game server's large object heap to produce 141 short
|
||||||
|
/// strings.
|
||||||
|
/// </summary>
|
||||||
|
private static string HashFile(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var sha = System.Security.Cryptography.SHA256.Create())
|
||||||
|
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read,
|
||||||
|
FileShare.ReadWrite, 1 << 16))
|
||||||
|
{
|
||||||
|
var buffer = new byte[1 << 16];
|
||||||
|
int read;
|
||||||
|
|
||||||
|
while ((read = stream.Read(buffer, 0, buffer.Length)) > 0)
|
||||||
|
sha.TransformBlock(buffer, 0, read, null, 0);
|
||||||
|
|
||||||
|
sha.TransformFinalBlock(buffer, 0, 0);
|
||||||
|
|
||||||
|
var sb = new StringBuilder(64);
|
||||||
|
|
||||||
|
foreach (byte b in sha.Hash)
|
||||||
|
sb.Append(b.ToString("x2", CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] tree: cannot hash {0}: {1}", path, e.Message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long ToUnixMs(DateTime utc)
|
||||||
|
{
|
||||||
|
return (long)(utc - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>For `[Bridge] status`, the same one-line shape every other family reports.</summary>
|
||||||
|
public static string Status()
|
||||||
|
{
|
||||||
|
if (!BridgeConfig.TreeEnabled)
|
||||||
|
return "tree(disabled)";
|
||||||
|
|
||||||
|
List<TreeFile> files = Enumerate();
|
||||||
|
long bytes = 0;
|
||||||
|
|
||||||
|
foreach (TreeFile file in files)
|
||||||
|
bytes += file.Bytes;
|
||||||
|
|
||||||
|
return String.Format("tree(files={0} bytes={1} catalog={2})",
|
||||||
|
files.Count, bytes, FingerprintOf(files));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
823
overlay/Scripts/Custom/Bridge/BridgeUop.cs
Normal file
823
overlay/Scripts/Custom/Bridge/BridgeUop.cs
Normal file
@@ -0,0 +1,823 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
using Ultima;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// **The UOP animation reader** (docs/link/v8.md §4.3, §4.9 — protocol 8, phase 4): the
|
||||||
|
/// second and last decoder this protocol writes rather than calls.
|
||||||
|
///
|
||||||
|
/// ServUO's vendored <c>Ultima.Animations</c> reads legacy <c>anim*.mul</c> only — it
|
||||||
|
/// constructs its five <c>FileIndex</c>es with the four-argument constructor, which passes
|
||||||
|
/// <c>uopFile: null</c>, so <c>AnimationFrame*.uop</c> is never opened. Everything a
|
||||||
|
/// modern client added there is invisible to it. This class opens those packages directly.
|
||||||
|
///
|
||||||
|
/// ── **Why this is not the never-sweep rule being broken** ──
|
||||||
|
///
|
||||||
|
/// §4.3's rule is that a body's file type comes from <c>BodyConverter.Convert</c> and is
|
||||||
|
/// never guessed, because asking another <c>anim*.mul</c> for an index it does not own
|
||||||
|
/// returns a decodable picture of something else — a giant spider on the gargoyle page.
|
||||||
|
/// That rule exists because a legacy index is addressed **by position**: nothing in the
|
||||||
|
/// file says which body a record belongs to.
|
||||||
|
///
|
||||||
|
/// A UOP package is addressed by the **hash of a name that contains the body id**
|
||||||
|
/// (<c>build/animationlegacyframe/000666/00.bin</c>). Looking in all five packages for one
|
||||||
|
/// hash is therefore not a sweep — a hit is proof of identity, not a coincidence of
|
||||||
|
/// position, and the payload repeats the body id in its own header for us to check against.
|
||||||
|
/// Measured on this machine's client: 10,724 entries across the five packages, every one
|
||||||
|
/// of them claimed by that name scheme, and **no hash appears in more than one package**.
|
||||||
|
///
|
||||||
|
/// ── **Validate as we go, because here we are the library** ──
|
||||||
|
///
|
||||||
|
/// §4.5's rule is "validate before calling", and it exists because <c>Ultima</c>'s decoders
|
||||||
|
/// take their bounds from the file they are reading. Nothing about this code can be
|
||||||
|
/// validated from outside — it *is* the decode — so the same discipline appears as a bound
|
||||||
|
/// on every read: the block chain against the file length, an entry's record against the
|
||||||
|
/// file, the inflated length against the declared one, the frame table against the
|
||||||
|
/// payload, and every run header against **both** the record's remaining bytes and the
|
||||||
|
/// bitmap it is writing into. A record that fails any of them is reported absent and no
|
||||||
|
/// pixel of it is kept.
|
||||||
|
///
|
||||||
|
/// Measured the same way §4.5 was, which is the only measurement that says the boundary is
|
||||||
|
/// in the right place: across every UOP body on a stock client the walk refused **nothing**
|
||||||
|
/// that carries art, and the one body it does refuse (286) declares a 0×0 frame, which the
|
||||||
|
/// legacy decoder treats as absent too.
|
||||||
|
///
|
||||||
|
/// ── **No <c>System.Drawing</c>, deliberately** ──
|
||||||
|
///
|
||||||
|
/// §4.4 states it: libgdiplus was archived in March 2025, and the long-term argument for
|
||||||
|
/// moving extraction off <c>System.Drawing</c> is that a Linux shard depends on an
|
||||||
|
/// unmaintained library to see a sprite. This decoder writes ARGB1555 into a
|
||||||
|
/// <c>ushort[]</c> of its own and <see cref="BridgePng"/> encodes that directly, so the
|
||||||
|
/// door stays open. (Phase 4 does not walk through it: the catalogue still refuses the
|
||||||
|
/// whole family when imaging is unavailable, because most of it genuinely needs GDI+.)
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeUop
|
||||||
|
{
|
||||||
|
/// <summary>'MYP\0' — the Mythic package magic, as <c>FileIndex</c> reads it.</summary>
|
||||||
|
private const int PackageMagic = 0x50594D;
|
||||||
|
|
||||||
|
/// <summary>'AMOU' — the animation payload's own magic, little-endian.</summary>
|
||||||
|
private const int PayloadMagic = 0x554F4D41;
|
||||||
|
|
||||||
|
/// <summary>Each frame record opens with its own palette: 0x100 ARGB1555 entries.</summary>
|
||||||
|
private const int PaletteBytes = 0x100 * 2;
|
||||||
|
|
||||||
|
/// <summary>The frame table's row width: group, frame id, two unknowns, pixel offset.</summary>
|
||||||
|
private const int FrameRowBytes = 16;
|
||||||
|
|
||||||
|
/// <summary>One block-chain record: offset, three lengths, hash, adler32, flag.</summary>
|
||||||
|
private const int BlockEntryBytes = 34;
|
||||||
|
|
||||||
|
/// <summary>The xor <c>Frame</c> applies to every run header, and so must this.</summary>
|
||||||
|
private const int DoubleXor = (0x200 << 22) | (0x200 << 12);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A ceiling on a declared decompressed payload. One group file is a whole action for
|
||||||
|
/// one body across every direction; the largest on this machine's client is body
|
||||||
|
/// 1248's at 4.3 MB, so this is two orders of magnitude of headroom over real data and
|
||||||
|
/// still small enough that a corrupt length cannot ask for the host's memory.
|
||||||
|
/// </summary>
|
||||||
|
public const int MaxPayloadBytes = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A ceiling on the block chain. Five packages hold 10,724 entries between them; this
|
||||||
|
/// bounds a cyclic or corrupt chain into a refusal rather than a hang.
|
||||||
|
/// </summary>
|
||||||
|
private const int MaxEntries = 1 << 20;
|
||||||
|
|
||||||
|
/// <summary>The five packages this client ships. There is no AnimationFrame5.uop.</summary>
|
||||||
|
private static readonly int[] PackageNumbers = { 1, 2, 3, 4, 6 };
|
||||||
|
|
||||||
|
public static IEnumerable<int> Packages
|
||||||
|
{
|
||||||
|
get { return PackageNumbers; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string PackageName(int n)
|
||||||
|
{
|
||||||
|
return "AnimationFrame" + n.ToString(CultureInfo.InvariantCulture) + ".uop";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where a UOP animation package lives.
|
||||||
|
///
|
||||||
|
/// <c>Ultima.Files.GetFilePath</c> cannot answer this: its table of known client files
|
||||||
|
/// predates UOP animations and contains no <c>AnimationFrame*.uop</c> entry, so it
|
||||||
|
/// returns null for every one of them. So the lookup is done here, against the same
|
||||||
|
/// directories ServUO itself resolved at boot — <c>Files.RootDir</c> first, then
|
||||||
|
/// <c>Core.DataDirectories</c>, which §1 is built on.
|
||||||
|
///
|
||||||
|
/// The comparison is case-insensitive **by enumeration** rather than by trying one
|
||||||
|
/// spelling. On Windows either would work; on a Linux shard host the client directory
|
||||||
|
/// is case-sensitive and the file may be shipped as `AnimationFrame1.uop`,
|
||||||
|
/// `animationframe1.uop` or anything between, which is exactly the shape of bug that
|
||||||
|
/// presents as "the gargoyles import on my machine and not on the server".
|
||||||
|
///
|
||||||
|
/// <see cref="FindClientFile"/> is the general form, and `assets.sources` uses it for
|
||||||
|
/// the same reason: a file Ultima's table predates has to be found some other way.
|
||||||
|
/// </summary>
|
||||||
|
public static string PackagePath(int n)
|
||||||
|
{
|
||||||
|
return FindClientFile(PackageName(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly object _pathSync = new object();
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, string> _paths =
|
||||||
|
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds a client file <c>Ultima.Files</c> has never heard of.
|
||||||
|
///
|
||||||
|
/// Only successful answers are cached: a file an operator copies in while the shard is
|
||||||
|
/// up should be found by the next import, and nothing here is hot enough for a
|
||||||
|
/// negative cache to be worth that.
|
||||||
|
/// </summary>
|
||||||
|
public static string FindClientFile(string name)
|
||||||
|
{
|
||||||
|
if (String.IsNullOrEmpty(name))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
lock (_pathSync)
|
||||||
|
{
|
||||||
|
string cached;
|
||||||
|
|
||||||
|
if (_paths.TryGetValue(name, out cached))
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string dir in Directories())
|
||||||
|
{
|
||||||
|
if (String.IsNullOrEmpty(dir))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(dir))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
string direct = Path.Combine(dir, name);
|
||||||
|
string hit = File.Exists(direct) ? direct : null;
|
||||||
|
|
||||||
|
if (hit == null)
|
||||||
|
{
|
||||||
|
foreach (string found in Directory.GetFiles(dir))
|
||||||
|
{
|
||||||
|
if (String.Equals(Path.GetFileName(found), name,
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
hit = found;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hit == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
lock (_pathSync)
|
||||||
|
_paths[name] = hit;
|
||||||
|
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] uop: cannot look in {0}: {1}", dir, e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> Directories()
|
||||||
|
{
|
||||||
|
string root = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
root = Files.RootDir;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ultima's static initialiser reads the registry on Windows. A host where that
|
||||||
|
// throws still has Core.DataDirectories, which is the path ServUO actually booted
|
||||||
|
// from.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!String.IsNullOrEmpty(root))
|
||||||
|
yield return root;
|
||||||
|
|
||||||
|
List<string> dirs = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
dirs = Core.DataDirectories;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Same reasoning; an empty list is a real answer and the caller reports absent.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dirs == null)
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
foreach (string dir in dirs)
|
||||||
|
yield return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The name a body's action file is stored under, hashed the way the container indexes
|
||||||
|
/// it. <c>Ultima.FileIndex.HashFileName</c> is pure arithmetic over a string — no file
|
||||||
|
/// is touched and no decoder is entered — so this is the one place phase 4 leans on
|
||||||
|
/// the vendored code, and it leans on it precisely so that our lookup cannot disagree
|
||||||
|
/// with the container's own.
|
||||||
|
/// </summary>
|
||||||
|
public static ulong HashOf(int body, int action)
|
||||||
|
{
|
||||||
|
string name = String.Format(CultureInfo.InvariantCulture,
|
||||||
|
"build/animationlegacyframe/{0:D6}/{1:D2}.bin", body, action);
|
||||||
|
|
||||||
|
return FileIndex.HashFileName(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the container ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private struct Entry
|
||||||
|
{
|
||||||
|
public long At;
|
||||||
|
public int CompressedLength;
|
||||||
|
public int DecompressedLength;
|
||||||
|
public short Flag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One opened <c>AnimationFrame*.uop</c>: its entry table in memory, its bytes on
|
||||||
|
/// demand. Opening one is a single pass over the block chain — 10,724 entries across
|
||||||
|
/// all five on this client — and the handle is held for the life of a reply, exactly
|
||||||
|
/// like the legacy readers next to it.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Package : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Dictionary<ulong, Entry> _entries;
|
||||||
|
private readonly FileStream _stream;
|
||||||
|
|
||||||
|
public readonly string Path;
|
||||||
|
|
||||||
|
private Package(string path, FileStream stream, Dictionary<ulong, Entry> entries)
|
||||||
|
{
|
||||||
|
Path = path;
|
||||||
|
_stream = stream;
|
||||||
|
_entries = entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Count
|
||||||
|
{
|
||||||
|
get { return _entries.Count; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the block chain, refusing anything that does not fit inside the file.
|
||||||
|
/// Returns null — never throws — because a client that ships a truncated package
|
||||||
|
/// is an ordinary thing to survive, not an error to raise.
|
||||||
|
/// </summary>
|
||||||
|
public static Package Open(string path)
|
||||||
|
{
|
||||||
|
if (String.IsNullOrEmpty(path))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
FileStream stream = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
stream = new FileStream(path, FileMode.Open, FileAccess.Read,
|
||||||
|
FileShare.ReadWrite);
|
||||||
|
|
||||||
|
long length = stream.Length;
|
||||||
|
|
||||||
|
var entries = new Dictionary<ulong, Entry>();
|
||||||
|
|
||||||
|
using (var br = new BinaryReader(stream, Encoding.UTF8, true))
|
||||||
|
{
|
||||||
|
if (length < 28 || br.ReadInt32() != PackageMagic)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] uop: {0} is not a Mythic package", path);
|
||||||
|
stream.Dispose();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
br.ReadInt32(); // version
|
||||||
|
br.ReadUInt32(); // signature
|
||||||
|
|
||||||
|
long nextBlock = br.ReadInt64();
|
||||||
|
|
||||||
|
br.ReadInt32(); // block capacity
|
||||||
|
br.ReadInt32(); // declared file count
|
||||||
|
|
||||||
|
while (nextBlock > 0)
|
||||||
|
{
|
||||||
|
// A block header is 12 bytes. Anything that does not leave room for
|
||||||
|
// one is a corrupt or cyclic chain, and this is where it stops.
|
||||||
|
if (nextBlock + 12 > length)
|
||||||
|
break;
|
||||||
|
|
||||||
|
stream.Seek(nextBlock, SeekOrigin.Begin);
|
||||||
|
|
||||||
|
int filesCount = br.ReadInt32();
|
||||||
|
long following = br.ReadInt64();
|
||||||
|
|
||||||
|
if (filesCount < 0
|
||||||
|
|| nextBlock + 12 + ((long)filesCount * BlockEntryBytes) > length)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < filesCount; i++)
|
||||||
|
{
|
||||||
|
long offset = br.ReadInt64();
|
||||||
|
int headerLength = br.ReadInt32();
|
||||||
|
int compressedLength = br.ReadInt32();
|
||||||
|
int decompressedLength = br.ReadInt32();
|
||||||
|
ulong hash = br.ReadUInt64();
|
||||||
|
|
||||||
|
br.ReadUInt32(); // adler32
|
||||||
|
|
||||||
|
short flag = br.ReadInt16();
|
||||||
|
|
||||||
|
if (offset <= 0 || headerLength < 0 || compressedLength <= 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (decompressedLength <= 0 || decompressedLength > MaxPayloadBytes)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
long at = offset + headerLength;
|
||||||
|
|
||||||
|
// The check FileIndex.Seek is missing, in the place it matters
|
||||||
|
// here too: that the record ENDS inside the file, not merely that
|
||||||
|
// it starts inside it (§4.5).
|
||||||
|
if (at < 0 || at + compressedLength > length)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (entries.Count >= MaxEntries)
|
||||||
|
break;
|
||||||
|
|
||||||
|
// First writer wins. Nothing on this client produces a collision
|
||||||
|
// — measured: no hash appears in two packages, and none twice in
|
||||||
|
// one — and if a patched client ever did, taking the first is the
|
||||||
|
// answer that does not depend on chain order.
|
||||||
|
if (!entries.ContainsKey(hash))
|
||||||
|
entries[hash] = new Entry
|
||||||
|
{
|
||||||
|
At = at,
|
||||||
|
CompressedLength = compressedLength,
|
||||||
|
DecompressedLength = decompressedLength,
|
||||||
|
Flag = flag
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (following <= nextBlock)
|
||||||
|
break; // a chain that does not move forward is a loop
|
||||||
|
|
||||||
|
nextBlock = following;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Package(path, stream, entries);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bridge] uop: cannot open {0}: {1}: {2}",
|
||||||
|
path, e.GetType().Name, e.Message);
|
||||||
|
|
||||||
|
if (stream != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
stream.Dispose();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Closing a read-only handle.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Has(ulong hash)
|
||||||
|
{
|
||||||
|
return _entries.ContainsKey(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The bytes behind one entry, decompressed. False with a reason is the ordinary
|
||||||
|
/// answer for "this package does not hold it".
|
||||||
|
/// </summary>
|
||||||
|
public bool TryRead(ulong hash, out byte[] payload, out string reason)
|
||||||
|
{
|
||||||
|
payload = null;
|
||||||
|
reason = null;
|
||||||
|
|
||||||
|
Entry entry;
|
||||||
|
|
||||||
|
if (!_entries.TryGetValue(hash, out entry))
|
||||||
|
{
|
||||||
|
reason = "not in " + System.IO.Path.GetFileName(Path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] raw;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_stream.Seek(entry.At, SeekOrigin.Begin);
|
||||||
|
|
||||||
|
raw = new byte[entry.CompressedLength];
|
||||||
|
|
||||||
|
if (!Fill(_stream, raw, raw.Length))
|
||||||
|
{
|
||||||
|
// The §4.5 failure, in our own code this time: a short read that nobody
|
||||||
|
// checked is how the library ends up decoding the previous asset.
|
||||||
|
reason = "record is shorter than the index claims";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
reason = "read failed: " + e.GetType().Name;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.Flag != 1)
|
||||||
|
{
|
||||||
|
if (raw.Length != entry.DecompressedLength)
|
||||||
|
{
|
||||||
|
reason = "stored record is " + raw.Length + " bytes, not the declared "
|
||||||
|
+ entry.DecompressedLength;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = raw;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TryInflate(raw, entry.DecompressedLength, out payload, out reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_stream.Dispose();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Closing a read-only handle. Nothing useful is left to do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Fill(Stream stream, byte[] into, int count)
|
||||||
|
{
|
||||||
|
int read = 0;
|
||||||
|
|
||||||
|
while (read < count)
|
||||||
|
{
|
||||||
|
int n = stream.Read(into, read, count - read);
|
||||||
|
|
||||||
|
if (n <= 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
read += n;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// zlib, which .NET Framework 4.8 does not expose — only raw deflate. The two-byte
|
||||||
|
/// zlib header is checked and skipped rather than assumed, because handing a
|
||||||
|
/// <c>DeflateStream</c> a stream that is not deflate produces garbage as readily as an
|
||||||
|
/// exception, and the trailing adler32 is left to the length check below: a stream
|
||||||
|
/// that inflates to exactly the declared number of bytes did not silently truncate.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryInflate(byte[] raw, int declared, out byte[] payload, out string reason)
|
||||||
|
{
|
||||||
|
payload = null;
|
||||||
|
reason = null;
|
||||||
|
|
||||||
|
if (raw.Length < 3)
|
||||||
|
{
|
||||||
|
reason = "compressed record is too short to be zlib";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int cmf = raw[0];
|
||||||
|
int flg = raw[1];
|
||||||
|
|
||||||
|
if ((cmf & 0x0F) != 8 || (((cmf << 8) + flg) % 31) != 0 || (flg & 0x20) != 0)
|
||||||
|
{
|
||||||
|
reason = "compressed record is not a zlib stream";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var output = new byte[declared];
|
||||||
|
|
||||||
|
using (var source = new MemoryStream(raw, 2, raw.Length - 2, false))
|
||||||
|
using (var inflate = new DeflateStream(source, CompressionMode.Decompress))
|
||||||
|
{
|
||||||
|
int read = 0;
|
||||||
|
|
||||||
|
while (read < declared)
|
||||||
|
{
|
||||||
|
int n = inflate.Read(output, read, declared - read);
|
||||||
|
|
||||||
|
if (n <= 0)
|
||||||
|
break;
|
||||||
|
|
||||||
|
read += n;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (read != declared)
|
||||||
|
{
|
||||||
|
reason = "inflated " + read + " bytes, not the declared " + declared;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One more byte would mean the record is longer than its own header says,
|
||||||
|
// which is a different file from the one we were promised.
|
||||||
|
if (inflate.ReadByte() != -1)
|
||||||
|
{
|
||||||
|
reason = "inflated past the declared " + declared + " bytes";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = output;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
reason = "inflate failed: " + e.GetType().Name;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the payload ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>One decoded frame: ARGB1555 in our own array, no <c>Bitmap</c> anywhere.</summary>
|
||||||
|
public sealed class Pixels
|
||||||
|
{
|
||||||
|
public int Width;
|
||||||
|
public int Height;
|
||||||
|
public int CenterX;
|
||||||
|
public int CenterY;
|
||||||
|
public ushort[] Argb1555;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One action of one body — every direction of it, concatenated.
|
||||||
|
///
|
||||||
|
/// The legacy files address a frame as <c>index + action * 5 + direction</c>; a UOP
|
||||||
|
/// group file holds the whole action in one record and the directions are equal-length
|
||||||
|
/// runs inside its frame table. So <see cref="DirectionAt"/> is where "direction 1" is
|
||||||
|
/// turned into a frame number, and it is integer division exactly as the reference
|
||||||
|
/// implementations do it — see the note there for the nine bodies where that matters.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Group
|
||||||
|
{
|
||||||
|
private readonly byte[] _buf;
|
||||||
|
private readonly int _dataStart;
|
||||||
|
|
||||||
|
public readonly int FrameCount;
|
||||||
|
public readonly int Body;
|
||||||
|
|
||||||
|
private Group(byte[] buf, int body, int frameCount, int dataStart)
|
||||||
|
{
|
||||||
|
_buf = buf;
|
||||||
|
Body = body;
|
||||||
|
FrameCount = frameCount;
|
||||||
|
_dataStart = dataStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryOpen(byte[] buf, int expectedBody, out Group group, out string reason)
|
||||||
|
{
|
||||||
|
group = null;
|
||||||
|
reason = null;
|
||||||
|
|
||||||
|
if (buf == null || buf.Length < 40)
|
||||||
|
{
|
||||||
|
reason = "payload is too short to carry a header";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BitConverter.ToInt32(buf, 0) != PayloadMagic)
|
||||||
|
{
|
||||||
|
reason = "payload is not an AMOU animation record";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int body = BitConverter.ToInt32(buf, 12);
|
||||||
|
|
||||||
|
// The container said which body this is, by the name it was stored under; the
|
||||||
|
// payload says it again. They agree on every record of this client, and the day
|
||||||
|
// they do not is the day something is being read that was not asked for.
|
||||||
|
if (body != expectedBody)
|
||||||
|
{
|
||||||
|
reason = "payload declares body " + body + ", not " + expectedBody;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int frameCount = BitConverter.ToInt32(buf, 32);
|
||||||
|
int dataStart = BitConverter.ToInt32(buf, 36);
|
||||||
|
|
||||||
|
if (frameCount <= 0 || frameCount > BridgeAssetValidator.MaxAnimFrames)
|
||||||
|
{
|
||||||
|
reason = "payload declares " + frameCount + " frames";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataStart < 40 || dataStart > buf.Length)
|
||||||
|
{
|
||||||
|
reason = "frame table starts at " + dataStart + " of " + buf.Length;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((long)dataStart + ((long)frameCount * FrameRowBytes) > buf.Length)
|
||||||
|
{
|
||||||
|
reason = "frame table of " + frameCount + " rows runs past the record";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
group = new Group(buf, body, frameCount, dataStart);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which frame of this action faces a given direction.
|
||||||
|
///
|
||||||
|
/// Five directions share the action's frames equally, so direction *d* starts at
|
||||||
|
/// <c>d * (FrameCount / 5)</c>. On nine of this client's 244 UOP bodies the frame
|
||||||
|
/// count is **not** a multiple of five (41, 42, 46…), and integer division then
|
||||||
|
/// lands a direction or so early in the run. That is what ClassicUO does, it is
|
||||||
|
/// the right trade, and the reason is §4.8's: the failure being guarded against is
|
||||||
|
/// a picture of the **wrong creature**, and this cannot produce one — the worst
|
||||||
|
/// case is the right creature at a slightly different angle, on nine bodies, where
|
||||||
|
/// refusing them instead would lose nine creatures outright.
|
||||||
|
/// </summary>
|
||||||
|
public int DirectionAt(int direction)
|
||||||
|
{
|
||||||
|
int perDirection = FrameCount / 5;
|
||||||
|
|
||||||
|
if (perDirection <= 0)
|
||||||
|
return direction == 0 ? 0 : -1;
|
||||||
|
|
||||||
|
if (direction < 0 || direction > 4)
|
||||||
|
return -1;
|
||||||
|
|
||||||
|
int at = direction * perDirection;
|
||||||
|
|
||||||
|
return at < FrameCount ? at : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decodes one frame, bounding every read against the record and every write
|
||||||
|
/// against the bitmap.
|
||||||
|
///
|
||||||
|
/// The run loop is <c>Ultima.Frame</c>'s, with the two bounds it does not have.
|
||||||
|
/// <c>Frame</c> writes through a <c>LockBits</c> pointer whose origin comes from
|
||||||
|
/// two signed shorts in the file and never checks where a run lands; here a run
|
||||||
|
/// that would leave the bitmap, or read past the record, refuses the frame. Across
|
||||||
|
/// every UOP body on a stock client that refuses nothing that carries art.
|
||||||
|
///
|
||||||
|
/// A 0×0 frame returns false with <paramref name="empty"/> set: the legacy decoder
|
||||||
|
/// treats that as no art rather than as damage, and so must this, or body 286
|
||||||
|
/// would be logged as a defect on every scan.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryDecode(int index, out Pixels pixels, out bool empty, out string reason)
|
||||||
|
{
|
||||||
|
pixels = null;
|
||||||
|
empty = false;
|
||||||
|
reason = null;
|
||||||
|
|
||||||
|
if (index < 0 || index >= FrameCount)
|
||||||
|
{
|
||||||
|
reason = "frame " + index + " of " + FrameCount;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int row = _dataStart + (index * FrameRowBytes);
|
||||||
|
|
||||||
|
long at = (long)row + (uint)BitConverter.ToInt32(_buf, row + 12);
|
||||||
|
|
||||||
|
if (at < 0 || at + PaletteBytes + 8 > _buf.Length)
|
||||||
|
{
|
||||||
|
reason = "frame " + index + " points outside the record";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int pixelAt = (int)at;
|
||||||
|
|
||||||
|
int centerX = BitConverter.ToInt16(_buf, pixelAt + PaletteBytes);
|
||||||
|
int centerY = BitConverter.ToInt16(_buf, pixelAt + PaletteBytes + 2);
|
||||||
|
int width = BitConverter.ToUInt16(_buf, pixelAt + PaletteBytes + 4);
|
||||||
|
int height = BitConverter.ToUInt16(_buf, pixelAt + PaletteBytes + 6);
|
||||||
|
|
||||||
|
if (width <= 0 || height <= 0)
|
||||||
|
{
|
||||||
|
empty = true;
|
||||||
|
reason = "frame " + index + " is " + width + "x" + height;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (width > BridgeAssetValidator.MaxArtDimension
|
||||||
|
|| height > BridgeAssetValidator.MaxArtDimension)
|
||||||
|
{
|
||||||
|
reason = "frame " + index + " declares " + width + "x" + height;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var palette = new ushort[0x100];
|
||||||
|
|
||||||
|
for (int i = 0; i < palette.Length; i++)
|
||||||
|
{
|
||||||
|
// The library's own xor: the stored entry has its alpha bit clear and every
|
||||||
|
// palette colour is opaque. A pixel no run covers stays zero, which is how a
|
||||||
|
// sprite keeps its transparent background.
|
||||||
|
palette[i] = (ushort)(BitConverter.ToUInt16(_buf, pixelAt + (i * 2)) ^ 0x8000);
|
||||||
|
}
|
||||||
|
|
||||||
|
var canvas = new ushort[width * height];
|
||||||
|
|
||||||
|
int p = pixelAt + PaletteBytes + 8;
|
||||||
|
|
||||||
|
int xBase = centerX - 0x200;
|
||||||
|
int yBase = (centerY + height) - 0x200;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (p + 4 > _buf.Length)
|
||||||
|
{
|
||||||
|
reason = "frame " + index + " has no terminator inside the record";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int header = BitConverter.ToInt32(_buf, p);
|
||||||
|
p += 4;
|
||||||
|
|
||||||
|
if (header == 0x7FFF7FFF)
|
||||||
|
break;
|
||||||
|
|
||||||
|
header ^= DoubleXor;
|
||||||
|
|
||||||
|
int x = ((header >> 22) & 0x3FF) + xBase;
|
||||||
|
int y = ((header >> 12) & 0x3FF) + yBase;
|
||||||
|
int run = header & 0xFFF;
|
||||||
|
|
||||||
|
if (run == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (p + run > _buf.Length)
|
||||||
|
{
|
||||||
|
reason = "frame " + index + " has a run past the end of the record";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (y < 0 || y >= height || x < 0 || x + run > width)
|
||||||
|
{
|
||||||
|
reason = "frame " + index + " has a run at " + x + "," + y + " of "
|
||||||
|
+ run + " outside " + width + "x" + height;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int cursor = (y * width) + x;
|
||||||
|
|
||||||
|
for (int i = 0; i < run; i++)
|
||||||
|
canvas[cursor + i] = palette[_buf[p + i]];
|
||||||
|
|
||||||
|
p += run;
|
||||||
|
}
|
||||||
|
|
||||||
|
pixels = new Pixels
|
||||||
|
{
|
||||||
|
Width = width,
|
||||||
|
Height = height,
|
||||||
|
CenterX = centerX,
|
||||||
|
CenterY = centerY,
|
||||||
|
Argb1555 = canvas
|
||||||
|
};
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1144
overlay/Scripts/Custom/Bridge/BridgeWorld.cs
Normal file
1144
overlay/Scripts/Custom/Bridge/BridgeWorld.cs
Normal file
File diff suppressed because it is too large
Load Diff
580
tools/patch_client.ps1
Normal file
580
tools/patch_client.ps1
Normal file
@@ -0,0 +1,580 @@
|
|||||||
|
<#
|
||||||
|
.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"
|
||||||
1065
tools/scaffolding/BridgeAssetProbe.cs
Normal file
1065
tools/scaffolding/BridgeAssetProbe.cs
Normal file
File diff suppressed because it is too large
Load Diff
687
tools/scaffolding/BridgeDemoDress.cs
Normal file
687
tools/scaffolding/BridgeDemoDress.cs
Normal file
@@ -0,0 +1,687 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
using Server.Accounting;
|
||||||
|
using Server.Commands;
|
||||||
|
using Server.Guilds;
|
||||||
|
using Server.Mobiles;
|
||||||
|
using Server.Multis;
|
||||||
|
|
||||||
|
namespace Server.Custom
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gives a BridgeSeeder world presentable names, so a shard standing behind a public
|
||||||
|
/// screenshot does not read as test data.
|
||||||
|
///
|
||||||
|
/// Test scaffolding. Not part of the bridge. Never deployed — see tools/README.md.
|
||||||
|
///
|
||||||
|
/// WHY THIS EXISTS
|
||||||
|
/// ---------------
|
||||||
|
/// BridgeSeeder builds a world at realistic SCALE, which is what the bridge needed:
|
||||||
|
/// 50 accounts, 150 characters, 30 houses, 30 vendors, 1,200 listings. It never needed
|
||||||
|
/// the world to look like anything, so a vendor is "seed vendor" trading as
|
||||||
|
/// "Seed Shop 810" and a character is "Seed004A". Every one of those names travels the
|
||||||
|
/// whole bridge — plugin, sidecar, website — and lands on the marketplace, the guild
|
||||||
|
/// roster and the housing pages, which are exactly the pages a screenshot wants.
|
||||||
|
///
|
||||||
|
/// This pass renames what is already there rather than seeding anything new. That
|
||||||
|
/// matters: the data keeps its provenance. The prices, the listing counts, the decay
|
||||||
|
/// stages, the fame and the skill sheets are all still whatever BridgeSeeder produced
|
||||||
|
/// and whatever the shard has done to them since — only the strings a human reads are
|
||||||
|
/// replaced. Nothing here invents shard state that the game did not produce.
|
||||||
|
///
|
||||||
|
/// IDEMPOTENT, AND DETERMINISTIC
|
||||||
|
/// -----------------------------
|
||||||
|
/// Names come from fixed tables indexed by the object's own serial, so the same vendor
|
||||||
|
/// draws the same shop name on every run against the same save — screenshots retaken
|
||||||
|
/// later still match. A second run is therefore a no-op, and a world half-dressed by an
|
||||||
|
/// interrupted run finishes cleanly.
|
||||||
|
///
|
||||||
|
/// Shop and house names are also re-dressed when they are names THIS pass produced, so
|
||||||
|
/// a change to the tables or to the hash can be applied to a world that has already been
|
||||||
|
/// through here once. Character names are not: a person's name is an ordinary string
|
||||||
|
/// with no closed set to recognise it by, so once dressed it is left alone.
|
||||||
|
///
|
||||||
|
/// WHAT IT ALSO DOES, AND WHY EACH IS HERE
|
||||||
|
/// ---------------------------------------
|
||||||
|
/// - Walks a few houses into IDOC, in two passes with a wait between them, because the
|
||||||
|
/// website only records a collapse it watched happen. Decay is a live process: by the
|
||||||
|
/// time anybody looks the stages have moved on and "Houses in danger" is empty. Empty
|
||||||
|
/// is a true state and a poor screenshot, so this stages a handful — see PrimeIdoc.
|
||||||
|
/// - Sets a known password on one seeded account. Logging a character in is the only
|
||||||
|
/// way to make the online roster non-empty, and it needs a client, and a client needs
|
||||||
|
/// a password. The seeder gives every account a random GUID nobody kept.
|
||||||
|
/// - BUILDS GUILDS, which is the one thing here that creates rather than renames. The
|
||||||
|
/// seeder never made any, so a shard behind these screenshots has an empty guild
|
||||||
|
/// board and — because the website's Teams are reconciled from that board — no teams
|
||||||
|
/// either. There is nothing to rename: a guild has to exist before it can be called
|
||||||
|
/// something. Members are drawn from characters the seeder already made, so the only
|
||||||
|
/// invention is the association itself.
|
||||||
|
///
|
||||||
|
/// A NOTE ON THE GUILD BOARD, FOUND WHILE BUILDING THIS
|
||||||
|
/// ---------------------------------------------------
|
||||||
|
/// `BridgeSocial.Signature()` folds name, abbreviation, leader serial, member count and
|
||||||
|
/// alliance — not member NAMES — and the roster is only re-emitted when the member SET
|
||||||
|
/// changes. So renaming a guild member never reaches the site: the board keeps the name
|
||||||
|
/// the member had when the roster was last emitted. Dressing a world that was already
|
||||||
|
/// published therefore leaves stale rosters behind, and creating the guilds after the
|
||||||
|
/// rename (as this does) is what avoids it. Raised as a product observation, not fixed
|
||||||
|
/// here — a rename is rare in a real shard, and the fix belongs in the plugin.
|
||||||
|
///
|
||||||
|
/// Flag: `DemoDressOnStart=True` in Config/Bridge.cfg. In game: `[demodress`.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeDemoDress
|
||||||
|
{
|
||||||
|
private const string Prefix = "seed_";
|
||||||
|
|
||||||
|
/// <summary>The account whose password is set, so a character can be logged in.</summary>
|
||||||
|
private const string LoginAccount = "seed_000";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read from Config/Bridge.cfg (`DemoDressPassword`) so a password never lands in
|
||||||
|
/// source control. Absent means the account is left alone.
|
||||||
|
/// </summary>
|
||||||
|
private static string LoginPassword
|
||||||
|
{
|
||||||
|
get { return Config.Get("Bridge.DemoDressPassword", default(string)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How many condemned houses to put back into the last two decay stages.</summary>
|
||||||
|
private const int IdocHouses = 4;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How long after boot the second IDOC pass runs. See <see cref="PrimeIdoc"/> —
|
||||||
|
/// the delay is the whole point, not a politeness.
|
||||||
|
/// </summary>
|
||||||
|
private static int IdocDelaySeconds
|
||||||
|
{
|
||||||
|
get { return Config.Get("Bridge.DemoDressIdocDelaySeconds", 150); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Name tables ────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Ordinary fantasy given names and English trade-sign nouns. Deliberately dull: the
|
||||||
|
// point is that a reader's eye passes over them, which is what a real roster does.
|
||||||
|
|
||||||
|
private static readonly string[] Given =
|
||||||
|
{
|
||||||
|
"Alaric", "Bess", "Corwin", "Dagna", "Edric", "Fenna", "Garrick", "Halle",
|
||||||
|
"Ivo", "Jessa", "Kellen", "Lira", "Marek", "Nessa", "Orrin", "Perrin",
|
||||||
|
"Quill", "Rowan", "Sera", "Tamsin", "Ulric", "Vera", "Wendel", "Xanthe",
|
||||||
|
"Yorick", "Zara", "Bram", "Caitrin", "Doran", "Elspeth"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] Family =
|
||||||
|
{
|
||||||
|
"Ashdown", "Bellweather", "Crowe", "Dunmore", "Eastgate", "Fairbourne",
|
||||||
|
"Grimsby", "Hollowell", "Ironwood", "Larkspur", "Mosswick", "Thornbury"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] ShopFirst =
|
||||||
|
{
|
||||||
|
"The Copper", "The Silver", "The Gilded", "The Iron", "The Rusted", "The Amber",
|
||||||
|
"The Quiet", "The Crooked", "The Old", "The Wandering", "The Salted", "The Ember"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] ShopSecond =
|
||||||
|
{
|
||||||
|
"Anvil", "Kettle", "Lantern", "Compass", "Bellows", "Flask", "Ledger",
|
||||||
|
"Wagon", "Tankard", "Whetstone", "Sextant", "Coffer"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] HouseNames =
|
||||||
|
{
|
||||||
|
"Ashwood Cottage", "Bramblegate", "Candlewick House", "Dovecote",
|
||||||
|
"Eastmarch", "Fernhollow", "Greywater", "Hearthstone",
|
||||||
|
"Ivyfall", "Kestrel Lodge", "Longmeadow", "Millrace",
|
||||||
|
"Northrest", "Oakenshaw", "Pinefall", "Quarrystone",
|
||||||
|
"Riverwatch", "Stonebrook", "Thistledown", "Umberley",
|
||||||
|
"Vinesend", "Westbarrow", "Yewcross", "Almsgate",
|
||||||
|
"Brightmoor", "Coldspring", "Duskvale", "Elmshade",
|
||||||
|
"Foxhollow", "Gravensward"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One guild to build, and how many of the seeded characters to put in it.
|
||||||
|
///
|
||||||
|
/// Four rather than one, and four of different sizes, because every screen that
|
||||||
|
/// shows guilds shows a LIST: a board with one row proves nothing about sorting,
|
||||||
|
/// member counts or the online column. The sizes are the shape a small shard
|
||||||
|
/// actually has — one large guild, one middling, two small.
|
||||||
|
/// </summary>
|
||||||
|
private struct GuildPlan
|
||||||
|
{
|
||||||
|
public readonly string Name;
|
||||||
|
public readonly string Abbr;
|
||||||
|
public readonly int Size;
|
||||||
|
|
||||||
|
public GuildPlan(string name, string abbr, int size)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
Abbr = abbr;
|
||||||
|
Size = size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly GuildPlan[] GuildsToBuild =
|
||||||
|
{
|
||||||
|
new GuildPlan("The Ashen Compact", "ASH", 14),
|
||||||
|
new GuildPlan("Hollowell Rangers", "HOL", 9),
|
||||||
|
new GuildPlan("The Quiet Ledger", "QLG", 6),
|
||||||
|
new GuildPlan("Wardens of Northrest", "WRD", 4)
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The first two guilds are allied, because `/uo/guilds` promises "rosters,
|
||||||
|
/// alliances and who's online" and an alliance column that is empty on every row
|
||||||
|
/// reads as a feature that does not work.
|
||||||
|
/// </summary>
|
||||||
|
private const string AllianceName = "The Northern Compact";
|
||||||
|
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
CommandSystem.Register("demodress", AccessLevel.Administrator, Dress_OnCommand);
|
||||||
|
|
||||||
|
if (Config.Get("Bridge.DemoDressOnStart", false))
|
||||||
|
EventSink.ServerStarted += () => Run(null, save: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Usage("demodress")]
|
||||||
|
[Description("Renames BridgeSeeder's synthetic world so it is presentable in screenshots.")]
|
||||||
|
private static void Dress_OnCommand(CommandEventArgs e)
|
||||||
|
{
|
||||||
|
Run(e.Mobile, save: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Report(Mobile to, string text)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[BridgeDemoDress] " + text);
|
||||||
|
|
||||||
|
if (to != null)
|
||||||
|
to.SendMessage(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Run(Mobile to, bool save)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var start = DateTime.UtcNow;
|
||||||
|
|
||||||
|
int chars = DressCharacters();
|
||||||
|
int vendors = DressVendors();
|
||||||
|
int houses = DressHouses();
|
||||||
|
|
||||||
|
// After the rename, never before: the roster the bridge publishes is the one
|
||||||
|
// that exists when the guild's member set first changes, and that is here.
|
||||||
|
int guilds = BuildGuilds(to);
|
||||||
|
|
||||||
|
bool password = SetLoginPassword(to);
|
||||||
|
|
||||||
|
Report(to, String.Format(
|
||||||
|
"Dressed {0} characters, {1} vendors, {2} house signs; " +
|
||||||
|
"built {3} guilds; login password {4}. ({5:F1}s)",
|
||||||
|
chars, vendors, houses, guilds, password ? "set" : "skipped",
|
||||||
|
(DateTime.UtcNow - start).TotalSeconds));
|
||||||
|
|
||||||
|
// IDOC is two steps, and at boot the second one is LATE. See PrimeIdoc.
|
||||||
|
Report(to, "Primed " + PrimeIdoc() + " houses for decay.");
|
||||||
|
|
||||||
|
if (save)
|
||||||
|
Timer.DelayCall(
|
||||||
|
TimeSpan.FromSeconds(IdocDelaySeconds),
|
||||||
|
() => Report(to, "Staged " + StageIdoc() + " houses into IDOC."));
|
||||||
|
else
|
||||||
|
Report(to, "Staged " + StageIdoc() + " houses into IDOC.");
|
||||||
|
|
||||||
|
if (save)
|
||||||
|
{
|
||||||
|
Report(to, "Saving world...");
|
||||||
|
World.Save();
|
||||||
|
Report(to, "Save complete.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Report(to, "FAILED: " + ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A stable index for a world object, salted so that two names drawn for the SAME
|
||||||
|
/// object land in unrelated places in their tables.
|
||||||
|
///
|
||||||
|
/// Serial is the only identifier that survives a save and is identical on every
|
||||||
|
/// load, which is what makes the naming reproducible. But serials are dense and
|
||||||
|
/// sequential, so a weak mix hands neighbouring objects neighbouring names. The
|
||||||
|
/// first attempt derived the second word from `serial / 5`, which is constant
|
||||||
|
/// across five consecutive serials — twenty-seven vendors came out as four
|
||||||
|
/// Flasks, four Lanterns and three Anvils in a row. Salting and re-mixing per
|
||||||
|
/// draw is what fixes that: each word is an independent hash of the pair.
|
||||||
|
/// </summary>
|
||||||
|
private static int Pick(int serial, int salt, int modulus)
|
||||||
|
{
|
||||||
|
unchecked
|
||||||
|
{
|
||||||
|
uint h = (uint)serial ^ ((uint)salt * 0x9E3779B1u);
|
||||||
|
h ^= h >> 15;
|
||||||
|
h *= 2246822519u;
|
||||||
|
h ^= h >> 13;
|
||||||
|
h *= 3266489917u;
|
||||||
|
h ^= h >> 16;
|
||||||
|
return (int)(h % (uint)modulus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string PersonName(int serial)
|
||||||
|
{
|
||||||
|
return Given[Pick(serial, 1, Given.Length)] + " " + Family[Pick(serial, 2, Family.Length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ShopSign(int serial)
|
||||||
|
{
|
||||||
|
return ShopFirst[Pick(serial, 3, ShopFirst.Length)] + " " +
|
||||||
|
ShopSecond[Pick(serial, 4, ShopSecond.Length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool LooksSeeded(string name, string marker)
|
||||||
|
{
|
||||||
|
return name != null && name.StartsWith(marker, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when a name is one this pass could have produced.
|
||||||
|
///
|
||||||
|
/// Dressing has to be re-runnable in both directions: a first pass renames what the
|
||||||
|
/// seeder left, and a later pass — after the tables or the hash change — has to be
|
||||||
|
/// able to rename its own earlier output. A name is recognised by MEMBERSHIP of the
|
||||||
|
/// closed tables rather than by a marker on the object, because the object is a
|
||||||
|
/// PlayerVendor whose name is a plain string with nowhere to hide a flag, and a
|
||||||
|
/// name that is not in the tables was set by a person and is left alone.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsOurs(string name, string[] first, string[] second)
|
||||||
|
{
|
||||||
|
if (String.IsNullOrEmpty(name))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
foreach (var a in first)
|
||||||
|
{
|
||||||
|
if (!name.StartsWith(a + " ", StringComparison.Ordinal))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var rest = name.Substring(a.Length + 1);
|
||||||
|
|
||||||
|
foreach (var b in second)
|
||||||
|
{
|
||||||
|
if (rest == b)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsOurHouseName(string name)
|
||||||
|
{
|
||||||
|
foreach (var h in HouseNames)
|
||||||
|
{
|
||||||
|
if (h == name)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int DressCharacters()
|
||||||
|
{
|
||||||
|
int n = 0;
|
||||||
|
|
||||||
|
foreach (Account acct in Accounts.GetAccounts())
|
||||||
|
{
|
||||||
|
if (!acct.Username.StartsWith(Prefix, StringComparison.Ordinal))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
for (int i = 0; i < acct.Length; i++)
|
||||||
|
{
|
||||||
|
var m = acct[i];
|
||||||
|
|
||||||
|
if (m == null || !LooksSeeded(m.Name, "Seed"))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Offset by the slot so an account's three characters are three people
|
||||||
|
// rather than three spellings of one.
|
||||||
|
m.Name = PersonName(m.Serial.Value + i * 101);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int DressVendors()
|
||||||
|
{
|
||||||
|
int n = 0;
|
||||||
|
|
||||||
|
if (PlayerVendor.PlayerVendors == null)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
// PlayerVendors is a live collection; the rename does not add or remove members,
|
||||||
|
// but copy anyway so an unrelated vendor placement mid-pass cannot invalidate it.
|
||||||
|
var vendors = new List<PlayerVendor>(PlayerVendor.PlayerVendors);
|
||||||
|
|
||||||
|
foreach (var vendor in vendors)
|
||||||
|
{
|
||||||
|
bool touched = false;
|
||||||
|
|
||||||
|
// "Bridge Test Shop" is not the seeder's — it is left over from a hand-run
|
||||||
|
// smoke test — and it reaches the marketplace exactly like the rest.
|
||||||
|
if (LooksSeeded(vendor.ShopName, "Seed Shop") ||
|
||||||
|
LooksSeeded(vendor.ShopName, "Bridge Test") ||
|
||||||
|
IsOurs(vendor.ShopName, ShopFirst, ShopSecond))
|
||||||
|
{
|
||||||
|
var sign = ShopSign(vendor.Serial.Value);
|
||||||
|
|
||||||
|
if (sign != vendor.ShopName)
|
||||||
|
{
|
||||||
|
vendor.ShopName = sign;
|
||||||
|
touched = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LooksSeeded(vendor.Name, "seed vendor"))
|
||||||
|
{
|
||||||
|
vendor.Name = PersonName(vendor.Serial.Value + 7919);
|
||||||
|
touched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (touched)
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int DressHouses()
|
||||||
|
{
|
||||||
|
int n = 0;
|
||||||
|
|
||||||
|
foreach (var house in BaseHouse.AllHouses)
|
||||||
|
{
|
||||||
|
if (house.Sign == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!LooksSeeded(house.Sign.Name, "Seed House") && !IsOurHouseName(house.Sign.Name))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var name = HouseNames[Pick(house.Serial.Value, 5, HouseNames.Length)];
|
||||||
|
|
||||||
|
if (name == house.Sign.Name)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
house.Sign.Name = name;
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The houses this run picked to walk into IDOC, held between the two passes so the
|
||||||
|
/// second one moves the same houses the first one primed.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly List<BaseHouse> _idocPicks = new List<BaseHouse>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Picks the houses that will collapse and puts them at a MIDDLE decay stage.
|
||||||
|
///
|
||||||
|
/// Only houses that CAN decay are touched — an active owner's AutoRefresh house is
|
||||||
|
/// left alone, because forcing one into IDOC would be inventing a state the game
|
||||||
|
/// would never produce and the next refresh would undo it anyway.
|
||||||
|
///
|
||||||
|
/// WHY THE STAGING IS TWO PASSES, WITH A WAIT BETWEEN THEM
|
||||||
|
/// ------------------------------------------------------
|
||||||
|
/// The website's "Houses in danger" page reads a column the ingest only writes when
|
||||||
|
/// the plugin reports a house CHANGING decay stage (`house.decay`). The richer
|
||||||
|
/// `house.update` registry frame carries the stage as well, but the ingest
|
||||||
|
/// deliberately leaves that column to the transition feed so the two cannot clobber
|
||||||
|
/// each other. A house that is ALREADY in IDOC when the site connects therefore
|
||||||
|
/// never appears: the plugin's baseline records IDOC as the starting state and no
|
||||||
|
/// transition is ever emitted. The first run of this pass hit exactly that — the
|
||||||
|
/// shard plainly had two collapsing houses and the page said none.
|
||||||
|
///
|
||||||
|
/// So: prime now, collapse later. The sweep takes its baseline at the middle stage
|
||||||
|
/// and then sees a real move to IDOC, which is the event the page is built to show.
|
||||||
|
/// The underlying asymmetry is a product observation, raised rather than patched
|
||||||
|
/// from here.
|
||||||
|
/// </summary>
|
||||||
|
private static int PrimeIdoc()
|
||||||
|
{
|
||||||
|
_idocPicks.Clear();
|
||||||
|
|
||||||
|
foreach (var house in BaseHouse.AllHouses)
|
||||||
|
{
|
||||||
|
if (_idocPicks.Count >= IdocHouses)
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (house == null || house.Deleted || !house.CanDecay)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
_idocPicks.Add(house);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most of the world cannot decay at all: a house whose owner's account is active
|
||||||
|
// is AutoRefresh, and AutoRefresh reports Ageless forever. The seeder condemned
|
||||||
|
// its houses by backdating the owner's last login, which is the same lever a real
|
||||||
|
// shard pulls when somebody stops playing — so where there are not enough
|
||||||
|
// candidates, condemn a few more the same way rather than forcing a stage that
|
||||||
|
// the next refresh would undo.
|
||||||
|
if (_idocPicks.Count < IdocHouses)
|
||||||
|
{
|
||||||
|
foreach (var house in BaseHouse.AllHouses)
|
||||||
|
{
|
||||||
|
if (_idocPicks.Count >= IdocHouses)
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (house == null || house.Deleted || house.CanDecay || house.Owner == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var acct = house.Owner.Account as Account;
|
||||||
|
|
||||||
|
// Never the account somebody is about to log in with: an inactive account
|
||||||
|
// is exactly what this is making, and logging in would undo it anyway.
|
||||||
|
if (acct == null || acct.Username == LoginAccount)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
acct.LastLogin = DateTime.UtcNow - TimeSpan.FromDays(365);
|
||||||
|
|
||||||
|
if (house.CanDecay)
|
||||||
|
_idocPicks.Add(house);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var house in _idocPicks)
|
||||||
|
{
|
||||||
|
house.SetDynamicDecay(DecayLevel.Fairly);
|
||||||
|
house.NextDecayStage = DateTime.UtcNow + TimeSpan.FromHours(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _idocPicks.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Collapses the primed houses. See <see cref="PrimeIdoc"/> for the two-step.</summary>
|
||||||
|
private static int StageIdoc()
|
||||||
|
{
|
||||||
|
int n = 0;
|
||||||
|
|
||||||
|
foreach (var house in _idocPicks)
|
||||||
|
{
|
||||||
|
if (house == null || house.Deleted)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Alternating, so the page shows a stage column doing something rather than
|
||||||
|
// four identical rows.
|
||||||
|
house.SetDynamicDecay(n % 2 == 0 ? DecayLevel.IDOC : DecayLevel.Greatly);
|
||||||
|
house.NextDecayStage = DateTime.UtcNow + TimeSpan.FromHours(6);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the guilds in <see cref="GuildsToBuild"/> out of seeded characters that
|
||||||
|
/// are not in a guild already, and allies the first two.
|
||||||
|
///
|
||||||
|
/// Idempotent by NAME: a guild that already exists is left exactly as it is, so a
|
||||||
|
/// second run adds nobody and a guild somebody has since edited in game is not
|
||||||
|
/// stamped back to the table. A character already in a guild is never moved, which
|
||||||
|
/// is what keeps a re-run from shuffling the world between screenshots.
|
||||||
|
///
|
||||||
|
/// Ranks are set rather than left at the default, because the roster the site draws
|
||||||
|
/// shows a rank per member and a page where every row says the same word tells a
|
||||||
|
/// reader nothing about what ranks are for. Real guilds are mostly members with a
|
||||||
|
/// couple of officers, so that is what this makes.
|
||||||
|
/// </summary>
|
||||||
|
private static int BuildGuilds(Mobile to)
|
||||||
|
{
|
||||||
|
var pool = UnguildedSeedCharacters();
|
||||||
|
var cursor = 0;
|
||||||
|
var made = 0;
|
||||||
|
|
||||||
|
var built = new List<Guild>();
|
||||||
|
|
||||||
|
foreach (var plan in GuildsToBuild)
|
||||||
|
{
|
||||||
|
var existing = FindGuild(plan.Name);
|
||||||
|
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
built.Add(existing);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor >= pool.Count)
|
||||||
|
{
|
||||||
|
Report(to, "Ran out of unguilded characters — " + plan.Name + " not built.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var leader = pool[cursor++];
|
||||||
|
var guild = new Guild(leader, plan.Name, plan.Abbr);
|
||||||
|
|
||||||
|
for (int i = 1; i < plan.Size && cursor < pool.Count; i++)
|
||||||
|
{
|
||||||
|
var member = pool[cursor++];
|
||||||
|
guild.AddMember(member);
|
||||||
|
|
||||||
|
var pm = member as PlayerMobile;
|
||||||
|
|
||||||
|
if (pm == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Two officers per guild, then members. RankDefinition.Ranks is
|
||||||
|
// { Ronin, Member, Emissary, Warlord, Leader } — Ronin is the default a
|
||||||
|
// fresh member gets, and a board of Ronins looks like nobody has ever
|
||||||
|
// touched the guild.
|
||||||
|
pm.GuildRank =
|
||||||
|
i == 1 ? RankDefinition.Ranks[3] :
|
||||||
|
i == 2 ? RankDefinition.Ranks[2] :
|
||||||
|
RankDefinition.Member;
|
||||||
|
}
|
||||||
|
|
||||||
|
built.Add(guild);
|
||||||
|
made++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (built.Count >= 2 && built[0].Alliance == null && built[1].Alliance == null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var alliance = new AllianceInfo(built[0], AllianceName, built[1]);
|
||||||
|
alliance.TurnToMember(built[1]);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Report(to, "Alliance not formed: " + ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return made;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every seeded character with no guild, in a stable order: account name, then
|
||||||
|
/// character slot. Stable ordering is what makes the same person lead the same
|
||||||
|
/// guild on every run against the same save.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Mobile> UnguildedSeedCharacters()
|
||||||
|
{
|
||||||
|
var accounts = new List<Account>();
|
||||||
|
|
||||||
|
foreach (Account acct in Accounts.GetAccounts())
|
||||||
|
{
|
||||||
|
if (acct.Username.StartsWith(Prefix, StringComparison.Ordinal))
|
||||||
|
accounts.Add(acct);
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts.Sort((a, b) => String.CompareOrdinal(a.Username, b.Username));
|
||||||
|
|
||||||
|
var chars = new List<Mobile>();
|
||||||
|
|
||||||
|
foreach (var acct in accounts)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < acct.Length; i++)
|
||||||
|
{
|
||||||
|
var m = acct[i];
|
||||||
|
|
||||||
|
if (m == null || m.Deleted || m.Guild != null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
chars.Add(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chars;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guild FindGuild(string name)
|
||||||
|
{
|
||||||
|
foreach (var bg in BaseGuild.List.Values)
|
||||||
|
{
|
||||||
|
var g = bg as Guild;
|
||||||
|
|
||||||
|
if (g != null && !g.Disbanded && g.Name == name)
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets a known password on one seeded account so a character can be logged in with
|
||||||
|
/// a real client. The seeder assigns a random GUID, which nobody kept.
|
||||||
|
/// </summary>
|
||||||
|
private static bool SetLoginPassword(Mobile to)
|
||||||
|
{
|
||||||
|
var password = LoginPassword;
|
||||||
|
|
||||||
|
if (String.IsNullOrEmpty(password))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var acct = Accounts.GetAccount(LoginAccount) as Account;
|
||||||
|
|
||||||
|
if (acct == null)
|
||||||
|
{
|
||||||
|
Report(to, "No account " + LoginAccount + " — password not set.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
acct.SetPassword(password);
|
||||||
|
|
||||||
|
// The seeder backdates some accounts past InactiveDuration to condemn their
|
||||||
|
// houses. This one has to be able to log in, so bring it back to the present.
|
||||||
|
acct.LastLogin = DateTime.UtcNow;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
532
tools/scaffolding/BridgeMythicCliloc.cs
Normal file
532
tools/scaffolding/BridgeMythicCliloc.cs
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Server.Custom
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A reader for the **Mythic compressed** cliloc container, in plain .NET Framework 4.8 C#.
|
||||||
|
///
|
||||||
|
/// This is the Asset Bridge's §9 decoder — the ONE decoder Protocol 8 writes rather than
|
||||||
|
/// calls (docs/link/v8.md §4, §9). ServUO's bundled <c>Ultima.StringList</c> implements only
|
||||||
|
/// the plain layout and throws <c>Non-negative number required</c> on every modern client's
|
||||||
|
/// file, which is also why the shard's own <c>VendorSearch.GetItemName</c> is already inert.
|
||||||
|
///
|
||||||
|
/// **Provenance.** Ported from UOFiddler's <c>Ultima/Helpers/MythicDecompress.cs</c>,
|
||||||
|
/// <c>MoveToFront.cs</c> and <c>StringList.TryParse</c> (polserver/UOFiddler). UOFiddler is
|
||||||
|
/// released under the **Beerware** licence, so carrying its algorithm into this
|
||||||
|
/// GPL-3.0-or-later tree is clean — see v8.md §9.
|
||||||
|
///
|
||||||
|
/// **What the port had to change**, and why the differences are not cosmetic:
|
||||||
|
///
|
||||||
|
/// * UOFiddler targets net10.0 and its implementation is written in <c>Span<T></c>,
|
||||||
|
/// <c>stackalloc</c>, <c>ArrayPool</c> and <c>BinaryPrimitives</c>. ServUO compiles the
|
||||||
|
/// overlay against net48 with no package feed, so all of that becomes plain arrays.
|
||||||
|
/// * Every read of the compressed payload is **bounds-checked here and is not there**.
|
||||||
|
/// Upstream indexes <c>input[m + 1024]</c> and <c>input[firstVal + 1024]</c> with
|
||||||
|
/// offsets derived from the file's own frequency header, inside a
|
||||||
|
/// <c>try { } catch (Exception) { return false; }</c>. That is adequate for a desktop
|
||||||
|
/// tool and is not adequate for us: this runs inside a live shard, and a corrupt or
|
||||||
|
/// hostile Cliloc.enu must produce a refusal, not an exception unwinding through the
|
||||||
|
/// bridge. Every such index is tested before use and returns <c>false</c> instead.
|
||||||
|
///
|
||||||
|
/// Phase 0 uses this from <see cref="BridgeAssetProbe"/> to prove the port reproduces
|
||||||
|
/// UOFiddler's own output exactly. **Phase 2 promotes this file into
|
||||||
|
/// <c>overlay/Scripts/Custom/Bridge/</c>** — it lives in scaffolding only for as long as it
|
||||||
|
/// is a spike.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeMythicCliloc
|
||||||
|
{
|
||||||
|
/// <summary>The first DWORD of a compressed file is the decompressed length, XORed with this.</summary>
|
||||||
|
private const uint HeaderXorKey = 0x8E2C9A3D;
|
||||||
|
|
||||||
|
/// <summary>256 little-endian int32 symbol frequencies precede the coded payload.</summary>
|
||||||
|
private const int FrequencyHeaderSize = 1024;
|
||||||
|
|
||||||
|
/// <summary>One decoded cliloc row. Mirrors <c>Ultima.StringEntry</c>'s three fields.</summary>
|
||||||
|
public struct Entry
|
||||||
|
{
|
||||||
|
public int Number;
|
||||||
|
public byte Flag;
|
||||||
|
public string Text;
|
||||||
|
|
||||||
|
public Entry(int number, byte flag, string text)
|
||||||
|
{
|
||||||
|
Number = number;
|
||||||
|
Flag = flag;
|
||||||
|
Text = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Container detection ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the file looks like the Mythic container. The marker is the high byte of
|
||||||
|
/// the first DWORD being <c>0x8E</c> — which is not a magic number in the file so much
|
||||||
|
/// as a consequence of <see cref="HeaderXorKey"/>: a plausible decompressed length is
|
||||||
|
/// small enough that its top byte is zero, so the XOR leaves 0x8E showing.
|
||||||
|
/// </summary>
|
||||||
|
public static bool LooksCompressed(byte[] buffer)
|
||||||
|
{
|
||||||
|
return buffer != null && buffer.Length >= 4 && buffer[3] == 0x8E;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The public entry point ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a cliloc file, compressed or plain, and returns its entries.
|
||||||
|
///
|
||||||
|
/// Tries the layout the header suggests first and the other one second — the same
|
||||||
|
/// fallback UOFiddler performs, and the reason an already-converted file passes
|
||||||
|
/// straight through. <paramref name="warning"/> is non-null when a layout parsed
|
||||||
|
/// *partially*: that is the case a caller must surface rather than swallow, because a
|
||||||
|
/// quietly short table is the failure mode the website's importer refuses.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryLoadFile(string path, out List<Entry> entries, out string warning, out string error)
|
||||||
|
{
|
||||||
|
entries = null;
|
||||||
|
warning = null;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
byte[] buffer;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
buffer = File.ReadAllBytes(path);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
error = "cannot read " + path + ": " + e.Message;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TryLoad(buffer, out entries, out warning, out error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads an in-memory cliloc file. See <see cref="TryLoadFile"/>.</summary>
|
||||||
|
public static bool TryLoad(byte[] buffer, out List<Entry> entries, out string warning, out string error)
|
||||||
|
{
|
||||||
|
entries = null;
|
||||||
|
warning = null;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
bool compressedFirst = LooksCompressed(buffer);
|
||||||
|
|
||||||
|
List<Entry> primary;
|
||||||
|
string primaryError;
|
||||||
|
bool primaryComplete;
|
||||||
|
|
||||||
|
if (TryParse(buffer, compressedFirst, out primary, out primaryComplete, out primaryError) && primaryComplete)
|
||||||
|
{
|
||||||
|
entries = primary;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Entry> fallback;
|
||||||
|
string fallbackError;
|
||||||
|
bool fallbackComplete;
|
||||||
|
|
||||||
|
if (TryParse(buffer, !compressedFirst, out fallback, out fallbackComplete, out fallbackError) && fallbackComplete)
|
||||||
|
{
|
||||||
|
entries = fallback;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neither layout parsed to the end. Take whichever salvaged more rows and say so.
|
||||||
|
int primaryCount = primary == null ? 0 : primary.Count;
|
||||||
|
int fallbackCount = fallback == null ? 0 : fallback.Count;
|
||||||
|
|
||||||
|
if (primaryCount == 0 && fallbackCount == 0)
|
||||||
|
{
|
||||||
|
error = "as " + Label(compressedFirst) + ": " + primaryError
|
||||||
|
+ "; as " + Label(!compressedFirst) + ": " + fallbackError;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primaryCount >= fallbackCount)
|
||||||
|
{
|
||||||
|
entries = primary;
|
||||||
|
warning = "parsed partially as " + Label(compressedFirst) + ": " + primaryError
|
||||||
|
+ " (" + primaryCount + " entries salvaged)";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
entries = fallback;
|
||||||
|
warning = "parsed partially as " + Label(!compressedFirst) + ": " + fallbackError
|
||||||
|
+ " (" + fallbackCount + " entries salvaged)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Label(bool compressed)
|
||||||
|
{
|
||||||
|
return compressed ? "compressed" : "uncompressed";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Record layout ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Walks the plain record layout: a 4-byte and a 2-byte header, then repeating
|
||||||
|
/// [int32 number][byte flag][uint16 length][length bytes of UTF-8].
|
||||||
|
///
|
||||||
|
/// <paramref name="complete"/> distinguishes "parsed to the end of the file" from
|
||||||
|
/// "stopped early but salvaged rows", which is the distinction the caller needs and
|
||||||
|
/// an exception would destroy.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryParse(byte[] buffer, bool decompress, out List<Entry> entries, out bool complete, out string error)
|
||||||
|
{
|
||||||
|
entries = new List<Entry>();
|
||||||
|
complete = false;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
byte[] data;
|
||||||
|
|
||||||
|
if (decompress)
|
||||||
|
{
|
||||||
|
if (!TryDecompress(buffer, out data, out error))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
data = buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.Length < 6)
|
||||||
|
{
|
||||||
|
error = "file is " + data.Length + " bytes, smaller than the 6-byte header";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int cursor = 6; // int32 version marker + int16 language marker
|
||||||
|
int lastNumber = -1;
|
||||||
|
|
||||||
|
while (cursor < data.Length)
|
||||||
|
{
|
||||||
|
int entryStart = cursor;
|
||||||
|
int remaining = data.Length - cursor;
|
||||||
|
|
||||||
|
if (remaining < 7)
|
||||||
|
{
|
||||||
|
error = "unexpected " + remaining + " trailing byte(s) at 0x" + entryStart.ToString("X")
|
||||||
|
+ " after entry #" + lastNumber + "; an entry header needs 7";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int number = ReadInt32(data, cursor);
|
||||||
|
byte flag = data[cursor + 4];
|
||||||
|
// Deliberately UNSIGNED. Read as Int16, a string of 32768 bytes or more comes back
|
||||||
|
// negative and corrupts every record after it.
|
||||||
|
int length = data[cursor + 5] | (data[cursor + 6] << 8);
|
||||||
|
cursor += 7;
|
||||||
|
|
||||||
|
if (length > data.Length - cursor)
|
||||||
|
{
|
||||||
|
error = "entry #" + number + " at 0x" + entryStart.ToString("X") + " declares length "
|
||||||
|
+ length + " but only " + (data.Length - cursor) + " byte(s) remain (parsed "
|
||||||
|
+ entries.Count + " so far)";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
string text;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
text = Encoding.UTF8.GetString(data, cursor, length);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
error = "entry #" + number + " at 0x" + entryStart.ToString("X") + " has " + length
|
||||||
|
+ " body bytes that are not valid UTF-8: " + e.Message;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor += length;
|
||||||
|
|
||||||
|
entries.Add(new Entry(number, flag, text));
|
||||||
|
lastNumber = number;
|
||||||
|
}
|
||||||
|
|
||||||
|
complete = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mythic stage 1: the XOR header and the move-to-front code ────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the obfuscated decompressed length from the first DWORD. Public so a caller
|
||||||
|
/// can size a buffer before committing to the decode.
|
||||||
|
/// </summary>
|
||||||
|
public static uint PeekDecompressedLength(byte[] source)
|
||||||
|
{
|
||||||
|
if (source == null || source.Length < 4)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
return ReadUInt32(source, 0) ^ HeaderXorKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decompresses the Mythic container: strip the 4-byte length header, undo the
|
||||||
|
/// move-to-front coding, then run stage 2.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryDecompress(byte[] source, out byte[] output, out string error)
|
||||||
|
{
|
||||||
|
output = null;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
if (source == null || source.Length < 4)
|
||||||
|
{
|
||||||
|
error = "payload shorter than the 4-byte length header";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint dataLength = ReadUInt32(source, 0) ^ HeaderXorKey;
|
||||||
|
|
||||||
|
// A wrong guess about the container makes this astronomically large, which is the
|
||||||
|
// cheapest possible rejection and must happen before any allocation.
|
||||||
|
if (dataLength == 0 || dataLength > int.MaxValue)
|
||||||
|
{
|
||||||
|
error = "implausible decompressed length " + dataLength + " — not the compressed layout";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mtf = new byte[source.Length - 4];
|
||||||
|
MoveToFrontDecode(source, 4, mtf);
|
||||||
|
|
||||||
|
var destination = new byte[(int)dataLength];
|
||||||
|
int written;
|
||||||
|
|
||||||
|
if (!TryInternalDecompress(mtf, destination, out written, out error))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (written != (int)dataLength)
|
||||||
|
{
|
||||||
|
error = "decompressed " + written + " bytes, header declared " + dataLength;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
output = destination;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Move-to-front decode. Each input byte is an index into a 256-symbol table; the
|
||||||
|
/// symbol found there is emitted and moved to the front.
|
||||||
|
/// </summary>
|
||||||
|
private static void MoveToFrontDecode(byte[] input, int offset, byte[] output)
|
||||||
|
{
|
||||||
|
var symbols = new byte[256];
|
||||||
|
|
||||||
|
for (int i = 0; i < 256; i++)
|
||||||
|
symbols[i] = (byte)i;
|
||||||
|
|
||||||
|
for (int i = 0; i < output.Length; i++)
|
||||||
|
{
|
||||||
|
int index = input[offset + i];
|
||||||
|
byte symbol = symbols[index];
|
||||||
|
output[i] = symbol;
|
||||||
|
|
||||||
|
for (int j = index; j > 0; j--)
|
||||||
|
symbols[j] = symbols[j - 1];
|
||||||
|
|
||||||
|
symbols[0] = symbol;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mythic stage 2 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns the MTF-decoded payload back into the original bytes.
|
||||||
|
///
|
||||||
|
/// The payload is a 1024-byte frequency header (256 little-endian int32 symbol counts)
|
||||||
|
/// followed by the coded stream. The counts partition the stream into one run per
|
||||||
|
/// symbol; <c>cursor[]</c> holds each run's read position and <c>limit[]</c> its end,
|
||||||
|
/// and the walk emits a symbol, advances that symbol's run, and re-orders the symbol
|
||||||
|
/// table by the index it reads.
|
||||||
|
///
|
||||||
|
/// Every index derived from file content is checked. Upstream's equivalent is wrapped
|
||||||
|
/// in a blanket catch; here a malformed file is a <c>false</c> with a reason.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryInternalDecompress(byte[] input, byte[] destination, out int written, out string error)
|
||||||
|
{
|
||||||
|
written = 0;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
if (input.Length < FrequencyHeaderSize)
|
||||||
|
{
|
||||||
|
error = "payload (" + input.Length + " bytes) is smaller than the 1024-byte frequency header";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var counts = new int[256]; // symbol → number of occurrences
|
||||||
|
var cursor = new int[256]; // symbol → next unread position in its run
|
||||||
|
var limit = new int[256]; // symbol → one past the end of its run
|
||||||
|
|
||||||
|
int sum = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < 256; i++)
|
||||||
|
{
|
||||||
|
counts[i] = ReadInt32(input, i * 4);
|
||||||
|
|
||||||
|
if (counts[i] < 0)
|
||||||
|
{
|
||||||
|
error = "frequency header declares a negative count for symbol " + i;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
sum += counts[i];
|
||||||
|
|
||||||
|
if (sum < 0)
|
||||||
|
{
|
||||||
|
error = "frequency header sums past int range at symbol " + i;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sum == 0)
|
||||||
|
{
|
||||||
|
written = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (destination.Length < sum)
|
||||||
|
{
|
||||||
|
error = "destination holds " + destination.Length + " bytes, payload needs " + sum;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int nonZeroCount = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < 256; i++)
|
||||||
|
{
|
||||||
|
if (counts[i] != 0)
|
||||||
|
nonZeroCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The coded stream must be long enough to hold one index per emitted byte.
|
||||||
|
if (input.Length - FrequencyHeaderSize < sum)
|
||||||
|
{
|
||||||
|
error = "coded stream holds " + (input.Length - FrequencyHeaderSize) + " bytes, frequency header claims " + sum;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var order = new byte[256];
|
||||||
|
FrequencyOrder(counts, order);
|
||||||
|
|
||||||
|
var symbolTable = new byte[256];
|
||||||
|
|
||||||
|
for (int i = 0; i < 256; i++)
|
||||||
|
symbolTable[i] = (byte)i;
|
||||||
|
|
||||||
|
for (int i = 0, m = 0; i < nonZeroCount; ++i)
|
||||||
|
{
|
||||||
|
byte symbol = order[i];
|
||||||
|
|
||||||
|
// m indexes the coded stream and comes from the file's own counts.
|
||||||
|
if (m < 0 || m >= input.Length - FrequencyHeaderSize)
|
||||||
|
{
|
||||||
|
error = "run table for symbol " + symbol + " starts at " + m + ", past the coded stream";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
symbolTable[input[m + FrequencyHeaderSize]] = symbol;
|
||||||
|
cursor[symbol] = m + 1;
|
||||||
|
m += counts[symbol];
|
||||||
|
limit[symbol] = m;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte val = symbolTable[0];
|
||||||
|
int count = 0;
|
||||||
|
int liveSymbols = nonZeroCount;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
destination[count] = val;
|
||||||
|
|
||||||
|
if (cursor[val] < limit[val])
|
||||||
|
{
|
||||||
|
int at = cursor[val] + FrequencyHeaderSize;
|
||||||
|
|
||||||
|
if (at < FrequencyHeaderSize || at >= input.Length)
|
||||||
|
{
|
||||||
|
error = "run for symbol " + val + " reads at " + at + ", past the " + input.Length + "-byte payload";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte index = input[at];
|
||||||
|
cursor[val]++;
|
||||||
|
|
||||||
|
if (index != 0)
|
||||||
|
{
|
||||||
|
ShiftLeft(symbolTable, index);
|
||||||
|
symbolTable[index] = val;
|
||||||
|
val = symbolTable[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (liveSymbols-- > 0)
|
||||||
|
{
|
||||||
|
ShiftLeft(symbolTable, liveSymbols);
|
||||||
|
val = symbolTable[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
while (count < sum);
|
||||||
|
|
||||||
|
written = sum;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Orders symbols by descending frequency: repeatedly take the largest remaining count
|
||||||
|
/// and record its symbol. Ties go to the lower symbol, because the scan keeps the first
|
||||||
|
/// strictly-greater value — matching upstream, and the tie-break is load-bearing.
|
||||||
|
/// </summary>
|
||||||
|
private static void FrequencyOrder(int[] counts, byte[] output)
|
||||||
|
{
|
||||||
|
var tmp = new int[256];
|
||||||
|
Array.Copy(counts, tmp, 256);
|
||||||
|
|
||||||
|
for (int i = 0; i < 256; i++)
|
||||||
|
{
|
||||||
|
int best = 0;
|
||||||
|
byte index = 0;
|
||||||
|
|
||||||
|
for (int j = 0; j < 256; j++)
|
||||||
|
{
|
||||||
|
if (tmp[j] > best)
|
||||||
|
{
|
||||||
|
index = (byte)j;
|
||||||
|
best = tmp[j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (best == 0)
|
||||||
|
break;
|
||||||
|
|
||||||
|
output[i] = index;
|
||||||
|
tmp[index] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Shifts <c>[1..element]</c> down one slot, dropping element 0.</summary>
|
||||||
|
private static void ShiftLeft(byte[] input, int element)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < element; ++i)
|
||||||
|
input[i] = input[i + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Little-endian readers (BinaryPrimitives is not available on net48) ───────────────
|
||||||
|
|
||||||
|
private static int ReadInt32(byte[] b, int at)
|
||||||
|
{
|
||||||
|
return b[at] | (b[at + 1] << 8) | (b[at + 2] << 16) | (b[at + 3] << 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint ReadUInt32(byte[] b, int at)
|
||||||
|
{
|
||||||
|
return (uint)(b[at] | (b[at + 1] << 8) | (b[at + 2] << 16) | (b[at + 3] << 24));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
159
tools/scaffolding/BridgeParticipationProbe.cs
Normal file
159
tools/scaffolding/BridgeParticipationProbe.cs
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
using Server.Commands;
|
||||||
|
using Server.Mobiles;
|
||||||
|
|
||||||
|
namespace Server.Custom
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Produces real kill credit inside a participation area, without a game client.
|
||||||
|
///
|
||||||
|
/// ── What this can drive, and what it cannot ───────────────────────────────────────────
|
||||||
|
///
|
||||||
|
/// The participation ledger counts two things: presence, and kill credit. Only one of them
|
||||||
|
/// is reachable from a headless rig, and the split is worth stating rather than discovering.
|
||||||
|
///
|
||||||
|
/// **Presence needs a connected client.** The sweep credits online players — `NetState !=
|
||||||
|
/// null` — which is the correct test and not one a probe should loosen: a character parked
|
||||||
|
/// in Britain and logged out for eight hours did not attend anything, and a ledger that said
|
||||||
|
/// otherwise would put people at the top of a leaderboard for being AFK. There is no way to
|
||||||
|
/// produce a NetState here short of writing a client, so presence accrual is exercised by a
|
||||||
|
/// real login and not by this file.
|
||||||
|
///
|
||||||
|
/// **Kill credit needs none.** `EventSink.CreatureDeath` fires for a creature killed by any
|
||||||
|
/// means, `Mobile.DamageEntries` is populated by real damage, and the area test is a
|
||||||
|
/// coordinate comparison. So the whole of the credit path — the damager filter, the
|
||||||
|
/// per-damager fold, the area test applied to the DAMAGER rather than only the corpse, the
|
||||||
|
/// member cap — runs exactly as it would in a fight.
|
||||||
|
///
|
||||||
|
/// What it does, in order: moves two real player mobiles to the venue, spawns a creature
|
||||||
|
/// there, damages it unequally from both, and kills it.
|
||||||
|
///
|
||||||
|
/// Test scaffolding. Never deployed; `deploy.ps1` copies only `overlay/`.
|
||||||
|
/// In game: `[partprobe <map> <x> <y>`. From a headless rig, through
|
||||||
|
/// `BridgeRigDriver`'s `partprobe` verb — the two ship together for that reason.
|
||||||
|
/// **Moves players and spawns and kills a creature. Rig only.**
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeParticipationProbe
|
||||||
|
{
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
CommandSystem.Register("partprobe", AccessLevel.Administrator, Probe_OnCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Usage("partprobe <map> <x> <y>")]
|
||||||
|
[Description("Moves two players to a point, spawns a creature there and kills it.")]
|
||||||
|
private static void Probe_OnCommand(CommandEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Length < 3)
|
||||||
|
{
|
||||||
|
Say(e.Mobile, "partprobe <map> <x> <y>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Run(e.Mobile, e.GetString(0), e.GetInt32(1), e.GetInt32(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Run(Mobile from, string mapName, int x, int y)
|
||||||
|
{
|
||||||
|
var map = MapByName(mapName);
|
||||||
|
|
||||||
|
if (map == null)
|
||||||
|
{
|
||||||
|
Say(from, "partprobe: unknown map " + mapName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var players = FindPlayers(2);
|
||||||
|
|
||||||
|
if (players.Count < 2)
|
||||||
|
{
|
||||||
|
Say(from, "partprobe: need two player mobiles in the world; found " + players.Count);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var z = map.GetAverageZ(x, y);
|
||||||
|
|
||||||
|
for (int i = 0; i < players.Count; i++)
|
||||||
|
{
|
||||||
|
// Spread them a tile apart so neither lands inside the other, and so the area test
|
||||||
|
// is answering about two distinct points rather than one.
|
||||||
|
players[i].MoveToWorld(new Point3D(x + i, y, z), map);
|
||||||
|
Say(from, String.Format(CultureInfo.InvariantCulture,
|
||||||
|
"partprobe: {0} moved to {1} ({2}, {3})", players[i].Name, map.Name, x + i, y));
|
||||||
|
}
|
||||||
|
|
||||||
|
var victim = new Mongbat();
|
||||||
|
victim.MoveToWorld(new Point3D(x, y + 1, z), map);
|
||||||
|
|
||||||
|
// Real damage through the real path, unequal so the fold is doing something: the
|
||||||
|
// ledger credits one kill per damager regardless of how much they did, and a table
|
||||||
|
// where both did the same amount could not show that.
|
||||||
|
//
|
||||||
|
// **Both amounts are small on purpose, and the first run of this probe is why.** A
|
||||||
|
// Mongbat has around thirty hit points, and an opening blow of 40 killed it where it
|
||||||
|
// stood -- so the SECOND damager never landed a hit, `DamageEntries` held one name,
|
||||||
|
// and the ledger correctly credited one player. The frame looked like a plugin bug
|
||||||
|
// crediting only the killer and was a rig artefact. A probe that means to produce two
|
||||||
|
// damagers has to leave the creature alive to receive the second one.
|
||||||
|
var hit = Math.Max(1, victim.HitsMax / 10);
|
||||||
|
victim.Damage(hit * 2, players[0]);
|
||||||
|
victim.Damage(hit, players[1]);
|
||||||
|
|
||||||
|
Say(from, String.Format(CultureInfo.InvariantCulture,
|
||||||
|
"partprobe: {0} spawned at ({1}, {2}) and damaged by {3} and {4}",
|
||||||
|
victim.Name, x, y + 1, players[0].Name, players[1].Name));
|
||||||
|
|
||||||
|
// Killed on the next tick rather than inline, so the damage above has actually been
|
||||||
|
// registered against the creature before CreatureDeath reads the entries.
|
||||||
|
Timer.DelayCall(TimeSpan.FromSeconds(1.0), () =>
|
||||||
|
{
|
||||||
|
victim.Kill();
|
||||||
|
Say(from, "partprobe: killed; the credit should now be on the ledger");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<PlayerMobile> FindPlayers(int count)
|
||||||
|
{
|
||||||
|
var found = new List<PlayerMobile>();
|
||||||
|
|
||||||
|
foreach (var m in World.Mobiles.Values)
|
||||||
|
{
|
||||||
|
var pm = m as PlayerMobile;
|
||||||
|
|
||||||
|
if (pm == null || pm.Deleted || pm.AccessLevel > AccessLevel.Player)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
found.Add(pm);
|
||||||
|
|
||||||
|
if (found.Count >= count)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map MapByName(string name)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < Map.Maps.Length; i++)
|
||||||
|
{
|
||||||
|
var map = Map.Maps[i];
|
||||||
|
|
||||||
|
if (map != null && String.Equals(map.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Say(Mobile to, string text)
|
||||||
|
{
|
||||||
|
if (to != null)
|
||||||
|
to.SendMessage(text);
|
||||||
|
else
|
||||||
|
Console.WriteLine("[PartProbe] " + text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
275
tools/scaffolding/BridgeProtocol5Probe.cs
Normal file
275
tools/scaffolding/BridgeProtocol5Probe.cs
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
using Server.Accounting;
|
||||||
|
using Server.Commands;
|
||||||
|
using Server.Mobiles;
|
||||||
|
using Server.Multis;
|
||||||
|
using Server.Network;
|
||||||
|
|
||||||
|
namespace Server.Custom
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Exercises all three Protocol 5 enrichments on a live shard, without a game client.
|
||||||
|
///
|
||||||
|
/// Each of the three needs something a unit test cannot produce, and each needs it for a
|
||||||
|
/// different reason:
|
||||||
|
///
|
||||||
|
/// * house.decay's `schedule` is only interesting ACROSS a transition, and the interesting
|
||||||
|
/// pair is Greatly -> IDOC: the first must carry no estimatedCollapse (under dynamic
|
||||||
|
/// decay the remaining stages have not been drawn yet) and the second must carry one.
|
||||||
|
/// A fixture can assert the mapping; only a real BaseHouse walking a real
|
||||||
|
/// SetDynamicDecay proves the emitter reads ServUO the way the comment claims.
|
||||||
|
/// * vendor.listing's `fees` are computed from PlayerVendor state that differs between
|
||||||
|
/// ServUO's two vendor systems. This reports what the shard actually holds so the
|
||||||
|
/// emitted frame can be checked against it rather than against an assumption.
|
||||||
|
/// * account.login.result is the one that could not be built at all before v5, because
|
||||||
|
/// EventSink.AccountLogin fires BEFORE the verdict exists. Invoking the real sink with a
|
||||||
|
/// real password (right and wrong) runs the shard's own AccountHandler, which is what
|
||||||
|
/// sets Accepted/RejectReason -- so this proves the deferred read sees the FINAL verdict
|
||||||
|
/// and not the constructor's default of true.
|
||||||
|
///
|
||||||
|
/// Test scaffolding. Never deployed; `deploy.ps1` copies only `overlay/`.
|
||||||
|
/// In game / at the console: `[p5probe`.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeProtocol5Probe
|
||||||
|
{
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
CommandSystem.Register("p5probe", AccessLevel.Administrator, Probe_OnCommand);
|
||||||
|
|
||||||
|
if (Config.Get("Bridge.Protocol5ProbeOnStart", false))
|
||||||
|
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(8.0), () => Run(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Usage("p5probe")]
|
||||||
|
[Description("Drives the three Protocol 5 enrichments so their frames can be observed.")]
|
||||||
|
private static void Probe_OnCommand(CommandEventArgs e)
|
||||||
|
{
|
||||||
|
Run(e.Mobile);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Report(Mobile to, string line)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[P5Probe] " + line);
|
||||||
|
|
||||||
|
if (to != null)
|
||||||
|
to.SendMessage(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Run(Mobile to)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ReportVendorFees(to);
|
||||||
|
DriveLogins(to);
|
||||||
|
WalkHouseToIdoc(to);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Report(to, "threw: " + ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- (a) house.decay schedule ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Walks one house Greatly, then (after a pause long enough for a decay sweep to run)
|
||||||
|
/// IDOC. Two frames, and the PAIR is the assertion: no estimatedCollapse on the first,
|
||||||
|
/// one on the second.
|
||||||
|
/// </summary>
|
||||||
|
private static void WalkHouseToIdoc(Mobile to)
|
||||||
|
{
|
||||||
|
BaseHouse target = null;
|
||||||
|
var byType = new Dictionary<string, int>();
|
||||||
|
|
||||||
|
foreach (var h in BaseHouse.AllHouses)
|
||||||
|
{
|
||||||
|
if (h == null || h.Deleted || h.Owner == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var type = h.DecayType.ToString();
|
||||||
|
byType[type] = (byType.ContainsKey(type) ? byType[type] : 0) + 1;
|
||||||
|
|
||||||
|
// CanDecay is the filter that matters, and getting it wrong is silent. A house
|
||||||
|
// whose DecayType is AutoRefresh or Ageless -- and the owner's NEWEST house is
|
||||||
|
// always AutoRefresh -- has a DecayLevel getter that calls ResetDynamicDecay() and
|
||||||
|
// reports Ageless, so a forced SetDynamicDecay is wiped on the very next read. The
|
||||||
|
// sweep then sees no change and emits nothing at all, which looks exactly like a
|
||||||
|
// broken emitter.
|
||||||
|
if (!h.CanDecay)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// The current level does NOT disqualify a house. On this rig every decaying house
|
||||||
|
// is already at IDOC (a seeded world has only a couple of Condemned houses and they
|
||||||
|
// have long since bottomed out), so the walk starts by putting one BACK to Fairly.
|
||||||
|
// BridgeDemoDress.PrimeIdoc does the same thing for the same reason.
|
||||||
|
target = h;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var kv in byType)
|
||||||
|
Report(to, "houses by DecayType: " + kv.Key + "=" + kv.Value);
|
||||||
|
|
||||||
|
if (target == null)
|
||||||
|
{
|
||||||
|
Report(to, "no walkable house found (none with CanDecay below IDOC)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Report(to, string.Format(
|
||||||
|
"walking house 0x{0:X} owner={1} decayType={2} from {3}; dynamicDecay={4}",
|
||||||
|
target.Serial.Value,
|
||||||
|
target.Owner == null ? "?" : target.Owner.Name,
|
||||||
|
target.DecayType,
|
||||||
|
target.DecayLevel,
|
||||||
|
DynamicDecay.Enabled));
|
||||||
|
|
||||||
|
// Each step needs its own sweep to land, or the sweep sees one net change and emits a
|
||||||
|
// single frame -- which would collapse the whole point, since the assertion is the
|
||||||
|
// DIFFERENCE between the Greatly frame and the IDOC one.
|
||||||
|
var step = TimeSpan.FromSeconds(Math.Max(4, BridgeConfigSeconds()) * 2 + 4);
|
||||||
|
|
||||||
|
Step(to, target, DecayLevel.Fairly, TimeSpan.Zero, "reset (no estimatedCollapse expected)");
|
||||||
|
Step(to, target, DecayLevel.Greatly, step, "expect schedule WITHOUT estimatedCollapse");
|
||||||
|
Step(to, target, DecayLevel.IDOC, TimeSpan.FromTicks(step.Ticks * 2), "expect schedule WITH estimatedCollapse");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Step(Mobile to, BaseHouse house, DecayLevel level, TimeSpan after, string note)
|
||||||
|
{
|
||||||
|
Action go = () =>
|
||||||
|
{
|
||||||
|
if (house.Deleted)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Report(to, string.Format("house 0x{0:X} -> {1} ({2})", house.Serial.Value, level, note));
|
||||||
|
house.SetDynamicDecay(level);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (after <= TimeSpan.Zero)
|
||||||
|
go();
|
||||||
|
else
|
||||||
|
Timer.DelayCall(after, () => go());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The decay sweep interval, read the same way the bridge reads it.</summary>
|
||||||
|
private static int BridgeConfigSeconds()
|
||||||
|
{
|
||||||
|
return Config.Get("Bridge.DecaySweepSeconds", 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- (b) vendor.listing fees ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prints the fee state of the first few player vendors straight off the PlayerVendor
|
||||||
|
/// objects, so the emitted `fees` block can be compared against the shard's own numbers
|
||||||
|
/// rather than against what the emitter believes them to be.
|
||||||
|
/// </summary>
|
||||||
|
private static void ReportVendorFees(Mobile to)
|
||||||
|
{
|
||||||
|
bool newSystem = BaseHouse.NewVendorSystem;
|
||||||
|
int shown = 0;
|
||||||
|
|
||||||
|
Report(to, "NewVendorSystem=" + newSystem);
|
||||||
|
|
||||||
|
foreach (var m in World.Mobiles.Values)
|
||||||
|
{
|
||||||
|
var v = m as PlayerVendor;
|
||||||
|
|
||||||
|
if (v == null || v.Deleted)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
int charge = newSystem ? v.ChargePerRealWorldDay : v.ChargePerDay;
|
||||||
|
int funds = newSystem ? v.HoldGold : v.BankAccount + v.HoldGold;
|
||||||
|
var acct = v.Owner == null ? null : v.Owner.Account as Account;
|
||||||
|
|
||||||
|
Report(to, string.Format(
|
||||||
|
"vendor 0x{0:X} owner={1} acct={2} commission={3} charge={4} funds={5} periods={6} nextPay={7:o}",
|
||||||
|
v.Serial.Value,
|
||||||
|
v.Owner == null ? "?" : v.Owner.Name,
|
||||||
|
acct == null ? "<none>" : acct.Username,
|
||||||
|
v.IsCommission,
|
||||||
|
charge,
|
||||||
|
funds,
|
||||||
|
charge > 0 ? (funds / charge).ToString() : "n/a",
|
||||||
|
v.NextPayTime.ToUniversalTime()));
|
||||||
|
|
||||||
|
if (++shown >= 3)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shown == 0)
|
||||||
|
Report(to, "no player vendors in the world");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- (c) account.login.result ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fires the real EventSink.AccountLogin twice against a real account: once with a
|
||||||
|
/// deliberately wrong password and once with the right one.
|
||||||
|
///
|
||||||
|
/// The shard's own AccountHandler is what decides, and it decides AFTER our handler has
|
||||||
|
/// returned. So a correct implementation emits `accepted:false reason:BadPass` for the
|
||||||
|
/// first and `accepted:true` for the second. An implementation that read the verdict
|
||||||
|
/// inside the handler would emit `accepted:true` for BOTH -- which is precisely the bug
|
||||||
|
/// this kind exists to make impossible, and precisely what this probe would show.
|
||||||
|
///
|
||||||
|
/// The password is read from config, never compiled in. `Bridge.Protocol5ProbeAccount`
|
||||||
|
/// and `Bridge.Protocol5ProbePassword`; with no password configured only the failing
|
||||||
|
/// half runs, which is still the half that matters.
|
||||||
|
/// </summary>
|
||||||
|
private static void DriveLogins(Mobile to)
|
||||||
|
{
|
||||||
|
var username = Config.Get("Bridge.Protocol5ProbeAccount", (string)null);
|
||||||
|
|
||||||
|
if (String.IsNullOrEmpty(username))
|
||||||
|
{
|
||||||
|
Report(to, "no Bridge.Protocol5ProbeAccount configured; skipping the login probe");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var password = Config.Get("Bridge.Protocol5ProbePassword", (string)null);
|
||||||
|
|
||||||
|
// Accounts store a hash, so the rig cannot READ a password to log in with -- it has to
|
||||||
|
// set one. Same posture as BridgeDemoDress, which does this for the same reason: the
|
||||||
|
// value comes from config and is never compiled in or logged.
|
||||||
|
if (!String.IsNullOrEmpty(password))
|
||||||
|
{
|
||||||
|
var acct = Accounts.GetAccount(username) as Account;
|
||||||
|
|
||||||
|
if (acct == null)
|
||||||
|
{
|
||||||
|
Report(to, "account '" + username + "' does not exist; skipping the login probe");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
acct.SetPassword(password);
|
||||||
|
Report(to, "set a known password on '" + username + "' for the accepted half");
|
||||||
|
}
|
||||||
|
|
||||||
|
Report(to, "login probe: '" + username + "' with a WRONG password (expect accepted:false)");
|
||||||
|
Fire(username, "definitely-not-the-password-" + Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
if (String.IsNullOrEmpty(password))
|
||||||
|
{
|
||||||
|
Report(to, "no Bridge.Protocol5ProbePassword configured; skipping the accepted half");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spaced out so the two results are unambiguous in the sidecar's history.
|
||||||
|
Timer.DelayCall(TimeSpan.FromSeconds(3.0), () =>
|
||||||
|
{
|
||||||
|
Report(to, "login probe: '" + username + "' with the RIGHT password (expect accepted:true)");
|
||||||
|
Fire(username, password);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Fire(string username, string password)
|
||||||
|
{
|
||||||
|
// A null NetState is deliberate and is itself part of the test: the real emitter reads
|
||||||
|
// the address defensively because AccountLogin_ReplyRej disposes the state before the
|
||||||
|
// deferred read runs, so it must already survive not having one.
|
||||||
|
EventSink.InvokeAccountLogin(new AccountLoginEventArgs(null, username, password));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
201
tools/scaffolding/BridgeProtocol6Probe.cs
Normal file
201
tools/scaffolding/BridgeProtocol6Probe.cs
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
using Server.Commands;
|
||||||
|
using Server.Custom.Bridge;
|
||||||
|
using Server.Engines.CannedEvil;
|
||||||
|
using Server.Mobiles;
|
||||||
|
|
||||||
|
namespace Server.Custom
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Exercises the two halves of Protocol 6 on a live shard, without a game client.
|
||||||
|
///
|
||||||
|
/// **Idempotency needs no probe.** It is driven from the OTHER end — two identical POSTs to
|
||||||
|
/// the sidecar, the second of which must come back `replayed: true` under its own reqId — so
|
||||||
|
/// a curl and the shard's own audit trail are the whole test. Nothing here would make that
|
||||||
|
/// more convincing.
|
||||||
|
///
|
||||||
|
/// `champ.boss.killed` is the opposite case. It cannot be produced from outside the game at
|
||||||
|
/// all: a champion boss appears only when a spawn is driven to its final level, and the
|
||||||
|
/// damage table the frame carries is assembled by real combat against a real creature. A
|
||||||
|
/// fixture can assert the shape of the JSON; only this proves that
|
||||||
|
/// `EventSink.CreatureDeath` fires for a `BaseChampion`, that `DamageEntries` still holds
|
||||||
|
/// anything by the time it does, and that the sweep's spawn attribution is there to name the
|
||||||
|
/// altar.
|
||||||
|
///
|
||||||
|
/// What it does, in order:
|
||||||
|
///
|
||||||
|
/// 1. Places a real `ChampionSpawn`, activates it and calls `SpawnChampion()` — the
|
||||||
|
/// shard's own code path, not a hand-constructed creature.
|
||||||
|
/// 2. Waits for the champ sweep to see it, so the boss is attributed to its altar exactly
|
||||||
|
/// the way a real one would be. **This wait is the assertion**: run without it and the
|
||||||
|
/// kill still emits, but with no `serial`, `type` or `level` — which is the phase's own
|
||||||
|
/// documented fallback rather than the case being tested.
|
||||||
|
/// 3. Damages it from two real player mobiles found in the world, so the damage table has
|
||||||
|
/// two ranked entries rather than none.
|
||||||
|
/// 4. Kills it and cleans up the altar.
|
||||||
|
///
|
||||||
|
/// Test scaffolding. Never deployed; `deploy.ps1` copies only `overlay/`.
|
||||||
|
/// In game / at the console: `[p6probe`. Flag: `Protocol6ProbeOnStart`.
|
||||||
|
/// **Spawns and kills a champion boss.** Use on a rig, never on a live shard.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeProtocol6Probe
|
||||||
|
{
|
||||||
|
private static ChampionSpawn _spawn;
|
||||||
|
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
CommandSystem.Register("p6probe", AccessLevel.Administrator, Probe_OnCommand);
|
||||||
|
|
||||||
|
if (Config.Get("Bridge.Protocol6ProbeOnStart", false))
|
||||||
|
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(10.0), () => Run(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Usage("p6probe")]
|
||||||
|
[Description("Spawns a champion boss, damages it from two players and kills it.")]
|
||||||
|
private static void Probe_OnCommand(CommandEventArgs e)
|
||||||
|
{
|
||||||
|
Run(e == null ? null : e.Mobile);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Say(Mobile to, string text)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[p6probe] {0}", text);
|
||||||
|
|
||||||
|
if (to != null)
|
||||||
|
to.SendMessage(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Run(Mobile from)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Inside a NAMED region, deliberately. A champion altar really lives in a dungeon
|
||||||
|
// and the first version of this probe put one there — but the dungeon floor at
|
||||||
|
// Destard belongs to the map's default region, whose Name is empty, so the emitted
|
||||||
|
// frame carried no `region` at all and the one field a phase condition is most
|
||||||
|
// likely to match on ("the boss in Yew") went unproven. Britain has a named region,
|
||||||
|
// so this exercises the field rather than the guard that omits it.
|
||||||
|
var where = new Point3D(1496, 1628, 10);
|
||||||
|
var map = Map.Felucca;
|
||||||
|
|
||||||
|
Cleanup();
|
||||||
|
|
||||||
|
_spawn = new ChampionSpawn();
|
||||||
|
_spawn.MoveToWorld(where, map);
|
||||||
|
_spawn.Type = ChampionSpawnType.Abyss;
|
||||||
|
_spawn.AutoRestart = false;
|
||||||
|
_spawn.Active = true;
|
||||||
|
|
||||||
|
Say(from, "altar placed; spawning its champion");
|
||||||
|
|
||||||
|
_spawn.SpawnChampion();
|
||||||
|
|
||||||
|
var boss = _spawn.Champion;
|
||||||
|
|
||||||
|
if (boss == null)
|
||||||
|
{
|
||||||
|
Say(from, "FAILED: the spawn produced no champion");
|
||||||
|
Cleanup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Say(from, String.Format("champion up: {0} ({1}) serial {2} region {3}",
|
||||||
|
boss.Name, boss.GetType().Name, boss.Serial,
|
||||||
|
boss.Region == null ? "(none)" : ("\"" + boss.Region.Name + "\"")));
|
||||||
|
|
||||||
|
// Give the sweep time to attribute the boss to its altar. Two intervals, because a
|
||||||
|
// single one races the timer that is already part-way through its period.
|
||||||
|
var wait = TimeSpan.FromSeconds(Math.Max(2.0, BridgeConfig.ChampSweepSeconds * 2.0));
|
||||||
|
|
||||||
|
Say(from, String.Format("waiting {0:0}s for the champ sweep to see it", wait.TotalSeconds));
|
||||||
|
|
||||||
|
Timer.DelayCall(wait, () => Finish(from, boss));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Say(from, "threw: " + ex);
|
||||||
|
Cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Finish(Mobile from, Mobile boss)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (boss == null || boss.Deleted)
|
||||||
|
{
|
||||||
|
Say(from, "FAILED: the champion vanished before it could be killed");
|
||||||
|
Cleanup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two real players, so the damage table has two ranked entries and the ranking is
|
||||||
|
// testable rather than trivially one row. Registered through Mobile.RegisterDamage,
|
||||||
|
// which is the same call combat makes.
|
||||||
|
var players = World.Mobiles.Values
|
||||||
|
.OfType<PlayerMobile>()
|
||||||
|
.Where(p => !p.Deleted && p.Account != null)
|
||||||
|
.Take(2)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (players.Count < 2)
|
||||||
|
{
|
||||||
|
Say(from, "note: fewer than two player mobiles in the world; the table will be short");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < players.Count; i++)
|
||||||
|
{
|
||||||
|
// Deliberately unequal and deliberately in ascending order, so a frame that
|
||||||
|
// reported them in arrival order rather than by damage would be visibly wrong.
|
||||||
|
int amount = 120 * (i + 1);
|
||||||
|
boss.RegisterDamage(amount, players[i]);
|
||||||
|
Say(from, String.Format("registered {0} damage from {1}", amount, players[i].Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
var killer = players.Count > 0 ? players[players.Count - 1] : null;
|
||||||
|
|
||||||
|
Say(from, "killing the champion");
|
||||||
|
|
||||||
|
boss.Damage(boss.HitsMax * 10, killer);
|
||||||
|
|
||||||
|
if (!boss.Deleted && boss.Alive)
|
||||||
|
{
|
||||||
|
Say(from, "note: it survived the blow; killing it outright");
|
||||||
|
boss.Kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer.DelayCall(TimeSpan.FromSeconds(2.0), () =>
|
||||||
|
{
|
||||||
|
Cleanup();
|
||||||
|
Say(from, "done — check the sidecar feed for champ.boss.killed");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Say(from, "threw: " + ex);
|
||||||
|
Cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Cleanup()
|
||||||
|
{
|
||||||
|
if (_spawn == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_spawn.Active = false;
|
||||||
|
_spawn.Delete();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// The altar is scaffolding; failing to tidy it is not worth an exception.
|
||||||
|
}
|
||||||
|
|
||||||
|
_spawn = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
802
tools/scaffolding/BridgeRigDriver.cs
Normal file
802
tools/scaffolding/BridgeRigDriver.cs
Normal file
@@ -0,0 +1,802 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
using Server.Accounting;
|
||||||
|
using Server.Commands;
|
||||||
|
using Server.Engines.CityLoyalty;
|
||||||
|
using Server.Mobiles;
|
||||||
|
using Server.Multis;
|
||||||
|
|
||||||
|
namespace Server.Custom
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Drives the shard from OUTSIDE the game, one verb per line in a file the driver polls.
|
||||||
|
///
|
||||||
|
/// Every other probe here runs a fixed script at boot or from `[command`, and both are the
|
||||||
|
/// wrong shape for an acceptance walk: a walk asserts what happened BETWEEN two steps
|
||||||
|
/// ("one mail, then nothing for a day"), so the steps have to be separated by the observer
|
||||||
|
/// rather than by a hard-coded delay -- and ServUO's console reads a fixed verb set
|
||||||
|
/// (`Scripts/Misc/ConsoleCommands.cs`), so `[p5probe` cannot be typed at a headless shard
|
||||||
|
/// at all. A file is the one channel a headless shard already has.
|
||||||
|
///
|
||||||
|
/// Write one or more lines to `Config/rigcmd.txt`; the driver runs them on the Core thread
|
||||||
|
/// within a second, prints `[RigDriver]` lines, and TRUNCATES the file so the next write is
|
||||||
|
/// the next command. Output is console-only: nothing here emits, and everything observed
|
||||||
|
/// travels the real bridge.
|
||||||
|
///
|
||||||
|
/// Verbs:
|
||||||
|
/// decaylist houses that CAN decay, with owner account and stage
|
||||||
|
/// decay <serial|any> <stage> force a decay stage (LikeNew|Slightly|Somewhat|
|
||||||
|
/// Fairly|Greatly|IDOC|Collapsed)
|
||||||
|
/// vendorlist player vendors, with owner account and next pay time
|
||||||
|
/// vendorfunds <serial> <gold> set a vendor's held gold (drives periodsRemaining)
|
||||||
|
/// citylist cities, governors and election phases
|
||||||
|
/// governor <city> <mobile|none> seat a governor (a mobile serial, or a player's name)
|
||||||
|
/// election <city> force a new election into its nomination window
|
||||||
|
/// activate <account> clear an account's inactivity, so its houses stop
|
||||||
|
/// being Condemned and CAN be refreshed
|
||||||
|
/// password <account> <pw> set a game account's password (for a login probe)
|
||||||
|
/// worldgone <serial> delete an object BEHIND the ownership registry's
|
||||||
|
/// back, playing the player who killed it
|
||||||
|
/// spawnerlist [n] name a few XmlSpawners, serial AND UniqueId --
|
||||||
|
/// the two ways a property lease names its target
|
||||||
|
/// propset <target> <prop> <v> set a property BEHIND the lease plane's back,
|
||||||
|
/// which is the only way to reach `drifted` here
|
||||||
|
/// propread <target> <prop> read one back, to assert a restore landed
|
||||||
|
/// seasonlist every seasonal event and its status
|
||||||
|
/// save a world save
|
||||||
|
/// shutdown a CLEAN shutdown, so the bridge emits server.shutdown
|
||||||
|
///
|
||||||
|
/// Test scaffolding. Never deployed; `deploy.ps1` copies only `overlay/`.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeRigDriver
|
||||||
|
{
|
||||||
|
private static string _path;
|
||||||
|
private static DateTime _lastWrite = DateTime.MinValue;
|
||||||
|
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
if (!Config.Get("Bridge.RigDriverEnabled", false))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_path = Path.Combine(Core.BaseDirectory, "Config", "rigcmd.txt");
|
||||||
|
|
||||||
|
CommandSystem.Register("rigdriver", AccessLevel.Administrator, e => Poll());
|
||||||
|
|
||||||
|
Console.WriteLine("[RigDriver] watching {0}", _path);
|
||||||
|
Timer.DelayCall(TimeSpan.FromSeconds(2.0), TimeSpan.FromSeconds(1.0), Poll);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the poll ----
|
||||||
|
|
||||||
|
private static void Poll()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(_path))
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Written-and-not-finished is a real case: the observer writes with a shell
|
||||||
|
// redirect while this timer fires. An empty file is nothing to do, and the
|
||||||
|
// timestamp guard keeps a slow write from being run twice.
|
||||||
|
var stamp = File.GetLastWriteTimeUtc(_path);
|
||||||
|
if (stamp <= _lastWrite)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var lines = File.ReadAllLines(_path);
|
||||||
|
if (lines.Length == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_lastWrite = stamp;
|
||||||
|
File.WriteAllText(_path, String.Empty);
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
var trimmed = (line ?? String.Empty).Trim();
|
||||||
|
if (trimmed.Length == 0 || trimmed.StartsWith("#"))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Run(trimmed);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Say("\"" + trimmed + "\" threw: " + ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Say("done");
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// The writer still holds it. Next tick.
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Say("poll threw: " + ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Say(string line)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[RigDriver] " + line);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Run(string line)
|
||||||
|
{
|
||||||
|
var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var verb = parts[0].ToLowerInvariant();
|
||||||
|
|
||||||
|
switch (verb)
|
||||||
|
{
|
||||||
|
case "decaylist": DecayList(); break;
|
||||||
|
case "decay": Decay(Arg(parts, 1), Arg(parts, 2)); break;
|
||||||
|
case "vendorlist": VendorList(); break;
|
||||||
|
case "vendorfunds": VendorFunds(Arg(parts, 1), Arg(parts, 2)); break;
|
||||||
|
case "citylist": CityList(); break;
|
||||||
|
case "governor": Governor(Arg(parts, 1), Arg(parts, 2)); break;
|
||||||
|
case "election": Election(Arg(parts, 1)); break;
|
||||||
|
case "activate": Activate(Arg(parts, 1)); break;
|
||||||
|
case "password": Password(Arg(parts, 1), Arg(parts, 2)); break;
|
||||||
|
// Phase 11b. Plays the interfering GM a config lease's compare-and-set exists to
|
||||||
|
// catch, and reads a key back the way the game reads it. Both halves are here
|
||||||
|
// rather than only in `[leaseprobe` because a headless rig has no client to type
|
||||||
|
// a command at, and ServUO's own console takes a fixed verb set.
|
||||||
|
case "configset": ConfigSet(Arg(parts, 1), Arg(parts, 2)); break;
|
||||||
|
case "configread": ConfigRead(Arg(parts, 1)); break;
|
||||||
|
// Kill credit inside a participation area. Lives in BridgeParticipationProbe
|
||||||
|
// because it moves mobiles and spawns a creature; reachable from here because a
|
||||||
|
// headless rig has no client to type `[partprobe` at. The two files ship together.
|
||||||
|
case "partprobe":
|
||||||
|
BridgeParticipationProbe.Run(null, Arg(parts, 1), Int(Arg(parts, 2)), Int(Arg(parts, 3)));
|
||||||
|
break;
|
||||||
|
// Asset Bridge phase 0. Here for the same reason as partprobe, and for one more:
|
||||||
|
// the point of that spike is comparing the STOCK client's answers with a patched
|
||||||
|
// client's, and `AssetProbeOnStart` can only ever run whichever one the config
|
||||||
|
// names. Driving it from here runs both against a single boot, so a difference
|
||||||
|
// between them cannot be a difference between two shard processes.
|
||||||
|
case "assetprobe":
|
||||||
|
BridgeAssetProbe.Begin(null, Arg(parts, 1) ?? "all", Arg(parts, 2));
|
||||||
|
break;
|
||||||
|
// Phase 12a. `world.despawn` answering `gone` rather than `removed` is the
|
||||||
|
// path a player takes every time they kill an event creature, and it is the one
|
||||||
|
// outcome the rig cannot reach by asking the bridge: every bridge verb that
|
||||||
|
// removes an object also drops its registry row, so the two never disagree.
|
||||||
|
// This deletes the object and leaves the row, which is exactly what a sword does.
|
||||||
|
case "worldgone": WorldGone(Arg(parts, 1)); break;
|
||||||
|
case "spawnerlist": SpawnerList(Arg(parts, 1)); break;
|
||||||
|
case "propset": PropSet(Arg(parts, 1), Arg(parts, 2), Arg(parts, 3)); break;
|
||||||
|
case "propread": PropRead(Arg(parts, 1), Arg(parts, 2)); break;
|
||||||
|
case "seasonlist": SeasonList(); break;
|
||||||
|
case "save": Say("saving"); Misc.AutoSave.Save(); break;
|
||||||
|
// A clean shutdown, which is the only kind that EMITS. `Stop-Process` drops the
|
||||||
|
// socket and the shard says nothing, so a killed shard is indistinguishable from
|
||||||
|
// a wedged one -- and `uo.server.down` never fires. Core.Kill runs
|
||||||
|
// EventSink.Shutdown, which is what BridgeBoot listens on.
|
||||||
|
case "shutdown": Say("shutting down"); Timer.DelayCall(TimeSpan.Zero, () => Core.Kill(false)); break;
|
||||||
|
default: Say("unknown verb \"" + verb + "\""); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes an object by serial, without telling anything.
|
||||||
|
///
|
||||||
|
/// Accepts the `0x…` form the bridge writes serials in, so a serial can be pasted
|
||||||
|
/// straight out of a `world.owned` reply.
|
||||||
|
/// </summary>
|
||||||
|
private static void WorldGone(string raw)
|
||||||
|
{
|
||||||
|
if (String.IsNullOrEmpty(raw))
|
||||||
|
{
|
||||||
|
Say("worldgone <serial>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = raw.Trim();
|
||||||
|
uint parsed;
|
||||||
|
var ok = text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? UInt32.TryParse(text.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out parsed)
|
||||||
|
: UInt32.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed);
|
||||||
|
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
Say("worldgone: \"" + raw + "\" is not a serial");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entity = World.FindEntity((Serial)unchecked((int)parsed));
|
||||||
|
|
||||||
|
if (entity == null || entity.Deleted)
|
||||||
|
{
|
||||||
|
Say("worldgone: nothing at " + text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.Delete();
|
||||||
|
Say("worldgone: deleted " + text + " and told nobody");
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Names a few spawners, with both ways of addressing one.
|
||||||
|
///
|
||||||
|
/// A property lease is targeted by a serial or by an `XmlSpawner.UniqueId`, and the rig
|
||||||
|
/// has no other way to learn either — the website's dropdown comes from the atlas, and
|
||||||
|
/// the rig does not have one.
|
||||||
|
/// </summary>
|
||||||
|
private static void SpawnerList(string raw)
|
||||||
|
{
|
||||||
|
var want = 5;
|
||||||
|
|
||||||
|
if (!String.IsNullOrEmpty(raw))
|
||||||
|
Int32.TryParse(raw.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out want);
|
||||||
|
|
||||||
|
if (want < 1)
|
||||||
|
want = 1;
|
||||||
|
|
||||||
|
var shown = 0;
|
||||||
|
|
||||||
|
foreach (var item in World.Items.Values)
|
||||||
|
{
|
||||||
|
if (shown >= want)
|
||||||
|
break;
|
||||||
|
|
||||||
|
var xml = item as Mobiles.XmlSpawner;
|
||||||
|
|
||||||
|
if (xml == null || xml.Deleted)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
Say(String.Format(CultureInfo.InvariantCulture,
|
||||||
|
"spawner 0x{0:X} uid={1} maxCount={2} running={3} name={4}",
|
||||||
|
item.Serial.Value, xml.UniqueId, xml.MaxCount, xml.Running, xml.Name ?? "-"));
|
||||||
|
|
||||||
|
shown++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shown == 0)
|
||||||
|
Say("spawnerlist: this world has no XmlSpawners");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets a property on an object BEHIND the lease plane's back.
|
||||||
|
///
|
||||||
|
/// 11b's `configset` exists because `Config.Set` has one caller in the whole tree, so
|
||||||
|
/// nothing on a stock shard could drift a config lease. A spawner is the opposite — a GM
|
||||||
|
/// drifts one with `[props` in about four seconds — but the rig has no client, so it
|
||||||
|
/// needs the same door. This is the only way to reach `drifted` on a property lease
|
||||||
|
/// without one, and it is exactly what a staff member's `[set` does.
|
||||||
|
/// </summary>
|
||||||
|
private static void PropSet(string target, string property, string value)
|
||||||
|
{
|
||||||
|
if (String.IsNullOrEmpty(target) || String.IsNullOrEmpty(property) || value == null)
|
||||||
|
{
|
||||||
|
Say("propset <serial|uniqueId> <property> <value>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Item item = null;
|
||||||
|
uint parsed;
|
||||||
|
var text = target.Trim();
|
||||||
|
var isSerial = text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? UInt32.TryParse(text.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out parsed)
|
||||||
|
: UInt32.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed);
|
||||||
|
|
||||||
|
if (isSerial)
|
||||||
|
{
|
||||||
|
item = World.FindItem((Serial)unchecked((int)parsed));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var candidate in World.Items.Values)
|
||||||
|
{
|
||||||
|
var xml = candidate as Mobiles.XmlSpawner;
|
||||||
|
|
||||||
|
if (xml == null || !String.Equals(xml.UniqueId, text, StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
item = xml;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item == null || item.Deleted)
|
||||||
|
{
|
||||||
|
Say("propset: nothing at " + text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var info = item.GetType().GetProperty(property, BindingFlags.Public | BindingFlags.Instance);
|
||||||
|
|
||||||
|
if (info == null || !info.CanWrite)
|
||||||
|
{
|
||||||
|
Say("propset: " + item.GetType().Name + " has no writable " + property);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
object typed;
|
||||||
|
|
||||||
|
if (info.PropertyType == typeof(TimeSpan))
|
||||||
|
typed = TimeSpan.FromSeconds(Double.Parse(value, CultureInfo.InvariantCulture));
|
||||||
|
else if (info.PropertyType == typeof(bool))
|
||||||
|
typed = String.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || value == "1";
|
||||||
|
else
|
||||||
|
typed = Convert.ChangeType(value, info.PropertyType, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
info.SetValue(item, typed, null);
|
||||||
|
Say("propset: " + property + " on " + text + " is now " + value + ", and nobody was told");
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Say("propset: " + e.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads a property back, so the rig can assert a restore actually landed.</summary>
|
||||||
|
private static void PropRead(string target, string property)
|
||||||
|
{
|
||||||
|
if (String.IsNullOrEmpty(target) || String.IsNullOrEmpty(property))
|
||||||
|
{
|
||||||
|
Say("propread <serial|uniqueId> <property>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Item item = null;
|
||||||
|
uint parsed;
|
||||||
|
var text = target.Trim();
|
||||||
|
var isSerial = text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? UInt32.TryParse(text.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out parsed)
|
||||||
|
: UInt32.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed);
|
||||||
|
|
||||||
|
if (isSerial)
|
||||||
|
{
|
||||||
|
item = World.FindItem((Serial)unchecked((int)parsed));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var candidate in World.Items.Values)
|
||||||
|
{
|
||||||
|
var xml = candidate as Mobiles.XmlSpawner;
|
||||||
|
|
||||||
|
if (xml == null || !String.Equals(xml.UniqueId, text, StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
item = xml;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item == null || item.Deleted)
|
||||||
|
{
|
||||||
|
Say("propread: nothing at " + text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var info = item.GetType().GetProperty(property, BindingFlags.Public | BindingFlags.Instance);
|
||||||
|
|
||||||
|
if (info == null)
|
||||||
|
{
|
||||||
|
Say("propread: " + item.GetType().Name + " has no " + property);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw = info.GetValue(item, null);
|
||||||
|
Say("propread: " + property + " = " + Convert.ToString(raw, CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Says what the seasonal system holds, which is the seasonal lease's target list.</summary>
|
||||||
|
private static void SeasonList()
|
||||||
|
{
|
||||||
|
foreach (Engines.SeasonalEvents.EventType type in Enum.GetValues(typeof(Engines.SeasonalEvents.EventType)))
|
||||||
|
{
|
||||||
|
var entry = Engines.SeasonalEvents.SeasonalEventSystem.GetEntry(type);
|
||||||
|
|
||||||
|
Say(entry == null
|
||||||
|
? "season " + type + " = (no entry)"
|
||||||
|
: "season " + type + " = " + entry.Status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static string Arg(string[] parts, int i)
|
||||||
|
{
|
||||||
|
return i < parts.Length ? parts[i] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int Int(string raw)
|
||||||
|
{
|
||||||
|
int n;
|
||||||
|
return Int32.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a live config key, so a lease's `drifted` verdict can be produced at all.
|
||||||
|
///
|
||||||
|
/// **`Config.Set` has exactly ONE caller in the whole of ServUO 57.4**
|
||||||
|
/// (`Server/ScriptCompiler.cs`, for `Compiler.Dynamic`). No in-game command, gump or
|
||||||
|
/// console verb writes a config key, so on a stock shard a GM cannot drift a
|
||||||
|
/// configuration lease even deliberately -- and the one safety property a lease has
|
||||||
|
/// that nothing else does would go untested. Written through the same typed setter a
|
||||||
|
/// float lease uses, so what it produces is indistinguishable to the compare-and-set
|
||||||
|
/// from a real interfering write.
|
||||||
|
///
|
||||||
|
/// Deliberately no `Config.Save()`, matching BridgeLeases: nothing about a rig should
|
||||||
|
/// leave a modified .cfg behind for the next boot to inherit.
|
||||||
|
/// </summary>
|
||||||
|
private static void ConfigSet(string key, string raw)
|
||||||
|
{
|
||||||
|
if (key == null || raw == null)
|
||||||
|
{
|
||||||
|
Say("configset <key> <value>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
double n;
|
||||||
|
|
||||||
|
if (Double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out n))
|
||||||
|
Config.Set(key, n);
|
||||||
|
else
|
||||||
|
Config.Set(key, raw);
|
||||||
|
|
||||||
|
Say("configset " + key + " = " + raw + " (in memory only)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a key back through `Config.Get`, at a moment long after every type
|
||||||
|
/// initialiser has run.
|
||||||
|
///
|
||||||
|
/// This is the check that tells a key which TOOK from one that only appeared to: a
|
||||||
|
/// lease on one of ServUO's ~150 cached call sites applies cleanly and does nothing,
|
||||||
|
/// which is the worst failure this feature has.
|
||||||
|
/// </summary>
|
||||||
|
private static void ConfigRead(string key)
|
||||||
|
{
|
||||||
|
if (key == null)
|
||||||
|
{
|
||||||
|
Say("configread <key>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Say("configread " + key + " = " + Config.Get(key, Double.NaN).ToString("R", CultureInfo.InvariantCulture)
|
||||||
|
+ " (double), \"" + Config.Get(key, "<unset>") + "\" (string)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- houses ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// `CanDecay` is the filter, and getting it wrong is silent: an AutoRefresh house --
|
||||||
|
/// and the owner's newest house is always AutoRefresh -- has a DecayLevel getter that
|
||||||
|
/// calls ResetDynamicDecay(), so a forced stage is wiped before the sweep reads it and
|
||||||
|
/// NOTHING is emitted. That looks exactly like a broken emitter.
|
||||||
|
/// </summary>
|
||||||
|
private static IEnumerable<BaseHouse> Decayable()
|
||||||
|
{
|
||||||
|
return BaseHouse.AllHouses
|
||||||
|
.Where(h => h != null && !h.Deleted && h.Owner != null && h.CanDecay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DecayList()
|
||||||
|
{
|
||||||
|
foreach (var h in Decayable())
|
||||||
|
{
|
||||||
|
var acct = h.Owner.Account == null ? "-" : h.Owner.Account.Username;
|
||||||
|
Say(String.Format(
|
||||||
|
"house 0x{0:X} owner={1} acct={2} name=\"{3}\" region={4} type={5} level={6}",
|
||||||
|
h.Serial.Value, h.Owner.Name, acct, HouseName(h), RegionName(h),
|
||||||
|
h.DecayType, h.DecayLevel));
|
||||||
|
}
|
||||||
|
|
||||||
|
Say("decayable=" + Decayable().Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string HouseName(BaseHouse h)
|
||||||
|
{
|
||||||
|
return h.Sign != null && h.Sign.Name != null ? h.Sign.Name : String.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RegionName(BaseHouse h)
|
||||||
|
{
|
||||||
|
var r = Region.Find(h.Location, h.Map);
|
||||||
|
return r == null ? "-" : r.Name ?? "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Decay(string which, string stage)
|
||||||
|
{
|
||||||
|
DecayLevel level;
|
||||||
|
if (!TryParseStage(stage, out level))
|
||||||
|
{
|
||||||
|
Say("unknown stage \"" + stage + "\"");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseHouse house = null;
|
||||||
|
|
||||||
|
if (String.IsNullOrEmpty(which) || which == "any")
|
||||||
|
house = Decayable().FirstOrDefault();
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var serial = ParseSerial(which);
|
||||||
|
house = Decayable().FirstOrDefault(h => h.Serial.Value == serial);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (house == null)
|
||||||
|
{
|
||||||
|
Say("no decayable house matched \"" + which + "\"");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var from = house.DecayLevel;
|
||||||
|
|
||||||
|
// A refresh is what a player does at the sign, and it is NOT SetDynamicDecay: the
|
||||||
|
// level is derived from LastRefreshed, so a "LikeNew" that only rewrote the dynamic
|
||||||
|
// stage would be undone by the next read.
|
||||||
|
if (level == DecayLevel.LikeNew)
|
||||||
|
house.RefreshDecay();
|
||||||
|
else
|
||||||
|
house.SetDynamicDecay(level);
|
||||||
|
|
||||||
|
Say(String.Format(
|
||||||
|
"house 0x{0:X} {1} -> {2} (now {3})",
|
||||||
|
house.Serial.Value, from, level, house.DecayLevel));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseStage(string s, out DecayLevel level)
|
||||||
|
{
|
||||||
|
level = DecayLevel.Ageless;
|
||||||
|
if (String.IsNullOrEmpty(s))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
foreach (DecayLevel candidate in Enum.GetValues(typeof(DecayLevel)))
|
||||||
|
{
|
||||||
|
if (String.Equals(candidate.ToString(), s, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
level = candidate;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ParseSerial(string s)
|
||||||
|
{
|
||||||
|
var text = s.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? s.Substring(2) : s;
|
||||||
|
int parsed;
|
||||||
|
|
||||||
|
if (Int32.TryParse(text, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out parsed))
|
||||||
|
return parsed;
|
||||||
|
|
||||||
|
return Int32.TryParse(s, out parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- vendors ----
|
||||||
|
|
||||||
|
private static IEnumerable<PlayerVendor> Vendors()
|
||||||
|
{
|
||||||
|
return World.Mobiles.Values.OfType<PlayerVendor>().Where(v => !v.Deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void VendorList()
|
||||||
|
{
|
||||||
|
Say("NewVendorSystem=" + BaseHouse.NewVendorSystem);
|
||||||
|
|
||||||
|
foreach (var v in Vendors())
|
||||||
|
{
|
||||||
|
var owner = v.Owner;
|
||||||
|
var acct = owner == null || owner.Account == null ? "-" : owner.Account.Username;
|
||||||
|
Say(String.Format(
|
||||||
|
"vendor 0x{0:X} shop=\"{1}\" owner={2} acct={3} hold={4} charge={5} nextPay={6}",
|
||||||
|
v.Serial.Value, v.ShopName, owner == null ? "-" : owner.Name, acct,
|
||||||
|
v.HoldGold, v.ChargePerDay, v.NextPayTime.ToUniversalTime().ToString("o")));
|
||||||
|
}
|
||||||
|
|
||||||
|
Say("vendors=" + Vendors().Count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set a vendor's held gold, which is the only knob that walks it toward dismissal
|
||||||
|
/// without waiting a pay period -- `NextPayTime` has a private setter, and a period is
|
||||||
|
/// a real day on the new vendor system and a UO day (~2 real hours) on the old one.
|
||||||
|
/// The emitter computes `periodsRemaining` as funds / chargePerPeriod, so this moves
|
||||||
|
/// exactly the field the threshold tracker watches.
|
||||||
|
/// </summary>
|
||||||
|
private static void VendorFunds(string which, string gold)
|
||||||
|
{
|
||||||
|
var serial = ParseSerial(which ?? String.Empty);
|
||||||
|
var vendor = Vendors().FirstOrDefault(v => v.Serial.Value == serial);
|
||||||
|
|
||||||
|
if (vendor == null)
|
||||||
|
{
|
||||||
|
Say("no vendor matched \"" + which + "\"");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int funds;
|
||||||
|
if (!Int32.TryParse(gold, out funds))
|
||||||
|
{
|
||||||
|
Say("bad gold \"" + gold + "\"");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both, because the old vendor system spends BankAccount + HoldGold and the new one
|
||||||
|
// spends HoldGold alone -- setting one would leave the other paying the charge.
|
||||||
|
vendor.HoldGold = funds;
|
||||||
|
vendor.BankAccount = 0;
|
||||||
|
|
||||||
|
var charge = BaseHouse.NewVendorSystem ? vendor.ChargePerRealWorldDay : vendor.ChargePerDay;
|
||||||
|
Say(String.Format(
|
||||||
|
"vendor 0x{0:X} hold={1} bank=0 charge={2} periodsRemaining={3}",
|
||||||
|
vendor.Serial.Value, vendor.HoldGold, charge, charge > 0 ? funds / charge : -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- accounts ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mark an account as having just logged in.
|
||||||
|
///
|
||||||
|
/// This is the ONLY way to walk a decaying house back out of danger on a seeded
|
||||||
|
/// world, and the reason is ServUO's, not the rig's: every house that CAN decay here
|
||||||
|
/// is `DecayType.Condemned` (the seeder backdates accounts past
|
||||||
|
/// `Account.InactiveDuration` precisely to make them decay), and
|
||||||
|
/// `BaseHouse.RefreshDecay()` returns false immediately for a Condemned house. A
|
||||||
|
/// condemned house is not refreshable by anyone; it is rescued by its OWNER LOGGING
|
||||||
|
/// IN, which is what this reproduces.
|
||||||
|
///
|
||||||
|
/// What the shard then reports depends on how many houses the owner has:
|
||||||
|
/// `AutoRefresh` (their newest) stops decaying and reads **Ageless**, while an older
|
||||||
|
/// `ManualRefresh` one is back on the clock and reads **LikeNew**. Both are "out of
|
||||||
|
/// danger", and a mapper that reads only one of them misses most rescues.
|
||||||
|
/// </summary>
|
||||||
|
private static void Activate(string username)
|
||||||
|
{
|
||||||
|
var acct = Accounts.GetAccount(username) as Account;
|
||||||
|
|
||||||
|
if (acct == null)
|
||||||
|
{
|
||||||
|
Say("no account \"" + username + "\"");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
acct.LastLogin = DateTime.UtcNow;
|
||||||
|
Say(String.Format("account {0} lastLogin=now inactive={1}", acct.Username, acct.Inactive));
|
||||||
|
|
||||||
|
foreach (var h in BaseHouse.AllHouses)
|
||||||
|
{
|
||||||
|
if (h == null || h.Deleted || h.Owner == null || h.Owner.Account != acct)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
Say(String.Format(
|
||||||
|
" house 0x{0:X} type={1} level={2}", h.Serial.Value, h.DecayType, h.DecayLevel));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set a game account's password, so a login can be driven over a real socket.
|
||||||
|
///
|
||||||
|
/// The socket is not optional for the ACCEPTED half: ServUO's own AccountHandler calls
|
||||||
|
/// `acct.HasAccess(e.State)` before it ever checks the password, and a null NetState
|
||||||
|
/// fails that -- so an in-process probe reports "access denied" for a correct password
|
||||||
|
/// and can never produce `accepted:true`.
|
||||||
|
/// </summary>
|
||||||
|
private static void Password(string username, string pw)
|
||||||
|
{
|
||||||
|
var acct = Accounts.GetAccount(username) as Account;
|
||||||
|
|
||||||
|
if (acct == null)
|
||||||
|
{
|
||||||
|
Say("no account \"" + username + "\"");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String.IsNullOrEmpty(pw))
|
||||||
|
{
|
||||||
|
Say("refusing to set an empty password");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
acct.SetPassword(pw);
|
||||||
|
Say("account " + acct.Username + " password set");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- cities ----
|
||||||
|
|
||||||
|
private static void CityList()
|
||||||
|
{
|
||||||
|
Say("CityLoyaltySystem.Enabled=" + CityLoyaltySystem.Enabled);
|
||||||
|
|
||||||
|
foreach (var city in CityLoyaltySystem.Cities)
|
||||||
|
{
|
||||||
|
if (city == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var e = city.Election;
|
||||||
|
Say(String.Format(
|
||||||
|
"city={0} governor={1} elect={2} election={3} candidates={4} autoPick={5}",
|
||||||
|
city.City,
|
||||||
|
city.Governor == null ? "-" : city.Governor.Name + "/0x" + city.Governor.Serial.Value.ToString("X"),
|
||||||
|
city.GovernorElect == null ? "-" : city.GovernorElect.Name,
|
||||||
|
e == null ? "-" : (e.CanNominate() ? "nominate" : e.CanVote() ? "vote" : e.Ongoing ? "pending" : "none"),
|
||||||
|
e == null || e.Candidates == null ? 0 : e.Candidates.Count,
|
||||||
|
e == null ? "-" : e.AutoPickGovernor.ToUniversalTime().ToString("o")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CityLoyaltySystem FindCity(string name)
|
||||||
|
{
|
||||||
|
return CityLoyaltySystem.Cities.FirstOrDefault(
|
||||||
|
c => c != null && String.Equals(c.City.ToString(), name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Governor(string cityName, string who)
|
||||||
|
{
|
||||||
|
var city = FindCity(cityName);
|
||||||
|
if (city == null)
|
||||||
|
{
|
||||||
|
Say("no city \"" + cityName + "\"");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String.Equals(who, "none", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
city.Governor = null;
|
||||||
|
Say("city=" + city.City + " governor cleared");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mob = FindMobile(who);
|
||||||
|
if (mob == null)
|
||||||
|
{
|
||||||
|
Say("no player matched \"" + who + "\"");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
city.Governor = mob;
|
||||||
|
var acct = mob.Account == null ? "-" : mob.Account.Username;
|
||||||
|
Say(String.Format(
|
||||||
|
"city={0} governor={1} 0x{2:X} acct={3}",
|
||||||
|
city.City, mob.Name, mob.Serial.Value, acct));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mobile FindMobile(string who)
|
||||||
|
{
|
||||||
|
var serial = ParseSerial(who);
|
||||||
|
|
||||||
|
if (serial != 0)
|
||||||
|
{
|
||||||
|
var bySerial = World.FindMobile(serial);
|
||||||
|
if (bySerial != null)
|
||||||
|
return bySerial;
|
||||||
|
}
|
||||||
|
|
||||||
|
return World.Mobiles.Values.OfType<PlayerMobile>()
|
||||||
|
.FirstOrDefault(m => !m.Deleted && String.Equals(m.Name, who, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Election(string cityName)
|
||||||
|
{
|
||||||
|
var city = FindCity(cityName);
|
||||||
|
if (city == null)
|
||||||
|
{
|
||||||
|
Say("no city \"" + cityName + "\"");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (city.Election == null)
|
||||||
|
{
|
||||||
|
Say("city=" + city.City + " has no election object");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
city.Election.StartNewElection();
|
||||||
|
Say(String.Format(
|
||||||
|
"city={0} election restarted; autoPick={1} nominate={2}",
|
||||||
|
city.City,
|
||||||
|
city.Election.AutoPickGovernor.ToUniversalTime().ToString("o"),
|
||||||
|
city.Election.CanNominate()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,13 @@ These two scripts produced the measured budget in [PLAN.md](https://gitea.whitlo
|
|||||||
| `BridgeLinkProbe.cs` | `Scripts/Custom/BridgeLinkProbe.cs` | Triggers `[link` for seed_001 without a client, then saves so the `WebsiteUserId` tag reaches `accounts.xml`. Flag: `LinkProbeOnStart`. Pair with a sidecar that reads the code and sends `link.confirm`. |
|
| `BridgeLinkProbe.cs` | `Scripts/Custom/BridgeLinkProbe.cs` | Triggers `[link` for seed_001 without a client, then saves so the `WebsiteUserId` tag reaches `accounts.xml`. Flag: `LinkProbeOnStart`. Pair with a sidecar that reads the code and sends `link.confirm`. |
|
||||||
| `BridgeCrierProbe.cs` | `Scripts/Custom/BridgeCrierProbe.cs` | Logs the global town-crier entry list every 3s so `towncrier.add` / `remove` can be seen landing in game state. Flag: `CrierProbeOnStart`. |
|
| `BridgeCrierProbe.cs` | `Scripts/Custom/BridgeCrierProbe.cs` | Logs the global town-crier entry list every 3s so `towncrier.add` / `remove` can be seen landing in game state. Flag: `CrierProbeOnStart`. |
|
||||||
| `BridgeVendorSaleProbe.cs` | `Scripts/Custom/BridgeVendorSaleProbe.cs` | Fires `PlayerVendorSale` (Phase 7) with real seeded-vendor data so `vendor.sale` can be verified without a live buy. Requires the Phase 7 patches applied. Flag: `VendorSaleProbeOnStart`. |
|
| `BridgeVendorSaleProbe.cs` | `Scripts/Custom/BridgeVendorSaleProbe.cs` | Fires `PlayerVendorSale` (Phase 7) with real seeded-vendor data so `vendor.sale` can be verified without a live buy. Requires the Phase 7 patches applied. Flag: `VendorSaleProbeOnStart`. |
|
||||||
|
| `BridgeDemoDress.cs` | `Scripts/Custom/BridgeDemoDress.cs` | Renames a seeded world so it is presentable in a screenshot: shop signs, vendor and character names, house signs. Also stages a few condemned houses back into IDOC, and sets a known password on `seed_000` so a character can be logged in. Flags: `DemoDressOnStart`, `DemoDressPassword`. In game: `[demodress`. |
|
||||||
|
| `BridgeRigDriver.cs` | `Scripts/Custom/BridgeRigDriver.cs` | Drives the shard from OUTSIDE the game, one verb per line in `Config/rigcmd.txt`, which the driver polls and truncates. Written for the engagement Phase 11b acceptance walk, where each step's assertion is what happened BETWEEN two steps, so the steps have to be separated by the observer rather than by a hard-coded delay -- and ServUO's console takes a fixed verb set (`Scripts/Misc/ConsoleCommands.cs`), so `[p5probe` cannot be typed at a headless shard at all. Verbs: `decaylist`, `decay`, `vendorlist`, `vendorfunds`, `citylist`, `governor`, `election`, `activate`, `password`, `configset`, `configread`, `partprobe`, `assetprobe`, `save`, `shutdown`. Flag: `RigDriverEnabled`. `configset` exists because **`Config.Get` is written by exactly ONE caller in the whole of ServUO 57.4** (`Server/ScriptCompiler.cs`): no in-game command, gump or console verb writes a config key, so on a stock shard a GM cannot drift a configuration lease even deliberately, and a lease's compare-and-set restore would have no way to be proved. `configread` reads a key back through `Config.Get` long after every type initialiser has run, which is how a key that TOOK is told from one that only appeared to. **Sets passwords, writes live config and mutates the world.** |
|
||||||
|
| `BridgeProtocol5Probe.cs` | `Scripts/Custom/BridgeProtocol5Probe.cs` | Drives all three Protocol 5 enrichments so their frames can be observed: walks one house Fairly -> Greatly -> IDOC (the PAIR is the assertion -- `estimatedCollapse` must appear only on the IDOC frame), reports each player vendor's fee state straight off the `PlayerVendor` so the emitted `fees` block can be checked against the shard's own numbers, and fires `EventSink.AccountLogin`. Flags: `Protocol5ProbeOnStart`, `Protocol5ProbeAccount`, `Protocol5ProbePassword`. In game: `[p5probe`. **Sets a password on the named account.** |
|
||||||
|
| `BridgeProtocol6Probe.cs` | `Scripts/Custom/BridgeProtocol6Probe.cs` | Spawns a real champion boss through the shard's own `SpawnChampion()`, waits two champ sweeps so the boss is attributed to its altar, registers unequal damage from two seeded players and kills it -- so `champ.boss.killed` can be observed with a real damage table. **The wait is the assertion**: without it the kill still emits, but with no `serial`/`type`/`level`, which is the documented fallback rather than the case being tested. The altar is placed inside a NAMED region on purpose (see below). Flag: `Protocol6ProbeOnStart`. In game: `[p6probe`. **Spawns and kills a champion boss; rig only.** Protocol 6's other half, the idempotency key, needs no probe -- it is driven from outside with two identical POSTs to the sidecar. |
|
||||||
|
| `BridgeParticipationProbe.cs` | `Scripts/Custom/BridgeParticipationProbe.cs` | Produces real kill credit inside a participation area with no game client: moves two player mobiles to the venue, spawns a creature there, damages it unequally from both and kills it. **Presence is the half it cannot drive** -- the sweep credits players with a live `NetState`, which is the correct test and not one a probe should loosen, so presence accrual needs a real login. In game: `[partprobe <map> <x> <y>`; from a headless rig, through `BridgeRigDriver`'s `partprobe` verb (the two ship together for that reason). **Moves players and spawns and kills a creature; rig only.** |
|
||||||
|
| `BridgeAssetProbe.cs` | `Scripts/Custom/BridgeAssetProbe.cs` | **Asset Bridge phase 0** (docs/link/v8.md §16). Drives ServUO's vendored `Ultima` decoders from inside a running shard against a deliberately patched client, and compares every answer with what a pre-flight validator says about the index entry *before* the call. The interesting column is not the error count, it is **WRONG PICTURES** -- records the validator rejects and the library renders anyway. Sweeps statics, land, all 2,048 bodies, the player-character bodies from `Race.AllRaces`, and the ported Mythic cliloc reader against UOFiddler's own output. In game / from `BridgeRigDriver`: `[assetprobe [section] [stock|patched]`. Flag: `AssetProbeOnStart`. **Its `gump` section deliberately kills the shard** and is never part of `all`. |
|
||||||
|
| `BridgeMythicCliloc.cs` | `Scripts/Custom/BridgeMythicCliloc.cs` | The §9 reader for the **Mythic compressed** cliloc container -- the one decoder Protocol 8 writes rather than calls. Ported from UOFiddler (Beerware) into net48 C# with every file-derived index bounds-checked, which upstream's blanket `catch` does not do. Reproduces UOFiddler's 123,490-entry table exactly. **Phase 2 promotes this file into `overlay/`**; it is scaffolding only for as long as it is a spike. |
|
||||||
|
|
||||||
## Deploy overwrites Bridge.cfg
|
## Deploy overwrites Bridge.cfg
|
||||||
|
|
||||||
@@ -34,6 +41,41 @@ Because `Config.Get` returns `false` for a missing key, a server whose `Bridge.c
|
|||||||
|
|
||||||
In-game, `[seedworld` and `[unseedworld` (Administrator) do the same work on a live shard.
|
In-game, `[seedworld` and `[unseedworld` (Administrator) do the same work on a live shard.
|
||||||
|
|
||||||
|
## Dressing a seeded world for screenshots
|
||||||
|
|
||||||
|
`BridgeSeeder` builds a world at realistic **scale**, which is all the bridge ever needed. It does not
|
||||||
|
build one that looks like anything: a vendor is `seed vendor` trading as `Seed Shop 810`, a character
|
||||||
|
is `Seed004A`, a house sign says `Seed House 12`. Those strings travel the whole bridge and land on
|
||||||
|
the marketplace, the guild roster and the housing pages of the website — fine for a protocol test,
|
||||||
|
wrong for a screenshot.
|
||||||
|
|
||||||
|
`BridgeDemoDress.cs` renames them in place. It seeds nothing: prices, listing counts, decay stages,
|
||||||
|
fame and skills stay exactly as the seeder left them and as the shard has moved them since, so the
|
||||||
|
data keeps its provenance and only the strings a human reads change. Names are drawn from fixed
|
||||||
|
tables by a hash of each object's serial, so a re-run reproduces the same world, and shop and house
|
||||||
|
names are re-dressed when they are names the pass itself produced — so a change to the tables can be
|
||||||
|
applied to a world that has already been through here.
|
||||||
|
|
||||||
|
```ini
|
||||||
|
DemoDressOnStart=True
|
||||||
|
DemoDressPassword=<a password you choose>
|
||||||
|
```
|
||||||
|
|
||||||
|
Boot once, then set `DemoDressOnStart=False`. The password is written to `seed_000` so a real client
|
||||||
|
can log a character in — the only way to make the website's online roster non-empty — and it is read
|
||||||
|
from the config rather than compiled in, so it never lands in source control.
|
||||||
|
|
||||||
|
**It dresses seeded objects only, which means your own characters keep their names.** That is the
|
||||||
|
right behaviour for a test shard and a thing to remember before pointing a camera at one: a dev
|
||||||
|
world usually also holds the accounts, characters, guilds and houses of whoever built it, and those
|
||||||
|
are real identifiers on a page that may end up public.
|
||||||
|
|
||||||
|
**The sidecar's board is cached, so the website lags a rename.** A shop name reaches the site on the
|
||||||
|
next market sweep, and a sweep advances `MarketSweepBatch` vendors per tick — 27 vendors at the
|
||||||
|
defaults is two ticks. Allow a couple of minutes before concluding that a rename failed. This cost a
|
||||||
|
debugging detour once: the shard had the new names all along and the sidecar was still serving the
|
||||||
|
previous ones.
|
||||||
|
|
||||||
## Back up `Saves/` first
|
## Back up `Saves/` first
|
||||||
|
|
||||||
`[seedworld` and `SeedOnStart` **write to the live world**. Copy `Saves/` somewhere outside the repo before running either. `Backups/Automatic` is rotated by `AutoSave.cs` and `Backups/Temp` is deleted outright, so neither is a safe destination.
|
`[seedworld` and `SeedOnStart` **write to the live world**. Copy `Saves/` somewhere outside the repo before running either. `Backups/Automatic` is rotated by `AutoSave.cs` and `Backups/Temp` is deleted outright, so neither is a safe destination.
|
||||||
@@ -70,3 +112,206 @@ Probe, best-of-20 on the Core thread:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Seeded characters carry 8 items with ~6 mods each and ~12 trained skills. A real endgame character has more of both, so profile cost and payload are a **floor** — budget 2–4× for a fully-kitted character.
|
Seeded characters carry 8 items with ~6 mods each and ~12 trained skills. A real endgame character has more of both, so profile cost and payload are a **floor** — budget 2–4× for a fully-kitted character.
|
||||||
|
|
||||||
|
## The login half needs a socket, not the sink
|
||||||
|
|
||||||
|
`BridgeProtocol5Probe` fires `EventSink.InvokeAccountLogin` directly, which proves the REJECTED
|
||||||
|
half of `account.login.result` and nothing more. ServUO's own `AccountHandler` calls
|
||||||
|
`acct.HasAccess(e.State)` *before* it ever checks the password, and a null `NetState` fails that --
|
||||||
|
so an in-process probe logs `Access denied` for a correct password too, and never produces an
|
||||||
|
`accepted:true`.
|
||||||
|
|
||||||
|
To prove the accepted half, speak the wire. A real socket also gives the frame a real `ip`, which
|
||||||
|
is one of the fields being tested:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 4-byte seed, then 0x80 = [0x80][30b username][30b password][1b]
|
||||||
|
s = socket.create_connection(('127.0.0.1', 2593))
|
||||||
|
s.sendall(b'\x7f\x00\x00\x01')
|
||||||
|
s.sendall(b'\x80' + pad(user) + pad(password) + b'\x5d')
|
||||||
|
```
|
||||||
|
|
||||||
|
The shard logs `Invalid password for '<acct>'` or `Valid credentials for '<acct>'`, and the sidecar's
|
||||||
|
`/history?kind=account.login.result` should show `accepted:false reason:BadPass` and `accepted:true`
|
||||||
|
respectively. **Both saying `accepted:true` is the bug the kind exists to prevent** -- it means the
|
||||||
|
verdict was read inside the handler, before it existed.
|
||||||
|
|
||||||
|
## Walking a house into IDOC needs a house that can decay
|
||||||
|
|
||||||
|
Only a `Condemned` or `ManualRefresh` house decays. An `AutoRefresh` one -- and the owner's NEWEST
|
||||||
|
house is always `AutoRefresh` -- has a `DecayLevel` getter that calls `ResetDynamicDecay()` and
|
||||||
|
reports `Ageless`, so a forced `SetDynamicDecay` is wiped on the very next read, the sweep sees no
|
||||||
|
change, and **nothing is emitted at all**. That looks exactly like a broken emitter. Filter on
|
||||||
|
`house.CanDecay`, and expect a seeded world to have only one or two houses that qualify -- both
|
||||||
|
probably already at IDOC, so the walk has to put one back down first.
|
||||||
|
|
||||||
|
## A decaying house cannot be refreshed — only its owner coming back rescues it
|
||||||
|
|
||||||
|
`BaseHouse.RefreshDecay()` returns `false` immediately when `DecayType == Condemned`, and on a
|
||||||
|
seeded world **every house that can decay is Condemned** — the seeder backdates 18 accounts past
|
||||||
|
`Account.InactiveDuration` precisely to make them decay. So `SetDynamicDecay(DecayLevel.LikeNew)`
|
||||||
|
is wiped by the next read and `RefreshDecay()` does nothing: the sweep sees no change and emits
|
||||||
|
nothing, which looks exactly like a broken emitter for the second time on the same page.
|
||||||
|
|
||||||
|
The rescue is the OWNER LOGGING IN (`BridgeRigDriver`'s `activate <account>` reproduces it by
|
||||||
|
setting `LastLogin`). What the shard then reports depends on how many houses that owner has:
|
||||||
|
|
||||||
|
| the house | `DecayType` after the login | `DecayLevel` reads |
|
||||||
|
|---|---|---|
|
||||||
|
| their newest | `AutoRefresh` | **`Ageless`** — off the decay clock entirely |
|
||||||
|
| any older one | `ManualRefresh` | **`LikeNew`** — back on the clock, at the top |
|
||||||
|
|
||||||
|
Both are "out of danger", and the newest-house case is the common one. A consumer that watches only
|
||||||
|
for `LikeNew` misses most rescues — which is what the engagement mapper did until this walk.
|
||||||
|
|
||||||
|
## The console takes a fixed verb set, so `[commands` cannot be typed at a headless shard
|
||||||
|
|
||||||
|
`Scripts/Misc/ConsoleCommands.cs` handles `save`, `shutdown`, `restart`, `online`, `kick` and a
|
||||||
|
handful more; it does **not** dispatch arbitrary `[commands`. Every other probe here therefore runs
|
||||||
|
either at boot or from an in-game client, and neither works for a walk driven from a script. That is
|
||||||
|
what `BridgeRigDriver` and its `rigcmd.txt` are for.
|
||||||
|
|
||||||
|
Also: only a CLEAN shutdown emits. `Stop-Process` drops the socket and the shard says nothing, so a
|
||||||
|
killed shard is indistinguishable from a wedged one and `server.shutdown` never reaches the sidecar —
|
||||||
|
use the driver's `shutdown` verb (`Core.Kill`) when the shutdown itself is what is being tested.
|
||||||
|
|
||||||
|
## The innermost region has no name
|
||||||
|
|
||||||
|
`BridgeProtocol6Probe` places its altar in the middle of **Britain** rather than at a dungeon altar,
|
||||||
|
and that is not cosmetic. An active `ChampionSpawn` registers a `ChampionSpawnRegion` over its own
|
||||||
|
spawn area, constructed with a **null name** and with the town region as its parent -- so the most
|
||||||
|
specific region containing a champion boss is the one region on the map guaranteed to be nameless.
|
||||||
|
`Mobile.Region` then hides that by falling back to the map's unnamed default region rather than to
|
||||||
|
null, and the emitted frame simply has no `region`.
|
||||||
|
|
||||||
|
Region registration is also **deferred**, which is what makes this survive a first look: a lookup
|
||||||
|
taken immediately after the altar is placed answers `"Britain"`, and one taken at the kill twenty
|
||||||
|
seconds later does not. The probe prints the spawn-time read for exactly this reason -- it is the
|
||||||
|
value that lies, printed next to a frame that disagrees with it.
|
||||||
|
|
||||||
|
Emitting from a named region is therefore the test. At a dungeon altar the field is legitimately
|
||||||
|
absent and the probe proves nothing about it.
|
||||||
|
|
||||||
|
## What phase 0 found
|
||||||
|
|
||||||
|
`BridgeAssetProbe` exists because [v8.md](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v8.md) §4 chose to **call** ServUO's vendored `Ultima` rather than reimplement it, and the evidence for that choice was a PowerShell probe against a stock client — neither the process nor the client the extractor will actually run in. These are its results, from inside a running ServUO 57.4 against this machine's client, and against a copy broken in 21 catalogued ways by `tools/patch_client.ps1`.
|
||||||
|
|
||||||
|
### The UOP wins outright, and it took a whole run to notice
|
||||||
|
|
||||||
|
`FileIndex`'s UOP constructor ends with a bare `MulPath = uopPath`. **When `artLegacyMUL.uop` is present it wins, and `art.mul` / `artidx.mul` are never opened at all.** Every current client ships the UOP, so:
|
||||||
|
|
||||||
|
- A validator that bounds an index offset against `art.mul` while the index holds UOP offsets is not approximate, it is nonsense. The first run of this probe refused **34,299 perfectly good statics** for "declaring 10533x2085" — and every one of those refusals looked like a real finding. `BridgeAssetValidator.ArtDataPath()` now mirrors `FileIndex`'s own resolution order, and phase 1 must too.
|
||||||
|
- A custom-art shard that adds graphics to `art.mul` while the UOP is still in place **gets nothing**, silently. That is an operator trap rather than a bug in this protocol, but the extractor is where it will be noticed.
|
||||||
|
- The `corrupt` and `customart` tiers of `patch_client.ps1` therefore need its `nouop` tier to mean anything at all. Without it they report that they applied, and change nothing.
|
||||||
|
|
||||||
|
### 22,102 wrong pictures on a stock, unmodified client
|
||||||
|
|
||||||
|
The counts that matter, `assetprobe all stock`:
|
||||||
|
|
||||||
|
```
|
||||||
|
statics 0..65535 ok 39,189 WRONG PICTURES (empty record) 9,962 threw 16,385
|
||||||
|
land 0..16383 ok 4,244 WRONG PICTURES (empty record) 12,140
|
||||||
|
```
|
||||||
|
|
||||||
|
Those 22,102 ids have an index entry of `lookup 0, length 0` — **no record at all**. `FileIndex.Seek` treats that as a hit (it rejects `lookup < 0` and `length < 0`, and zero is neither), hands back the stream, and `LoadStatic` decodes `length` = 0 bytes into `m_StreamBuffer` — which is **reused, only ever grown, and filled by a `stream.Read` whose return value is discarded**. So the id renders whatever the previously-decoded asset left in the buffer.
|
||||||
|
|
||||||
|
**It is specific to the UOP path.** Run the same sweep against the mul path and those ids come back empty and honest, because `artidx.mul` stores `-1` for an absent record while unmapped UOP slots are simply zeroed structs. That is also why the earlier PowerShell probe counted 32,766 of these as "ok": they decode, they raise nothing, and no success count can tell them from art.
|
||||||
|
|
||||||
|
A bulk import that trusted the library would have written 22,102 duplicate images into the site under ids that have no art. This one measurement is the argument for validate-before-calling.
|
||||||
|
|
||||||
|
### Every deliberate defect was caught by the validator and rendered by the library
|
||||||
|
|
||||||
|
`assetprobe all patched`, against the 21-defect client:
|
||||||
|
|
||||||
|
```
|
||||||
|
statics ok 39,190 absent 9,954 refused 1 WRONG PICTURES (bad record) 6 threw 16,385
|
||||||
|
land ok 4,243 absent 12,140 WRONG PICTURES (bad record) 1
|
||||||
|
```
|
||||||
|
|
||||||
|
| id | the defect | what the library did |
|
||||||
|
|---|---|---|
|
||||||
|
| `static/4104` | lookup 4 KB past the end of `art.mul` | returns nothing — `Seek` does check the record's **start** |
|
||||||
|
| `static/4105` | starts 64 bytes before EOF, declares 8,192 | **renders the previous asset** — `Seek` never checks the record's **end** |
|
||||||
|
| `static/4108` | declared length 4, smaller than the header | renders something |
|
||||||
|
| `static/4109` | header declares 8000x8000 | **allocates it** — a ~128 MB bitmap from two bytes in a file, and the same field can ask for 65535×65535 |
|
||||||
|
| `static/4111` | row table points 60,000 words outside a 512-byte record | renders — `LoadStatic`'s two guards bound the *write* into the bitmap, and nothing bounds the *read* |
|
||||||
|
| `static/4112` | a 16-pixel run declared in a 20-byte record | renders |
|
||||||
|
| `static/4131` | verdata entry whose lookup is past verdata.mul's own end | renders — **`Verdata.Seek` has no bounds check whatsoever** |
|
||||||
|
| `land/256` | 512-byte land record | renders — `LoadLand` reads a fixed 2,024 bytes whatever the length says |
|
||||||
|
|
||||||
|
Seven of the eight produce a confident, wrong picture and raise nothing anywhere.
|
||||||
|
|
||||||
|
The validator refused all eight, and refused **nothing** on the stock client across 49,151 statics and 16,384 land tiles. That second number is the one that matters: a checker that refuses real art is worse than no checker, so "zero false refusals on a clean client" is what makes validate-before-calling more than a hopeful phrase.
|
||||||
|
|
||||||
|
The eight `customart` ids appended past the stock ceiling all decode cleanly, which is that tier's whole point — the ceiling is a property of a file, not a constant anyone should write down.
|
||||||
|
|
||||||
|
### Two more ways to get a wrong answer out of an id that has no art
|
||||||
|
|
||||||
|
- **`Art.GetStatic(id, false)` throws `IndexOutOfRangeException` for `id >= 49,152`** rather than returning null — 16,385 of them in a full sweep.
|
||||||
|
- **`Art.GetStatic(id)` with the default `checkmaxid: true` is worse**: `GetLegalItemID` maps an out-of-range id to **0**, so the call returns **item 0's picture**. An exception is recoverable; a picture of the wrong item is not even detectable.
|
||||||
|
|
||||||
|
So the extractor takes its id ceiling from the index it opened, and passes `checkmaxid: false` so an overrun is loud rather than plausible.
|
||||||
|
|
||||||
|
### The gump crash reproduces in-process, and nothing catches it
|
||||||
|
|
||||||
|
`assetprobe gump` called `Ultima.Gumps.GetGump(2)` once. **The ServUO process disappeared** — no exception line in the report, no `catch` reached, no shutdown, nothing in the console. The report ends mid-section, and `checkpoint.txt` reading `gump 2` is the entire record of what happened. That is exactly why the checkpoint is written *before* the call and flushed.
|
||||||
|
|
||||||
|
`AccessViolationException` is a corrupted-state exception and .NET Framework 4.8 does not deliver it to ordinary handlers, so **there is no in-process defence** — on a live shard this is a crash with players on it. "Nothing calls `Ultima.Gumps`" is a safety rule, and phase 0's job was to make sure that sentence had been earned rather than assumed. It has.
|
||||||
|
|
||||||
|
### The cliloc port is byte-identical to UOFiddler
|
||||||
|
|
||||||
|
```
|
||||||
|
123,490 entries in 218 ms (55,986 blank, 67,504 would be stored)
|
||||||
|
vs UOFiddler: 123,490 identical, 0 differ, 0 only ours, 0 only theirs
|
||||||
|
```
|
||||||
|
|
||||||
|
§9 is proven: the shard can produce the whole table with no UOFiddler installed, no `dotnet build`, and no 5 MB file copied to a server.
|
||||||
|
|
||||||
|
The reference is what makes this a test rather than a demonstration. A subtly wrong inverse-BWT coder still produces a plausible table — mostly-right strings with a few mangled ones is the *expected* shape of a bug in this algorithm, and a row count alone would sail past it.
|
||||||
|
|
||||||
|
Note the blank count is **55,986**, not the 55,994 recorded from the manual pipeline. The difference is eight whitespace-only entries, blank to a `trim()` and not to `IsNullOrEmpty` — a definition rather than a defect, but exactly the sort of eight-row drift that gets investigated as one.
|
||||||
|
|
||||||
|
### What phase 0 did not cover, and phase 1 must
|
||||||
|
|
||||||
|
**The animation path has no validator.** The patched client's verdata entry for body 34 points past verdata.mul's end and the wolf still "decoded" — counted among the 1,144 successes, silently rendering something else, with nothing in the report to say so. `GetAnimation` also allocates `new int[frameCount]` straight from a file-supplied int. Everything above about statics applies here and none of it is implemented yet.
|
||||||
|
|
||||||
|
The deliberate `Bodyconv.def` mis-mappings (bodies 1900 and 1901) produced **nothing** rather than a wrong creature on this client, so they did not reproduce the spider. The gargoyle rows remain the real evidence for the never-sweep-file-types rule: 666, 667, 694 and 695 report nothing, and nothing is the correct answer.
|
||||||
|
|
||||||
|
### Reference: the rest of the run
|
||||||
|
|
||||||
|
```
|
||||||
|
bodies 0..2047, direction 1 decoded 1,144 empty 904 faulted 0
|
||||||
|
by file type: 1=1222, 2=140, 3=244, 4=150, 5=292
|
||||||
|
|
||||||
|
player bodies (Race.AllRaces, direction 0) 6 decoded, 6 absent, of 12
|
||||||
|
Human 400 / 401 decode; ghosts 402 / 403 absent
|
||||||
|
Elf 605 / 606 / 607 / 608 all decode
|
||||||
|
Gargoyle 666 / 667 / 694 / 695 all absent
|
||||||
|
```
|
||||||
|
|
||||||
|
Two details worth keeping. The body counts reproduce the PowerShell probe **exactly**, from a different process against the same files, which is what makes the two runs comparable at all. And the gargoyle *ghost* bodies resolve to file type **1**, not 5 like the living gargoyle bodies — so "the gargoyle is an anim5 problem" is not quite the shape of it.
|
||||||
|
|
||||||
|
## Building the patched client
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\tools\patch_client.ps1 -Dest D:\uo-patched-client
|
||||||
|
```
|
||||||
|
|
||||||
|
Copies a client (~3.5 GB) and breaks the copy in five catalogued tiers — `nouop`, `verdata`, `customart`, `corrupt`, `bodyconv`. **It never writes to the source**: every file it touches is hashed in the source before and after, and a changed hash aborts the run. Each defect is recorded in `patched-client.manifest.json` beside the copy, which is what makes a nonzero WRONG PICTURES count readable as "the tier worked" instead of "something broke".
|
||||||
|
|
||||||
|
Then point the shard at it and drive the probe:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
RigDriverEnabled=true
|
||||||
|
AssetProbeClient=D:\uo-patched-client
|
||||||
|
AssetProbeClilocRef=<a clilocs.tsv from website/server/tools/cliloc-export --tsv>
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
assetprobe all stock # the baseline: the validator must refuse nothing here
|
||||||
|
assetprobe all patched # the experiment
|
||||||
|
```
|
||||||
|
|
||||||
|
Run both against **one boot**, through `rigcmd.txt`, so a difference between them cannot be a difference between two shard processes. Without `AssetProbeClilocRef` the cliloc section reports a row count, which proves nothing about the strings.
|
||||||
|
|
||||||
|
**The copy is EA's client art.** It stays on the machine that made it, exactly like every other extraction in this project, and is never committed.
|
||||||
|
|||||||
Reference in New Issue
Block a user