10 Commits

Author SHA1 Message Date
e4f088e90b fix(modules): a site runs one module; the installer refuses a second
All checks were successful
PR Checks / client-build (pull_request) Successful in 33s
PR Checks / bot-tests (pull_request) Successful in 34s
PR Checks / server-tests (pull_request) Successful in 13m28s
A site is one game, and the module contract already has singletons that
assume it. registerTeamProvider holds one value per deployment, and a
second module registering one fails that module's whole load. The loader
scans alphabetically, so installing module-rust (which gains a Team
provider in its phase 9) beside module-uo would have taken uo down, not
rust.

install() now refuses, with 409 and before the artifact is downloaded,
any install whose id differs from a module already on the volume. An
upgrade of the installed module is still accepted; to change game,
remove the module first. Both install surfaces share this path, so a
MODULES declaration naming two modules installs the first and reports
the second as refused without failing the boot.

"Installed" means what the loader would scan: a directory named with a
module id that holds a module.json. An install's scratch directory and a
swap's aside copy do not count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-23 01:26:52 -05:00
702ab89ae2 Merge pull request 'fix(admin): stop a long action name printing over the activity row beside it' (#203) from fix/activity-action-overflow into main
All checks were successful
sync-project-tree / sync (push) Successful in 13s
Build container images / build (push) Successful in 1m29s
Build container images / deploy (push) Successful in 46s
SonarQube / analysis (push) Successful in 9m11s
Reviewed-on: #203
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-21 22:24:29 +00:00
9e1f591b25 fix(admin): stop a long action name printing over the activity row beside it
All checks were successful
PR Checks / client-build (pull_request) Successful in 57s
PR Checks / server-tests (pull_request) Successful in 6m9s
PR Checks / bot-tests (pull_request) Successful in 8m3s
The dashboard renders an activity row action in a fixed `width: 110` span with
`flex: none` and no overflow handling, so a name wider than that overflows its
box and prints on top of the detail text next to it.

Core own actions all fit. A module one need not: `module-rust` writes
`rust.account.unlink.staff` when staff sever a player Steam link, and it
overlapped `steamId: …` on a live dashboard. `module-uo` `uoLink.account.link`
is already close to the edge.

`minWidth` instead of `width` keeps the column aligned for every short name and
lets a longer one push the detail right rather than sit under it. One property,
verified in a browser with both rows on screen.

Found while walking module-rust phase 6; raised here rather than worked around
there, because a module may legitimately name an action and shortening one
module names only moves the ceiling to the next one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
2026-09-21 09:10:43 -05:00
efa9db7330 Merge pull request 'chore(tools): delete the cliloc converter the Asset Bridge replaced (Asset Bridge cutover, 2 of 5)' (#202) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in 34s
Build container images / build (push) Successful in 22s
Build container images / deploy (push) Successful in 38s
SonarQube / analysis (push) Successful in 9m11s
Reviewed-on: #202
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-15 10:20:24 +00:00
720103e3d4 Merge branch 'main' into edge
All checks were successful
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 13m24s
2026-09-15 10:04:54 +00:00
f373f2e897 Merge pull request 'chore(tools): delete the cliloc converter the Asset Bridge replaced (Phase 2)' (#201) from feat/asset-bridge-p2 into edge
All checks were successful
PR Checks / client-build (pull_request) Successful in 40s
PR Checks / bot-tests (pull_request) Successful in 41s
PR Checks / server-tests (pull_request) Successful in 13m28s
Reviewed-on: #201
2026-09-10 16:20:24 +00:00
61dc692088 chore(tools): delete the cliloc converter the Asset Bridge replaced (Phase 2)
All checks were successful
PR Checks / client-build (pull_request) Successful in 39s
PR Checks / bot-tests (pull_request) Successful in 41s
PR Checks / server-tests (pull_request) Successful in 5m52s
`server/tools/cliloc-export/` existed for one reason: every modern UO client
ships its cliloc table in the Mythic container, and nothing in this stack could
read it — not the site, and not ServUO's own bundled `Ultima.StringList`. So an
operator installed UOFiddler, built this against its `Ultima.dll`, ran it over
their client and copied a 5 MB file to the web host, every time they patched.

Protocol 8 phase 2 put the decompressor in the shard plugin, where the client
files already are, and module-uo imports the table over the bridge. The tool has
nothing left to do. See docs/link/v8.md §9 and docs/website/CLILOCS.md.

Nothing in core referenced it — it was a standalone .NET console app under
`server/tools/`, and that directory is now empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 11:13:38 -05:00
655fbf3f69 Merge pull request 'feat(events): the Event System — core (Phase 16b cutover, 2 of 6)' (#199) from edge into main
Some checks failed
sync-project-tree / sync (push) Successful in 1m32s
Build container images / build (push) Successful in 2m12s
Build container images / deploy (push) Successful in 49s
SonarQube / analysis (push) Failing after 33m0s
Reviewed-on: #199
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-10 00:43:29 +00:00
baa4f7d5ba Merge pull request 'fix(events): midnight in an announcement is 12:00 am on the Node we ship' (#200) from fix/events-announce-midnight-hourcycle into edge
All checks were successful
PR Checks / client-build (pull_request) Successful in 28s
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / server-tests (pull_request) Successful in 13m32s
Reviewed-on: #200
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-10 00:27:58 +00:00
7d7840eb6b fix(events): midnight in an announcement is 12:00 am on the Node we ship
All checks were successful
PR Checks / client-build (pull_request) Successful in 40s
PR Checks / bot-tests (pull_request) Successful in 41s
PR Checks / server-tests (pull_request) Successful in 5m56s
`startsAtLabel` asked for `hour12: true` on `en-GB`. That is not the same
request as a 12-hour clock, and it does not survive a Node upgrade: for a
locale whose default cycle is h23, Node 20 resolves `hour12: true` to h11,
whose hours run 0-11, so midnight renders "0:00 am". Node 22 and later
resolve it to h12 and it renders "12:00 am". Same ICU on both sides, so it
is V8's ECMA-402 behaviour rather than locale data.

The image ships node:20-alpine and CI runs Node 20, while a dev machine is
newer -- which is how this rendered correctly in front of everyone who wrote
it and wrongly for every real recipient. An event mail announcing a midnight
start said "0:00 am" while the schedule editor beside it said "12:00 AM":
one instant, two spellings, which is the exact contradiction the option was
added to prevent.

`hourCycle: 'h12'` is the request that means what was meant. `recurrence.js`
already states the mirror-image rule for `h23`, and every other formatter in
this repo and in module-uo uses `hourCycle`; there is no `hour12` left here.

This is the one test that has been red on every events PR since #192, and
the only one -- each of those runs reported `# fail 1`. Verified by running
the suite under node:20-alpine, where the test fails without this change and
2152 tests pass with it; on Node 22+ it passes either way, so the test's
comment now says that a green run on a dev machine is not evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-09 19:20:31 -05:00
11 changed files with 158 additions and 235 deletions

View File

@@ -166,7 +166,12 @@ export default function Dashboard() {
className="sans"
style={{ display: 'flex', gap: 14, alignItems: 'center', padding: '13px 18px', borderBottom: '1px solid var(--line-soft)', fontSize: '0.86rem' }}
>
<span style={{ flex: 'none', color: 'var(--accent)', fontSize: '0.66rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', width: 110, fontFamily: 'ui-monospace,Menlo,monospace' }}>
{/* `minWidth` rather than `width`: the column still lines up for core's
own short action names, and a longer one — a module's namespaced
action, say `rust.account.unlink.staff` — grows the box instead of
overflowing it and printing on top of the detail beside it. Found
on a live dashboard with a module installed. */}
<span style={{ flex: 'none', color: 'var(--accent)', fontSize: '0.66rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', minWidth: 110, fontFamily: 'ui-monospace,Menlo,monospace' }}>
{a.action}
</span>
<span style={{ flex: 1, color: 'var(--text)' }}>{formatDetail(a)}</span>

View File

@@ -67,7 +67,18 @@ function startsAtLabel(at, zone) {
// Explicit rather than left to the locale, because `en-GB` would otherwise
// render midnight as "00:00" while the schedule editor beside it writes
// "12:00 AM" — one event, two spellings of the same instant.
hour12: true,
//
// `hourCycle: 'h12'` and NOT `hour12: true`, which is not the same request
// and does not survive a Node upgrade. For a locale whose default cycle is
// h23 — `en-GB` is one — Node 20 resolves `hour12: true` to **h11**, whose
// hours run 0–11, so midnight comes out "0:00 am"; Node 22 and later
// resolve it to h12 and it comes out "12:00 am". Same ICU on both, so this
// is V8's ECMA-402 behaviour and not locale data, and the image ships
// node:20-alpine while a dev machine is newer — which is how this rendered
// correctly in front of everyone who wrote it and wrongly for every real
// recipient. `recurrence.js` states the mirror-image rule for `h23`; there
// is no `hour12` left in this repo and it should stay that way.
hourCycle: 'h12',
}).format(when)
return `${text} (${zone || 'UTC'})`
} catch {

View File

@@ -17,6 +17,11 @@
// through modules/install.js, the same fetch-verify-unpack path the admin panel
// uses, under the same host allowlist.
//
// **A site runs one module** (org lead, 2026-09-23), and install.js enforces it
// for this path too: a declaration naming a second module installs the first,
// and the second is refused — logged and kept for the admin screen like any
// other failed entry, never fatal to the boot.
//
// Three things this file deliberately does not do:
//
// - **It does not decide whether a module RUNS.** Resolution owns what is on

View File

@@ -290,6 +290,28 @@ function isInstalled(id) {
}
}
/**
* Every module on the volume, by id — the directories the loader would scan.
*
* The loader's own rule, restated: a directory whose name is a module id and
* which holds a `module.json`. That excludes an install's scratch directory
* (`.install-*`) and a swap's aside copy (`<id>.replaced-*`), neither of which
* is a module and both of which can briefly exist beside one.
*/
function installedIds() {
let entries
try {
entries = fs.readdirSync(loader.dir(), { withFileTypes: true })
} catch {
return [] // no modules directory is the normal case for a bare core
}
return entries
.filter((e) => e.isDirectory() && ID.test(e.name))
.filter((e) => fs.existsSync(path.join(loader.dir(), e.name, 'module.json')))
.map((e) => e.name)
.sort()
}
/**
* The absolute path of a module's `purge.sql`, or null.
*
@@ -348,6 +370,25 @@ async function install({ url, hosts, expect = null, fetchImpl = fetch }) {
)
}
// One module per site (org lead, 2026-09-23). A site is one game, and the
// contract has singletons that assume it: `registerTeamProvider` holds ONE
// value per deployment, and a second module registering one fails its whole
// load — with modules loaded alphabetically, installing `rust` beside `uo`
// would have taken `uo` down, not `rust`. So an install is an UPGRADE of the
// module already here, or it is refused before a byte is downloaded.
//
// Refused here rather than in the admin controller so the declared module set
// (modules/declared.js) gets the same answer: an environment naming two
// modules installs the first and is told why the second was not.
const others = installedIds().filter((id) => id !== manifest.id)
if (others.length) {
throw new InstallError(
`this site already runs the module "${others.join('", "')}", and a site runs one module. ` +
`Upgrade it with its own release, or remove it before installing "${manifest.id}".`,
{ status: 409 },
)
}
const target = moduleDir(manifest.id)
const scratch = await fsp.mkdtemp(path.join(loader.dir(), `.install-${manifest.id}-`))
const tarball = path.join(scratch, 'bundle.tar.gz')
@@ -444,6 +485,7 @@ module.exports = {
removeDir,
moduleDir,
isInstalled,
installedIds,
purgeFile,
MAX_MANIFEST_BYTES,
MAX_ARTIFACT_BYTES,

View File

@@ -41,11 +41,12 @@ modulesRouter.post(
'/',
// #swagger.tags = ['Admin · Modules']
// #swagger.summary = 'Install or upgrade a module from a release install-manifest URL'
// #swagger.description = 'Downloads the artifact the manifest names, verifies its sha256, inspects the archive in full and unpacks it onto the modules volume. The module mounts on the next restart.'
// #swagger.description = 'Downloads the artifact the manifest names, verifies its sha256, inspects the archive in full and unpacks it onto the modules volume. The module mounts on the next restart. A site runs ONE module: installing a module other than the one already on the volume is refused with 409 before anything is downloaded, and only an upgrade of the installed module is accepted.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["url"], properties: { url: { type: "string", description: "https URL of the release install manifest, on an allowed host" } } } } } } */
/* #swagger.responses[201] = { description: 'Installed — restart to mount it', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'The URL, the manifest, the hash or the archive was refused', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'A different module is already installed; a site runs one module', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[502] = { description: 'The source host could not be reached or answered badly', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
body('url').isString().trim().isLength({ min: 1, max: 2048 }),

View File

@@ -7211,7 +7211,7 @@
"Admin · Modules"
],
"summary": "Install or upgrade a module from a release install-manifest URL",
"description": "Downloads the artifact the manifest names, verifies its sha256, inspects the archive in full and unpacks it onto the modules volume. The module mounts on the next restart.",
"description": "Downloads the artifact the manifest names, verifies its sha256, inspects the archive in full and unpacks it onto the modules volume. The module mounts on the next restart. A site runs ONE module: installing a module other than the one already on the volume is refused with 409 before anything is downloaded, and only an upgrade of the installed module is accepted.",
"responses": {
"201": {
"description": "Installed — restart to mount it",
@@ -7234,6 +7234,16 @@
}
}
},
"409": {
"description": "A different module is already installed; a site runs one module",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
},

View File

@@ -260,9 +260,15 @@ test('the start time is written out in the SHARD\'s zone, not the server\'s', ()
})
test('midnight reads as 12:00 am and never as 00:00', () => {
// `hour12` is set explicitly. Left to the en-GB locale this would render
// The hour cycle is set explicitly. Left to the en-GB locale this would render
// "00:00" while the schedule editor beside it writes "12:00 AM" — one event,
// two spellings of the same instant.
//
// This assertion only has teeth on the Node the image ships (20), where
// `hour12: true` resolves to h11 and midnight reads "0:00 am". On Node 22+ it
// passes either way — so a green run on a dev machine is not evidence, and CI
// is what actually holds this line. See the note beside `hourCycle` in
// events/announce.js.
assert.match(announce.startsAtLabel(new Date('2026-09-13T04:00:00Z'), 'America/New_York'), /12:00 am/)
})

View File

@@ -395,6 +395,79 @@ test('a failed upgrade leaves the previous version in place', async () => {
assert.deepEqual(fs.readdirSync(tmpRoot), ['uo'])
})
// ── One module per site ────────────────────────────────────────────────────
/** A second, different module's manifest and artifact, served beside the first. */
function otherModuleRoutes(id = 'rust') {
const tarball = bundle({ id })
const artifact = `https://releases.example.com/mod/${id}-1.0.0.tar.gz`
const url = `https://releases.example.com/mod/${id}-1.0.0.json`
return {
url,
routes: {
[url]: manifestFor(tarball, { id, name: id, artifact: `${id}-1.0.0.tar.gz`, url: artifact }),
[artifact]: tarball,
},
artifact,
}
}
test('a second, different module is refused before anything is downloaded', async () => {
const first = goodRoutes()
await install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(first.routes) })
const other = otherModuleRoutes('rust')
const fetchImpl = fakeFetch(other.routes)
await assert.rejects(
() => install.install({ url: other.url, hosts: HOSTS, fetchImpl }),
(err) => {
assert.equal(err.name, 'InstallError')
// 409: nothing is wrong with the URL; the SITE is not in a state to take it.
assert.equal(err.status, 409)
assert.match(err.message, /already runs the module "uo"/)
assert.match(err.message, /remove it before installing "rust"/)
return true
},
)
// Refused on the manifest alone: the artifact was never fetched, and the
// volume holds exactly what it held before.
assert.ok(!fetchImpl.seen.includes(other.artifact), 'the artifact was not downloaded')
assert.deepEqual(fs.readdirSync(tmpRoot), ['uo'])
})
test('the same module is still an upgrade, and removing it frees the site for another', async () => {
const first = goodRoutes()
await install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(first.routes) })
// An upgrade of what is installed is exactly what the rule allows.
const second = goodRoutes({ version: '2.0.0', manifest: { version: '2.0.0' } })
second.routes[MANIFEST_URL] = manifestFor(second.tarball, { version: '2.0.0' })
const upgraded = await install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(second.routes) })
assert.equal(upgraded.replaced, true)
// And once it is gone, the site takes a different one.
await install.removeDir('uo')
const other = otherModuleRoutes('rust')
const result = await install.install({ url: other.url, hosts: HOSTS, fetchImpl: fakeFetch(other.routes) })
assert.equal(result.id, 'rust')
assert.deepEqual(install.installedIds(), ['rust'])
})
test('what counts as installed is what the loader would scan', () => {
// A real module, an install's scratch directory, a swap's aside copy and a
// directory with no module.json. Only the first is a module.
fs.mkdirSync(path.join(tmpRoot, 'uo'))
fs.writeFileSync(path.join(tmpRoot, 'uo', 'module.json'), '{}')
fs.mkdirSync(path.join(tmpRoot, '.install-rust-abc'))
fs.writeFileSync(path.join(tmpRoot, '.install-rust-abc', 'module.json'), '{}')
fs.mkdirSync(path.join(tmpRoot, 'uo.replaced-123'))
fs.writeFileSync(path.join(tmpRoot, 'uo.replaced-123', 'module.json'), '{}')
fs.mkdirSync(path.join(tmpRoot, 'notes'))
assert.deepEqual(install.installedIds(), ['uo'])
})
// ── The volume ─────────────────────────────────────────────────────────────
test('moduleDir refuses an id that is not one', () => {

View File

@@ -1,140 +0,0 @@
// Cliloc export — converts a modern client's COMPRESSED Cliloc.enu into the
// plain format the website can read (docs/website/CLILOCS.md).
//
// Why this exists at all: every current UO client ships its cliloc files in the
// compressed "Mythic" format — the first DWORD's high byte is 0x8E — and the
// plain layout the website parses is what those files looked like before that
// change. Decompressing is a bit-level inverse-BWT coder that the site has no
// business carrying at runtime, and ServUO's own bundled `Ultima.StringList`
// cannot read it either (which is why `VendorSearch.GetItemName` is already
// inert on such a shard, and why the shard cannot supply names instead).
//
// So the conversion happens ONCE, here, against a decompressor that already
// exists and is maintained: UOFiddler's `Ultima.dll`.
//
// ── Why reflection rather than a project reference ────────────────────────
//
// UOFiddler ships as net10.0. Referencing it from a project built by an older
// SDK fails at COMPILE time with CS1705 ("uses System.Runtime 10.0 which has a
// higher version than referenced assembly"). Loading it reflectively moves that
// question to run time, where `RollForward: LatestMajor` answers it — so this
// builds on whatever SDK an operator happens to have and runs on the newest
// runtime installed.
//
// ── Why not StringList.SaveStringList ────────────────────────────────────
//
// It looks like exactly the right method and it is not: it RE-COMPRESSES on
// save, because its purpose is round-tripping a file back into the client. The
// output is byte-identical to the compressed input. The plain records below are
// written by hand for that reason.
//
// Usage:
// dotnet run -- <Ultima.dll> <Cliloc.enu> <output> [--tsv]
//
// Nothing produced by this tool is committed. See docs/website/CLILOCS.md.
using System;
using System.Collections;
using System.IO;
using System.Reflection;
using System.Text;
internal static class Program
{
private static int Main(string[] args)
{
if (args.Length < 3)
{
Console.Error.WriteLine("usage: clilocexport <path-to-Ultima.dll> <cliloc-file> <output-file> [--tsv]");
Console.Error.WriteLine(" Ultima.dll ships with UOFiddler (https://github.com/polserver/UOFiddler).");
return 2;
}
var (ultimaDll, input, output) = (args[0], args[1], args[2]);
var asTsv = Array.IndexOf(args, "--tsv") >= 0;
// The language code only names the file when StringList resolves the path
// itself; here the path is explicit, so it is cosmetic.
var language = Path.GetExtension(input).TrimStart('.');
if (string.IsNullOrWhiteSpace(language)) language = "enu";
var assembly = Assembly.LoadFrom(Path.GetFullPath(ultimaDll));
var stringListType = assembly.GetType("Ultima.StringList")
?? throw new InvalidOperationException("Ultima.StringList not found — is that really UOFiddler's Ultima.dll?");
// (language, path, decompress). `decompress: true` is the whole point;
// the loader falls back to a plain read on its own if the file turns out
// not to be compressed, so an already-converted file passes through.
var ctor = stringListType.GetConstructor(new[] { typeof(string), typeof(string), typeof(bool) })
?? throw new InvalidOperationException("Unexpected Ultima.StringList API — this tool targets UOFiddler 4.21+.");
var stringList = ctor.Invoke(new object[] { language, Path.GetFullPath(input), true });
// A partial parse is reported rather than thrown. Surfacing it matters:
// the output would otherwise be a quietly short table, which is exactly
// the failure mode the website's parser refuses to import.
var warning = stringListType.GetProperty("LoadWarning")?.GetValue(stringList) as string;
if (!string.IsNullOrWhiteSpace(warning)) Console.Error.WriteLine("warning: " + warning);
var entries = (IEnumerable)stringListType.GetProperty("Entries")!.GetValue(stringList)!;
var entryType = assembly.GetType("Ultima.StringEntry")!;
var numberProp = entryType.GetProperty("Number")!;
var textProp = entryType.GetProperty("Text")!;
var flagProp = entryType.GetProperty("Flag")!;
int written = 0, skipped = 0, maxBytes = 0;
if (asTsv)
{
using var writer = new StreamWriter(output, false, new UTF8Encoding(false));
foreach (var entry in entries)
{
var number = (int)numberProp.GetValue(entry)!;
var text = (string?)textProp.GetValue(entry) ?? "";
maxBytes = Math.Max(maxBytes, Encoding.UTF8.GetByteCount(text));
// A tab or newline inside a cliloc string would break the row.
// Neither occurs in real tables, but silently emitting a broken
// file is worse than collapsing the whitespace.
writer.WriteLine($"{number}\t{text.Replace('\t', ' ').Replace('\r', ' ').Replace('\n', ' ')}");
written++;
}
}
else
{
using var stream = new FileStream(output, FileMode.Create, FileAccess.Write);
using var binary = new BinaryWriter(stream);
binary.Write(2); // int32 — the plain-format version marker
binary.Write((short)1); // int16 — language marker
foreach (var entry in entries)
{
var number = (int)numberProp.GetValue(entry)!;
var text = (string?)textProp.GetValue(entry) ?? "";
var flag = Convert.ToByte(Convert.ToInt32(flagProp.GetValue(entry)));
var utf8 = Encoding.UTF8.GetBytes(text);
maxBytes = Math.Max(maxBytes, utf8.Length);
// The length field is 16 bits. Real tables peak around 12 KB, so
// this has never fired — but writing a truncated length would
// corrupt every record after it, so an oversize entry is dropped
// and counted instead.
if (utf8.Length > ushort.MaxValue) { skipped++; continue; }
binary.Write(number);
binary.Write(flag);
binary.Write((ushort)utf8.Length);
binary.Write(utf8);
written++;
}
}
Console.WriteLine($"wrote {written} entries to {output} (maxTextBytes={maxBytes}, skippedOversize={skipped})");
if (written == 0)
{
Console.Error.WriteLine("no entries were written — is that a cliloc file?");
return 1;
}
return 0;
}
}

View File

@@ -1,64 +0,0 @@
# cliloc-export
Converts a UO client's **compressed** `Cliloc.enu` into the plain format the
website can read.
This is a one-off operator utility, not part of the website build. Nothing in the
Node application references it and CI never touches it. Full background —
including why the conversion is necessary at all — is in
[`docs/website/CLILOCS.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/edge/website/CLILOCS.md).
## The short version
Every current UO client ships its cliloc files in the compressed "Mythic"
container (the first DWORD's high byte is `0x8E`). The website parses the plain
layout those files used before that change. Decompressing is an inverse-BWT coder
that the site has no business carrying at runtime — and ServUO's own bundled
`Ultima.StringList` cannot read it either, so the shard cannot supply item names
on our behalf.
So: convert once, here, using a decompressor that already exists and is already
maintained — [UOFiddler](https://github.com/polserver/UOFiddler)'s `Ultima.dll`.
## Usage
```bash
dotnet build -c Release
# plain binary (recommended — exact)
dotnet run -- "<UOFiddler>/Ultima.dll" "<UO client>/Cliloc.enu" /srv/uo-data/clilocs.plain
# tab-delimited text (convenient; does not preserve leading/trailing whitespace)
dotnet run -- "<UOFiddler>/Ultima.dll" "<UO client>/Cliloc.enu" /srv/uo-data/clilocs.tsv --tsv
```
Then point the site at the output: **Admin → Shard → cliloc path**, or the
`UO_CLIENT_PATH` environment variable. The setting wins over the environment.
Expected output for a stock English client:
```
wrote 123490 entries to /srv/uo-data/clilocs.plain (maxTextBytes=12150, skippedOversize=0)
```
The site stores ~67,500 of those — roughly half a cliloc table is empty strings
for ids the client reserves and never uses.
## Two implementation notes worth keeping
**`Ultima.dll` is loaded reflectively, not referenced.** UOFiddler ships as
net10.0; a project reference from an older SDK fails at *compile* time with
CS1705. Reflection moves that to run time, where `RollForward: LatestMajor`
answers it — so this builds on whatever SDK you have and runs on the newest
runtime installed.
**`StringList.SaveStringList` is not the export path**, despite looking exactly
like it. It *re-compresses* on save, because its purpose is round-tripping a file
back into the client — its output is byte-identical to its input. The plain
records are written by hand for that reason.
## Output is never committed
UO's strings are EA's. `.gitignore` covers this project's build output and the
conventional in-repo output location, but the supported arrangement is a path
**outside** the repository entirely.

View File

@@ -1,26 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
A one-off operator utility, not part of the website build. Nothing in the
Node application references it and CI never touches it; it exists so an
operator can convert their client's compressed cliloc file without clicking
through a GUI. See README.md and docs/website/CLILOCS.md.
TargetFramework is deliberately net8.0 — the OLDEST runtime this needs — so
it builds on whatever SDK an operator already has. UOFiddler's Ultima.dll is
net10.0 and is loaded reflectively at run time rather than referenced, which
is what keeps that version difference from being a compile error; the
RollForward below is what lets the resulting binary run on it.
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<AssemblyName>clilocexport</AssemblyName>
<RootNamespace>ClilocExport</RootNamespace>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<RollForward>LatestMajor</RollForward>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
</Project>