fix(nitro): normalise uploaded bundles so meta.image matches the texture
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 39s
CI / tests-unit (push) Successful in 2m3s
CI / tests-integration (push) Successful in 2m7s
CI / tests-ui (push) Successful in 2m50s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 1m45s
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 39s
CI / tests-unit (push) Successful in 2m3s
CI / tests-integration (push) Successful in 2m7s
CI / tests-ui (push) Successful in 2m50s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 1m45s
An uploaded .nitro was written to disk byte-for-byte, so a bundle from a third-party tool that ships a WebP member while still pointing spritesheet.meta.image at a .png was accepted and stored as-is. The client resolves the spritesheet through that pointer, so the result was a file that validates fine and then renders nothing. Re-write the bundle through createNitroBundle on import, which labels the member from the actual bytes and repairs the pointer. No texture is re-encoded, so the bytes stay identical, and the member keeps the base name it arrived with so `chair*2` colour variants that share the `chair` library are not renamed.
This commit is contained in:
1 parent
17de94d984
commit
306e209e29
4 files changed
+36
-153
No files matched your search
@@ -1,19 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
apps: [
|
|
||||||
{
|
|
||||||
name: 'epic-next-app',
|
|
||||||
script: 'node_modules/next/dist/bin/next',
|
|
||||||
args: 'start',
|
|
||||||
cwd: '/var/www/atom-nexst',
|
|
||||||
instances: 1,
|
|
||||||
autorestart: true,
|
|
||||||
watch: false,
|
|
||||||
max_memory_restart: '1G',
|
|
||||||
node_args: '--max-old-space-size=1024',
|
|
||||||
env: {
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
PORT: 3002
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
/**
|
|
||||||
* Pre-compress large gamedata JSON files to .gz so nginx `gzip_static`
|
|
||||||
* serves them via sendfile instead of re-compressing up to 48 MB on every
|
|
||||||
* request (see /etc/nginx/nginx.conf: `gzip_static on`).
|
|
||||||
*
|
|
||||||
* Idempotent: a file is only re-compressed when its source mtime is newer
|
|
||||||
* than the existing .gz (e.g. after a Studio catalog export rewrites it).
|
|
||||||
*
|
|
||||||
* Usage: node scripts/compress-gamedata.mjs
|
|
||||||
* Env: GAMEDATA_ROOT (default /var/www/Gamedata)
|
|
||||||
* GAMEDATA_GZIP_MIN_BYTES (default 1048576)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createReadStream, createWriteStream, promises as fs } from "node:fs";
|
|
||||||
import { cpus } from "node:os";
|
|
||||||
import { join, relative } from "node:path";
|
|
||||||
import { pipeline } from "node:stream/promises";
|
|
||||||
import { createGzip } from "node:zlib";
|
|
||||||
|
|
||||||
const GAMEDATA_ROOT = process.env.GAMEDATA_ROOT ?? "/var/www/Gamedata";
|
|
||||||
const MIN_BYTES = Number(process.env.GAMEDATA_GZIP_MIN_BYTES ?? 1024 * 1024);
|
|
||||||
const GZIP_LEVEL = 9;
|
|
||||||
const TARGET_DIRS = ["config", "bundled/config"];
|
|
||||||
const CONCURRENCY = Math.max(1, Math.min(4, cpus().length));
|
|
||||||
|
|
||||||
function isCompressible(name) {
|
|
||||||
return (
|
|
||||||
name.endsWith(".json") &&
|
|
||||||
!name.endsWith(".gz") &&
|
|
||||||
!name.endsWith(".min.json")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function collectCandidates() {
|
|
||||||
const files = [];
|
|
||||||
for (const dir of TARGET_DIRS) {
|
|
||||||
const root = join(GAMEDATA_ROOT, dir);
|
|
||||||
let entries;
|
|
||||||
try {
|
|
||||||
entries = await fs.readdir(root, { withFileTypes: true });
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.isFile() || !isCompressible(entry.name)) continue;
|
|
||||||
const full = join(root, entry.name);
|
|
||||||
const stat = await fs.stat(full);
|
|
||||||
if (stat.size < MIN_BYTES) continue;
|
|
||||||
files.push({ src: full, size: stat.size, mtimeMs: stat.mtimeMs });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function needsCompression({ src, mtimeMs }) {
|
|
||||||
const gz = `${src}.gz`;
|
|
||||||
try {
|
|
||||||
const stat = await fs.stat(gz);
|
|
||||||
return stat.mtimeMs < mtimeMs;
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function compressOne({ src }) {
|
|
||||||
const gz = `${src}.gz`;
|
|
||||||
const tmp = `${gz}.tmp-${process.pid}`;
|
|
||||||
await pipeline(
|
|
||||||
createReadStream(src),
|
|
||||||
createGzip({ level: GZIP_LEVEL }),
|
|
||||||
createWriteStream(tmp),
|
|
||||||
);
|
|
||||||
await fs.rename(tmp, gz);
|
|
||||||
try {
|
|
||||||
await fs.chmod(gz, 0o644);
|
|
||||||
} catch {
|
|
||||||
// Best-effort; ownership is up to the caller.
|
|
||||||
}
|
|
||||||
return gz;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const candidates = await collectCandidates();
|
|
||||||
const work = [];
|
|
||||||
const skipped = [];
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (await needsCompression(candidate)) {
|
|
||||||
work.push(candidate);
|
|
||||||
} else {
|
|
||||||
skipped.push(candidate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = [];
|
|
||||||
let idx = 0;
|
|
||||||
async function worker() {
|
|
||||||
while (idx < work.length) {
|
|
||||||
const item = work[idx++];
|
|
||||||
try {
|
|
||||||
const gz = await compressOne(item);
|
|
||||||
const stat = await fs.stat(gz);
|
|
||||||
results.push(
|
|
||||||
`compressed ${relative(GAMEDATA_ROOT, gz)} (${item.size} -> ${stat.size} bytes, ${Math.round((1 - stat.size / item.size) * 1000) / 10}% smaller)`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
results.push(
|
|
||||||
`FAILED ${relative(GAMEDATA_ROOT, item.src)}: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const workers = Array.from(
|
|
||||||
{ length: Math.min(CONCURRENCY, work.length) },
|
|
||||||
() => worker(),
|
|
||||||
);
|
|
||||||
await Promise.all(workers);
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`gamedata: ${work.length} to compress, ${skipped.length} up to date`,
|
|
||||||
);
|
|
||||||
for (const line of results) console.log(`gamedata: ${line}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error(
|
|
||||||
`gamedata: FATAL ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -84,8 +84,19 @@ vi.mock("@/lib/services/furni-import", () => ({
|
|||||||
IMPORTED_PAGE_CAPTION_SAVE: "imported_furni",
|
IMPORTED_PAGE_CAPTION_SAVE: "imported_furni",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const nitroBuilderMock = vi.hoisted(() => ({
|
||||||
|
createNitroBundle: vi.fn((json: object) =>
|
||||||
|
Buffer.from(`rebuilt:${JSON.stringify(json)}`, "utf-8"),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/services/swf/nitro-builder", () => ({
|
vi.mock("@/lib/services/swf/nitro-builder", () => ({
|
||||||
parseNitroBundle: vi.fn(() => ({ json: { xdim: 1, ydim: 1 } })),
|
parseNitroBundle: vi.fn(() => ({
|
||||||
|
json: { xdim: 1, ydim: 1 },
|
||||||
|
texture: Buffer.from("texture-bytes"),
|
||||||
|
textureFileName: "chair.png",
|
||||||
|
})),
|
||||||
|
createNitroBundle: nitroBuilderMock.createNitroBundle,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -135,11 +146,19 @@ describe("uploadSingleFurni live asset mirrors", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
|
// The bundle is re-written through the builder so `meta.image` can never
|
||||||
|
// disagree with the texture member, while the member keeps the name it
|
||||||
|
// arrived with (colour variants share a library name).
|
||||||
|
expect(nitroBuilderMock.createNitroBundle).toHaveBeenCalledWith(
|
||||||
|
{ xdim: 1, ydim: 1 },
|
||||||
|
Buffer.from("texture-bytes"),
|
||||||
|
"chair",
|
||||||
|
);
|
||||||
await expect(
|
await expect(
|
||||||
fs.readFile(
|
fs.readFile(
|
||||||
getRuntimePath(assetTargets.mirrorDirs[0].nitroDir, "chair.nitro"),
|
getRuntimePath(assetTargets.mirrorDirs[0].nitroDir, "chair.nitro"),
|
||||||
),
|
),
|
||||||
).resolves.toEqual(nitroBuffer);
|
).resolves.toEqual(Buffer.from('rebuilt:{"xdim":1,"ydim":1}', "utf-8"));
|
||||||
await expect(
|
await expect(
|
||||||
fs.readFile(
|
fs.readFile(
|
||||||
getRuntimePath(assetTargets.mirrorDirs[0].iconDir, "chair_icon.png"),
|
getRuntimePath(assetTargets.mirrorDirs[0].iconDir, "chair_icon.png"),
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ import {
|
|||||||
IMPORTED_PAGE_CAPTION_SAVE,
|
IMPORTED_PAGE_CAPTION_SAVE,
|
||||||
} from "@/lib/services/furni-import";
|
} from "@/lib/services/furni-import";
|
||||||
import { nitroAnimationStatesCount } from "@/lib/services/furni-real-interaction";
|
import { nitroAnimationStatesCount } from "@/lib/services/furni-real-interaction";
|
||||||
import { parseNitroBundle } from "@/lib/services/swf/nitro-builder";
|
import {
|
||||||
|
createNitroBundle,
|
||||||
|
parseNitroBundle,
|
||||||
|
} from "@/lib/services/swf/nitro-builder";
|
||||||
import { getRuntimePath, getRuntimeResolve } from "@/lib/utils/runtime-path";
|
import { getRuntimePath, getRuntimeResolve } from "@/lib/utils/runtime-path";
|
||||||
|
|
||||||
export interface UploadResult {
|
export interface UploadResult {
|
||||||
@@ -275,9 +278,19 @@ export async function uploadSingleFurni(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let nitroMeta: Record<string, unknown> | null = null;
|
let nitroMeta: Record<string, unknown> | null = null;
|
||||||
|
// The client resolves the spritesheet through `spritesheet.meta.image`, so
|
||||||
|
// that pointer must name the member we actually ship. A bundle from a
|
||||||
|
// third-party tool can arrive with a stale pointer (say `.png` next to a
|
||||||
|
// WebP member), which stores fine and then renders nothing. Re-writing
|
||||||
|
// through the builder repairs it without touching a single texture byte.
|
||||||
|
// The member keeps its own base name, so `chair*2` colour variants that
|
||||||
|
// share the `chair` library are not renamed.
|
||||||
|
let storedNitro = nitroBuffer;
|
||||||
try {
|
try {
|
||||||
const parsed = parseNitroBundle(nitroBuffer);
|
const parsed = parseNitroBundle(nitroBuffer);
|
||||||
nitroMeta = parsed.json;
|
nitroMeta = parsed.json;
|
||||||
|
const memberBase = parsed.textureFileName.replace(/\.(?:png|webp)$/i, "");
|
||||||
|
storedNitro = createNitroBundle(parsed.json, parsed.texture, memberBase);
|
||||||
} catch {
|
} catch {
|
||||||
return { ok: false, classname, warnings, error: "invalid .nitro bundle" };
|
return { ok: false, classname, warnings, error: "invalid .nitro bundle" };
|
||||||
}
|
}
|
||||||
@@ -299,7 +312,7 @@ export async function uploadSingleFurni(params: {
|
|||||||
error: ".nitro file already exists on disk",
|
error: ".nitro file already exists on disk",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
await fs.writeFile(nitroPath, nitroBuffer);
|
await fs.writeFile(nitroPath, storedNitro);
|
||||||
|
|
||||||
let iconFileName: string | null = null;
|
let iconFileName: string | null = null;
|
||||||
let iconPath: string | null = null;
|
let iconPath: string | null = null;
|
||||||
|
|||||||
Reference in new issue
Block a user