Skip to main content

siglus_scene_vm/
emote.rs

1//! Siglus OBJECT Emote host adapter.
2//!
3//! Eluna owns PSB parsing, timeline/physics state and native draw-list recovery.
4//! This module only adapts the original Siglus object contract around it.
5
6#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
7use std::cell::Cell;
8use std::collections::HashMap;
9#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::{Arc, RwLock};
12
13use anyhow::{Context, Result, anyhow, bail};
14use eluna::{EmotePlayerControl, EmoteStaticScene, EmoteTextureSource, TimelinePlayMode};
15
16mod player;
17
18#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
19static NEXT_RENDER_ID: AtomicU64 = AtomicU64::new(1);
20
21#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
22thread_local! {
23    static NEXT_RENDER_ID: Cell<u64> = Cell::new(1);
24}
25
26#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
27fn next_render_id() -> u64 {
28    NEXT_RENDER_ID.fetch_add(1, Ordering::Relaxed)
29}
30
31#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
32fn next_render_id() -> u64 {
33    NEXT_RENDER_ID.with(|next| {
34        let id = next.get();
35        next.set(id.wrapping_add(1).max(1));
36        id
37    })
38}
39
40#[derive(Debug, Clone)]
41pub struct EmoteDecodedTexture {
42    pub width: u32,
43    pub height: u32,
44    pub rgba: Arc<Vec<u8>>,
45}
46
47#[derive(Debug, Clone)]
48struct EmoteHitSurface {
49    version: u64,
50    width: u32,
51    height: u32,
52    alpha: Vec<u8>,
53}
54
55#[derive(Debug, Clone)]
56pub struct EmoteRenderPacket {
57    pub render_id: u64,
58    pub version: u64,
59    pub width: u32,
60    pub height: u32,
61    pub rep_x: f32,
62    pub rep_y: f32,
63    pub alpha_readback: bool,
64    pub scene: Arc<EmoteStaticScene>,
65    pub textures: Arc<HashMap<u32, EmoteDecodedTexture>>,
66    hit_surface: Arc<RwLock<Option<EmoteHitSurface>>>,
67}
68
69impl EmoteRenderPacket {
70    pub fn alpha_hit_test(&self, x: i32, y: i32) -> bool {
71        if x < 0 || y < 0 {
72            return false;
73        }
74        let Ok(surface) = self.hit_surface.read() else {
75            return false;
76        };
77        let Some(surface) = surface.as_ref() else {
78            return false;
79        };
80        if surface.version != self.version
81            || surface.width != self.width
82            || surface.height != self.height
83        {
84            return false;
85        }
86        let x = x as u32;
87        let y = y as u32;
88        if x >= surface.width || y >= surface.height {
89            return false;
90        }
91        surface
92            .alpha
93            .get((y as usize) * (surface.width as usize) + x as usize)
94            .copied()
95            .unwrap_or(0)
96            != 0
97    }
98
99    pub(crate) fn has_current_hit_surface(&self) -> bool {
100        let Ok(surface) = self.hit_surface.read() else {
101            return false;
102        };
103        surface.as_ref().is_some_and(|surface| {
104            surface.version == self.version
105                && surface.width == self.width
106                && surface.height == self.height
107        })
108    }
109
110    pub(crate) fn publish_hit_alpha(&self, alpha: Vec<u8>) {
111        if alpha.len() != self.width as usize * self.height as usize {
112            return;
113        }
114        let Ok(mut surface) = self.hit_surface.write() else {
115            return;
116        };
117        if surface
118            .as_ref()
119            .is_some_and(|current| current.version > self.version)
120        {
121            return;
122        }
123        *surface = Some(EmoteHitSurface {
124            version: self.version,
125            width: self.width,
126            height: self.height,
127            alpha,
128        });
129    }
130}
131
132#[derive(Debug, Clone)]
133pub struct SiglusEmoteRuntime {
134    runtime: player::Player,
135    decoded_textures: Arc<HashMap<u32, EmoteDecodedTexture>>,
136    render_id: u64,
137    version: u64,
138    hit_surface: Arc<RwLock<Option<EmoteHitSurface>>>,
139}
140
141impl SiglusEmoteRuntime {
142    pub fn from_psb_bytes(data: &[u8], key: Option<u32>) -> Result<Self> {
143        Self::from_psb_sources(&[data], key)
144    }
145
146    pub fn from_psb_sources(sources: &[&[u8]], key: Option<u32>) -> Result<Self> {
147        let runtime = player::Player::from_sources(sources, key)
148            .context("Eluna failed to create Emote runtime")?;
149        let spec = runtime.schema().spec.as_deref();
150
151        let mut decoded = HashMap::new();
152        for source in runtime.schema().textures.values() {
153            let bytes = runtime
154                .texture_bytes(source.resource_index)
155                .ok_or_else(|| {
156                    anyhow!("missing Emote texture resource {}", source.resource_index)
157                })?;
158            let tex = decode_texture_source(source, bytes, spec).with_context(|| {
159                format!(
160                    "failed to decode Emote texture {} resource {}",
161                    source.name, source.resource_index
162                )
163            })?;
164            decoded.insert(source.resource_index, tex);
165        }
166
167        Ok(Self {
168            runtime,
169            decoded_textures: Arc::new(decoded),
170            render_id: next_render_id(),
171            version: 1,
172            hit_surface: Arc::new(RwLock::new(None)),
173        })
174    }
175
176    pub fn clone_for_object(&self) -> Self {
177        let mut cloned = self.clone();
178        // C_elm_object::copy clones the player but creates a fresh render target.
179        cloned.render_id = next_render_id();
180        cloned.version = cloned.version.wrapping_add(1).max(1);
181        cloned.hit_surface = Arc::new(RwLock::new(None));
182        cloned
183    }
184
185    pub fn progress_ms(&mut self, ms: i32) -> Result<()> {
186        if ms <= 0 {
187            return Ok(());
188        }
189        // Original `EmoteUpdate`: player->Progress(ms * 60 / 1000). Do not use
190        // Eluna's RAF-capped helper here; Siglus does not clamp this delta.
191        self.runtime
192            .progress_ticks(ms as f32 * 60.0 / 1000.0)
193            .context("Eluna Emote Progress failed")?;
194        self.bump_version();
195        Ok(())
196    }
197
198    pub fn set_face_talk(&mut self, value: f32) -> Result<()> {
199        self.runtime
200            .inner
201            .set_variable_immediate("face_talk", value);
202        self.runtime
203            .rebuild_scene(0.0)
204            .context("Eluna SetVariable(face_talk) failed")?;
205        self.bump_version();
206        Ok(())
207    }
208
209    pub fn play_timeline(&mut self, name: &str, option: i64) -> Result<()> {
210        self.runtime
211            .play_timeline(name, TimelinePlayMode::from_flags(option as u32))
212            .with_context(|| format!("Eluna PlayTimeline({name:?}, {option}) failed"))?;
213        self.bump_version();
214        Ok(())
215    }
216
217    /// Mirrors the no-argument IEmotePlayer::StopTimeline overload used by
218    /// Siglus: Eluna represents it as an empty timeline name.
219    pub fn stop_all_timelines(&mut self) -> Result<()> {
220        self.runtime.inner.stop_timeline("");
221        self.runtime
222            .rebuild_scene(0.0)
223            .context("Eluna StopTimeline() failed")?;
224        self.bump_version();
225        Ok(())
226    }
227
228    pub fn stop_timeline(&mut self, name: &str) -> Result<()> {
229        self.runtime.inner.stop_timeline(name);
230        self.runtime
231            .rebuild_scene(0.0)
232            .with_context(|| format!("Eluna StopTimeline({name:?}) failed"))?;
233        self.bump_version();
234        Ok(())
235    }
236
237    pub fn is_animating(&self) -> bool {
238        self.runtime.inner.is_animating()
239    }
240
241    pub fn pass(&mut self) -> Result<()> {
242        self.runtime.inner.pass();
243        self.runtime
244            .rebuild_scene(0.0)
245            .context("Eluna Pass failed")?;
246        self.bump_version();
247        Ok(())
248    }
249
250    pub fn skip(&mut self) -> Result<()> {
251        self.runtime.inner.skip();
252        self.runtime
253            .rebuild_scene(0.0)
254            .context("Eluna scene rebuild after Skip failed")?;
255        self.bump_version();
256        Ok(())
257    }
258
259    pub fn packet(
260        &self,
261        width: i64,
262        height: i64,
263        rep_x: i64,
264        rep_y: i64,
265        alpha_readback: bool,
266    ) -> Arc<EmoteRenderPacket> {
267        Arc::new(EmoteRenderPacket {
268            render_id: self.render_id,
269            version: self.version,
270            width: width.max(1).min(u32::MAX as i64) as u32,
271            height: height.max(1).min(u32::MAX as i64) as u32,
272            rep_x: rep_x as f32,
273            rep_y: rep_y as f32,
274            alpha_readback,
275            scene: Arc::new(self.runtime.scene().clone()),
276            textures: self.decoded_textures.clone(),
277            hit_surface: self.hit_surface.clone(),
278        })
279    }
280
281    fn bump_version(&mut self) {
282        self.version = self.version.wrapping_add(1).max(1);
283    }
284}
285
286fn decode_texture_source(
287    source: &EmoteTextureSource,
288    bytes: &[u8],
289    spec: Option<&str>,
290) -> Result<EmoteDecodedTexture> {
291    let width = source.width.max(1);
292    let height = source.height.max(1);
293    let bit_count = source.bit_count.unwrap_or(32);
294    if bit_count != 32 {
295        bail!("unsupported Emote texture bit depth {bit_count}; expected 32-bit texture");
296    }
297    let expected = width as usize * height as usize * 4;
298
299    let raw = if source
300        .compress
301        .as_deref()
302        .map(|s| s.eq_ignore_ascii_case("RL"))
303        .unwrap_or(false)
304    {
305        decode_rl(bytes, 4, expected)?
306    } else if bytes.len() == expected {
307        bytes.to_vec()
308    } else {
309        // Some PSB resources contain encoded PNG/BMP/DDS data rather than a
310        // raw 32-bit plane. Reuse the project's mature image decoder.
311        let image = image::load_from_memory(bytes)
312            .context("Emote texture resource is neither raw RGBA/BGRA nor a decodable image")?
313            .to_rgba8();
314        if image.width() != width || image.height() != height {
315            bail!(
316                "decoded Emote texture size {}x{} does not match metadata {}x{}",
317                image.width(),
318                image.height(),
319                width,
320                height
321            );
322        }
323        return Ok(EmoteDecodedTexture {
324            width,
325            height,
326            rgba: Arc::new(image.into_raw()),
327        });
328    };
329
330    let format = source.format.as_deref().unwrap_or("");
331    let spec_upper = spec.unwrap_or("").to_ascii_uppercase();
332    let format_upper = format.to_ascii_uppercase();
333    let big_rgba_spec = matches!(
334        spec_upper.as_str(),
335        "COMMON" | "EMS" | "VITA" | "PSP" | "PS3"
336    );
337
338    let mode = match format_upper.as_str() {
339        "BERGBA8" => Raw32Mode::Rgba,
340        "LERGBA8" | "BGRA8" | "ARGB8" | "A8R8G8B8" | "D3DFMTA8R8G8B8" => Raw32Mode::Bgra,
341        "BGRX8" | "X8R8G8B8" | "D3DFMTX8R8G8B8" => Raw32Mode::Bgrx,
342        "RGBX8" | "RGBX" => Raw32Mode::Rgbx,
343        "RGBA" | "RGBA8" => {
344            if big_rgba_spec {
345                Raw32Mode::Rgba
346            } else {
347                Raw32Mode::Bgra
348            }
349        }
350        _ => {
351            if big_rgba_spec {
352                Raw32Mode::Rgba
353            } else {
354                Raw32Mode::Bgra
355            }
356        }
357    };
358
359    let mut rgba = vec![0u8; expected];
360    for (src, dst) in raw
361        .as_chunks::<4>()
362        .0
363        .iter()
364        .zip(rgba.as_chunks_mut::<4>().0.iter_mut())
365    {
366        match mode {
367            Raw32Mode::Rgba => dst.copy_from_slice(src),
368            Raw32Mode::Bgra => dst.copy_from_slice(&[src[2], src[1], src[0], src[3]]),
369            Raw32Mode::Bgrx => dst.copy_from_slice(&[src[2], src[1], src[0], 255]),
370            Raw32Mode::Rgbx => dst.copy_from_slice(&[src[0], src[1], src[2], 255]),
371        }
372    }
373
374    Ok(EmoteDecodedTexture {
375        width,
376        height,
377        rgba: Arc::new(rgba),
378    })
379}
380
381#[derive(Clone, Copy)]
382enum Raw32Mode {
383    Rgba,
384    Bgra,
385    Bgrx,
386    Rgbx,
387}
388
389fn decode_rl(bytes: &[u8], align: usize, expected: usize) -> Result<Vec<u8>> {
390    let mut out = Vec::with_capacity(expected);
391    let mut pos = 0usize;
392    while pos < bytes.len() && out.len() < expected {
393        let cmd = bytes[pos];
394        pos += 1;
395        if cmd & 0x80 != 0 {
396            let count = ((cmd ^ 0x80) as usize) + 3;
397            if pos + align > bytes.len() {
398                bail!("truncated Emote RL repeat command");
399            }
400            let unit = &bytes[pos..pos + align];
401            pos += align;
402            for _ in 0..count {
403                out.extend_from_slice(unit);
404            }
405        } else {
406            let count = (cmd as usize) + 1;
407            let len = count
408                .checked_mul(align)
409                .ok_or_else(|| anyhow!("Emote RL literal length overflow"))?;
410            if pos + len > bytes.len() {
411                bail!("truncated Emote RL literal command");
412            }
413            out.extend_from_slice(&bytes[pos..pos + len]);
414            pos += len;
415        }
416        if out.len() > expected {
417            bail!("Emote RL output exceeds expected texture size");
418        }
419    }
420    if out.len() != expected {
421        bail!(
422            "Emote RL output size mismatch: got {}, expected {}",
423            out.len(),
424            expected
425        );
426    }
427    Ok(out)
428}
429
430#[cfg(test)]
431mod physics_regression_tests {
432    use super::*;
433
434    #[test]
435    #[ignore = "requires SIGLUS_EMOTE_TEST_PROJECT pointing to 神待少女纱波"]
436    fn after_idle_physics_is_continuous_and_mouth_updates_preserve_it() {
437        let project =
438            std::path::PathBuf::from(std::env::var_os("SIGLUS_EMOTE_TEST_PROJECT").unwrap());
439        let key = siglus_assets::key_toml::load_emote_key_from_project_dir(&project).unwrap();
440        // yu02's body and head both name their source `tex`
441        // (rename_colliding_sources).
442        for (prefix, body, head) in [
443            ("bup", "sn01_03制服+エプロン", "sn01_頭部"),
444            ("ap_bup", "sn01_03制服+エプロン", "sn01_頭部"),
445            ("bup", "yu02_02私服", "yu02_頭部"),
446            ("ap_bup", "yu02_02私服", "yu02_頭部"),
447        ] {
448            let sources = [
449                format!("{prefix}_{body}.psb"),
450                format!("{prefix}_{head}.psb"),
451                "bup_共通tl.psb".to_owned(),
452            ]
453            .map(|name| std::fs::read(project.join("dat").join(name)).unwrap());
454            let mut runtime = SiglusEmoteRuntime::from_psb_sources(
455                &sources.iter().map(Vec::as_slice).collect::<Vec<_>>(),
456                key,
457            )
458            .unwrap();
459            runtime.play_timeline("喜ぶ02", 0).unwrap();
460            runtime.skip().unwrap();
461            runtime.play_timeline("ポーズA", 1).unwrap();
462            runtime.play_timeline("待機ループ00", 2).unwrap();
463            let mut previous = std::collections::BTreeMap::<String, f32>::new();
464            // Cover several breathing cycles, including initial placement.
465            for frame in 0..600 {
466                runtime.progress_ms(16).unwrap();
467                let before_mouth = runtime.runtime.inner.evaluated_variable_values();
468                runtime.set_face_talk((frame % 10) as f32 / 10.0).unwrap();
469                let values = runtime.runtime.inner.evaluated_variable_values();
470                assert!(
471                    values
472                        .keys()
473                        .any(|name| name.starts_with("hair_") || name.starts_with("bust_")),
474                    "{prefix}_{body}: no physics outputs to check"
475                );
476                for (name, value) in values
477                    .iter()
478                    .filter(|(name, _)| name.starts_with("hair_") || name.starts_with("bust_"))
479                {
480                    assert_eq!(
481                        *value, before_mouth[name],
482                        "{prefix} {frame} {name}: mouth changed physics"
483                    );
484                    assert!(value.is_finite());
485                    if frame == 0 {
486                        assert!(
487                            value.abs() < 1.0,
488                            "{prefix} {name}: startup impulse {value}"
489                        );
490                    }
491                    if let Some(prev) = previous.insert(name.clone(), *value) {
492                        assert!(
493                            (value - prev).abs() < 2.0,
494                            "{prefix} {frame} {name}: jumped from {prev} to {value}"
495                        );
496                    }
497                }
498            }
499        }
500    }
501}