Compare commits
10 Commits
b92b85c3a9
...
fix/one-mo
| Author | SHA1 | Date | |
|---|---|---|---|
| e4f088e90b | |||
| 702ab89ae2 | |||
| 9e1f591b25 | |||
| efa9db7330 | |||
| 720103e3d4 | |||
| f373f2e897 | |||
| 61dc692088 | |||
| 655fbf3f69 | |||
| baa4f7d5ba | |||
| 7d7840eb6b |
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user