From bd48bb547864f1be7337b97008ed86e5e2c89e7f Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Sun, 4 Oct 2026 01:16:09 +0100
Subject: [PATCH] MONKEY (gfx): repair alpha-blended eye glow materials
Use additive RGB, black fog and no depth writes for named unlit mode-2 eye-glow batches. This preserves the background behind opaque black glow-texture backing. Verified two focused tests including installed Undead models, Windows play build and the app test gate.
Co-Authored-By: GPT-6 Astra (Codex)
---
.../benilla-formats/src/models/m2_batches.rs | 66 ++++++++++++++++++-
1 file changed, 64 insertions(+), 2 deletions(-)
diff --git a/crates/benilla-formats/src/models/m2_batches.rs b/crates/benilla-formats/src/models/m2_batches.rs
index 15bbe477..c407bbe2 100644
--- a/crates/benilla-formats/src/models/m2_batches.rs
+++ b/crates/benilla-formats/src/models/m2_batches.rs
@@ -27,6 +27,21 @@ fn parent_dir(path: &str) -> &str {
}
}
+// MONKEY (gfx): converted character eye-glow cards can carry mode 2 despite using black-backed
+// glow art. Keep ordinary alpha materials intact; only named, unlit eye glows need additive RGB.
+fn monkey_alpha_eye_glow(texture: Option<&str>, flags: u16, mode: u16) -> bool {
+ mode == 2
+ && flags & 1 != 0
+ && texture.is_some_and(|path| {
+ let name = path
+ .rsplit(['\\', '/'])
+ .next()
+ .unwrap_or(path)
+ .to_ascii_lowercase();
+ name.contains("eyeglow") && name.ends_with(".blp")
+ })
+}
+
/// A batch's `.blp`: `Monster1/2/3` come from the creature's skin variations
/// (`\.blp`), else the embedded filename.
fn resolve_texture(
@@ -380,6 +395,9 @@ pub fn parse_m2_render_submeshes(
.map(|t| resolve_texture(t, dir, skins))
.unwrap_or((None, None, None, false));
let material = model.materials.get(batch.material_index as usize);
+ let eye_glow = material.is_some_and(|m| {
+ monkey_alpha_eye_glow(texture.as_deref(), m.flags.bits(), m.blend_mode.bits())
+ });
let blend = match material.map(|m| m.blend_mode.bits()) {
Some(0) | None => ModelBlend::Opaque,
Some(1) => ModelBlend::AlphaTest,
@@ -390,7 +408,8 @@ pub fn parse_m2_render_submeshes(
Some(_) => ModelBlend::Blend,
};
// Blend modes 3 (NoAlphaAdd) and 4 (Add) add the batch's colour (glow cards, coronae).
- let additive = matches!(material.map(|m| m.blend_mode.bits()), Some(3) | Some(4));
+ let additive =
+ eye_glow || matches!(material.map(|m| m.blend_mode.bits()), Some(3) | Some(4));
// Render flag 0x04: two-sided; without it the reference culls back faces.
let two_sided = material.is_some_and(|m| m.flags.bits() & 0x04 != 0);
// `texCoordSet (+0x12) → texture_unit_lookup (0x9c)` above 2 is a generated environment
@@ -400,13 +419,14 @@ pub fn parse_m2_render_submeshes(
let emissive = material.is_some_and(|m| m.flags.bits() & 0x01 != 0);
// Render flags 0x10 (no depth write) and 0x08 (no depth test): the reference keys depth
// state on these bits, not on the blend mode (`0x70c190`).
- let no_depth_write = material.is_some_and(|m| m.flags.bits() & 0x10 != 0);
+ let no_depth_write = eye_glow || material.is_some_and(|m| m.flags.bits() & 0x10 != 0);
let no_depth_test = material.is_some_and(|m| m.flags.bits() & 0x08 != 0);
// Fog colour (setter `0x70baf0`): off under render flag 0x02 (`0x70bb24`), else by blend
// mode from `DAT_811fc4 = {1,1,1,2,2,3,4}` (`0x70bddf`, jump table `0x70c17c`): modes
// 0/1/2 the scene colour, 3/4 black, 5 white, 6 grey.
let fog_policy = match material {
Some(m) if m.flags.bits() & 0x02 != 0 => FogPolicy::Off,
+ Some(_) if eye_glow => FogPolicy::Black,
Some(m) => match m.blend_mode.bits() {
3 | 4 => FogPolicy::Black,
5 => FogPolicy::White,
@@ -789,6 +809,48 @@ pub fn m2_sequence_visible_textures(bytes: &[u8], anim_id: u16) -> Option = subs
+ .iter()
+ .filter(|s| {
+ s.texture
+ .as_deref()
+ .is_some_and(|t| t.to_ascii_lowercase().contains("eyeglow"))
+ })
+ .collect();
+ assert!(!eyes.is_empty(), "{path}: eye batches exist");
+ for eye in eyes {
+ assert!(eye.additive && eye.emissive && eye.no_depth_write, "{path}");
+ assert!(
+ matches!(eye.fog_policy, FogPolicy::Black | FogPolicy::Off),
+ "{path}"
+ );
+ }
+ }
+ }
+
/// A bucket never sorts under the exact rung, which its sibling quad cloud keeps.
#[test]
fn owner_rung_buckets_cover_every_reachable_rung() {