use std::collections::BTreeMap;
use serde::Serialize;
use crate::bump;
use crate::player::{ObjRef, Player};
use crate::value::{Array, Value};
use crate::{Result, invalid};
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct UnityBehaviour {
pub behaviour: ObjRef,
pub script: String,
pub enabled: bool,
#[serde(skip)]
pub data: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct UnityAmbient {
pub mode: i64,
pub sky: [f32; 4],
pub equator: [f32; 4],
pub ground: [f32; 4],
pub intensity: f32,
pub probe: Option<[f32; 27]>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct UnityFog {
pub enabled: bool,
pub mode: i64,
pub color: [f32; 4],
pub density: f32,
pub start: f32,
pub end: f32,
}
pub type Matrix = [[f64; 4]; 4];
pub const IDENTITY: Matrix = [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
];
pub fn mul(a: &Matrix, b: &Matrix) -> Matrix {
let mut m = [[0.0; 4]; 4];
for (row, a_row) in m.iter_mut().zip(a) {
for (j, x) in row.iter_mut().enumerate() {
*x = a_row
.iter()
.zip(b)
.map(|(&av, b_row)| av * b_row.get(j).copied().unwrap_or(0.0))
.sum();
}
}
m
}
pub fn trs(p: [f64; 3], q: [f64; 4], s: [f64; 3]) -> Matrix {
let [x, y, z, w] = q;
let r = [
[
1.0 - 2.0 * (y * y + z * z),
2.0 * (x * y - z * w),
2.0 * (x * z + y * w),
],
[
2.0 * (x * y + z * w),
1.0 - 2.0 * (x * x + z * z),
2.0 * (y * z - x * w),
],
[
2.0 * (x * z - y * w),
2.0 * (y * z + x * w),
1.0 - 2.0 * (x * x + y * y),
],
];
let mut m = IDENTITY;
for ((row, r_row), &position) in m.iter_mut().zip(&r).zip(&p) {
for ((x, &rv), &sv) in row.iter_mut().zip(r_row).zip(&s) {
*x = rv * sv;
}
if let Some(last) = row.get_mut(3) {
*last = position;
}
}
m
}
fn num(v: &Value, k: &str) -> Result<f64> {
v.get(k)
.and_then(Value::as_f32)
.map(f64::from)
.ok_or_else(|| invalid(format!("no float {k}")))
}
fn vec3(v: &Value, k: &str) -> Result<[f64; 3]> {
let x = v.get(k).ok_or_else(|| invalid(format!("no {k}")))?;
Ok([num(x, "x")?, num(x, "y")?, num(x, "z")?])
}
fn vec2(v: &Value, k: &str) -> Result<[f64; 2]> {
let x = v.get(k).ok_or_else(|| invalid(format!("no {k}")))?;
Ok([num(x, "x")?, num(x, "y")?])
}
fn flag_value(v: &Value) -> bool {
match v {
Value::Bool(b) => *b,
x => x.as_i64().is_some_and(|i| i != 0),
}
}
fn flag(v: &Value, k: &str) -> Result<bool> {
match v.get(k) {
Some(Value::Bool(b)) => Ok(*b),
Some(x) => x
.as_i64()
.map(|i| i != 0)
.ok_or_else(|| invalid(format!("{k} is not a flag"))),
None => Err(invalid(format!("no {k}"))),
}
}
fn path_id(v: &Value, k: &str) -> Result<i64> {
v.at(&[k, "m_PathID"])
.and_then(Value::as_i64)
.ok_or_else(|| invalid(format!("no {k}.m_PathID")))
}
fn values<'a>(v: &'a Value, k: &str) -> Result<&'a [Value]> {
match v.get(k) {
Some(Value::Array(Array::Values(items))) => Ok(items),
Some(Value::Array(a)) if a.is_empty() => Ok(&[]),
_ => Err(invalid(format!("no list {k}"))),
}
}
#[derive(Clone, Debug)]
pub struct GameObject {
pub name: String,
pub active: bool,
pub layer: u32,
pub components: Vec<i64>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum OffFlag {
Inactive(i64),
Disabled(i64),
}
#[derive(Clone, Debug)]
struct Node {
game_object: i64,
father: i64,
local: Matrix,
trs: ([f64; 3], [f64; 4], [f64; 3]),
}
#[derive(Clone, Debug, PartialEq)]
pub struct SceneJoint {
pub name: String,
pub father: Option<i64>,
pub translation: [f64; 3],
pub rotation: [f64; 4],
pub scale: [f64; 3],
}
#[derive(Clone, Debug, PartialEq)]
pub struct Animator {
pub animator: ObjRef,
pub game_object: i64,
pub path: String,
pub controller: Option<ObjRef>,
pub avatar: Option<ObjRef>,
pub enabled: bool,
pub apply_root_motion: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Character {
pub animator: Animator,
pub skinned_renderers: usize,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RendererSkin {
pub bones: Vec<Option<i64>>,
pub root_bone: Option<i64>,
pub blend_weights: Vec<f32>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Renderer {
pub renderer: ObjRef,
pub game_object: i64,
pub path: String,
pub layer: u32,
pub mesh: ObjRef,
pub materials: Vec<Option<ObjRef>>,
pub world: Matrix,
pub submeshes: Option<(u32, u32)>,
pub cast_shadows: i64,
pub receive_shadows: Option<bool>,
pub off: Vec<OffFlag>,
pub skin: Option<RendererSkin>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct LodGroup {
pub group: ObjRef,
pub path: String,
pub enabled: bool,
pub off: Vec<OffFlag>,
pub levels: Vec<Vec<i64>>,
}
const MAX_DEPTH: usize = 1024;
pub type Skipped = std::collections::BTreeMap<&'static str, u64>;
#[derive(Debug)]
pub struct Scene {
pub file: String,
pub game_objects: BTreeMap<i64, GameObject>,
nodes: BTreeMap<i64, Node>,
transform_of: BTreeMap<i64, i64>,
world: std::sync::Mutex<BTreeMap<i64, Matrix>>,
}
fn layer_of(g: &Value) -> Result<u32> {
let layer = g.get("m_Layer").and_then(Value::as_i64).unwrap_or(0);
u32::try_from(layer).map_err(|_| invalid(format!("m_Layer {layer} is not a 32-bit unsigned number")))
}
fn culling_mask(c: &Value, path_id: i64) -> Result<u32> {
match c.at(&["m_CullingMask", "m_Bits"]).and_then(Value::as_i64) {
None => Ok(u32::MAX),
Some(bits) => u32::try_from(bits)
.or_else(|_| i32::try_from(bits).map(|signed| u32::from_ne_bytes(signed.to_ne_bytes())))
.map_err(|_| {
invalid(format!(
"object {path_id}: m_CullingMask {bits} does not fit 32 bits"
))
}),
}
}
fn check_depth(nodes: &BTreeMap<i64, Node>) -> Result<()> {
let mut depths: BTreeMap<i64, usize> = BTreeMap::new();
for &start in nodes.keys() {
let mut chain = Vec::new();
let mut t = start;
let base = loop {
if let Some(&d) = depths.get(&t) {
break d;
}
let Some(n) = nodes.get(&t) else { break 0 };
chain.push(t);
if chain.len() > MAX_DEPTH {
return Err(invalid(format!(
"transform {start}: its parents are deeper than {MAX_DEPTH} (or loop)"
)));
}
if n.father == 0 {
break 0;
}
t = n.father;
};
if base.saturating_add(chain.len()) > MAX_DEPTH {
return Err(invalid(format!(
"transform {start}: its parents are deeper than {MAX_DEPTH}"
)));
}
for (k, &t) in chain.iter().rev().enumerate() {
depths.insert(t, base.saturating_add(k).saturating_add(1));
}
}
Ok(())
}
impl Scene {
pub fn read(player: &Player, file: &str) -> Result<Scene> {
let f = player.file(file)?;
let r = |file: &str, pid: i64| ObjRef {
file: file.to_owned(),
path_id: pid,
};
let name_of =
|o: &crate::serialized::ObjectInfo| player.class_name(&r(file, o.path_id)).unwrap_or("");
let mut nodes = BTreeMap::new();
let mut rects: BTreeMap<i64, Value> = BTreeMap::new();
let mut game_objects = BTreeMap::new();
for o in &f.objects {
match name_of(o) {
"Transform" | "RectTransform" => {
let t = player.decode(&r(file, o.path_id))?;
let p = vec3(&t, "m_LocalPosition")?;
let q = t
.get("m_LocalRotation")
.ok_or_else(|| invalid("no m_LocalRotation"))?;
let q = [num(q, "x")?, num(q, "y")?, num(q, "z")?, num(q, "w")?];
let s = vec3(&t, "m_LocalScale")?;
nodes.insert(
o.path_id,
Node {
game_object: path_id(&t, "m_GameObject")?,
father: path_id(&t, "m_Father")?,
local: trs(p, q, s),
trs: (p, q, s),
},
);
if name_of(o) == "RectTransform" {
rects.insert(o.path_id, t);
}
}
"GameObject" => {
let g = player.decode(&r(file, o.path_id))?;
let components = values(&g, "m_Component")?
.iter()
.map(|c| path_id(c, "component"))
.collect::<Result<Vec<_>>>()?;
game_objects.insert(
o.path_id,
GameObject {
name: g.get("m_Name").and_then(Value::as_str).unwrap_or("").to_owned(),
active: flag(&g, "m_IsActive")?,
layer: layer_of(&g)?,
components,
},
);
}
_ => {}
}
}
check_depth(&nodes)?;
let mut sizes: BTreeMap<i64, ([f64; 2], [f64; 2])> = BTreeMap::new();
fn rect_size(
tp: i64,
rects: &BTreeMap<i64, Value>,
nodes: &BTreeMap<i64, Node>,
sizes: &mut BTreeMap<i64, ([f64; 2], [f64; 2])>,
depth: u32,
) -> Result<([f64; 2], [f64; 2])> {
if let Some(s) = sizes.get(&tp) {
return Ok(*s);
}
let Some(t) = rects.get(&tp) else {
return Ok(([0.0; 2], [0.0; 2]));
};
if depth > 4096 {
return Err(invalid("RectTransform parents loop"));
}
let (sd, amin, amax, piv) = (
vec2(t, "m_SizeDelta")?,
vec2(t, "m_AnchorMin")?,
vec2(t, "m_AnchorMax")?,
vec2(t, "m_Pivot")?,
);
let fa = nodes.get(&tp).map_or(0, |n| n.father);
let (ps, _) = if fa != 0 {
rect_size(fa, rects, nodes, sizes, depth.saturating_add(1))?
} else {
([0.0; 2], [0.0; 2])
};
let s = (
[
ps[0] * (amax[0] - amin[0]) + sd[0],
ps[1] * (amax[1] - amin[1]) + sd[1],
],
piv,
);
sizes.insert(tp, s);
Ok(s)
}
let rect_ids: Vec<i64> = rects.keys().copied().collect();
for tp in rect_ids {
let missing = || invalid(format!("RectTransform {tp} is not a transform of the scene"));
let t = rects.get(&tp).ok_or_else(missing)?;
let fa = nodes.get(&tp).ok_or_else(missing)?.father;
let (ps, ppiv) = if fa != 0 {
rect_size(fa, &rects, &nodes, &mut sizes, 0)?
} else {
([0.0; 2], [0.0; 2])
};
let (amin, amax, piv, ap) = (
vec2(t, "m_AnchorMin")?,
vec2(t, "m_AnchorMax")?,
vec2(t, "m_Pivot")?,
vec2(t, "m_AnchoredPosition")?,
);
let n = nodes.get_mut(&tp).ok_or_else(missing)?;
let anchored = ppiv
.iter()
.zip(&ps)
.zip(&amin)
.zip(&amax)
.zip(&piv)
.zip(&ap)
.map(|(((((&pp, &p), &lo), &hi), &pv), &a)| -pp * p + p * (lo + (hi - lo) * pv) + a);
for (row, value) in n.local.iter_mut().zip(anchored) {
if let Some(x) = row.get_mut(3) {
*x = value;
}
}
}
let transform_of = nodes.iter().map(|(&tp, n)| (n.game_object, tp)).collect();
Ok(Scene {
file: file.to_owned(),
game_objects,
nodes,
transform_of,
world: std::sync::Mutex::new(BTreeMap::new()),
})
}
pub fn behaviours_of(&self, player: &Player, go: i64) -> Result<Vec<UnityBehaviour>> {
let mut out = Vec::new();
for &c in self.game_objects.get(&go).map_or(&[][..], |g| &g.components) {
let r = ObjRef {
file: self.file.clone(),
path_id: c,
};
if player.class_name(&r) != Some("MonoBehaviour") {
continue;
}
let (base, data) = player.behaviour(&r)?;
let script = match base.get("m_Script") {
Some(p) => match player.resolve(&r.file, p)? {
Some(s) => {
let s = player.decode(&s)?;
let field = |k: &str| s.get(k).and_then(Value::as_str).unwrap_or("").to_owned();
let (ns, class) = (field("m_Namespace"), field("m_ClassName"));
if ns.is_empty() {
class
} else {
format!("{ns}.{class}")
}
}
None => String::new(),
},
None => String::new(),
};
out.push(UnityBehaviour {
behaviour: r.clone(),
script,
enabled: base
.get("m_Enabled")
.and_then(Value::as_i64)
.is_some_and(|x| x != 0)
|| base.get("m_Enabled").and_then(Value::as_bool) == Some(true),
data,
});
}
Ok(out)
}
pub fn behaviours(&self, player: &Player) -> Result<Vec<(i64, UnityBehaviour)>> {
let mut out = Vec::new();
for &go in self.game_objects.keys() {
out.extend(self.behaviours_of(player, go)?.into_iter().map(|b| (go, b)));
}
Ok(out)
}
fn world_cache(&self) -> std::sync::MutexGuard<'_, BTreeMap<i64, Matrix>> {
self.world
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn root_transform(&self, root: Option<i64>) -> Result<Option<i64>> {
root.map(|go| {
self.transform_of(go)
.ok_or_else(|| invalid(format!("GameObject {go} has no transform")))
})
.transpose()
}
pub fn world_of(&self, tp: i64) -> Result<Matrix> {
let mut chain = Vec::new();
let mut t = tp;
let base = loop {
if let Some(m) = self.world_cache().get(&t) {
break *m;
}
let Some(n) = self.nodes.get(&t) else {
break IDENTITY;
};
chain.push(t);
if chain.len() > self.nodes.len() {
return Err(invalid(format!("transform {tp}: parents loop")));
}
if n.father == 0 {
break IDENTITY;
}
t = n.father;
};
let mut m = base;
let mut cache = self.world_cache();
for &t in chain.iter().rev() {
let node = self
.nodes
.get(&t)
.ok_or_else(|| invalid(format!("transform {t} on the chain of {tp} is not in the scene")))?;
m = mul(&m, &node.local);
cache.insert(t, m);
}
Ok(m)
}
pub fn off_flags(&self, tp: i64, component: Option<(i64, bool)>) -> Vec<OffFlag> {
let mut out = Vec::new();
let mut t = tp;
for _ in 0..=self.nodes.len() {
let Some(n) = self.nodes.get(&t) else { break };
if !self.game_objects.get(&n.game_object).is_some_and(|g| g.active) {
out.push(OffFlag::Inactive(n.game_object));
}
if n.father == 0 {
break;
}
t = n.father;
}
out.reverse();
if let Some((c, false)) = component {
out.push(OffFlag::Disabled(c));
}
out
}
pub fn active(&self, tp: i64) -> bool {
let mut t = tp;
for _ in 0..=self.nodes.len() {
let Some(n) = self.nodes.get(&t) else { return true };
if !self.game_objects.get(&n.game_object).is_some_and(|g| g.active) {
return false;
}
if n.father == 0 {
return true;
}
t = n.father;
}
false
}
pub fn path(&self, tp: i64) -> String {
let mut parts = Vec::new();
let mut t = tp;
while let Some(n) = self.nodes.get(&t) {
parts.push(
self.game_objects
.get(&n.game_object)
.map_or("", |g| g.name.as_str()),
);
if n.father == 0 || parts.len() > self.nodes.len() {
break;
}
t = n.father;
}
parts.reverse();
parts.join("/")
}
pub fn transform_of(&self, game_object: i64) -> Option<i64> {
self.transform_of.get(&game_object).copied()
}
pub fn ancestors(&self, game_object: i64) -> Vec<i64> {
let mut out = vec![game_object];
let Some(mut t) = self.transform_of(game_object) else {
return out;
};
while let Some(n) = self.nodes.get(&t) {
if n.father == 0 || out.len() > self.nodes.len() {
break;
}
t = n.father;
match self.nodes.get(&t) {
Some(p) => out.push(p.game_object),
None => break,
}
}
out
}
pub fn joint(&self, tp: i64) -> Result<Option<SceneJoint>> {
let Some(n) = self.nodes.get(&tp) else {
return Ok(None);
};
Ok(Some(SceneJoint {
name: self
.game_objects
.get(&n.game_object)
.map_or("", |g| g.name.as_str())
.to_owned(),
father: (n.father != 0).then_some(n.father),
translation: [n.local[0][3], n.local[1][3], n.local[2][3]],
rotation: n.trs.1,
scale: n.trs.2,
}))
}
pub fn characters(&self, player: &Player) -> Result<Vec<Character>> {
let file = self.file.as_str();
let r = |pid: i64| ObjRef {
file: file.to_owned(),
path_id: pid,
};
let mut skinned = Vec::new();
for o in &player.file(file)?.objects {
if player.class_name(&r(o.path_id)) == Some("SkinnedMeshRenderer") {
let go = path_id(&player.decode(&r(o.path_id))?, "m_GameObject")?;
skinned.extend(self.transform_of(go));
}
}
let mut out = Vec::new();
for animator in self.animators(player)? {
let Some(tp) = self.transform_of(animator.game_object) else {
continue;
};
let count = skinned
.iter()
.filter(|&&s| self.relative_to(s, tp).is_some())
.count();
if count > 0 {
out.push(Character {
animator,
skinned_renderers: count,
});
}
}
Ok(out)
}
pub fn path_below(&self, tp: i64, root: i64) -> Option<String> {
let mut names = Vec::new();
let mut t = tp;
while t != root {
let n = self.nodes.get(&t)?;
names.push(
self.game_objects
.get(&n.game_object)
.map_or("", |g| g.name.as_str()),
);
if n.father == 0 || names.len() > self.nodes.len() {
return None;
}
t = n.father;
}
names.reverse();
Some(names.join("/"))
}
pub fn animators(&self, player: &Player) -> Result<Vec<Animator>> {
self.animators_under(player, None)
}
pub fn animators_under(&self, player: &Player, root: Option<i64>) -> Result<Vec<Animator>> {
let root_tp = self.root_transform(root)?;
let file = self.file.as_str();
let r = |pid: i64| ObjRef {
file: file.to_owned(),
path_id: pid,
};
let mut out = Vec::new();
for o in &player.file(file)?.objects {
if player.class_name(&r(o.path_id)) != Some("Animator") {
continue;
}
let a = player.decode(&r(o.path_id))?;
let go = path_id(&a, "m_GameObject")?;
let Some(tp) = self.transform_of(go) else {
continue;
};
if root_tp.is_some_and(|r| self.relative_to(tp, r).is_none()) {
continue;
}
let pointer = |k: &str| -> Result<Option<ObjRef>> {
a.get(k)
.map(|p| player.resolve(file, p))
.transpose()
.map(Option::flatten)
};
out.push(Animator {
animator: r(o.path_id),
game_object: go,
path: self.path(tp),
controller: pointer("m_Controller")?,
avatar: pointer("m_Avatar")?,
enabled: a.get("m_Enabled").is_none_or(flag_value),
apply_root_motion: a.get("m_ApplyRootMotion").is_some_and(flag_value),
});
}
Ok(out)
}
pub fn local_of(&self, tp: i64) -> Option<Matrix> {
self.nodes.get(&tp).map(|n| n.local)
}
pub fn relative_to(&self, tp: i64, root: i64) -> Option<Matrix> {
let mut chain = Vec::new();
let mut t = tp;
while t != root {
let n = self.nodes.get(&t)?;
chain.push(t);
if n.father == 0 || chain.len() > self.nodes.len() {
return None;
}
t = n.father;
}
chain
.iter()
.rev()
.try_fold(IDENTITY, |m, t| Some(mul(&m, &self.nodes.get(t)?.local)))
}
pub fn renderers(&self, player: &Player) -> Result<(Vec<Renderer>, Skipped)> {
self.renderers_under(player, None)
}
pub fn lod_groups_under(&self, player: &Player, root: Option<i64>) -> Result<Vec<LodGroup>> {
let root_tp = self.root_transform(root)?;
let file = self.file.as_str();
let f = player.file(file)?;
let r = |pid: i64| ObjRef {
file: file.to_owned(),
path_id: pid,
};
let mut out = Vec::new();
for o in f
.objects
.iter()
.filter(|o| player.class_name(&r(o.path_id)) == Some("LODGroup"))
{
let g = player.decode(&r(o.path_id))?;
let Some(tp) = self.transform_of(path_id(&g, "m_GameObject")?) else {
continue;
};
if root_tp.is_some_and(|root| self.relative_to(tp, root).is_none()) {
continue;
}
let enabled = flag(&g, "m_Enabled")?;
let mut levels = Vec::new();
for lod in values(&g, "m_LODs")? {
let mut level = Vec::new();
for x in values(lod, "renderers")? {
let pid = path_id(x, "renderer")?;
if pid != 0 {
level.push(pid);
}
}
levels.push(level);
}
out.push(LodGroup {
group: r(o.path_id),
path: self.path(tp),
enabled,
off: self.off_flags(tp, Some((o.path_id, enabled))),
levels,
});
}
Ok(out)
}
pub fn renderers_under(&self, player: &Player, root: Option<i64>) -> Result<(Vec<Renderer>, Skipped)> {
let root_tp = self.root_transform(root)?;
let file = self.file.as_str();
let f = player.file(file)?;
let r = |pid: i64| ObjRef {
file: file.to_owned(),
path_id: pid,
};
let class = |pid: i64| player.class_name(&r(pid)).unwrap_or("");
let mut out = Vec::new();
let mut skipped = Skipped::new();
for o in f
.objects
.iter()
.filter(|o| matches!(class(o.path_id), "MeshRenderer" | "SkinnedMeshRenderer"))
{
let skinned = class(o.path_id) == "SkinnedMeshRenderer";
let mr = player.decode(&r(o.path_id))?;
let go = path_id(&mr, "m_GameObject")?;
let Some(tp) = self.transform_of(go) else {
bump(&mut skipped, "no transform");
continue;
};
let relative = match root_tp {
Some(root) => match self.relative_to(tp, root) {
Some(m) => Some(m),
None => continue,
},
None => None,
};
let off = self.off_flags(tp, Some((o.path_id, flag(&mr, "m_Enabled")?)));
let g = self.game_objects.get(&go).ok_or_else(|| {
invalid(format!(
"GameObject {go} of renderer {} is not in the scene",
o.path_id
))
})?;
let filter;
let mesh_ptr = if skinned {
mr.get("m_Mesh")
.ok_or_else(|| invalid("SkinnedMeshRenderer without m_Mesh"))?
} else {
let Some(&mf) = g.components.iter().find(|&&c| class(c) == "MeshFilter") else {
bump(&mut skipped, "no MeshFilter");
continue;
};
filter = player.decode(&r(mf))?;
filter
.get("m_Mesh")
.ok_or_else(|| invalid("MeshFilter without m_Mesh"))?
};
let Some(mesh) = player.resolve(file, mesh_ptr)? else {
bump(&mut skipped, "null mesh");
continue;
};
let materials = values(&mr, "m_Materials")?
.iter()
.map(|m| player.resolve(file, m))
.collect::<Result<Vec<_>>>()?;
let sb = mr.get("m_StaticBatchInfo");
let first = sb
.and_then(|s| s.get("firstSubMesh"))
.and_then(Value::as_i64)
.unwrap_or(0);
let count = sb
.and_then(|s| s.get("subMeshCount"))
.and_then(Value::as_i64)
.unwrap_or(0);
let submeshes = if count > 0 {
let to_u32 = |n: i64, k: &str| {
u32::try_from(n).map_err(|_| {
invalid(format!("renderer {}: {k} {n} is not 32-bit unsigned", o.path_id))
})
};
Some((to_u32(first, "firstSubMesh")?, to_u32(count, "subMeshCount")?))
} else {
None
};
let skin = if skinned {
let bone = |p: &Value| -> Result<Option<i64>> {
let pid = p
.get("m_PathID")
.and_then(Value::as_i64)
.ok_or_else(|| invalid("a bone pointer without m_PathID"))?;
if p.get("m_FileID").and_then(Value::as_i64).unwrap_or(0) != 0 {
return Err(invalid(format!(
"SkinnedMeshRenderer {}: a bone in another file",
o.path_id
)));
}
Ok((pid != 0).then_some(pid))
};
Some(RendererSkin {
bones: values(&mr, "m_Bones")?.iter().map(bone).collect::<Result<_>>()?,
root_bone: match mr.get("m_RootBone") {
Some(p) => bone(p)?,
None => None,
},
blend_weights: match mr.get("m_BlendShapeWeights") {
Some(Value::Array(Array::F32(w))) => w.clone(),
Some(Value::Array(a)) if a.is_empty() => Vec::new(),
None => Vec::new(),
_ => return Err(invalid("SkinnedMeshRenderer: m_BlendShapeWeights not floats")),
},
})
} else {
None
};
out.push(Renderer {
renderer: r(o.path_id),
game_object: go,
path: self.path(tp),
layer: g.layer,
mesh,
materials,
world: if submeshes.is_some() {
IDENTITY
} else {
match relative {
Some(m) => m,
None => self.world_of(tp)?,
}
},
submeshes,
cast_shadows: mr.get("m_CastShadows").and_then(Value::as_i64).unwrap_or(1),
receive_shadows: mr.get("m_ReceiveShadows").and_then(Value::as_i64).map(|v| v != 0),
off,
skin,
});
}
Ok((out, skipped))
}
fn rigidbody(&self, player: &Player, tp: i64) -> Result<Option<bool>> {
let r = |pid: i64| ObjRef {
file: self.file.clone(),
path_id: pid,
};
let mut t = tp;
for _ in 0..=self.nodes.len() {
let Some(n) = self.nodes.get(&t) else {
return Ok(None);
};
if let Some(g) = self.game_objects.get(&n.game_object)
&& let Some(&rb) = g
.components
.iter()
.find(|&&c| player.class_name(&r(c)) == Some("Rigidbody"))
{
return Ok(Some(flag(&player.decode(&r(rb))?, "m_IsKinematic")?));
}
if n.father == 0 {
return Ok(None);
}
t = n.father;
}
Ok(None)
}
pub fn colliders_under(&self, player: &Player, root: Option<i64>) -> Result<Vec<ColliderComponent>> {
let file = self.file.as_str();
let f = player.file(file)?;
let r = |pid: i64| ObjRef {
file: file.to_owned(),
path_id: pid,
};
let root_tp = self.root_transform(root)?;
let v3 = |v: &Value, k: &str| -> Result<[f64; 3]> { vec3(v, k) };
let mut out = Vec::new();
for o in &f.objects {
let class = player.class_name(&r(o.path_id)).unwrap_or("");
if !matches!(
class,
"BoxCollider" | "SphereCollider" | "CapsuleCollider" | "MeshCollider"
) {
continue;
}
let c = player.decode(&r(o.path_id))?;
let go = path_id(&c, "m_GameObject")?;
let Some(tp) = self.transform_of(go) else { continue };
let world = match root_tp {
Some(root) => match self.relative_to(tp, root) {
Some(m) => m,
None => continue,
},
None => self.world_of(tp)?,
};
let fl = |k: &str| -> Result<f64> { num(&c, k) };
let shape = match class {
"BoxCollider" => ColliderShape::Box {
center: v3(&c, "m_Center")?,
size: v3(&c, "m_Size")?,
},
"SphereCollider" => ColliderShape::Sphere {
center: v3(&c, "m_Center")?,
radius: fl("m_Radius")?,
},
"CapsuleCollider" => ColliderShape::Capsule {
center: v3(&c, "m_Center")?,
radius: fl("m_Radius")?,
height: fl("m_Height")?,
direction: match c.get("m_Direction").and_then(Value::as_i64).unwrap_or(1) {
d @ 0..=2 => usize::try_from(d).unwrap_or_default(),
d => {
return Err(invalid(format!(
"CapsuleCollider {}: m_Direction {d} is not 0, 1 or 2",
o.path_id
)));
}
},
},
_ => ColliderShape::Mesh {
mesh: match c.get("m_Mesh") {
Some(p) => player.resolve(file, p)?,
None => None,
},
convex: flag(&c, "m_Convex")?,
},
};
out.push(ColliderComponent {
collider: r(o.path_id),
path: self.path(tp),
layer: self.game_objects.get(&go).map_or(0, |g| g.layer),
world,
shape,
off: self.off_flags(tp, Some((o.path_id, flag(&c, "m_Enabled")?))),
trigger: c.get("m_IsTrigger").is_some_and(flag_value),
physic_material: match c.get("m_Material") {
Some(p) => player.resolve(file, p)?,
None => None,
},
rigidbody: self.rigidbody(player, tp)?,
});
}
Ok(out)
}
pub fn render_settings(&self, player: &Player) -> Result<Option<SceneRenderSettings>> {
let file = self.file.as_str();
let f = player.file(file)?;
let r = |pid: i64| ObjRef {
file: file.to_owned(),
path_id: pid,
};
let mut found = None;
for o in &f.objects {
if player.class_name(&r(o.path_id)) != Some("RenderSettings") {
continue;
}
if found.is_some() {
return Err(invalid(format!("{file}: more than one RenderSettings")));
}
let settings = player.decode(&r(o.path_id))?;
let sun = match settings.get("m_Sun") {
Some(p) => player.resolve(file, p)?,
None => None,
};
let rgba = |k: &str| -> Result<[f32; 4]> {
let c = settings
.get(k)
.ok_or_else(|| invalid(format!("{file}: RenderSettings without {k}")))?;
Ok([num(c, "r")?, num(c, "g")?, num(c, "b")?, num(c, "a")?].map(crate::narrow))
};
let ambient = match settings.get("m_AmbientMode").and_then(Value::as_i64) {
None => None,
Some(mode) => Some(UnityAmbient {
mode,
sky: rgba("m_AmbientSkyColor")?,
equator: rgba("m_AmbientEquatorColor")?,
ground: rgba("m_AmbientGroundColor")?,
intensity: settings
.get("m_AmbientIntensity")
.and_then(Value::as_f32)
.ok_or_else(|| {
invalid(format!("{file}: RenderSettings without m_AmbientIntensity"))
})?,
probe: match settings.get("m_AmbientProbe") {
None => None,
Some(sh) => {
let mut out = [0.0f32; 27];
for (i, c) in out.iter_mut().enumerate() {
*c = sh.get(&format!("sh[{i:2}]")).and_then(Value::as_f32).ok_or_else(
|| {
invalid(format!(
"{file}: RenderSettings' m_AmbientProbe without sh[{i:2}]"
))
},
)?;
}
Some(out)
}
},
}),
};
let fog = match settings.get("m_Fog").and_then(Value::as_bool) {
None => None,
Some(enabled) => Some(UnityFog {
enabled,
mode: settings
.get("m_FogMode")
.and_then(Value::as_i64)
.ok_or_else(|| invalid(format!("{file}: RenderSettings without m_FogMode")))?,
color: rgba("m_FogColor")?,
density: crate::narrow(num(&settings, "m_FogDensity")?),
start: crate::narrow(num(&settings, "m_LinearFogStart")?),
end: crate::narrow(num(&settings, "m_LinearFogEnd")?),
}),
};
found = Some(SceneRenderSettings { sun, ambient, fog });
}
Ok(found)
}
pub fn lights(&self, player: &Player) -> Result<Vec<LightComponent>> {
let file = self.file.as_str();
let f = player.file(file)?;
let r = |pid: i64| ObjRef {
file: file.to_owned(),
path_id: pid,
};
let class = |pid: i64| player.class_name(&r(pid)).unwrap_or("");
let mut out = Vec::new();
for o in f.objects.iter().filter(|o| class(o.path_id) == "Light") {
let l = player.decode(&r(o.path_id))?;
let Some(tp) = self.transform_of(path_id(&l, "m_GameObject")?) else {
continue;
};
let off = self.off_flags(tp, Some((o.path_id, flag(&l, "m_Enabled")?)));
let fl = |k: &str| l.get(k).and_then(Value::as_f32);
let int = |k: &str| l.get(k).and_then(Value::as_i64);
let c = l.get("m_Color").ok_or_else(|| invalid("light: no m_Color"))?;
let area = l.get("m_AreaSize");
out.push(LightComponent {
light: r(o.path_id),
path: self.path(tp),
world: self.world_of(tp)?,
kind: int("m_Type").ok_or_else(|| invalid("light: no m_Type"))?,
color: [num(c, "r")?, num(c, "g")?, num(c, "b")?, num(c, "a")?].map(crate::narrow),
intensity: fl("m_Intensity").unwrap_or(1.0),
range: fl("m_Range").unwrap_or(10.0),
spot_angle: fl("m_SpotAngle").unwrap_or(30.0),
inner_spot_angle: fl("m_InnerSpotAngle").unwrap_or(0.0),
shadows: l
.at(&["m_Shadows", "m_Type"])
.and_then(Value::as_i64)
.unwrap_or(0),
color_temperature: if l
.get("m_UseColorTemperature")
.is_some_and(|v| v.as_bool() == Some(true) || v.as_i64() == Some(1))
{
fl("m_ColorTemperature")
} else {
None
},
bounce_intensity: fl("m_BounceIntensity").unwrap_or(1.0),
light_unit: int("m_LightUnit"),
lux_at_distance: fl("m_LuxAtDistance"),
area_size: [
area.and_then(|a| a.get("x"))
.and_then(Value::as_f32)
.unwrap_or(1.0),
area.and_then(|a| a.get("y"))
.and_then(Value::as_f32)
.unwrap_or(1.0),
],
render_mode: int("m_RenderMode").unwrap_or(0),
baked: l.at(&["m_BakingOutput", "isBaked"]).is_some_and(flag_value)
&& l.at(&["m_BakingOutput", "lightmapBakeMode", "lightmapBakeType"])
.and_then(Value::as_i64)
== Some(2),
culling_mask: culling_mask(&l, o.path_id)?,
cookie: match l.get("m_Cookie") {
Some(p) => player.resolve(file, p)?,
None => None,
},
off,
});
}
Ok(out)
}
pub fn cameras(&self, player: &Player) -> Result<Vec<CameraComponent>> {
let file = self.file.as_str();
let f = player.file(file)?;
let r = |pid: i64| ObjRef {
file: file.to_owned(),
path_id: pid,
};
let class = |pid: i64| player.class_name(&r(pid)).unwrap_or("");
let mut out = Vec::new();
for o in f.objects.iter().filter(|o| class(o.path_id) == "Camera") {
let c = player.decode(&r(o.path_id))?;
let Some(tp) = self.transform_of(path_id(&c, "m_GameObject")?) else {
continue;
};
out.push(CameraComponent {
camera: r(o.path_id),
path: self.path(tp),
world: self.world_of(tp)?,
culling_mask: culling_mask(&c, o.path_id)?,
target_texture: match c.get("m_TargetTexture") {
Some(p) => player.resolve(file, p)?,
None => None,
},
off: self.off_flags(tp, Some((o.path_id, flag(&c, "m_Enabled")?))),
});
}
Ok(out)
}
pub fn terrains(&self, player: &Player) -> Result<(Vec<TerrainComponent>, Skipped)> {
let file = self.file.as_str();
let f = player.file(file)?;
let r = |pid: i64| ObjRef {
file: file.to_owned(),
path_id: pid,
};
let class = |pid: i64| player.class_name(&r(pid)).unwrap_or("");
let mut out = Vec::new();
let mut skipped = Skipped::new();
for o in f.objects.iter().filter(|o| class(o.path_id) == "Terrain") {
let t = player.decode(&r(o.path_id))?;
let go = path_id(&t, "m_GameObject")?;
let Some(tp) = self.transform_of(go) else {
bump(&mut skipped, "no transform");
continue;
};
let off = self.off_flags(tp, Some((o.path_id, flag(&t, "m_Enabled")?)));
let pptr = |v: &Value, k: &str| -> Result<Option<ObjRef>> {
match v.get(k) {
Some(p) => player.resolve(file, p),
None => Ok(None),
}
};
let Some(data) = pptr(&t, "m_TerrainData")? else {
bump(&mut skipped, "no terrain data");
continue;
};
let collider = match self
.game_objects
.get(&go)
.ok_or_else(|| {
invalid(format!(
"GameObject {go} of terrain {} is not in the scene",
o.path_id
))
})?
.components
.iter()
.find(|&&c| class(c) == "TerrainCollider")
{
Some(&c) => {
let tc = player.decode(&r(c))?;
Some(TerrainColliderComponent {
collider: r(c),
enabled: flag(&tc, "m_Enabled")?,
data: pptr(&tc, "m_TerrainData")?,
material: pptr(&tc, "m_Material")?,
tree_colliders: flag(&tc, "m_EnableTreeColliders")?,
})
}
None => None,
};
out.push(TerrainComponent {
terrain: r(o.path_id),
path: self.path(tp),
world: self.world_of(tp)?,
data,
material: pptr(&t, "m_MaterialTemplate")?,
draw_heightmap: flag(&t, "m_DrawHeightmap")?,
draw_trees: flag(&t, "m_DrawTreesAndFoliage")?,
collider,
off,
});
}
Ok((out, skipped))
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum ColliderShape {
Box {
center: [f64; 3],
size: [f64; 3],
},
Sphere {
center: [f64; 3],
radius: f64,
},
Capsule {
center: [f64; 3],
radius: f64,
height: f64,
direction: usize,
},
Mesh {
mesh: Option<ObjRef>,
convex: bool,
},
}
#[derive(Clone, Debug, PartialEq)]
pub struct ColliderComponent {
pub collider: ObjRef,
pub path: String,
pub layer: u32,
pub world: Matrix,
pub shape: ColliderShape,
pub off: Vec<OffFlag>,
pub trigger: bool,
pub physic_material: Option<ObjRef>,
pub rigidbody: Option<bool>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct LightComponent {
pub light: ObjRef,
pub path: String,
pub world: Matrix,
pub kind: i64,
pub color: [f32; 4],
pub intensity: f32,
pub range: f32,
pub spot_angle: f32,
pub inner_spot_angle: f32,
pub shadows: i64,
pub color_temperature: Option<f32>,
pub bounce_intensity: f32,
pub light_unit: Option<i64>,
pub lux_at_distance: Option<f32>,
pub area_size: [f32; 2],
pub render_mode: i64,
pub baked: bool,
pub culling_mask: u32,
pub cookie: Option<ObjRef>,
pub off: Vec<OffFlag>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CameraComponent {
pub camera: ObjRef,
pub path: String,
pub world: Matrix,
pub culling_mask: u32,
pub target_texture: Option<ObjRef>,
pub off: Vec<OffFlag>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TerrainColliderComponent {
pub collider: ObjRef,
pub enabled: bool,
pub data: Option<ObjRef>,
pub material: Option<ObjRef>,
pub tree_colliders: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TerrainComponent {
pub terrain: ObjRef,
pub path: String,
pub world: Matrix,
pub data: ObjRef,
pub material: Option<ObjRef>,
pub draw_heightmap: bool,
pub draw_trees: bool,
pub collider: Option<TerrainColliderComponent>,
pub off: Vec<OffFlag>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct SceneRenderSettings {
pub sun: Option<ObjRef>,
pub ambient: Option<UnityAmbient>,
pub fog: Option<UnityFog>,
}
#[cfg(test)]
mod tests {
use super::*;
fn chain(len: i64) -> BTreeMap<i64, Node> {
(1..=len)
.map(|id| {
let node = Node {
game_object: id,
father: if id == 1 { 0 } else { id - 1 },
local: IDENTITY,
trs: ([0.0; 3], [0.0, 0.0, 0.0, 1.0], [1.0; 3]),
};
(id, node)
})
.collect()
}
#[test]
fn deep_and_looping_parents_are_refused() {
assert!(check_depth(&chain(MAX_DEPTH as i64)).is_ok());
let e = check_depth(&chain(MAX_DEPTH as i64 + 1)).unwrap_err().to_string();
assert!(e.contains("deeper than 1024"), "{e}");
let mut looped = chain(3);
looped.get_mut(&1).unwrap().father = 3;
assert!(check_depth(&looped).is_err());
let mut orphan = chain(3);
orphan.get_mut(&1).unwrap().father = 99;
assert!(check_depth(&orphan).is_ok());
}
fn scene_of(nodes: BTreeMap<i64, Node>) -> Scene {
Scene {
file: "level0".into(),
game_objects: BTreeMap::new(),
transform_of: nodes.iter().map(|(&tp, n)| (n.game_object, tp)).collect(),
nodes,
world: std::sync::Mutex::new(BTreeMap::new()),
}
}
#[test]
fn a_root_without_a_transform_is_refused() {
let scene = scene_of(chain(2));
assert_eq!(scene.root_transform(None).unwrap(), None);
assert_eq!(scene.root_transform(Some(2)).unwrap(), Some(2));
let e = scene.root_transform(Some(77)).unwrap_err().to_string();
assert!(e.contains("GameObject 77 has no transform"), "{e}");
}
#[test]
fn a_poisoned_world_cache_is_still_read() {
let scene = std::sync::Arc::new(scene_of(chain(3)));
let held = scene.clone();
let _ = std::thread::spawn(move || {
let _guard = held.world.lock().unwrap();
panic!("poison the cache");
})
.join();
assert!(scene.world.is_poisoned());
assert_eq!(scene.world_of(3).unwrap(), IDENTITY);
}
fn with(k: &str, v: Value) -> Value {
Value::Struct(vec![(k.into(), v)])
}
#[test]
fn layers_and_masks_are_not_cast() {
assert_eq!(layer_of(&with("m_Layer", Value::Int(5))).unwrap(), 5);
assert_eq!(layer_of(&Value::Struct(vec![])).unwrap(), 0);
for bad in [-1, 1 << 32] {
assert!(layer_of(&with("m_Layer", Value::Int(bad))).is_err());
}
let mask = |bits| culling_mask(&with("m_CullingMask", with("m_Bits", Value::Int(bits))), 9);
assert_eq!(mask(0xffff_fff0).unwrap(), 0xffff_fff0);
assert_eq!(mask(-1).unwrap(), u32::MAX);
assert_eq!(culling_mask(&Value::Struct(vec![]), 9).unwrap(), u32::MAX);
let e = mask(1 << 32).unwrap_err().to_string();
assert!(e.contains("object 9") && e.contains("4294967296"), "{e}");
assert!(mask(-(1 << 40)).is_err());
}
}