forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
6114a2295f
commit
69809f7228
593 files changed
+903643
-40774
No files matched your search
@@ -16,6 +16,8 @@ from pathlib import Path
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from native_material_alpha import native_alpha_action, native_material_identity
|
||||
|
||||
|
||||
COLLISION_CHUNK_TRIANGLE_LIMIT = 90_000
|
||||
@@ -135,12 +137,14 @@ def image_alpha_role(image: bpy.types.Image | None) -> str:
|
||||
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:
|
||||
def apply_material_alpha_role(
|
||||
material: bpy.types.Material,
|
||||
image: bpy.types.Image | None,
|
||||
role: str,
|
||||
threshold: float = 0.5,
|
||||
) -> str:
|
||||
if role == "opaque" or image is None or material.node_tree is None:
|
||||
return role
|
||||
|
||||
image_node = next(
|
||||
node
|
||||
for node in material.node_tree.nodes
|
||||
@@ -167,32 +171,117 @@ def preserve_material_alpha(material: bpy.types.Material) -> str:
|
||||
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
|
||||
alpha_clip.inputs[1].default_value = threshold
|
||||
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
|
||||
material.alpha_threshold = threshold
|
||||
return role
|
||||
|
||||
|
||||
def deduplicate_materials(objects: list[bpy.types.Object]) -> dict[str, int]:
|
||||
def preserve_material_alpha(
|
||||
material: bpy.types.Material,
|
||||
source_report: dict[str, object] | None,
|
||||
) -> tuple[str, dict[str, object] | None, dict[str, object]]:
|
||||
image = material_image(material)
|
||||
role = image_alpha_role(image)
|
||||
image_path = bpy.path.abspath(image.filepath) if image is not None else ""
|
||||
identity = native_material_identity(material.name, image_path, source_report)
|
||||
decision = native_alpha_action(identity, role)
|
||||
action = str(decision.get("action", "unsupported"))
|
||||
if action == "pixel-alpha-heuristic":
|
||||
applied_role = apply_material_alpha_role(material, image, role)
|
||||
decision = {
|
||||
"action": applied_role,
|
||||
"reason": decision.get("reason", "no-verified-native-material-match"),
|
||||
}
|
||||
elif action in {"blend", "mask", "additive"}:
|
||||
applied_role = apply_material_alpha_role(
|
||||
material,
|
||||
image,
|
||||
"cutout" if action == "mask" else "blend",
|
||||
float(decision.get("threshold", 0.5)),
|
||||
)
|
||||
decision = {**decision, "applied": applied_role}
|
||||
if identity is not None:
|
||||
material["nativeSourceModel"] = identity["sourceModel"]
|
||||
material["nativeSourceResourceSha256"] = identity["sourceResourceSha256"]
|
||||
material["nativeMaterialIndex"] = identity["materialIndex"]
|
||||
material["nativeSourceTexture"] = identity["sourceTexture"]
|
||||
material["nativeAlphaPolicy"] = str(decision.get("reason", action))
|
||||
lighting = identity.get("lightingType")
|
||||
lighting_name = lighting.get("name") if isinstance(lighting, dict) else None
|
||||
if isinstance(lighting_name, str) and lighting_name.strip():
|
||||
material["nativeLightingType"] = lighting_name.strip().lower()
|
||||
elif "nativeLightingType" in material:
|
||||
del material["nativeLightingType"]
|
||||
if action == "additive":
|
||||
material["nativeBlendMode"] = "additive"
|
||||
elif "nativeBlendMode" in material:
|
||||
del material["nativeBlendMode"]
|
||||
return role, identity, decision
|
||||
|
||||
|
||||
def deduplicate_materials(
|
||||
objects: list[bpy.types.Object],
|
||||
source_report: dict[str, object] | None,
|
||||
) -> dict[str, object]:
|
||||
canonical: dict[str, bpy.types.Material] = {}
|
||||
replacements = 0
|
||||
native_alpha_rows: dict[int, dict[str, object]] = {}
|
||||
for obj in objects:
|
||||
for slot in obj.material_slots:
|
||||
material = slot.material
|
||||
if material is None:
|
||||
continue
|
||||
key = material_image_key(material)
|
||||
alpha_role, identity, decision = preserve_material_alpha(material, source_report)
|
||||
semantics_key = (
|
||||
repr((identity["sourceModel"], identity["sourceResourceSha256"],
|
||||
identity["materialIndex"], identity["sourceTexture"],
|
||||
sorted(identity["alphaBlendParameters"].items())))
|
||||
if identity is not None
|
||||
else "pixel-alpha-heuristic:" + alpha_role
|
||||
)
|
||||
key = material_image_key(material) + "|" + semantics_key
|
||||
if identity is not None:
|
||||
native_alpha_rows[identity["materialIndex"]] = {
|
||||
"material": identity["materialName"],
|
||||
"materialIndex": identity["materialIndex"],
|
||||
"sourceModel": identity["sourceModel"],
|
||||
"sourceResourceSha256": identity["sourceResourceSha256"],
|
||||
"sourceTexture": identity["sourceTexture"],
|
||||
"stagedTexture": identity["stagedTexture"],
|
||||
"materialType": identity["materialType"],
|
||||
"lightingType": identity["lightingType"],
|
||||
"alphaBlendParameters": identity["alphaBlendParameters"],
|
||||
"sourceImageAlphaRole": alpha_role,
|
||||
"conversionPolicy": decision,
|
||||
}
|
||||
if key in canonical:
|
||||
if slot.material != canonical[key]:
|
||||
slot.material = canonical[key]
|
||||
replacements += 1
|
||||
else:
|
||||
canonical[key] = material
|
||||
material["semanticRole"] = preserve_material_alpha(material)
|
||||
material["semanticRole"] = decision.get("action", alpha_role)
|
||||
material["sourceFormat"] = "RuneWaker DDS"
|
||||
return {"unique": len(canonical), "replacements": replacements}
|
||||
source_model = source_report.get("sourceModel") if isinstance(source_report, dict) else None
|
||||
source_hash = source_report.get("sourceResourceSha256") if isinstance(source_report, dict) else None
|
||||
native_alpha_materials = [native_alpha_rows[index] for index in sorted(native_alpha_rows)]
|
||||
return {
|
||||
"unique": len(canonical),
|
||||
"replacements": replacements,
|
||||
"nativeAlpha": {
|
||||
"sourceModel": source_model,
|
||||
"sourceResourceSha256": source_hash,
|
||||
"matchedMaterialCount": len(native_alpha_materials),
|
||||
"materials": native_alpha_materials,
|
||||
"unsupportedPolicies": [
|
||||
row for row in native_alpha_materials
|
||||
if row["conversionPolicy"].get("action") == "unsupported"
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def export_glb(
|
||||
@@ -302,6 +391,12 @@ def render_topdown_preview(
|
||||
def main() -> None:
|
||||
source_obj, output_dir, slug, unit_scale = arguments()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
source_report_file = source_obj.parent / "export-report.json"
|
||||
source_report = (
|
||||
json.loads(source_report_file.read_text(encoding="utf-8"))
|
||||
if source_report_file.is_file()
|
||||
else None
|
||||
)
|
||||
for stale in output_dir.glob(f"{slug}-collision*.glb"):
|
||||
stale.unlink()
|
||||
|
||||
@@ -330,7 +425,7 @@ def main() -> None:
|
||||
obj["sourceFormat"] = "RuneWaker ROS via OBJ bridge"
|
||||
obj["sourceUnitScaleMeters"] = unit_scale
|
||||
|
||||
material_stats = deduplicate_materials(visual)
|
||||
material_stats = deduplicate_materials(visual, source_report)
|
||||
bounds_min, bounds_max = three_bounds(visual)
|
||||
source_triangles = sum(triangles(obj) for obj in visual)
|
||||
|
||||
@@ -364,12 +459,6 @@ def main() -> None:
|
||||
|
||||
blend_file = output_dir / f"{slug}.source.blend"
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(blend_file))
|
||||
source_report_file = source_obj.parent / "export-report.json"
|
||||
source_report = (
|
||||
json.loads(source_report_file.read_text(encoding="utf-8"))
|
||||
if source_report_file.is_file()
|
||||
else None
|
||||
)
|
||||
report = {
|
||||
"schemaVersion": 1,
|
||||
"dungeonId": slug,
|
||||
@@ -379,6 +468,7 @@ def main() -> None:
|
||||
"obj": source_obj.name,
|
||||
"report": source_report_file.name if source_report is not None else None,
|
||||
"model": source_report.get("sourceModel") if source_report else None,
|
||||
"resourceSha256": source_report.get("sourceResourceSha256") if source_report else None,
|
||||
},
|
||||
"coordinateTransform": {
|
||||
"nativeBridge": "three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Fail-closed matching and policy for native Runewaker material alpha flags."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
|
||||
_MATERIAL_NAME = re.compile(r"^runewaker_material_(\d+)$", re.IGNORECASE)
|
||||
_SHA256 = re.compile(r"^[0-9a-f]{64}$", re.IGNORECASE)
|
||||
|
||||
|
||||
def _resource_path(value: Any) -> str:
|
||||
return str(value or "").replace("/", "\\").strip("\\").casefold()
|
||||
|
||||
|
||||
def _image_basename(value: Any) -> str:
|
||||
normalized = str(value or "").replace("\\", "/")
|
||||
basename = normalized.rsplit("/", 1)[-1].casefold()
|
||||
if basename.endswith(".dds"):
|
||||
return basename[:-4] + ".png"
|
||||
if not os.path.splitext(basename)[1]:
|
||||
return basename + ".png"
|
||||
return basename
|
||||
|
||||
|
||||
def native_material_identity(
|
||||
material_name: str,
|
||||
image_path: str,
|
||||
source_report: dict[str, Any] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Join one imported material only when source, native index and diffuse image agree."""
|
||||
if not isinstance(source_report, dict):
|
||||
return None
|
||||
source_model = str(source_report.get("sourceModel") or "")
|
||||
source_hash = str(source_report.get("sourceResourceSha256") or "")
|
||||
if not source_model or not _SHA256.fullmatch(source_hash):
|
||||
return None
|
||||
match = _MATERIAL_NAME.fullmatch(str(material_name or ""))
|
||||
if not match:
|
||||
return None
|
||||
material_index = int(match.group(1))
|
||||
rows = [row for row in source_report.get("materials", [])
|
||||
if isinstance(row, dict) and row.get("name") == material_name]
|
||||
if len(rows) != 1:
|
||||
return None
|
||||
row = rows[0]
|
||||
if _image_basename(image_path) != _image_basename(row.get("stagedTexture")):
|
||||
return None
|
||||
source_texture = _resource_path(row.get("sourceTexture"))
|
||||
diffuse_textures = {
|
||||
_resource_path(channel.get("texture"))
|
||||
for channel in row.get("sourceTextureChannels", [])
|
||||
if isinstance(channel, dict) and str(channel.get("name", "")).casefold() == "diffuse"
|
||||
}
|
||||
if not source_texture or source_texture not in diffuse_textures:
|
||||
return None
|
||||
parameters = {
|
||||
str(parameter.get("name")): parameter.get("value")
|
||||
for parameter in row.get("alphaBlendParameters", [])
|
||||
if isinstance(parameter, dict) and parameter.get("name")
|
||||
}
|
||||
return {
|
||||
"sourceModel": source_model,
|
||||
"sourceResourceSha256": source_hash.casefold(),
|
||||
"materialName": material_name,
|
||||
"materialIndex": material_index,
|
||||
"sourceTexture": source_texture,
|
||||
"stagedTexture": _image_basename(row.get("stagedTexture")),
|
||||
"materialType": row.get("materialType"),
|
||||
"lightingType": row.get("lightingType"),
|
||||
"alphaBlendParameters": parameters,
|
||||
}
|
||||
|
||||
|
||||
def native_alpha_action(identity: dict[str, Any] | None, image_alpha_role: str) -> dict[str, Any]:
|
||||
"""Translate only proven, supported native modes; report unknown modes explicitly."""
|
||||
if identity is None:
|
||||
return {"action": "pixel-alpha-heuristic", "reason": "no-verified-native-material-match"}
|
||||
parameters = identity.get("alphaBlendParameters", {})
|
||||
required = ("alphaTestMode", "alphaMode", "translucent", "alpha", "alphaModifier")
|
||||
if any(name not in parameters for name in required):
|
||||
return {"action": "unsupported", "reason": "native-alpha-parameters-incomplete"}
|
||||
try:
|
||||
test_mode = int(parameters["alphaTestMode"])
|
||||
alpha_mode = int(parameters["alphaMode"])
|
||||
translucent = bool(parameters["translucent"])
|
||||
alpha = float(parameters["alpha"])
|
||||
alpha_modifier = float(parameters["alphaModifier"])
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return {"action": "unsupported", "reason": "native-alpha-parameters-invalid"}
|
||||
if abs(alpha - 1.0) > 1e-6 or abs(alpha_modifier - 1.0) > 1e-6:
|
||||
return {"action": "unsupported", "reason": "native-global-alpha-factor-not-one"}
|
||||
if test_mode == 0 and alpha_mode == 0 and translucent:
|
||||
return {
|
||||
"action": "blend" if image_alpha_role != "opaque" else "opaque",
|
||||
"reason": "native-normal-translucent-alpha" if image_alpha_role != "opaque"
|
||||
else "native-normal-translucent-texture-alpha-opaque",
|
||||
}
|
||||
if test_mode == 0 and alpha_mode == 0 and not translucent:
|
||||
return {"action": "opaque", "reason": "native-normal-opaque-material"}
|
||||
if test_mode == 0 and alpha_mode == 1 and translucent:
|
||||
return {"action": "additive", "reason": "native-srcalpha-one-additive"}
|
||||
if test_mode == 1 and alpha_mode == 0 and not translucent:
|
||||
try:
|
||||
threshold = max(0, min(255, int(parameters["alphaTestValue"]))) / 255.0
|
||||
except (KeyError, TypeError, ValueError, OverflowError):
|
||||
return {"action": "unsupported", "reason": "native-alpha-test-threshold-missing"}
|
||||
return {
|
||||
"action": "mask" if image_alpha_role != "opaque" else "opaque",
|
||||
"reason": "native-greater-alpha-test",
|
||||
"threshold": threshold,
|
||||
}
|
||||
return {"action": "unsupported", "reason": "native-alpha-mode-combination-unsupported"}
|
||||
@@ -0,0 +1,105 @@
|
||||
import unittest
|
||||
|
||||
from native_material_alpha import native_alpha_action, native_material_identity
|
||||
|
||||
|
||||
class NativeMaterialAlphaTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.report = {
|
||||
"sourceModel": r"model\dungeon\the_origin\act_precipice_02.ros",
|
||||
"sourceResourceSha256": "a" * 64,
|
||||
"materials": [
|
||||
{
|
||||
"name": "runewaker_material_2",
|
||||
"sourceTexture": r"texture\dungeon\the_origin\gate_waterfall",
|
||||
"stagedTexture": "textures/32435b59_gate_waterfall.dds",
|
||||
"materialType": {"value": 0, "name": "standard"},
|
||||
"lightingType": {"value": 0, "name": "standard"},
|
||||
"sourceTextureChannels": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "diffuse",
|
||||
"texture": r"texture\dungeon\the_origin\gate_waterfall",
|
||||
},
|
||||
],
|
||||
"alphaBlendParameters": [
|
||||
{"name": "alphaTestMode", "value": 0},
|
||||
{"name": "alphaTestValue", "value": 192},
|
||||
{"name": "alphaMode", "value": 0},
|
||||
{"name": "translucent", "value": True},
|
||||
{"name": "alpha", "value": 1},
|
||||
{"name": "alphaModifier", "value": 1},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
def test_exact_resource_material_and_diffuse_identity_are_required(self):
|
||||
identity = native_material_identity(
|
||||
"runewaker_material_2",
|
||||
r"C:\staging\textures\32435b59_gate_waterfall.png",
|
||||
self.report,
|
||||
)
|
||||
self.assertIsNotNone(identity)
|
||||
self.assertEqual(identity["materialIndex"], 2)
|
||||
self.assertEqual(identity["sourceResourceSha256"], "a" * 64)
|
||||
self.assertEqual(identity["sourceTexture"], r"texture\dungeon\the_origin\gate_waterfall")
|
||||
self.assertEqual(native_material_identity(
|
||||
"runewaker_material_3",
|
||||
r"C:\staging\textures\32435b59_gate_waterfall.png",
|
||||
self.report,
|
||||
), None, "a name-only match is insufficient")
|
||||
self.assertEqual(native_material_identity(
|
||||
"runewaker_material_2",
|
||||
r"C:\staging\textures\other.png",
|
||||
self.report,
|
||||
), None, "the staged diffuse basename must match")
|
||||
invalid_provenance = {**self.report, "sourceResourceSha256": "not-a-hash"}
|
||||
self.assertEqual(native_material_identity(
|
||||
"runewaker_material_2",
|
||||
r"C:\staging\textures\32435b59_gate_waterfall.png",
|
||||
invalid_provenance,
|
||||
), None, "source resource hash is mandatory")
|
||||
|
||||
def test_native_modes_distinguish_normal_blend_additive_and_opaque(self):
|
||||
identity = native_material_identity(
|
||||
"runewaker_material_2",
|
||||
r"C:\staging\textures\32435b59_gate_waterfall.png",
|
||||
self.report,
|
||||
)
|
||||
self.assertEqual(native_alpha_action(identity, "blend")["action"], "blend")
|
||||
self.assertEqual(native_alpha_action(identity, "opaque")["action"], "opaque")
|
||||
|
||||
additive_parameters = dict(identity["alphaBlendParameters"], alphaMode=1)
|
||||
additive = {**identity, "alphaBlendParameters": additive_parameters}
|
||||
self.assertEqual(
|
||||
native_alpha_action(additive, "blend"),
|
||||
{"action": "additive", "reason": "native-srcalpha-one-additive"},
|
||||
)
|
||||
self.assertEqual(native_alpha_action(additive, "opaque")["action"], "additive")
|
||||
|
||||
additive_not_translucent = {**identity, "alphaBlendParameters": dict(additive_parameters, translucent=False)}
|
||||
self.assertEqual(native_alpha_action(additive_not_translucent, "blend")["action"], "unsupported")
|
||||
additive_alpha_tested = {**identity, "alphaBlendParameters": dict(additive_parameters, alphaTestMode=1)}
|
||||
self.assertEqual(native_alpha_action(additive_alpha_tested, "blend")["action"], "unsupported")
|
||||
additive_scaled = {**identity, "alphaBlendParameters": dict(additive_parameters, alpha=0.5)}
|
||||
self.assertEqual(native_alpha_action(additive_scaled, "blend")["action"], "unsupported")
|
||||
|
||||
opaque_parameters = dict(identity["alphaBlendParameters"], translucent=False)
|
||||
opaque_native = {**identity, "alphaBlendParameters": opaque_parameters}
|
||||
self.assertEqual(native_alpha_action(opaque_native, "blend")["action"], "opaque")
|
||||
|
||||
def test_native_alpha_test_preserves_its_threshold(self):
|
||||
identity = native_material_identity(
|
||||
"runewaker_material_2",
|
||||
r"C:\staging\textures\32435b59_gate_waterfall.png",
|
||||
self.report,
|
||||
)
|
||||
parameters = dict(identity["alphaBlendParameters"], alphaTestMode=1, translucent=False, alphaTestValue=192)
|
||||
decision = native_alpha_action({**identity, "alphaBlendParameters": parameters}, "cutout")
|
||||
self.assertEqual(decision["action"], "mask")
|
||||
self.assertAlmostEqual(decision["threshold"], 192 / 255)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user