Release Healer Man 0.1.24
This commit is contained in:
1 parent
c4346751a9
commit
bac0da9300
118 files changed
+857
-289
No files matched your search
@@ -0,0 +1,262 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import sharp from "sharp";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const recipeDirectory = path.join(projectRoot, "scripts", "runewaker-pipeline", "recipes");
|
||||
const workRoot = path.join(projectRoot, "runewaker-export-work");
|
||||
const shippingRoot = path.join(projectRoot, "src", "assets", "game", "dungeons");
|
||||
const resourceCacheRoot = path.join(workRoot, "resource-cache");
|
||||
const requestedSlugs = new Set(process.argv.slice(2).filter((value) => !value.startsWith("--")));
|
||||
const fix = process.argv.includes("--fix");
|
||||
const alphaClassifications = new Map();
|
||||
let resourceTextureIndex;
|
||||
|
||||
async function exists(file) {
|
||||
try {
|
||||
await stat(file);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseGlb(buffer, file) {
|
||||
if (buffer.length < 20 || buffer.toString("utf8", 0, 4) !== "glTF") {
|
||||
throw new Error(`${file} is not a GLB file.`);
|
||||
}
|
||||
const chunks = [];
|
||||
let json;
|
||||
let offset = 12;
|
||||
while (offset + 8 <= buffer.length) {
|
||||
const chunkLength = buffer.readUInt32LE(offset);
|
||||
const chunkType = buffer.readUInt32LE(offset + 4);
|
||||
const chunkStart = offset + 8;
|
||||
const chunkEnd = chunkStart + chunkLength;
|
||||
if (chunkEnd > buffer.length) break;
|
||||
const data = buffer.subarray(chunkStart, chunkEnd);
|
||||
chunks.push({ type: chunkType, data });
|
||||
if (chunkType === 0x4e4f534a) {
|
||||
json = JSON.parse(data.toString("utf8").replace(/\0+$/u, "").trim());
|
||||
}
|
||||
offset = chunkEnd;
|
||||
}
|
||||
if (!json) throw new Error(`${file} does not contain a JSON chunk.`);
|
||||
return { json, chunks };
|
||||
}
|
||||
|
||||
function serializeGlb(source, json, chunks) {
|
||||
const encodedJson = Buffer.from(JSON.stringify(json));
|
||||
const paddedJson = Buffer.alloc(Math.ceil(encodedJson.length / 4) * 4, 0x20);
|
||||
encodedJson.copy(paddedJson);
|
||||
const updatedChunks = chunks.map((chunk) => (
|
||||
chunk.type === 0x4e4f534a ? { ...chunk, data: paddedJson } : chunk
|
||||
));
|
||||
const totalLength = 12 + updatedChunks.reduce((total, chunk) => total + 8 + chunk.data.length, 0);
|
||||
const output = Buffer.alloc(totalLength);
|
||||
source.copy(output, 0, 0, 12);
|
||||
output.writeUInt32LE(totalLength, 8);
|
||||
let offset = 12;
|
||||
for (const chunk of updatedChunks) {
|
||||
output.writeUInt32LE(chunk.data.length, offset);
|
||||
output.writeUInt32LE(chunk.type, offset + 4);
|
||||
chunk.data.copy(output, offset + 8);
|
||||
offset += 8 + chunk.data.length;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async function classifyAlpha(file) {
|
||||
if (alphaClassifications.has(file)) return alphaClassifications.get(file);
|
||||
const metadata = await sharp(file).metadata();
|
||||
if (!metadata.hasAlpha) {
|
||||
alphaClassifications.set(file, "opaque");
|
||||
return "opaque";
|
||||
}
|
||||
|
||||
const { data, info } = await sharp(file).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
||||
let hasTransparentPixel = false;
|
||||
for (let offset = 3; offset < data.length; offset += info.channels) {
|
||||
const alpha = data[offset];
|
||||
if (alpha === 255) continue;
|
||||
hasTransparentPixel = true;
|
||||
if (alpha > 0) {
|
||||
alphaClassifications.set(file, "blend");
|
||||
return "blend";
|
||||
}
|
||||
}
|
||||
const role = hasTransparentPixel ? "cutout" : "opaque";
|
||||
alphaClassifications.set(file, role);
|
||||
return role;
|
||||
}
|
||||
|
||||
function sourceImageIndex(texture) {
|
||||
return texture?.extensions?.KHR_texture_basisu?.source ?? texture?.source;
|
||||
}
|
||||
|
||||
async function indexResourceTextures() {
|
||||
if (resourceTextureIndex) return resourceTextureIndex;
|
||||
resourceTextureIndex = new Map();
|
||||
if (!(await exists(resourceCacheRoot))) return resourceTextureIndex;
|
||||
const cacheDirectories = (await readdir(resourceCacheRoot, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
for (const cacheDirectory of cacheDirectories) {
|
||||
const textureDirectory = path.join(resourceCacheRoot, cacheDirectory.name, "prepared", "textures");
|
||||
if (!(await exists(textureDirectory))) continue;
|
||||
for (const texture of (await readdir(textureDirectory)).filter((file) => file.endsWith(".png")).sort()) {
|
||||
if (!resourceTextureIndex.has(texture)) {
|
||||
resourceTextureIndex.set(texture, path.join(textureDirectory, texture));
|
||||
}
|
||||
}
|
||||
}
|
||||
return resourceTextureIndex;
|
||||
}
|
||||
|
||||
async function resolveSourceTexture(localDirectory, imageName) {
|
||||
const fileName = `${imageName}.png`;
|
||||
const localFile = path.join(localDirectory, fileName);
|
||||
if (await exists(localFile)) return localFile;
|
||||
return (await indexResourceTextures()).get(fileName);
|
||||
}
|
||||
|
||||
function sha256(buffer) {
|
||||
return createHash("sha256").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
async function refreshShippingMetadata(metadataFile, metadata, visualArtifact, visualBuffer) {
|
||||
visualArtifact.size = visualBuffer.length;
|
||||
visualArtifact.sha256 = sha256(visualBuffer);
|
||||
await writeFile(metadataFile, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
||||
|
||||
const optimizationFile = path.join(path.dirname(metadataFile), "optimization-report.json");
|
||||
if (!(await exists(optimizationFile))) return;
|
||||
const optimization = JSON.parse(await readFile(optimizationFile, "utf8"));
|
||||
if (optimization.optimized) {
|
||||
optimization.optimized.size = visualBuffer.length;
|
||||
if (Object.hasOwn(optimization.optimized, "checksum")) {
|
||||
optimization.optimized.checksum = sha256(visualBuffer);
|
||||
}
|
||||
}
|
||||
await writeFile(optimizationFile, `${JSON.stringify(optimization, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function auditDungeon(recipe) {
|
||||
const slug = recipe.slug;
|
||||
const metadataFile = path.join(shippingRoot, slug, "import-metadata.json");
|
||||
const preparedTextureDirectory = path.join(workRoot, slug, "prepared", "textures");
|
||||
if (!(await exists(metadataFile))) {
|
||||
return { slug, status: "incomplete", problems: [`Missing ${path.relative(projectRoot, metadataFile)}`] };
|
||||
}
|
||||
if (!(await exists(preparedTextureDirectory))) {
|
||||
return {
|
||||
slug,
|
||||
status: "incomplete",
|
||||
problems: [`Missing ${path.relative(projectRoot, preparedTextureDirectory)}`],
|
||||
};
|
||||
}
|
||||
|
||||
const metadata = JSON.parse(await readFile(metadataFile, "utf8"));
|
||||
const visualArtifact = (metadata.assets ?? metadata.artifacts ?? [])
|
||||
.find((artifact) => artifact.role === "visual");
|
||||
const visualFile = path.join(shippingRoot, slug, visualArtifact?.fileName ?? `${slug}-visual.glb`);
|
||||
if (!(await exists(visualFile))) {
|
||||
return { slug, status: "incomplete", problems: [`Missing ${path.relative(projectRoot, visualFile)}`] };
|
||||
}
|
||||
|
||||
const sourceGlb = await readFile(visualFile);
|
||||
const { json: gltf, chunks } = parseGlb(sourceGlb, visualFile);
|
||||
const materialResults = [];
|
||||
const missingTextures = [];
|
||||
for (const [materialIndex, material] of (gltf.materials ?? []).entries()) {
|
||||
const textureIndex = material.pbrMetallicRoughness?.baseColorTexture?.index;
|
||||
const texture = gltf.textures?.[textureIndex];
|
||||
const image = gltf.images?.[sourceImageIndex(texture)];
|
||||
if (!image?.name) continue;
|
||||
|
||||
const sourceFile = await resolveSourceTexture(preparedTextureDirectory, image.name);
|
||||
if (!sourceFile) {
|
||||
missingTextures.push(`${image.name}.png`);
|
||||
continue;
|
||||
}
|
||||
const expectedRole = await classifyAlpha(sourceFile);
|
||||
const expectedAlphaMode = expectedRole === "blend" ? "BLEND" : expectedRole === "cutout" ? "MASK" : "OPAQUE";
|
||||
const actualAlphaMode = material.alphaMode ?? "OPAQUE";
|
||||
const actualRole = material.extras?.semanticRole ?? "missing";
|
||||
materialResults.push({
|
||||
materialIndex,
|
||||
material: material.name ?? `material-${materialIndex}`,
|
||||
texture: image.name,
|
||||
expectedRole,
|
||||
expectedAlphaMode,
|
||||
actualRole,
|
||||
actualAlphaMode,
|
||||
matches: expectedAlphaMode === actualAlphaMode && expectedRole === actualRole,
|
||||
});
|
||||
if (fix && (expectedAlphaMode !== actualAlphaMode || expectedRole !== actualRole)) {
|
||||
if (expectedAlphaMode === "OPAQUE") delete material.alphaMode;
|
||||
else material.alphaMode = expectedAlphaMode;
|
||||
if (expectedAlphaMode === "MASK") material.alphaCutoff = 0.5;
|
||||
else delete material.alphaCutoff;
|
||||
material.extras = { ...(material.extras ?? {}), semanticRole: expectedRole };
|
||||
}
|
||||
}
|
||||
|
||||
const mismatches = materialResults.filter((result) => !result.matches);
|
||||
const alphaMaterials = materialResults.filter((result) => result.expectedRole !== "opaque");
|
||||
const problems = [
|
||||
...mismatches.map((result) =>
|
||||
`${result.texture}: expected ${result.expectedAlphaMode}/${result.expectedRole}, got ${result.actualAlphaMode}/${result.actualRole}`,
|
||||
),
|
||||
...[...new Set(missingTextures)].map((file) => `Missing prepared source texture ${file}`),
|
||||
];
|
||||
if (fix && mismatches.length > 0 && missingTextures.length === 0) {
|
||||
const visualBuffer = serializeGlb(sourceGlb, gltf, chunks);
|
||||
await writeFile(visualFile, visualBuffer);
|
||||
if (visualArtifact) {
|
||||
await refreshShippingMetadata(metadataFile, metadata, visualArtifact, visualBuffer);
|
||||
}
|
||||
}
|
||||
return {
|
||||
slug,
|
||||
status: problems.length === 0 ? "green" : fix && missingTextures.length === 0 ? "repaired" : "mismatch",
|
||||
materials: materialResults.length,
|
||||
alphaMaterials: alphaMaterials.map(({ material, texture, expectedAlphaMode }) => ({
|
||||
material,
|
||||
texture,
|
||||
alphaMode: expectedAlphaMode,
|
||||
})),
|
||||
mismatches,
|
||||
problems,
|
||||
};
|
||||
}
|
||||
|
||||
const recipeFiles = (await readdir(recipeDirectory))
|
||||
.filter((file) => file.endsWith(".json") && !file.endsWith("-population.json"))
|
||||
.sort();
|
||||
const recipes = [];
|
||||
for (const recipeFile of recipeFiles) {
|
||||
const recipe = JSON.parse(await readFile(path.join(recipeDirectory, recipeFile), "utf8"));
|
||||
if (!recipe.slug || recipe.slug === "runewaker-player-avatars") continue;
|
||||
if (requestedSlugs.size > 0 && !requestedSlugs.has(recipe.slug)) continue;
|
||||
recipes.push(recipe);
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const recipe of recipes) {
|
||||
results.push(await auditDungeon(recipe));
|
||||
}
|
||||
|
||||
const failures = results.filter((result) => !["green", "repaired"].includes(result.status));
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
dungeonCount: results.length,
|
||||
status: failures.length === 0 ? fix ? "repaired" : "green" : "mismatch",
|
||||
failureCount: failures.length,
|
||||
alphaDungeonCount: results.filter((result) => result.alphaMaterials?.length > 0).length,
|
||||
results,
|
||||
};
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (failures.length > 0) process.exitCode = 1;
|
||||
Reference in new issue
Block a user