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;
|
||||
@@ -6,6 +6,7 @@ Expected invocation:
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from array import array
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
@@ -111,6 +112,69 @@ def material_image_key(material: bpy.types.Material) -> str:
|
||||
return f"material:{material.name.casefold()}"
|
||||
|
||||
|
||||
def material_image(material: bpy.types.Material) -> bpy.types.Image | None:
|
||||
if not material.use_nodes or material.node_tree is None:
|
||||
return None
|
||||
for node in material.node_tree.nodes:
|
||||
if node.type == "TEX_IMAGE" and node.image is not None:
|
||||
return node.image
|
||||
return None
|
||||
|
||||
|
||||
def image_alpha_role(image: bpy.types.Image | None) -> str:
|
||||
"""Classify source alpha without turning fully opaque RGBA textures translucent."""
|
||||
if image is None or image.channels < 4:
|
||||
return "opaque"
|
||||
pixels = array("f", [0.0]) * len(image.pixels)
|
||||
image.pixels.foreach_get(pixels)
|
||||
alpha_values = pixels[3::image.channels]
|
||||
if min(alpha_values, default=1.0) >= 0.999:
|
||||
return "opaque"
|
||||
if any(0.001 < alpha < 0.999 for alpha in alpha_values):
|
||||
return "blend"
|
||||
return "cutout"
|
||||
|
||||
|
||||
def preserve_material_alpha(material: bpy.types.Material) -> str:
|
||||
image = material_image(material)
|
||||
role = image_alpha_role(image)
|
||||
if role == "opaque" or material.node_tree is None:
|
||||
return role
|
||||
|
||||
image_node = next(
|
||||
node
|
||||
for node in material.node_tree.nodes
|
||||
if node.type == "TEX_IMAGE" and node.image == image
|
||||
)
|
||||
principled = next(
|
||||
(node for node in material.node_tree.nodes if node.type == "BSDF_PRINCIPLED"),
|
||||
None,
|
||||
)
|
||||
if principled is None:
|
||||
return role
|
||||
alpha_output = image_node.outputs.get("Alpha")
|
||||
alpha_input = principled.inputs.get("Alpha")
|
||||
if alpha_output is None or alpha_input is None:
|
||||
return role
|
||||
|
||||
if role == "blend":
|
||||
if not alpha_input.is_linked:
|
||||
material.node_tree.links.new(alpha_output, alpha_input)
|
||||
material.blend_method = "BLEND"
|
||||
if hasattr(material, "surface_render_method"):
|
||||
material.surface_render_method = "BLENDED"
|
||||
else:
|
||||
alpha_clip = material.node_tree.nodes.new("ShaderNodeMath")
|
||||
alpha_clip.name = "RuneWaker Alpha Clip"
|
||||
alpha_clip.operation = "GREATER_THAN"
|
||||
alpha_clip.inputs[1].default_value = 0.5
|
||||
material.node_tree.links.new(alpha_output, alpha_clip.inputs[0])
|
||||
material.node_tree.links.new(alpha_clip.outputs[0], alpha_input)
|
||||
material.blend_method = "CLIP"
|
||||
material.alpha_threshold = 0.5
|
||||
return role
|
||||
|
||||
|
||||
def deduplicate_materials(objects: list[bpy.types.Object]) -> dict[str, int]:
|
||||
canonical: dict[str, bpy.types.Material] = {}
|
||||
replacements = 0
|
||||
@@ -126,7 +190,7 @@ def deduplicate_materials(objects: list[bpy.types.Object]) -> dict[str, int]:
|
||||
replacements += 1
|
||||
else:
|
||||
canonical[key] = material
|
||||
material["semanticRole"] = "opaque"
|
||||
material["semanticRole"] = preserve_material_alpha(material)
|
||||
material["sourceFormat"] = "RuneWaker DDS"
|
||||
return {"unique": len(canonical), "replacements": replacements}
|
||||
|
||||
|
||||
@@ -241,6 +241,7 @@ async function cachedResourceAsset(resourceRecord) {
|
||||
throw new Error("source ROS is missing: " + projectPath(sourceFile));
|
||||
}
|
||||
const sourceSha256 = await sha256(sourceFile);
|
||||
const converterSha256 = await sha256(blenderScript);
|
||||
const cacheRoot = path.join(resourceCacheDirectory, resourceRecord.cacheKey);
|
||||
const raw = path.join(cacheRoot, "raw");
|
||||
const prepared = path.join(cacheRoot, "prepared");
|
||||
@@ -253,6 +254,7 @@ async function cachedResourceAsset(resourceRecord) {
|
||||
.map((file) => path.join(cacheRoot, file));
|
||||
if (cached.source?.resource === resourceRecord.normalizedResource
|
||||
&& cached.source?.sha256 === sourceSha256
|
||||
&& cached.toolchain?.converterSha256 === converterSha256
|
||||
&& cached.coordinateSystem?.metersPerSourceUnit === recipe.coordinateSystem.metersPerSourceUnit
|
||||
&& (await Promise.all(outputs.map(exists))).every(Boolean)) {
|
||||
return {
|
||||
@@ -300,6 +302,9 @@ async function cachedResourceAsset(resourceRecord) {
|
||||
nativeBridge: recipe.coordinateSystem.nativeBridge,
|
||||
metersPerSourceUnit: recipe.coordinateSystem.metersPerSourceUnit,
|
||||
},
|
||||
toolchain: {
|
||||
converterSha256,
|
||||
},
|
||||
visual: {
|
||||
file: path.relative(
|
||||
cacheRoot,
|
||||
|
||||
Reference in new issue
Block a user