diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs index bd43a81..5dd4735 100644 --- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs +++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs @@ -566,6 +566,7 @@ mod tests { indices, batches, node_names: None, + animation: None, } } @@ -626,6 +627,7 @@ mod tests { }, ], node_names: None, + animation: None, }; let selected = static_model_preview_batches(&model).expect("valid selected slot"); diff --git a/crates/fparkan-msh/Cargo.toml b/crates/fparkan-msh/Cargo.toml index 9ddcc0a..7df80f9 100644 --- a/crates/fparkan-msh/Cargo.toml +++ b/crates/fparkan-msh/Cargo.toml @@ -7,10 +7,8 @@ repository.workspace = true [dependencies] encoding_rs = "0.8" -fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" } - -[dev-dependencies] fparkan-animation = { path = "../fparkan-animation", version = "0.1.0" } +fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" } [lints] workspace = true diff --git a/crates/fparkan-msh/src/lib.rs b/crates/fparkan-msh/src/lib.rs index f35e7c3..38a8984 100644 --- a/crates/fparkan-msh/src/lib.rs +++ b/crates/fparkan-msh/src/lib.rs @@ -21,6 +21,7 @@ //! Stage-3 MSH asset contract. use encoding_rs::WINDOWS_1251; +use fparkan_animation::{AnimKey24, Pose}; use fparkan_nres::{EntryMeta, NresDocument, NresError}; /// Node table stream. @@ -110,6 +111,22 @@ pub struct ModelAsset { pub batches: Vec, /// Optional decoded node names. pub node_names: Option>>, + /// Optional decoded node-animation streams. + pub animation: Option, +} + +/// Decoded MSH node-animation streams. +/// +/// This preserves the exact type-8/type-19 boundary used by `Node38` without +/// assigning runtime-state ownership to a static asset. +#[derive(Clone, Debug, PartialEq)] +pub struct ModelAnimation { + /// Type-8 animation keys in source order. + pub keys: Vec, + /// Type-19 frame-to-key mapping words in source order. + pub frame_map: Vec, + /// Declared frame count from type-19 `attr2`. + pub frame_count: u32, } /// Node id. @@ -127,6 +144,17 @@ pub struct Node { pub raw: Vec, } +/// Raw fields of the standard 38-byte node layout that precede slot selection. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Node38Metadata { + /// Unassigned source link value at byte offset two. + pub parent_or_link_raw: u16, + /// Offset into the type-19 frame map, or `0xFFFF` for no map. + pub anim_map_start: u16, + /// Fallback type-8 key index. + pub fallback_key: u16, +} + /// Slot descriptor. #[derive(Clone, Debug, PartialEq)] pub struct Slot { @@ -387,6 +415,41 @@ pub fn selected_slot(model: &ModelAsset, node: NodeId, lod: Lod, group: Group) - (slot < model.slots.len()).then_some(SlotId(u32::from(raw))) } +/// Returns the undecorated metadata of a standard 38-byte node. +#[must_use] +pub fn node38_metadata(model: &ModelAsset, node: NodeId) -> Option { + if model.node_stride != 38 { + return None; + } + let node_index = usize::try_from(node.0).ok()?; + if node_index >= model.node_count { + return None; + } + let offset = node_index.checked_mul(model.node_stride)?; + Some(Node38Metadata { + parent_or_link_raw: read_u16(&model.nodes_raw, offset + 2)?, + anim_map_start: read_u16(&model.nodes_raw, offset + 4)?, + fallback_key: read_u16(&model.nodes_raw, offset + 6)?, + }) +} + +/// Returns the source fallback pose of a standard node. +/// +/// The legacy sampler uses this key when a node has no frame map or its current +/// frame falls outside that map. It is a static-pose input only; callers must +/// still recover animation state and the meaning of `parent_or_link_raw` before +/// assembling a runtime hierarchy. +#[must_use] +pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option { + let metadata = node38_metadata(model, node)?; + model + .animation + .as_ref()? + .keys + .get(usize::from(metadata.fallback_key)) + .map(AnimKey24::sampling_pose) +} + /// Returns draw batches for a validated slot. /// /// # Errors @@ -526,6 +589,7 @@ fn parse_model_document(document: &NresDocument) -> Result let node_names = read_optional_stream(document, STREAM_NAMES)? .map(|raw| parse_res10_names(&raw.bytes, node_count)) .transpose()?; + let animation = parse_optional_animation(document)?; Ok(ModelAsset { node_stride, @@ -538,9 +602,37 @@ fn parse_model_document(document: &NresDocument) -> Result indices, batches, node_names, + animation, }) } +fn parse_optional_animation(document: &NresDocument) -> Result, MshError> { + let keys = read_optional_stream(document, STREAM_ANIMATION_KEYS)?; + let frame_map = read_optional_stream(document, STREAM_ANIMATION_FRAME_MAP)?; + let (Some(keys), Some(frame_map)) = (keys, frame_map) else { + return Ok(None); + }; + if !keys.bytes.len().is_multiple_of(24) { + return Err(invalid_resource_size("Res8", keys.bytes.len(), 24)); + } + if !frame_map.bytes.len().is_multiple_of(2) { + return Err(invalid_resource_size("Res19", frame_map.bytes.len(), 2)); + } + let keys = keys + .bytes + .chunks_exact(24) + .map(AnimKey24::decode) + .collect::, _>>() + .map_err(|err| MshError::InvalidGeometry(format!("invalid Res8 animation key: {err}")))?; + let frame_count = frame_map.attributes.attr2; + let frame_map = parse_u16_array(&frame_map.bytes, "Res19")?; + Ok(Some(ModelAnimation { + keys, + frame_map, + frame_count, + })) +} + struct RawStream { attributes: EntryAttributes, bytes: Vec, @@ -1063,6 +1155,55 @@ mod tests { assert_eq!(selected_slot(&model, NodeId(0), Lod(2), Group(4)), None); } + #[test] + fn standard_node_exposes_fallback_pose_and_unassigned_link() { + let mut node = node38([u16::MAX; 15]); + node[2..4].copy_from_slice(&7_u16.to_le_bytes()); + node[4..6].copy_from_slice(&u16::MAX.to_le_bytes()); + node[6..8].copy_from_slice(&0_u16.to_le_bytes()); + let mut key = Vec::new(); + push_f32(&mut key, 1.0); + push_f32(&mut key, 2.0); + push_f32(&mut key, 3.0); + push_f32(&mut key, 0.0); + push_u16(&mut key, 0); + push_u16(&mut key, 0); + push_u16(&mut key, 0); + push_u16(&mut key, 32_767); + let document = decode_nested(&build_nres(&[ + stream(STREAM_NODE_TABLE, 38, b"Res1", &node), + stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])), + stream(STREAM_POSITIONS, 0, b"Res3", &[]), + stream(STREAM_INDICES, 0, b"Res6", &[]), + stream(STREAM_ANIMATION_KEYS, 0, b"Res8", &key), + stream(STREAM_BATCHES, 0, b"Res13", &[]), + stream(STREAM_ANIMATION_FRAME_MAP, 0, b"Res19", &[]), + ])) + .expect("nested NRes"); + let model = + validate_msh(&decode_msh(&document).expect("msh document")).expect("model asset"); + + assert_eq!( + node38_metadata(&model, NodeId(0)), + Some(Node38Metadata { + parent_or_link_raw: 7, + anim_map_start: u16::MAX, + fallback_key: 0, + }) + ); + assert_eq!( + node38_fallback_pose(&model, NodeId(0)).map(|pose| pose.translation), + Some([1.0, 2.0, 3.0]) + ); + assert_eq!( + model + .animation + .as_ref() + .map(|animation| animation.keys.len()), + Some(1) + ); + } + #[test] fn type2_header_and_slot_tail_framing_are_exact() { let too_small = decode_nested(&build_nres(&[ diff --git a/docs/reference/msh.md b/docs/reference/msh.md index 25aaad2..16a4035 100644 --- a/docs/reference/msh.md +++ b/docs/reference/msh.md @@ -43,6 +43,12 @@ struct Node38 { `slot_index[lod * 5 + group]` выбирает geometry slot. `0xFFFF` означает отсутствие геометрии для комбинации LOD/group. +Validated `ModelAsset` также сохраняет decoded type 8 keys и type 19 map как +`ModelAnimation`. `node38_fallback_pose` возвращает pose по `fallback_key`, +то есть доказанный static input. Значение `parent_or_link` сохраняется как raw +field, пока runtime evidence не подтверждает, является ли оно parent index или +другой link semantic. + ## Slot and batch Type 2 содержит header `0x8C`, затем `Slot68`: diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 3ee2998..bc75c8b 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -1598,6 +1598,15 @@ MSH static bridge теперь также перестал отправлять parent hierarchy и animation-key/fallback semantics. Модели без layout Node38 сохраняют прежний all-batches diagnostic fallback. +Чтобы следующий шаг не перечитывал raw NRes streams в renderer, validated +`ModelAsset` теперь переносит `ModelAnimation` с decoded type-8 keys, type-19 +map и declared frame count. `node38_fallback_pose` возвращает exact fallback +key pose, а offset `+2` остаётся именованным только как `parent_or_link_raw`. +Licensed gates Part 1/Part 2 повторно подтвердили 435/511 models, 157/200 +animated models, 3,469/5,233 node samples и утверждённые captures. Это +доказывает production handoff animation data, не семантику parent links и не +готовую model assembly. + Параллельно bridge для legacy-camera path теперь переводит только высоту `Land.msh` в world units с масштабом `1/32`: raw AutoDemo heights `95.29412..288.23532` становятся `2.978..9.007`, что согласуется с TMA Z и