1104 lines
48 KiB
Rust
1104 lines
48 KiB
Rust
//! Schema-validated generators for the pinned avatar and foliage XML catalogs.
|
|
|
|
use super::{InputSpec, generated_rust, normalize_text, verify_inputs};
|
|
use roxmltree::{Document, Node};
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::fmt::Write as _;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
struct AlphaDefinition {
|
|
domain: f32,
|
|
tga_file: String,
|
|
skip_if_zero: bool,
|
|
multiply_blend: bool,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum ColorOperation {
|
|
Add,
|
|
Blend,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
struct ColorDefinition {
|
|
operation: ColorOperation,
|
|
colors: Vec<[u8; 4]>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
struct DrivenDefinition {
|
|
param_id: i32,
|
|
min1: f32,
|
|
max1: f32,
|
|
max2: f32,
|
|
min2: f32,
|
|
has_range: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
struct SkeletalDefinition {
|
|
bone_name: String,
|
|
scale: [f32; 3],
|
|
position: [f32; 3],
|
|
has_position: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
struct VolumeMorphDefinition {
|
|
bone_name: String,
|
|
scale: [f32; 3],
|
|
has_scale: bool,
|
|
position: [f32; 3],
|
|
has_position: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
struct VisualDefinition {
|
|
param_id: i32,
|
|
name: String,
|
|
group: i32,
|
|
wearable: Option<String>,
|
|
label: String,
|
|
label_min: String,
|
|
label_max: String,
|
|
default_value: f32,
|
|
min_value: f32,
|
|
max_value: f32,
|
|
is_bump_attribute: bool,
|
|
alpha: Option<AlphaDefinition>,
|
|
color: Option<ColorDefinition>,
|
|
driven: Vec<DrivenDefinition>,
|
|
skeletal: Vec<SkeletalDefinition>,
|
|
volume_morphs: Vec<VolumeMorphDefinition>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
struct VisualCatalog {
|
|
params: BTreeMap<i32, VisualDefinition>,
|
|
transmitted_ids: Vec<i32>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
struct TreeDefinition {
|
|
name: String,
|
|
species_id: u8,
|
|
texture_id: String,
|
|
droop: f32,
|
|
twist: f32,
|
|
branches: f32,
|
|
depth: i32,
|
|
scale_step: f32,
|
|
trunk_depth: f32,
|
|
branch_length: f32,
|
|
trunk_length: f32,
|
|
leaf_scale: f32,
|
|
billboard_scale: f32,
|
|
billboard_ratio: f32,
|
|
trunk_aspect: f32,
|
|
branch_aspect: f32,
|
|
leaf_rotate: f32,
|
|
noise_mag: f32,
|
|
noise_scale: f32,
|
|
taper: f32,
|
|
repeat_z: i32,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
struct GrassDefinition {
|
|
name: String,
|
|
species_id: u8,
|
|
texture_id: String,
|
|
blade_size_x: f32,
|
|
blade_size_y: f32,
|
|
}
|
|
|
|
fn location(path: &str, node: Node<'_, '_>, code: &str, message: &str) -> String {
|
|
let position = node.document().text_pos_at(node.range().start);
|
|
format!(
|
|
"{path}:{}:{}: error[{code}]: {message}",
|
|
position.row, position.col
|
|
)
|
|
}
|
|
|
|
fn required_attribute(
|
|
path: &str,
|
|
node: Node<'_, '_>,
|
|
name: &str,
|
|
code: &str,
|
|
) -> Result<String, String> {
|
|
node.attribute(name).map(str::to_owned).ok_or_else(|| {
|
|
location(
|
|
path,
|
|
node,
|
|
code,
|
|
&format!("<{}> requires attribute {name:?}", node.tag_name().name()),
|
|
)
|
|
})
|
|
}
|
|
|
|
fn parse_i32(path: &str, node: Node<'_, '_>, name: &str, code: &str) -> Result<i32, String> {
|
|
let value = required_attribute(path, node, name, code)?;
|
|
value.parse::<i32>().map_err(|_| {
|
|
location(
|
|
path,
|
|
node,
|
|
code,
|
|
&format!("attribute {name:?} must be a signed 32-bit integer, found {value:?}"),
|
|
)
|
|
})
|
|
}
|
|
|
|
fn parse_u8(path: &str, node: Node<'_, '_>, name: &str, code: &str) -> Result<u8, String> {
|
|
let value = required_attribute(path, node, name, code)?;
|
|
value.parse::<u8>().map_err(|_| {
|
|
location(
|
|
path,
|
|
node,
|
|
code,
|
|
&format!("attribute {name:?} must be an unsigned byte, found {value:?}"),
|
|
)
|
|
})
|
|
}
|
|
|
|
fn parse_f32(path: &str, node: Node<'_, '_>, name: &str, code: &str) -> Result<f32, String> {
|
|
let value = required_attribute(path, node, name, code)?;
|
|
let parsed = value.parse::<f32>().map_err(|_| {
|
|
location(
|
|
path,
|
|
node,
|
|
code,
|
|
&format!("attribute {name:?} must be a finite float, found {value:?}"),
|
|
)
|
|
})?;
|
|
if parsed.is_finite() {
|
|
Ok(parsed)
|
|
} else {
|
|
Err(location(
|
|
path,
|
|
node,
|
|
code,
|
|
&format!("attribute {name:?} must be a finite float, found {value:?}"),
|
|
))
|
|
}
|
|
}
|
|
|
|
fn optional_f32(
|
|
path: &str,
|
|
node: Node<'_, '_>,
|
|
name: &str,
|
|
default: f32,
|
|
code: &str,
|
|
) -> Result<f32, String> {
|
|
if node.attribute(name).is_some() {
|
|
parse_f32(path, node, name, code)
|
|
} else {
|
|
Ok(default)
|
|
}
|
|
}
|
|
|
|
fn true_attribute(node: Node<'_, '_>, name: &str) -> bool {
|
|
node.attribute(name)
|
|
.is_some_and(|value| value.eq_ignore_ascii_case("true"))
|
|
}
|
|
|
|
fn parse_vector(
|
|
path: &str,
|
|
node: Node<'_, '_>,
|
|
name: &str,
|
|
code: &str,
|
|
) -> Result<[f32; 3], String> {
|
|
let value = required_attribute(path, node, name, code)?;
|
|
let components = value
|
|
.split_ascii_whitespace()
|
|
.map(str::parse::<f32>)
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|_| {
|
|
location(
|
|
path,
|
|
node,
|
|
code,
|
|
&format!("attribute {name:?} must contain three finite floats, found {value:?}"),
|
|
)
|
|
})?;
|
|
if components.len() != 3 || components.iter().any(|component| !component.is_finite()) {
|
|
return Err(location(
|
|
path,
|
|
node,
|
|
code,
|
|
&format!("attribute {name:?} must contain three finite floats, found {value:?}"),
|
|
));
|
|
}
|
|
Ok([components[0], components[1], components[2]])
|
|
}
|
|
|
|
fn parse_color(path: &str, node: Node<'_, '_>) -> Result<[u8; 4], String> {
|
|
let value = required_attribute(path, node, "color", "VP021")?;
|
|
let components = value
|
|
.split(|character: char| character == ',' || character.is_ascii_whitespace())
|
|
.filter(|component| !component.is_empty())
|
|
.map(str::parse::<u8>)
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|_| {
|
|
location(
|
|
path,
|
|
node,
|
|
"VP021",
|
|
&format!("color must contain four bytes, found {value:?}"),
|
|
)
|
|
})?;
|
|
if components.len() != 4 {
|
|
return Err(location(
|
|
path,
|
|
node,
|
|
"VP021",
|
|
&format!("color must contain four bytes, found {value:?}"),
|
|
));
|
|
}
|
|
Ok([components[0], components[1], components[2], components[3]])
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)] // The schema mirrors all golden visual-param child variants.
|
|
fn parse_visual_catalog(path: &str, text: &str) -> Result<VisualCatalog, String> {
|
|
let document = Document::parse(text).map_err(|error| format!("{path}: {error}"))?;
|
|
let root = document.root_element();
|
|
if !root.has_tag_name("linden_avatar") {
|
|
return Err(location(
|
|
path,
|
|
root,
|
|
"VP001",
|
|
"root element must be <linden_avatar>",
|
|
));
|
|
}
|
|
|
|
let mut params = BTreeMap::new();
|
|
let mut transmitted_ids = Vec::new();
|
|
for node in root
|
|
.descendants()
|
|
.filter(|node| node.is_element() && node.has_tag_name("param"))
|
|
{
|
|
let Some(group_text) = node.attribute("group") else {
|
|
continue;
|
|
};
|
|
if node.attribute("shared") == Some("1") {
|
|
continue;
|
|
}
|
|
let param_id = parse_i32(path, node, "id", "VP010")?;
|
|
let name = required_attribute(path, node, "name", "VP011")?;
|
|
let group = group_text.parse::<i32>().map_err(|_| {
|
|
location(
|
|
path,
|
|
node,
|
|
"VP012",
|
|
&format!(
|
|
"attribute \"group\" must be a signed 32-bit integer, found {group_text:?}"
|
|
),
|
|
)
|
|
})?;
|
|
if params.contains_key(¶m_id) {
|
|
return Err(location(
|
|
path,
|
|
node,
|
|
"VP013",
|
|
&format!("duplicate visual parameter ID {param_id}"),
|
|
));
|
|
}
|
|
let min_value = parse_f32(path, node, "value_min", "VP014")?;
|
|
let max_value = parse_f32(path, node, "value_max", "VP015")?;
|
|
if min_value > max_value {
|
|
return Err(location(
|
|
path,
|
|
node,
|
|
"VP016",
|
|
&format!("visual parameter {param_id} has value_min greater than value_max"),
|
|
));
|
|
}
|
|
let default_value = optional_f32(path, node, "value_default", 0.0, "VP017")?;
|
|
|
|
let parent_layer = node.parent().filter(|parent| parent.has_tag_name("layer"));
|
|
let is_bump_attribute =
|
|
parent_layer.and_then(|parent| parent.attribute("render_pass")) == Some("bump");
|
|
let skip_color = parent_layer.is_some_and(|parent| {
|
|
parent.children().any(|child| {
|
|
child.has_tag_name("texture")
|
|
&& child
|
|
.attribute("local_texture_alpha_only")
|
|
.is_some_and(|value| value.eq_ignore_ascii_case("true"))
|
|
})
|
|
});
|
|
|
|
let mut alpha = None;
|
|
let mut color = None;
|
|
let mut driven = Vec::new();
|
|
let mut skeletal = Vec::new();
|
|
let mut volume_morphs = Vec::new();
|
|
for child in node.children().filter(Node::is_element) {
|
|
if child.has_tag_name("param_alpha") {
|
|
if alpha.is_some() {
|
|
return Err(location(path, child, "VP018", "duplicate <param_alpha>"));
|
|
}
|
|
alpha = Some(AlphaDefinition {
|
|
domain: optional_f32(path, child, "domain", 0.0, "VP019")?,
|
|
tga_file: child.attribute("tga_file").unwrap_or_default().to_owned(),
|
|
skip_if_zero: true_attribute(child, "skip_if_zero"),
|
|
multiply_blend: true_attribute(child, "multiply_blend"),
|
|
});
|
|
} else if child.has_tag_name("param_color") {
|
|
if color.is_some() {
|
|
return Err(location(path, child, "VP020", "duplicate <param_color>"));
|
|
}
|
|
let operation = match child.attribute("operation") {
|
|
None => ColorOperation::Add,
|
|
Some("blend" | "multiply") => ColorOperation::Blend,
|
|
Some(value) => {
|
|
return Err(location(
|
|
path,
|
|
child,
|
|
"VP022",
|
|
&format!("unknown color operation {value:?}"),
|
|
));
|
|
}
|
|
};
|
|
let colors = child
|
|
.children()
|
|
.filter(|value| value.is_element() && value.has_tag_name("value"))
|
|
.map(|value| parse_color(path, value))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
if colors.is_empty() {
|
|
return Err(location(
|
|
path,
|
|
child,
|
|
"VP023",
|
|
"<param_color> requires at least one <value color=...>",
|
|
));
|
|
}
|
|
if !skip_color {
|
|
color = Some(ColorDefinition { operation, colors });
|
|
}
|
|
} else if child.has_tag_name("param_driver") {
|
|
for driven_node in child
|
|
.children()
|
|
.filter(|value| value.is_element() && value.has_tag_name("driven"))
|
|
{
|
|
let driven_id = parse_i32(path, driven_node, "id", "VP030")?;
|
|
let has_range = driven_node.attribute("min1").is_some();
|
|
let item = if has_range {
|
|
DrivenDefinition {
|
|
param_id: driven_id,
|
|
min1: parse_f32(path, driven_node, "min1", "VP031")?,
|
|
max1: parse_f32(path, driven_node, "max1", "VP032")?,
|
|
max2: parse_f32(path, driven_node, "max2", "VP033")?,
|
|
min2: parse_f32(path, driven_node, "min2", "VP034")?,
|
|
has_range: true,
|
|
}
|
|
} else {
|
|
for attribute in ["max1", "max2", "min2"] {
|
|
if driven_node.attribute(attribute).is_some() {
|
|
return Err(location(
|
|
path,
|
|
driven_node,
|
|
"VP035",
|
|
"driven range attributes must be supplied together",
|
|
));
|
|
}
|
|
}
|
|
DrivenDefinition {
|
|
param_id: driven_id,
|
|
min1: 0.0,
|
|
max1: 0.0,
|
|
max2: 0.0,
|
|
min2: 0.0,
|
|
has_range: false,
|
|
}
|
|
};
|
|
driven.push(item);
|
|
}
|
|
} else if child.has_tag_name("param_skeleton") {
|
|
for bone in child
|
|
.children()
|
|
.filter(|value| value.is_element() && value.has_tag_name("bone"))
|
|
{
|
|
let position = bone
|
|
.attribute("offset")
|
|
.map(|_| parse_vector(path, bone, "offset", "VP041"))
|
|
.transpose()?
|
|
.unwrap_or([0.0; 3]);
|
|
skeletal.push(SkeletalDefinition {
|
|
bone_name: required_attribute(path, bone, "name", "VP040")?,
|
|
scale: parse_vector(path, bone, "scale", "VP042")?,
|
|
position,
|
|
has_position: bone.attribute("offset").is_some(),
|
|
});
|
|
}
|
|
} else if child.has_tag_name("param_morph") {
|
|
for morph in child
|
|
.children()
|
|
.filter(|value| value.is_element() && value.has_tag_name("volume_morph"))
|
|
{
|
|
let scale = morph
|
|
.attribute("scale")
|
|
.map(|_| parse_vector(path, morph, "scale", "VP051"))
|
|
.transpose()?
|
|
.unwrap_or([0.0; 3]);
|
|
let position = morph
|
|
.attribute("pos")
|
|
.map(|_| parse_vector(path, morph, "pos", "VP052"))
|
|
.transpose()?
|
|
.unwrap_or([0.0; 3]);
|
|
volume_morphs.push(VolumeMorphDefinition {
|
|
bone_name: required_attribute(path, morph, "name", "VP050")?,
|
|
scale,
|
|
has_scale: morph.attribute("scale").is_some(),
|
|
position,
|
|
has_position: morph.attribute("pos").is_some(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if matches!(group, 0 | 3) {
|
|
transmitted_ids.push(param_id);
|
|
}
|
|
params.insert(
|
|
param_id,
|
|
VisualDefinition {
|
|
param_id,
|
|
name,
|
|
group,
|
|
wearable: node.attribute("wearable").map(str::to_owned),
|
|
label: node.attribute("label").unwrap_or_default().to_owned(),
|
|
label_min: node.attribute("label_min").unwrap_or_default().to_owned(),
|
|
label_max: node.attribute("label_max").unwrap_or_default().to_owned(),
|
|
default_value,
|
|
min_value,
|
|
max_value,
|
|
is_bump_attribute,
|
|
alpha,
|
|
color,
|
|
driven,
|
|
skeletal,
|
|
volume_morphs,
|
|
},
|
|
);
|
|
}
|
|
transmitted_ids.sort_unstable();
|
|
if params.is_empty() || transmitted_ids.is_empty() {
|
|
return Err(location(
|
|
path,
|
|
root,
|
|
"VP060",
|
|
"visual parameter catalog must not be empty",
|
|
));
|
|
}
|
|
for definition in params.values() {
|
|
for driven in &definition.driven {
|
|
if !params.contains_key(&driven.param_id) {
|
|
return Err(location(
|
|
path,
|
|
root,
|
|
"VP061",
|
|
&format!(
|
|
"visual parameter {} drives missing parameter {}",
|
|
definition.param_id, driven.param_id
|
|
),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Ok(VisualCatalog {
|
|
params,
|
|
transmitted_ids,
|
|
})
|
|
}
|
|
|
|
fn valid_uuid(value: &str) -> bool {
|
|
value.len() == 36
|
|
&& value.bytes().enumerate().all(|(index, byte)| match index {
|
|
8 | 13 | 18 | 23 => byte == b'-',
|
|
_ => byte.is_ascii_hexdigit(),
|
|
})
|
|
}
|
|
|
|
fn parse_trees(path: &str, text: &str) -> Result<Vec<TreeDefinition>, String> {
|
|
let document = Document::parse(text).map_err(|error| format!("{path}: {error}"))?;
|
|
let root = document.root_element();
|
|
if !root.has_tag_name("tree_defs") {
|
|
return Err(location(
|
|
path,
|
|
root,
|
|
"TR001",
|
|
"root element must be <tree_defs>",
|
|
));
|
|
}
|
|
let mut definitions = Vec::new();
|
|
let mut ids = BTreeSet::new();
|
|
for node in root.children().filter(Node::is_element) {
|
|
if !node.has_tag_name("tree") {
|
|
return Err(location(
|
|
path,
|
|
node,
|
|
"TR002",
|
|
"<tree_defs> may contain only <tree> elements",
|
|
));
|
|
}
|
|
let species_id = parse_u8(path, node, "species_id", "TR003")?;
|
|
if !ids.insert(species_id) {
|
|
return Err(location(
|
|
path,
|
|
node,
|
|
"TR004",
|
|
&format!("duplicate tree species ID {species_id}"),
|
|
));
|
|
}
|
|
let texture_id = required_attribute(path, node, "texture_id", "TR005")?;
|
|
if !valid_uuid(&texture_id) {
|
|
return Err(location(
|
|
path,
|
|
node,
|
|
"TR006",
|
|
&format!("invalid tree texture UUID {texture_id:?}"),
|
|
));
|
|
}
|
|
definitions.push(TreeDefinition {
|
|
name: required_attribute(path, node, "name", "TR007")?,
|
|
species_id,
|
|
texture_id,
|
|
droop: parse_f32(path, node, "droop", "TR010")?,
|
|
twist: parse_f32(path, node, "twist", "TR011")?,
|
|
branches: parse_f32(path, node, "branches", "TR012")?,
|
|
depth: parse_i32(path, node, "depth", "TR013")?,
|
|
scale_step: parse_f32(path, node, "scale_step", "TR014")?,
|
|
trunk_depth: parse_f32(path, node, "trunk_depth", "TR015")?,
|
|
branch_length: parse_f32(path, node, "branch_length", "TR016")?,
|
|
trunk_length: parse_f32(path, node, "trunk_length", "TR017")?,
|
|
leaf_scale: parse_f32(path, node, "leaf_scale", "TR018")?,
|
|
billboard_scale: parse_f32(path, node, "billboard_scale", "TR019")?,
|
|
billboard_ratio: parse_f32(path, node, "billboard_ratio", "TR020")?,
|
|
trunk_aspect: parse_f32(path, node, "trunk_aspect", "TR021")?,
|
|
branch_aspect: parse_f32(path, node, "branch_aspect", "TR022")?,
|
|
leaf_rotate: parse_f32(path, node, "leaf_rotate", "TR023")?,
|
|
noise_mag: parse_f32(path, node, "noise_mag", "TR024")?,
|
|
noise_scale: parse_f32(path, node, "noise_scale", "TR025")?,
|
|
taper: parse_f32(path, node, "taper", "TR026")?,
|
|
repeat_z: parse_i32(path, node, "repeat_z", "TR027")?,
|
|
});
|
|
}
|
|
validate_contiguous(
|
|
path,
|
|
root,
|
|
"tree",
|
|
definitions.iter().map(|item| item.species_id),
|
|
)?;
|
|
Ok(definitions)
|
|
}
|
|
|
|
fn parse_grass(path: &str, text: &str) -> Result<Vec<GrassDefinition>, String> {
|
|
let document = Document::parse(text).map_err(|error| format!("{path}: {error}"))?;
|
|
let root = document.root_element();
|
|
if !root.has_tag_name("grass_defs") {
|
|
return Err(location(
|
|
path,
|
|
root,
|
|
"GR001",
|
|
"root element must be <grass_defs>",
|
|
));
|
|
}
|
|
let mut definitions = Vec::new();
|
|
let mut ids = BTreeSet::new();
|
|
for node in root.children().filter(Node::is_element) {
|
|
if !node.has_tag_name("grass") {
|
|
return Err(location(
|
|
path,
|
|
node,
|
|
"GR002",
|
|
"<grass_defs> may contain only <grass> elements",
|
|
));
|
|
}
|
|
let species_id = parse_u8(path, node, "species_id", "GR003")?;
|
|
if !ids.insert(species_id) {
|
|
return Err(location(
|
|
path,
|
|
node,
|
|
"GR004",
|
|
&format!("duplicate grass species ID {species_id}"),
|
|
));
|
|
}
|
|
let texture_id = required_attribute(path, node, "texture_id", "GR005")?;
|
|
if !valid_uuid(&texture_id) {
|
|
return Err(location(
|
|
path,
|
|
node,
|
|
"GR006",
|
|
&format!("invalid grass texture UUID {texture_id:?}"),
|
|
));
|
|
}
|
|
definitions.push(GrassDefinition {
|
|
name: required_attribute(path, node, "name", "GR007")?,
|
|
species_id,
|
|
texture_id,
|
|
blade_size_x: parse_f32(path, node, "blade_size_x", "GR010")?,
|
|
blade_size_y: parse_f32(path, node, "blade_size_y", "GR011")?,
|
|
});
|
|
}
|
|
validate_contiguous(
|
|
path,
|
|
root,
|
|
"grass",
|
|
definitions.iter().map(|item| item.species_id),
|
|
)?;
|
|
Ok(definitions)
|
|
}
|
|
|
|
fn validate_contiguous(
|
|
path: &str,
|
|
root: Node<'_, '_>,
|
|
name: &str,
|
|
ids: impl Iterator<Item = u8>,
|
|
) -> Result<(), String> {
|
|
let ids = ids.collect::<Vec<_>>();
|
|
if ids.is_empty() {
|
|
return Err(location(
|
|
path,
|
|
root,
|
|
"FG020",
|
|
&format!("{name} catalog must not be empty"),
|
|
));
|
|
}
|
|
if ids
|
|
.iter()
|
|
.copied()
|
|
.enumerate()
|
|
.any(|(index, id)| usize::from(id) != index)
|
|
{
|
|
return Err(location(
|
|
path,
|
|
root,
|
|
"FG021",
|
|
&format!("{name} species IDs must be contiguous and in source order from zero"),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn rust_f32(value: f32) -> String {
|
|
format!("{value:?}_f32")
|
|
}
|
|
|
|
fn vector_expression(value: [f32; 3]) -> String {
|
|
format!(
|
|
"libremetaverse_types::Vector3 {{ x: {}, y: {}, z: {} }}",
|
|
rust_f32(value[0]),
|
|
rust_f32(value[1]),
|
|
rust_f32(value[2])
|
|
)
|
|
}
|
|
|
|
fn option_string(value: Option<&str>) -> String {
|
|
value.map_or_else(
|
|
|| "None".to_owned(),
|
|
|value| format!("Some({value:?}.to_owned())"),
|
|
)
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)] // One renderer keeps the generated public type/data contract atomic.
|
|
fn render_visual_catalog(catalog: &VisualCatalog) -> String {
|
|
let mut body = String::new();
|
|
body.push_str(
|
|
"#![allow(clippy::float_cmp, clippy::manual_string_new, clippy::missing_errors_doc, clippy::must_use_candidate, clippy::needless_pass_by_value, clippy::too_many_lines, clippy::unnecessary_wraps)]\n\n\
|
|
use std::collections::{BTreeMap, HashMap};\n\
|
|
use std::sync::OnceLock;\n\n\
|
|
#[derive(Clone, Debug, PartialEq)]\n\
|
|
pub struct VisualAlphaParam { pub domain: f32, pub multiply_blend: bool, pub skip_if_zero: bool, pub tga_file: String }\n\
|
|
impl VisualAlphaParam { pub fn new(domain: f32, tga_file: String, skip_if_zero: bool, multiply_blend: bool) -> Result<Self, crate::Error> { Ok(Self { domain, multiply_blend, skip_if_zero, tga_file }) } }\n\n\
|
|
#[derive(Clone, Debug, PartialEq)]\n\
|
|
pub struct VisualColorParam { pub colors: Vec<libremetaverse_types::Color4>, pub operation: crate::VisualColorOperation }\n\
|
|
impl VisualColorParam { pub fn new(operation: crate::VisualColorOperation, colors: Vec<libremetaverse_types::Color4>) -> Result<Self, crate::Error> { Ok(Self { colors, operation }) } }\n\n\
|
|
#[derive(Clone, Debug, PartialEq)]\n\
|
|
pub struct DrivenParamInfo { pub has_range: bool, pub max1: f32, pub max2: f32, pub min1: f32, pub min2: f32, pub param_id: i32 }\n\
|
|
impl DrivenParamInfo { pub fn new(param_id: i32, min1: f32, max1: f32, max2: f32, min2: f32, has_range: bool) -> Result<Self, crate::Error> { Ok(Self { has_range, max1, max2, min1, min2, param_id }) } }\n\n\
|
|
#[derive(Clone, Debug, PartialEq)]\n\
|
|
pub struct SkeletalBoneInfo { pub bone_name: String, pub has_position_deformation: bool, pub position_deformation: libremetaverse_types::Vector3, pub scale_deformation: libremetaverse_types::Vector3 }\n\
|
|
impl SkeletalBoneInfo { pub fn new(bone_name: String, scale_deformation: libremetaverse_types::Vector3, position_deformation: libremetaverse_types::Vector3, has_position_deformation: bool) -> Result<Self, crate::Error> { Ok(Self { bone_name, has_position_deformation, position_deformation, scale_deformation }) } }\n\n\
|
|
#[derive(Clone, Debug, PartialEq)]\n\
|
|
pub struct VolumeMorphInfo { pub bone_name: String, pub has_position: bool, pub has_scale: bool, pub position_delta: libremetaverse_types::Vector3, pub scale_delta: libremetaverse_types::Vector3 }\n\
|
|
impl VolumeMorphInfo { pub fn new(bone_name: String, scale_delta: libremetaverse_types::Vector3, has_scale: bool, position_delta: libremetaverse_types::Vector3, has_position: bool) -> Result<Self, crate::Error> { Ok(Self { bone_name, has_position, has_scale, position_delta, scale_delta }) } }\n\n\
|
|
#[derive(Clone, Debug, PartialEq)]\n\
|
|
pub struct VisualParam {\n\
|
|
pub alpha_params: Option<Option<VisualAlphaParam>>, pub color_params: Option<Option<VisualColorParam>>,\n\
|
|
pub default_value: f32, pub driven_params: Option<Vec<DrivenParamInfo>>, pub drivers: Option<Vec<i32>>,\n\
|
|
pub group: i32, pub is_bump_attribute: bool, pub label: String, pub label_max: String, pub label_min: String,\n\
|
|
pub max_value: f32, pub min_value: f32, pub name: String, pub param_id: i32,\n\
|
|
pub skeletal_distortions: Option<Vec<SkeletalBoneInfo>>, pub volume_morphs: Option<Vec<VolumeMorphInfo>>, pub wearable: Option<String>,\n\
|
|
}\n\
|
|
impl Default for VisualParam { fn default() -> Self { Self { alpha_params: None, color_params: None, default_value: 0.0, driven_params: None, drivers: None, group: 0, is_bump_attribute: false, label: String::new(), label_max: String::new(), label_min: String::new(), max_value: 0.0, min_value: 0.0, name: String::new(), param_id: 0, skeletal_distortions: None, volume_morphs: None, wearable: None } } }\n\
|
|
impl VisualParam {\n\
|
|
#[allow(clippy::too_many_arguments)]\n\
|
|
pub fn new(param_id: i32, name: String, group: i32, wearable: Option<String>, label: String, label_min: String, label_max: String, def_: f32, min: f32, max: f32, is_bump_attribute: bool, drivers: Option<Vec<i32>>, alpha: Option<Option<VisualAlphaParam>>, color_params: Option<Option<VisualColorParam>>, driven_params: Option<Vec<DrivenParamInfo>>, skeletal_distortions: Option<Vec<SkeletalBoneInfo>>, volume_morphs: Option<Vec<VolumeMorphInfo>>) -> Result<Self, crate::Error> { Ok(Self { alpha_params: alpha, color_params, default_value: def_, driven_params, drivers, group, is_bump_attribute, label, label_max, label_min, max_value: max, min_value: min, name, param_id, skeletal_distortions, volume_morphs, wearable }) }\n\
|
|
}\n\n",
|
|
);
|
|
body.push_str("const GROUP0_PARAM_IDS: &[i32] = &[");
|
|
for id in &catalog.transmitted_ids {
|
|
let _ = write!(body, "{id},");
|
|
}
|
|
body.push_str("];\n\nfn generated_params() -> &'static BTreeMap<i32, VisualParam> {\n static PARAMS: OnceLock<BTreeMap<i32, VisualParam>> = OnceLock::new();\n PARAMS.get_or_init(|| {\n let mut params = BTreeMap::new();\n");
|
|
for definition in catalog.params.values() {
|
|
let alpha = definition.alpha.as_ref().map_or_else(
|
|
|| "None".to_owned(),
|
|
|alpha| {
|
|
format!(
|
|
"Some(Some(VisualAlphaParam {{ domain: {}, multiply_blend: {}, skip_if_zero: {}, tga_file: {:?}.to_owned() }}))",
|
|
rust_f32(alpha.domain), alpha.multiply_blend, alpha.skip_if_zero, alpha.tga_file
|
|
)
|
|
},
|
|
);
|
|
let color = definition.color.as_ref().map_or_else(
|
|
|| "None".to_owned(),
|
|
|color| {
|
|
let operation = match color.operation {
|
|
ColorOperation::Add => "crate::VisualColorOperation::Add",
|
|
ColorOperation::Blend => "crate::VisualColorOperation::Blend",
|
|
};
|
|
let values = color
|
|
.colors
|
|
.iter()
|
|
.map(|value| {
|
|
format!(
|
|
"libremetaverse_types::Color4 {{ r: f32::from({}_u8) / 255.0, g: f32::from({}_u8) / 255.0, b: f32::from({}_u8) / 255.0, a: f32::from({}_u8) / 255.0 }}",
|
|
value[0], value[1], value[2], value[3]
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
format!("Some(Some(VisualColorParam {{ colors: vec![{values}], operation: {operation} }}))")
|
|
},
|
|
);
|
|
let drivers = if definition.driven.is_empty() {
|
|
"None".to_owned()
|
|
} else {
|
|
format!(
|
|
"Some(vec![{}])",
|
|
definition
|
|
.driven
|
|
.iter()
|
|
.map(|item| item.param_id.to_string())
|
|
.collect::<Vec<_>>()
|
|
.join(",")
|
|
)
|
|
};
|
|
let driven = if definition.driven.is_empty() {
|
|
"None".to_owned()
|
|
} else {
|
|
format!(
|
|
"Some(vec![{}])",
|
|
definition
|
|
.driven
|
|
.iter()
|
|
.map(|item| format!(
|
|
"DrivenParamInfo {{ has_range: {}, max1: {}, max2: {}, min1: {}, min2: {}, param_id: {} }}",
|
|
item.has_range,
|
|
rust_f32(item.max1), rust_f32(item.max2), rust_f32(item.min1), rust_f32(item.min2), item.param_id
|
|
))
|
|
.collect::<Vec<_>>()
|
|
.join(",")
|
|
)
|
|
};
|
|
let skeletal = if definition.skeletal.is_empty() {
|
|
"None".to_owned()
|
|
} else {
|
|
format!(
|
|
"Some(vec![{}])",
|
|
definition
|
|
.skeletal
|
|
.iter()
|
|
.map(|item| format!(
|
|
"SkeletalBoneInfo {{ bone_name: {:?}.to_owned(), has_position_deformation: {}, position_deformation: {}, scale_deformation: {} }}",
|
|
item.bone_name, item.has_position, vector_expression(item.position), vector_expression(item.scale)
|
|
))
|
|
.collect::<Vec<_>>()
|
|
.join(",")
|
|
)
|
|
};
|
|
let volume_morphs = if definition.volume_morphs.is_empty() {
|
|
"None".to_owned()
|
|
} else {
|
|
format!(
|
|
"Some(vec![{}])",
|
|
definition
|
|
.volume_morphs
|
|
.iter()
|
|
.map(|item| format!(
|
|
"VolumeMorphInfo {{ bone_name: {:?}.to_owned(), has_position: {}, has_scale: {}, position_delta: {}, scale_delta: {} }}",
|
|
item.bone_name, item.has_position, item.has_scale, vector_expression(item.position), vector_expression(item.scale)
|
|
))
|
|
.collect::<Vec<_>>()
|
|
.join(",")
|
|
)
|
|
};
|
|
let _ = writeln!(
|
|
body,
|
|
" params.insert({}, VisualParam {{ alpha_params: {alpha}, color_params: {color}, default_value: {}, driven_params: {driven}, drivers: {drivers}, group: {}, is_bump_attribute: {}, label: {:?}.to_owned(), label_max: {:?}.to_owned(), label_min: {:?}.to_owned(), max_value: {}, min_value: {}, name: {:?}.to_owned(), param_id: {}, skeletal_distortions: {skeletal}, volume_morphs: {volume_morphs}, wearable: {} }});",
|
|
definition.param_id,
|
|
rust_f32(definition.default_value),
|
|
definition.group,
|
|
definition.is_bump_attribute,
|
|
definition.label,
|
|
definition.label_max,
|
|
definition.label_min,
|
|
rust_f32(definition.max_value),
|
|
rust_f32(definition.min_value),
|
|
definition.name,
|
|
definition.param_id,
|
|
option_string(definition.wearable.as_deref()),
|
|
);
|
|
}
|
|
body.push_str(
|
|
" params\n })\n}\n\n\
|
|
pub struct VisualParams;\n\
|
|
impl VisualParams {\n\
|
|
pub fn group0_param_ids() -> Vec<i32> { GROUP0_PARAM_IDS.to_vec() }\n\
|
|
pub fn params() -> libremetaverse_types::compat::SortedList<i32, VisualParam> { libremetaverse_types::compat::SortedList(generated_params().clone()) }\n\
|
|
pub fn find(name: String, wearable: Option<String>) -> Result<VisualParam, crate::Error> { Ok(generated_params().values().find(|param| param.name == name && param.wearable == wearable).cloned().unwrap_or_default()) }\n\
|
|
}\n\n\
|
|
pub(crate) fn decode_visual_params(bytes: &[u8]) -> HashMap<i32, f32> {\n\
|
|
let params = generated_params();\n\
|
|
let mut result = HashMap::with_capacity(GROUP0_PARAM_IDS.len());\n\
|
|
for (&id, &value) in GROUP0_PARAM_IDS.iter().zip(bytes) { if let Some(param) = params.get(&id) { let range = param.max_value - param.min_value; let mut decoded = f32::from(value) * (1.0 / 255.0) * range + param.min_value; if decoded.abs() < range * (1.0 / 255.0) { decoded = 0.0; } result.insert(id, decoded); } }\n\
|
|
for driver in params.values() {\n\
|
|
let Some(driven_params) = &driver.driven_params else { continue };\n\
|
|
let Some(&driver_value) = result.get(&driver.param_id) else { continue };\n\
|
|
for driven in driven_params {\n\
|
|
let Some(target) = params.get(&driven.param_id) else { continue };\n\
|
|
let normalized = if driven.has_range {\n\
|
|
if driver_value < driven.min1 { 0.0 } else if driver_value < driven.max1 { (driver_value - driven.min1) / (driven.max1 - driven.min1) } else if driver_value <= driven.max2 { 1.0 } else if driver_value < driven.min2 { (driven.min2 - driver_value) / (driven.min2 - driven.max2) } else { 0.0 }\n\
|
|
} else { let range = driver.max_value - driver.min_value; if range > 1.0e-6 { (driver_value - driver.min_value) / range } else { 0.0 } }.clamp(0.0, 1.0);\n\
|
|
result.insert(driven.param_id, target.min_value + normalized * (target.max_value - target.min_value));\n\
|
|
}\n\
|
|
}\n\
|
|
result\n\
|
|
}\n",
|
|
);
|
|
body.push_str(
|
|
"pub(crate) fn new_avatar() -> Result<crate::Avatar, crate::Error> {\n\
|
|
Ok(crate::Avatar {\n\
|
|
animations: Vec::new(), appearance_flags: crate::AppearanceFlags::NONE, appearance_version: 0,\n\
|
|
attachments: Vec::new(), cof_version: 0, control_flags: crate::AgentManagerControlFlags(0),\n\
|
|
groups: Vec::new(), hover_height: libremetaverse_types::Vector3::zero(),\n\
|
|
profile_interests: crate::AvatarInterests { languages_text: String::new(), skills_mask: 0, skills_text: String::new(), want_to_mask: 0, want_to_text: String::new() },\n\
|
|
profile_properties: crate::AvatarAvatarProperties { about_text: String::new(), born_on: String::new(), charter_member: String::new(), first_life_image: libremetaverse_types::UUID::zero(), first_life_text: String::new(), flags: crate::ProfileFlags(0), partner: libremetaverse_types::UUID::zero(), profile_image: libremetaverse_types::UUID::zero(), profile_url: String::new() },\n\
|
|
profile_statistics: crate::AvatarStatistics { appearance_negative: 0, appearance_positive: 0, behavior_negative: 0, behavior_positive: 0, building_negative: 0, building_positive: 0, given_negative: 0, given_positive: 0 },\n\
|
|
visual_parameters: Vec::new(),\n\
|
|
})\n\
|
|
}\n\n\
|
|
#[cfg(test)] mod tests { use super::*; #[test] fn golden_catalog_shape_and_values() { let params = generated_params(); assert_eq!(params.len(), 672); assert_eq!(GROUP0_PARAM_IDS.len(), 253); assert!(GROUP0_PARAM_IDS.windows(2).all(|ids| ids[0] < ids[1])); let height = ¶ms[&33]; assert_eq!(height.name, \"Height\"); assert_eq!(height.group, 0); assert_eq!(height.wearable.as_deref(), Some(\"shape\")); assert_eq!(height.min_value, -2.3); assert_eq!(height.max_value, 2.0); assert!(!height.skeletal_distortions.as_ref().unwrap().is_empty()); } }\n",
|
|
);
|
|
body
|
|
}
|
|
|
|
fn render_foliage_catalog(trees: &[TreeDefinition], grasses: &[GrassDefinition]) -> String {
|
|
let mut body = String::new();
|
|
body.push_str(
|
|
"#![allow(clippy::missing_errors_doc, clippy::must_use_candidate, clippy::too_many_lines)]\n\n\
|
|
use std::sync::OnceLock;\n\n\
|
|
#[derive(Clone, Debug, PartialEq)]\n\
|
|
pub struct TreeDefinition { name: String, species_id: i32, texture_id: libremetaverse_types::UUID, droop: f32, twist: f32, branches: f32, depth: i32, scale_step: f32, trunk_depth: f32, branch_length: f32, trunk_length: f32, leaf_scale: f32, billboard_scale: f32, billboard_ratio: f32, trunk_aspect: f32, branch_aspect: f32, leaf_rotate: f32, noise_mag: f32, noise_scale: f32, taper: f32, repeat_z: i32 }\n\
|
|
impl TreeDefinition {\n\
|
|
pub fn name(&self) -> String { self.name.clone() } pub fn set_name(&mut self, value: String) { self.name = value; }\n\
|
|
pub fn species_id(&self) -> i32 { self.species_id } pub fn set_species_id(&mut self, value: i32) { self.species_id = value; }\n\
|
|
pub fn texture_id(&self) -> libremetaverse_types::UUID { self.texture_id } pub fn set_texture_id(&mut self, value: libremetaverse_types::UUID) { self.texture_id = value; }\n",
|
|
);
|
|
for name in [
|
|
"droop",
|
|
"twist",
|
|
"branches",
|
|
"scale_step",
|
|
"trunk_depth",
|
|
"branch_length",
|
|
"trunk_length",
|
|
"leaf_scale",
|
|
"billboard_scale",
|
|
"billboard_ratio",
|
|
"trunk_aspect",
|
|
"branch_aspect",
|
|
"leaf_rotate",
|
|
"noise_mag",
|
|
"noise_scale",
|
|
"taper",
|
|
] {
|
|
let _ = writeln!(
|
|
body,
|
|
" pub fn {name}(&self) -> f32 {{ self.{name} }} pub fn set_{name}(&mut self, value: f32) {{ self.{name} = value; }}"
|
|
);
|
|
}
|
|
body.push_str(" pub fn depth(&self) -> i32 { self.depth } pub fn set_depth(&mut self, value: i32) { self.depth = value; }\n pub fn repeat_z(&self) -> i32 { self.repeat_z } pub fn set_repeat_z(&mut self, value: i32) { self.repeat_z = value; }\n}\n\n");
|
|
body.push_str("fn generated_trees() -> &'static [TreeDefinition] { static TREES: OnceLock<Vec<TreeDefinition>> = OnceLock::new(); TREES.get_or_init(|| vec![\n");
|
|
for tree in trees {
|
|
let _ = writeln!(
|
|
body,
|
|
" TreeDefinition {{ name: {:?}.to_owned(), species_id: {}, texture_id: libremetaverse_types::UUID::parse({:?}.to_owned()).expect(\"validated tree UUID\"), droop: {}, twist: {}, branches: {}, depth: {}, scale_step: {}, trunk_depth: {}, branch_length: {}, trunk_length: {}, leaf_scale: {}, billboard_scale: {}, billboard_ratio: {}, trunk_aspect: {}, branch_aspect: {}, leaf_rotate: {}, noise_mag: {}, noise_scale: {}, taper: {}, repeat_z: {} }},",
|
|
tree.name,
|
|
tree.species_id,
|
|
tree.texture_id,
|
|
rust_f32(tree.droop),
|
|
rust_f32(tree.twist),
|
|
rust_f32(tree.branches),
|
|
tree.depth,
|
|
rust_f32(tree.scale_step),
|
|
rust_f32(tree.trunk_depth),
|
|
rust_f32(tree.branch_length),
|
|
rust_f32(tree.trunk_length),
|
|
rust_f32(tree.leaf_scale),
|
|
rust_f32(tree.billboard_scale),
|
|
rust_f32(tree.billboard_ratio),
|
|
rust_f32(tree.trunk_aspect),
|
|
rust_f32(tree.branch_aspect),
|
|
rust_f32(tree.leaf_rotate),
|
|
rust_f32(tree.noise_mag),
|
|
rust_f32(tree.noise_scale),
|
|
rust_f32(tree.taper),
|
|
tree.repeat_z
|
|
);
|
|
}
|
|
body.push_str(
|
|
"]).as_slice() }\n\n\
|
|
pub struct TreeDefinitions; impl TreeDefinitions { pub fn all() -> Vec<TreeDefinition> { generated_trees().to_vec() } pub fn get(species: crate::Tree) -> Result<TreeDefinition, crate::Error> { generated_trees().get(species as usize).cloned().ok_or(crate::Error::Argument) } }\n\n\
|
|
#[derive(Clone, Debug, PartialEq)]\n\
|
|
pub struct GrassDefinition { name: String, species_id: i32, texture_id: libremetaverse_types::UUID, blade_size_x: f32, blade_size_y: f32 }\n\
|
|
impl GrassDefinition {\n\
|
|
pub fn name(&self) -> String { self.name.clone() } pub fn set_name(&mut self, value: String) { self.name = value; }\n\
|
|
pub fn species_id(&self) -> i32 { self.species_id } pub fn set_species_id(&mut self, value: i32) { self.species_id = value; }\n\
|
|
pub fn texture_id(&self) -> libremetaverse_types::UUID { self.texture_id } pub fn set_texture_id(&mut self, value: libremetaverse_types::UUID) { self.texture_id = value; }\n\
|
|
pub fn blade_size_x(&self) -> f32 { self.blade_size_x } pub fn set_blade_size_x(&mut self, value: f32) { self.blade_size_x = value; }\n\
|
|
pub fn blade_size_y(&self) -> f32 { self.blade_size_y } pub fn set_blade_size_y(&mut self, value: f32) { self.blade_size_y = value; }\n\
|
|
}\n\nfn generated_grasses() -> &'static [GrassDefinition] { static GRASSES: OnceLock<Vec<GrassDefinition>> = OnceLock::new(); GRASSES.get_or_init(|| vec![\n",
|
|
);
|
|
for grass in grasses {
|
|
let _ = writeln!(
|
|
body,
|
|
" GrassDefinition {{ name: {:?}.to_owned(), species_id: {}, texture_id: libremetaverse_types::UUID::parse({:?}.to_owned()).expect(\"validated grass UUID\"), blade_size_x: {}, blade_size_y: {} }},",
|
|
grass.name,
|
|
grass.species_id,
|
|
grass.texture_id,
|
|
rust_f32(grass.blade_size_x),
|
|
rust_f32(grass.blade_size_y)
|
|
);
|
|
}
|
|
body.push_str(
|
|
"]).as_slice() }\n\n\
|
|
pub struct GrassDefinitions; impl GrassDefinitions { pub fn all() -> Vec<GrassDefinition> { generated_grasses().to_vec() } pub fn get(species: crate::Grass) -> Result<GrassDefinition, crate::Error> { grass_by_id(species as u8) } }\n\
|
|
pub(crate) fn grass_by_id(species: u8) -> Result<GrassDefinition, crate::Error> { generated_grasses().get(usize::from(species)).cloned().ok_or(crate::Error::Argument) }\n\n\
|
|
#[cfg(test)] mod tests { use super::*; #[test] fn golden_foliage_shape_and_values() { let trees = TreeDefinitions::all(); assert_eq!(trees.len(), 21); assert_eq!(trees[0].name(), \"Pine 1\"); assert_eq!(trees[0].species_id(), crate::Tree::Pine1 as i32); assert_eq!(trees[20].name(), \"Kelp 2\"); assert_eq!(TreeDefinitions::get(crate::Tree::Oak).unwrap(), trees[1]); let grass = GrassDefinitions::all(); assert_eq!(grass.len(), 6); assert_eq!(grass[5].name(), \"undergrowth_1\"); assert_eq!(GrassDefinitions::get(crate::Grass::Undergrowth1).unwrap(), grass[5]); } }\n",
|
|
);
|
|
body
|
|
}
|
|
|
|
fn load_input<'a>(inventory: &'a super::Inventory, id: &str) -> Result<&'a InputSpec, String> {
|
|
inventory
|
|
.inputs
|
|
.iter()
|
|
.find(|input| input.id == id)
|
|
.ok_or_else(|| format!("source inventory has no {id} input"))
|
|
}
|
|
|
|
fn load_text(root: &Path, input: &InputSpec) -> Result<String, String> {
|
|
let path = root.join(&input.vendored_path);
|
|
let bytes = fs::read(&path).map_err(|error| format!("{}: {error}", path.display()))?;
|
|
normalize_text(&input.vendored_path, &bytes).map_err(|error| error.to_string())
|
|
}
|
|
|
|
fn format_body(body: &str, name: &str) -> Result<String, String> {
|
|
let syntax = syn::parse_file(body)
|
|
.map_err(|error| format!("generated {name} Rust is invalid: {error}"))?;
|
|
Ok(prettyplease::unparse(&syntax))
|
|
}
|
|
|
|
pub(super) fn visual_catalog_bytes(root: &Path) -> Result<Vec<u8>, String> {
|
|
let inventory = super::load_inventory(root)?;
|
|
verify_inputs(root, &inventory)?;
|
|
let input = load_input(&inventory, "avatar_lad")?;
|
|
let catalog = parse_visual_catalog(&input.vendored_path, &load_text(root, input)?)?;
|
|
let body = format_body(&render_visual_catalog(&catalog), "visual parameter")?;
|
|
Ok(generated_rust("visual-params", &[input], &body))
|
|
}
|
|
|
|
pub(super) fn foliage_catalog_bytes(root: &Path) -> Result<Vec<u8>, String> {
|
|
let inventory = super::load_inventory(root)?;
|
|
verify_inputs(root, &inventory)?;
|
|
let tree_input = load_input(&inventory, "trees")?;
|
|
let grass_input = load_input(&inventory, "grass")?;
|
|
let trees = parse_trees(&tree_input.vendored_path, &load_text(root, tree_input)?)?;
|
|
let grasses = parse_grass(&grass_input.vendored_path, &load_text(root, grass_input)?)?;
|
|
let body = format_body(&render_foliage_catalog(&trees, &grasses), "foliage")?;
|
|
Ok(generated_rust("foliage", &[tree_input, grass_input], &body))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn visual_schema_rejects_malformed_values_and_missing_driver_targets() {
|
|
let malformed = "<linden_avatar><param id=\"1\" name=\"Bad\" group=\"0\" value_min=\"x\" value_max=\"1\"/></linden_avatar>";
|
|
assert!(
|
|
parse_visual_catalog("bad.xml", malformed)
|
|
.unwrap_err()
|
|
.contains("error[VP014]")
|
|
);
|
|
let missing = "<linden_avatar><param id=\"1\" name=\"Driver\" group=\"0\" value_min=\"0\" value_max=\"1\"><param_driver><driven id=\"2\"/></param_driver></param></linden_avatar>";
|
|
assert!(
|
|
parse_visual_catalog("bad.xml", missing)
|
|
.unwrap_err()
|
|
.contains("error[VP061]")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn foliage_schema_rejects_noncontiguous_ids_and_bad_uuids() {
|
|
let tree = "<tree_defs><tree name=\"Bad\" species_id=\"1\" texture_id=\"bad\" droop=\"0\" twist=\"0\" branches=\"0\" depth=\"0\" scale_step=\"0\" trunk_depth=\"0\" branch_length=\"0\" trunk_length=\"0\" leaf_scale=\"0\" billboard_scale=\"0\" billboard_ratio=\"0\" trunk_aspect=\"0\" branch_aspect=\"0\" leaf_rotate=\"0\" noise_mag=\"0\" noise_scale=\"0\" taper=\"0\" repeat_z=\"0\"/></tree_defs>";
|
|
assert!(
|
|
parse_trees("bad.xml", tree)
|
|
.unwrap_err()
|
|
.contains("error[TR006]")
|
|
);
|
|
let grass = "<grass_defs><grass name=\"Bad\" species_id=\"1\" texture_id=\"00000000-0000-0000-0000-000000000000\" blade_size_x=\"1\" blade_size_y=\"1\"/></grass_defs>";
|
|
assert!(
|
|
parse_grass("bad.xml", grass)
|
|
.unwrap_err()
|
|
.contains("error[FG021]")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pinned_catalog_semantics_have_stable_digests() {
|
|
let root = super::super::workspace_root();
|
|
let inventory = super::super::load_inventory(&root).expect("pinned inventory");
|
|
let visual_input = load_input(&inventory, "avatar_lad").expect("visual input");
|
|
let visual = parse_visual_catalog(
|
|
&visual_input.vendored_path,
|
|
&load_text(&root, visual_input).expect("visual text"),
|
|
)
|
|
.expect("visual catalog");
|
|
let tree_input = load_input(&inventory, "trees").expect("tree input");
|
|
let trees = parse_trees(
|
|
&tree_input.vendored_path,
|
|
&load_text(&root, tree_input).expect("tree text"),
|
|
)
|
|
.expect("tree catalog");
|
|
let grass_input = load_input(&inventory, "grass").expect("grass input");
|
|
let grasses = parse_grass(
|
|
&grass_input.vendored_path,
|
|
&load_text(&root, grass_input).expect("grass text"),
|
|
)
|
|
.expect("grass catalog");
|
|
|
|
assert_eq!(
|
|
super::super::sha256(format!("{visual:#?}").as_bytes()),
|
|
"3cdedcd412fa8f8dee4a99407968e849c55bdcdb13b883861352d042b1783a4a"
|
|
);
|
|
assert_eq!(
|
|
super::super::sha256(format!("{trees:#?}\n{grasses:#?}").as_bytes()),
|
|
"636b3a00085d68f72c8506766a7e09c37e7db41bfca1acae228ad9cdb6b5ee50"
|
|
);
|
|
}
|
|
}
|