Release Healer Man 0.1.24

This commit is contained in:
phenom committed 2026-09-09 16:43:37 -04:00
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,