Skip to main content

mmd_anim_format/
pmm.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::fmt;
3
4use glam::{Quat, Vec3A};
5use mmd_anim_runtime::{
6    AnimationClip, BoneAnimationBinding, BoneIndex, InterpolationScalar, InterpolationVector3,
7    MorphAnimationBinding, MorphIndex, MorphKeyframe, MorphTrack, MovableBoneKeyframe,
8    MovableBoneTrack,
9};
10use serde::Serialize;
11
12use crate::binary::{
13    ByteReader, write_f32_le as push_f32, write_i32_le as push_i32, write_u32_le as push_u32,
14};
15use crate::error::ImportError;
16use crate::pmx::PmxParsedModel;
17use crate::sjis::{decode_sjis, decode_sjis_trim_nul, encode_sjis, encode_sjis_prefix_fit};
18use crate::vmd::{VmdParsedAnimation, VmdParsedBoneFrame, VmdParsedMorphFrame};
19
20#[derive(Debug, Clone, Serialize)]
21#[serde(rename_all = "camelCase")]
22pub struct PmmParsedManifest {
23    pub signature: &'static str,
24    pub version: String,
25    pub parsed_version: Option<u32>,
26    pub byte_length: usize,
27    pub project_settings: PmmProjectSettings,
28    pub timeline: PmmTimeline,
29    pub display_state: PmmDisplayState,
30    pub header_text_entries: Vec<PmmHeaderTextEntry>,
31    pub model_slots: Vec<PmmModelSlot>,
32    pub document_summary: Option<PmmDocumentSummary>,
33    pub document_global_summary: Option<PmmDocumentGlobalSummary>,
34    pub project_graph: Option<PmmProjectGraph>,
35    pub asset_summary: PmmAssetSummary,
36    pub asset_references: Vec<PmmAssetReference>,
37    pub model_assets: Vec<PmmSceneAsset>,
38    pub accessory_assets: Vec<PmmSceneAsset>,
39    pub motion_assets: Vec<PmmSceneAsset>,
40    pub audio_assets: Vec<PmmSceneAsset>,
41    pub image_assets: Vec<PmmSceneAsset>,
42    pub video_assets: Vec<PmmSceneAsset>,
43    pub model_paths: Vec<String>,
44    pub accessory_paths: Vec<String>,
45    pub motion_paths: Vec<String>,
46    pub audio_paths: Vec<String>,
47    pub image_paths: Vec<String>,
48    pub video_paths: Vec<String>,
49    pub diagnostics: Vec<PmmParserDiagnostic>,
50    #[serde(skip)]
51    source_bytes: Vec<u8>,
52}
53
54#[derive(Debug, Clone, Serialize)]
55#[serde(rename_all = "camelCase")]
56pub struct PmmProjectSettings {
57    pub screen_width: Option<u32>,
58    pub screen_height: Option<u32>,
59    pub timeline_frame_count: Option<u32>,
60    pub frame_rate: Option<f32>,
61}
62
63#[derive(Debug, Clone, Serialize)]
64#[serde(rename_all = "camelCase")]
65pub struct PmmTimeline {
66    pub start_frame: Option<u32>,
67    pub end_frame_exclusive: Option<u32>,
68    pub frame_count: Option<u32>,
69    pub frame_rate: Option<f32>,
70    pub duration_seconds: Option<f32>,
71}
72
73#[derive(Debug, Clone, Serialize)]
74#[serde(rename_all = "camelCase")]
75pub struct PmmDisplayState {
76    pub layout: &'static str,
77    pub model_slot_flags: Vec<u8>,
78    pub model_slot_flag_entries: Vec<PmmModelSlotFlagEntry>,
79    pub document_expand_flags: Option<PmmDocumentExpandFlags>,
80    pub selected_model_index: Option<u8>,
81    pub document_model_count: Option<u8>,
82    pub declared_model_slot_count: Option<u8>,
83    pub model_slot_count: usize,
84    pub non_zero_model_slot_count: usize,
85    pub accessory_slot_count: Option<u8>,
86    pub active_model_slot_indices: Vec<usize>,
87    pub empty_model_slot_indices: Vec<usize>,
88    pub model_slot_flag_counts: BTreeMap<u8, usize>,
89}
90
91#[derive(Debug, Clone, Serialize)]
92#[serde(rename_all = "camelCase")]
93pub struct PmmDocumentExpandFlags {
94    pub editing_cla: bool,
95    pub camera_panel: bool,
96    pub light_panel: bool,
97    pub accessory_panel: bool,
98    pub bone_panel: bool,
99    pub morph_panel: bool,
100    pub self_shadow_panel: bool,
101}
102
103#[derive(Debug, Clone, Serialize)]
104#[serde(rename_all = "camelCase")]
105pub struct PmmModelSlotFlagEntry {
106    pub slot_index: usize,
107    pub flag: u8,
108    pub active: bool,
109}
110
111#[derive(Debug, Clone, Serialize)]
112#[serde(rename_all = "camelCase")]
113pub struct PmmHeaderTextEntry {
114    pub index: usize,
115    pub offset: usize,
116    pub offset_end: usize,
117    pub text: String,
118    pub text_bytes: Vec<u8>,
119}
120
121#[derive(Debug, Clone, Serialize)]
122#[serde(rename_all = "camelCase")]
123pub struct PmmModelSlot {
124    pub slot_index: usize,
125    pub display_slot_index: Option<usize>,
126    pub offset: usize,
127    pub offset_end: usize,
128    pub model_path_offset: usize,
129    pub trailing_zero_padding_bytes: usize,
130    pub next_non_zero_offset: Option<usize>,
131    pub name: String,
132    pub name_bytes: Vec<u8>,
133    pub english_name: String,
134    pub english_name_bytes: Vec<u8>,
135    pub model_path: String,
136    pub normalized_path: String,
137    pub asset_reference_index: Option<usize>,
138    pub confidence: &'static str,
139}
140
141#[derive(Debug, Clone, Serialize)]
142#[serde(rename_all = "camelCase")]
143pub struct PmmAssetSummary {
144    pub reference_count: usize,
145    pub high_confidence_count: usize,
146    pub medium_confidence_count: usize,
147    pub low_confidence_count: usize,
148    pub kind_counts: BTreeMap<String, usize>,
149    pub extension_counts: BTreeMap<String, usize>,
150    pub confidence_counts: BTreeMap<String, usize>,
151}
152
153#[derive(Debug, Clone, Serialize)]
154#[serde(rename_all = "camelCase")]
155pub struct PmmDocumentSummary {
156    pub source: &'static str,
157    pub selected_model_index: u8,
158    pub model_count: usize,
159    pub counts: PmmDocumentCounts,
160    pub models: Vec<PmmDocumentModelSummary>,
161}
162
163#[derive(Debug, Clone, Serialize)]
164#[serde(rename_all = "camelCase")]
165pub struct PmmDocumentCounts {
166    pub models: usize,
167    pub bones: usize,
168    pub morphs: usize,
169    pub initial_bone_keyframes: usize,
170    pub bone_keyframes: usize,
171    pub initial_morph_keyframes: usize,
172    pub morph_keyframes: usize,
173    pub initial_model_keyframes: usize,
174    pub model_keyframes: usize,
175}
176
177#[derive(Debug, Clone, Serialize)]
178#[serde(rename_all = "camelCase")]
179pub struct PmmDocumentGlobalSummary {
180    pub source: &'static str,
181    pub offset: usize,
182    pub offset_end: usize,
183    pub camera: PmmDocumentTrackSummary,
184    pub light: PmmDocumentTrackSummary,
185    pub accessories: PmmDocumentAccessoryBlockSummary,
186    pub settings: PmmDocumentSettingsSummary,
187    pub gravity: PmmDocumentTrackSummary,
188    pub self_shadow: PmmDocumentTrackSummary,
189}
190
191#[derive(Debug, Clone, Serialize)]
192#[serde(rename_all = "camelCase")]
193pub struct PmmProjectSceneSettings {
194    pub offset: usize,
195    pub offset_end: usize,
196    pub current_frame_index: i32,
197    pub current_frame_index_in_text_field: i32,
198    pub begin_frame_index_enabled: bool,
199    pub end_frame_index_enabled: bool,
200    pub begin_frame_index: i32,
201    pub end_frame_index: i32,
202    pub preferred_fps: f32,
203    pub loop_enabled: bool,
204    pub audio_enabled: bool,
205    pub audio_path: String,
206    pub audio_asset_reference_index: Option<usize>,
207    pub background_video_enabled: bool,
208    pub background_video_path: String,
209    pub background_video_asset_reference_index: Option<usize>,
210    pub background_video_offset: [i32; 2],
211    pub background_video_scale_factor: f32,
212    pub background_image_enabled: bool,
213    pub background_image_path: String,
214    pub background_image_asset_reference_index: Option<usize>,
215    pub background_image_offset: [i32; 2],
216    pub background_image_scale_factor: f32,
217}
218
219#[derive(Debug, Clone, Serialize)]
220#[serde(rename_all = "camelCase")]
221pub struct PmmProjectAssetBinding {
222    pub scope: &'static str,
223    pub asset_kind: &'static str,
224    pub owner_index: Option<usize>,
225    pub document_index: Option<u8>,
226    pub owner_name: Option<String>,
227    pub path: String,
228    pub path_offset: Option<usize>,
229    pub asset_reference_index: Option<usize>,
230    pub asset_reference_offset: Option<usize>,
231    pub asset_reference_end_offset: Option<usize>,
232    pub asset_reference_confidence: Option<&'static str>,
233}
234
235#[derive(Debug, Clone, Serialize)]
236#[serde(rename_all = "camelCase")]
237pub struct PmmProjectExportBlocker {
238    pub code: &'static str,
239    pub severity: &'static str,
240    pub message: String,
241    pub scope: Option<&'static str>,
242    pub kind: Option<&'static str>,
243    pub count: Option<usize>,
244    pub coverage_ratio: Option<f32>,
245}
246
247#[derive(Debug, Clone, Serialize)]
248#[serde(rename_all = "camelCase")]
249pub struct PmmProjectExportReadiness {
250    pub lossless_parsed_byte_export_supported: bool,
251    pub semantic_graph_export_supported: bool,
252    pub source_byte_preservation_required: bool,
253    pub blocker_count: usize,
254    pub blockers: Vec<PmmProjectExportBlocker>,
255}
256
257#[derive(Debug, Clone, Serialize)]
258#[serde(rename_all = "camelCase")]
259pub struct PmmProjectGraph {
260    pub source: &'static str,
261    pub version: String,
262    pub parsed_version: Option<u32>,
263    pub timeline: PmmTimeline,
264    pub display_state: PmmDisplayState,
265    pub asset_summary: PmmAssetSummary,
266    pub asset_references: Vec<PmmAssetReference>,
267    pub models: Vec<PmmDocumentModelSummary>,
268    pub global: PmmDocumentGlobalSummary,
269    pub model_counts: PmmDocumentCounts,
270    pub accessory_count: usize,
271    pub accessory_keyframes: usize,
272    pub track_references: Vec<PmmProjectTrackReference>,
273    pub keyframe_references: Vec<PmmProjectKeyframeReference>,
274    pub byte_coverage: PmmProjectByteCoverage,
275    pub scene_settings: PmmProjectSceneSettings,
276    pub asset_bindings: Vec<PmmProjectAssetBinding>,
277    pub export_readiness: PmmProjectExportReadiness,
278}
279
280#[derive(Debug, Clone, Serialize)]
281#[serde(rename_all = "camelCase")]
282pub struct PmmProjectTrackReference {
283    pub scope: &'static str,
284    pub track_kind: &'static str,
285    pub owner_index: Option<usize>,
286    pub document_index: Option<u8>,
287    pub owner_name: Option<String>,
288    pub initial_keyframes: usize,
289    pub keyframes: usize,
290    pub initial_keyframes_offset: Option<usize>,
291    pub keyframe_count_offset: Option<usize>,
292    pub keyframes_offset: usize,
293    pub keyframes_end_offset: usize,
294    pub state_offset: Option<usize>,
295    pub state_end_offset: Option<usize>,
296}
297
298#[derive(Debug, Clone, Serialize)]
299#[serde(rename_all = "camelCase")]
300pub struct PmmProjectKeyframeReference {
301    pub scope: &'static str,
302    pub track_kind: &'static str,
303    pub owner_index: Option<usize>,
304    pub document_index: Option<u8>,
305    pub owner_name: Option<String>,
306    pub initial: bool,
307    pub keyframe_index: Option<i32>,
308    pub frame_index: i32,
309    pub previous_keyframe_index: i32,
310    pub next_keyframe_index: i32,
311    pub offset: usize,
312    pub byte_length: usize,
313    pub payload_offset: usize,
314    pub payload_byte_length: usize,
315}
316
317#[derive(Debug, Clone, Serialize)]
318#[serde(rename_all = "camelCase")]
319pub struct PmmProjectByteRange {
320    pub scope: &'static str,
321    pub kind: &'static str,
322    pub owner_index: Option<usize>,
323    pub document_index: Option<u8>,
324    pub name: Option<String>,
325    pub offset: usize,
326    pub offset_end: usize,
327    pub byte_length: usize,
328}
329
330#[derive(Debug, Clone, Serialize)]
331#[serde(rename_all = "camelCase")]
332pub struct PmmProjectByteCoverage {
333    pub offset: usize,
334    pub offset_end: usize,
335    pub byte_length: usize,
336    pub covered_byte_length: usize,
337    pub coverage_ratio: f32,
338    pub gap_count: usize,
339    pub gaps: Vec<PmmProjectByteRange>,
340    pub range_count: usize,
341    pub ranges: Vec<PmmProjectByteRange>,
342}
343
344#[derive(Debug, Clone, Serialize)]
345#[serde(rename_all = "camelCase")]
346pub struct PmmDocumentTrackSummary {
347    pub offset: usize,
348    pub offset_end: usize,
349    pub initial_keyframes: usize,
350    pub keyframes: usize,
351    pub initial_keyframe: Option<PmmDocumentKeyframeSummary>,
352    pub keyframe_summaries: Vec<PmmDocumentKeyframeSummary>,
353    pub keyframe_count_offset: usize,
354    pub keyframes_offset: usize,
355    pub keyframes_end_offset: usize,
356    pub state_offset: Option<usize>,
357    pub state_end_offset: Option<usize>,
358}
359
360#[derive(Debug, Clone, Serialize)]
361#[serde(
362    rename_all = "camelCase",
363    rename_all_fields = "camelCase",
364    tag = "kind"
365)]
366pub enum PmmDocumentKeyframeSummary {
367    Camera {
368        index: Option<i32>,
369        frame_index: i32,
370        previous_keyframe_index: i32,
371        next_keyframe_index: i32,
372        distance: f32,
373        look_at: [f32; 3],
374        angle: [f32; 3],
375        parent_model_index: i32,
376        parent_model_bone_index: i32,
377        interpolation: [u8; 24],
378        perspective_view: bool,
379        fov: i32,
380        selected: bool,
381        distance_offset: usize,
382        look_at_offset: usize,
383        look_at_byte_length: usize,
384        angle_offset: usize,
385        angle_byte_length: usize,
386        parent_model_index_offset: usize,
387        parent_model_bone_index_offset: usize,
388        interpolation_offset: usize,
389        interpolation_byte_length: usize,
390        perspective_view_offset: usize,
391        fov_offset: usize,
392        selected_offset: usize,
393        offset: usize,
394        byte_length: usize,
395        payload_offset: usize,
396        payload_byte_length: usize,
397        payload_bytes: Vec<u8>,
398    },
399    Light {
400        index: Option<i32>,
401        frame_index: i32,
402        previous_keyframe_index: i32,
403        next_keyframe_index: i32,
404        color: [f32; 3],
405        direction: [f32; 3],
406        selected: bool,
407        color_offset: usize,
408        color_byte_length: usize,
409        direction_offset: usize,
410        direction_byte_length: usize,
411        selected_offset: usize,
412        offset: usize,
413        byte_length: usize,
414        payload_offset: usize,
415        payload_byte_length: usize,
416        payload_bytes: Vec<u8>,
417    },
418    Gravity {
419        index: Option<i32>,
420        frame_index: i32,
421        previous_keyframe_index: i32,
422        next_keyframe_index: i32,
423        noise_enabled: bool,
424        noise: i32,
425        acceleration: f32,
426        direction: [f32; 3],
427        selected: bool,
428        noise_enabled_offset: usize,
429        noise_offset: usize,
430        acceleration_offset: usize,
431        direction_offset: usize,
432        direction_byte_length: usize,
433        selected_offset: usize,
434        offset: usize,
435        byte_length: usize,
436        payload_offset: usize,
437        payload_byte_length: usize,
438        payload_bytes: Vec<u8>,
439    },
440    SelfShadow {
441        index: Option<i32>,
442        frame_index: i32,
443        previous_keyframe_index: i32,
444        next_keyframe_index: i32,
445        mode: u8,
446        distance: f32,
447        selected: bool,
448        mode_offset: usize,
449        distance_offset: usize,
450        selected_offset: usize,
451        offset: usize,
452        byte_length: usize,
453        payload_offset: usize,
454        payload_byte_length: usize,
455        payload_bytes: Vec<u8>,
456    },
457}
458
459#[derive(Debug, Clone, Copy)]
460struct PmmDocumentBaseKeyframeSummary {
461    index: Option<i32>,
462    frame_index: i32,
463    previous_keyframe_index: i32,
464    next_keyframe_index: i32,
465}
466
467#[derive(Debug, Clone, Serialize)]
468#[serde(rename_all = "camelCase")]
469pub struct PmmDocumentAccessoryKeyframeSummary {
470    pub index: Option<i32>,
471    pub frame_index: i32,
472    pub previous_keyframe_index: i32,
473    pub next_keyframe_index: i32,
474    pub visible: bool,
475    pub opacity: f32,
476    pub parent_model_index: i32,
477    pub parent_model_bone_index: i32,
478    pub translation: [f32; 3],
479    pub orientation: [f32; 3],
480    pub scale_factor: f32,
481    pub shadow_enabled: bool,
482    pub selected: bool,
483    pub packed_opacity_visible_offset: usize,
484    pub parent_model_index_offset: usize,
485    pub parent_model_bone_index_offset: usize,
486    pub translation_offset: usize,
487    pub translation_byte_length: usize,
488    pub orientation_offset: usize,
489    pub orientation_byte_length: usize,
490    pub scale_factor_offset: usize,
491    pub shadow_enabled_offset: usize,
492    pub selected_offset: usize,
493    pub offset: usize,
494    pub byte_length: usize,
495    pub payload_offset: usize,
496    pub payload_byte_length: usize,
497    pub payload_bytes: Vec<u8>,
498}
499
500#[derive(Debug, Clone, Serialize)]
501#[serde(rename_all = "camelCase")]
502pub struct PmmDocumentBoneKeyframeSummary {
503    pub index: Option<i32>,
504    pub frame_index: i32,
505    pub previous_keyframe_index: i32,
506    pub next_keyframe_index: i32,
507    pub interpolation: [u8; 16],
508    pub translation: [f32; 3],
509    pub orientation: [f32; 4],
510    pub physics_disabled: bool,
511    pub selected: bool,
512    pub offset: usize,
513    pub byte_length: usize,
514    pub payload_offset: usize,
515    pub payload_byte_length: usize,
516    pub payload_bytes: Vec<u8>,
517}
518
519#[derive(Debug, Clone, Serialize)]
520#[serde(rename_all = "camelCase")]
521pub struct PmmDocumentMorphKeyframeSummary {
522    pub index: Option<i32>,
523    pub frame_index: i32,
524    pub previous_keyframe_index: i32,
525    pub next_keyframe_index: i32,
526    pub weight: f32,
527    pub selected: bool,
528    pub offset: usize,
529    pub byte_length: usize,
530    pub payload_offset: usize,
531    pub payload_byte_length: usize,
532    pub payload_bytes: Vec<u8>,
533}
534
535#[derive(Debug, Clone, Serialize)]
536#[serde(rename_all = "camelCase")]
537pub struct PmmDocumentModelKeyframeSummary {
538    pub index: Option<i32>,
539    pub frame_index: i32,
540    pub previous_keyframe_index: i32,
541    pub next_keyframe_index: i32,
542    pub visible: bool,
543    pub constraint_states: Vec<bool>,
544    pub outside_parent_indices: Vec<PmmDocumentOutsideParentIndexSummary>,
545    pub self_shadow_enabled: bool,
546    pub visible_offset: usize,
547    pub constraint_state_count: usize,
548    pub constraint_states_offset: usize,
549    pub constraint_states_byte_length: usize,
550    pub outside_parent_index_count: usize,
551    pub outside_parent_indices_offset: usize,
552    pub outside_parent_indices_byte_length: usize,
553    pub self_shadow_enabled_offset: usize,
554    pub offset: usize,
555    pub byte_length: usize,
556    pub payload_offset: usize,
557    pub payload_byte_length: usize,
558    pub payload_bytes: Vec<u8>,
559}
560
561#[derive(Debug, Clone, Serialize)]
562#[serde(rename_all = "camelCase")]
563pub struct PmmDocumentOutsideParentIndexSummary {
564    pub parent_model_index: i32,
565    pub parent_model_bone_index: i32,
566}
567
568#[derive(Debug, Clone, Serialize)]
569#[serde(rename_all = "camelCase")]
570pub struct PmmDocumentBoneStateSummary {
571    pub translation: [f32; 3],
572    pub orientation: [f32; 4],
573    pub dirty: bool,
574    pub physics_disabled: bool,
575    pub selected: bool,
576}
577
578#[derive(Debug, Clone, Serialize)]
579#[serde(rename_all = "camelCase")]
580pub struct PmmDocumentMorphStateSummary {
581    pub weight: f32,
582}
583
584#[derive(Debug, Clone, Serialize)]
585#[serde(rename_all = "camelCase")]
586pub struct PmmDocumentConstraintStateSummary {
587    pub enabled: bool,
588}
589
590/// Raw 16-byte outside-parent state block. Semantics not fully confirmed;
591/// field names reflect observed byte layout only.
592#[derive(Debug, Clone, Serialize)]
593#[serde(rename_all = "camelCase")]
594pub struct PmmDocumentOutsideParentStateSummary {
595    pub parent_model_index: i32,
596    pub parent_model_bone_index: i32,
597    pub subject_bone_index: i32,
598    pub target_model_index: i32,
599}
600
601#[derive(Debug, Clone, Serialize)]
602#[serde(rename_all = "camelCase")]
603pub struct PmmDocumentAccessoryBlockSummary {
604    pub offset: usize,
605    pub offset_end: usize,
606    pub selected_accessory_index: u8,
607    pub horizontal_scroll: i32,
608    pub accessory_count: usize,
609    pub keyframes: usize,
610    pub accessories: Vec<PmmDocumentAccessorySummary>,
611}
612
613#[derive(Debug, Clone, Serialize)]
614#[serde(rename_all = "camelCase")]
615pub struct PmmDocumentAccessorySummary {
616    pub slot_index: usize,
617    pub document_accessory_index: u8,
618    pub offset: usize,
619    pub offset_end: usize,
620    pub path_offset: usize,
621    pub name: String,
622    pub path: String,
623    pub asset_reference_index: Option<usize>,
624    pub draw_order_index: u8,
625    pub keyframes: usize,
626    pub initial_keyframe: PmmDocumentAccessoryKeyframeSummary,
627    pub keyframe_summaries: Vec<PmmDocumentAccessoryKeyframeSummary>,
628    pub keyframe_count_offset: usize,
629    pub keyframes_offset: usize,
630    pub keyframes_end_offset: usize,
631    pub state_offset: usize,
632    pub state_end_offset: usize,
633    pub visible: bool,
634    pub opacity: f32,
635    pub parent_model_index: i32,
636    pub parent_model_bone_index: i32,
637    pub scale_factor: f32,
638    pub shadow_enabled: bool,
639    pub add_blend_enabled: bool,
640}
641
642#[derive(Debug, Clone, Serialize)]
643#[serde(rename_all = "camelCase")]
644pub struct PmmDocumentSettingsSummary {
645    pub offset: usize,
646    pub offset_end: usize,
647    pub current_frame_index: i32,
648    pub current_frame_index_offset: usize,
649    pub horizontal_scroll: i32,
650    pub horizontal_scroll_thumb: i32,
651    pub editing_mode: i32,
652    pub camera_look_mode: u8,
653    pub loop_enabled: bool,
654    pub begin_frame_index_enabled: bool,
655    pub begin_frame_index_enabled_offset: usize,
656    pub end_frame_index_enabled: bool,
657    pub end_frame_index_enabled_offset: usize,
658    pub begin_frame_index: i32,
659    pub begin_frame_index_offset: usize,
660    pub end_frame_index: i32,
661    pub end_frame_index_offset: usize,
662    pub audio_enabled: bool,
663    pub audio_path: String,
664    pub audio_path_offset: usize,
665    pub background_video_offset: [i32; 2],
666    pub background_video_scale_factor: f32,
667    pub background_video_path: String,
668    pub background_video_path_offset: usize,
669    pub background_video_enabled: bool,
670    pub background_image_offset: [i32; 2],
671    pub background_image_scale_factor: f32,
672    pub background_image_path: String,
673    pub background_image_path_offset: usize,
674    pub background_image_enabled: bool,
675    pub information_shown: bool,
676    pub grid_and_axis_shown: bool,
677    pub ground_shadow_shown: bool,
678    pub preferred_fps: f32,
679    pub screen_capture_mode: i32,
680    pub accessory_index_after_models: i32,
681    pub ground_shadow_brightness: f32,
682    pub translucent_ground_shadow_enabled: bool,
683    pub physics_simulation_mode: u8,
684    pub edge_color: [f32; 3],
685    pub black_background_enabled: bool,
686    pub camera_look_at_model_index: i32,
687    pub camera_look_at_model_bone_index: i32,
688    pub unknown_matrix_offset: usize,
689    pub unknown_matrix_end_offset: usize,
690    pub following_look_at_enabled: bool,
691    pub physics_ground_enabled: bool,
692    pub current_frame_index_in_text_field: i32,
693    pub current_frame_index_in_text_field_offset: usize,
694    pub model_selection_footer_present: bool,
695    pub model_selection_footer_offset: Option<usize>,
696    pub model_selection_footer_end_offset: Option<usize>,
697}
698
699#[derive(Debug, Clone, Serialize)]
700#[serde(rename_all = "camelCase")]
701pub struct PmmDocumentModelSummary {
702    pub slot_index: usize,
703    pub document_model_index: u8,
704    pub offset: usize,
705    pub offset_end: usize,
706    pub path_offset: usize,
707    pub name: String,
708    pub english_name: String,
709    pub path: String,
710    pub asset_reference_index: Option<usize>,
711    pub bone_count: usize,
712    pub bone_names: Vec<String>,
713    pub morph_count: usize,
714    pub morph_names: Vec<String>,
715    pub constraint_bone_count: usize,
716    pub outside_parent_subject_bone_count: usize,
717    pub draw_order_index: u8,
718    pub transform_order_index: u8,
719    pub selected_bone_index: i32,
720    pub selected_morph_indices: [i32; 4],
721    pub vertical_scroll: i32,
722    pub sections: PmmDocumentModelSections,
723    pub initial_bone_keyframes: usize,
724    pub initial_bone_keyframe_summaries: Vec<PmmDocumentBoneKeyframeSummary>,
725    pub bone_keyframes: usize,
726    pub bone_keyframe_summaries: Vec<PmmDocumentBoneKeyframeSummary>,
727    pub initial_morph_keyframes: usize,
728    pub initial_morph_keyframe_summaries: Vec<PmmDocumentMorphKeyframeSummary>,
729    pub morph_keyframes: usize,
730    pub morph_keyframe_summaries: Vec<PmmDocumentMorphKeyframeSummary>,
731    pub initial_model_keyframes: usize,
732    pub model_keyframes: usize,
733    pub initial_model_keyframe: PmmDocumentModelKeyframeSummary,
734    pub model_keyframe_summaries: Vec<PmmDocumentModelKeyframeSummary>,
735    pub last_frame_index: i32,
736    pub visible: bool,
737    pub blend_enabled: bool,
738    pub edge_width: f32,
739    pub self_shadow_enabled: bool,
740    pub bone_state_summaries: Vec<PmmDocumentBoneStateSummary>,
741    pub morph_state_summaries: Vec<PmmDocumentMorphStateSummary>,
742    pub constraint_state_summaries: Vec<PmmDocumentConstraintStateSummary>,
743    pub outside_parent_state_summaries: Vec<PmmDocumentOutsideParentStateSummary>,
744}
745
746#[derive(Debug, Clone, PartialEq, Eq)]
747pub struct PmmDocumentClipBuildError {
748    message: String,
749}
750
751impl PmmDocumentClipBuildError {
752    fn new(message: impl Into<String>) -> Self {
753        Self {
754            message: message.into(),
755        }
756    }
757}
758
759impl fmt::Display for PmmDocumentClipBuildError {
760    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
761        formatter.write_str(&self.message)
762    }
763}
764
765impl std::error::Error for PmmDocumentClipBuildError {}
766
767/// Builds a runtime-neutral clip from one PMMv2 document model.
768///
769/// PMM document tracks are indexed by the model's stored bone/morph order, so
770/// the resulting bindings preserve those indices. Callers must verify that the
771/// paired runtime model has the same structure before evaluating the clip.
772/// Model visibility, property IK, physics state, and outside-parent state are
773/// intentionally outside this diagnostic slice.
774pub fn build_pmm_document_model_clip(
775    model: &PmmDocumentModelSummary,
776) -> Result<AnimationClip, PmmDocumentClipBuildError> {
777    if model.initial_bone_keyframe_summaries.len() != model.bone_count {
778        return Err(PmmDocumentClipBuildError::new(format!(
779            "PMM model {} initial bone keyframe count {} does not match bone count {}",
780            model.name,
781            model.initial_bone_keyframe_summaries.len(),
782            model.bone_count
783        )));
784    }
785    if model.initial_morph_keyframe_summaries.len() != model.morph_count {
786        return Err(PmmDocumentClipBuildError::new(format!(
787            "PMM model {} initial morph keyframe count {} does not match morph count {}",
788            model.name,
789            model.initial_morph_keyframe_summaries.len(),
790            model.morph_count
791        )));
792    }
793
794    for (bone_index, initial) in model.initial_bone_keyframe_summaries.iter().enumerate() {
795        if let Some(index) = initial.index {
796            return Err(PmmDocumentClipBuildError::new(format!(
797                "PMM bone {bone_index} initial keyframe must not have explicit index {index}"
798            )));
799        }
800    }
801    for (morph_index, initial) in model.initial_morph_keyframe_summaries.iter().enumerate() {
802        if let Some(index) = initial.index {
803            return Err(PmmDocumentClipBuildError::new(format!(
804                "PMM morph {morph_index} initial keyframe must not have explicit index {index}"
805            )));
806        }
807    }
808
809    let additional_bones = indexed_bone_keyframes(model)?;
810    let mut bone_tracks = Vec::with_capacity(model.bone_count);
811    let mut consumed_bone_keyframes = HashSet::with_capacity(additional_bones.len());
812    for (bone_index, initial) in model.initial_bone_keyframe_summaries.iter().enumerate() {
813        let summaries = collect_bone_keyframe_chain(initial, &additional_bones, bone_index)?;
814        let mut keyframes = Vec::with_capacity(summaries.len());
815        let mut frames = HashSet::with_capacity(summaries.len());
816        for summary in summaries {
817            if let Some(index) = summary.index
818                && !consumed_bone_keyframes.insert(index)
819            {
820                return Err(PmmDocumentClipBuildError::new(format!(
821                    "PMM additional bone keyframe index {index} is shared by multiple tracks"
822                )));
823            }
824            let frame = pmm_frame_index(summary.frame_index, "bone", bone_index)?;
825            if !frames.insert(frame) {
826                return Err(PmmDocumentClipBuildError::new(format!(
827                    "PMM bone {bone_index} has duplicate frame {frame}"
828                )));
829            }
830            let (position_interpolation, rotation_interpolation) =
831                decode_pmm_bone_interpolation(summary.interpolation);
832            let position = Vec3A::from_array(summary.translation);
833            let rotation = Quat::from_array(summary.orientation);
834            if !position.is_finite() {
835                return Err(PmmDocumentClipBuildError::new(format!(
836                    "PMM bone {bone_index} frame {frame} has non-finite translation"
837                )));
838            }
839            if !rotation.is_finite() || rotation.length_squared() <= f32::EPSILON {
840                return Err(PmmDocumentClipBuildError::new(format!(
841                    "PMM bone {bone_index} frame {frame} has invalid orientation"
842                )));
843            }
844            keyframes.push(MovableBoneKeyframe {
845                frame,
846                position,
847                rotation,
848                position_interpolation,
849                rotation_interpolation,
850            });
851        }
852        bone_tracks.push(BoneAnimationBinding {
853            bone: BoneIndex(bone_index as u32),
854            track: MovableBoneTrack::from_keyframes(keyframes),
855        });
856    }
857    // MMD can retain deleted/edited keys as records whose previous and next
858    // links are both zero. Only the chains reachable from each initial key are
859    // authoritative. Ignore only fully unlinked records; a remaining link on
860    // an unreachable record indicates a disconnected or malformed chain.
861    let disconnected_bone_keyframes = additional_bones
862        .iter()
863        .filter(|(index, summary)| {
864            !consumed_bone_keyframes.contains(index)
865                && (summary.previous_keyframe_index != 0 || summary.next_keyframe_index != 0)
866        })
867        .count();
868    if disconnected_bone_keyframes != 0 {
869        return Err(PmmDocumentClipBuildError::new(format!(
870            "PMM model {} has {disconnected_bone_keyframes} disconnected additional bone keyframe(s)",
871            model.name
872        )));
873    }
874
875    let additional_morphs = indexed_morph_keyframes(model)?;
876    let mut morph_tracks = Vec::with_capacity(model.morph_count);
877    let mut consumed_morph_keyframes = HashSet::with_capacity(additional_morphs.len());
878    for (morph_index, initial) in model.initial_morph_keyframe_summaries.iter().enumerate() {
879        let summaries = collect_morph_keyframe_chain(initial, &additional_morphs, morph_index)?;
880        let mut keyframes = Vec::with_capacity(summaries.len());
881        let mut frames = HashSet::with_capacity(summaries.len());
882        for summary in summaries {
883            if let Some(index) = summary.index
884                && !consumed_morph_keyframes.insert(index)
885            {
886                return Err(PmmDocumentClipBuildError::new(format!(
887                    "PMM additional morph keyframe index {index} is shared by multiple tracks"
888                )));
889            }
890            let frame = pmm_frame_index(summary.frame_index, "morph", morph_index)?;
891            if !frames.insert(frame) {
892                return Err(PmmDocumentClipBuildError::new(format!(
893                    "PMM morph {morph_index} has duplicate frame {frame}"
894                )));
895            }
896            if !summary.weight.is_finite() {
897                return Err(PmmDocumentClipBuildError::new(format!(
898                    "PMM morph {morph_index} frame {frame} has non-finite weight"
899                )));
900            }
901            keyframes.push(MorphKeyframe::new(frame, summary.weight));
902        }
903        morph_tracks.push(MorphAnimationBinding {
904            morph: MorphIndex(morph_index as u32),
905            track: MorphTrack::from_keyframes(keyframes),
906        });
907    }
908    let disconnected_morph_keyframes = additional_morphs
909        .iter()
910        .filter(|(index, summary)| {
911            !consumed_morph_keyframes.contains(index)
912                && (summary.previous_keyframe_index != 0 || summary.next_keyframe_index != 0)
913        })
914        .count();
915    if disconnected_morph_keyframes != 0 {
916        return Err(PmmDocumentClipBuildError::new(format!(
917            "PMM model {} has {disconnected_morph_keyframes} disconnected additional morph keyframe(s)",
918            model.name
919        )));
920    }
921    Ok(AnimationClip::new_full(bone_tracks, morph_tracks, None))
922}
923
924fn indexed_bone_keyframes(
925    model: &PmmDocumentModelSummary,
926) -> Result<HashMap<i32, &PmmDocumentBoneKeyframeSummary>, PmmDocumentClipBuildError> {
927    let mut indexed = HashMap::with_capacity(model.bone_keyframe_summaries.len());
928    for summary in &model.bone_keyframe_summaries {
929        let index = summary.index.ok_or_else(|| {
930            PmmDocumentClipBuildError::new("PMM additional bone keyframe is missing its index")
931        })?;
932        if index < 0 {
933            return Err(PmmDocumentClipBuildError::new(format!(
934                "PMM additional bone keyframe index {index} must be non-negative"
935            )));
936        }
937        if (index as usize) < model.bone_count {
938            return Err(PmmDocumentClipBuildError::new(format!(
939                "PMM additional bone keyframe index {index} overlaps initial bone track index namespace; expected index >= {}",
940                model.bone_count
941            )));
942        }
943        if indexed.insert(index, summary).is_some() {
944            return Err(PmmDocumentClipBuildError::new(format!(
945                "PMM duplicate additional bone keyframe index {index}"
946            )));
947        }
948    }
949    Ok(indexed)
950}
951
952fn indexed_morph_keyframes(
953    model: &PmmDocumentModelSummary,
954) -> Result<HashMap<i32, &PmmDocumentMorphKeyframeSummary>, PmmDocumentClipBuildError> {
955    let mut indexed = HashMap::with_capacity(model.morph_keyframe_summaries.len());
956    for summary in &model.morph_keyframe_summaries {
957        let index = summary.index.ok_or_else(|| {
958            PmmDocumentClipBuildError::new("PMM additional morph keyframe is missing its index")
959        })?;
960        if index < 0 {
961            return Err(PmmDocumentClipBuildError::new(format!(
962                "PMM additional morph keyframe index {index} must be non-negative"
963            )));
964        }
965        if (index as usize) < model.morph_count {
966            return Err(PmmDocumentClipBuildError::new(format!(
967                "PMM additional morph keyframe index {index} overlaps initial morph track index namespace; expected index >= {}",
968                model.morph_count
969            )));
970        }
971        if indexed.insert(index, summary).is_some() {
972            return Err(PmmDocumentClipBuildError::new(format!(
973                "PMM duplicate additional morph keyframe index {index}"
974            )));
975        }
976    }
977    Ok(indexed)
978}
979
980fn collect_bone_keyframe_chain<'a>(
981    initial: &'a PmmDocumentBoneKeyframeSummary,
982    indexed: &HashMap<i32, &'a PmmDocumentBoneKeyframeSummary>,
983    bone_index: usize,
984) -> Result<Vec<&'a PmmDocumentBoneKeyframeSummary>, PmmDocumentClipBuildError> {
985    let mut result = vec![initial];
986    let mut visited = HashSet::new();
987    let mut next_index = initial.next_keyframe_index;
988    let mut expected_previous_index = bone_index as i32;
989    while next_index != 0 {
990        let summary = indexed.get(&next_index).copied().ok_or_else(|| {
991            PmmDocumentClipBuildError::new(format!(
992                "PMM bone {bone_index} keyframe chain references missing index {next_index}"
993            ))
994        })?;
995        if !visited.insert(next_index) {
996            return Err(PmmDocumentClipBuildError::new(format!(
997                "PMM bone {bone_index} keyframe chain contains a cycle at index {next_index}"
998            )));
999        }
1000        if summary.previous_keyframe_index != expected_previous_index {
1001            return Err(PmmDocumentClipBuildError::new(format!(
1002                "PMM bone {bone_index} keyframe index {next_index} has previous index {}; expected {expected_previous_index}",
1003                summary.previous_keyframe_index
1004            )));
1005        }
1006        result.push(summary);
1007        expected_previous_index = next_index;
1008        next_index = summary.next_keyframe_index;
1009    }
1010    Ok(result)
1011}
1012
1013fn collect_morph_keyframe_chain<'a>(
1014    initial: &'a PmmDocumentMorphKeyframeSummary,
1015    indexed: &HashMap<i32, &'a PmmDocumentMorphKeyframeSummary>,
1016    morph_index: usize,
1017) -> Result<Vec<&'a PmmDocumentMorphKeyframeSummary>, PmmDocumentClipBuildError> {
1018    let mut result = vec![initial];
1019    let mut visited = HashSet::new();
1020    let mut next_index = initial.next_keyframe_index;
1021    let mut expected_previous_index = morph_index as i32;
1022    while next_index != 0 {
1023        let summary = indexed.get(&next_index).copied().ok_or_else(|| {
1024            PmmDocumentClipBuildError::new(format!(
1025                "PMM morph {morph_index} keyframe chain references missing index {next_index}"
1026            ))
1027        })?;
1028        if !visited.insert(next_index) {
1029            return Err(PmmDocumentClipBuildError::new(format!(
1030                "PMM morph {morph_index} keyframe chain contains a cycle at index {next_index}"
1031            )));
1032        }
1033        if summary.previous_keyframe_index != expected_previous_index {
1034            return Err(PmmDocumentClipBuildError::new(format!(
1035                "PMM morph {morph_index} keyframe index {next_index} has previous index {}; expected {expected_previous_index}",
1036                summary.previous_keyframe_index
1037            )));
1038        }
1039        result.push(summary);
1040        expected_previous_index = next_index;
1041        next_index = summary.next_keyframe_index;
1042    }
1043    Ok(result)
1044}
1045
1046fn pmm_frame_index(
1047    frame_index: i32,
1048    track_kind: &str,
1049    track_index: usize,
1050) -> Result<u32, PmmDocumentClipBuildError> {
1051    u32::try_from(frame_index).map_err(|_| {
1052        PmmDocumentClipBuildError::new(format!(
1053            "PMM {track_kind} {track_index} has negative frame index {frame_index}"
1054        ))
1055    })
1056}
1057
1058fn decode_pmm_bone_interpolation(
1059    interpolation: [u8; 16],
1060) -> (InterpolationVector3, InterpolationScalar) {
1061    fn scalar(values: &[u8]) -> InterpolationScalar {
1062        InterpolationScalar {
1063            x1: values[0].min(127),
1064            y1: values[1].min(127),
1065            x2: values[2].min(127),
1066            y2: values[3].min(127),
1067        }
1068    }
1069    (
1070        InterpolationVector3 {
1071            x: scalar(&interpolation[0..4]),
1072            y: scalar(&interpolation[4..8]),
1073            z: scalar(&interpolation[8..12]),
1074        },
1075        scalar(&interpolation[12..16]),
1076    )
1077}
1078
1079#[derive(Debug, Clone, Serialize)]
1080#[serde(rename_all = "camelCase")]
1081pub struct PmmDocumentModelSections {
1082    pub initial_bone_keyframes_offset: usize,
1083    pub bone_keyframe_count_offset: usize,
1084    pub bone_keyframes_offset: usize,
1085    pub bone_keyframes_end_offset: usize,
1086    pub initial_morph_keyframes_offset: usize,
1087    pub morph_keyframe_count_offset: usize,
1088    pub morph_keyframes_offset: usize,
1089    pub morph_keyframes_end_offset: usize,
1090    pub initial_model_keyframe_offset: usize,
1091    pub model_keyframe_count_offset: usize,
1092    pub model_keyframes_offset: usize,
1093    pub model_keyframes_end_offset: usize,
1094    pub bone_states_offset: usize,
1095    pub morph_states_offset: usize,
1096    pub constraint_states_offset: usize,
1097    pub outside_parent_states_offset: usize,
1098}
1099
1100#[derive(Debug, Clone, Serialize)]
1101#[serde(rename_all = "camelCase")]
1102pub struct PmmAssetReference {
1103    pub path: String,
1104    pub normalized_path: String,
1105    pub file_name: String,
1106    pub extension: String,
1107    pub kind: &'static str,
1108    pub offset: usize,
1109    pub offset_end: usize,
1110    pub confidence: &'static str,
1111}
1112
1113#[derive(Debug, Clone, Serialize)]
1114#[serde(rename_all = "camelCase")]
1115pub struct PmmSceneAsset {
1116    pub reference_index: usize,
1117    pub kind_index: usize,
1118    pub path: String,
1119    pub normalized_path: String,
1120    pub file_name: String,
1121    pub extension: String,
1122    pub offset: usize,
1123    pub offset_end: usize,
1124    pub confidence: &'static str,
1125}
1126
1127#[derive(Debug, Clone, Serialize)]
1128#[serde(rename_all = "camelCase")]
1129pub struct PmmParserDiagnostic {
1130    pub level: &'static str,
1131    pub code: &'static str,
1132    pub message: String,
1133}
1134
1135#[allow(clippy::too_many_arguments)]
1136fn make_keyframe_reference(
1137    scope: &'static str,
1138    track_kind: &'static str,
1139    owner_index: Option<usize>,
1140    document_index: Option<u8>,
1141    owner_name: Option<String>,
1142    initial: bool,
1143    keyframe_index: Option<i32>,
1144    frame_index: i32,
1145    previous_keyframe_index: i32,
1146    next_keyframe_index: i32,
1147    offset: usize,
1148    byte_length: usize,
1149    payload_offset: usize,
1150    payload_byte_length: usize,
1151) -> PmmProjectKeyframeReference {
1152    PmmProjectKeyframeReference {
1153        scope,
1154        track_kind,
1155        owner_index,
1156        document_index,
1157        owner_name,
1158        initial,
1159        keyframe_index,
1160        frame_index,
1161        previous_keyframe_index,
1162        next_keyframe_index,
1163        offset,
1164        byte_length,
1165        payload_offset,
1166        payload_byte_length,
1167    }
1168}
1169
1170fn make_global_track_ref(
1171    track: &PmmDocumentTrackSummary,
1172    kind: &'static str,
1173) -> PmmProjectTrackReference {
1174    let initial_kf_offset = track.initial_keyframe.as_ref().map(|kf| match kf {
1175        PmmDocumentKeyframeSummary::Camera { offset, .. } => *offset,
1176        PmmDocumentKeyframeSummary::Light { offset, .. } => *offset,
1177        PmmDocumentKeyframeSummary::Gravity { offset, .. } => *offset,
1178        PmmDocumentKeyframeSummary::SelfShadow { offset, .. } => *offset,
1179    });
1180    PmmProjectTrackReference {
1181        scope: "global",
1182        track_kind: kind,
1183        owner_index: None,
1184        document_index: None,
1185        owner_name: None,
1186        initial_keyframes: track.initial_keyframes,
1187        keyframes: track.keyframes,
1188        initial_keyframes_offset: initial_kf_offset,
1189        keyframe_count_offset: Some(track.keyframe_count_offset),
1190        keyframes_offset: track.keyframes_offset,
1191        keyframes_end_offset: track.keyframes_end_offset,
1192        state_offset: track.state_offset,
1193        state_end_offset: track.state_end_offset,
1194    }
1195}
1196
1197fn append_global_keyframe_refs(
1198    refs: &mut Vec<PmmProjectKeyframeReference>,
1199    track: &PmmDocumentTrackSummary,
1200    kind: &'static str,
1201) {
1202    if let Some(kf) = &track.initial_keyframe {
1203        let (index, frame, prev, next, off, blen, poff, plen) = match kf {
1204            PmmDocumentKeyframeSummary::Camera {
1205                index,
1206                frame_index,
1207                previous_keyframe_index,
1208                next_keyframe_index,
1209                offset,
1210                byte_length,
1211                payload_offset,
1212                payload_byte_length,
1213                ..
1214            } => (
1215                *index,
1216                *frame_index,
1217                *previous_keyframe_index,
1218                *next_keyframe_index,
1219                *offset,
1220                *byte_length,
1221                *payload_offset,
1222                *payload_byte_length,
1223            ),
1224            PmmDocumentKeyframeSummary::Light {
1225                index,
1226                frame_index,
1227                previous_keyframe_index,
1228                next_keyframe_index,
1229                offset,
1230                byte_length,
1231                payload_offset,
1232                payload_byte_length,
1233                ..
1234            } => (
1235                *index,
1236                *frame_index,
1237                *previous_keyframe_index,
1238                *next_keyframe_index,
1239                *offset,
1240                *byte_length,
1241                *payload_offset,
1242                *payload_byte_length,
1243            ),
1244            PmmDocumentKeyframeSummary::Gravity {
1245                index,
1246                frame_index,
1247                previous_keyframe_index,
1248                next_keyframe_index,
1249                offset,
1250                byte_length,
1251                payload_offset,
1252                payload_byte_length,
1253                ..
1254            } => (
1255                *index,
1256                *frame_index,
1257                *previous_keyframe_index,
1258                *next_keyframe_index,
1259                *offset,
1260                *byte_length,
1261                *payload_offset,
1262                *payload_byte_length,
1263            ),
1264            PmmDocumentKeyframeSummary::SelfShadow {
1265                index,
1266                frame_index,
1267                previous_keyframe_index,
1268                next_keyframe_index,
1269                offset,
1270                byte_length,
1271                payload_offset,
1272                payload_byte_length,
1273                ..
1274            } => (
1275                *index,
1276                *frame_index,
1277                *previous_keyframe_index,
1278                *next_keyframe_index,
1279                *offset,
1280                *byte_length,
1281                *payload_offset,
1282                *payload_byte_length,
1283            ),
1284        };
1285        refs.push(make_keyframe_reference(
1286            "global", kind, None, None, None, true, index, frame, prev, next, off, blen, poff, plen,
1287        ));
1288    }
1289    for kf in &track.keyframe_summaries {
1290        let (index, frame, prev, next, off, blen, poff, plen) = match kf {
1291            PmmDocumentKeyframeSummary::Camera {
1292                index,
1293                frame_index,
1294                previous_keyframe_index,
1295                next_keyframe_index,
1296                offset,
1297                byte_length,
1298                payload_offset,
1299                payload_byte_length,
1300                ..
1301            } => (
1302                *index,
1303                *frame_index,
1304                *previous_keyframe_index,
1305                *next_keyframe_index,
1306                *offset,
1307                *byte_length,
1308                *payload_offset,
1309                *payload_byte_length,
1310            ),
1311            PmmDocumentKeyframeSummary::Light {
1312                index,
1313                frame_index,
1314                previous_keyframe_index,
1315                next_keyframe_index,
1316                offset,
1317                byte_length,
1318                payload_offset,
1319                payload_byte_length,
1320                ..
1321            } => (
1322                *index,
1323                *frame_index,
1324                *previous_keyframe_index,
1325                *next_keyframe_index,
1326                *offset,
1327                *byte_length,
1328                *payload_offset,
1329                *payload_byte_length,
1330            ),
1331            PmmDocumentKeyframeSummary::Gravity {
1332                index,
1333                frame_index,
1334                previous_keyframe_index,
1335                next_keyframe_index,
1336                offset,
1337                byte_length,
1338                payload_offset,
1339                payload_byte_length,
1340                ..
1341            } => (
1342                *index,
1343                *frame_index,
1344                *previous_keyframe_index,
1345                *next_keyframe_index,
1346                *offset,
1347                *byte_length,
1348                *payload_offset,
1349                *payload_byte_length,
1350            ),
1351            PmmDocumentKeyframeSummary::SelfShadow {
1352                index,
1353                frame_index,
1354                previous_keyframe_index,
1355                next_keyframe_index,
1356                offset,
1357                byte_length,
1358                payload_offset,
1359                payload_byte_length,
1360                ..
1361            } => (
1362                *index,
1363                *frame_index,
1364                *previous_keyframe_index,
1365                *next_keyframe_index,
1366                *offset,
1367                *byte_length,
1368                *payload_offset,
1369                *payload_byte_length,
1370            ),
1371        };
1372        refs.push(make_keyframe_reference(
1373            "global", kind, None, None, None, false, index, frame, prev, next, off, blen, poff,
1374            plen,
1375        ));
1376    }
1377}
1378
1379fn make_global_track_byte_range(
1380    track: &PmmDocumentTrackSummary,
1381    kind: &'static str,
1382) -> PmmProjectByteRange {
1383    let byte_length = track.offset_end - track.offset;
1384    PmmProjectByteRange {
1385        scope: "global",
1386        kind,
1387        owner_index: None,
1388        document_index: None,
1389        name: None,
1390        offset: track.offset,
1391        offset_end: track.offset_end,
1392        byte_length,
1393    }
1394}
1395
1396fn build_project_track_references(
1397    doc: &PmmDocumentSummary,
1398    glob: &PmmDocumentGlobalSummary,
1399) -> Vec<PmmProjectTrackReference> {
1400    let mut track_references: Vec<PmmProjectTrackReference> = Vec::new();
1401    for model in &doc.models {
1402        let s = &model.sections;
1403        track_references.push(PmmProjectTrackReference {
1404            scope: "model",
1405            track_kind: "bone",
1406            owner_index: Some(model.slot_index),
1407            document_index: Some(model.document_model_index),
1408            owner_name: Some(model.name.clone()),
1409            initial_keyframes: model.initial_bone_keyframes,
1410            keyframes: model.bone_keyframes,
1411            initial_keyframes_offset: Some(s.initial_bone_keyframes_offset),
1412            keyframe_count_offset: Some(s.bone_keyframe_count_offset),
1413            keyframes_offset: s.bone_keyframes_offset,
1414            keyframes_end_offset: s.bone_keyframes_end_offset,
1415            state_offset: None,
1416            state_end_offset: None,
1417        });
1418        track_references.push(PmmProjectTrackReference {
1419            scope: "model",
1420            track_kind: "morph",
1421            owner_index: Some(model.slot_index),
1422            document_index: Some(model.document_model_index),
1423            owner_name: Some(model.name.clone()),
1424            initial_keyframes: model.initial_morph_keyframes,
1425            keyframes: model.morph_keyframes,
1426            initial_keyframes_offset: Some(s.initial_morph_keyframes_offset),
1427            keyframe_count_offset: Some(s.morph_keyframe_count_offset),
1428            keyframes_offset: s.morph_keyframes_offset,
1429            keyframes_end_offset: s.morph_keyframes_end_offset,
1430            state_offset: None,
1431            state_end_offset: None,
1432        });
1433        track_references.push(PmmProjectTrackReference {
1434            scope: "model",
1435            track_kind: "model",
1436            owner_index: Some(model.slot_index),
1437            document_index: Some(model.document_model_index),
1438            owner_name: Some(model.name.clone()),
1439            initial_keyframes: model.initial_model_keyframes,
1440            keyframes: model.model_keyframes,
1441            initial_keyframes_offset: Some(s.initial_model_keyframe_offset),
1442            keyframe_count_offset: Some(s.model_keyframe_count_offset),
1443            keyframes_offset: s.model_keyframes_offset,
1444            keyframes_end_offset: s.model_keyframes_end_offset,
1445            state_offset: None,
1446            state_end_offset: None,
1447        });
1448    }
1449    track_references.push(make_global_track_ref(&glob.camera, "camera"));
1450    track_references.push(make_global_track_ref(&glob.light, "light"));
1451    track_references.push(make_global_track_ref(&glob.gravity, "gravity"));
1452    track_references.push(make_global_track_ref(&glob.self_shadow, "selfShadow"));
1453    for acc in &glob.accessories.accessories {
1454        track_references.push(PmmProjectTrackReference {
1455            scope: "accessory",
1456            track_kind: "accessory",
1457            owner_index: Some(acc.slot_index),
1458            document_index: Some(acc.document_accessory_index),
1459            owner_name: Some(acc.name.clone()),
1460            initial_keyframes: 1,
1461            keyframes: acc.keyframes,
1462            initial_keyframes_offset: Some(acc.initial_keyframe.offset),
1463            keyframe_count_offset: Some(acc.keyframe_count_offset),
1464            keyframes_offset: acc.keyframes_offset,
1465            keyframes_end_offset: acc.keyframes_end_offset,
1466            state_offset: Some(acc.state_offset),
1467            state_end_offset: Some(acc.state_end_offset),
1468        });
1469    }
1470    track_references
1471}
1472
1473fn build_project_keyframe_references(
1474    doc: &PmmDocumentSummary,
1475    glob: &PmmDocumentGlobalSummary,
1476) -> Vec<PmmProjectKeyframeReference> {
1477    // Build keyframeReferences inventory derived only from already-decoded summaries.
1478    // This is a graph index slice; no new parsing, no payload bytes duplication.
1479    let mut keyframe_references: Vec<PmmProjectKeyframeReference> = Vec::new();
1480
1481    // per document model: initial + additional for bone, morph, model
1482    for model in &doc.models {
1483        // bone keyframes (initial + additional)
1484        for kf in &model.initial_bone_keyframe_summaries {
1485            keyframe_references.push(make_keyframe_reference(
1486                "model",
1487                "bone",
1488                Some(model.slot_index),
1489                Some(model.document_model_index),
1490                Some(model.name.clone()),
1491                true,
1492                kf.index,
1493                kf.frame_index,
1494                kf.previous_keyframe_index,
1495                kf.next_keyframe_index,
1496                kf.offset,
1497                kf.byte_length,
1498                kf.payload_offset,
1499                kf.payload_byte_length,
1500            ));
1501        }
1502        for kf in &model.bone_keyframe_summaries {
1503            keyframe_references.push(make_keyframe_reference(
1504                "model",
1505                "bone",
1506                Some(model.slot_index),
1507                Some(model.document_model_index),
1508                Some(model.name.clone()),
1509                false,
1510                kf.index,
1511                kf.frame_index,
1512                kf.previous_keyframe_index,
1513                kf.next_keyframe_index,
1514                kf.offset,
1515                kf.byte_length,
1516                kf.payload_offset,
1517                kf.payload_byte_length,
1518            ));
1519        }
1520        // morph keyframes (initial + additional)
1521        for kf in &model.initial_morph_keyframe_summaries {
1522            keyframe_references.push(make_keyframe_reference(
1523                "model",
1524                "morph",
1525                Some(model.slot_index),
1526                Some(model.document_model_index),
1527                Some(model.name.clone()),
1528                true,
1529                kf.index,
1530                kf.frame_index,
1531                kf.previous_keyframe_index,
1532                kf.next_keyframe_index,
1533                kf.offset,
1534                kf.byte_length,
1535                kf.payload_offset,
1536                kf.payload_byte_length,
1537            ));
1538        }
1539        for kf in &model.morph_keyframe_summaries {
1540            keyframe_references.push(make_keyframe_reference(
1541                "model",
1542                "morph",
1543                Some(model.slot_index),
1544                Some(model.document_model_index),
1545                Some(model.name.clone()),
1546                false,
1547                kf.index,
1548                kf.frame_index,
1549                kf.previous_keyframe_index,
1550                kf.next_keyframe_index,
1551                kf.offset,
1552                kf.byte_length,
1553                kf.payload_offset,
1554                kf.payload_byte_length,
1555            ));
1556        }
1557        // model keyframes: initial is singular, additional in vec
1558        keyframe_references.push(make_keyframe_reference(
1559            "model",
1560            "model",
1561            Some(model.slot_index),
1562            Some(model.document_model_index),
1563            Some(model.name.clone()),
1564            true,
1565            model.initial_model_keyframe.index,
1566            model.initial_model_keyframe.frame_index,
1567            model.initial_model_keyframe.previous_keyframe_index,
1568            model.initial_model_keyframe.next_keyframe_index,
1569            model.initial_model_keyframe.offset,
1570            model.initial_model_keyframe.byte_length,
1571            model.initial_model_keyframe.payload_offset,
1572            model.initial_model_keyframe.payload_byte_length,
1573        ));
1574        for kf in &model.model_keyframe_summaries {
1575            keyframe_references.push(make_keyframe_reference(
1576                "model",
1577                "model",
1578                Some(model.slot_index),
1579                Some(model.document_model_index),
1580                Some(model.name.clone()),
1581                false,
1582                kf.index,
1583                kf.frame_index,
1584                kf.previous_keyframe_index,
1585                kf.next_keyframe_index,
1586                kf.offset,
1587                kf.byte_length,
1588                kf.payload_offset,
1589                kf.payload_byte_length,
1590            ));
1591        }
1592    }
1593
1594    // global: initial + additional for camera, light, gravity, selfShadow
1595    // helper to append from PmmDocumentTrackSummary + variant kind (uses existing summaries only)
1596    append_global_keyframe_refs(&mut keyframe_references, &glob.camera, "camera");
1597    append_global_keyframe_refs(&mut keyframe_references, &glob.light, "light");
1598    append_global_keyframe_refs(&mut keyframe_references, &glob.gravity, "gravity");
1599    append_global_keyframe_refs(&mut keyframe_references, &glob.self_shadow, "selfShadow");
1600
1601    // per decoded accessory: initial + additional accessory keyframes
1602    for acc in &glob.accessories.accessories {
1603        keyframe_references.push(make_keyframe_reference(
1604            "accessory",
1605            "accessory",
1606            Some(acc.slot_index),
1607            Some(acc.document_accessory_index),
1608            Some(acc.name.clone()),
1609            true,
1610            acc.initial_keyframe.index,
1611            acc.initial_keyframe.frame_index,
1612            acc.initial_keyframe.previous_keyframe_index,
1613            acc.initial_keyframe.next_keyframe_index,
1614            acc.initial_keyframe.offset,
1615            acc.initial_keyframe.byte_length,
1616            acc.initial_keyframe.payload_offset,
1617            acc.initial_keyframe.payload_byte_length,
1618        ));
1619        for kf in &acc.keyframe_summaries {
1620            keyframe_references.push(make_keyframe_reference(
1621                "accessory",
1622                "accessory",
1623                Some(acc.slot_index),
1624                Some(acc.document_accessory_index),
1625                Some(acc.name.clone()),
1626                false,
1627                kf.index,
1628                kf.frame_index,
1629                kf.previous_keyframe_index,
1630                kf.next_keyframe_index,
1631                kf.offset,
1632                kf.byte_length,
1633                kf.payload_offset,
1634                kf.payload_byte_length,
1635            ));
1636        }
1637    }
1638
1639    keyframe_references
1640}
1641
1642fn build_project_byte_coverage(
1643    doc: &PmmDocumentSummary,
1644    glob: &PmmDocumentGlobalSummary,
1645) -> PmmProjectByteCoverage {
1646    // Build byteCoverage summary derived only from already-decoded range summaries.
1647    // Diagnostic/exporter-prep slice: which top-level decoded sections cover bytes in the PMM.
1648    // Includes: per decoded model, global root, global camera/light/gravity/selfShadow tracks,
1649    // accessory block, and each decoded accessory. No raw bytes, no keyframe-level ranges
1650    // (those are in keyframeReferences).
1651    let mut bc_ranges: Vec<PmmProjectByteRange> = Vec::new();
1652
1653    // each decoded document model
1654    for model in &doc.models {
1655        let bl = model.offset_end - model.offset;
1656        bc_ranges.push(PmmProjectByteRange {
1657            scope: "model",
1658            kind: "model",
1659            owner_index: Some(model.slot_index),
1660            document_index: Some(model.document_model_index),
1661            name: Some(model.name.clone()),
1662            offset: model.offset,
1663            offset_end: model.offset_end,
1664            byte_length: bl,
1665        });
1666    }
1667
1668    // global root (the document global summary span)
1669    {
1670        let off = glob.offset;
1671        let end = glob.offset_end;
1672        bc_ranges.push(PmmProjectByteRange {
1673            scope: "global",
1674            kind: "root",
1675            owner_index: None,
1676            document_index: None,
1677            name: None,
1678            offset: off,
1679            offset_end: end,
1680            byte_length: end - off,
1681        });
1682    }
1683
1684    // global tracks (camera/light/gravity/selfShadow) - use their track.offset..offset_end
1685    bc_ranges.push(make_global_track_byte_range(&glob.camera, "camera"));
1686    bc_ranges.push(make_global_track_byte_range(&glob.light, "light"));
1687    bc_ranges.push(make_global_track_byte_range(&glob.gravity, "gravity"));
1688    bc_ranges.push(make_global_track_byte_range(
1689        &glob.self_shadow,
1690        "selfShadow",
1691    ));
1692
1693    // accessory block
1694    {
1695        let blk = &glob.accessories;
1696        let bl = blk.offset_end - blk.offset;
1697        bc_ranges.push(PmmProjectByteRange {
1698            scope: "global",
1699            kind: "accessoryBlock",
1700            owner_index: None,
1701            document_index: None,
1702            name: None,
1703            offset: blk.offset,
1704            offset_end: blk.offset_end,
1705            byte_length: bl,
1706        });
1707    }
1708
1709    // each decoded accessory
1710    for acc in &glob.accessories.accessories {
1711        let bl = acc.offset_end - acc.offset;
1712        bc_ranges.push(PmmProjectByteRange {
1713            scope: "accessory",
1714            kind: "accessory",
1715            owner_index: Some(acc.slot_index),
1716            document_index: Some(acc.document_accessory_index),
1717            name: Some(acc.name.clone()),
1718            offset: acc.offset,
1719            offset_end: acc.offset_end,
1720            byte_length: bl,
1721        });
1722    }
1723
1724    // Merge overlapping decoded ranges (e.g. global root overlaps global tracks; models/tracks may abut or overlap)
1725    // before summing for covered_byte_length. Compute unknown gaps within the overall span [bc_offset, bc_offset_end].
1726    let mut merged: Vec<(usize, usize)> =
1727        bc_ranges.iter().map(|r| (r.offset, r.offset_end)).collect();
1728    merged.sort_by_key(|&(s, _)| s);
1729    let mut merged_intervals: Vec<(usize, usize)> = Vec::new();
1730    for (start, end) in merged {
1731        if let Some(last) = merged_intervals.last_mut() {
1732            if start <= last.1 {
1733                last.1 = last.1.max(end);
1734            } else {
1735                merged_intervals.push((start, end));
1736            }
1737        } else {
1738            merged_intervals.push((start, end));
1739        }
1740    }
1741    let covered_byte_length: usize = merged_intervals.iter().map(|&(s, e)| e - s).sum();
1742
1743    let bc_offset = bc_ranges.iter().map(|r| r.offset).min().unwrap_or(0);
1744    let bc_offset_end = bc_ranges.iter().map(|r| r.offset_end).max().unwrap_or(0);
1745    let bc_byte_length = bc_offset_end - bc_offset;
1746
1747    let mut gaps: Vec<PmmProjectByteRange> = Vec::new();
1748    let mut cursor = bc_offset;
1749    for (gstart, gend) in &merged_intervals {
1750        if cursor < *gstart {
1751            let goff = cursor;
1752            let gend_ = *gstart;
1753            let glen = gend_ - goff;
1754            gaps.push(PmmProjectByteRange {
1755                scope: "unknown",
1756                kind: "gap",
1757                owner_index: None,
1758                document_index: None,
1759                name: None,
1760                offset: goff,
1761                offset_end: gend_,
1762                byte_length: glen,
1763            });
1764        }
1765        cursor = cursor.max(*gend);
1766    }
1767    if cursor < bc_offset_end {
1768        let goff = cursor;
1769        let gend_ = bc_offset_end;
1770        let glen = gend_ - goff;
1771        gaps.push(PmmProjectByteRange {
1772            scope: "unknown",
1773            kind: "gap",
1774            owner_index: None,
1775            document_index: None,
1776            name: None,
1777            offset: goff,
1778            offset_end: gend_,
1779            byte_length: glen,
1780        });
1781    }
1782
1783    let coverage_ratio = if bc_byte_length == 0 {
1784        1.0
1785    } else {
1786        covered_byte_length as f32 / bc_byte_length as f32
1787    };
1788
1789    PmmProjectByteCoverage {
1790        offset: bc_offset,
1791        offset_end: bc_offset_end,
1792        byte_length: bc_byte_length,
1793        covered_byte_length,
1794        coverage_ratio,
1795        gap_count: gaps.len(),
1796        gaps,
1797        range_count: bc_ranges.len(),
1798        ranges: bc_ranges,
1799    }
1800}
1801
1802#[allow(clippy::too_many_arguments)]
1803fn make_asset_binding(
1804    scope: &'static str,
1805    asset_kind: &'static str,
1806    owner_index: Option<usize>,
1807    document_index: Option<u8>,
1808    owner_name: Option<String>,
1809    path: String,
1810    path_offset: Option<usize>,
1811    asset_reference_index: Option<usize>,
1812    asset_references: &[PmmAssetReference],
1813) -> PmmProjectAssetBinding {
1814    let (ar_offset, ar_end, ar_conf) = match asset_reference_index {
1815        Some(idx) => asset_references.get(idx).map_or((None, None, None), |ar| {
1816            (Some(ar.offset), Some(ar.offset_end), Some(ar.confidence))
1817        }),
1818        None => (None, None, None),
1819    };
1820    PmmProjectAssetBinding {
1821        scope,
1822        asset_kind,
1823        owner_index,
1824        document_index,
1825        owner_name,
1826        path,
1827        path_offset,
1828        asset_reference_index,
1829        asset_reference_offset: ar_offset,
1830        asset_reference_end_offset: ar_end,
1831        asset_reference_confidence: ar_conf,
1832    }
1833}
1834
1835fn build_project_asset_bindings(
1836    doc: &PmmDocumentSummary,
1837    glob: &PmmDocumentGlobalSummary,
1838    asset_references: &[PmmAssetReference],
1839) -> Vec<PmmProjectAssetBinding> {
1840    let mut asset_bindings: Vec<PmmProjectAssetBinding> = Vec::new();
1841
1842    for model in &doc.models {
1843        asset_bindings.push(make_asset_binding(
1844            "model",
1845            "model",
1846            Some(model.slot_index),
1847            Some(model.document_model_index),
1848            Some(model.name.clone()),
1849            model.path.clone(),
1850            Some(model.path_offset),
1851            model.asset_reference_index,
1852            asset_references,
1853        ));
1854    }
1855    for acc in &glob.accessories.accessories {
1856        asset_bindings.push(make_asset_binding(
1857            "accessory",
1858            "accessory",
1859            Some(acc.slot_index),
1860            Some(acc.document_accessory_index),
1861            Some(acc.name.clone()),
1862            acc.path.clone(),
1863            Some(acc.path_offset),
1864            acc.asset_reference_index,
1865            asset_references,
1866        ));
1867    }
1868
1869    let settings = &glob.settings;
1870    let audio_asset_reference_index = if settings.audio_path.is_empty() {
1871        None
1872    } else {
1873        asset_reference_index_for_path(asset_references, "audio", &settings.audio_path)
1874    };
1875    let background_video_asset_reference_index = if settings.background_video_path.is_empty() {
1876        None
1877    } else {
1878        asset_reference_index_for_path(asset_references, "video", &settings.background_video_path)
1879    };
1880    let background_image_asset_reference_index = if settings.background_image_path.is_empty() {
1881        None
1882    } else {
1883        asset_reference_index_for_path(asset_references, "image", &settings.background_image_path)
1884    };
1885
1886    if !settings.audio_path.is_empty() {
1887        asset_bindings.push(make_asset_binding(
1888            "sceneSettings",
1889            "audio",
1890            None,
1891            None,
1892            None,
1893            settings.audio_path.clone(),
1894            Some(settings.audio_path_offset),
1895            audio_asset_reference_index,
1896            asset_references,
1897        ));
1898    }
1899    if !settings.background_video_path.is_empty() {
1900        asset_bindings.push(make_asset_binding(
1901            "sceneSettings",
1902            "video",
1903            None,
1904            None,
1905            None,
1906            settings.background_video_path.clone(),
1907            Some(settings.background_video_path_offset),
1908            background_video_asset_reference_index,
1909            asset_references,
1910        ));
1911    }
1912    if !settings.background_image_path.is_empty() {
1913        asset_bindings.push(make_asset_binding(
1914            "sceneSettings",
1915            "image",
1916            None,
1917            None,
1918            None,
1919            settings.background_image_path.clone(),
1920            Some(settings.background_image_path_offset),
1921            background_image_asset_reference_index,
1922            asset_references,
1923        ));
1924    }
1925
1926    asset_bindings
1927}
1928
1929fn build_project_scene_settings(
1930    glob: &PmmDocumentGlobalSummary,
1931    asset_references: &[PmmAssetReference],
1932) -> PmmProjectSceneSettings {
1933    let settings = &glob.settings;
1934    let audio_asset_reference_index = if settings.audio_path.is_empty() {
1935        None
1936    } else {
1937        asset_reference_index_for_path(asset_references, "audio", &settings.audio_path)
1938    };
1939    let background_video_asset_reference_index = if settings.background_video_path.is_empty() {
1940        None
1941    } else {
1942        asset_reference_index_for_path(asset_references, "video", &settings.background_video_path)
1943    };
1944    let background_image_asset_reference_index = if settings.background_image_path.is_empty() {
1945        None
1946    } else {
1947        asset_reference_index_for_path(asset_references, "image", &settings.background_image_path)
1948    };
1949
1950    PmmProjectSceneSettings {
1951        offset: settings.offset,
1952        offset_end: settings.offset_end,
1953        current_frame_index: settings.current_frame_index,
1954        current_frame_index_in_text_field: settings.current_frame_index_in_text_field,
1955        begin_frame_index_enabled: settings.begin_frame_index_enabled,
1956        end_frame_index_enabled: settings.end_frame_index_enabled,
1957        begin_frame_index: settings.begin_frame_index,
1958        end_frame_index: settings.end_frame_index,
1959        preferred_fps: settings.preferred_fps,
1960        loop_enabled: settings.loop_enabled,
1961        audio_enabled: settings.audio_enabled,
1962        audio_path: settings.audio_path.clone(),
1963        audio_asset_reference_index,
1964        background_video_enabled: settings.background_video_enabled,
1965        background_video_path: settings.background_video_path.clone(),
1966        background_video_asset_reference_index,
1967        background_video_offset: settings.background_video_offset,
1968        background_video_scale_factor: settings.background_video_scale_factor,
1969        background_image_enabled: settings.background_image_enabled,
1970        background_image_path: settings.background_image_path.clone(),
1971        background_image_asset_reference_index,
1972        background_image_offset: settings.background_image_offset,
1973        background_image_scale_factor: settings.background_image_scale_factor,
1974    }
1975}
1976
1977fn build_project_export_readiness(
1978    byte_coverage: &PmmProjectByteCoverage,
1979    asset_bindings: &[PmmProjectAssetBinding],
1980) -> PmmProjectExportReadiness {
1981    let mut blockers: Vec<PmmProjectExportBlocker> = Vec::new();
1982    blockers.push(PmmProjectExportBlocker {
1983        code: "PMM_SEMANTIC_EXPORTER_UNFINISHED",
1984        severity: "blocker",
1985        message: "full semantic graph export/editing is not yet supported".to_string(),
1986        scope: None,
1987        kind: None,
1988        count: None,
1989        coverage_ratio: None,
1990    });
1991    if byte_coverage.gap_count > 0 {
1992        blockers.push(PmmProjectExportBlocker {
1993            code: "PMM_DECODED_BYTE_GAPS_REMAIN",
1994            severity: "warning",
1995            message: format!(
1996                "{} decoded byte gaps remain (coverage ratio {:.4})",
1997                byte_coverage.gap_count, byte_coverage.coverage_ratio
1998            ),
1999            scope: Some("projectGraph"),
2000            kind: Some("byteCoverage"),
2001            count: Some(byte_coverage.gap_count),
2002            coverage_ratio: Some(byte_coverage.coverage_ratio),
2003        });
2004    }
2005    let unresolved_count = asset_bindings
2006        .iter()
2007        .filter(|binding| binding.asset_reference_index.is_none())
2008        .count();
2009    if unresolved_count > 0 {
2010        blockers.push(PmmProjectExportBlocker {
2011            code: "PMM_UNRESOLVED_ASSET_BINDINGS",
2012            severity: "warning",
2013            message: format!(
2014                "{} asset bindings have unresolved asset references",
2015                unresolved_count
2016            ),
2017            scope: Some("projectGraph"),
2018            kind: Some("assetBindings"),
2019            count: Some(unresolved_count),
2020            coverage_ratio: None,
2021        });
2022    }
2023    let blocker_count = blockers.len();
2024    PmmProjectExportReadiness {
2025        lossless_parsed_byte_export_supported: true,
2026        semantic_graph_export_supported: false,
2027        source_byte_preservation_required: true,
2028        blocker_count,
2029        blockers,
2030    }
2031}
2032
2033const PMM_MANIFEST_PREFIX: &[u8] = b"Polygon Movie maker ";
2034
2035fn parse_pmm_manifest_version(data: &[u8]) -> Result<(String, Option<u32>), ImportError> {
2036    if !data.starts_with(PMM_MANIFEST_PREFIX) {
2037        return Err(ImportError::InvalidMagic { format: "PMM" });
2038    }
2039    let version_bytes = &data[PMM_MANIFEST_PREFIX.len()..data.len().min(32)];
2040    let version_end = version_bytes
2041        .iter()
2042        .position(|&byte| byte == 0)
2043        .unwrap_or(version_bytes.len());
2044    let version = String::from_utf8_lossy(&version_bytes[..version_end])
2045        .trim()
2046        .to_owned();
2047    let parsed_version = version.parse::<u32>().ok();
2048    Ok((version, parsed_version))
2049}
2050
2051pub fn parse_pmm_manifest(data: &[u8]) -> Result<PmmParsedManifest, ImportError> {
2052    let (version, parsed_version) = parse_pmm_manifest_version(data)?;
2053    let project_settings = parse_project_settings(data);
2054    let display_state = parse_display_state(data, parsed_version);
2055    let asset_references = extract_asset_references(data);
2056    let model_slot_scan = parse_model_slots_from_header(data, &asset_references, &display_state);
2057    let document_summary = parse_document_summary(data, parsed_version, &asset_references);
2058    let document_global_summary =
2059        parse_document_global_summary(data, parsed_version, &asset_references);
2060    let header_text_entries =
2061        parse_header_text_entries(data, parsed_version, asset_references.first());
2062    let model_assets = scene_assets_by_kind(&asset_references, "model");
2063    let accessory_assets = scene_assets_by_kind(&asset_references, "accessory");
2064    let motion_assets = scene_assets_by_kind(&asset_references, "motion");
2065    let audio_assets = scene_assets_by_kind(&asset_references, "audio");
2066    let image_assets = scene_assets_by_kind(&asset_references, "image");
2067    let video_assets = scene_assets_by_kind(&asset_references, "video");
2068    let timeline = timeline_from_project_settings(&project_settings);
2069    let asset_summary = asset_summary(&asset_references);
2070    let diagnostics = pmm_diagnostics(
2071        &asset_references,
2072        &model_slot_scan.slots,
2073        document_summary.as_ref(),
2074        document_global_summary.as_ref(),
2075        &display_state,
2076        model_slot_scan.stop.as_ref(),
2077        data,
2078    );
2079    let project_graph = match (&document_summary, &document_global_summary) {
2080        (Some(doc), Some(glob)) => {
2081            let track_references = build_project_track_references(doc, glob);
2082            let keyframe_references = build_project_keyframe_references(doc, glob);
2083            let byte_coverage = build_project_byte_coverage(doc, glob);
2084
2085            // Build assetBindings derived only from already-decoded summaries + asset_references.
2086            // Exporter-prep read-only slice: connects owners (model/accessory/sceneSettings) to their asset paths + resolved asset reference metadata.
2087            // No new parsing.
2088            let asset_bindings = build_project_asset_bindings(doc, glob, &asset_references);
2089            let scene_settings = build_project_scene_settings(glob, &asset_references);
2090
2091            // Build exportReadiness immediately before PmmProjectGraph construction (exporter-prep diagnostics only).
2092            // lossless parsed byte supported; semantic graph export remains false (full exporter unfinished).
2093            let export_readiness = build_project_export_readiness(&byte_coverage, &asset_bindings);
2094
2095            Some(PmmProjectGraph {
2096                source: "mmd-anim-format first PMMv2 document graph DTO slice",
2097                version: version.clone(),
2098                parsed_version,
2099                timeline: timeline.clone(),
2100                display_state: display_state.clone(),
2101                asset_summary: asset_summary.clone(),
2102                asset_references: asset_references.clone(),
2103                models: doc.models.clone(),
2104                global: glob.clone(),
2105                model_counts: doc.counts.clone(),
2106                accessory_count: glob.accessories.accessory_count,
2107                accessory_keyframes: glob.accessories.keyframes,
2108                track_references,
2109                keyframe_references,
2110                byte_coverage,
2111                scene_settings,
2112                asset_bindings,
2113                export_readiness,
2114            })
2115        }
2116        _ => None,
2117    };
2118    Ok(PmmParsedManifest {
2119        signature: "Polygon Movie maker",
2120        version,
2121        parsed_version,
2122        byte_length: data.len(),
2123        timeline,
2124        project_settings,
2125        display_state,
2126        header_text_entries,
2127        model_slots: model_slot_scan.slots,
2128        document_summary,
2129        document_global_summary,
2130        project_graph,
2131        asset_summary,
2132        model_assets,
2133        accessory_assets,
2134        motion_assets,
2135        audio_assets,
2136        image_assets,
2137        video_assets,
2138        model_paths: paths_by_kind(&asset_references, "model"),
2139        accessory_paths: paths_by_kind(&asset_references, "accessory"),
2140        motion_paths: paths_by_kind(&asset_references, "motion"),
2141        audio_paths: paths_by_kind(&asset_references, "audio"),
2142        image_paths: paths_by_kind(&asset_references, "image"),
2143        video_paths: paths_by_kind(&asset_references, "video"),
2144        diagnostics,
2145        asset_references,
2146        source_bytes: data.to_vec(),
2147    })
2148}
2149
2150pub fn export_pmm_manifest(manifest: &PmmParsedManifest) -> Vec<u8> {
2151    if !manifest.source_bytes.is_empty() {
2152        return manifest.source_bytes.clone();
2153    }
2154    let mut out = Vec::new();
2155    out.extend_from_slice(b"Polygon Movie maker 0002");
2156    out.resize(30, 0);
2157
2158    push_u32(
2159        &mut out,
2160        manifest.project_settings.screen_width.unwrap_or(640),
2161    );
2162    push_u32(
2163        &mut out,
2164        manifest.project_settings.screen_height.unwrap_or(480),
2165    );
2166    push_u32(
2167        &mut out,
2168        manifest
2169            .project_settings
2170            .timeline_frame_count
2171            .or(manifest.timeline.frame_count)
2172            .unwrap_or(0),
2173    );
2174    push_f32(
2175        &mut out,
2176        manifest.project_settings.frame_rate.unwrap_or(30.0),
2177    );
2178
2179    let mut flags = [0u8; 8];
2180    for (index, flag) in manifest
2181        .display_state
2182        .model_slot_flags
2183        .iter()
2184        .take(8)
2185        .enumerate()
2186    {
2187        flags[index] = *flag;
2188    }
2189    if flags.iter().all(|flag| *flag == 0) && !manifest.model_slots.is_empty() {
2190        for slot in &manifest.model_slots {
2191            if slot.slot_index < flags.len() {
2192                flags[slot.slot_index] = 1;
2193            }
2194        }
2195    }
2196    out.extend_from_slice(&flags);
2197
2198    let document_model_count = manifest
2199        .display_state
2200        .document_model_count
2201        .unwrap_or_else(|| manifest.model_slots.len().min(u8::MAX as usize) as u8);
2202    out.push(document_model_count);
2203    out.push(manifest.display_state.accessory_slot_count.unwrap_or(0));
2204
2205    let mut emitted_paths = Vec::<String>::new();
2206    for slot in &manifest.model_slots {
2207        push_pmm_len_prefixed_sjis(&mut out, &slot.name, &slot.name_bytes);
2208        push_pmm_len_prefixed_sjis(&mut out, &slot.english_name, &slot.english_name_bytes);
2209        push_pmm_sjis_string(&mut out, &slot.model_path, None);
2210        out.push(0);
2211        emitted_paths.push(slot.normalized_path.to_ascii_lowercase());
2212    }
2213
2214    for reference in &manifest.asset_references {
2215        let normalized = reference.normalized_path.to_ascii_lowercase();
2216        if emitted_paths.iter().any(|path| path == &normalized) {
2217            continue;
2218        }
2219        push_pmm_sjis_string(&mut out, &reference.path, None);
2220        out.push(0);
2221        emitted_paths.push(normalized);
2222    }
2223
2224    out
2225}
2226
2227/// Lossless source-byte patch export for PMMv2 document model paths only.
2228/// Clones the manifest's source_bytes and overwrites only the 256-byte fixed path
2229/// region for each specified document_model_index (from the decoded PmmDocumentModelSummary).
2230/// This is a narrow helper for MMD GUI load-smoke model path policy adjustments.
2231/// It does not implement full semantic PMM export (which remains unfinished).
2232pub fn export_pmm_manifest_with_document_model_path_overrides(
2233    manifest: &PmmParsedManifest,
2234    overrides: &[(u8, &str)],
2235) -> Result<Vec<u8>, String> {
2236    if manifest.source_bytes.is_empty() {
2237        return Err(
2238            "parsed source bytes are required for document model path override export".to_owned(),
2239        );
2240    }
2241    let document = manifest.document_summary.as_ref().ok_or_else(|| {
2242        "PMMv2 document summary is required for document model path override export".to_owned()
2243    })?;
2244
2245    let mut patched = manifest.source_bytes.clone();
2246
2247    for &(document_model_index, new_path) in overrides {
2248        let matching: Vec<_> = document
2249            .models
2250            .iter()
2251            .filter(|m| m.document_model_index == document_model_index)
2252            .collect();
2253        if matching.len() != 1 {
2254            return Err(format!(
2255                "exactly one PmmDocumentModelSummary with document_model_index {} is required (found {})",
2256                document_model_index,
2257                matching.len()
2258            ));
2259        }
2260        let model = matching[0];
2261        let start = model.path_offset;
2262        let end = start + 256;
2263        if end > patched.len() {
2264            return Err("document model path field exceeds source byte length".to_owned());
2265        }
2266        let field = &mut patched[start..end];
2267        write_pmm_fixed_sjis_to_slice(field, new_path)?;
2268    }
2269
2270    Ok(patched)
2271}
2272
2273/// Lossless source-byte patch export for decoded scene frame range fields in PMMv2 document settings.
2274/// Clones the manifest's source_bytes and overwrites only the exact byte positions recorded in
2275/// PmmDocumentSettingsSummary (current_frame_index_offset etc). Only fields with Some(...) are patched.
2276/// i32 values are written little-endian; bool as single byte 0/1. Byte length is preserved.
2277/// Requires source bytes and PMMv2 global/document settings summary. This is a narrow exporter-prep
2278/// step and does not implement full semantic PMM export (which remains unfinished).
2279#[derive(Debug, Clone, Default)]
2280pub struct PmmSceneFrameRangePatch {
2281    pub current_frame_index: Option<i32>,
2282    pub current_frame_index_in_text_field: Option<i32>,
2283    pub begin_frame_index_enabled: Option<bool>,
2284    pub end_frame_index_enabled: Option<bool>,
2285    pub begin_frame_index: Option<i32>,
2286    pub end_frame_index: Option<i32>,
2287}
2288
2289pub fn export_pmm_manifest_with_scene_frame_range_patch(
2290    manifest: &PmmParsedManifest,
2291    patch: &PmmSceneFrameRangePatch,
2292) -> Result<Vec<u8>, String> {
2293    if manifest.source_bytes.is_empty() {
2294        return Err(
2295            "parsed source bytes are required for scene frame range patch export".to_owned(),
2296        );
2297    }
2298    let global = manifest.document_global_summary.as_ref().ok_or_else(|| {
2299        "PMMv2 global/document settings summary is required for scene frame range patch export"
2300            .to_owned()
2301    })?;
2302    let settings = &global.settings;
2303
2304    let mut patched = manifest.source_bytes.clone();
2305
2306    if let Some(v) = patch.current_frame_index {
2307        let off = settings.current_frame_index_offset;
2308        if off + 4 > patched.len() {
2309            return Err("current_frame_index field offset exceeds source byte length".to_owned());
2310        }
2311        patched[off..off + 4].copy_from_slice(&v.to_le_bytes());
2312    }
2313    if let Some(v) = patch.current_frame_index_in_text_field {
2314        let off = settings.current_frame_index_in_text_field_offset;
2315        if off + 4 > patched.len() {
2316            return Err(
2317                "current_frame_index_in_text_field field offset exceeds source byte length"
2318                    .to_owned(),
2319            );
2320        }
2321        patched[off..off + 4].copy_from_slice(&v.to_le_bytes());
2322    }
2323    if let Some(v) = patch.begin_frame_index_enabled {
2324        let off = settings.begin_frame_index_enabled_offset;
2325        if off >= patched.len() {
2326            return Err(
2327                "begin_frame_index_enabled field offset exceeds source byte length".to_owned(),
2328            );
2329        }
2330        patched[off] = if v { 1 } else { 0 };
2331    }
2332    if let Some(v) = patch.end_frame_index_enabled {
2333        let off = settings.end_frame_index_enabled_offset;
2334        if off >= patched.len() {
2335            return Err(
2336                "end_frame_index_enabled field offset exceeds source byte length".to_owned(),
2337            );
2338        }
2339        patched[off] = if v { 1 } else { 0 };
2340    }
2341    if let Some(v) = patch.begin_frame_index {
2342        let off = settings.begin_frame_index_offset;
2343        if off + 4 > patched.len() {
2344            return Err("begin_frame_index field offset exceeds source byte length".to_owned());
2345        }
2346        patched[off..off + 4].copy_from_slice(&v.to_le_bytes());
2347    }
2348    if let Some(v) = patch.end_frame_index {
2349        let off = settings.end_frame_index_offset;
2350        if off + 4 > patched.len() {
2351            return Err("end_frame_index field offset exceeds source byte length".to_owned());
2352        }
2353        patched[off..off + 4].copy_from_slice(&v.to_le_bytes());
2354    }
2355
2356    Ok(patched)
2357}
2358
2359/// Lossless source-byte patch export for decoded scene media paths (audio / background video / background image)
2360/// in PMMv2 document settings.
2361/// Clones the manifest's source_bytes and overwrites only the exact 256-byte fixed path regions
2362/// recorded via the new *_path_offset fields in PmmDocumentSettingsSummary. Only fields with Some(...) are patched.
2363/// Uses write_pmm_fixed_sjis_to_slice to preserve 256-byte length and NUL room. Byte length is preserved.
2364/// Requires source bytes and PMMv2 global/document settings summary. This is a narrow exporter-prep
2365/// step and does not implement full semantic PMM export (which remains unfinished).
2366#[derive(Debug, Clone, Default)]
2367pub struct PmmSceneMediaPathPatch {
2368    pub audio_path: Option<String>,
2369    pub background_video_path: Option<String>,
2370    pub background_image_path: Option<String>,
2371}
2372
2373pub fn export_pmm_manifest_with_scene_media_path_patch(
2374    manifest: &PmmParsedManifest,
2375    patch: &PmmSceneMediaPathPatch,
2376) -> Result<Vec<u8>, String> {
2377    if manifest.source_bytes.is_empty() {
2378        return Err(
2379            "parsed source bytes are required for scene media path patch export".to_owned(),
2380        );
2381    }
2382    let global = manifest.document_global_summary.as_ref().ok_or_else(|| {
2383        "PMMv2 global/document settings summary is required for scene media path patch export"
2384            .to_owned()
2385    })?;
2386    let settings = &global.settings;
2387
2388    let mut patched = manifest.source_bytes.clone();
2389
2390    if let Some(ref p) = patch.audio_path {
2391        let off = settings.audio_path_offset;
2392        if off + 256 > patched.len() {
2393            return Err("audio path field offset exceeds source byte length".to_owned());
2394        }
2395        let field = &mut patched[off..off + 256];
2396        write_pmm_fixed_sjis_to_slice(field, p)?;
2397    }
2398    if let Some(ref p) = patch.background_video_path {
2399        let off = settings.background_video_path_offset;
2400        if off + 256 > patched.len() {
2401            return Err("background video path field offset exceeds source byte length".to_owned());
2402        }
2403        let field = &mut patched[off..off + 256];
2404        write_pmm_fixed_sjis_to_slice(field, p)?;
2405    }
2406    if let Some(ref p) = patch.background_image_path {
2407        let off = settings.background_image_path_offset;
2408        if off + 256 > patched.len() {
2409            return Err("background image path field offset exceeds source byte length".to_owned());
2410        }
2411        let field = &mut patched[off..off + 256];
2412        write_pmm_fixed_sjis_to_slice(field, p)?;
2413    }
2414
2415    Ok(patched)
2416}
2417
2418#[derive(Debug, Clone)]
2419pub struct PmmSceneExportOptions {
2420    pub screen_width: u32,
2421    pub screen_height: u32,
2422    pub frame_rate: f32,
2423    pub camera_fov: f32,
2424}
2425
2426impl Default for PmmSceneExportOptions {
2427    fn default() -> Self {
2428        Self {
2429            screen_width: 1024,
2430            screen_height: 1024,
2431            frame_rate: 30.0,
2432            camera_fov: 30.0,
2433        }
2434    }
2435}
2436
2437#[derive(Debug, Clone)]
2438pub struct PmmSceneExportReport {
2439    pub bytes: Vec<u8>,
2440    pub bone_keyframes: usize,
2441    pub morph_keyframes: usize,
2442    pub frame_zero_bone_keyframes: usize,
2443    pub frame_zero_morph_keyframes: usize,
2444    pub skipped_bone_keyframes: usize,
2445    pub skipped_morph_keyframes: usize,
2446    pub max_frame: u32,
2447}
2448
2449#[derive(Debug, Clone)]
2450struct PmmExportBoneKeyframe {
2451    bone_index: usize,
2452    frame: u32,
2453    translation: [f32; 3],
2454    rotation: [f32; 4],
2455    interpolation: [u8; 16],
2456}
2457
2458#[derive(Debug, Clone)]
2459struct PmmExportMorphKeyframe {
2460    morph_index: usize,
2461    frame: u32,
2462    weight: f32,
2463}
2464
2465pub fn export_pmm_scene_from_pmx_vmd(
2466    model: &PmxParsedModel,
2467    motion: &VmdParsedAnimation,
2468    model_path: &str,
2469    options: &PmmSceneExportOptions,
2470) -> PmmSceneExportReport {
2471    let bone_names: Vec<&str> = model
2472        .skeleton
2473        .bones
2474        .iter()
2475        .map(|bone| bone.name.as_str())
2476        .collect();
2477    let morph_names: Vec<&str> = model
2478        .morphs
2479        .iter()
2480        .map(|morph| morph.name.as_str())
2481        .collect();
2482    let bone_indices: HashMap<&str, usize> = bone_names
2483        .iter()
2484        .enumerate()
2485        .map(|(index, name)| (*name, index))
2486        .collect();
2487    let morph_indices: HashMap<&str, usize> = morph_names
2488        .iter()
2489        .enumerate()
2490        .map(|(index, name)| (*name, index))
2491        .collect();
2492
2493    let mut deduped_bones = BTreeMap::<(usize, u32), &VmdParsedBoneFrame>::new();
2494    let mut skipped_bone_keyframes = 0usize;
2495    for frame in &motion.bone_frames {
2496        if let Some(&bone_index) = bone_indices.get(frame.bone_name.as_str()) {
2497            deduped_bones.insert((bone_index, frame.frame), frame);
2498        } else {
2499            skipped_bone_keyframes += 1;
2500        }
2501    }
2502
2503    let mut deduped_morphs = BTreeMap::<(usize, u32), &VmdParsedMorphFrame>::new();
2504    let mut skipped_morph_keyframes = 0usize;
2505    for frame in &motion.morph_frames {
2506        if let Some(&morph_index) = morph_indices.get(frame.morph_name.as_str()) {
2507            deduped_morphs.insert((morph_index, frame.frame), frame);
2508        } else {
2509            skipped_morph_keyframes += 1;
2510        }
2511    }
2512
2513    let mut initial_bones = vec![None::<PmmExportBoneKeyframe>; bone_names.len()];
2514    let mut additional_bones = Vec::<PmmExportBoneKeyframe>::new();
2515    let mut max_frame = 0u32;
2516    for ((bone_index, frame_index), frame) in deduped_bones {
2517        max_frame = max_frame.max(frame_index);
2518        let keyframe = pmm_export_bone_keyframe(bone_index, frame);
2519        if frame_index == 0 {
2520            initial_bones[bone_index] = Some(keyframe);
2521        } else {
2522            additional_bones.push(keyframe);
2523        }
2524    }
2525    additional_bones.sort_by_key(|frame| (frame.bone_index, frame.frame));
2526
2527    let mut initial_morphs = vec![None::<PmmExportMorphKeyframe>; morph_names.len()];
2528    let mut additional_morphs = Vec::<PmmExportMorphKeyframe>::new();
2529    for ((morph_index, frame_index), frame) in deduped_morphs {
2530        max_frame = max_frame.max(frame_index);
2531        let keyframe = PmmExportMorphKeyframe {
2532            morph_index,
2533            frame: frame.frame,
2534            weight: frame.weight,
2535        };
2536        if frame_index == 0 {
2537            initial_morphs[morph_index] = Some(keyframe);
2538        } else {
2539            additional_morphs.push(keyframe);
2540        }
2541    }
2542    additional_morphs.sort_by_key(|frame| (frame.morph_index, frame.frame));
2543
2544    let frame_zero_bone_keyframes = initial_bones.iter().filter(|frame| frame.is_some()).count();
2545    let frame_zero_morph_keyframes = initial_morphs
2546        .iter()
2547        .filter(|frame| frame.is_some())
2548        .count();
2549    let bytes = write_pmm_scene(
2550        model,
2551        model_path,
2552        options,
2553        &bone_names,
2554        &morph_names,
2555        &initial_bones,
2556        &additional_bones,
2557        &initial_morphs,
2558        &additional_morphs,
2559        max_frame,
2560    );
2561
2562    PmmSceneExportReport {
2563        bytes,
2564        bone_keyframes: additional_bones.len(),
2565        morph_keyframes: additional_morphs.len(),
2566        frame_zero_bone_keyframes,
2567        frame_zero_morph_keyframes,
2568        skipped_bone_keyframes,
2569        skipped_morph_keyframes,
2570        max_frame,
2571    }
2572}
2573
2574fn pmm_export_bone_keyframe(
2575    bone_index: usize,
2576    frame: &VmdParsedBoneFrame,
2577) -> PmmExportBoneKeyframe {
2578    PmmExportBoneKeyframe {
2579        bone_index,
2580        frame: frame.frame,
2581        translation: frame.translation,
2582        rotation: frame.rotation,
2583        interpolation: pmm_bone_interpolation_from_vmd(&frame.interpolation),
2584    }
2585}
2586
2587fn pmm_bone_interpolation_from_vmd(interpolation: &[u8]) -> [u8; 16] {
2588    if interpolation.len() < 16 {
2589        return [20; 16];
2590    }
2591    [
2592        interpolation[0],
2593        interpolation[4],
2594        interpolation[8],
2595        interpolation[12],
2596        interpolation[1],
2597        interpolation[5],
2598        interpolation[9],
2599        interpolation[13],
2600        interpolation[2],
2601        interpolation[6],
2602        interpolation[10],
2603        interpolation[14],
2604        interpolation[3],
2605        interpolation[7],
2606        interpolation[11],
2607        interpolation[15],
2608    ]
2609}
2610
2611/// Max byte length MMD keeps for a bone/morph name (20-byte NUL-terminated buffer).
2612const PMM_MODEL_NAME_MAX_BYTES: usize = 19;
2613
2614/// Normalizes a runtime bone or morph name to the content PMM stores.
2615///
2616/// PMM uses a 20-byte Shift-JIS buffer with one byte reserved for the NUL
2617/// terminator. Prefix fitting is performed on encoded characters so a
2618/// two-byte Shift-JIS character is never split.
2619pub fn normalize_pmm_model_item_name(name: &str) -> String {
2620    decode_sjis(&encode_sjis_prefix_fit(name, PMM_MODEL_NAME_MAX_BYTES))
2621}
2622
2623struct PmmExportModelStructure {
2624    fixed_tracks: u8,
2625    constraint_bone_indices: Vec<i32>,
2626    outside_parent_subject_bone_indices: Vec<i32>,
2627}
2628
2629fn pmm_export_model_structure_from_pmx(model: &PmxParsedModel) -> PmmExportModelStructure {
2630    let constraint_bone_indices = model
2631        .skeleton
2632        .bones
2633        .iter()
2634        .enumerate()
2635        .filter(|(_, bone)| bone.flags.ik)
2636        .map(|(index, _)| index as i32)
2637        .collect();
2638    let outside_parent_subject_bone_indices = std::iter::once(-1)
2639        .chain(
2640            model
2641                .skeleton
2642                .bones
2643                .iter()
2644                .enumerate()
2645                .filter(|(_, bone)| pmm_is_outside_parent_subject_bone(bone))
2646                .map(|(index, _)| index as i32),
2647        )
2648        .collect();
2649    let fixed_tracks = if model.display_frames.is_empty() {
2650        0
2651    } else {
2652        (model.display_frames.len() + 1).min(255) as u8
2653    };
2654    PmmExportModelStructure {
2655        fixed_tracks,
2656        constraint_bone_indices,
2657        outside_parent_subject_bone_indices,
2658    }
2659}
2660
2661fn pmm_is_outside_parent_subject_bone(bone: &crate::pmx::PmxParsedBone) -> bool {
2662    bone.flags.ik || (bone.flags.visible && bone.flags.translatable)
2663}
2664
2665fn push_document_outside_parent_current_state(out: &mut Vec<u8>) {
2666    push_i32(out, 0);
2667    push_i32(out, 0);
2668    push_i32(out, -1);
2669    push_i32(out, 0);
2670}
2671
2672#[allow(clippy::too_many_arguments)]
2673fn write_pmm_scene(
2674    model: &PmxParsedModel,
2675    model_path: &str,
2676    options: &PmmSceneExportOptions,
2677    bone_names: &[&str],
2678    morph_names: &[&str],
2679    initial_bones: &[Option<PmmExportBoneKeyframe>],
2680    additional_bones: &[PmmExportBoneKeyframe],
2681    initial_morphs: &[Option<PmmExportMorphKeyframe>],
2682    additional_morphs: &[PmmExportMorphKeyframe],
2683    max_frame: u32,
2684) -> Vec<u8> {
2685    let model_structure = pmm_export_model_structure_from_pmx(model);
2686    let constraint_count = model_structure.constraint_bone_indices.len();
2687    let outside_parent_count = model_structure.outside_parent_subject_bone_indices.len();
2688    let constraint_states = vec![true; constraint_count];
2689    let outside_parent_keyframe_indices = vec![(-1, 0); outside_parent_count];
2690
2691    let mut out = b"Polygon Movie maker 0002".to_vec();
2692    out.resize(30, 0);
2693    push_u32(&mut out, options.screen_width);
2694    push_u32(&mut out, options.screen_height);
2695    push_u32(&mut out, max_frame);
2696    push_f32(&mut out, options.frame_rate);
2697    out.extend_from_slice(&[0, 1, 1, 1, 1, 1, 1]);
2698    out.push(0);
2699    out.push(1);
2700
2701    out.push(0);
2702    push_pmm_len_prefixed_sjis(&mut out, &model.metadata.name, &[]);
2703    push_pmm_len_prefixed_sjis(&mut out, &model.metadata.english_name, &[]);
2704    push_pmm_fixed_sjis(&mut out, model_path, 256);
2705    out.push(model_structure.fixed_tracks);
2706
2707    // MMD stores bone/morph names in a 20-byte NUL-terminated buffer, so it caps
2708    // each name at 19 content bytes (on a char boundary) when it loads the PMX. The
2709    // PMM must carry the same truncated names or MMD reports "モデル構造が異なります".
2710    push_i32(&mut out, bone_names.len() as i32);
2711    for name in bone_names {
2712        let fitted = encode_sjis_prefix_fit(name, PMM_MODEL_NAME_MAX_BYTES);
2713        push_pmm_len_prefixed_sjis(&mut out, name, &fitted);
2714    }
2715    push_i32(&mut out, morph_names.len() as i32);
2716    for name in morph_names {
2717        let fitted = encode_sjis_prefix_fit(name, PMM_MODEL_NAME_MAX_BYTES);
2718        push_pmm_len_prefixed_sjis(&mut out, name, &fitted);
2719    }
2720
2721    push_i32(&mut out, constraint_count as i32);
2722    for index in &model_structure.constraint_bone_indices {
2723        push_i32(&mut out, *index);
2724    }
2725    push_i32(&mut out, outside_parent_count as i32);
2726    for index in &model_structure.outside_parent_subject_bone_indices {
2727        push_i32(&mut out, *index);
2728    }
2729    out.push(1); // draw order index (1-based; MMD treats 0 as invalid)
2730    out.push(1);
2731    push_i32(&mut out, -1);
2732    for _ in 0..4 {
2733        push_i32(&mut out, -1);
2734    }
2735    out.push(0);
2736    push_i32(&mut out, 0);
2737    push_i32(&mut out, max_frame as i32);
2738
2739    let bone_next =
2740        next_keyframe_indices(bone_names.len(), bone_names.len(), additional_bones, |f| {
2741            f.bone_index
2742        });
2743    for bone_index in 0..bone_names.len() {
2744        let fallback = PmmExportBoneKeyframe {
2745            bone_index,
2746            frame: 0,
2747            translation: [0.0; 3],
2748            rotation: [0.0, 0.0, 0.0, 1.0],
2749            interpolation: [20; 16],
2750        };
2751        let frame = initial_bones[bone_index].as_ref().unwrap_or(&fallback);
2752        push_document_bone_keyframe(
2753            &mut out,
2754            None,
2755            0,
2756            0,
2757            bone_next[bone_index],
2758            frame.interpolation,
2759            frame.translation,
2760            frame.rotation,
2761        );
2762    }
2763    push_i32(&mut out, additional_bones.len() as i32);
2764    for (offset, frame) in additional_bones.iter().enumerate() {
2765        let index = bone_names.len() + offset;
2766        let previous = previous_keyframe_index(
2767            additional_bones,
2768            offset,
2769            frame.bone_index,
2770            bone_names.len(),
2771            |f| f.bone_index,
2772        );
2773        let next = next_keyframe_index(
2774            additional_bones,
2775            offset,
2776            frame.bone_index,
2777            bone_names.len(),
2778            |f| f.bone_index,
2779        );
2780        push_document_bone_keyframe(
2781            &mut out,
2782            Some(index as i32),
2783            frame.frame as i32,
2784            previous,
2785            next,
2786            frame.interpolation,
2787            frame.translation,
2788            frame.rotation,
2789        );
2790    }
2791
2792    let morph_next = next_keyframe_indices(
2793        morph_names.len(),
2794        morph_names.len(),
2795        additional_morphs,
2796        |f| f.morph_index,
2797    );
2798    for morph_index in 0..morph_names.len() {
2799        let weight = initial_morphs[morph_index]
2800            .as_ref()
2801            .map(|frame| frame.weight)
2802            .unwrap_or(0.0);
2803        push_document_morph_keyframe(&mut out, None, 0, 0, morph_next[morph_index], weight);
2804    }
2805    push_i32(&mut out, additional_morphs.len() as i32);
2806    for (offset, frame) in additional_morphs.iter().enumerate() {
2807        let index = morph_names.len() + offset;
2808        let previous = previous_keyframe_index(
2809            additional_morphs,
2810            offset,
2811            frame.morph_index,
2812            morph_names.len(),
2813            |f| f.morph_index,
2814        );
2815        let next = next_keyframe_index(
2816            additional_morphs,
2817            offset,
2818            frame.morph_index,
2819            morph_names.len(),
2820            |f| f.morph_index,
2821        );
2822        push_document_morph_keyframe(
2823            &mut out,
2824            Some(index as i32),
2825            frame.frame as i32,
2826            previous,
2827            next,
2828            frame.weight,
2829        );
2830    }
2831
2832    push_document_model_keyframe(
2833        &mut out,
2834        None,
2835        0,
2836        0,
2837        0,
2838        true,
2839        &constraint_states,
2840        &outside_parent_keyframe_indices,
2841        false,
2842    );
2843    push_i32(&mut out, 0);
2844
2845    for _ in bone_names {
2846        for value in [0.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0] {
2847            push_f32(&mut out, value);
2848        }
2849        out.push(0);
2850        out.push(0);
2851        out.push(0);
2852    }
2853    for _ in morph_names {
2854        push_f32(&mut out, 0.0);
2855    }
2856
2857    // Constraint (IK) current states: all enabled.
2858    out.resize(out.len() + constraint_count, 1);
2859    for _ in 0..outside_parent_count {
2860        push_document_outside_parent_current_state(&mut out);
2861    }
2862
2863    out.push(0);
2864    push_f32(&mut out, 1.0);
2865    out.push(0);
2866    out.push(1); // transform order index (1-based, single model)
2867
2868    write_pmm_global_tail(&mut out, max_frame, options.camera_fov);
2869    push_pmm_sjis_string(&mut out, model_path, None);
2870    out.push(0);
2871    out
2872}
2873
2874fn next_keyframe_indices<T>(
2875    count: usize,
2876    initial_count: usize,
2877    frames: &[T],
2878    object_index: impl Fn(&T) -> usize,
2879) -> Vec<i32> {
2880    let mut next = vec![0; count];
2881    for (offset, frame) in frames.iter().enumerate() {
2882        let target = object_index(frame);
2883        if target < count && next[target] == 0 {
2884            next[target] = (initial_count + offset) as i32;
2885        }
2886    }
2887    next
2888}
2889
2890fn previous_keyframe_index<T>(
2891    frames: &[T],
2892    offset: usize,
2893    object_index: usize,
2894    initial_count: usize,
2895    frame_object_index: impl Fn(&T) -> usize,
2896) -> i32 {
2897    if offset > 0 && frame_object_index(&frames[offset - 1]) == object_index {
2898        (initial_count + offset - 1) as i32
2899    } else {
2900        object_index as i32
2901    }
2902}
2903
2904fn next_keyframe_index<T>(
2905    frames: &[T],
2906    offset: usize,
2907    object_index: usize,
2908    initial_count: usize,
2909    frame_object_index: impl Fn(&T) -> usize,
2910) -> i32 {
2911    if offset + 1 < frames.len() && frame_object_index(&frames[offset + 1]) == object_index {
2912        (initial_count + offset + 1) as i32
2913    } else {
2914        0
2915    }
2916}
2917
2918fn push_pmm_fixed_sjis(out: &mut Vec<u8>, text: &str, length: usize) {
2919    let encoded = encode_sjis(text);
2920    let mut bytes = vec![0u8; length];
2921    let copy_len = encoded.len().min(length);
2922    bytes[..copy_len].copy_from_slice(&encoded[..copy_len]);
2923    out.extend_from_slice(&bytes);
2924}
2925
2926/// Write Shift-JIS encoded bytes (with zero padding) into an existing fixed-size slice.
2927/// Leaves room for a trailing NUL if encoded length < slice length.
2928/// Intended for patching document model path fields in source bytes.
2929fn write_pmm_fixed_sjis_to_slice(dest: &mut [u8], text: &str) -> Result<(), String> {
2930    let encoded = encode_sjis(text);
2931    if encoded.len() >= dest.len() {
2932        return Err("encoded Shift-JIS path must leave room for NUL terminator".to_owned());
2933    }
2934    dest.fill(0);
2935    let n = encoded.len();
2936    dest[..n].copy_from_slice(&encoded[..n]);
2937    Ok(())
2938}
2939
2940fn push_empty_pmm_path(out: &mut Vec<u8>) {
2941    out.extend_from_slice(&[0u8; 256]);
2942}
2943
2944#[allow(clippy::too_many_arguments)]
2945fn push_document_bone_keyframe(
2946    out: &mut Vec<u8>,
2947    index: Option<i32>,
2948    frame: i32,
2949    previous: i32,
2950    next: i32,
2951    interpolation: [u8; 16],
2952    translation: [f32; 3],
2953    rotation: [f32; 4],
2954) {
2955    if let Some(index) = index {
2956        push_i32(out, index);
2957    }
2958    push_i32(out, frame);
2959    push_i32(out, previous);
2960    push_i32(out, next);
2961    out.extend_from_slice(&interpolation);
2962    for value in translation {
2963        push_f32(out, value);
2964    }
2965    for value in rotation {
2966        push_f32(out, value);
2967    }
2968    out.push(0);
2969    out.push(0);
2970}
2971
2972fn push_document_morph_keyframe(
2973    out: &mut Vec<u8>,
2974    index: Option<i32>,
2975    frame: i32,
2976    previous: i32,
2977    next: i32,
2978    weight: f32,
2979) {
2980    if let Some(index) = index {
2981        push_i32(out, index);
2982    }
2983    push_i32(out, frame);
2984    push_i32(out, previous);
2985    push_i32(out, next);
2986    push_f32(out, weight);
2987    out.push(0);
2988}
2989
2990#[allow(clippy::too_many_arguments)]
2991fn push_document_model_keyframe(
2992    out: &mut Vec<u8>,
2993    index: Option<i32>,
2994    frame: i32,
2995    previous: i32,
2996    next: i32,
2997    visible: bool,
2998    constraint_states: &[bool],
2999    outside_parent_indices: &[(i32, i32)],
3000    self_shadow_enabled: bool,
3001) {
3002    if let Some(index) = index {
3003        push_i32(out, index);
3004    }
3005    push_i32(out, frame);
3006    push_i32(out, previous);
3007    push_i32(out, next);
3008    out.push(u8::from(visible));
3009    for &enabled in constraint_states {
3010        out.push(u8::from(enabled));
3011    }
3012    for &(parent_model_index, parent_model_bone_index) in outside_parent_indices {
3013        push_i32(out, parent_model_index);
3014        push_i32(out, parent_model_bone_index);
3015    }
3016    out.push(u8::from(self_shadow_enabled));
3017}
3018
3019fn push_document_base_keyframe(
3020    out: &mut Vec<u8>,
3021    index: Option<i32>,
3022    frame: i32,
3023    previous: i32,
3024    next: i32,
3025) {
3026    if let Some(index) = index {
3027        push_i32(out, index);
3028    }
3029    push_i32(out, frame);
3030    push_i32(out, previous);
3031    push_i32(out, next);
3032}
3033
3034fn push_document_camera_keyframe(out: &mut Vec<u8>, index: Option<i32>, selected: bool, fov: i32) {
3035    push_document_base_keyframe(out, index, 0, 0, 0);
3036    push_f32(out, 45.0);
3037    out.extend_from_slice(&[0u8; 12]);
3038    out.extend_from_slice(&[0u8; 12]);
3039    push_i32(out, -1);
3040    push_i32(out, -1);
3041    out.extend_from_slice(&[20u8; 24]);
3042    out.push(0);
3043    push_i32(out, fov);
3044    out.push(u8::from(selected));
3045}
3046
3047fn push_document_light_keyframe(out: &mut Vec<u8>, index: Option<i32>, selected: bool) {
3048    push_document_base_keyframe(out, index, 0, 0, 0);
3049    for value in [0.602f32, 0.602, 0.602, -0.5, -1.0, 0.5] {
3050        push_f32(out, value);
3051    }
3052    out.push(u8::from(selected));
3053}
3054
3055fn push_document_gravity_keyframe(out: &mut Vec<u8>, index: Option<i32>, selected: bool) {
3056    push_document_base_keyframe(out, index, 0, 0, 0);
3057    out.push(0);
3058    push_i32(out, 10);
3059    push_f32(out, 9.8);
3060    for value in [0.0f32, -1.0, 0.0] {
3061        push_f32(out, value);
3062    }
3063    out.push(u8::from(selected));
3064}
3065
3066fn push_document_self_shadow_keyframe(out: &mut Vec<u8>, index: Option<i32>, selected: bool) {
3067    push_document_base_keyframe(out, index, 0, 0, 0);
3068    out.push(1);
3069    push_f32(out, 0.01125);
3070    out.push(u8::from(selected));
3071}
3072
3073fn write_pmm_global_tail(out: &mut Vec<u8>, max_frame: u32, camera_fov: f32) {
3074    let camera_fov = camera_fov.round().clamp(1.0, i32::MAX as f32) as i32;
3075    push_document_camera_keyframe(out, None, true, camera_fov);
3076    push_i32(out, 0);
3077    out.extend_from_slice(&[0u8; 12 * 3]);
3078    out.push(0);
3079
3080    push_document_light_keyframe(out, None, true);
3081    push_i32(out, 0);
3082    for value in [0.602f32, 0.602, 0.602, -0.5, -1.0, 0.5] {
3083        push_f32(out, value);
3084    }
3085
3086    out.push(0);
3087    push_i32(out, 0);
3088    out.push(0);
3089
3090    push_i32(out, 0);
3091    push_i32(out, 0);
3092    push_i32(out, max_frame as i32);
3093    push_i32(out, 0);
3094    out.push(0);
3095    out.push(0);
3096    out.push(0);
3097    out.push(0);
3098    push_i32(out, 500);
3099    push_i32(out, 0);
3100    out.push(0);
3101    push_empty_pmm_path(out);
3102    push_i32(out, 0);
3103    push_i32(out, 0);
3104    push_f32(out, 0.0);
3105    push_empty_pmm_path(out);
3106    push_i32(out, 0);
3107    push_i32(out, 0);
3108    push_i32(out, 0);
3109    push_f32(out, 1.0);
3110    push_empty_pmm_path(out);
3111    out.push(0);
3112    out.push(0);
3113    out.push(1);
3114    out.push(1);
3115    push_f32(out, 60.0);
3116    push_i32(out, 0);
3117    push_i32(out, 1);
3118    push_f32(out, 1.0);
3119    out.push(1);
3120    out.push(2);
3121
3122    push_f32(out, 9.8);
3123    push_i32(out, 10);
3124    for value in [0.0f32, -1.0, 0.0] {
3125        push_f32(out, value);
3126    }
3127    out.push(0);
3128    push_document_gravity_keyframe(out, None, true);
3129    push_i32(out, 0);
3130
3131    out.push(1);
3132    push_f32(out, 0.01125);
3133    push_document_self_shadow_keyframe(out, None, true);
3134    push_i32(out, 0);
3135
3136    push_i32(out, 0);
3137    push_i32(out, 0);
3138    push_i32(out, 0);
3139    out.push(0);
3140    push_i32(out, -1);
3141    push_i32(out, -1);
3142    for value in [
3143        1.0f32, 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,
3144    ] {
3145        push_f32(out, value);
3146    }
3147    out.push(0);
3148    out.push(0);
3149    out.push(1);
3150    push_i32(out, 0);
3151    out.push(1);
3152    out.push(0);
3153    push_i32(out, 0);
3154}
3155
3156fn push_pmm_len_prefixed_sjis(out: &mut Vec<u8>, text: &str, original_bytes: &[u8]) {
3157    let bytes = if !original_bytes.is_empty() {
3158        original_bytes.to_vec()
3159    } else {
3160        encode_sjis(text)
3161    };
3162    let length = bytes.len().min(u8::MAX as usize);
3163    out.push(length as u8);
3164    out.extend_from_slice(&bytes[..length]);
3165}
3166
3167fn push_pmm_sjis_string(out: &mut Vec<u8>, text: &str, original_bytes: Option<&[u8]>) {
3168    if let Some(bytes) = original_bytes
3169        && !bytes.is_empty()
3170    {
3171        out.extend_from_slice(bytes);
3172        return;
3173    }
3174    out.extend_from_slice(&encode_sjis(text));
3175}
3176
3177fn timeline_from_project_settings(settings: &PmmProjectSettings) -> PmmTimeline {
3178    let duration_seconds = settings
3179        .timeline_frame_count
3180        .zip(settings.frame_rate)
3181        .and_then(|(frame_count, frame_rate)| {
3182            if frame_rate > 0.0 {
3183                Some(frame_count as f32 / frame_rate)
3184            } else {
3185                None
3186            }
3187        });
3188    PmmTimeline {
3189        start_frame: settings.timeline_frame_count.map(|_| 0),
3190        end_frame_exclusive: settings.timeline_frame_count,
3191        frame_count: settings.timeline_frame_count,
3192        frame_rate: settings.frame_rate,
3193        duration_seconds,
3194    }
3195}
3196
3197fn parse_project_settings(data: &[u8]) -> PmmProjectSettings {
3198    let screen_width = read_u32_at(data, 30).filter(|value| (1..=16_384).contains(value));
3199    let screen_height = read_u32_at(data, 34).filter(|value| (1..=16_384).contains(value));
3200    let timeline_frame_count = read_u32_at(data, 38).filter(|value| *value <= 10_000_000);
3201    let frame_rate =
3202        read_f32_at(data, 42).filter(|value| value.is_finite() && *value > 0.0 && *value <= 1000.0);
3203
3204    PmmProjectSettings {
3205        screen_width,
3206        screen_height,
3207        timeline_frame_count,
3208        frame_rate,
3209    }
3210}
3211
3212fn active_slot_indices(flags: &[u8]) -> Vec<usize> {
3213    flags
3214        .iter()
3215        .enumerate()
3216        .filter(|&(_, f)| *f != 0)
3217        .map(|(i, _)| i)
3218        .collect()
3219}
3220
3221fn empty_slot_indices(flags: &[u8]) -> Vec<usize> {
3222    flags
3223        .iter()
3224        .enumerate()
3225        .filter(|&(_, f)| *f == 0)
3226        .map(|(i, _)| i)
3227        .collect()
3228}
3229
3230fn slot_flag_counts(flags: &[u8]) -> BTreeMap<u8, usize> {
3231    let mut counts: BTreeMap<u8, usize> = BTreeMap::new();
3232    for &flag in flags {
3233        *counts.entry(flag).or_insert(0) += 1;
3234    }
3235    counts
3236}
3237
3238fn slot_flag_entries(flags: &[u8]) -> Vec<PmmModelSlotFlagEntry> {
3239    flags
3240        .iter()
3241        .enumerate()
3242        .map(|(slot_index, flag)| PmmModelSlotFlagEntry {
3243            slot_index,
3244            flag: *flag,
3245            active: *flag != 0,
3246        })
3247        .collect()
3248}
3249
3250fn document_expand_flags(flags: &[u8]) -> Option<PmmDocumentExpandFlags> {
3251    Some(PmmDocumentExpandFlags {
3252        editing_cla: *flags.first()? != 0,
3253        camera_panel: *flags.get(1)? != 0,
3254        light_panel: *flags.get(2)? != 0,
3255        accessory_panel: *flags.get(3)? != 0,
3256        bone_panel: *flags.get(4)? != 0,
3257        morph_panel: *flags.get(5)? != 0,
3258        self_shadow_panel: *flags.get(6)? != 0,
3259    })
3260}
3261
3262fn parse_display_state(data: &[u8], parsed_version: Option<u32>) -> PmmDisplayState {
3263    if parsed_version == Some(1) {
3264        if let (Some(model_count), Some(accessory_count)) =
3265            (data.get(52).copied(), data.get(53).copied())
3266            && model_count <= 64
3267            && accessory_count <= 64
3268        {
3269            return PmmDisplayState {
3270                layout: "pmm-v1-counts",
3271                model_slot_flags: Vec::new(),
3272                model_slot_flag_entries: Vec::new(),
3273                document_expand_flags: None,
3274                selected_model_index: None,
3275                document_model_count: None,
3276                declared_model_slot_count: Some(model_count),
3277                model_slot_count: model_count as usize,
3278                non_zero_model_slot_count: model_count as usize,
3279                accessory_slot_count: Some(accessory_count),
3280                active_model_slot_indices: Vec::new(),
3281                empty_model_slot_indices: Vec::new(),
3282                model_slot_flag_counts: BTreeMap::new(),
3283            };
3284        }
3285        return unknown_display_state();
3286    }
3287
3288    let model_slot_flags = data
3289        .get(46..54)
3290        .filter(|bytes| bytes.iter().all(|byte| *byte <= 2))
3291        .map(|bytes| bytes.to_vec());
3292    let accessory_slot_count = None;
3293    let document_expand_flags = model_slot_flags
3294        .as_ref()
3295        .and_then(|flags| document_expand_flags(flags));
3296    let selected_model_index = model_slot_flags
3297        .as_ref()
3298        .and_then(|_| data.get(53).copied());
3299    let document_model_count = model_slot_flags
3300        .as_ref()
3301        .and_then(|_| data.get(54).copied());
3302
3303    let model_slot_flags = model_slot_flags.unwrap_or_default();
3304    let model_slot_count = model_slot_flags.len();
3305    let non_zero_model_slot_count = model_slot_flags.iter().filter(|flag| **flag != 0).count();
3306    let active_model_slot_indices = active_slot_indices(&model_slot_flags);
3307    let empty_model_slot_indices = empty_slot_indices(&model_slot_flags);
3308    let model_slot_flag_counts = slot_flag_counts(&model_slot_flags);
3309    let model_slot_flag_entries = slot_flag_entries(&model_slot_flags);
3310    PmmDisplayState {
3311        layout: if model_slot_flags.is_empty() {
3312            "unknown"
3313        } else {
3314            "pmm-v2-flags"
3315        },
3316        declared_model_slot_count: if model_slot_flags.is_empty() {
3317            None
3318        } else {
3319            Some(model_slot_count as u8)
3320        },
3321        model_slot_count,
3322        non_zero_model_slot_count,
3323        model_slot_flags,
3324        model_slot_flag_entries,
3325        document_expand_flags,
3326        selected_model_index,
3327        document_model_count,
3328        accessory_slot_count,
3329        active_model_slot_indices,
3330        empty_model_slot_indices,
3331        model_slot_flag_counts,
3332    }
3333}
3334
3335fn unknown_display_state() -> PmmDisplayState {
3336    PmmDisplayState {
3337        layout: "unknown",
3338        model_slot_flags: Vec::new(),
3339        model_slot_flag_entries: Vec::new(),
3340        document_expand_flags: None,
3341        selected_model_index: None,
3342        document_model_count: None,
3343        declared_model_slot_count: None,
3344        model_slot_count: 0,
3345        non_zero_model_slot_count: 0,
3346        accessory_slot_count: None,
3347        active_model_slot_indices: Vec::new(),
3348        empty_model_slot_indices: Vec::new(),
3349        model_slot_flag_counts: BTreeMap::new(),
3350    }
3351}
3352
3353fn parse_header_text_entries(
3354    data: &[u8],
3355    parsed_version: Option<u32>,
3356    first_reference: Option<&PmmAssetReference>,
3357) -> Vec<PmmHeaderTextEntry> {
3358    if parsed_version != Some(1) {
3359        return Vec::new();
3360    }
3361    let start = 54usize;
3362    let Some(first_reference) = first_reference else {
3363        return Vec::new();
3364    };
3365    let end = first_reference.offset.min(data.len());
3366    if end <= start {
3367        return Vec::new();
3368    }
3369
3370    let mut entries = Vec::new();
3371    let mut chunk_start = start;
3372    for index in start..end {
3373        if data[index] != 0 {
3374            continue;
3375        }
3376        if index > chunk_start {
3377            let bytes = &data[chunk_start..index];
3378            let text = decode_sjis(bytes).trim().to_owned();
3379            if !text.is_empty() {
3380                entries.push(PmmHeaderTextEntry {
3381                    index: entries.len(),
3382                    offset: chunk_start,
3383                    offset_end: index,
3384                    text,
3385                    text_bytes: bytes.to_vec(),
3386                });
3387            }
3388        }
3389        chunk_start = index + 1;
3390    }
3391    entries
3392}
3393
3394fn read_u32_at(data: &[u8], offset: usize) -> Option<u32> {
3395    data.get(offset..offset + 4)
3396        .and_then(|bytes| bytes.try_into().ok())
3397        .map(u32::from_le_bytes)
3398}
3399
3400fn read_f32_at(data: &[u8], offset: usize) -> Option<f32> {
3401    data.get(offset..offset + 4)
3402        .and_then(|bytes| bytes.try_into().ok())
3403        .map(f32::from_le_bytes)
3404}
3405
3406struct PmmDocumentCursor<'a> {
3407    inner: ByteReader<'a>,
3408}
3409
3410impl<'a> PmmDocumentCursor<'a> {
3411    fn new(data: &'a [u8]) -> Self {
3412        Self {
3413            inner: ByteReader::new(data),
3414        }
3415    }
3416
3417    fn offset(&self) -> usize {
3418        self.inner.pos
3419    }
3420
3421    fn data(&self) -> &'a [u8] {
3422        self.inner.data
3423    }
3424
3425    fn read_u8(&mut self) -> Option<u8> {
3426        self.inner.read_u8().ok()
3427    }
3428
3429    fn read_bool(&mut self) -> Option<bool> {
3430        Some(self.read_u8()? != 0)
3431    }
3432
3433    fn read_i32(&mut self) -> Option<i32> {
3434        self.inner.read_i32_le().ok()
3435    }
3436
3437    fn read_f32(&mut self) -> Option<f32> {
3438        self.inner.read_f32_le().ok()
3439    }
3440
3441    fn read_f32x3(&mut self) -> Option<[f32; 3]> {
3442        Some([self.read_f32()?, self.read_f32()?, self.read_f32()?])
3443    }
3444
3445    fn read_f32x4(&mut self) -> Option<[f32; 4]> {
3446        Some([
3447            self.read_f32()?,
3448            self.read_f32()?,
3449            self.read_f32()?,
3450            self.read_f32()?,
3451        ])
3452    }
3453
3454    fn read_bytes16(&mut self) -> Option<[u8; 16]> {
3455        self.inner.read_bytes(16).ok()?.try_into().ok()
3456    }
3457
3458    fn read_bytes24(&mut self) -> Option<[u8; 24]> {
3459        self.inner.read_bytes(24).ok()?.try_into().ok()
3460    }
3461
3462    fn read_variable_string(&mut self) -> Option<String> {
3463        let length = self.read_u8()? as usize;
3464        let bytes = self.inner.read_bytes(length).ok()?;
3465        Some(decode_shift_jis(bytes))
3466    }
3467
3468    fn read_fixed_string(&mut self, length: usize) -> Option<String> {
3469        let bytes = self.inner.read_bytes(length).ok()?;
3470        let end = bytes
3471            .iter()
3472            .position(|byte| *byte == 0)
3473            .unwrap_or(bytes.len());
3474        Some(decode_shift_jis(&bytes[..end]))
3475    }
3476
3477    fn skip(&mut self, length: usize) -> Option<()> {
3478        self.inner.skip(length).ok()
3479    }
3480}
3481
3482fn parse_document_summary(
3483    data: &[u8],
3484    parsed_version: Option<u32>,
3485    references: &[PmmAssetReference],
3486) -> Option<PmmDocumentSummary> {
3487    if parsed_version != Some(2) {
3488        return None;
3489    }
3490    let mut cursor = PmmDocumentCursor::new(data);
3491    cursor.skip(30)?;
3492    cursor.read_i32()?;
3493    cursor.read_i32()?;
3494    cursor.read_i32()?;
3495    cursor.read_f32()?;
3496    cursor.skip(7)?;
3497    let selected_model_index = cursor.read_u8()?;
3498    let model_count = cursor.read_u8()? as usize;
3499    if model_count == 0 || selected_model_index as usize >= model_count {
3500        return None;
3501    }
3502
3503    let mut models = Vec::with_capacity(model_count);
3504    for slot_index in 0..model_count {
3505        models.push(read_document_model_summary(
3506            &mut cursor,
3507            slot_index,
3508            references,
3509        )?);
3510    }
3511    let counts = summarize_document_counts(&models);
3512
3513    Some(PmmDocumentSummary {
3514        source: "nanoem/ext/document.c PMMv2 layout",
3515        selected_model_index,
3516        model_count,
3517        counts,
3518        models,
3519    })
3520}
3521
3522fn parse_document_global_summary(
3523    data: &[u8],
3524    parsed_version: Option<u32>,
3525    references: &[PmmAssetReference],
3526) -> Option<PmmDocumentGlobalSummary> {
3527    if parsed_version != Some(2) {
3528        return None;
3529    }
3530    let mut cursor = PmmDocumentCursor::new(data);
3531    cursor.skip(30)?;
3532    cursor.read_i32()?;
3533    cursor.read_i32()?;
3534    cursor.read_i32()?;
3535    cursor.read_f32()?;
3536    cursor.skip(7)?;
3537    let selected_model_index = cursor.read_u8()?;
3538    let model_count = cursor.read_u8()? as usize;
3539    if model_count == 0 || selected_model_index as usize >= model_count {
3540        return None;
3541    }
3542    for slot_index in 0..model_count {
3543        read_document_model_summary(&mut cursor, slot_index, references)?;
3544    }
3545
3546    let offset = cursor.offset();
3547    let camera = read_document_camera_summary(&mut cursor)?;
3548    let light = read_document_light_summary(&mut cursor)?;
3549    let accessories = read_document_accessory_block_summary(&mut cursor, references)?;
3550    let settings_before_gravity = read_document_settings_before_gravity(&mut cursor)?;
3551    let gravity = read_document_gravity_summary(&mut cursor)?;
3552    let self_shadow = read_document_self_shadow_summary(&mut cursor)?;
3553    let settings =
3554        finish_document_settings_summary(settings_before_gravity, &mut cursor, model_count)?;
3555    let offset_end = cursor.offset();
3556
3557    Some(PmmDocumentGlobalSummary {
3558        source: "nanoem/ext/document.c PMMv2 global layout",
3559        offset,
3560        offset_end,
3561        camera,
3562        light,
3563        accessories,
3564        settings,
3565        gravity,
3566        self_shadow,
3567    })
3568}
3569
3570fn read_document_model_summary(
3571    cursor: &mut PmmDocumentCursor<'_>,
3572    slot_index: usize,
3573    references: &[PmmAssetReference],
3574) -> Option<PmmDocumentModelSummary> {
3575    const PMM_PATH_BYTE_LENGTH: usize = 256;
3576    let offset = cursor.offset();
3577    let document_model_index = cursor.read_u8()?;
3578    let name = cursor.read_variable_string()?;
3579    let english_name = cursor.read_variable_string()?;
3580    let path_offset = cursor.offset();
3581    let path = cursor.read_fixed_string(PMM_PATH_BYTE_LENGTH)?;
3582    let asset_reference_index = asset_reference_index_for_path(references, "model", &path);
3583    cursor.read_u8()?;
3584    let bone_count = usize_from_i32(cursor.read_i32()?)?;
3585    let mut bone_names = Vec::new();
3586    for _ in 0..bone_count {
3587        bone_names.push(cursor.read_variable_string()?);
3588    }
3589    let morph_count = usize_from_i32(cursor.read_i32()?)?;
3590    let mut morph_names = Vec::new();
3591    for _ in 0..morph_count {
3592        morph_names.push(cursor.read_variable_string()?);
3593    }
3594    let constraint_bone_count = usize_from_i32(cursor.read_i32()?)?;
3595    cursor.skip(constraint_bone_count.checked_mul(4)?)?;
3596    let outside_parent_subject_bone_count = usize_from_i32(cursor.read_i32()?)?;
3597    cursor.skip(outside_parent_subject_bone_count.checked_mul(4)?)?;
3598    let draw_order_index = cursor.read_u8()?;
3599    let visible = cursor.read_bool()?;
3600    let selected_bone_index = cursor.read_i32()?;
3601    let selected_morph_indices = [
3602        cursor.read_i32()?,
3603        cursor.read_i32()?,
3604        cursor.read_i32()?,
3605        cursor.read_i32()?,
3606    ];
3607    let expansion_state_count = cursor.read_u8()? as usize;
3608    cursor.skip(expansion_state_count)?;
3609    let vertical_scroll = cursor.read_i32()?;
3610    let last_frame_index = cursor.read_i32()?;
3611
3612    let initial_bone_keyframes_offset = cursor.offset();
3613    let initial_bone_keyframes = bone_count;
3614    let mut initial_bone_keyframe_summaries = Vec::with_capacity(initial_bone_keyframes);
3615    for _ in 0..initial_bone_keyframes {
3616        initial_bone_keyframe_summaries.push(read_document_bone_keyframe(cursor, false)?);
3617    }
3618    let bone_keyframe_count_offset = cursor.offset();
3619    let bone_keyframes = usize_from_i32(cursor.read_i32()?)?;
3620    let bone_keyframes_offset = cursor.offset();
3621    let mut bone_keyframe_summaries = Vec::with_capacity(bone_keyframes);
3622    for _ in 0..bone_keyframes {
3623        bone_keyframe_summaries.push(read_document_bone_keyframe(cursor, true)?);
3624    }
3625    let bone_keyframes_end_offset = cursor.offset();
3626
3627    let initial_morph_keyframes_offset = cursor.offset();
3628    let initial_morph_keyframes = morph_count;
3629    let mut initial_morph_keyframe_summaries = Vec::with_capacity(initial_morph_keyframes);
3630    for _ in 0..initial_morph_keyframes {
3631        initial_morph_keyframe_summaries.push(read_document_morph_keyframe(cursor, false)?);
3632    }
3633    let morph_keyframe_count_offset = cursor.offset();
3634    let morph_keyframes = usize_from_i32(cursor.read_i32()?)?;
3635    let morph_keyframes_offset = cursor.offset();
3636    let mut morph_keyframe_summaries = Vec::with_capacity(morph_keyframes);
3637    for _ in 0..morph_keyframes {
3638        morph_keyframe_summaries.push(read_document_morph_keyframe(cursor, true)?);
3639    }
3640    let morph_keyframes_end_offset = cursor.offset();
3641
3642    let initial_model_keyframes = 1;
3643    let initial_model_keyframe_offset = cursor.offset();
3644    let initial_model_keyframe = read_document_model_keyframe(
3645        cursor,
3646        false,
3647        constraint_bone_count,
3648        outside_parent_subject_bone_count,
3649    )?;
3650    let model_keyframe_count_offset = cursor.offset();
3651    let model_keyframes = usize_from_i32(cursor.read_i32()?)?;
3652    let model_keyframes_offset = cursor.offset();
3653    let mut model_keyframe_summaries = Vec::with_capacity(model_keyframes);
3654    for _ in 0..model_keyframes {
3655        model_keyframe_summaries.push(read_document_model_keyframe(
3656            cursor,
3657            true,
3658            constraint_bone_count,
3659            outside_parent_subject_bone_count,
3660        )?);
3661    }
3662    let model_keyframes_end_offset = cursor.offset();
3663
3664    let bone_states_offset = cursor.offset();
3665    let mut bone_state_summaries = Vec::with_capacity(bone_count);
3666    for _ in 0..bone_count {
3667        bone_state_summaries.push(read_document_bone_state(cursor)?);
3668    }
3669    let morph_states_offset = cursor.offset();
3670    let mut morph_state_summaries = Vec::with_capacity(morph_count);
3671    for _ in 0..morph_count {
3672        morph_state_summaries.push(read_document_morph_state(cursor)?);
3673    }
3674    let constraint_states_offset = cursor.offset();
3675    let mut constraint_state_summaries = Vec::with_capacity(constraint_bone_count);
3676    for _ in 0..constraint_bone_count {
3677        constraint_state_summaries.push(read_document_constraint_state(cursor)?);
3678    }
3679    let outside_parent_states_offset = cursor.offset();
3680    let mut outside_parent_state_summaries = Vec::with_capacity(outside_parent_subject_bone_count);
3681    for _ in 0..outside_parent_subject_bone_count {
3682        outside_parent_state_summaries.push(read_document_outside_parent_state(cursor)?);
3683    }
3684
3685    let blend_enabled = cursor.read_bool()?;
3686    let edge_width = cursor.read_f32()?;
3687    let self_shadow_enabled = cursor.read_bool()?;
3688    let transform_order_index = cursor.read_u8()?;
3689    let offset_end = cursor.offset();
3690
3691    Some(PmmDocumentModelSummary {
3692        slot_index,
3693        document_model_index,
3694        offset,
3695        offset_end,
3696        path_offset,
3697        name,
3698        english_name,
3699        path,
3700        asset_reference_index,
3701        bone_count,
3702        bone_names,
3703        morph_count,
3704        morph_names,
3705        constraint_bone_count,
3706        outside_parent_subject_bone_count,
3707        draw_order_index,
3708        transform_order_index,
3709        selected_bone_index,
3710        selected_morph_indices,
3711        vertical_scroll,
3712        sections: PmmDocumentModelSections {
3713            initial_bone_keyframes_offset,
3714            bone_keyframe_count_offset,
3715            bone_keyframes_offset,
3716            bone_keyframes_end_offset,
3717            initial_morph_keyframes_offset,
3718            morph_keyframe_count_offset,
3719            morph_keyframes_offset,
3720            morph_keyframes_end_offset,
3721            initial_model_keyframe_offset,
3722            model_keyframe_count_offset,
3723            model_keyframes_offset,
3724            model_keyframes_end_offset,
3725            bone_states_offset,
3726            morph_states_offset,
3727            constraint_states_offset,
3728            outside_parent_states_offset,
3729        },
3730        initial_bone_keyframes,
3731        initial_bone_keyframe_summaries,
3732        bone_keyframes,
3733        bone_keyframe_summaries,
3734        initial_morph_keyframes,
3735        initial_morph_keyframe_summaries,
3736        morph_keyframes,
3737        morph_keyframe_summaries,
3738        initial_model_keyframes,
3739        model_keyframes,
3740        initial_model_keyframe,
3741        model_keyframe_summaries,
3742        last_frame_index,
3743        visible,
3744        blend_enabled,
3745        edge_width,
3746        self_shadow_enabled,
3747        bone_state_summaries,
3748        morph_state_summaries,
3749        constraint_state_summaries,
3750        outside_parent_state_summaries,
3751    })
3752}
3753
3754fn read_document_bone_keyframe(
3755    cursor: &mut PmmDocumentCursor<'_>,
3756    include_index: bool,
3757) -> Option<PmmDocumentBoneKeyframeSummary> {
3758    let offset = cursor.offset();
3759    let base = read_document_base_keyframe(cursor, include_index)?;
3760    let payload_offset = cursor.offset();
3761    let interpolation = cursor.read_bytes16()?;
3762    let translation = cursor.read_f32x3()?;
3763    let orientation = cursor.read_f32x4()?;
3764    let physics_disabled = cursor.read_bool()?;
3765    let selected = cursor.read_bool()?;
3766    let byte_length = cursor.offset() - offset;
3767    let payload_byte_length = cursor.offset() - payload_offset;
3768    let payload_bytes = cursor.data().get(payload_offset..cursor.offset())?.to_vec();
3769    Some(PmmDocumentBoneKeyframeSummary {
3770        index: base.index,
3771        frame_index: base.frame_index,
3772        previous_keyframe_index: base.previous_keyframe_index,
3773        next_keyframe_index: base.next_keyframe_index,
3774        interpolation,
3775        translation,
3776        orientation,
3777        physics_disabled,
3778        selected,
3779        offset,
3780        byte_length,
3781        payload_offset,
3782        payload_byte_length,
3783        payload_bytes,
3784    })
3785}
3786
3787fn read_document_morph_keyframe(
3788    cursor: &mut PmmDocumentCursor<'_>,
3789    include_index: bool,
3790) -> Option<PmmDocumentMorphKeyframeSummary> {
3791    let offset = cursor.offset();
3792    let base = read_document_base_keyframe(cursor, include_index)?;
3793    let payload_offset = cursor.offset();
3794    let weight = cursor.read_f32()?;
3795    let selected = cursor.read_bool()?;
3796    let byte_length = cursor.offset() - offset;
3797    let payload_byte_length = cursor.offset() - payload_offset;
3798    let payload_bytes = cursor.data().get(payload_offset..cursor.offset())?.to_vec();
3799    Some(PmmDocumentMorphKeyframeSummary {
3800        index: base.index,
3801        frame_index: base.frame_index,
3802        previous_keyframe_index: base.previous_keyframe_index,
3803        next_keyframe_index: base.next_keyframe_index,
3804        weight,
3805        selected,
3806        offset,
3807        byte_length,
3808        payload_offset,
3809        payload_byte_length,
3810        payload_bytes,
3811    })
3812}
3813
3814fn read_document_model_keyframe(
3815    cursor: &mut PmmDocumentCursor<'_>,
3816    include_index: bool,
3817    constraint_bone_count: usize,
3818    outside_parent_subject_bone_count: usize,
3819) -> Option<PmmDocumentModelKeyframeSummary> {
3820    let offset = cursor.offset();
3821    let base = read_document_base_keyframe(cursor, include_index)?;
3822    let payload_offset = cursor.offset();
3823    let visible_offset = cursor.offset();
3824    let visible = cursor.read_bool()?;
3825    let constraint_states_offset = cursor.offset();
3826    let mut constraint_states = Vec::with_capacity(constraint_bone_count);
3827    for _ in 0..constraint_bone_count {
3828        constraint_states.push(cursor.read_bool()?);
3829    }
3830    let constraint_states_byte_length = cursor.offset() - constraint_states_offset;
3831    let outside_parent_indices_offset = cursor.offset();
3832    let mut outside_parent_indices = Vec::with_capacity(outside_parent_subject_bone_count);
3833    for _ in 0..outside_parent_subject_bone_count {
3834        outside_parent_indices.push(PmmDocumentOutsideParentIndexSummary {
3835            parent_model_index: cursor.read_i32()?,
3836            parent_model_bone_index: cursor.read_i32()?,
3837        });
3838    }
3839    let outside_parent_indices_byte_length = cursor.offset() - outside_parent_indices_offset;
3840    let self_shadow_enabled_offset = cursor.offset();
3841    let self_shadow_enabled = cursor.read_bool()?;
3842    let byte_length = cursor.offset() - offset;
3843    let payload_byte_length = cursor.offset() - payload_offset;
3844    let payload_bytes = cursor.data().get(payload_offset..cursor.offset())?.to_vec();
3845    let constraint_state_count = constraint_bone_count;
3846    let outside_parent_index_count = outside_parent_subject_bone_count;
3847    Some(PmmDocumentModelKeyframeSummary {
3848        index: base.index,
3849        frame_index: base.frame_index,
3850        previous_keyframe_index: base.previous_keyframe_index,
3851        next_keyframe_index: base.next_keyframe_index,
3852        visible,
3853        constraint_states,
3854        outside_parent_indices,
3855        self_shadow_enabled,
3856        visible_offset,
3857        constraint_state_count,
3858        constraint_states_offset,
3859        constraint_states_byte_length,
3860        outside_parent_index_count,
3861        outside_parent_indices_offset,
3862        outside_parent_indices_byte_length,
3863        self_shadow_enabled_offset,
3864        offset,
3865        byte_length,
3866        payload_offset,
3867        payload_byte_length,
3868        payload_bytes,
3869    })
3870}
3871
3872fn read_document_camera_summary(
3873    cursor: &mut PmmDocumentCursor<'_>,
3874) -> Option<PmmDocumentTrackSummary> {
3875    let offset = cursor.offset();
3876    let initial_keyframe = read_document_camera_keyframe(cursor, false)?;
3877    let keyframe_count_offset = cursor.offset();
3878    let keyframes = usize_from_i32(cursor.read_i32()?)?;
3879    let keyframes_offset = cursor.offset();
3880    let mut keyframe_summaries = Vec::with_capacity(keyframes);
3881    for _ in 0..keyframes {
3882        keyframe_summaries.push(read_document_camera_keyframe(cursor, true)?);
3883    }
3884    let keyframes_end_offset = cursor.offset();
3885    let state_offset = cursor.offset();
3886    cursor.skip(12 * 3)?;
3887    cursor.read_bool()?;
3888    let state_end_offset = cursor.offset();
3889    Some(PmmDocumentTrackSummary {
3890        offset,
3891        offset_end: state_end_offset,
3892        initial_keyframes: 1,
3893        keyframes,
3894        initial_keyframe: Some(initial_keyframe),
3895        keyframe_summaries,
3896        keyframe_count_offset,
3897        keyframes_offset,
3898        keyframes_end_offset,
3899        state_offset: Some(state_offset),
3900        state_end_offset: Some(state_end_offset),
3901    })
3902}
3903
3904fn read_document_camera_keyframe(
3905    cursor: &mut PmmDocumentCursor<'_>,
3906    include_index: bool,
3907) -> Option<PmmDocumentKeyframeSummary> {
3908    let offset = cursor.offset();
3909    let base = read_document_base_keyframe(cursor, include_index)?;
3910    let payload_offset = cursor.offset();
3911    let distance_offset = cursor.offset();
3912    let distance = cursor.read_f32()?;
3913    let look_at_offset = cursor.offset();
3914    let look_at = cursor.read_f32x3()?;
3915    let look_at_byte_length = cursor.offset() - look_at_offset;
3916    let angle_offset = cursor.offset();
3917    let angle = cursor.read_f32x3()?;
3918    let angle_byte_length = cursor.offset() - angle_offset;
3919    let parent_model_index_offset = cursor.offset();
3920    let parent_model_index = cursor.read_i32()?;
3921    let parent_model_bone_index_offset = cursor.offset();
3922    let parent_model_bone_index = cursor.read_i32()?;
3923    let interpolation_offset = cursor.offset();
3924    let interpolation = cursor.read_bytes24()?;
3925    let interpolation_byte_length = cursor.offset() - interpolation_offset;
3926    let perspective_view_offset = cursor.offset();
3927    let perspective_view = !cursor.read_bool()?;
3928    let fov_offset = cursor.offset();
3929    let fov = cursor.read_i32()?;
3930    let selected_offset = cursor.offset();
3931    let selected = cursor.read_bool()?;
3932    let byte_length = cursor.offset() - offset;
3933    let payload_byte_length = cursor.offset() - payload_offset;
3934    let payload_bytes = cursor.data().get(payload_offset..cursor.offset())?.to_vec();
3935    Some(PmmDocumentKeyframeSummary::Camera {
3936        index: base.index,
3937        frame_index: base.frame_index,
3938        previous_keyframe_index: base.previous_keyframe_index,
3939        next_keyframe_index: base.next_keyframe_index,
3940        distance,
3941        look_at,
3942        angle,
3943        parent_model_index,
3944        parent_model_bone_index,
3945        interpolation,
3946        perspective_view,
3947        fov,
3948        selected,
3949        distance_offset,
3950        look_at_offset,
3951        look_at_byte_length,
3952        angle_offset,
3953        angle_byte_length,
3954        parent_model_index_offset,
3955        parent_model_bone_index_offset,
3956        interpolation_offset,
3957        interpolation_byte_length,
3958        perspective_view_offset,
3959        fov_offset,
3960        selected_offset,
3961        offset,
3962        byte_length,
3963        payload_offset,
3964        payload_byte_length,
3965        payload_bytes,
3966    })
3967}
3968
3969fn read_document_light_summary(
3970    cursor: &mut PmmDocumentCursor<'_>,
3971) -> Option<PmmDocumentTrackSummary> {
3972    let offset = cursor.offset();
3973    let initial_keyframe = read_document_light_keyframe(cursor, false)?;
3974    let keyframe_count_offset = cursor.offset();
3975    let keyframes = usize_from_i32(cursor.read_i32()?)?;
3976    let keyframes_offset = cursor.offset();
3977    let mut keyframe_summaries = Vec::with_capacity(keyframes);
3978    for _ in 0..keyframes {
3979        keyframe_summaries.push(read_document_light_keyframe(cursor, true)?);
3980    }
3981    let keyframes_end_offset = cursor.offset();
3982    let state_offset = cursor.offset();
3983    cursor.skip(12)?;
3984    cursor.skip(12)?;
3985    let state_end_offset = cursor.offset();
3986    Some(PmmDocumentTrackSummary {
3987        offset,
3988        offset_end: state_end_offset,
3989        initial_keyframes: 1,
3990        keyframes,
3991        initial_keyframe: Some(initial_keyframe),
3992        keyframe_summaries,
3993        keyframe_count_offset,
3994        keyframes_offset,
3995        keyframes_end_offset,
3996        state_offset: Some(state_offset),
3997        state_end_offset: Some(state_end_offset),
3998    })
3999}
4000
4001fn read_document_light_keyframe(
4002    cursor: &mut PmmDocumentCursor<'_>,
4003    include_index: bool,
4004) -> Option<PmmDocumentKeyframeSummary> {
4005    let offset = cursor.offset();
4006    let base = read_document_base_keyframe(cursor, include_index)?;
4007    let payload_offset = cursor.offset();
4008    let color_offset = cursor.offset();
4009    let color = cursor.read_f32x3()?;
4010    let color_byte_length = cursor.offset() - color_offset;
4011    let direction_offset = cursor.offset();
4012    let direction = cursor.read_f32x3()?;
4013    let direction_byte_length = cursor.offset() - direction_offset;
4014    let selected_offset = cursor.offset();
4015    let selected = cursor.read_bool()?;
4016    let byte_length = cursor.offset() - offset;
4017    let payload_byte_length = cursor.offset() - payload_offset;
4018    let payload_bytes = cursor.data().get(payload_offset..cursor.offset())?.to_vec();
4019    Some(PmmDocumentKeyframeSummary::Light {
4020        index: base.index,
4021        frame_index: base.frame_index,
4022        previous_keyframe_index: base.previous_keyframe_index,
4023        next_keyframe_index: base.next_keyframe_index,
4024        color,
4025        direction,
4026        selected,
4027        color_offset,
4028        color_byte_length,
4029        direction_offset,
4030        direction_byte_length,
4031        selected_offset,
4032        offset,
4033        byte_length,
4034        payload_offset,
4035        payload_byte_length,
4036        payload_bytes,
4037    })
4038}
4039
4040fn read_document_accessory_block_summary(
4041    cursor: &mut PmmDocumentCursor<'_>,
4042    references: &[PmmAssetReference],
4043) -> Option<PmmDocumentAccessoryBlockSummary> {
4044    let offset = cursor.offset();
4045    let selected_accessory_index = cursor.read_u8()?;
4046    let horizontal_scroll = cursor.read_i32()?;
4047    let accessory_count = cursor.read_u8()? as usize;
4048    let mut accessories = Vec::with_capacity(accessory_count);
4049    for slot_index in 0..accessory_count {
4050        accessories.push(read_document_accessory_summary(
4051            cursor, slot_index, references,
4052        )?);
4053    }
4054    let offset_end = cursor.offset();
4055    let keyframes = accessories
4056        .iter()
4057        .map(|accessory| accessory.keyframes)
4058        .sum();
4059    Some(PmmDocumentAccessoryBlockSummary {
4060        offset,
4061        offset_end,
4062        selected_accessory_index,
4063        horizontal_scroll,
4064        accessory_count,
4065        keyframes,
4066        accessories,
4067    })
4068}
4069
4070fn read_document_accessory_summary(
4071    cursor: &mut PmmDocumentCursor<'_>,
4072    slot_index: usize,
4073    references: &[PmmAssetReference],
4074) -> Option<PmmDocumentAccessorySummary> {
4075    const PMM_ACCESSORY_NAME_BYTE_LENGTH: usize = 100;
4076    const PMM_PATH_BYTE_LENGTH: usize = 256;
4077    let offset = cursor.offset();
4078    let document_accessory_index = cursor.read_u8()?;
4079    let name = cursor.read_fixed_string(PMM_ACCESSORY_NAME_BYTE_LENGTH)?;
4080    let path_offset = cursor.offset();
4081    let path = cursor.read_fixed_string(PMM_PATH_BYTE_LENGTH)?;
4082    let asset_reference_index = asset_reference_index_for_path(references, "accessory", &path);
4083    let draw_order_index = cursor.read_u8()?;
4084    let initial_keyframe = read_document_accessory_keyframe(cursor, false)?;
4085    let keyframe_count_offset = cursor.offset();
4086    let keyframes = usize_from_i32(cursor.read_i32()?)?;
4087    let keyframes_offset = cursor.offset();
4088    let mut keyframe_summaries = Vec::with_capacity(keyframes);
4089    for _ in 0..keyframes {
4090        keyframe_summaries.push(read_document_accessory_keyframe(cursor, true)?);
4091    }
4092    let keyframes_end_offset = cursor.offset();
4093
4094    let state_offset = cursor.offset();
4095    let (opacity, visible) = unpack_document_accessory_opacity_and_visible(cursor.read_u8()?);
4096    let parent_model_index = cursor.read_i32()?;
4097    let parent_model_bone_index = cursor.read_i32()?;
4098    cursor.skip(12)?;
4099    let scale_factor = cursor.read_f32()?;
4100    cursor.skip(12)?;
4101    let shadow_enabled = cursor.read_bool()?;
4102    let add_blend_enabled = cursor.read_bool()?;
4103    let state_end_offset = cursor.offset();
4104    let offset_end = cursor.offset();
4105
4106    Some(PmmDocumentAccessorySummary {
4107        slot_index,
4108        document_accessory_index,
4109        offset,
4110        offset_end,
4111        path_offset,
4112        name,
4113        path,
4114        asset_reference_index,
4115        draw_order_index,
4116        keyframes,
4117        initial_keyframe,
4118        keyframe_summaries,
4119        keyframe_count_offset,
4120        keyframes_offset,
4121        keyframes_end_offset,
4122        state_offset,
4123        state_end_offset,
4124        visible,
4125        opacity,
4126        parent_model_index,
4127        parent_model_bone_index,
4128        scale_factor,
4129        shadow_enabled,
4130        add_blend_enabled,
4131    })
4132}
4133
4134fn read_document_accessory_keyframe(
4135    cursor: &mut PmmDocumentCursor<'_>,
4136    include_index: bool,
4137) -> Option<PmmDocumentAccessoryKeyframeSummary> {
4138    let offset = cursor.offset();
4139    let base = read_document_base_keyframe(cursor, include_index)?;
4140    let payload_offset = cursor.offset();
4141    let packed_opacity_visible_offset = cursor.offset();
4142    let (opacity, visible) = unpack_document_accessory_opacity_and_visible(cursor.read_u8()?);
4143    let parent_model_index_offset = cursor.offset();
4144    let parent_model_index = cursor.read_i32()?;
4145    let parent_model_bone_index_offset = cursor.offset();
4146    let parent_model_bone_index = cursor.read_i32()?;
4147    let translation_offset = cursor.offset();
4148    let translation = cursor.read_f32x3()?;
4149    let translation_byte_length = cursor.offset() - translation_offset;
4150    let orientation_offset = cursor.offset();
4151    let orientation = cursor.read_f32x3()?;
4152    let orientation_byte_length = cursor.offset() - orientation_offset;
4153    let scale_factor_offset = cursor.offset();
4154    let scale_factor = cursor.read_f32()?;
4155    let shadow_enabled_offset = cursor.offset();
4156    let shadow_enabled = cursor.read_bool()?;
4157    let selected_offset = cursor.offset();
4158    let selected = cursor.read_bool()?;
4159    let byte_length = cursor.offset() - offset;
4160    let payload_byte_length = cursor.offset() - payload_offset;
4161    let payload_bytes = cursor.data().get(payload_offset..cursor.offset())?.to_vec();
4162    Some(PmmDocumentAccessoryKeyframeSummary {
4163        index: base.index,
4164        frame_index: base.frame_index,
4165        previous_keyframe_index: base.previous_keyframe_index,
4166        next_keyframe_index: base.next_keyframe_index,
4167        visible,
4168        opacity,
4169        parent_model_index,
4170        parent_model_bone_index,
4171        translation,
4172        orientation,
4173        scale_factor,
4174        shadow_enabled,
4175        selected,
4176        packed_opacity_visible_offset,
4177        parent_model_index_offset,
4178        parent_model_bone_index_offset,
4179        translation_offset,
4180        translation_byte_length,
4181        orientation_offset,
4182        orientation_byte_length,
4183        scale_factor_offset,
4184        shadow_enabled_offset,
4185        selected_offset,
4186        offset,
4187        byte_length,
4188        payload_offset,
4189        payload_byte_length,
4190        payload_bytes,
4191    })
4192}
4193
4194struct PmmDocumentSettingsBeforeGravity {
4195    offset: usize,
4196    current_frame_index: i32,
4197    current_frame_index_offset: usize,
4198    horizontal_scroll: i32,
4199    horizontal_scroll_thumb: i32,
4200    editing_mode: i32,
4201    camera_look_mode: u8,
4202    loop_enabled: bool,
4203    begin_frame_index_enabled: bool,
4204    begin_frame_index_enabled_offset: usize,
4205    end_frame_index_enabled: bool,
4206    end_frame_index_enabled_offset: usize,
4207    begin_frame_index: i32,
4208    begin_frame_index_offset: usize,
4209    end_frame_index: i32,
4210    end_frame_index_offset: usize,
4211    audio_enabled: bool,
4212    audio_path: String,
4213    audio_path_offset: usize,
4214    background_video_offset: [i32; 2],
4215    background_video_scale_factor: f32,
4216    background_video_path: String,
4217    background_video_path_offset: usize,
4218    background_video_enabled: bool,
4219    background_image_offset: [i32; 2],
4220    background_image_scale_factor: f32,
4221    background_image_path: String,
4222    background_image_path_offset: usize,
4223    background_image_enabled: bool,
4224    information_shown: bool,
4225    grid_and_axis_shown: bool,
4226    ground_shadow_shown: bool,
4227    preferred_fps: f32,
4228    screen_capture_mode: i32,
4229    accessory_index_after_models: i32,
4230    ground_shadow_brightness: f32,
4231    translucent_ground_shadow_enabled: bool,
4232    physics_simulation_mode: u8,
4233}
4234
4235fn read_document_settings_before_gravity(
4236    cursor: &mut PmmDocumentCursor<'_>,
4237) -> Option<PmmDocumentSettingsBeforeGravity> {
4238    const PMM_PATH_BYTE_LENGTH: usize = 256;
4239    let offset = cursor.offset();
4240    let current_frame_index_offset = cursor.offset();
4241    let current_frame_index = cursor.read_i32()?;
4242    let horizontal_scroll = cursor.read_i32()?;
4243    let horizontal_scroll_thumb = cursor.read_i32()?;
4244    let editing_mode = cursor.read_i32()?;
4245    let camera_look_mode = cursor.read_u8()?;
4246    let loop_enabled = cursor.read_bool()?;
4247    let begin_frame_index_enabled_offset = cursor.offset();
4248    let begin_frame_index_enabled = cursor.read_bool()?;
4249    let end_frame_index_enabled_offset = cursor.offset();
4250    let end_frame_index_enabled = cursor.read_bool()?;
4251    let begin_frame_index_offset = cursor.offset();
4252    let begin_frame_index = cursor.read_i32()?;
4253    let end_frame_index_offset = cursor.offset();
4254    let end_frame_index = cursor.read_i32()?;
4255    let audio_enabled = cursor.read_bool()?;
4256    let audio_path_offset = cursor.offset();
4257    let audio_path = cursor.read_fixed_string(PMM_PATH_BYTE_LENGTH)?;
4258    let background_video_offset = [cursor.read_i32()?, cursor.read_i32()?];
4259    let background_video_scale_factor = cursor.read_f32()?;
4260    let background_video_path_offset = cursor.offset();
4261    let background_video_path = cursor.read_fixed_string(PMM_PATH_BYTE_LENGTH)?;
4262    let background_video_enabled = cursor.read_i32()? != 0;
4263    let background_image_offset = [cursor.read_i32()?, cursor.read_i32()?];
4264    let background_image_scale_factor = cursor.read_f32()?;
4265    let background_image_path_offset = cursor.offset();
4266    let background_image_path = cursor.read_fixed_string(PMM_PATH_BYTE_LENGTH)?;
4267    let background_image_enabled = cursor.read_bool()?;
4268    let information_shown = cursor.read_bool()?;
4269    let grid_and_axis_shown = cursor.read_bool()?;
4270    let ground_shadow_shown = cursor.read_bool()?;
4271    let preferred_fps = cursor.read_f32()?;
4272    let screen_capture_mode = cursor.read_i32()?;
4273    let accessory_index_after_models = cursor.read_i32()?;
4274    let ground_shadow_brightness = cursor.read_f32()?;
4275    let translucent_ground_shadow_enabled = cursor.read_bool()?;
4276    let physics_simulation_mode = cursor.read_u8()?;
4277
4278    Some(PmmDocumentSettingsBeforeGravity {
4279        offset,
4280        current_frame_index,
4281        current_frame_index_offset,
4282        horizontal_scroll,
4283        horizontal_scroll_thumb,
4284        editing_mode,
4285        camera_look_mode,
4286        loop_enabled,
4287        begin_frame_index_enabled,
4288        begin_frame_index_enabled_offset,
4289        end_frame_index_enabled,
4290        end_frame_index_enabled_offset,
4291        begin_frame_index,
4292        begin_frame_index_offset,
4293        end_frame_index,
4294        end_frame_index_offset,
4295        audio_enabled,
4296        audio_path,
4297        audio_path_offset,
4298        background_video_offset,
4299        background_video_scale_factor,
4300        background_video_path,
4301        background_video_path_offset,
4302        background_video_enabled,
4303        background_image_offset,
4304        background_image_scale_factor,
4305        background_image_path,
4306        background_image_path_offset,
4307        background_image_enabled,
4308        information_shown,
4309        grid_and_axis_shown,
4310        ground_shadow_shown,
4311        preferred_fps,
4312        screen_capture_mode,
4313        accessory_index_after_models,
4314        ground_shadow_brightness,
4315        translucent_ground_shadow_enabled,
4316        physics_simulation_mode,
4317    })
4318}
4319
4320fn read_document_gravity_summary(
4321    cursor: &mut PmmDocumentCursor<'_>,
4322) -> Option<PmmDocumentTrackSummary> {
4323    let offset = cursor.offset();
4324    let state_offset = cursor.offset();
4325    cursor.read_f32()?;
4326    cursor.read_i32()?;
4327    cursor.skip(12)?;
4328    cursor.read_bool()?;
4329    let state_end_offset = cursor.offset();
4330    let initial_keyframe = read_document_gravity_keyframe(cursor, false)?;
4331    let keyframe_count_offset = cursor.offset();
4332    let keyframes = usize_from_i32(cursor.read_i32()?)?;
4333    let keyframes_offset = cursor.offset();
4334    let mut keyframe_summaries = Vec::with_capacity(keyframes);
4335    for _ in 0..keyframes {
4336        keyframe_summaries.push(read_document_gravity_keyframe(cursor, true)?);
4337    }
4338    let keyframes_end_offset = cursor.offset();
4339    Some(PmmDocumentTrackSummary {
4340        offset,
4341        offset_end: keyframes_end_offset,
4342        initial_keyframes: 1,
4343        keyframes,
4344        initial_keyframe: Some(initial_keyframe),
4345        keyframe_summaries,
4346        keyframe_count_offset,
4347        keyframes_offset,
4348        keyframes_end_offset,
4349        state_offset: Some(state_offset),
4350        state_end_offset: Some(state_end_offset),
4351    })
4352}
4353
4354fn read_document_gravity_keyframe(
4355    cursor: &mut PmmDocumentCursor<'_>,
4356    include_index: bool,
4357) -> Option<PmmDocumentKeyframeSummary> {
4358    let offset = cursor.offset();
4359    let base = read_document_base_keyframe(cursor, include_index)?;
4360    let payload_offset = cursor.offset();
4361    let noise_enabled_offset = cursor.offset();
4362    let noise_enabled = cursor.read_bool()?;
4363    let noise_offset = cursor.offset();
4364    let noise = cursor.read_i32()?;
4365    let acceleration_offset = cursor.offset();
4366    let acceleration = cursor.read_f32()?;
4367    let direction_offset = cursor.offset();
4368    let direction = cursor.read_f32x3()?;
4369    let direction_byte_length = cursor.offset() - direction_offset;
4370    let selected_offset = cursor.offset();
4371    let selected = cursor.read_bool()?;
4372    let byte_length = cursor.offset() - offset;
4373    let payload_byte_length = cursor.offset() - payload_offset;
4374    let payload_bytes = cursor.data().get(payload_offset..cursor.offset())?.to_vec();
4375    Some(PmmDocumentKeyframeSummary::Gravity {
4376        index: base.index,
4377        frame_index: base.frame_index,
4378        previous_keyframe_index: base.previous_keyframe_index,
4379        next_keyframe_index: base.next_keyframe_index,
4380        noise_enabled,
4381        noise,
4382        acceleration,
4383        direction,
4384        selected,
4385        noise_enabled_offset,
4386        noise_offset,
4387        acceleration_offset,
4388        direction_offset,
4389        direction_byte_length,
4390        selected_offset,
4391        offset,
4392        byte_length,
4393        payload_offset,
4394        payload_byte_length,
4395        payload_bytes,
4396    })
4397}
4398
4399fn read_document_self_shadow_summary(
4400    cursor: &mut PmmDocumentCursor<'_>,
4401) -> Option<PmmDocumentTrackSummary> {
4402    let offset = cursor.offset();
4403    let state_offset = cursor.offset();
4404    cursor.read_bool()?;
4405    cursor.read_f32()?;
4406    let state_end_offset = cursor.offset();
4407    let initial_keyframe = read_document_self_shadow_keyframe(cursor, false)?;
4408    let keyframe_count_offset = cursor.offset();
4409    let keyframes = usize_from_i32(cursor.read_i32()?)?;
4410    let keyframes_offset = cursor.offset();
4411    let mut keyframe_summaries = Vec::with_capacity(keyframes);
4412    for _ in 0..keyframes {
4413        keyframe_summaries.push(read_document_self_shadow_keyframe(cursor, true)?);
4414    }
4415    let keyframes_end_offset = cursor.offset();
4416    Some(PmmDocumentTrackSummary {
4417        offset,
4418        offset_end: keyframes_end_offset,
4419        initial_keyframes: 1,
4420        keyframes,
4421        initial_keyframe: Some(initial_keyframe),
4422        keyframe_summaries,
4423        keyframe_count_offset,
4424        keyframes_offset,
4425        keyframes_end_offset,
4426        state_offset: Some(state_offset),
4427        state_end_offset: Some(state_end_offset),
4428    })
4429}
4430
4431fn read_document_self_shadow_keyframe(
4432    cursor: &mut PmmDocumentCursor<'_>,
4433    include_index: bool,
4434) -> Option<PmmDocumentKeyframeSummary> {
4435    let offset = cursor.offset();
4436    let base = read_document_base_keyframe(cursor, include_index)?;
4437    let payload_offset = cursor.offset();
4438    let mode_offset = cursor.offset();
4439    let mode = cursor.read_u8()?;
4440    let distance_offset = cursor.offset();
4441    let distance = cursor.read_f32()?;
4442    let selected_offset = cursor.offset();
4443    let selected = cursor.read_bool()?;
4444    let byte_length = cursor.offset() - offset;
4445    let payload_byte_length = cursor.offset() - payload_offset;
4446    let payload_bytes = cursor.data().get(payload_offset..cursor.offset())?.to_vec();
4447    Some(PmmDocumentKeyframeSummary::SelfShadow {
4448        index: base.index,
4449        frame_index: base.frame_index,
4450        previous_keyframe_index: base.previous_keyframe_index,
4451        next_keyframe_index: base.next_keyframe_index,
4452        mode,
4453        distance,
4454        selected,
4455        mode_offset,
4456        distance_offset,
4457        selected_offset,
4458        offset,
4459        byte_length,
4460        payload_offset,
4461        payload_byte_length,
4462        payload_bytes,
4463    })
4464}
4465
4466fn finish_document_settings_summary(
4467    before_gravity: PmmDocumentSettingsBeforeGravity,
4468    cursor: &mut PmmDocumentCursor<'_>,
4469    model_count: usize,
4470) -> Option<PmmDocumentSettingsSummary> {
4471    let edge_color = [
4472        cursor.read_i32()? as f32 / 255.0,
4473        cursor.read_i32()? as f32 / 255.0,
4474        cursor.read_i32()? as f32 / 255.0,
4475    ];
4476    let black_background_enabled = cursor.read_bool()?;
4477    let camera_look_at_model_index = cursor.read_i32()?;
4478    let camera_look_at_model_bone_index = cursor.read_i32()?;
4479    let unknown_matrix_offset = cursor.offset();
4480    cursor.skip(16 * 4)?;
4481    let unknown_matrix_end_offset = cursor.offset();
4482    let following_look_at_enabled = cursor.read_bool()?;
4483    cursor.read_bool()?;
4484    let physics_ground_enabled = cursor.read_bool()?;
4485    let current_frame_index_in_text_field_offset = cursor.offset();
4486    let current_frame_index_in_text_field = cursor.read_i32()?;
4487
4488    let mut model_selection_footer_present = false;
4489    let mut model_selection_footer_offset = None;
4490    let mut model_selection_footer_end_offset = None;
4491    if cursor.offset() < cursor.data().len() {
4492        let footer_offset = cursor.offset();
4493        if cursor.read_u8()? != 0 {
4494            model_selection_footer_present = true;
4495            model_selection_footer_offset = Some(footer_offset);
4496            for _ in 0..model_count {
4497                cursor.read_u8()?;
4498                cursor.read_i32()?;
4499            }
4500            model_selection_footer_end_offset = Some(cursor.offset());
4501        }
4502    }
4503    let offset_end = cursor.offset();
4504
4505    Some(PmmDocumentSettingsSummary {
4506        offset: before_gravity.offset,
4507        offset_end,
4508        current_frame_index: before_gravity.current_frame_index,
4509        current_frame_index_offset: before_gravity.current_frame_index_offset,
4510        horizontal_scroll: before_gravity.horizontal_scroll,
4511        horizontal_scroll_thumb: before_gravity.horizontal_scroll_thumb,
4512        editing_mode: before_gravity.editing_mode,
4513        camera_look_mode: before_gravity.camera_look_mode,
4514        loop_enabled: before_gravity.loop_enabled,
4515        begin_frame_index_enabled: before_gravity.begin_frame_index_enabled,
4516        begin_frame_index_enabled_offset: before_gravity.begin_frame_index_enabled_offset,
4517        end_frame_index_enabled: before_gravity.end_frame_index_enabled,
4518        end_frame_index_enabled_offset: before_gravity.end_frame_index_enabled_offset,
4519        begin_frame_index: before_gravity.begin_frame_index,
4520        begin_frame_index_offset: before_gravity.begin_frame_index_offset,
4521        end_frame_index: before_gravity.end_frame_index,
4522        end_frame_index_offset: before_gravity.end_frame_index_offset,
4523        audio_enabled: before_gravity.audio_enabled,
4524        audio_path: before_gravity.audio_path,
4525        audio_path_offset: before_gravity.audio_path_offset,
4526        background_video_offset: before_gravity.background_video_offset,
4527        background_video_scale_factor: before_gravity.background_video_scale_factor,
4528        background_video_path: before_gravity.background_video_path,
4529        background_video_path_offset: before_gravity.background_video_path_offset,
4530        background_video_enabled: before_gravity.background_video_enabled,
4531        background_image_offset: before_gravity.background_image_offset,
4532        background_image_scale_factor: before_gravity.background_image_scale_factor,
4533        background_image_path: before_gravity.background_image_path,
4534        background_image_path_offset: before_gravity.background_image_path_offset,
4535        background_image_enabled: before_gravity.background_image_enabled,
4536        information_shown: before_gravity.information_shown,
4537        grid_and_axis_shown: before_gravity.grid_and_axis_shown,
4538        ground_shadow_shown: before_gravity.ground_shadow_shown,
4539        preferred_fps: before_gravity.preferred_fps,
4540        screen_capture_mode: before_gravity.screen_capture_mode,
4541        accessory_index_after_models: before_gravity.accessory_index_after_models,
4542        ground_shadow_brightness: before_gravity.ground_shadow_brightness,
4543        translucent_ground_shadow_enabled: before_gravity.translucent_ground_shadow_enabled,
4544        physics_simulation_mode: before_gravity.physics_simulation_mode,
4545        edge_color,
4546        black_background_enabled,
4547        camera_look_at_model_index,
4548        camera_look_at_model_bone_index,
4549        unknown_matrix_offset,
4550        unknown_matrix_end_offset,
4551        following_look_at_enabled,
4552        physics_ground_enabled,
4553        current_frame_index_in_text_field,
4554        current_frame_index_in_text_field_offset,
4555        model_selection_footer_present,
4556        model_selection_footer_offset,
4557        model_selection_footer_end_offset,
4558    })
4559}
4560
4561fn read_document_bone_state(
4562    cursor: &mut PmmDocumentCursor<'_>,
4563) -> Option<PmmDocumentBoneStateSummary> {
4564    let translation = cursor.read_f32x3()?;
4565    let orientation = cursor.read_f32x4()?;
4566    let dirty = cursor.read_bool()?;
4567    let physics_disabled = cursor.read_bool()?;
4568    let selected = cursor.read_bool()?;
4569    Some(PmmDocumentBoneStateSummary {
4570        translation,
4571        orientation,
4572        dirty,
4573        physics_disabled,
4574        selected,
4575    })
4576}
4577
4578fn read_document_morph_state(
4579    cursor: &mut PmmDocumentCursor<'_>,
4580) -> Option<PmmDocumentMorphStateSummary> {
4581    let weight = cursor.read_f32()?;
4582    Some(PmmDocumentMorphStateSummary { weight })
4583}
4584
4585fn read_document_constraint_state(
4586    cursor: &mut PmmDocumentCursor<'_>,
4587) -> Option<PmmDocumentConstraintStateSummary> {
4588    let enabled = cursor.read_bool()?;
4589    Some(PmmDocumentConstraintStateSummary { enabled })
4590}
4591
4592fn read_document_outside_parent_state(
4593    cursor: &mut PmmDocumentCursor<'_>,
4594) -> Option<PmmDocumentOutsideParentStateSummary> {
4595    let parent_model_index = cursor.read_i32()?;
4596    let parent_model_bone_index = cursor.read_i32()?;
4597    let subject_bone_index = cursor.read_i32()?;
4598    let target_model_index = cursor.read_i32()?;
4599    Some(PmmDocumentOutsideParentStateSummary {
4600        parent_model_index,
4601        parent_model_bone_index,
4602        subject_bone_index,
4603        target_model_index,
4604    })
4605}
4606
4607fn read_document_base_keyframe(
4608    cursor: &mut PmmDocumentCursor<'_>,
4609    include_index: bool,
4610) -> Option<PmmDocumentBaseKeyframeSummary> {
4611    let index = if include_index {
4612        Some(cursor.read_i32()?)
4613    } else {
4614        None
4615    };
4616    let frame_index = cursor.read_i32()?;
4617    let previous_keyframe_index = cursor.read_i32()?;
4618    let next_keyframe_index = cursor.read_i32()?;
4619    Some(PmmDocumentBaseKeyframeSummary {
4620        index,
4621        frame_index,
4622        previous_keyframe_index,
4623        next_keyframe_index,
4624    })
4625}
4626
4627fn unpack_document_accessory_opacity_and_visible(value: u8) -> (f32, bool) {
4628    let visible = value & 1 != 0;
4629    let opacity = 1.0 - ((value >> 1) as f32 / 100.0);
4630    (opacity, visible)
4631}
4632
4633fn summarize_document_counts(models: &[PmmDocumentModelSummary]) -> PmmDocumentCounts {
4634    PmmDocumentCounts {
4635        models: models.len(),
4636        bones: models.iter().map(|model| model.bone_count).sum(),
4637        morphs: models.iter().map(|model| model.morph_count).sum(),
4638        initial_bone_keyframes: models
4639            .iter()
4640            .map(|model| model.initial_bone_keyframes)
4641            .sum(),
4642        bone_keyframes: models.iter().map(|model| model.bone_keyframes).sum(),
4643        initial_morph_keyframes: models
4644            .iter()
4645            .map(|model| model.initial_morph_keyframes)
4646            .sum(),
4647        morph_keyframes: models.iter().map(|model| model.morph_keyframes).sum(),
4648        initial_model_keyframes: models
4649            .iter()
4650            .map(|model| model.initial_model_keyframes)
4651            .sum(),
4652        model_keyframes: models.iter().map(|model| model.model_keyframes).sum(),
4653    }
4654}
4655
4656fn usize_from_i32(value: i32) -> Option<usize> {
4657    usize::try_from(value).ok()
4658}
4659
4660fn decode_shift_jis(bytes: &[u8]) -> String {
4661    decode_sjis_trim_nul(bytes)
4662}
4663
4664#[derive(Debug, Clone)]
4665struct PmmModelSlotScan {
4666    slots: Vec<PmmModelSlot>,
4667    stop: Option<PmmModelSlotScanStop>,
4668}
4669
4670#[derive(Debug, Clone)]
4671struct PmmModelSlotScanStop {
4672    offset: usize,
4673    reason: &'static str,
4674}
4675
4676fn parse_model_slots_from_header(
4677    data: &[u8],
4678    references: &[PmmAssetReference],
4679    display_state: &PmmDisplayState,
4680) -> PmmModelSlotScan {
4681    const INITIAL_MODEL_SLOT_OFFSET: usize = 56;
4682    if display_state.layout != "pmm-v2-flags" {
4683        return PmmModelSlotScan {
4684            slots: Vec::new(),
4685            stop: None,
4686        };
4687    }
4688    let declared_count = display_state.non_zero_model_slot_count.max(1);
4689    let mut slots = Vec::new();
4690    let mut offset = INITIAL_MODEL_SLOT_OFFSET;
4691    let mut stop = None;
4692
4693    for slot_index in 0..declared_count {
4694        let require_reference_at_offset = slot_index > 0;
4695        match parse_model_slot_at(
4696            data,
4697            references,
4698            offset,
4699            slot_index,
4700            display_state
4701                .active_model_slot_indices
4702                .get(slot_index)
4703                .copied(),
4704            require_reference_at_offset,
4705        ) {
4706            Ok((slot, next_offset)) => {
4707                slots.push(slot);
4708                offset = next_offset;
4709            }
4710            Err(reason) => {
4711                if !slots.is_empty() && slot_index < declared_count {
4712                    stop = Some(PmmModelSlotScanStop { offset, reason });
4713                }
4714                break;
4715            }
4716        }
4717    }
4718
4719    PmmModelSlotScan { slots, stop }
4720}
4721
4722fn parse_model_slot_at(
4723    data: &[u8],
4724    references: &[PmmAssetReference],
4725    offset: usize,
4726    slot_index: usize,
4727    display_slot_index: Option<usize>,
4728    require_reference_at_offset: bool,
4729) -> Result<(PmmModelSlot, usize), &'static str> {
4730    let (name, name_bytes, after_name) =
4731        read_pmm_len_prefixed_sjis(data, offset).ok_or("missing_name")?;
4732    let (english_name, english_name_bytes, path_offset) =
4733        read_pmm_len_prefixed_sjis(data, after_name).ok_or("missing_english_name")?;
4734    let path_end = data
4735        .get(path_offset..)
4736        .ok_or("missing_model_path")?
4737        .iter()
4738        .position(|byte| *byte == 0)
4739        .map(|relative| path_offset + relative)
4740        .ok_or("missing_path_terminator")?;
4741    if path_end == path_offset {
4742        return Err("empty_path");
4743    }
4744    let decoded_path = decode_sjis(&data[path_offset..path_end]);
4745    let model_path = find_asset_candidates(&decoded_path)
4746        .into_iter()
4747        .find(|candidate| {
4748            candidate
4749                .rsplit_once('.')
4750                .map(|(_, ext)| matches!(ext.to_ascii_lowercase().as_str(), "pmx" | "pmd"))
4751                .unwrap_or(false)
4752        })
4753        .ok_or("missing_model_path")?;
4754    let normalized_path = normalize_path(&model_path);
4755    let asset_reference_index = references.iter().position(|reference| {
4756        reference.kind == "model"
4757            && reference
4758                .normalized_path
4759                .eq_ignore_ascii_case(&normalized_path)
4760            && (!require_reference_at_offset || reference.offset == offset)
4761    });
4762    if require_reference_at_offset && asset_reference_index.is_none() {
4763        return Err("asset_reference_not_matched");
4764    }
4765    let confidence = asset_reference_index
4766        .and_then(|index| references.get(index).map(|reference| reference.confidence))
4767        .unwrap_or_else(|| asset_reference_confidence(&model_path, &normalized_path));
4768    let padding_start = path_end + 1;
4769    let trailing_zero_padding_bytes = data
4770        .get(padding_start..)
4771        .map(|bytes| bytes.iter().take_while(|byte| **byte == 0).count())
4772        .unwrap_or(0);
4773    let next_non_zero_offset = data
4774        .get(padding_start..)
4775        .and_then(|bytes| bytes.iter().position(|byte| *byte != 0))
4776        .map(|relative| padding_start + relative);
4777
4778    Ok((
4779        PmmModelSlot {
4780            slot_index,
4781            display_slot_index,
4782            offset,
4783            offset_end: path_end,
4784            model_path_offset: path_offset,
4785            trailing_zero_padding_bytes,
4786            next_non_zero_offset,
4787            name,
4788            name_bytes,
4789            english_name,
4790            english_name_bytes,
4791            model_path,
4792            normalized_path,
4793            asset_reference_index,
4794            confidence,
4795        },
4796        path_end + 1,
4797    ))
4798}
4799
4800fn read_pmm_len_prefixed_sjis(data: &[u8], offset: usize) -> Option<(String, Vec<u8>, usize)> {
4801    let length = *data.get(offset)? as usize;
4802    if length == 0 || length > 128 {
4803        return None;
4804    }
4805    let start = offset + 1;
4806    let end = start.checked_add(length)?;
4807    let bytes = data.get(start..end)?;
4808    if bytes.contains(&0) {
4809        return None;
4810    }
4811    Some((decode_sjis_trim_nul(bytes), bytes.to_vec(), end))
4812}
4813
4814fn extract_asset_references(data: &[u8]) -> Vec<PmmAssetReference> {
4815    let mut refs = Vec::new();
4816    let mut chunk_start = 0usize;
4817    for index in 0..=data.len() {
4818        if index < data.len() && data[index] != 0 {
4819            continue;
4820        }
4821        if index > chunk_start {
4822            let decoded = decode_sjis(&data[chunk_start..index]);
4823            for candidate in find_asset_candidates(&decoded) {
4824                let normalized = normalize_path(&candidate);
4825                if refs.iter().any(|existing: &PmmAssetReference| {
4826                    existing.normalized_path.eq_ignore_ascii_case(&normalized)
4827                }) {
4828                    continue;
4829                }
4830                let file_name = normalized
4831                    .rsplit('/')
4832                    .next()
4833                    .unwrap_or(&normalized)
4834                    .to_owned();
4835                let extension = file_name
4836                    .rsplit_once('.')
4837                    .map(|(_, ext)| ext.to_ascii_lowercase())
4838                    .unwrap_or_default();
4839                refs.push(PmmAssetReference {
4840                    confidence: asset_reference_confidence(&candidate, &normalized),
4841                    path: candidate,
4842                    normalized_path: normalized,
4843                    file_name,
4844                    kind: classify_extension(&extension),
4845                    extension,
4846                    offset: chunk_start,
4847                    offset_end: index,
4848                });
4849            }
4850        }
4851        chunk_start = index + 1;
4852    }
4853    refs.sort_by_key(|reference| reference.offset);
4854    refs
4855}
4856
4857fn asset_reference_confidence(path: &str, normalized_path: &str) -> &'static str {
4858    if path.contains('\u{FFFD}')
4859        || normalized_path.starts_with('/')
4860        || normalized_path.starts_with(":/")
4861    {
4862        return "low";
4863    }
4864    if normalized_path.contains('/') || last_windows_drive_path_start(path).is_some() {
4865        return "high";
4866    }
4867    "medium"
4868}
4869
4870fn find_asset_candidates(text: &str) -> Vec<String> {
4871    let mut candidates = Vec::new();
4872    for segment in text.split(|c: char| c.is_control() || c == '"' || c == '\'') {
4873        let lower = segment.to_ascii_lowercase();
4874        let mut search_from = 0usize;
4875        while let Some((relative_end, extension)) = next_asset_extension(&lower[search_from..]) {
4876            let end = search_from + relative_end + extension.len();
4877            let start = asset_candidate_start(segment, end);
4878            let candidate = segment[start..end].trim();
4879            if has_asset_file_stem(candidate)
4880                && !candidates
4881                    .iter()
4882                    .any(|existing: &String| existing.eq_ignore_ascii_case(candidate))
4883            {
4884                candidates.push(candidate.to_owned());
4885            }
4886            search_from = end;
4887        }
4888    }
4889    candidates
4890}
4891
4892fn next_asset_extension(text: &str) -> Option<(usize, &'static str)> {
4893    [
4894        ".pmd", ".pmx", ".vmd", ".vac", ".x", ".wav", ".mp3", ".ogg", ".bmp", ".tga", ".png",
4895        ".jpg", ".jpeg", ".dds", ".avi", ".avs", ".mp4", ".wmv",
4896    ]
4897    .iter()
4898    .filter_map(|extension| {
4899        find_extension_with_boundary(text, extension).map(|index| (index, *extension))
4900    })
4901    .min_by_key(|(index, _)| *index)
4902}
4903
4904fn find_extension_with_boundary(text: &str, extension: &str) -> Option<usize> {
4905    let mut search_from = 0usize;
4906    while let Some(relative_index) = text[search_from..].find(extension) {
4907        let index = search_from + relative_index;
4908        let after = index + extension.len();
4909        if text[after..]
4910            .chars()
4911            .next()
4912            .is_none_or(|ch| ch.is_whitespace())
4913        {
4914            return Some(index);
4915        }
4916        search_from = after;
4917    }
4918    None
4919}
4920
4921fn asset_candidate_start(segment: &str, end: usize) -> usize {
4922    let prefix = &segment[..end];
4923    let lower = prefix.to_ascii_lowercase();
4924    if let Some(index) = last_userfile_path_start(&lower) {
4925        return index;
4926    }
4927    if let Some(index) = last_windows_drive_path_start(prefix) {
4928        return index;
4929    }
4930    prefix
4931        .rfind(char::is_whitespace)
4932        .map(|index| index + 1)
4933        .unwrap_or(0)
4934}
4935
4936fn last_userfile_path_start(value: &str) -> Option<usize> {
4937    ["userfile\\", "userfile/"]
4938        .iter()
4939        .flat_map(|needle| value.match_indices(needle).map(|(index, _)| index))
4940        .filter(|index| {
4941            *index == 0
4942                || value.as_bytes()[index - 1] == b'\\'
4943                || value.as_bytes()[index - 1] == b'/'
4944        })
4945        .last()
4946}
4947
4948fn last_windows_drive_path_start(value: &str) -> Option<usize> {
4949    let bytes = value.as_bytes();
4950    if bytes.len() < 3 {
4951        return None;
4952    }
4953    let mut last = None;
4954    for index in 0..=bytes.len() - 3 {
4955        if bytes[index].is_ascii_alphabetic()
4956            && bytes[index + 1] == b':'
4957            && (bytes[index + 2] == b'\\' || bytes[index + 2] == b'/')
4958        {
4959            last = Some(index);
4960        }
4961    }
4962    last
4963}
4964
4965fn has_asset_file_stem(value: &str) -> bool {
4966    let Some((stem, _)) = value
4967        .rsplit(['\\', '/'])
4968        .next()
4969        .and_then(|file_name| file_name.rsplit_once('.'))
4970    else {
4971        return false;
4972    };
4973    if stem.is_empty() {
4974        return false;
4975    }
4976    if value.contains(['\\', '/']) || last_windows_drive_path_start(value).is_some() {
4977        return true;
4978    }
4979    !stem
4980        .chars()
4981        .any(|c| c.is_control() || matches!(c, '<' | '>' | ':' | '"' | '|' | '?' | '*' | '='))
4982}
4983
4984fn normalize_path(value: &str) -> String {
4985    let normalized = value.replace('\\', "/").replace("//", "/");
4986    let lower = normalized.to_ascii_lowercase();
4987    match last_userfile_path_start(&lower) {
4988        Some(index) => normalized[index..].to_owned(),
4989        None => normalized,
4990    }
4991}
4992
4993fn classify_extension(extension: &str) -> &'static str {
4994    match extension {
4995        "pmd" | "pmx" => "model",
4996        "x" | "vac" => "accessory",
4997        "vmd" => "motion",
4998        "wav" | "mp3" | "ogg" => "audio",
4999        "bmp" | "tga" | "png" | "jpg" | "jpeg" | "dds" => "image",
5000        "avi" | "avs" | "mp4" | "wmv" => "video",
5001        _ => "unknown",
5002    }
5003}
5004
5005fn paths_by_kind(references: &[PmmAssetReference], kind: &str) -> Vec<String> {
5006    references
5007        .iter()
5008        .filter(|reference| reference.kind == kind)
5009        .map(|reference| reference.normalized_path.clone())
5010        .collect()
5011}
5012
5013fn asset_reference_index_for_path(
5014    references: &[PmmAssetReference],
5015    kind: &str,
5016    path: &str,
5017) -> Option<usize> {
5018    let normalized = normalize_path(path);
5019    references.iter().position(|reference| {
5020        reference.kind == kind && reference.normalized_path.eq_ignore_ascii_case(&normalized)
5021    })
5022}
5023
5024fn scene_assets_by_kind(references: &[PmmAssetReference], kind: &str) -> Vec<PmmSceneAsset> {
5025    references
5026        .iter()
5027        .enumerate()
5028        .filter(|(_, reference)| reference.kind == kind)
5029        .enumerate()
5030        .map(|(kind_index, (reference_index, reference))| PmmSceneAsset {
5031            reference_index,
5032            kind_index,
5033            path: reference.path.clone(),
5034            normalized_path: reference.normalized_path.clone(),
5035            file_name: reference.file_name.clone(),
5036            extension: reference.extension.clone(),
5037            offset: reference.offset,
5038            offset_end: reference.offset_end,
5039            confidence: reference.confidence,
5040        })
5041        .collect()
5042}
5043
5044fn asset_summary(references: &[PmmAssetReference]) -> PmmAssetSummary {
5045    let mut kind_counts = BTreeMap::new();
5046    let mut extension_counts = BTreeMap::new();
5047    let mut confidence_counts = BTreeMap::new();
5048    for reference in references {
5049        *kind_counts.entry(reference.kind.to_owned()).or_insert(0) += 1;
5050        *extension_counts
5051            .entry(reference.extension.to_owned())
5052            .or_insert(0) += 1;
5053        *confidence_counts
5054            .entry(reference.confidence.to_owned())
5055            .or_insert(0) += 1;
5056    }
5057
5058    PmmAssetSummary {
5059        reference_count: references.len(),
5060        high_confidence_count: *confidence_counts.get("high").unwrap_or(&0),
5061        medium_confidence_count: *confidence_counts.get("medium").unwrap_or(&0),
5062        low_confidence_count: *confidence_counts.get("low").unwrap_or(&0),
5063        kind_counts,
5064        extension_counts,
5065        confidence_counts,
5066    }
5067}
5068
5069fn pmm_diagnostics(
5070    references: &[PmmAssetReference],
5071    model_slots: &[PmmModelSlot],
5072    document_summary: Option<&PmmDocumentSummary>,
5073    document_global_summary: Option<&PmmDocumentGlobalSummary>,
5074    display_state: &PmmDisplayState,
5075    model_slot_scan_stop: Option<&PmmModelSlotScanStop>,
5076    data: &[u8],
5077) -> Vec<PmmParserDiagnostic> {
5078    let mut diagnostics = vec![
5079        PmmParserDiagnostic {
5080            level: "warning",
5081            code: "PMM_PROJECT_GRAPH_PARTIAL",
5082            message: "PMM parser currently exposes header-derived project settings, timeline, display state, and manifest-derived asset references; binary project graph tracks are not fully decoded yet.".to_owned(),
5083        },
5084        PmmParserDiagnostic {
5085            level: "info",
5086            code: "PMM_ASSET_REFERENCES_SCAN",
5087            message: format!(
5088                "PMM asset references are extracted from decoded null-terminated text chunks and grouped by extension; references={}.",
5089                references.len()
5090            ),
5091        },
5092    ];
5093    if !model_slots.is_empty() {
5094        diagnostics.push(PmmParserDiagnostic {
5095            level: "info",
5096            code: "PMM_MODEL_SLOT_INITIAL_SLICE",
5097            message: format!(
5098                "PMM parser decoded model slot records from the initial fixed-header slice; modelSlots={}.",
5099                model_slots.len()
5100            ),
5101        });
5102    }
5103    if let Some(stop) = model_slot_scan_stop {
5104        diagnostics.push(PmmParserDiagnostic {
5105            level: "info",
5106            code: "PMM_MODEL_SLOT_SCAN_STOPPED",
5107            message: format!(
5108                "PMM model slot header scan stopped at offset {}: {}; decodedSlots={} declaredModelSlots={}.",
5109                stop.offset,
5110                stop.reason,
5111                model_slots.len(),
5112                display_state.non_zero_model_slot_count
5113            ),
5114        });
5115    }
5116    if let Some(summary) = document_summary {
5117        let total_bone_keyframes =
5118            summary.counts.initial_bone_keyframes + summary.counts.bone_keyframes;
5119        let total_morph_keyframes =
5120            summary.counts.initial_morph_keyframes + summary.counts.morph_keyframes;
5121        diagnostics.push(PmmParserDiagnostic {
5122            level: "info",
5123            code: "PMM_DOCUMENT_SUMMARY_PARTIAL",
5124            message: format!(
5125                "PMM parser decoded a PMMv2 document inventory slice with {} model(s), {} total bone keyframe(s) ({} additional), and {} total morph keyframe(s) ({} additional); full keyframe payloads and camera/light/self-shadow/property tracks are not exposed yet.",
5126                summary.model_count,
5127                total_bone_keyframes,
5128                summary.counts.bone_keyframes,
5129                total_morph_keyframes,
5130                summary.counts.morph_keyframes
5131            ),
5132        });
5133    }
5134    if let Some(summary) = document_global_summary {
5135        diagnostics.push(PmmParserDiagnostic {
5136            level: "info",
5137            code: "PMM_DOCUMENT_GLOBAL_SUMMARY_PARTIAL",
5138            message: format!(
5139                "PMM parser decoded a PMMv2 global document inventory slice with {} camera keyframe(s), {} light keyframe(s), {} accessory object(s), {} gravity keyframe(s), and {} self-shadow keyframe(s); full payloads are not exposed yet.",
5140                summary.camera.initial_keyframes + summary.camera.keyframes,
5141                summary.light.initial_keyframes + summary.light.keyframes,
5142                summary.accessories.accessory_count,
5143                summary.gravity.initial_keyframes + summary.gravity.keyframes,
5144                summary.self_shadow.initial_keyframes + summary.self_shadow.keyframes
5145            ),
5146        });
5147    }
5148    if display_state.layout == "pmm-v2-flags"
5149        && display_state.non_zero_model_slot_count > model_slots.len()
5150    {
5151        diagnostics.push(PmmParserDiagnostic {
5152            level: "warning",
5153            code: "PMM_MODEL_SLOT_COUNT_PARTIAL_DECODE",
5154            message: format!(
5155                "PMM displayState reports {} non-zero model slot flags, but only {} initial model slot(s) are decoded.",
5156                display_state.non_zero_model_slot_count,
5157                model_slots.len()
5158            ),
5159        });
5160    }
5161    diagnostics.extend(asset_count_mismatch_diagnostics(references, display_state));
5162    if display_state.layout == "unknown"
5163        && let Some(bytes) = data.get(46..54)
5164        && bytes.iter().any(|byte| *byte > 2)
5165    {
5166        diagnostics.push(PmmParserDiagnostic {
5167            level: "warning",
5168            code: "PMM_DISPLAY_STATE_UNPLAUSIBLE",
5169            message: format!(
5170                "PMM displayState.modelSlotFlags header bytes were outside the observed 0..=2 range and were omitted: {:?}.",
5171                bytes
5172            ),
5173        });
5174    }
5175    diagnostics.extend(duplicate_asset_reference_diagnostics(references));
5176    if references.iter().any(|reference| reference.kind == "model")
5177        && !references
5178            .iter()
5179            .any(|reference| reference.kind == "motion")
5180    {
5181        diagnostics.push(PmmParserDiagnostic {
5182            level: "warning",
5183            code: "PMM_MOTION_REFERENCES_NOT_FOUND_IN_SCAN",
5184            message: "PMM manifest-derived asset scan found model references but no motion references; motion paths may live in unparsed binary project graph sections.".to_owned(),
5185        });
5186    }
5187    diagnostics
5188}
5189
5190fn asset_count_mismatch_diagnostics(
5191    references: &[PmmAssetReference],
5192    display_state: &PmmDisplayState,
5193) -> Vec<PmmParserDiagnostic> {
5194    let mut diagnostics = Vec::new();
5195    let scanned_models = references
5196        .iter()
5197        .filter(|reference| reference.kind == "model")
5198        .count();
5199    if display_state.model_slot_count > 0 && scanned_models != display_state.model_slot_count {
5200        diagnostics.push(PmmParserDiagnostic {
5201            level: "info",
5202            code: "PMM_ASSET_COUNT_MISMATCH",
5203            message: format!(
5204                "PMM displayState declares {} model slot(s), but manifest-derived asset scan found {} model reference(s).",
5205                display_state.model_slot_count,
5206                scanned_models
5207            ),
5208        });
5209    }
5210
5211    if let Some(accessory_count) = display_state.accessory_slot_count {
5212        let scanned_accessories = references
5213            .iter()
5214            .filter(|reference| reference.kind == "accessory")
5215            .count();
5216        if scanned_accessories != accessory_count as usize {
5217            diagnostics.push(PmmParserDiagnostic {
5218                level: "info",
5219                code: "PMM_ASSET_COUNT_MISMATCH",
5220                message: format!(
5221                    "PMM displayState declares {} accessory slot(s), but manifest-derived asset scan found {} accessory reference(s).",
5222                    accessory_count,
5223                    scanned_accessories
5224                ),
5225            });
5226        }
5227    }
5228    diagnostics
5229}
5230
5231fn duplicate_asset_reference_diagnostics(
5232    references: &[PmmAssetReference],
5233) -> Vec<PmmParserDiagnostic> {
5234    let mut by_file = HashMap::<(String, String), Vec<(usize, &PmmAssetReference)>>::new();
5235    for (index, reference) in references.iter().enumerate() {
5236        by_file
5237            .entry((reference.file_name.clone(), reference.extension.clone()))
5238            .or_default()
5239            .push((index, reference));
5240    }
5241
5242    let mut diagnostics = Vec::new();
5243    for ((file_name, extension), entries) in by_file {
5244        if entries.len() < 2 {
5245            continue;
5246        }
5247        let mut unique_paths = entries
5248            .iter()
5249            .map(|(_, reference)| reference.normalized_path.to_ascii_lowercase())
5250            .collect::<Vec<_>>();
5251        unique_paths.sort();
5252        unique_paths.dedup();
5253        if unique_paths.len() < 2 {
5254            continue;
5255        }
5256        let indices = entries
5257            .iter()
5258            .map(|(index, _)| index.to_string())
5259            .collect::<Vec<_>>()
5260            .join(",");
5261        diagnostics.push(PmmParserDiagnostic {
5262            level: "warning",
5263            code: "PMM_ASSET_REFERENCE_DUPLICATE",
5264            message: format!(
5265                "PMM manifest-derived asset scan found duplicate file names with different paths: fileName={file_name:?} extension={extension:?} referenceIndices={indices}."
5266            ),
5267        });
5268    }
5269    diagnostics.sort_by(|a, b| a.message.cmp(&b.message));
5270    diagnostics
5271}
5272
5273#[cfg(test)]
5274mod tests {
5275    use super::*;
5276
5277    #[test]
5278    fn normalize_pmm_model_item_name_fits_sjis_without_splitting_character() {
5279        assert_eq!(
5280            normalize_pmm_model_item_name("123456789あいうえおか"),
5281            "123456789あいうえお"
5282        );
5283    }
5284
5285    fn pmm_with_project_settings() -> Vec<u8> {
5286        let mut data = b"Polygon Movie maker 0002".to_vec();
5287        data.resize(30, 0);
5288        data.extend_from_slice(&1920u32.to_le_bytes());
5289        data.extend_from_slice(&1080u32.to_le_bytes());
5290        data.extend_from_slice(&424u32.to_le_bytes());
5291        data.extend_from_slice(&30.0f32.to_le_bytes());
5292        data.extend_from_slice(&[1, 1, 2, 0, 0, 0, 0, 0]);
5293        data.push(3);
5294        data.push(0);
5295        data.extend_from_slice(
5296            b"UserFile\\Model\\miku.pmx\0UserFile\\Motion\\walk.vmd\0UserFile\\Audio\\song.wav\0",
5297        );
5298        data
5299    }
5300
5301    fn pmm_with_initial_model_slot() -> Vec<u8> {
5302        let mut data = b"Polygon Movie maker 0002".to_vec();
5303        data.resize(30, 0);
5304        data.extend_from_slice(&640u32.to_le_bytes());
5305        data.extend_from_slice(&360u32.to_le_bytes());
5306        data.extend_from_slice(&250u32.to_le_bytes());
5307        data.extend_from_slice(&30.0f32.to_le_bytes());
5308        data.extend_from_slice(&[0, 1, 1, 1, 1, 1, 1, 0]);
5309        data.push(1);
5310        data.push(0);
5311        data.push(9);
5312        data.extend_from_slice(b"TestModel");
5313        data.push(4);
5314        data.extend_from_slice(b"Base");
5315        data.extend_from_slice(b"C:\\MMDDev\\data\\unittest\\test_1bone_cube.pmx");
5316        data.push(0);
5317        data.extend_from_slice(b"UserFile\\Motion\\walk.vmd\0");
5318        data
5319    }
5320
5321    #[test]
5322    fn exports_pmm_project_settings_and_asset_references() {
5323        let parsed = parse_pmm_manifest(&pmm_with_project_settings()).unwrap();
5324        let exported = export_pmm_manifest(&parsed);
5325        let reparsed = parse_pmm_manifest(&exported).unwrap();
5326
5327        assert_eq!(reparsed.version, "0002");
5328        assert_eq!(reparsed.project_settings.screen_width, Some(1920));
5329        assert_eq!(reparsed.project_settings.screen_height, Some(1080));
5330        assert_eq!(reparsed.project_settings.timeline_frame_count, Some(424));
5331        assert_eq!(reparsed.project_settings.frame_rate, Some(30.0));
5332        assert_eq!(reparsed.model_paths, vec!["UserFile/Model/miku.pmx"]);
5333        assert_eq!(reparsed.motion_paths, vec!["UserFile/Motion/walk.vmd"]);
5334        assert_eq!(reparsed.audio_paths, vec!["UserFile/Audio/song.wav"]);
5335    }
5336
5337    #[test]
5338    fn exports_pmm_manifest_preserves_v1_input_bytes() {
5339        let mut data = pmm_with_project_settings();
5340        data[20..24].copy_from_slice(b"0001");
5341        let parsed = parse_pmm_manifest(&data).unwrap();
5342        assert_eq!(parsed.version, "0001");
5343
5344        let exported = export_pmm_manifest(&parsed);
5345        assert_eq!(
5346            exported, data,
5347            "PMMv1 input must be preserved byte-for-byte"
5348        );
5349        let reparsed = parse_pmm_manifest(&exported).unwrap();
5350
5351        assert_eq!(reparsed.version, "0001");
5352        assert_eq!(reparsed.project_settings.timeline_frame_count, Some(424));
5353        assert_eq!(reparsed.model_paths, vec!["UserFile/Model/miku.pmx"]);
5354    }
5355
5356    #[test]
5357    fn exports_pmm_initial_model_slot() {
5358        let parsed = parse_pmm_manifest(&pmm_with_initial_model_slot()).unwrap();
5359        let exported = export_pmm_manifest(&parsed);
5360        let reparsed = parse_pmm_manifest(&exported).unwrap();
5361
5362        assert_eq!(reparsed.project_settings.screen_width, Some(640));
5363        assert_eq!(reparsed.project_settings.screen_height, Some(360));
5364        assert_eq!(reparsed.project_settings.timeline_frame_count, Some(250));
5365        assert_eq!(reparsed.model_slots.len(), 1);
5366        let slot = &reparsed.model_slots[0];
5367        assert_eq!(slot.name, "TestModel");
5368        assert_eq!(slot.english_name, "Base");
5369        assert_eq!(
5370            slot.normalized_path,
5371            "C:/MMDDev/data/unittest/test_1bone_cube.pmx"
5372        );
5373        assert_eq!(reparsed.motion_paths, vec!["UserFile/Motion/walk.vmd"]);
5374    }
5375
5376    #[test]
5377    fn pmm_export_roundtrip_bytes_v2_document_global() {
5378        let input = pmm_with_document_global_summary();
5379        let parsed = parse_pmm_manifest(&input).unwrap();
5380        let exported = export_pmm_manifest(&parsed);
5381        assert_eq!(
5382            exported, input,
5383            "parse->export must be byte-for-byte for PMMv2 document/global synthetic"
5384        );
5385        assert!(parsed.document_summary.is_some());
5386        assert!(parsed.document_global_summary.is_some());
5387    }
5388
5389    #[test]
5390    fn pmm_export_roundtrip_bytes_v2_document_summary() {
5391        let input = pmm_with_document_summary();
5392        let parsed = parse_pmm_manifest(&input).unwrap();
5393        let exported = export_pmm_manifest(&parsed);
5394        assert_eq!(
5395            exported, input,
5396            "parse->export must be byte-for-byte for PMMv2 document synthetic"
5397        );
5398    }
5399
5400    #[test]
5401    fn pmm_document_model_path_override_basic() {
5402        // Uses only repo-local synthetic fixture (pmm_with_document_summary) -- no MMDDumper.
5403        let input = pmm_with_document_summary();
5404        let parsed = parse_pmm_manifest(&input).unwrap();
5405        let document = parsed
5406            .document_summary
5407            .as_ref()
5408            .expect("document_summary required");
5409        let model0 = &document.models[0];
5410        assert_eq!(model0.document_model_index, 0);
5411        let original_path = model0.path.clone();
5412        let path_offset = model0.path_offset;
5413
5414        let new_path = "UserFile\\Model\\override_for_gui_smoke.pmx";
5415        let patched =
5416            export_pmm_manifest_with_document_model_path_overrides(&parsed, &[(0u8, new_path)])
5417                .expect("override patch must succeed for valid short path");
5418
5419        assert_eq!(
5420            patched.len(),
5421            input.len(),
5422            "patched bytes must keep identical length"
5423        );
5424
5425        // Only the target 256-byte document model path field may differ.
5426        let mut changed_positions = Vec::new();
5427        for (i, (a, b)) in input.iter().zip(patched.iter()).enumerate() {
5428            if a != b {
5429                changed_positions.push(i);
5430            }
5431        }
5432        assert!(
5433            !changed_positions.is_empty(),
5434            "expected at least the path bytes to change"
5435        );
5436        let field_start = path_offset;
5437        let field_end = path_offset + 256;
5438        for &pos in &changed_positions {
5439            assert!(
5440                pos >= field_start && pos < field_end,
5441                "changed byte at {} must be inside the document model path field [{}, {})",
5442                pos,
5443                field_start,
5444                field_end
5445            );
5446        }
5447
5448        let reparsed = parse_pmm_manifest(&patched).unwrap();
5449        let reparsed_doc = reparsed
5450            .document_summary
5451            .as_ref()
5452            .expect("reparsed must have document_summary");
5453        assert_eq!(
5454            reparsed_doc.models[0].path, new_path,
5455            "reparsed document model path must reflect the override"
5456        );
5457        assert_ne!(
5458            reparsed_doc.models[0].path, original_path,
5459            "reparsed path must differ from original"
5460        );
5461    }
5462
5463    #[test]
5464    fn pmm_document_model_path_override_too_long_returns_err() {
5465        let input = pmm_with_document_summary();
5466        let parsed = parse_pmm_manifest(&input).unwrap();
5467        // 256 ASCII chars -> SJIS 256 bytes >=256 -> err (must leave room for NUL)
5468        let too_long = "X".repeat(256);
5469        let result =
5470            export_pmm_manifest_with_document_model_path_overrides(&parsed, &[(0u8, &too_long)]);
5471        assert!(result.is_err(), "too-long path must error");
5472        let msg = result.unwrap_err();
5473        assert!(
5474            msg.contains("NUL") || msg.contains("too long") || msg.contains("leave room"),
5475            "error message should indicate length/NUL room issue: {}",
5476            msg
5477        );
5478    }
5479
5480    #[test]
5481    fn pmm_document_model_path_override_missing_index_returns_err() {
5482        let input = pmm_with_document_summary();
5483        let parsed = parse_pmm_manifest(&input).unwrap();
5484        let result = export_pmm_manifest_with_document_model_path_overrides(
5485            &parsed,
5486            &[(99u8, "some/path.pmx")],
5487        );
5488        assert!(result.is_err(), "missing document_model_index must error");
5489        let msg = result.unwrap_err();
5490        assert!(
5491            msg.contains("exactly one") && msg.contains("99"),
5492            "error should report missing index: {}",
5493            msg
5494        );
5495    }
5496
5497    #[test]
5498    fn pmm_document_model_path_override_requires_source_bytes() {
5499        let input = pmm_with_document_summary();
5500        let mut parsed = parse_pmm_manifest(&input).unwrap();
5501        parsed.source_bytes.clear();
5502
5503        let result = export_pmm_manifest_with_document_model_path_overrides(
5504            &parsed,
5505            &[(0u8, "UserFile\\Model\\override_for_gui_smoke.pmx")],
5506        );
5507        assert!(result.is_err(), "missing source bytes must error");
5508        let msg = result.unwrap_err();
5509        assert!(
5510            msg.contains("source bytes"),
5511            "error should explain source bytes are required: {}",
5512            msg
5513        );
5514    }
5515
5516    #[test]
5517    fn pmm_scene_frame_range_patch_updates_only_expected_bytes() {
5518        let input = pmm_with_document_global_summary();
5519        let parsed = parse_pmm_manifest(&input).unwrap();
5520        let settings = parsed
5521            .document_global_summary
5522            .as_ref()
5523            .expect("fixture must have global summary")
5524            .settings
5525            .clone();
5526
5527        let patch = PmmSceneFrameRangePatch {
5528            current_frame_index: Some(99),
5529            current_frame_index_in_text_field: Some(77),
5530            begin_frame_index_enabled: Some(true),
5531            end_frame_index_enabled: Some(false),
5532            begin_frame_index: Some(10),
5533            end_frame_index: Some(240),
5534        };
5535        let patched = export_pmm_manifest_with_scene_frame_range_patch(&parsed, &patch)
5536            .expect("scene frame range patch must succeed");
5537
5538        assert_eq!(
5539            patched.len(),
5540            input.len(),
5541            "patch must preserve byte length"
5542        );
5543
5544        let reparsed = parse_pmm_manifest(&patched).unwrap();
5545        let updated = &reparsed
5546            .document_global_summary
5547            .as_ref()
5548            .expect("patched PMM must keep global summary")
5549            .settings;
5550        assert_eq!(updated.current_frame_index, 99);
5551        assert_eq!(updated.current_frame_index_in_text_field, 77);
5552        assert!(updated.begin_frame_index_enabled);
5553        assert!(!updated.end_frame_index_enabled);
5554        assert_eq!(updated.begin_frame_index, 10);
5555        assert_eq!(updated.end_frame_index, 240);
5556
5557        let mut allowed_changed = Vec::new();
5558        allowed_changed
5559            .extend(settings.current_frame_index_offset..settings.current_frame_index_offset + 4);
5560        allowed_changed.push(settings.begin_frame_index_enabled_offset);
5561        allowed_changed.push(settings.end_frame_index_enabled_offset);
5562        allowed_changed
5563            .extend(settings.begin_frame_index_offset..settings.begin_frame_index_offset + 4);
5564        allowed_changed
5565            .extend(settings.end_frame_index_offset..settings.end_frame_index_offset + 4);
5566        allowed_changed.extend(
5567            settings.current_frame_index_in_text_field_offset
5568                ..settings.current_frame_index_in_text_field_offset + 4,
5569        );
5570
5571        let changed: Vec<usize> = input
5572            .iter()
5573            .zip(patched.iter())
5574            .enumerate()
5575            .filter_map(|(index, (before, after))| (before != after).then_some(index))
5576            .collect();
5577
5578        assert!(
5579            changed.iter().all(|index| allowed_changed.contains(index)),
5580            "scene frame range patch changed bytes outside decoded frame range fields: {:?}",
5581            changed
5582                .iter()
5583                .filter(|index| !allowed_changed.contains(index))
5584                .collect::<Vec<_>>()
5585        );
5586        assert_eq!(
5587            &patched[settings.current_frame_index_offset..settings.current_frame_index_offset + 4],
5588            &99i32.to_le_bytes()
5589        );
5590        assert_eq!(patched[settings.begin_frame_index_enabled_offset], 1);
5591        assert_eq!(patched[settings.end_frame_index_enabled_offset], 0);
5592        assert_eq!(
5593            &patched[settings.begin_frame_index_offset..settings.begin_frame_index_offset + 4],
5594            &10i32.to_le_bytes()
5595        );
5596        assert_eq!(
5597            &patched[settings.end_frame_index_offset..settings.end_frame_index_offset + 4],
5598            &240i32.to_le_bytes()
5599        );
5600        assert_eq!(
5601            &patched[settings.current_frame_index_in_text_field_offset
5602                ..settings.current_frame_index_in_text_field_offset + 4],
5603            &77i32.to_le_bytes()
5604        );
5605    }
5606
5607    #[test]
5608    fn pmm_scene_frame_range_patch_noop_preserves_source_bytes() {
5609        let input = pmm_with_document_global_summary();
5610        let parsed = parse_pmm_manifest(&input).unwrap();
5611        let patched = export_pmm_manifest_with_scene_frame_range_patch(
5612            &parsed,
5613            &PmmSceneFrameRangePatch::default(),
5614        )
5615        .expect("no-op scene frame range patch must succeed");
5616
5617        assert_eq!(patched, input, "no-op patch must be byte-identical");
5618    }
5619
5620    #[test]
5621    fn pmm_scene_frame_range_patch_requires_source_bytes() {
5622        let input = pmm_with_document_global_summary();
5623        let mut parsed = parse_pmm_manifest(&input).unwrap();
5624        parsed.source_bytes.clear();
5625
5626        let result = export_pmm_manifest_with_scene_frame_range_patch(
5627            &parsed,
5628            &PmmSceneFrameRangePatch {
5629                begin_frame_index: Some(1),
5630                ..PmmSceneFrameRangePatch::default()
5631            },
5632        );
5633        assert!(result.is_err(), "missing source bytes must error");
5634        let msg = result.unwrap_err();
5635        assert!(
5636            msg.contains("source bytes"),
5637            "error should explain source bytes are required: {}",
5638            msg
5639        );
5640    }
5641
5642    #[test]
5643    fn pmm_scene_media_path_patch_updates_only_expected_bytes() {
5644        let input = pmm_with_document_global_summary();
5645        let parsed = parse_pmm_manifest(&input).unwrap();
5646        let settings = parsed
5647            .document_global_summary
5648            .as_ref()
5649            .expect("fixture must have global summary")
5650            .settings
5651            .clone();
5652
5653        let audio_path = "UserFile\\Audio\\song.wav";
5654        let background_video_path = "UserFile\\Movie\\bg.avi";
5655        let background_image_path = "UserFile\\Image\\bg.png";
5656        let patch = PmmSceneMediaPathPatch {
5657            audio_path: Some(audio_path.to_owned()),
5658            background_video_path: Some(background_video_path.to_owned()),
5659            background_image_path: Some(background_image_path.to_owned()),
5660        };
5661        let patched = export_pmm_manifest_with_scene_media_path_patch(&parsed, &patch)
5662            .expect("scene media path patch must succeed");
5663
5664        assert_eq!(
5665            patched.len(),
5666            input.len(),
5667            "patch must preserve byte length"
5668        );
5669
5670        let reparsed = parse_pmm_manifest(&patched).unwrap();
5671        let updated = &reparsed
5672            .document_global_summary
5673            .as_ref()
5674            .expect("patched PMM must keep global summary")
5675            .settings;
5676        assert_eq!(updated.audio_path, audio_path);
5677        assert_eq!(updated.background_video_path, background_video_path);
5678        assert_eq!(updated.background_image_path, background_image_path);
5679
5680        let bindings = &reparsed
5681            .project_graph
5682            .as_ref()
5683            .expect("patched PMM must keep project graph")
5684            .asset_bindings;
5685        let scene_binding_offset = |kind: &str| {
5686            bindings
5687                .iter()
5688                .find(|binding| binding.scope == "sceneSettings" && binding.asset_kind == kind)
5689                .and_then(|binding| binding.path_offset)
5690        };
5691        assert_eq!(
5692            scene_binding_offset("audio"),
5693            Some(settings.audio_path_offset)
5694        );
5695        assert_eq!(
5696            scene_binding_offset("video"),
5697            Some(settings.background_video_path_offset)
5698        );
5699        assert_eq!(
5700            scene_binding_offset("image"),
5701            Some(settings.background_image_path_offset)
5702        );
5703
5704        let allowed_ranges = [
5705            settings.audio_path_offset..settings.audio_path_offset + 256,
5706            settings.background_video_path_offset..settings.background_video_path_offset + 256,
5707            settings.background_image_path_offset..settings.background_image_path_offset + 256,
5708        ];
5709        let changed: Vec<usize> = input
5710            .iter()
5711            .zip(patched.iter())
5712            .enumerate()
5713            .filter_map(|(index, (before, after))| (before != after).then_some(index))
5714            .collect();
5715
5716        assert!(
5717            changed
5718                .iter()
5719                .all(|index| allowed_ranges.iter().any(|range| range.contains(index))),
5720            "scene media path patch changed bytes outside decoded media path fields: {:?}",
5721            changed
5722                .iter()
5723                .filter(|index| !allowed_ranges.iter().any(|range| range.contains(index)))
5724                .collect::<Vec<_>>()
5725        );
5726
5727        let assert_path_field = |offset: usize, path: &str| {
5728            let field = &patched[offset..offset + 256];
5729            assert_eq!(&field[..path.len()], path.as_bytes());
5730            assert_eq!(field[path.len()], 0);
5731        };
5732        assert_path_field(settings.audio_path_offset, audio_path);
5733        assert_path_field(settings.background_video_path_offset, background_video_path);
5734        assert_path_field(settings.background_image_path_offset, background_image_path);
5735    }
5736
5737    #[test]
5738    fn pmm_scene_media_path_patch_noop_preserves_source_bytes() {
5739        let input = pmm_with_document_global_summary();
5740        let parsed = parse_pmm_manifest(&input).unwrap();
5741        let patched = export_pmm_manifest_with_scene_media_path_patch(
5742            &parsed,
5743            &PmmSceneMediaPathPatch::default(),
5744        )
5745        .expect("no-op scene media path patch must succeed");
5746
5747        assert_eq!(patched, input, "no-op patch must be byte-identical");
5748    }
5749
5750    #[test]
5751    fn pmm_scene_media_path_patch_requires_source_bytes() {
5752        let input = pmm_with_document_global_summary();
5753        let mut parsed = parse_pmm_manifest(&input).unwrap();
5754        parsed.source_bytes.clear();
5755
5756        let result = export_pmm_manifest_with_scene_media_path_patch(
5757            &parsed,
5758            &PmmSceneMediaPathPatch {
5759                audio_path: Some("UserFile\\Audio\\song.wav".to_owned()),
5760                ..PmmSceneMediaPathPatch::default()
5761            },
5762        );
5763        assert!(result.is_err(), "missing source bytes must error");
5764        let msg = result.unwrap_err();
5765        assert!(
5766            msg.contains("source bytes"),
5767            "error should explain source bytes are required: {}",
5768            msg
5769        );
5770    }
5771
5772    #[test]
5773    fn pmm_scene_media_path_patch_too_long_returns_err() {
5774        let input = pmm_with_document_global_summary();
5775        let parsed = parse_pmm_manifest(&input).unwrap();
5776
5777        let result = export_pmm_manifest_with_scene_media_path_patch(
5778            &parsed,
5779            &PmmSceneMediaPathPatch {
5780                audio_path: Some("X".repeat(256)),
5781                ..PmmSceneMediaPathPatch::default()
5782            },
5783        );
5784        assert!(result.is_err(), "too-long media path must error");
5785        let msg = result.unwrap_err();
5786        assert!(
5787            msg.contains("NUL") || msg.contains("leave room"),
5788            "error should explain fixed string length/NUL room: {}",
5789            msg
5790        );
5791    }
5792
5793    #[test]
5794    fn pmm_export_json_top_level_does_not_include_source_bytes() {
5795        // Explicit proof per spec: serde_json output must not expose the internal preservation field.
5796        let data = pmm_with_document_global_summary();
5797        let parsed = parse_pmm_manifest(&data).unwrap();
5798        let value = serde_json::to_value(&parsed).unwrap();
5799        let keys = json_keys(&value);
5800        assert!(
5801            !keys.contains(&"sourceBytes".to_string())
5802                && !keys.contains(&"source_bytes".to_string()),
5803            "top-level JSON keys must not include source bytes field: {:?}",
5804            keys
5805        );
5806    }
5807
5808    #[test]
5809    fn exports_pmm_scene_header_timeline_fps_and_camera_fov() {
5810        let model =
5811            crate::pmx::parse_pmx_model(include_bytes!("../fixtures/pmx/ik_multi_axis_limit.pmx"))
5812                .unwrap();
5813        let motion = VmdParsedAnimation {
5814            kind: "vmd",
5815            metadata: crate::vmd::VmdParsedMetadata {
5816                format: "vmd",
5817                model_name: "ik_multi_axis_limit_fixture".to_owned(),
5818                model_name_bytes: Vec::new(),
5819                counts: crate::vmd::VmdParsedCounts {
5820                    bones: 1,
5821                    morphs: 0,
5822                    cameras: 0,
5823                    lights: 0,
5824                    self_shadows: 0,
5825                    properties: 0,
5826                },
5827                max_frame: 600,
5828            },
5829            bone_frames: vec![VmdParsedBoneFrame {
5830                bone_name: "link_root".to_owned(),
5831                bone_name_bytes: Vec::new(),
5832                frame: 600,
5833                translation: [0.0, 1.0, 0.0],
5834                rotation: [0.0, 0.0, 0.0, 1.0],
5835                interpolation: vec![20; 64],
5836            }],
5837            morph_frames: Vec::new(),
5838            camera_frames: Vec::new(),
5839            light_frames: Vec::new(),
5840            self_shadow_frames: Vec::new(),
5841            property_frames: Vec::new(),
5842        };
5843
5844        let report = export_pmm_scene_from_pmx_vmd(
5845            &model,
5846            &motion,
5847            "UserFile/Model/ik_multi_axis_limit.pmx",
5848            &PmmSceneExportOptions {
5849                screen_width: 800,
5850                screen_height: 600,
5851                frame_rate: 60.0,
5852                camera_fov: 42.0,
5853            },
5854        );
5855        let reparsed = parse_pmm_manifest(&report.bytes).unwrap();
5856
5857        assert_eq!(report.max_frame, 600);
5858        assert_eq!(reparsed.project_settings.screen_width, Some(800));
5859        assert_eq!(reparsed.project_settings.screen_height, Some(600));
5860        assert_eq!(reparsed.project_settings.timeline_frame_count, Some(600));
5861        assert_eq!(reparsed.project_settings.frame_rate, Some(60.0));
5862        let global = reparsed.document_global_summary.as_ref().unwrap();
5863        match global.camera.initial_keyframe.as_ref().unwrap() {
5864            PmmDocumentKeyframeSummary::Camera { fov, .. } => assert_eq!(*fov, 42),
5865            other => panic!("unexpected camera keyframe summary: {other:?}"),
5866        }
5867    }
5868
5869    fn append_pmm_model_slot(data: &mut Vec<u8>, name: &[u8], english_name: &[u8], path: &[u8]) {
5870        data.push(name.len() as u8);
5871        data.extend_from_slice(name);
5872        data.push(english_name.len() as u8);
5873        data.extend_from_slice(english_name);
5874        data.extend_from_slice(path);
5875        data.push(0);
5876    }
5877
5878    fn push_i32(data: &mut Vec<u8>, value: i32) {
5879        data.extend_from_slice(&value.to_le_bytes());
5880    }
5881
5882    fn push_f32(data: &mut Vec<u8>, value: f32) {
5883        data.extend_from_slice(&value.to_le_bytes());
5884    }
5885
5886    fn push_pmm_variable_string(data: &mut Vec<u8>, value: &[u8]) {
5887        data.push(value.len() as u8);
5888        data.extend_from_slice(value);
5889    }
5890
5891    fn push_pmm_fixed_path(data: &mut Vec<u8>, value: &[u8]) {
5892        let mut path = [0u8; 256];
5893        path[..value.len()].copy_from_slice(value);
5894        data.extend_from_slice(&path);
5895    }
5896
5897    fn push_pmm_fixed_bytes<const N: usize>(data: &mut Vec<u8>, value: &[u8]) {
5898        let mut bytes = [0u8; N];
5899        bytes[..value.len()].copy_from_slice(value);
5900        data.extend_from_slice(&bytes);
5901    }
5902
5903    fn push_document_bone_keyframe(data: &mut Vec<u8>, include_index: bool, frame: i32) {
5904        if include_index {
5905            push_i32(data, 0);
5906        }
5907        push_i32(data, frame);
5908        push_i32(data, 0);
5909        push_i32(data, 0);
5910        data.extend_from_slice(&[20u8; 16]);
5911        data.extend_from_slice(&[0.0f32.to_le_bytes(); 3].concat());
5912        push_f32(data, 0.0);
5913        push_f32(data, 0.0);
5914        push_f32(data, 0.0);
5915        push_f32(data, 1.0);
5916        data.push(0);
5917        data.push(0);
5918    }
5919
5920    fn push_document_morph_keyframe(data: &mut Vec<u8>, include_index: bool, frame: i32) {
5921        if include_index {
5922            push_i32(data, 0);
5923        }
5924        push_i32(data, frame);
5925        push_i32(data, 0);
5926        push_i32(data, 0);
5927        push_f32(data, 0.5);
5928        data.push(0);
5929    }
5930
5931    fn push_document_model_keyframe(data: &mut Vec<u8>, include_index: bool, frame: i32) {
5932        if include_index {
5933            push_i32(data, frame);
5934        }
5935        push_i32(data, frame);
5936        push_i32(data, 0);
5937        push_i32(data, 0);
5938        data.push(1);
5939        data.push(1);
5940        push_i32(data, 2);
5941        push_i32(data, 3);
5942        data.push(1);
5943    }
5944
5945    fn assert_model_keyframe_payload_layout(kf: &PmmDocumentModelKeyframeSummary) {
5946        assert_eq!(kf.visible_offset, kf.payload_offset);
5947        assert_eq!(kf.constraint_state_count, kf.constraint_states.len());
5948        assert_eq!(kf.constraint_state_count, 1);
5949        assert_eq!(kf.constraint_states_offset, kf.payload_offset + 1);
5950        assert_eq!(kf.constraint_states_byte_length, kf.constraint_state_count);
5951        assert_eq!(
5952            kf.outside_parent_index_count,
5953            kf.outside_parent_indices.len()
5954        );
5955        assert_eq!(kf.outside_parent_index_count, 1);
5956        assert_eq!(kf.outside_parent_indices_offset, kf.payload_offset + 2);
5957        assert_eq!(
5958            kf.outside_parent_indices_byte_length,
5959            kf.outside_parent_index_count * 8
5960        );
5961        assert_eq!(
5962            kf.self_shadow_enabled_offset,
5963            kf.payload_offset + kf.payload_byte_length - 1
5964        );
5965        assert_eq!(
5966            kf.payload_byte_length,
5967            1 + kf.constraint_states_byte_length + kf.outside_parent_indices_byte_length + 1
5968        );
5969        assert_eq!(kf.payload_bytes[0], kf.visible as u8);
5970        assert_eq!(kf.payload_bytes[1], kf.constraint_states[0] as u8);
5971        assert_eq!(
5972            i32::from_le_bytes(kf.payload_bytes[2..6].try_into().unwrap()),
5973            kf.outside_parent_indices[0].parent_model_index
5974        );
5975        assert_eq!(
5976            i32::from_le_bytes(kf.payload_bytes[6..10].try_into().unwrap()),
5977            kf.outside_parent_indices[0].parent_model_bone_index
5978        );
5979        assert_eq!(
5980            kf.payload_bytes[kf.payload_bytes.len() - 1],
5981            kf.self_shadow_enabled as u8
5982        );
5983    }
5984
5985    fn push_document_base_keyframe(data: &mut Vec<u8>, include_index: bool, frame: i32) {
5986        if include_index {
5987            push_i32(data, frame);
5988        }
5989        push_i32(data, frame);
5990        push_i32(data, 0);
5991        push_i32(data, 0);
5992    }
5993
5994    fn push_document_camera_keyframe(data: &mut Vec<u8>, include_index: bool, frame: i32) {
5995        push_document_base_keyframe(data, include_index, frame);
5996        push_f32(data, 45.0);
5997        data.extend_from_slice(&[0u8; 12]);
5998        data.extend_from_slice(&[0u8; 12]);
5999        push_i32(data, -1);
6000        push_i32(data, -1);
6001        data.extend_from_slice(&[20u8; 24]);
6002        data.push(0);
6003        push_i32(data, 30);
6004        data.push(0);
6005    }
6006
6007    fn push_document_light_keyframe(data: &mut Vec<u8>, include_index: bool, frame: i32) {
6008        push_document_base_keyframe(data, include_index, frame);
6009        for value in [0.6f32, 0.6, 0.6, -0.5, -1.0, 0.5] {
6010            push_f32(data, value);
6011        }
6012        data.push(0);
6013    }
6014
6015    fn push_document_gravity_keyframe(data: &mut Vec<u8>, include_index: bool, frame: i32) {
6016        push_document_base_keyframe(data, include_index, frame);
6017        data.push(0);
6018        push_i32(data, 10);
6019        push_f32(data, 9.8);
6020        for value in [0.0f32, -1.0, 0.0] {
6021            push_f32(data, value);
6022        }
6023        data.push(0);
6024    }
6025
6026    fn push_document_self_shadow_keyframe(data: &mut Vec<u8>, include_index: bool, frame: i32) {
6027        push_document_base_keyframe(data, include_index, frame);
6028        data.push(1);
6029        push_f32(data, 8875.0);
6030        data.push(0);
6031    }
6032
6033    fn push_document_accessory_keyframe(data: &mut Vec<u8>, include_index: bool, frame: i32) {
6034        push_document_base_keyframe(data, include_index, frame);
6035        data.push(1);
6036        push_i32(data, -1);
6037        push_i32(data, -1);
6038        for value in [1.0f32, 2.0, 3.0, 0.1, 0.2, 0.3] {
6039            push_f32(data, value);
6040        }
6041        push_f32(data, 10.0);
6042        data.push(1);
6043        data.push(0);
6044    }
6045
6046    fn push_empty_pmm_path(data: &mut Vec<u8>) {
6047        data.extend_from_slice(&[0u8; 256]);
6048    }
6049
6050    fn pmm_with_document_summary() -> Vec<u8> {
6051        let mut data = b"Polygon Movie maker 0002".to_vec();
6052        data.resize(30, 0);
6053        push_i32(&mut data, 640);
6054        push_i32(&mut data, 360);
6055        push_i32(&mut data, 120);
6056        push_f32(&mut data, 30.0);
6057        data.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0]);
6058        data.push(0);
6059        data.push(1);
6060        data.push(0);
6061        push_pmm_variable_string(&mut data, b"Miku");
6062        push_pmm_variable_string(&mut data, b"MikuEn");
6063        push_pmm_fixed_path(&mut data, b"UserFile\\Model\\miku.pmx");
6064        data.push(0);
6065        push_i32(&mut data, 1);
6066        push_pmm_variable_string(&mut data, b"center");
6067        push_i32(&mut data, 1);
6068        push_pmm_variable_string(&mut data, b"smile");
6069        push_i32(&mut data, 1);
6070        push_i32(&mut data, 7);
6071        push_i32(&mut data, 1);
6072        push_i32(&mut data, 8);
6073        data.push(0);
6074        data.push(1);
6075        push_i32(&mut data, 0);
6076        for _ in 0..4 {
6077            push_i32(&mut data, -1);
6078        }
6079        data.push(0);
6080        push_i32(&mut data, 0);
6081        push_i32(&mut data, 30);
6082        push_document_bone_keyframe(&mut data, false, 0);
6083        push_i32(&mut data, 1);
6084        push_document_bone_keyframe(&mut data, true, 30);
6085        push_document_morph_keyframe(&mut data, false, 0);
6086        push_i32(&mut data, 1);
6087        push_document_morph_keyframe(&mut data, true, 15);
6088        push_document_model_keyframe(&mut data, false, 0);
6089        push_i32(&mut data, 1);
6090        push_document_model_keyframe(&mut data, true, 30);
6091        // bone state: translation [1.0, 2.0, 3.0], orientation [0.0, 0.0, 0.0, 1.0], dirty=true, physicsDisabled=false, selected=true
6092        push_f32(&mut data, 1.0);
6093        push_f32(&mut data, 2.0);
6094        push_f32(&mut data, 3.0);
6095        push_f32(&mut data, 0.0);
6096        push_f32(&mut data, 0.0);
6097        push_f32(&mut data, 0.0);
6098        push_f32(&mut data, 1.0);
6099        data.push(1); // dirty
6100        data.push(0); // physicsDisabled
6101        data.push(1); // selected
6102        // morph state: weight 0.75
6103        push_f32(&mut data, 0.75);
6104        // constraint state: enabled=true
6105        data.push(1);
6106        // outside parent state: parentModelIndex=0, parentModelBoneIndex=5, subjectBoneIndex=7, targetModelIndex=1
6107        push_i32(&mut data, 0);
6108        push_i32(&mut data, 5);
6109        push_i32(&mut data, 7);
6110        push_i32(&mut data, 1);
6111        data.push(1);
6112        push_f32(&mut data, 1.0);
6113        data.push(0);
6114        data.push(0);
6115        data.extend_from_slice(b"UserFile\\Model\\miku.pmx\0");
6116        data
6117    }
6118
6119    fn pmm_with_document_global_summary() -> Vec<u8> {
6120        let mut data = pmm_with_document_summary();
6121        let trailing_reference = b"UserFile\\Model\\miku.pmx\0";
6122        data.truncate(data.len() - trailing_reference.len());
6123
6124        push_document_camera_keyframe(&mut data, false, 0);
6125        push_i32(&mut data, 1);
6126        push_document_camera_keyframe(&mut data, true, 42);
6127        data.extend_from_slice(&[0u8; 12 * 3]);
6128        data.push(0);
6129
6130        push_document_light_keyframe(&mut data, false, 0);
6131        push_i32(&mut data, 1);
6132        push_document_light_keyframe(&mut data, true, 43);
6133        for value in [0.6f32, 0.6, 0.6, -0.5, -1.0, 0.5] {
6134            push_f32(&mut data, value);
6135        }
6136
6137        data.push(0);
6138        push_i32(&mut data, 0);
6139        data.push(1);
6140        data.push(0);
6141        push_pmm_fixed_bytes::<100>(&mut data, b"Stage");
6142        push_pmm_fixed_path(&mut data, b"UserFile\\Accessory\\stage.x");
6143        data.push(0);
6144        push_document_accessory_keyframe(&mut data, false, 0);
6145        push_i32(&mut data, 1);
6146        push_document_accessory_keyframe(&mut data, true, 46);
6147        data.push(1);
6148        push_i32(&mut data, -1);
6149        push_i32(&mut data, -1);
6150        for value in [1.0f32, 2.0, 3.0] {
6151            push_f32(&mut data, value);
6152        }
6153        push_f32(&mut data, 10.0);
6154        for value in [0.1f32, 0.2, 0.3] {
6155            push_f32(&mut data, value);
6156        }
6157        data.push(1);
6158        data.push(0);
6159
6160        push_i32(&mut data, 12);
6161        push_i32(&mut data, 34);
6162        push_i32(&mut data, 56);
6163        push_i32(&mut data, 2);
6164        data.push(0);
6165        data.push(1);
6166        data.push(0);
6167        data.push(1);
6168        push_i32(&mut data, 3);
6169        push_i32(&mut data, 120);
6170        data.push(1);
6171        push_empty_pmm_path(&mut data);
6172        push_i32(&mut data, 0);
6173        push_i32(&mut data, 0);
6174        push_f32(&mut data, 1.0);
6175        push_empty_pmm_path(&mut data);
6176        push_i32(&mut data, 0);
6177        push_i32(&mut data, 0);
6178        push_i32(&mut data, 0);
6179        push_f32(&mut data, 1.0);
6180        push_empty_pmm_path(&mut data);
6181        data.push(0);
6182        data.push(1);
6183        data.push(1);
6184        data.push(1);
6185        push_f32(&mut data, 60.0);
6186        push_i32(&mut data, 2);
6187        push_i32(&mut data, -1);
6188        push_f32(&mut data, 1.0);
6189        data.push(1);
6190        data.push(2);
6191
6192        push_f32(&mut data, 9.8);
6193        push_i32(&mut data, 10);
6194        for value in [0.0f32, -1.0, 0.0] {
6195            push_f32(&mut data, value);
6196        }
6197        data.push(0);
6198        push_document_gravity_keyframe(&mut data, false, 0);
6199        push_i32(&mut data, 1);
6200        push_document_gravity_keyframe(&mut data, true, 44);
6201
6202        data.push(1);
6203        push_f32(&mut data, 8875.0);
6204        push_document_self_shadow_keyframe(&mut data, false, 0);
6205        push_i32(&mut data, 1);
6206        push_document_self_shadow_keyframe(&mut data, true, 45);
6207
6208        push_i32(&mut data, 0);
6209        push_i32(&mut data, 0);
6210        push_i32(&mut data, 0);
6211        data.push(0);
6212        push_i32(&mut data, -1);
6213        push_i32(&mut data, -1);
6214        data.extend_from_slice(&[0u8; 16 * 4]);
6215        data.push(0);
6216        data.push(0);
6217        data.push(1);
6218        push_i32(&mut data, 12);
6219        data.push(1);
6220        data.push(0);
6221        push_i32(&mut data, 0);
6222
6223        data
6224    }
6225
6226    fn pmm_with_two_initial_model_slots() -> Vec<u8> {
6227        let mut data = b"Polygon Movie maker 0002".to_vec();
6228        data.resize(30, 0);
6229        data.extend_from_slice(&640u32.to_le_bytes());
6230        data.extend_from_slice(&360u32.to_le_bytes());
6231        data.extend_from_slice(&250u32.to_le_bytes());
6232        data.extend_from_slice(&30.0f32.to_le_bytes());
6233        data.extend_from_slice(&[1, 1, 0, 0, 0, 0, 0, 0]);
6234        data.push(1);
6235        data.push(0);
6236        append_pmm_model_slot(
6237            &mut data,
6238            b"TestModel",
6239            b"Base",
6240            b"C:\\MMDDev\\data\\unittest\\test_1bone_cube.pmx",
6241        );
6242        append_pmm_model_slot(
6243            &mut data,
6244            b"Sour",
6245            b"Sour",
6246            b"G:\\MikuMikuDance\\MMD Models\\Sour\\sour.pmx",
6247        );
6248        data.extend_from_slice(b"UserFile\\Motion\\walk.vmd\0");
6249        data
6250    }
6251
6252    fn pmm_v1_with_display_counts() -> Vec<u8> {
6253        let mut data = b"Polygon Movie maker 0001".to_vec();
6254        data.resize(30, 0);
6255        data.extend_from_slice(&512u32.to_le_bytes());
6256        data.extend_from_slice(&288u32.to_le_bytes());
6257        data.extend_from_slice(&435u32.to_le_bytes());
6258        data.extend_from_slice(&35.0f32.to_le_bytes());
6259        data.extend_from_slice(&[1, 1, 1, 1, 1, 1]);
6260        data.push(8);
6261        data.push(9);
6262        data.extend_from_slice(b"Dummy\0 maker Dummy\0");
6263        data.extend_from_slice(b"UserFile\\Model\\miku.pmd\0UserFile\\Accessory\\stage01.x\0");
6264        data
6265    }
6266
6267    fn json_keys(value: &serde_json::Value) -> Vec<String> {
6268        let mut keys = value
6269            .as_object()
6270            .unwrap()
6271            .keys()
6272            .map(ToOwned::to_owned)
6273            .collect::<Vec<_>>();
6274        keys.sort();
6275        keys
6276    }
6277
6278    #[test]
6279    fn pmm_manifest_json_top_level_schema_is_stable() {
6280        let data =
6281            b"Polygon Movie maker 0002\0UserFile\\Model\\miku.pmx\0UserFile\\Motion\\walk.vmd\0";
6282        let parsed = parse_pmm_manifest(data).unwrap();
6283        let keys = json_keys(&serde_json::to_value(&parsed).unwrap());
6284
6285        assert_eq!(
6286            keys,
6287            vec![
6288                "accessoryAssets",
6289                "accessoryPaths",
6290                "assetReferences",
6291                "assetSummary",
6292                "audioAssets",
6293                "audioPaths",
6294                "byteLength",
6295                "diagnostics",
6296                "displayState",
6297                "documentGlobalSummary",
6298                "documentSummary",
6299                "headerTextEntries",
6300                "imageAssets",
6301                "imagePaths",
6302                "modelAssets",
6303                "modelPaths",
6304                "modelSlots",
6305                "motionAssets",
6306                "motionPaths",
6307                "parsedVersion",
6308                "projectGraph",
6309                "projectSettings",
6310                "signature",
6311                "timeline",
6312                "version",
6313                "videoAssets",
6314                "videoPaths",
6315            ]
6316        );
6317    }
6318
6319    #[test]
6320    fn pmm_project_graph_json_schema_is_stable() {
6321        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
6322        let graph = parsed.project_graph.unwrap();
6323        let keys = json_keys(&serde_json::to_value(&graph).unwrap());
6324
6325        assert_eq!(
6326            keys,
6327            vec![
6328                "accessoryCount",
6329                "accessoryKeyframes",
6330                "assetBindings",
6331                "assetReferences",
6332                "assetSummary",
6333                "byteCoverage",
6334                "displayState",
6335                "exportReadiness",
6336                "global",
6337                "keyframeReferences",
6338                "modelCounts",
6339                "models",
6340                "parsedVersion",
6341                "sceneSettings",
6342                "source",
6343                "timeline",
6344                "trackReferences",
6345                "version",
6346            ]
6347        );
6348    }
6349
6350    #[test]
6351    fn pmm_project_export_readiness_json_schema_is_stable() {
6352        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
6353        let graph = parsed.project_graph.unwrap();
6354        let readiness = &graph.export_readiness;
6355        let keys = json_keys(&serde_json::to_value(readiness).unwrap());
6356
6357        assert_eq!(
6358            keys,
6359            vec![
6360                "blockerCount",
6361                "blockers",
6362                "losslessParsedByteExportSupported",
6363                "semanticGraphExportSupported",
6364                "sourceBytePreservationRequired",
6365            ]
6366        );
6367    }
6368
6369    #[test]
6370    fn pmm_project_export_blocker_json_schema_is_stable() {
6371        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
6372        let graph = parsed.project_graph.unwrap();
6373        // the always-present semantic exporter unfinished blocker (first one)
6374        let blocker = graph
6375            .export_readiness
6376            .blockers
6377            .iter()
6378            .find(|b| b.code == "PMM_SEMANTIC_EXPORTER_UNFINISHED")
6379            .expect("PMM_SEMANTIC_EXPORTER_UNFINISHED blocker must be present for schema test");
6380        let keys = json_keys(&serde_json::to_value(blocker).unwrap());
6381
6382        assert_eq!(
6383            keys,
6384            vec![
6385                "code",
6386                "count",
6387                "coverageRatio",
6388                "kind",
6389                "message",
6390                "scope",
6391                "severity",
6392            ]
6393        );
6394    }
6395
6396    #[test]
6397    fn pmm_project_asset_binding_json_schema_is_stable() {
6398        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
6399        let graph = parsed.project_graph.unwrap();
6400        // use first (model) binding for schema
6401        let binding = &graph.asset_bindings[0];
6402        let keys = json_keys(&serde_json::to_value(binding).unwrap());
6403
6404        assert_eq!(
6405            keys,
6406            vec![
6407                "assetKind",
6408                "assetReferenceConfidence",
6409                "assetReferenceEndOffset",
6410                "assetReferenceIndex",
6411                "assetReferenceOffset",
6412                "documentIndex",
6413                "ownerIndex",
6414                "ownerName",
6415                "path",
6416                "pathOffset",
6417                "scope",
6418            ]
6419        );
6420    }
6421
6422    #[test]
6423    fn pmm_project_scene_settings_json_schema_is_stable() {
6424        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
6425        let graph = parsed.project_graph.unwrap();
6426        let keys = json_keys(&serde_json::to_value(&graph.scene_settings).unwrap());
6427
6428        assert_eq!(
6429            keys,
6430            vec![
6431                "audioAssetReferenceIndex",
6432                "audioEnabled",
6433                "audioPath",
6434                "backgroundImageAssetReferenceIndex",
6435                "backgroundImageEnabled",
6436                "backgroundImageOffset",
6437                "backgroundImagePath",
6438                "backgroundImageScaleFactor",
6439                "backgroundVideoAssetReferenceIndex",
6440                "backgroundVideoEnabled",
6441                "backgroundVideoOffset",
6442                "backgroundVideoPath",
6443                "backgroundVideoScaleFactor",
6444                "beginFrameIndex",
6445                "beginFrameIndexEnabled",
6446                "currentFrameIndex",
6447                "currentFrameIndexInTextField",
6448                "endFrameIndex",
6449                "endFrameIndexEnabled",
6450                "loopEnabled",
6451                "offset",
6452                "offsetEnd",
6453                "preferredFps",
6454            ]
6455        );
6456    }
6457
6458    #[test]
6459    fn pmm_project_track_reference_json_schema_is_stable() {
6460        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
6461        let graph = parsed.project_graph.unwrap();
6462        let reference = &graph.track_references[0];
6463        let keys = json_keys(&serde_json::to_value(reference).unwrap());
6464
6465        assert_eq!(
6466            keys,
6467            vec![
6468                "documentIndex",
6469                "initialKeyframes",
6470                "initialKeyframesOffset",
6471                "keyframeCountOffset",
6472                "keyframes",
6473                "keyframesEndOffset",
6474                "keyframesOffset",
6475                "ownerIndex",
6476                "ownerName",
6477                "scope",
6478                "stateEndOffset",
6479                "stateOffset",
6480                "trackKind",
6481            ]
6482        );
6483    }
6484
6485    #[test]
6486    fn pmm_project_keyframe_reference_json_schema_is_stable() {
6487        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
6488        let graph = parsed.project_graph.unwrap();
6489        let reference = &graph.keyframe_references[0];
6490        let keys = json_keys(&serde_json::to_value(reference).unwrap());
6491
6492        assert_eq!(
6493            keys,
6494            vec![
6495                "byteLength",
6496                "documentIndex",
6497                "frameIndex",
6498                "initial",
6499                "keyframeIndex",
6500                "nextKeyframeIndex",
6501                "offset",
6502                "ownerIndex",
6503                "ownerName",
6504                "payloadByteLength",
6505                "payloadOffset",
6506                "previousKeyframeIndex",
6507                "scope",
6508                "trackKind",
6509            ]
6510        );
6511    }
6512
6513    #[test]
6514    fn pmm_project_byte_coverage_json_schema_is_stable() {
6515        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
6516        let graph = parsed.project_graph.unwrap();
6517        let coverage = &graph.byte_coverage;
6518        let keys = json_keys(&serde_json::to_value(coverage).unwrap());
6519
6520        assert_eq!(
6521            keys,
6522            vec![
6523                "byteLength",
6524                "coverageRatio",
6525                "coveredByteLength",
6526                "gapCount",
6527                "gaps",
6528                "offset",
6529                "offsetEnd",
6530                "rangeCount",
6531                "ranges",
6532            ]
6533        );
6534    }
6535
6536    #[test]
6537    fn pmm_project_byte_range_json_schema_is_stable() {
6538        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
6539        let graph = parsed.project_graph.unwrap();
6540        let range0 = &graph.byte_coverage.ranges[0];
6541        let keys = json_keys(&serde_json::to_value(range0).unwrap());
6542
6543        assert_eq!(
6544            keys,
6545            vec![
6546                "byteLength",
6547                "documentIndex",
6548                "kind",
6549                "name",
6550                "offset",
6551                "offsetEnd",
6552                "ownerIndex",
6553                "scope",
6554            ]
6555        );
6556    }
6557
6558    #[test]
6559    fn pmm_header_text_entry_json_schema_is_stable() {
6560        let parsed = parse_pmm_manifest(&pmm_v1_with_display_counts()).unwrap();
6561        let keys = json_keys(&serde_json::to_value(&parsed.header_text_entries[0]).unwrap());
6562
6563        assert_eq!(
6564            keys,
6565            vec!["index", "offset", "offsetEnd", "text", "textBytes"]
6566        );
6567    }
6568
6569    #[test]
6570    fn pmm_model_slot_json_schema_is_stable() {
6571        let parsed = parse_pmm_manifest(&pmm_with_initial_model_slot()).unwrap();
6572        let keys = json_keys(&serde_json::to_value(&parsed.model_slots[0]).unwrap());
6573
6574        assert_eq!(
6575            keys,
6576            vec![
6577                "assetReferenceIndex",
6578                "confidence",
6579                "displaySlotIndex",
6580                "englishName",
6581                "englishNameBytes",
6582                "modelPath",
6583                "modelPathOffset",
6584                "name",
6585                "nameBytes",
6586                "nextNonZeroOffset",
6587                "normalizedPath",
6588                "offset",
6589                "offsetEnd",
6590                "slotIndex",
6591                "trailingZeroPaddingBytes"
6592            ]
6593        );
6594    }
6595
6596    #[test]
6597    fn pmm_project_settings_json_schema_is_stable() {
6598        let parsed = parse_pmm_manifest(&pmm_with_project_settings()).unwrap();
6599        let keys = json_keys(&serde_json::to_value(&parsed.project_settings).unwrap());
6600
6601        assert_eq!(
6602            keys,
6603            vec![
6604                "frameRate",
6605                "screenHeight",
6606                "screenWidth",
6607                "timelineFrameCount",
6608            ]
6609        );
6610    }
6611
6612    #[test]
6613    fn pmm_display_state_json_schema_is_stable() {
6614        let parsed = parse_pmm_manifest(&pmm_with_project_settings()).unwrap();
6615        let keys = json_keys(&serde_json::to_value(&parsed.display_state).unwrap());
6616
6617        assert_eq!(
6618            keys,
6619            vec![
6620                "accessorySlotCount",
6621                "activeModelSlotIndices",
6622                "declaredModelSlotCount",
6623                "documentExpandFlags",
6624                "documentModelCount",
6625                "emptyModelSlotIndices",
6626                "layout",
6627                "modelSlotCount",
6628                "modelSlotFlagCounts",
6629                "modelSlotFlagEntries",
6630                "modelSlotFlags",
6631                "nonZeroModelSlotCount",
6632                "selectedModelIndex"
6633            ]
6634        );
6635    }
6636
6637    #[test]
6638    fn pmm_model_slot_flag_entry_json_schema_is_stable() {
6639        let parsed = parse_pmm_manifest(&pmm_with_project_settings()).unwrap();
6640        let keys = json_keys(
6641            &serde_json::to_value(&parsed.display_state.model_slot_flag_entries[0]).unwrap(),
6642        );
6643
6644        assert_eq!(keys, vec!["active", "flag", "slotIndex"]);
6645    }
6646
6647    #[test]
6648    fn pmm_document_expand_flags_json_schema_is_stable() {
6649        let parsed = parse_pmm_manifest(&pmm_with_project_settings()).unwrap();
6650        let keys = json_keys(
6651            &serde_json::to_value(parsed.display_state.document_expand_flags.unwrap()).unwrap(),
6652        );
6653
6654        assert_eq!(
6655            keys,
6656            vec![
6657                "accessoryPanel",
6658                "bonePanel",
6659                "cameraPanel",
6660                "editingCla",
6661                "lightPanel",
6662                "morphPanel",
6663                "selfShadowPanel"
6664            ]
6665        );
6666    }
6667
6668    #[test]
6669    fn pmm_timeline_json_schema_is_stable() {
6670        let parsed = parse_pmm_manifest(&pmm_with_project_settings()).unwrap();
6671        let keys = json_keys(&serde_json::to_value(&parsed.timeline).unwrap());
6672
6673        assert_eq!(
6674            keys,
6675            vec![
6676                "durationSeconds",
6677                "endFrameExclusive",
6678                "frameCount",
6679                "frameRate",
6680                "startFrame"
6681            ]
6682        );
6683    }
6684
6685    #[test]
6686    fn pmm_scene_asset_json_schema_is_stable() {
6687        let parsed = parse_pmm_manifest(&pmm_with_project_settings()).unwrap();
6688        let keys = json_keys(&serde_json::to_value(&parsed.model_assets[0]).unwrap());
6689
6690        assert_eq!(
6691            keys,
6692            vec![
6693                "confidence",
6694                "extension",
6695                "fileName",
6696                "kindIndex",
6697                "normalizedPath",
6698                "offset",
6699                "offsetEnd",
6700                "path",
6701                "referenceIndex"
6702            ]
6703        );
6704    }
6705
6706    #[test]
6707    fn pmm_asset_reference_json_schema_is_stable() {
6708        let parsed = parse_pmm_manifest(&pmm_with_project_settings()).unwrap();
6709        let keys = json_keys(&serde_json::to_value(&parsed.asset_references[0]).unwrap());
6710
6711        assert_eq!(
6712            keys,
6713            vec![
6714                "confidence",
6715                "extension",
6716                "fileName",
6717                "kind",
6718                "normalizedPath",
6719                "offset",
6720                "offsetEnd",
6721                "path"
6722            ]
6723        );
6724    }
6725
6726    #[test]
6727    fn pmm_asset_summary_json_schema_is_stable() {
6728        let parsed = parse_pmm_manifest(&pmm_with_project_settings()).unwrap();
6729        let keys = json_keys(&serde_json::to_value(&parsed.asset_summary).unwrap());
6730
6731        assert_eq!(
6732            keys,
6733            vec![
6734                "confidenceCounts",
6735                "extensionCounts",
6736                "highConfidenceCount",
6737                "kindCounts",
6738                "lowConfidenceCount",
6739                "mediumConfidenceCount",
6740                "referenceCount"
6741            ]
6742        );
6743    }
6744
6745    #[test]
6746    fn pmm_document_summary_json_schema_is_stable() {
6747        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
6748        let keys = json_keys(&serde_json::to_value(parsed.document_summary.unwrap()).unwrap());
6749
6750        assert_eq!(
6751            keys,
6752            vec![
6753                "counts",
6754                "modelCount",
6755                "models",
6756                "selectedModelIndex",
6757                "source"
6758            ]
6759        );
6760    }
6761
6762    #[test]
6763    fn pmm_document_counts_json_schema_is_stable() {
6764        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
6765        let document = parsed.document_summary.unwrap();
6766        let keys = json_keys(&serde_json::to_value(document.counts).unwrap());
6767
6768        assert_eq!(
6769            keys,
6770            vec![
6771                "boneKeyframes",
6772                "bones",
6773                "initialBoneKeyframes",
6774                "initialModelKeyframes",
6775                "initialMorphKeyframes",
6776                "modelKeyframes",
6777                "models",
6778                "morphKeyframes",
6779                "morphs"
6780            ]
6781        );
6782    }
6783
6784    #[test]
6785    fn pmm_document_global_summary_json_schema_is_stable() {
6786        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
6787        let keys =
6788            json_keys(&serde_json::to_value(parsed.document_global_summary.unwrap()).unwrap());
6789
6790        assert_eq!(
6791            keys,
6792            vec![
6793                "accessories",
6794                "camera",
6795                "gravity",
6796                "light",
6797                "offset",
6798                "offsetEnd",
6799                "selfShadow",
6800                "settings",
6801                "source"
6802            ]
6803        );
6804    }
6805
6806    #[test]
6807    fn pmm_initial_camera_keyframe_json_schema_is_stable() {
6808        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
6809        let global = parsed.document_global_summary.unwrap();
6810        let keys =
6811            json_keys(&serde_json::to_value(global.camera.initial_keyframe.unwrap()).unwrap());
6812
6813        assert_eq!(
6814            keys,
6815            vec![
6816                "angle",
6817                "angleByteLength",
6818                "angleOffset",
6819                "byteLength",
6820                "distance",
6821                "distanceOffset",
6822                "fov",
6823                "fovOffset",
6824                "frameIndex",
6825                "index",
6826                "interpolation",
6827                "interpolationByteLength",
6828                "interpolationOffset",
6829                "kind",
6830                "lookAt",
6831                "lookAtByteLength",
6832                "lookAtOffset",
6833                "nextKeyframeIndex",
6834                "offset",
6835                "parentModelBoneIndex",
6836                "parentModelBoneIndexOffset",
6837                "parentModelIndex",
6838                "parentModelIndexOffset",
6839                "payloadByteLength",
6840                "payloadBytes",
6841                "payloadOffset",
6842                "perspectiveView",
6843                "perspectiveViewOffset",
6844                "previousKeyframeIndex",
6845                "selected",
6846                "selectedOffset"
6847            ]
6848        );
6849    }
6850
6851    #[test]
6852    fn pmm_initial_light_keyframe_json_schema_is_stable() {
6853        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
6854        let global = parsed.document_global_summary.unwrap();
6855        let keys =
6856            json_keys(&serde_json::to_value(global.light.initial_keyframe.unwrap()).unwrap());
6857
6858        assert_eq!(
6859            keys,
6860            vec![
6861                "byteLength",
6862                "color",
6863                "colorByteLength",
6864                "colorOffset",
6865                "direction",
6866                "directionByteLength",
6867                "directionOffset",
6868                "frameIndex",
6869                "index",
6870                "kind",
6871                "nextKeyframeIndex",
6872                "offset",
6873                "payloadByteLength",
6874                "payloadBytes",
6875                "payloadOffset",
6876                "previousKeyframeIndex",
6877                "selected",
6878                "selectedOffset"
6879            ]
6880        );
6881    }
6882
6883    #[test]
6884    fn pmm_initial_gravity_keyframe_json_schema_is_stable() {
6885        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
6886        let global = parsed.document_global_summary.unwrap();
6887        let keys =
6888            json_keys(&serde_json::to_value(global.gravity.initial_keyframe.unwrap()).unwrap());
6889
6890        assert_eq!(
6891            keys,
6892            vec![
6893                "acceleration",
6894                "accelerationOffset",
6895                "byteLength",
6896                "direction",
6897                "directionByteLength",
6898                "directionOffset",
6899                "frameIndex",
6900                "index",
6901                "kind",
6902                "nextKeyframeIndex",
6903                "noise",
6904                "noiseEnabled",
6905                "noiseEnabledOffset",
6906                "noiseOffset",
6907                "offset",
6908                "payloadByteLength",
6909                "payloadBytes",
6910                "payloadOffset",
6911                "previousKeyframeIndex",
6912                "selected",
6913                "selectedOffset"
6914            ]
6915        );
6916    }
6917
6918    #[test]
6919    fn pmm_initial_self_shadow_keyframe_json_schema_is_stable() {
6920        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
6921        let global = parsed.document_global_summary.unwrap();
6922        let keys =
6923            json_keys(&serde_json::to_value(global.self_shadow.initial_keyframe.unwrap()).unwrap());
6924
6925        assert_eq!(
6926            keys,
6927            vec![
6928                "byteLength",
6929                "distance",
6930                "distanceOffset",
6931                "frameIndex",
6932                "index",
6933                "kind",
6934                "mode",
6935                "modeOffset",
6936                "nextKeyframeIndex",
6937                "offset",
6938                "payloadByteLength",
6939                "payloadBytes",
6940                "payloadOffset",
6941                "previousKeyframeIndex",
6942                "selected",
6943                "selectedOffset"
6944            ]
6945        );
6946    }
6947
6948    #[test]
6949    fn pmm_document_track_summary_json_schema_is_stable() {
6950        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
6951        let global = parsed.document_global_summary.unwrap();
6952        let keys = json_keys(&serde_json::to_value(global.camera).unwrap());
6953
6954        assert_eq!(
6955            keys,
6956            vec![
6957                "initialKeyframe",
6958                "initialKeyframes",
6959                "keyframeCountOffset",
6960                "keyframeSummaries",
6961                "keyframes",
6962                "keyframesEndOffset",
6963                "keyframesOffset",
6964                "offset",
6965                "offsetEnd",
6966                "stateEndOffset",
6967                "stateOffset"
6968            ]
6969        );
6970    }
6971
6972    #[test]
6973    fn pmm_document_settings_summary_json_schema_is_stable() {
6974        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
6975        let global = parsed.document_global_summary.unwrap();
6976        let keys = json_keys(&serde_json::to_value(global.settings).unwrap());
6977
6978        assert_eq!(
6979            keys,
6980            vec![
6981                "accessoryIndexAfterModels",
6982                "audioEnabled",
6983                "audioPath",
6984                "audioPathOffset",
6985                "backgroundImageEnabled",
6986                "backgroundImageOffset",
6987                "backgroundImagePath",
6988                "backgroundImagePathOffset",
6989                "backgroundImageScaleFactor",
6990                "backgroundVideoEnabled",
6991                "backgroundVideoOffset",
6992                "backgroundVideoPath",
6993                "backgroundVideoPathOffset",
6994                "backgroundVideoScaleFactor",
6995                "beginFrameIndex",
6996                "beginFrameIndexEnabled",
6997                "beginFrameIndexEnabledOffset",
6998                "beginFrameIndexOffset",
6999                "blackBackgroundEnabled",
7000                "cameraLookAtModelBoneIndex",
7001                "cameraLookAtModelIndex",
7002                "cameraLookMode",
7003                "currentFrameIndex",
7004                "currentFrameIndexInTextField",
7005                "currentFrameIndexInTextFieldOffset",
7006                "currentFrameIndexOffset",
7007                "edgeColor",
7008                "editingMode",
7009                "endFrameIndex",
7010                "endFrameIndexEnabled",
7011                "endFrameIndexEnabledOffset",
7012                "endFrameIndexOffset",
7013                "followingLookAtEnabled",
7014                "gridAndAxisShown",
7015                "groundShadowBrightness",
7016                "groundShadowShown",
7017                "horizontalScroll",
7018                "horizontalScrollThumb",
7019                "informationShown",
7020                "loopEnabled",
7021                "modelSelectionFooterEndOffset",
7022                "modelSelectionFooterOffset",
7023                "modelSelectionFooterPresent",
7024                "offset",
7025                "offsetEnd",
7026                "physicsGroundEnabled",
7027                "physicsSimulationMode",
7028                "preferredFps",
7029                "screenCaptureMode",
7030                "translucentGroundShadowEnabled",
7031                "unknownMatrixEndOffset",
7032                "unknownMatrixOffset"
7033            ]
7034        );
7035    }
7036
7037    #[test]
7038    fn pmm_document_accessory_summary_json_schema_is_stable() {
7039        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
7040        let global = parsed.document_global_summary.unwrap();
7041        let keys = json_keys(&serde_json::to_value(&global.accessories.accessories[0]).unwrap());
7042
7043        assert_eq!(
7044            keys,
7045            vec![
7046                "addBlendEnabled",
7047                "assetReferenceIndex",
7048                "documentAccessoryIndex",
7049                "drawOrderIndex",
7050                "initialKeyframe",
7051                "keyframeCountOffset",
7052                "keyframeSummaries",
7053                "keyframes",
7054                "keyframesEndOffset",
7055                "keyframesOffset",
7056                "name",
7057                "offset",
7058                "offsetEnd",
7059                "opacity",
7060                "parentModelBoneIndex",
7061                "parentModelIndex",
7062                "path",
7063                "pathOffset",
7064                "scaleFactor",
7065                "shadowEnabled",
7066                "slotIndex",
7067                "stateEndOffset",
7068                "stateOffset",
7069                "visible"
7070            ]
7071        );
7072    }
7073
7074    #[test]
7075    fn pmm_document_accessory_keyframe_summary_json_schema_is_stable() {
7076        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
7077        let global = parsed.document_global_summary.unwrap();
7078        let keys = json_keys(
7079            &serde_json::to_value(&global.accessories.accessories[0].initial_keyframe).unwrap(),
7080        );
7081
7082        assert_eq!(
7083            keys,
7084            vec![
7085                "byteLength",
7086                "frameIndex",
7087                "index",
7088                "nextKeyframeIndex",
7089                "offset",
7090                "opacity",
7091                "orientation",
7092                "orientationByteLength",
7093                "orientationOffset",
7094                "packedOpacityVisibleOffset",
7095                "parentModelBoneIndex",
7096                "parentModelBoneIndexOffset",
7097                "parentModelIndex",
7098                "parentModelIndexOffset",
7099                "payloadByteLength",
7100                "payloadBytes",
7101                "payloadOffset",
7102                "previousKeyframeIndex",
7103                "scaleFactor",
7104                "scaleFactorOffset",
7105                "selected",
7106                "selectedOffset",
7107                "shadowEnabled",
7108                "shadowEnabledOffset",
7109                "translation",
7110                "translationByteLength",
7111                "translationOffset",
7112                "visible"
7113            ]
7114        );
7115    }
7116
7117    #[test]
7118    fn parses_pmm_v2_document_global_summary() {
7119        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
7120        let global = parsed.document_global_summary.as_ref().unwrap();
7121
7122        assert_eq!(global.source, "nanoem/ext/document.c PMMv2 global layout");
7123        assert_eq!(global.camera.initial_keyframes, 1);
7124        assert_eq!(global.camera.keyframes, 1);
7125        match global.camera.initial_keyframe.as_ref().unwrap() {
7126            PmmDocumentKeyframeSummary::Camera {
7127                index,
7128                frame_index,
7129                distance,
7130                look_at,
7131                angle,
7132                parent_model_index,
7133                parent_model_bone_index,
7134                interpolation,
7135                perspective_view,
7136                fov,
7137                selected,
7138                offset,
7139                byte_length,
7140                payload_offset,
7141                payload_byte_length,
7142                payload_bytes,
7143                distance_offset,
7144                look_at_offset,
7145                look_at_byte_length,
7146                angle_offset,
7147                angle_byte_length,
7148                parent_model_index_offset,
7149                parent_model_bone_index_offset,
7150                interpolation_offset,
7151                interpolation_byte_length,
7152                perspective_view_offset,
7153                fov_offset,
7154                selected_offset,
7155                ..
7156            } => {
7157                assert_eq!(*index, None);
7158                assert_eq!(*frame_index, 0);
7159                assert_eq!(*offset, global.camera.offset);
7160                assert_eq!(*byte_length, 78);
7161                assert_eq!(*payload_offset, *offset + 12);
7162                assert_eq!(*payload_byte_length, *byte_length - 12);
7163                assert_eq!(payload_bytes.len(), *payload_byte_length);
7164                assert_eq!(&payload_bytes[..4], &(*distance).to_le_bytes()[..]);
7165                assert_eq!(payload_bytes.last().copied(), Some(*selected as u8));
7166                assert_eq!(*distance, 45.0);
7167                assert_eq!(*look_at, [0.0, 0.0, 0.0]);
7168                assert_eq!(*angle, [0.0, 0.0, 0.0]);
7169                assert_eq!(*parent_model_index, -1);
7170                assert_eq!(*parent_model_bone_index, -1);
7171                // 24-byte raw interpolation block exposed (after dist/look/ang/parents in payload)
7172                assert_eq!(interpolation.len(), 24);
7173                assert_eq!(*interpolation, [20u8; 24]);
7174                let interp_offset_in_payload = 4 + 12 + 12 + 4 + 4; // distance + lookAt + angle + parentModelIndex + parentModelBoneIndex
7175                assert_eq!(
7176                    &payload_bytes[interp_offset_in_payload..interp_offset_in_payload + 24],
7177                    &interpolation[..]
7178                );
7179                assert_eq!(
7180                    &payload_bytes[interp_offset_in_payload..interp_offset_in_payload + 24],
7181                    &[20u8; 24]
7182                );
7183                assert!(*perspective_view);
7184                assert_eq!(*fov, 30);
7185                assert!(!*selected);
7186
7187                // layout metadata for camera keyframe payload (exporter-prep slice).
7188                // offsets are absolute; prove they address correct bytes inside payload_bytes for initial keyframe.
7189                assert_eq!(*distance_offset, *payload_offset);
7190                assert_eq!(*look_at_offset, *payload_offset + 4);
7191                assert_eq!(*look_at_byte_length, 12);
7192                assert_eq!(*angle_offset, *payload_offset + 16);
7193                assert_eq!(*angle_byte_length, 12);
7194                assert_eq!(*parent_model_index_offset, *payload_offset + 28);
7195                assert_eq!(*parent_model_bone_index_offset, *payload_offset + 32);
7196                assert_eq!(*interpolation_offset, *payload_offset + 36);
7197                assert_eq!(*interpolation_byte_length, 24);
7198                assert_eq!(*perspective_view_offset, *payload_offset + 60);
7199                assert_eq!(*fov_offset, *payload_offset + 61);
7200                assert_eq!(*selected_offset, *payload_offset + 65);
7201                let p0 = &payload_bytes;
7202                assert_eq!(
7203                    &p0[*distance_offset - *payload_offset..*distance_offset - *payload_offset + 4],
7204                    &45.0f32.to_le_bytes()
7205                );
7206                let la0 = *look_at_offset - *payload_offset;
7207                assert_eq!(&p0[la0..la0 + 12], &[0u8; 12]);
7208                let ang0 = *angle_offset - *payload_offset;
7209                assert_eq!(&p0[ang0..ang0 + 12], &[0u8; 12]);
7210                assert_eq!(
7211                    &p0[*parent_model_index_offset - *payload_offset
7212                        ..*parent_model_index_offset - *payload_offset + 4],
7213                    &(-1i32).to_le_bytes()
7214                );
7215                assert_eq!(
7216                    &p0[*parent_model_bone_index_offset - *payload_offset
7217                        ..*parent_model_bone_index_offset - *payload_offset + 4],
7218                    &(-1i32).to_le_bytes()
7219                );
7220                let ip0 = *interpolation_offset - *payload_offset;
7221                assert_eq!(&p0[ip0..ip0 + 24], &[20u8; 24]);
7222                assert_eq!(p0[*perspective_view_offset - *payload_offset], 0u8);
7223                assert_eq!(
7224                    &p0[*fov_offset - *payload_offset..*fov_offset - *payload_offset + 4],
7225                    &30i32.to_le_bytes()
7226                );
7227                assert_eq!(p0[*selected_offset - *payload_offset], 0u8);
7228            }
7229            other => panic!("unexpected camera keyframe summary: {other:?}"),
7230        }
7231        match &global.camera.keyframe_summaries[0] {
7232            PmmDocumentKeyframeSummary::Camera {
7233                index,
7234                frame_index,
7235                distance,
7236                parent_model_index,
7237                parent_model_bone_index,
7238                interpolation,
7239                selected,
7240                offset,
7241                byte_length,
7242                payload_offset,
7243                payload_byte_length,
7244                payload_bytes,
7245                distance_offset,
7246                look_at_offset,
7247                look_at_byte_length,
7248                angle_offset,
7249                angle_byte_length,
7250                parent_model_index_offset,
7251                parent_model_bone_index_offset,
7252                interpolation_offset,
7253                interpolation_byte_length,
7254                perspective_view_offset,
7255                fov_offset,
7256                selected_offset,
7257                ..
7258            } => {
7259                assert_eq!(*index, Some(42));
7260                assert_eq!(*frame_index, 42);
7261                assert_eq!(*offset, global.camera.keyframes_offset);
7262                assert_eq!(*byte_length, 82);
7263                assert_eq!(*payload_offset, *offset + 16);
7264                assert_eq!(*payload_byte_length, *byte_length - 16);
7265                assert_eq!(payload_bytes.len(), *payload_byte_length);
7266                assert_eq!(&payload_bytes[..4], &(*distance).to_le_bytes()[..]);
7267                assert_eq!(payload_bytes.last().copied(), Some(*selected as u8));
7268                // additional camera keyframe also exposes the 24 raw interp bytes at same payload offset
7269                assert_eq!(interpolation.len(), 24);
7270                assert_eq!(*interpolation, [20u8; 24]);
7271                let interp_offset_in_payload = 4 + 12 + 12 + 4 + 4; // distance + lookAt + angle + parentModelIndex + parentModelBoneIndex
7272                assert_eq!(
7273                    &payload_bytes[interp_offset_in_payload..interp_offset_in_payload + 24],
7274                    &interpolation[..]
7275                );
7276                assert_eq!(*parent_model_index, -1);
7277                assert_eq!(*parent_model_bone_index, -1);
7278
7279                // same layout assertions for indexed (non-initial) camera keyframe; relative positions inside payload identical.
7280                assert_eq!(*distance_offset, *payload_offset);
7281                assert_eq!(*look_at_offset, *payload_offset + 4);
7282                assert_eq!(*look_at_byte_length, 12);
7283                assert_eq!(*angle_offset, *payload_offset + 16);
7284                assert_eq!(*angle_byte_length, 12);
7285                assert_eq!(*parent_model_index_offset, *payload_offset + 28);
7286                assert_eq!(*parent_model_bone_index_offset, *payload_offset + 32);
7287                assert_eq!(*interpolation_offset, *payload_offset + 36);
7288                assert_eq!(*interpolation_byte_length, 24);
7289                assert_eq!(*perspective_view_offset, *payload_offset + 60);
7290                assert_eq!(*fov_offset, *payload_offset + 61);
7291                assert_eq!(*selected_offset, *payload_offset + 65);
7292                let p1 = &payload_bytes;
7293                assert_eq!(
7294                    &p1[*distance_offset - *payload_offset..*distance_offset - *payload_offset + 4],
7295                    &(*distance).to_le_bytes()[..]
7296                );
7297                let la1 = *look_at_offset - *payload_offset;
7298                assert_eq!(&p1[la1..la1 + 12], &[0u8; 12]);
7299                let ang1 = *angle_offset - *payload_offset;
7300                assert_eq!(&p1[ang1..ang1 + 12], &[0u8; 12]);
7301                assert_eq!(
7302                    &p1[*parent_model_index_offset - *payload_offset
7303                        ..*parent_model_index_offset - *payload_offset + 4],
7304                    &(-1i32).to_le_bytes()
7305                );
7306                assert_eq!(
7307                    &p1[*parent_model_bone_index_offset - *payload_offset
7308                        ..*parent_model_bone_index_offset - *payload_offset + 4],
7309                    &(-1i32).to_le_bytes()
7310                );
7311                let ip1 = *interpolation_offset - *payload_offset;
7312                assert_eq!(&p1[ip1..ip1 + 24], &[20u8; 24]);
7313                assert_eq!(p1[*perspective_view_offset - *payload_offset], 0u8);
7314                assert_eq!(
7315                    &p1[*fov_offset - *payload_offset..*fov_offset - *payload_offset + 4],
7316                    &30i32.to_le_bytes()
7317                );
7318                assert_eq!(p1[*selected_offset - *payload_offset], 0u8);
7319            }
7320            other => panic!("unexpected additional camera keyframe summary: {other:?}"),
7321        }
7322        assert_eq!(global.light.initial_keyframes, 1);
7323        assert_eq!(global.light.keyframes, 1);
7324        match global.light.initial_keyframe.as_ref().unwrap() {
7325            PmmDocumentKeyframeSummary::Light {
7326                color,
7327                direction,
7328                selected,
7329                offset,
7330                byte_length,
7331                payload_offset,
7332                payload_byte_length,
7333                payload_bytes,
7334                color_offset,
7335                color_byte_length,
7336                direction_offset,
7337                direction_byte_length,
7338                selected_offset,
7339                ..
7340            } => {
7341                assert_eq!(*offset, global.light.offset);
7342                assert_eq!(*byte_length, 37);
7343                assert_eq!(*payload_offset, *offset + 12);
7344                assert_eq!(*payload_byte_length, *byte_length - 12);
7345                assert_eq!(payload_bytes.len(), *payload_byte_length);
7346                let mut color_bytes = Vec::with_capacity(12);
7347                for c in color {
7348                    color_bytes.extend_from_slice(&c.to_le_bytes());
7349                }
7350                assert_eq!(&payload_bytes[..12], &color_bytes[..]);
7351                assert_eq!(payload_bytes.last().copied(), Some(*selected as u8));
7352                assert_eq!(*color, [0.6, 0.6, 0.6]);
7353                assert_eq!(*direction, [-0.5, -1.0, 0.5]);
7354                assert!(!*selected);
7355
7356                // layout metadata for light keyframe payload (exporter-prep slice).
7357                assert_eq!(*color_offset, *payload_offset);
7358                assert_eq!(*color_byte_length, 12);
7359                assert_eq!(*direction_offset, *payload_offset + 12);
7360                assert_eq!(*direction_byte_length, 12);
7361                assert_eq!(*selected_offset, *payload_offset + 24);
7362                let p0 = &payload_bytes;
7363                let mut color_bytes = Vec::with_capacity(12);
7364                for c in color {
7365                    color_bytes.extend_from_slice(&c.to_le_bytes());
7366                }
7367                assert_eq!(
7368                    &p0[*color_offset - *payload_offset..*color_offset - *payload_offset + 12],
7369                    &color_bytes[..]
7370                );
7371                let d0 = *direction_offset - *payload_offset;
7372                assert_eq!(
7373                    &p0[d0..d0 + 12],
7374                    &[-0.5f32, -1.0, 0.5]
7375                        .iter()
7376                        .flat_map(|f| f.to_le_bytes())
7377                        .collect::<Vec<_>>()[..]
7378                );
7379                assert_eq!(p0[*selected_offset - *payload_offset], 0u8);
7380            }
7381            other => panic!("unexpected light keyframe summary: {other:?}"),
7382        }
7383        match &global.light.keyframe_summaries[0] {
7384            PmmDocumentKeyframeSummary::Light {
7385                index,
7386                frame_index,
7387                color,
7388                selected,
7389                offset,
7390                byte_length,
7391                payload_offset,
7392                payload_byte_length,
7393                payload_bytes,
7394                color_offset,
7395                color_byte_length,
7396                direction_offset,
7397                direction_byte_length,
7398                selected_offset,
7399                ..
7400            } => {
7401                assert_eq!(*index, Some(43));
7402                assert_eq!(*frame_index, 43);
7403                assert_eq!(*offset, global.light.keyframes_offset);
7404                assert_eq!(*byte_length, 41);
7405                assert_eq!(*payload_offset, *offset + 16);
7406                assert_eq!(*payload_byte_length, *byte_length - 16);
7407                assert_eq!(payload_bytes.len(), *payload_byte_length);
7408                let mut color_bytes = Vec::with_capacity(12);
7409                for c in color {
7410                    color_bytes.extend_from_slice(&c.to_le_bytes());
7411                }
7412                assert_eq!(&payload_bytes[..12], &color_bytes[..]);
7413                assert_eq!(payload_bytes.last().copied(), Some(*selected as u8));
7414
7415                // same layout for indexed light keyframe
7416                assert_eq!(*color_offset, *payload_offset);
7417                assert_eq!(*color_byte_length, 12);
7418                assert_eq!(*direction_offset, *payload_offset + 12);
7419                assert_eq!(*direction_byte_length, 12);
7420                assert_eq!(*selected_offset, *payload_offset + 24);
7421                let p1 = &payload_bytes;
7422                let mut color_bytes2 = Vec::with_capacity(12);
7423                for c in color {
7424                    color_bytes2.extend_from_slice(&c.to_le_bytes());
7425                }
7426                assert_eq!(
7427                    &p1[*color_offset - *payload_offset..*color_offset - *payload_offset + 12],
7428                    &color_bytes2[..]
7429                );
7430                let d1 = *direction_offset - *payload_offset;
7431                assert_eq!(
7432                    &p1[d1..d1 + 12],
7433                    &[-0.5f32, -1.0, 0.5]
7434                        .iter()
7435                        .flat_map(|f| f.to_le_bytes())
7436                        .collect::<Vec<_>>()[..]
7437                );
7438                assert_eq!(p1[*selected_offset - *payload_offset], 0u8);
7439            }
7440            other => panic!("unexpected additional light keyframe summary: {other:?}"),
7441        }
7442        assert_eq!(global.accessories.accessory_count, 1);
7443        assert_eq!(global.accessories.keyframes, 1);
7444        let accessory = &global.accessories.accessories[0];
7445        assert_eq!(accessory.slot_index, 0);
7446        assert_eq!(accessory.document_accessory_index, 0);
7447        assert_eq!(accessory.name, "Stage");
7448        assert_eq!(accessory.path, "UserFile\\Accessory\\stage.x");
7449        assert_eq!(accessory.keyframes, 1);
7450        assert!(accessory.visible);
7451        assert_eq!(accessory.opacity, 1.0);
7452        assert_eq!(accessory.scale_factor, 10.0);
7453        assert!(accessory.shadow_enabled);
7454        assert!(!accessory.add_blend_enabled);
7455        assert_eq!(accessory.initial_keyframe.index, None);
7456        assert_eq!(accessory.initial_keyframe.frame_index, 0);
7457        assert_eq!(
7458            accessory.initial_keyframe.offset,
7459            accessory.offset + 1 + 100 + 256 + 1
7460        );
7461        assert_eq!(accessory.initial_keyframe.byte_length, 51);
7462        assert_eq!(
7463            accessory.initial_keyframe.payload_offset,
7464            accessory.initial_keyframe.offset + 12
7465        );
7466        assert_eq!(accessory.initial_keyframe.payload_byte_length, 39);
7467        assert_eq!(accessory.initial_keyframe.payload_bytes.len(), 39);
7468        assert_eq!(
7469            accessory.initial_keyframe.payload_bytes.len(),
7470            accessory.initial_keyframe.payload_byte_length
7471        );
7472        assert_eq!(accessory.initial_keyframe.payload_bytes[0], 1);
7473        assert_eq!(
7474            &accessory.initial_keyframe.payload_bytes
7475                [accessory.initial_keyframe.payload_bytes.len() - 2..],
7476            &[1, 0]
7477        );
7478        assert_eq!(accessory.initial_keyframe.translation, [1.0, 2.0, 3.0]);
7479        assert_eq!(accessory.initial_keyframe.orientation, [0.1, 0.2, 0.3]);
7480        // explicit payload layout offsets/ranges on initial accessory keyframe (from global summary path)
7481        assert_eq!(
7482            accessory.initial_keyframe.packed_opacity_visible_offset,
7483            accessory.initial_keyframe.payload_offset
7484        );
7485        assert_eq!(
7486            accessory.initial_keyframe.selected_offset,
7487            accessory.initial_keyframe.payload_offset + 38
7488        );
7489        assert_eq!(accessory.initial_keyframe.translation_byte_length, 12);
7490        assert_eq!(accessory.initial_keyframe.orientation_byte_length, 12);
7491        assert_eq!(accessory.keyframe_summaries[0].index, Some(46));
7492        assert_eq!(accessory.keyframe_summaries[0].frame_index, 46);
7493        assert_eq!(
7494            accessory.keyframe_summaries[0].offset,
7495            accessory.keyframes_offset
7496        );
7497        assert_eq!(accessory.keyframe_summaries[0].byte_length, 55);
7498        assert_eq!(
7499            accessory.keyframe_summaries[0].payload_offset,
7500            accessory.keyframe_summaries[0].offset + 16
7501        );
7502        assert_eq!(accessory.keyframe_summaries[0].payload_byte_length, 39);
7503        assert_eq!(accessory.keyframe_summaries[0].payload_bytes.len(), 39);
7504        assert_eq!(
7505            accessory.keyframe_summaries[0].payload_bytes.len(),
7506            accessory.keyframe_summaries[0].payload_byte_length
7507        );
7508        assert_eq!(accessory.keyframe_summaries[0].payload_bytes[0], 1);
7509        assert_eq!(
7510            &accessory.keyframe_summaries[0].payload_bytes
7511                [accessory.keyframe_summaries[0].payload_bytes.len() - 2..],
7512            &[1, 0]
7513        );
7514        // layout on indexed accessory keyframe (global summary test path)
7515        assert_eq!(
7516            accessory.keyframe_summaries[0].packed_opacity_visible_offset,
7517            accessory.keyframe_summaries[0].payload_offset
7518        );
7519        assert_eq!(
7520            accessory.keyframe_summaries[0].scale_factor_offset,
7521            accessory.keyframe_summaries[0].payload_offset + 33
7522        );
7523        assert_eq!(
7524            accessory.keyframe_summaries[0].shadow_enabled_offset,
7525            accessory.keyframe_summaries[0].payload_offset + 37
7526        );
7527        assert_eq!(global.gravity.initial_keyframes, 1);
7528        assert_eq!(global.gravity.keyframes, 1);
7529        match global.gravity.initial_keyframe.as_ref().unwrap() {
7530            PmmDocumentKeyframeSummary::Gravity {
7531                noise_enabled,
7532                noise,
7533                acceleration,
7534                direction,
7535                selected,
7536                offset,
7537                byte_length,
7538                payload_offset,
7539                payload_byte_length,
7540                payload_bytes,
7541                noise_enabled_offset,
7542                noise_offset,
7543                acceleration_offset,
7544                direction_offset,
7545                direction_byte_length,
7546                selected_offset,
7547                ..
7548            } => {
7549                assert_eq!(*offset, global.gravity.state_end_offset.unwrap());
7550                assert_eq!(*byte_length, 34);
7551                assert_eq!(*payload_offset, *offset + 12);
7552                assert_eq!(*payload_byte_length, *byte_length - 12);
7553                assert_eq!(payload_bytes.len(), *payload_byte_length);
7554                assert_eq!(payload_bytes[0], *noise_enabled as u8);
7555                assert_eq!(payload_bytes.last().copied(), Some(*selected as u8));
7556                assert!(!*noise_enabled);
7557                assert_eq!(*noise, 10);
7558                assert_eq!(*acceleration, 9.8);
7559                assert_eq!(*direction, [0.0, -1.0, 0.0]);
7560                assert!(!*selected);
7561
7562                // layout metadata for gravity keyframe payload (exporter-prep slice).
7563                assert_eq!(*noise_enabled_offset, *payload_offset);
7564                assert_eq!(*noise_offset, *payload_offset + 1);
7565                assert_eq!(*acceleration_offset, *payload_offset + 5);
7566                assert_eq!(*direction_offset, *payload_offset + 9);
7567                assert_eq!(*direction_byte_length, 12);
7568                assert_eq!(*selected_offset, *payload_offset + 21);
7569                let p0 = &payload_bytes;
7570                assert_eq!(
7571                    p0[*noise_enabled_offset - *payload_offset],
7572                    *noise_enabled as u8
7573                );
7574                assert_eq!(
7575                    &p0[*noise_offset - *payload_offset..*noise_offset - *payload_offset + 4],
7576                    &10i32.to_le_bytes()
7577                );
7578                assert_eq!(
7579                    &p0[*acceleration_offset - *payload_offset
7580                        ..*acceleration_offset - *payload_offset + 4],
7581                    &9.8f32.to_le_bytes()
7582                );
7583                let dir0 = *direction_offset - *payload_offset;
7584                assert_eq!(
7585                    &p0[dir0..dir0 + 12],
7586                    &[0.0f32, -1.0, 0.0]
7587                        .iter()
7588                        .flat_map(|f| f.to_le_bytes())
7589                        .collect::<Vec<_>>()[..]
7590                );
7591                assert_eq!(p0[*selected_offset - *payload_offset], 0u8);
7592            }
7593            other => panic!("unexpected gravity keyframe summary: {other:?}"),
7594        }
7595        match &global.gravity.keyframe_summaries[0] {
7596            PmmDocumentKeyframeSummary::Gravity {
7597                index,
7598                frame_index,
7599                noise_enabled,
7600                selected,
7601                offset,
7602                byte_length,
7603                payload_offset,
7604                payload_byte_length,
7605                payload_bytes,
7606                noise_enabled_offset,
7607                noise_offset,
7608                acceleration_offset,
7609                direction_offset,
7610                direction_byte_length,
7611                selected_offset,
7612                ..
7613            } => {
7614                assert_eq!(*index, Some(44));
7615                assert_eq!(*frame_index, 44);
7616                assert_eq!(*offset, global.gravity.keyframes_offset);
7617                assert_eq!(*byte_length, 38);
7618                assert_eq!(*payload_offset, *offset + 16);
7619                assert_eq!(*payload_byte_length, *byte_length - 16);
7620                assert_eq!(payload_bytes.len(), *payload_byte_length);
7621                assert_eq!(payload_bytes[0], *noise_enabled as u8);
7622                assert_eq!(payload_bytes.last().copied(), Some(*selected as u8));
7623
7624                // same layout for indexed gravity keyframe
7625                assert_eq!(*noise_enabled_offset, *payload_offset);
7626                assert_eq!(*noise_offset, *payload_offset + 1);
7627                assert_eq!(*acceleration_offset, *payload_offset + 5);
7628                assert_eq!(*direction_offset, *payload_offset + 9);
7629                assert_eq!(*direction_byte_length, 12);
7630                assert_eq!(*selected_offset, *payload_offset + 21);
7631                let p1 = &payload_bytes;
7632                assert_eq!(
7633                    p1[*noise_enabled_offset - *payload_offset],
7634                    *noise_enabled as u8
7635                );
7636                assert_eq!(
7637                    &p1[*noise_offset - *payload_offset..*noise_offset - *payload_offset + 4],
7638                    &10i32.to_le_bytes()
7639                );
7640                assert_eq!(
7641                    &p1[*acceleration_offset - *payload_offset
7642                        ..*acceleration_offset - *payload_offset + 4],
7643                    &9.8f32.to_le_bytes()
7644                );
7645                let dir1 = *direction_offset - *payload_offset;
7646                assert_eq!(
7647                    &p1[dir1..dir1 + 12],
7648                    &[0.0f32, -1.0, 0.0]
7649                        .iter()
7650                        .flat_map(|f| f.to_le_bytes())
7651                        .collect::<Vec<_>>()[..]
7652                );
7653                assert_eq!(p1[*selected_offset - *payload_offset], 0u8);
7654            }
7655            other => panic!("unexpected additional gravity keyframe summary: {other:?}"),
7656        }
7657        assert_eq!(global.self_shadow.initial_keyframes, 1);
7658        assert_eq!(global.self_shadow.keyframes, 1);
7659        match global.self_shadow.initial_keyframe.as_ref().unwrap() {
7660            PmmDocumentKeyframeSummary::SelfShadow {
7661                mode,
7662                distance,
7663                selected,
7664                offset,
7665                byte_length,
7666                payload_offset,
7667                payload_byte_length,
7668                payload_bytes,
7669                mode_offset,
7670                distance_offset,
7671                selected_offset,
7672                ..
7673            } => {
7674                assert_eq!(*offset, global.self_shadow.state_end_offset.unwrap());
7675                assert_eq!(*byte_length, 18);
7676                assert_eq!(*payload_offset, *offset + 12);
7677                assert_eq!(*payload_byte_length, *byte_length - 12);
7678                assert_eq!(payload_bytes.len(), *payload_byte_length);
7679                assert_eq!(payload_bytes[0], *mode);
7680                assert_eq!(payload_bytes.last().copied(), Some(*selected as u8));
7681                assert_eq!(*mode, 1);
7682                assert_eq!(*distance, 8875.0);
7683                assert!(!*selected);
7684
7685                // layout metadata for self-shadow keyframe payload (exporter-prep slice).
7686                assert_eq!(*mode_offset, *payload_offset);
7687                assert_eq!(*distance_offset, *payload_offset + 1);
7688                assert_eq!(*selected_offset, *payload_offset + 5);
7689                let p0 = &payload_bytes;
7690                assert_eq!(p0[*mode_offset - *payload_offset], *mode);
7691                assert_eq!(
7692                    &p0[*distance_offset - *payload_offset..*distance_offset - *payload_offset + 4],
7693                    &8875.0f32.to_le_bytes()
7694                );
7695                assert_eq!(p0[*selected_offset - *payload_offset], 0u8);
7696            }
7697            other => panic!("unexpected self-shadow keyframe summary: {other:?}"),
7698        }
7699        match &global.self_shadow.keyframe_summaries[0] {
7700            PmmDocumentKeyframeSummary::SelfShadow {
7701                index,
7702                frame_index,
7703                mode,
7704                selected,
7705                offset,
7706                byte_length,
7707                payload_offset,
7708                payload_byte_length,
7709                payload_bytes,
7710                mode_offset,
7711                distance_offset,
7712                selected_offset,
7713                ..
7714            } => {
7715                assert_eq!(*index, Some(45));
7716                assert_eq!(*frame_index, 45);
7717                assert_eq!(*offset, global.self_shadow.keyframes_offset);
7718                assert_eq!(*byte_length, 22);
7719                assert_eq!(*payload_offset, *offset + 16);
7720                assert_eq!(*payload_byte_length, *byte_length - 16);
7721                assert_eq!(payload_bytes.len(), *payload_byte_length);
7722                assert_eq!(payload_bytes[0], *mode);
7723                assert_eq!(payload_bytes.last().copied(), Some(*selected as u8));
7724
7725                // same layout for indexed self-shadow keyframe
7726                assert_eq!(*mode_offset, *payload_offset);
7727                assert_eq!(*distance_offset, *payload_offset + 1);
7728                assert_eq!(*selected_offset, *payload_offset + 5);
7729                let p1 = &payload_bytes;
7730                assert_eq!(p1[*mode_offset - *payload_offset], *mode);
7731                assert_eq!(
7732                    &p1[*distance_offset - *payload_offset..*distance_offset - *payload_offset + 4],
7733                    &8875.0f32.to_le_bytes()
7734                );
7735                assert_eq!(p1[*selected_offset - *payload_offset], 0u8);
7736            }
7737            other => panic!("unexpected additional self-shadow keyframe summary: {other:?}"),
7738        }
7739        assert_eq!(global.settings.current_frame_index, 12);
7740        assert_eq!(global.settings.horizontal_scroll, 34);
7741        assert_eq!(global.settings.horizontal_scroll_thumb, 56);
7742        assert_eq!(global.settings.begin_frame_index, 3);
7743        assert_eq!(global.settings.end_frame_index, 120);
7744        assert!(global.settings.audio_enabled);
7745        assert!(global.settings.grid_and_axis_shown);
7746        assert!(global.settings.physics_ground_enabled);
7747        assert!(global.settings.model_selection_footer_present);
7748        assert!(global.offset_end > global.offset);
7749        assert!(global.camera.offset < global.light.offset);
7750        assert!(global.light.offset < global.accessories.offset);
7751        assert!(global.gravity.offset < global.self_shadow.offset);
7752    }
7753
7754    #[test]
7755    fn parses_pmm_project_graph_only_when_full_document_and_global_summary_present() {
7756        // PMM data without full document/global summary has project_graph == None
7757        let parsed_minimal = parse_pmm_manifest(
7758            b"Polygon Movie maker 0002\0UserFile\\Model\\miku.pmx\0UserFile\\Motion\\walk.vmd\0",
7759        )
7760        .unwrap();
7761        assert!(parsed_minimal.project_graph.is_none());
7762
7763        let parsed_settings = parse_pmm_manifest(&pmm_with_project_settings()).unwrap();
7764        assert!(parsed_settings.project_graph.is_none());
7765
7766        let parsed_doc_only = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
7767        assert!(parsed_doc_only.project_graph.is_none());
7768        assert!(parsed_doc_only.document_summary.is_some());
7769        assert!(parsed_doc_only.document_global_summary.is_none());
7770
7771        // pmm_with_document_global_summary() has project_graph == Some(...)
7772        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
7773        let graph = parsed
7774            .project_graph
7775            .as_ref()
7776            .expect("project_graph must be Some for pmm_with_document_global_summary");
7777        assert_eq!(
7778            graph.source,
7779            "mmd-anim-format first PMMv2 document graph DTO slice"
7780        );
7781        assert_eq!(graph.version, "0002");
7782        assert_eq!(graph.parsed_version, Some(2));
7783
7784        // the graph model/global counts match document_summary and document_global_summary
7785        let doc = parsed.document_summary.as_ref().unwrap();
7786        let glob = parsed.document_global_summary.as_ref().unwrap();
7787        assert_eq!(graph.model_counts.models, doc.counts.models);
7788        assert_eq!(graph.model_counts.bones, doc.counts.bones);
7789        assert_eq!(graph.model_counts.morphs, doc.counts.morphs);
7790        assert_eq!(graph.model_counts.bone_keyframes, doc.counts.bone_keyframes);
7791        assert_eq!(
7792            graph.model_counts.morph_keyframes,
7793            doc.counts.morph_keyframes
7794        );
7795        assert_eq!(graph.accessory_count, glob.accessories.accessory_count);
7796        assert_eq!(graph.accessory_keyframes, glob.accessories.keyframes);
7797        assert_eq!(graph.models.len(), doc.models.len());
7798        assert_eq!(graph.global.camera.keyframes, glob.camera.keyframes);
7799    }
7800
7801    #[test]
7802    fn pmm_project_scene_settings_synthetic_pmmv2_full_fixture() {
7803        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
7804        let graph = parsed
7805            .project_graph
7806            .as_ref()
7807            .expect("project_graph must be Some for synthetic PMMv2 full fixture");
7808        let ss = &graph.scene_settings;
7809        let glob = parsed.document_global_summary.as_ref().unwrap();
7810        let settings = &glob.settings;
7811
7812        // offset/offsetEnd match global.settings
7813        assert_eq!(ss.offset, settings.offset);
7814        assert_eq!(ss.offset_end, settings.offset_end);
7815
7816        // current/begin/end/preferredFps/loop fields match global.settings
7817        assert_eq!(ss.current_frame_index, settings.current_frame_index);
7818        assert_eq!(
7819            ss.current_frame_index_in_text_field,
7820            settings.current_frame_index_in_text_field
7821        );
7822        assert_eq!(
7823            ss.begin_frame_index_enabled,
7824            settings.begin_frame_index_enabled
7825        );
7826        assert_eq!(ss.end_frame_index_enabled, settings.end_frame_index_enabled);
7827        assert_eq!(ss.begin_frame_index, settings.begin_frame_index);
7828        assert_eq!(ss.end_frame_index, settings.end_frame_index);
7829        assert_eq!(ss.preferred_fps, settings.preferred_fps);
7830        assert_eq!(ss.loop_enabled, settings.loop_enabled);
7831
7832        // audio/background paths and enabled flags match global.settings
7833        assert_eq!(ss.audio_enabled, settings.audio_enabled);
7834        assert_eq!(ss.audio_path, settings.audio_path);
7835        assert_eq!(
7836            ss.background_video_enabled,
7837            settings.background_video_enabled
7838        );
7839        assert_eq!(ss.background_video_path, settings.background_video_path);
7840        assert_eq!(
7841            ss.background_image_enabled,
7842            settings.background_image_enabled
7843        );
7844        assert_eq!(ss.background_image_path, settings.background_image_path);
7845
7846        // empty synthetic paths (using push_empty_pmm_path in fixture) resolve to None
7847        assert!(
7848            ss.audio_path.is_empty(),
7849            "synthetic fixture uses empty audio path"
7850        );
7851        assert!(
7852            ss.background_video_path.is_empty(),
7853            "synthetic fixture uses empty background video path"
7854        );
7855        assert!(
7856            ss.background_image_path.is_empty(),
7857            "synthetic fixture uses empty background image path"
7858        );
7859        assert!(
7860            ss.audio_asset_reference_index.is_none(),
7861            "empty audio path must resolve to None asset reference index"
7862        );
7863        assert!(
7864            ss.background_video_asset_reference_index.is_none(),
7865            "empty background video path must resolve to None asset reference index"
7866        );
7867        assert!(
7868            ss.background_image_asset_reference_index.is_none(),
7869            "empty background image path must resolve to None asset reference index"
7870        );
7871    }
7872
7873    #[test]
7874    fn pmm_project_asset_bindings_synthetic_pmmv2_full_fixture() {
7875        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
7876        let graph = parsed
7877            .project_graph
7878            .as_ref()
7879            .expect("project_graph must be Some for synthetic PMMv2 full fixture");
7880        let doc = parsed.document_summary.as_ref().unwrap();
7881        let glob = parsed.document_global_summary.as_ref().unwrap();
7882
7883        // exactly one model binding + one accessory binding (sceneSettings omitted for empty paths)
7884        assert_eq!(
7885            graph.asset_bindings.len(),
7886            2,
7887            "synthetic PMMv2 full fixture must have exactly 2 asset bindings (1 model + 1 accessory)"
7888        );
7889        let model_bindings: Vec<_> = graph
7890            .asset_bindings
7891            .iter()
7892            .filter(|b| b.scope == "model")
7893            .collect();
7894        let acc_bindings: Vec<_> = graph
7895            .asset_bindings
7896            .iter()
7897            .filter(|b| b.scope == "accessory")
7898            .collect();
7899        let scene_bindings: Vec<_> = graph
7900            .asset_bindings
7901            .iter()
7902            .filter(|b| b.scope == "sceneSettings")
7903            .collect();
7904        assert_eq!(model_bindings.len(), 1);
7905        assert_eq!(acc_bindings.len(), 1);
7906        assert_eq!(
7907            scene_bindings.len(),
7908            0,
7909            "no sceneSettings binding exists because the synthetic fixture paths are empty"
7910        );
7911
7912        // model binding matches decoded owner path, path_offset, owner indexes, asset_reference_index
7913        let model0 = &doc.models[0];
7914        let mb = model_bindings[0];
7915        assert_eq!(mb.scope, "model");
7916        assert_eq!(mb.asset_kind, "model");
7917        assert_eq!(mb.owner_index, Some(model0.slot_index));
7918        assert_eq!(mb.document_index, Some(model0.document_model_index));
7919        assert_eq!(mb.owner_name.as_deref(), Some(model0.name.as_str()));
7920        assert_eq!(mb.path, model0.path);
7921        assert_eq!(mb.path_offset, Some(model0.path_offset));
7922        assert_eq!(mb.asset_reference_index, model0.asset_reference_index);
7923        if let Some(idx) = mb.asset_reference_index {
7924            let ar = &graph.asset_references[idx];
7925            assert_eq!(mb.asset_reference_offset, Some(ar.offset));
7926            assert_eq!(mb.asset_reference_end_offset, Some(ar.offset_end));
7927            assert_eq!(mb.asset_reference_confidence, Some(ar.confidence));
7928        }
7929
7930        // accessory binding matches decoded owner path, path_offset, owner indexes, asset_reference_index
7931        let acc0 = &glob.accessories.accessories[0];
7932        let ab = acc_bindings[0];
7933        assert_eq!(ab.scope, "accessory");
7934        assert_eq!(ab.asset_kind, "accessory");
7935        assert_eq!(ab.owner_index, Some(acc0.slot_index));
7936        assert_eq!(ab.document_index, Some(acc0.document_accessory_index));
7937        assert_eq!(ab.owner_name.as_deref(), Some(acc0.name.as_str()));
7938        assert_eq!(ab.path, acc0.path);
7939        assert_eq!(ab.path_offset, Some(acc0.path_offset));
7940        assert_eq!(ab.asset_reference_index, acc0.asset_reference_index);
7941        if let Some(idx) = ab.asset_reference_index {
7942            let ar = &graph.asset_references[idx];
7943            assert_eq!(ab.asset_reference_offset, Some(ar.offset));
7944            assert_eq!(ab.asset_reference_end_offset, Some(ar.offset_end));
7945            assert_eq!(ab.asset_reference_confidence, Some(ar.confidence));
7946        }
7947    }
7948
7949    #[test]
7950    fn pmm_project_export_readiness_synthetic_pmmv2_full_fixture() {
7951        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
7952        let graph = parsed
7953            .project_graph
7954            .as_ref()
7955            .expect("project_graph must be Some for synthetic PMMv2 full fixture");
7956        let cov = &graph.byte_coverage;
7957        let r = &graph.export_readiness;
7958
7959        assert!(
7960            r.lossless_parsed_byte_export_supported,
7961            "lossless parsed-byte export supported"
7962        );
7963        assert!(
7964            !r.semantic_graph_export_supported,
7965            "semantic graph export must be false (full exporter unfinished)"
7966        );
7967        assert!(
7968            r.source_byte_preservation_required,
7969            "source byte preservation required"
7970        );
7971        assert_eq!(
7972            r.blocker_count,
7973            r.blockers.len(),
7974            "blocker_count must equal blockers.len()"
7975        );
7976
7977        // unfinished semantic exporter blocker is always present with exact shape
7978        let semantic_blocker = r
7979            .blockers
7980            .iter()
7981            .find(|b| b.code == "PMM_SEMANTIC_EXPORTER_UNFINISHED")
7982            .expect("PMM_SEMANTIC_EXPORTER_UNFINISHED blocker must be present");
7983        assert_eq!(semantic_blocker.severity, "blocker");
7984        assert!(semantic_blocker.scope.is_none());
7985        assert!(semantic_blocker.kind.is_none());
7986        assert!(
7987            semantic_blocker
7988                .message
7989                .contains("semantic graph export/editing is not yet supported"),
7990            "message must state full semantic graph export/editing is not yet supported"
7991        );
7992
7993        // gap blocker presence matches byte_coverage.gap_count > 0 (synthetic full fixture has 0)
7994        let has_gap_blocker = r
7995            .blockers
7996            .iter()
7997            .any(|b| b.code == "PMM_DECODED_BYTE_GAPS_REMAIN");
7998        assert_eq!(
7999            has_gap_blocker,
8000            cov.gap_count > 0,
8001            "PMM_DECODED_BYTE_GAPS_REMAIN presence must match gap_count > 0"
8002        );
8003    }
8004
8005    #[test]
8006    fn pmm_project_track_reference_synthetic_pmmv2_full_fixture() {
8007        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
8008        let graph = parsed
8009            .project_graph
8010            .as_ref()
8011            .expect("project_graph must be Some for synthetic PMMv2 full fixture");
8012
8013        // exactly 8 track references: 3 model (bone/morph/model) + 4 global + 1 accessory
8014        assert_eq!(
8015            graph.track_references.len(),
8016            8,
8017            "synthetic PMMv2 full fixture must have exactly 8 track references"
8018        );
8019        let model_tracks: Vec<_> = graph
8020            .track_references
8021            .iter()
8022            .filter(|r| r.scope == "model")
8023            .collect();
8024        let global_tracks: Vec<_> = graph
8025            .track_references
8026            .iter()
8027            .filter(|r| r.scope == "global")
8028            .collect();
8029        let accessory_tracks: Vec<_> = graph
8030            .track_references
8031            .iter()
8032            .filter(|r| r.scope == "accessory")
8033            .collect();
8034        assert_eq!(
8035            model_tracks.len(),
8036            3,
8037            "per document model: bone + morph + model track groups"
8038        );
8039        assert_eq!(
8040            global_tracks.len(),
8041            4,
8042            "global: camera + light + gravity + selfShadow"
8043        );
8044        assert_eq!(
8045            accessory_tracks.len(),
8046            1,
8047            "each decoded accessory contributes one accessory track group"
8048        );
8049
8050        let doc = parsed.document_summary.as_ref().unwrap();
8051        let glob = parsed.document_global_summary.as_ref().unwrap();
8052
8053        // model bone track counts/offsets must match PmmDocumentModelSummary + sections
8054        let bone_ref = graph
8055            .track_references
8056            .iter()
8057            .find(|r| r.scope == "model" && r.track_kind == "bone")
8058            .expect("model bone track ref");
8059        let model0 = &doc.models[0];
8060        assert_eq!(bone_ref.owner_index, Some(model0.slot_index));
8061        assert_eq!(bone_ref.document_index, Some(model0.document_model_index));
8062        assert_eq!(bone_ref.owner_name.as_deref(), Some(model0.name.as_str()));
8063        assert_eq!(bone_ref.initial_keyframes, model0.initial_bone_keyframes);
8064        assert_eq!(bone_ref.keyframes, model0.bone_keyframes);
8065        assert_eq!(
8066            bone_ref.initial_keyframes_offset,
8067            Some(model0.sections.initial_bone_keyframes_offset)
8068        );
8069        assert_eq!(
8070            bone_ref.keyframe_count_offset,
8071            Some(model0.sections.bone_keyframe_count_offset)
8072        );
8073        assert_eq!(
8074            bone_ref.keyframes_offset,
8075            model0.sections.bone_keyframes_offset
8076        );
8077        assert_eq!(
8078            bone_ref.keyframes_end_offset,
8079            model0.sections.bone_keyframes_end_offset
8080        );
8081
8082        // global camera track counts/offsets must match PmmDocumentTrackSummary
8083        let cam_ref = graph
8084            .track_references
8085            .iter()
8086            .find(|r| r.scope == "global" && r.track_kind == "camera")
8087            .expect("global camera track ref");
8088        assert_eq!(cam_ref.initial_keyframes, glob.camera.initial_keyframes);
8089        assert_eq!(cam_ref.keyframes, glob.camera.keyframes);
8090        assert_eq!(cam_ref.keyframes_offset, glob.camera.keyframes_offset);
8091        assert_eq!(
8092            cam_ref.keyframes_end_offset,
8093            glob.camera.keyframes_end_offset
8094        );
8095        // for camera, initial kf starts at the track offset
8096        assert_eq!(cam_ref.initial_keyframes_offset, Some(glob.camera.offset));
8097        assert_eq!(
8098            cam_ref.keyframe_count_offset,
8099            Some(glob.camera.keyframe_count_offset)
8100        );
8101
8102        // accessory track counts/offsets must match PmmDocumentAccessorySummary
8103        let acc_ref = graph
8104            .track_references
8105            .iter()
8106            .find(|r| r.scope == "accessory")
8107            .expect("accessory track ref");
8108        let acc0 = &glob.accessories.accessories[0];
8109        assert_eq!(acc_ref.owner_index, Some(acc0.slot_index));
8110        assert_eq!(acc_ref.document_index, Some(acc0.document_accessory_index));
8111        assert_eq!(acc_ref.owner_name.as_deref(), Some(acc0.name.as_str()));
8112        assert_eq!(acc_ref.initial_keyframes, 1);
8113        assert_eq!(acc_ref.keyframes, acc0.keyframes);
8114        assert_eq!(
8115            acc_ref.initial_keyframes_offset,
8116            Some(acc0.initial_keyframe.offset)
8117        );
8118        assert_eq!(
8119            acc_ref.keyframe_count_offset,
8120            Some(acc0.keyframe_count_offset)
8121        );
8122        assert_eq!(acc_ref.keyframes_offset, acc0.keyframes_offset);
8123        assert_eq!(acc_ref.keyframes_end_offset, acc0.keyframes_end_offset);
8124    }
8125
8126    #[test]
8127    fn pmm_project_keyframe_reference_synthetic_pmmv2_full_fixture() {
8128        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
8129        let graph = parsed
8130            .project_graph
8131            .as_ref()
8132            .expect("project_graph must be Some for synthetic PMMv2 full fixture");
8133
8134        // Inspect synthetic fixture: 1 model (6 kfs) + 4 globals (8 kfs) + 1 accessory (2 kfs) = 16
8135        // (model: 1 init bone +1 add bone +1 init morph +1 add morph +1 init model +1 add model =6;
8136        //  global: camera/light/gravity/selfShadow each 1+1=8; accessory:1 init +1 add=2)
8137        // Do not hardcode 15; use actual derived count from current summaries (16 here).
8138        assert_eq!(
8139            graph.keyframe_references.len(),
8140            16,
8141            "synthetic PMMv2 full fixture must have exactly 16 keyframe references (derived from decoded summaries: 6 model + 8 global + 2 accessory)"
8142        );
8143
8144        let model_kfs: Vec<_> = graph
8145            .keyframe_references
8146            .iter()
8147            .filter(|r| r.scope == "model")
8148            .collect();
8149        let global_kfs: Vec<_> = graph
8150            .keyframe_references
8151            .iter()
8152            .filter(|r| r.scope == "global")
8153            .collect();
8154        let accessory_kfs: Vec<_> = graph
8155            .keyframe_references
8156            .iter()
8157            .filter(|r| r.scope == "accessory")
8158            .collect();
8159        assert_eq!(
8160            model_kfs.len(),
8161            6,
8162            "per document model: bone/morph/model each initial+additional"
8163        );
8164        assert_eq!(
8165            global_kfs.len(),
8166            8,
8167            "global: camera/light/gravity/selfShadow each initial+additional"
8168        );
8169        assert_eq!(
8170            accessory_kfs.len(),
8171            2,
8172            "each decoded accessory contributes initial + additional accessory keyframes"
8173        );
8174
8175        let doc = parsed.document_summary.as_ref().unwrap();
8176        let glob = parsed.document_global_summary.as_ref().unwrap();
8177
8178        // initial model bone keyframe reference must match source summary (no payloadBytes)
8179        let init_model_bone_kf = graph
8180            .keyframe_references
8181            .iter()
8182            .find(|r| r.scope == "model" && r.track_kind == "bone" && r.initial)
8183            .expect("initial model bone keyframe ref");
8184        let model0 = &doc.models[0];
8185        let src_init_bone = &model0.initial_bone_keyframe_summaries[0];
8186        assert_eq!(init_model_bone_kf.owner_index, Some(model0.slot_index));
8187        assert_eq!(
8188            init_model_bone_kf.document_index,
8189            Some(model0.document_model_index)
8190        );
8191        assert_eq!(
8192            init_model_bone_kf.owner_name.as_deref(),
8193            Some(model0.name.as_str())
8194        );
8195        assert!(init_model_bone_kf.initial);
8196        assert_eq!(init_model_bone_kf.keyframe_index, src_init_bone.index);
8197        assert_eq!(init_model_bone_kf.frame_index, src_init_bone.frame_index);
8198        assert_eq!(
8199            init_model_bone_kf.previous_keyframe_index,
8200            src_init_bone.previous_keyframe_index
8201        );
8202        assert_eq!(
8203            init_model_bone_kf.next_keyframe_index,
8204            src_init_bone.next_keyframe_index
8205        );
8206        assert_eq!(init_model_bone_kf.offset, src_init_bone.offset);
8207        assert_eq!(init_model_bone_kf.byte_length, src_init_bone.byte_length);
8208        assert_eq!(
8209            init_model_bone_kf.payload_offset,
8210            src_init_bone.payload_offset
8211        );
8212        assert_eq!(
8213            init_model_bone_kf.payload_byte_length,
8214            src_init_bone.payload_byte_length
8215        );
8216
8217        // additional global camera keyframe reference must match source summary
8218        let add_global_cam_kf = graph
8219            .keyframe_references
8220            .iter()
8221            .find(|r| r.scope == "global" && r.track_kind == "camera" && !r.initial)
8222            .expect("additional global camera keyframe ref");
8223        let src_add_cam = match &glob.camera.keyframe_summaries[0] {
8224            PmmDocumentKeyframeSummary::Camera {
8225                index,
8226                frame_index,
8227                previous_keyframe_index,
8228                next_keyframe_index,
8229                offset,
8230                byte_length,
8231                payload_offset,
8232                payload_byte_length,
8233                ..
8234            } => (
8235                *index,
8236                *frame_index,
8237                *previous_keyframe_index,
8238                *next_keyframe_index,
8239                *offset,
8240                *byte_length,
8241                *payload_offset,
8242                *payload_byte_length,
8243            ),
8244            other => panic!("unexpected add cam summary: {other:?}"),
8245        };
8246        assert!(!add_global_cam_kf.initial);
8247        assert_eq!(add_global_cam_kf.keyframe_index, src_add_cam.0);
8248        assert_eq!(add_global_cam_kf.frame_index, src_add_cam.1);
8249        assert_eq!(add_global_cam_kf.previous_keyframe_index, src_add_cam.2);
8250        assert_eq!(add_global_cam_kf.next_keyframe_index, src_add_cam.3);
8251        assert_eq!(add_global_cam_kf.offset, src_add_cam.4);
8252        assert_eq!(add_global_cam_kf.byte_length, src_add_cam.5);
8253        assert_eq!(add_global_cam_kf.payload_offset, src_add_cam.6);
8254        assert_eq!(add_global_cam_kf.payload_byte_length, src_add_cam.7);
8255
8256        // initial accessory keyframe reference must match source summary
8257        let init_acc_kf = graph
8258            .keyframe_references
8259            .iter()
8260            .find(|r| r.scope == "accessory" && r.initial)
8261            .expect("initial accessory keyframe ref");
8262        let acc0 = &glob.accessories.accessories[0];
8263        let src_init_acc = &acc0.initial_keyframe;
8264        assert_eq!(init_acc_kf.owner_index, Some(acc0.slot_index));
8265        assert_eq!(
8266            init_acc_kf.document_index,
8267            Some(acc0.document_accessory_index)
8268        );
8269        assert_eq!(init_acc_kf.owner_name.as_deref(), Some(acc0.name.as_str()));
8270        assert!(init_acc_kf.initial);
8271        assert_eq!(init_acc_kf.keyframe_index, src_init_acc.index);
8272        assert_eq!(init_acc_kf.frame_index, src_init_acc.frame_index);
8273        assert_eq!(
8274            init_acc_kf.previous_keyframe_index,
8275            src_init_acc.previous_keyframe_index
8276        );
8277        assert_eq!(
8278            init_acc_kf.next_keyframe_index,
8279            src_init_acc.next_keyframe_index
8280        );
8281        assert_eq!(init_acc_kf.offset, src_init_acc.offset);
8282        assert_eq!(init_acc_kf.byte_length, src_init_acc.byte_length);
8283        assert_eq!(init_acc_kf.payload_offset, src_init_acc.payload_offset);
8284        assert_eq!(
8285            init_acc_kf.payload_byte_length,
8286            src_init_acc.payload_byte_length
8287        );
8288    }
8289
8290    #[test]
8291    fn pmm_project_byte_coverage_synthetic_pmmv2_full_fixture() {
8292        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
8293        let graph = parsed
8294            .project_graph
8295            .as_ref()
8296            .expect("project_graph must be Some for synthetic PMMv2 full fixture");
8297
8298        let cov = &graph.byte_coverage;
8299        let doc = parsed.document_summary.as_ref().unwrap();
8300        let glob = parsed.document_global_summary.as_ref().unwrap();
8301
8302        // 1 model + 1 global root + 4 global tracks + 1 accessory block + 1 accessory = 8
8303        assert_eq!(
8304            cov.range_count, 8,
8305            "synthetic PMMv2 full fixture must have exactly 8 byte coverage ranges (1 model + 1 global root + 4 global tracks + 1 accessory block + 1 accessory)"
8306        );
8307        assert_eq!(cov.ranges.len(), 8);
8308
8309        // offset equals the first decoded model offset
8310        let first_model = &doc.models[0];
8311        assert_eq!(
8312            cov.offset, first_model.offset,
8313            "coverage offset must equal first model offset"
8314        );
8315
8316        // offsetEnd equals global.offset_end
8317        assert_eq!(
8318            cov.offset_end, glob.offset_end,
8319            "coverage offsetEnd must equal global offset_end"
8320        );
8321
8322        // byteLength == offsetEnd - offset
8323        assert_eq!(cov.byte_length, cov.offset_end - cov.offset);
8324
8325        // a camera track range matches global.camera
8326        let cam_range = cov
8327            .ranges
8328            .iter()
8329            .find(|r| r.scope == "global" && r.kind == "camera")
8330            .expect("camera track byte range in coverage");
8331        assert_eq!(cam_range.offset, glob.camera.offset);
8332        assert_eq!(cam_range.offset_end, glob.camera.offset_end);
8333        assert_eq!(
8334            cam_range.byte_length,
8335            glob.camera.offset_end - glob.camera.offset
8336        );
8337        assert_eq!(cam_range.scope, "global");
8338        assert_eq!(cam_range.kind, "camera");
8339        assert!(cam_range.owner_index.is_none());
8340        assert!(cam_range.document_index.is_none());
8341
8342        // an accessory range matches the decoded accessory summary
8343        let acc_range = cov
8344            .ranges
8345            .iter()
8346            .find(|r| r.scope == "accessory")
8347            .expect("accessory byte range in coverage");
8348        let acc0 = &glob.accessories.accessories[0];
8349        assert_eq!(acc_range.owner_index, Some(acc0.slot_index));
8350        assert_eq!(
8351            acc_range.document_index,
8352            Some(acc0.document_accessory_index)
8353        );
8354        assert_eq!(acc_range.name.as_deref(), Some(acc0.name.as_str()));
8355        assert_eq!(acc_range.offset, acc0.offset);
8356        assert_eq!(acc_range.offset_end, acc0.offset_end);
8357        assert_eq!(acc_range.byte_length, acc0.offset_end - acc0.offset);
8358        assert_eq!(acc_range.scope, "accessory");
8359        assert_eq!(acc_range.kind, "accessory");
8360
8361        // New merged coverage metrics + unknown gap ranges (exporter-prep diagnostic, read-only, from bc_ranges only)
8362        assert!(
8363            cov.covered_byte_length <= cov.byte_length,
8364            "covered_byte_length must not exceed byte_length"
8365        );
8366        assert!(
8367            (0.0..=1.0).contains(&cov.coverage_ratio),
8368            "coverage_ratio must be in [0.0, 1.0], got {}",
8369            cov.coverage_ratio
8370        );
8371        assert_eq!(
8372            cov.gap_count,
8373            cov.gaps.len(),
8374            "gap_count must equal gaps.len()"
8375        );
8376        let cov_span_start = cov.offset;
8377        let cov_span_end = cov.offset_end;
8378        for (i, g) in cov.gaps.iter().enumerate() {
8379            assert_eq!(g.scope, "unknown", "gap[{}] scope must be unknown", i);
8380            assert_eq!(g.kind, "gap", "gap[{}] kind must be gap", i);
8381            assert!(
8382                g.byte_length > 0,
8383                "gap[{}] must have nonzero byte_length",
8384                i
8385            );
8386            assert!(
8387                g.offset >= cov_span_start && g.offset_end <= cov_span_end,
8388                "gap[{}] must be inside coverage span [{}, {})",
8389                i,
8390                cov_span_start,
8391                cov_span_end
8392            );
8393            assert_eq!(g.offset_end - g.offset, g.byte_length);
8394            assert!(g.owner_index.is_none());
8395            assert!(g.document_index.is_none());
8396            assert!(g.name.is_none());
8397        }
8398
8399        // Decoded ranges (ranges) still exist and populated as before (count + spot checks)
8400        assert_eq!(cov.range_count, 8);
8401        assert_eq!(cov.ranges.len(), 8);
8402        let model_range = cov
8403            .ranges
8404            .iter()
8405            .find(|r| r.scope == "model")
8406            .expect("model byte range still present in ranges after adding coverage metrics");
8407        assert_eq!(model_range.scope, "model");
8408        assert_eq!(model_range.kind, "model");
8409        assert!(model_range.owner_index.is_some());
8410    }
8411
8412    #[test]
8413    fn pmm_document_model_summary_json_schema_is_stable() {
8414        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
8415        let document = parsed.document_summary.unwrap();
8416        let keys = json_keys(&serde_json::to_value(&document.models[0]).unwrap());
8417
8418        assert_eq!(
8419            keys,
8420            vec![
8421                "assetReferenceIndex",
8422                "blendEnabled",
8423                "boneCount",
8424                "boneKeyframeSummaries",
8425                "boneKeyframes",
8426                "boneNames",
8427                "boneStateSummaries",
8428                "constraintBoneCount",
8429                "constraintStateSummaries",
8430                "documentModelIndex",
8431                "drawOrderIndex",
8432                "edgeWidth",
8433                "englishName",
8434                "initialBoneKeyframeSummaries",
8435                "initialBoneKeyframes",
8436                "initialModelKeyframe",
8437                "initialModelKeyframes",
8438                "initialMorphKeyframeSummaries",
8439                "initialMorphKeyframes",
8440                "lastFrameIndex",
8441                "modelKeyframeSummaries",
8442                "modelKeyframes",
8443                "morphCount",
8444                "morphKeyframeSummaries",
8445                "morphKeyframes",
8446                "morphNames",
8447                "morphStateSummaries",
8448                "name",
8449                "offset",
8450                "offsetEnd",
8451                "outsideParentStateSummaries",
8452                "outsideParentSubjectBoneCount",
8453                "path",
8454                "pathOffset",
8455                "sections",
8456                "selectedBoneIndex",
8457                "selectedMorphIndices",
8458                "selfShadowEnabled",
8459                "slotIndex",
8460                "transformOrderIndex",
8461                "verticalScroll",
8462                "visible"
8463            ]
8464        );
8465    }
8466
8467    #[test]
8468    fn pmm_document_model_keyframe_summary_json_schema_is_stable() {
8469        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
8470        let document = parsed.document_summary.unwrap();
8471        let keys =
8472            json_keys(&serde_json::to_value(&document.models[0].initial_model_keyframe).unwrap());
8473
8474        assert_eq!(
8475            keys,
8476            vec![
8477                "byteLength",
8478                "constraintStateCount",
8479                "constraintStates",
8480                "constraintStatesByteLength",
8481                "constraintStatesOffset",
8482                "frameIndex",
8483                "index",
8484                "nextKeyframeIndex",
8485                "offset",
8486                "outsideParentIndexCount",
8487                "outsideParentIndices",
8488                "outsideParentIndicesByteLength",
8489                "outsideParentIndicesOffset",
8490                "payloadByteLength",
8491                "payloadBytes",
8492                "payloadOffset",
8493                "previousKeyframeIndex",
8494                "selfShadowEnabled",
8495                "selfShadowEnabledOffset",
8496                "visible",
8497                "visibleOffset"
8498            ]
8499        );
8500    }
8501
8502    #[test]
8503    fn pmm_outside_parent_index_summary_json_schema_is_stable() {
8504        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
8505        let document = parsed.document_summary.unwrap();
8506        let kf = &document.models[0].initial_model_keyframe;
8507        let keys = json_keys(&serde_json::to_value(&kf.outside_parent_indices[0]).unwrap());
8508
8509        assert_eq!(keys, vec!["parentModelBoneIndex", "parentModelIndex"]);
8510    }
8511
8512    #[test]
8513    fn pmm_document_model_sections_json_schema_is_stable() {
8514        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
8515        let document = parsed.document_summary.unwrap();
8516        let keys = json_keys(&serde_json::to_value(&document.models[0].sections).unwrap());
8517
8518        assert_eq!(
8519            keys,
8520            vec![
8521                "boneKeyframeCountOffset",
8522                "boneKeyframesEndOffset",
8523                "boneKeyframesOffset",
8524                "boneStatesOffset",
8525                "constraintStatesOffset",
8526                "initialBoneKeyframesOffset",
8527                "initialModelKeyframeOffset",
8528                "initialMorphKeyframesOffset",
8529                "modelKeyframeCountOffset",
8530                "modelKeyframesEndOffset",
8531                "modelKeyframesOffset",
8532                "morphKeyframeCountOffset",
8533                "morphKeyframesEndOffset",
8534                "morphKeyframesOffset",
8535                "morphStatesOffset",
8536                "outsideParentStatesOffset"
8537            ]
8538        );
8539    }
8540
8541    #[test]
8542    fn pmm_diagnostic_json_schema_is_stable() {
8543        let parsed = parse_pmm_manifest(&pmm_with_project_settings()).unwrap();
8544        let keys = json_keys(&serde_json::to_value(&parsed.diagnostics[0]).unwrap());
8545
8546        assert_eq!(keys, vec!["code", "level", "message"]);
8547    }
8548
8549    #[test]
8550    fn parses_pmm_project_settings_header_slice() {
8551        let parsed = parse_pmm_manifest(&pmm_with_project_settings()).unwrap();
8552
8553        assert_eq!(parsed.project_settings.screen_width, Some(1920));
8554        assert_eq!(parsed.project_settings.screen_height, Some(1080));
8555        assert_eq!(parsed.project_settings.timeline_frame_count, Some(424));
8556        assert_eq!(parsed.project_settings.frame_rate, Some(30.0));
8557        assert_eq!(parsed.timeline.start_frame, Some(0));
8558        assert_eq!(parsed.timeline.end_frame_exclusive, Some(424));
8559        assert_eq!(parsed.timeline.frame_count, Some(424));
8560        assert_eq!(parsed.timeline.frame_rate, Some(30.0));
8561        assert_eq!(parsed.timeline.duration_seconds, Some(424.0 / 30.0));
8562        assert_eq!(
8563            parsed.display_state.model_slot_flags,
8564            vec![1, 1, 2, 0, 0, 0, 0, 0]
8565        );
8566        assert_eq!(parsed.display_state.layout, "pmm-v2-flags");
8567        assert_eq!(parsed.display_state.declared_model_slot_count, Some(8));
8568        assert_eq!(parsed.display_state.model_slot_count, 8);
8569        assert_eq!(parsed.display_state.non_zero_model_slot_count, 3);
8570        assert_eq!(parsed.display_state.accessory_slot_count, None);
8571        assert_eq!(parsed.model_paths, vec!["UserFile/Model/miku.pmx"]);
8572        assert_eq!(parsed.motion_paths, vec!["UserFile/Motion/walk.vmd"]);
8573        assert_eq!(parsed.audio_paths, vec!["UserFile/Audio/song.wav"]);
8574        assert_eq!(parsed.model_assets.len(), 1);
8575        assert_eq!(parsed.model_assets[0].reference_index, 0);
8576        assert_eq!(parsed.model_assets[0].kind_index, 0);
8577        assert_eq!(parsed.model_assets[0].file_name, "miku.pmx");
8578        assert_eq!(parsed.model_assets[0].confidence, "high");
8579        assert!(parsed.model_assets[0].offset_end > parsed.model_assets[0].offset);
8580        assert_eq!(parsed.motion_assets[0].reference_index, 1);
8581        assert_eq!(parsed.motion_assets[0].kind_index, 0);
8582        assert_eq!(parsed.audio_assets[0].reference_index, 2);
8583        assert_eq!(parsed.audio_assets[0].extension, "wav");
8584        assert_eq!(parsed.diagnostics.len(), 4);
8585        assert_eq!(parsed.diagnostics[0].code, "PMM_PROJECT_GRAPH_PARTIAL");
8586        assert_eq!(parsed.diagnostics[1].code, "PMM_ASSET_REFERENCES_SCAN");
8587        assert!(parsed.diagnostics[1].message.contains("references=3"));
8588        assert_eq!(
8589            parsed.diagnostics[2].code,
8590            "PMM_MODEL_SLOT_COUNT_PARTIAL_DECODE"
8591        );
8592        assert_eq!(parsed.diagnostics[3].code, "PMM_ASSET_COUNT_MISMATCH");
8593    }
8594
8595    #[test]
8596    fn pmm_asset_summary_groups_manifest_derived_references() {
8597        let parsed = parse_pmm_manifest(&pmm_with_project_settings()).unwrap();
8598        let summary = &parsed.asset_summary;
8599
8600        assert_eq!(summary.reference_count, 3);
8601        assert_eq!(summary.high_confidence_count, 3);
8602        assert_eq!(summary.medium_confidence_count, 0);
8603        assert_eq!(summary.low_confidence_count, 0);
8604        assert_eq!(summary.kind_counts.get("model"), Some(&1));
8605        assert_eq!(summary.kind_counts.get("motion"), Some(&1));
8606        assert_eq!(summary.kind_counts.get("audio"), Some(&1));
8607        assert_eq!(summary.extension_counts.get("pmx"), Some(&1));
8608        assert_eq!(summary.extension_counts.get("vmd"), Some(&1));
8609        assert_eq!(summary.extension_counts.get("wav"), Some(&1));
8610        assert_eq!(summary.confidence_counts.get("high"), Some(&3));
8611    }
8612
8613    #[test]
8614    fn parses_pmm_v2_document_model_keyframe_summary() {
8615        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
8616        let document = parsed.document_summary.as_ref().unwrap();
8617
8618        assert_eq!(document.source, "nanoem/ext/document.c PMMv2 layout");
8619        assert_eq!(document.selected_model_index, 0);
8620        assert_eq!(document.model_count, 1);
8621        assert_eq!(document.counts.models, 1);
8622        assert_eq!(document.counts.bones, 1);
8623        assert_eq!(document.counts.morphs, 1);
8624        assert_eq!(document.counts.initial_bone_keyframes, 1);
8625        assert_eq!(document.counts.bone_keyframes, 1);
8626        assert_eq!(document.counts.initial_morph_keyframes, 1);
8627        assert_eq!(document.counts.morph_keyframes, 1);
8628        assert_eq!(document.counts.initial_model_keyframes, 1);
8629        assert_eq!(document.counts.model_keyframes, 1);
8630
8631        let model = &document.models[0];
8632        assert_eq!(model.slot_index, 0);
8633        assert_eq!(model.document_model_index, 0);
8634        assert_eq!(model.name, "Miku");
8635        assert_eq!(model.english_name, "MikuEn");
8636        assert_eq!(model.path, "UserFile\\Model\\miku.pmx");
8637        assert_eq!(
8638            model.path_offset,
8639            model.offset + 1 + 1 + "Miku".len() + 1 + "MikuEn".len()
8640        );
8641        let asset_reference_index = model.asset_reference_index.unwrap();
8642        assert_eq!(
8643            parsed.asset_references[asset_reference_index].normalized_path,
8644            "UserFile/Model/miku.pmx"
8645        );
8646        assert_eq!(model.bone_count, 1);
8647        assert_eq!(model.bone_names, vec!["center".to_owned()]);
8648        assert_eq!(model.morph_count, 1);
8649        assert_eq!(model.morph_names, vec!["smile".to_owned()]);
8650        assert_eq!(model.constraint_bone_count, 1);
8651        assert_eq!(model.outside_parent_subject_bone_count, 1);
8652        assert_eq!(model.bone_keyframes, 1);
8653        assert_eq!(model.morph_keyframes, 1);
8654        assert_eq!(model.model_keyframes, 1);
8655        assert_eq!(model.initial_model_keyframe.index, None);
8656        assert_eq!(model.initial_model_keyframe.frame_index, 0);
8657        assert_eq!(model.initial_model_keyframe.previous_keyframe_index, 0);
8658        assert_eq!(model.initial_model_keyframe.next_keyframe_index, 0);
8659        assert_eq!(
8660            model.initial_model_keyframe.offset,
8661            model.sections.initial_model_keyframe_offset
8662        );
8663        assert_eq!(model.initial_model_keyframe.byte_length, 23);
8664        assert_eq!(
8665            model.initial_model_keyframe.payload_offset,
8666            model.initial_model_keyframe.offset + 12
8667        );
8668        assert_eq!(model.initial_model_keyframe.payload_byte_length, 11);
8669        assert_eq!(model.initial_model_keyframe.payload_bytes.len(), 11);
8670        assert_eq!(
8671            model.initial_model_keyframe.payload_bytes.len(),
8672            model.initial_model_keyframe.payload_byte_length
8673        );
8674        assert_model_keyframe_payload_layout(&model.initial_model_keyframe);
8675        assert_eq!(
8676            model.initial_model_keyframe.payload_bytes[0],
8677            model.initial_model_keyframe.visible as u8
8678        );
8679        assert_eq!(
8680            model.initial_model_keyframe.payload_bytes[10],
8681            model.initial_model_keyframe.self_shadow_enabled as u8
8682        );
8683        assert!(model.initial_model_keyframe.visible);
8684        assert_eq!(model.initial_model_keyframe.constraint_states, vec![true]);
8685        assert_eq!(
8686            model.initial_model_keyframe.outside_parent_indices[0].parent_model_index,
8687            2
8688        );
8689        assert_eq!(
8690            model.initial_model_keyframe.outside_parent_indices[0].parent_model_bone_index,
8691            3
8692        );
8693        assert!(model.initial_model_keyframe.self_shadow_enabled);
8694        assert_eq!(model.model_keyframe_summaries.len(), 1);
8695        assert_eq!(model.model_keyframe_summaries[0].index, Some(30));
8696        assert_eq!(model.model_keyframe_summaries[0].frame_index, 30);
8697        assert_eq!(
8698            model.model_keyframe_summaries[0].offset,
8699            model.sections.model_keyframes_offset
8700        );
8701        assert_eq!(model.model_keyframe_summaries[0].byte_length, 27);
8702        assert_eq!(
8703            model.model_keyframe_summaries[0].payload_offset,
8704            model.model_keyframe_summaries[0].offset + 16
8705        );
8706        assert_eq!(model.model_keyframe_summaries[0].payload_byte_length, 11);
8707        assert_eq!(model.model_keyframe_summaries[0].payload_bytes.len(), 11);
8708        assert_eq!(
8709            model.model_keyframe_summaries[0].payload_bytes.len(),
8710            model.model_keyframe_summaries[0].payload_byte_length
8711        );
8712        assert_model_keyframe_payload_layout(&model.model_keyframe_summaries[0]);
8713        assert_eq!(
8714            model.model_keyframe_summaries[0].payload_bytes[0],
8715            model.model_keyframe_summaries[0].visible as u8
8716        );
8717        assert_eq!(
8718            model.model_keyframe_summaries[0].payload_bytes[10],
8719            model.model_keyframe_summaries[0].self_shadow_enabled as u8
8720        );
8721        assert!(model.model_keyframe_summaries[0].visible);
8722        assert_eq!(
8723            model.model_keyframe_summaries[0].constraint_states,
8724            vec![true]
8725        );
8726        assert_eq!(
8727            model.model_keyframe_summaries[0].outside_parent_indices[0].parent_model_index,
8728            2
8729        );
8730        assert_eq!(
8731            model.model_keyframe_summaries[0].outside_parent_indices[0].parent_model_bone_index,
8732            3
8733        );
8734        assert!(model.model_keyframe_summaries[0].self_shadow_enabled);
8735        assert_eq!(model.draw_order_index, 0);
8736        assert_eq!(model.transform_order_index, 0);
8737        assert_eq!(model.selected_bone_index, 0);
8738        assert_eq!(model.selected_morph_indices, [-1, -1, -1, -1]);
8739        assert_eq!(model.vertical_scroll, 0);
8740        assert_eq!(model.last_frame_index, 30);
8741        assert!(model.visible);
8742        assert!(model.blend_enabled);
8743        assert_eq!(model.edge_width, 1.0);
8744        assert!(!model.self_shadow_enabled);
8745        assert!(model.offset_end > model.offset);
8746        assert!(model.path_offset > model.offset);
8747        assert!(model.sections.initial_bone_keyframes_offset > model.path_offset);
8748        assert!(model.sections.bone_keyframes_offset > model.sections.bone_keyframe_count_offset);
8749        assert!(model.sections.bone_keyframes_end_offset > model.sections.bone_keyframes_offset);
8750        assert!(model.sections.morph_keyframes_offset > model.sections.morph_keyframe_count_offset);
8751        assert!(model.sections.model_keyframes_offset > model.sections.model_keyframe_count_offset);
8752        assert!(
8753            model.sections.outside_parent_states_offset >= model.sections.constraint_states_offset
8754        );
8755        let document_diagnostic = parsed
8756            .diagnostics
8757            .iter()
8758            .find(|diagnostic| diagnostic.code == "PMM_DOCUMENT_SUMMARY_PARTIAL")
8759            .unwrap();
8760        assert!(
8761            document_diagnostic
8762                .message
8763                .contains("2 total bone keyframe(s) (1 additional)")
8764        );
8765        assert!(
8766            document_diagnostic
8767                .message
8768                .contains("2 total morph keyframe(s) (1 additional)")
8769        );
8770    }
8771
8772    #[test]
8773    fn pmm_document_bone_keyframe_summary_json_schema_is_stable() {
8774        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
8775        let document = parsed.document_summary.unwrap();
8776        let model = &document.models[0];
8777        let keys =
8778            json_keys(&serde_json::to_value(&model.initial_bone_keyframe_summaries[0]).unwrap());
8779
8780        assert_eq!(
8781            keys,
8782            vec![
8783                "byteLength",
8784                "frameIndex",
8785                "index",
8786                "interpolation",
8787                "nextKeyframeIndex",
8788                "offset",
8789                "orientation",
8790                "payloadByteLength",
8791                "payloadBytes",
8792                "payloadOffset",
8793                "physicsDisabled",
8794                "previousKeyframeIndex",
8795                "selected",
8796                "translation"
8797            ]
8798        );
8799    }
8800
8801    #[test]
8802    fn builds_runtime_clip_from_pmm_document_keyframe_chains() {
8803        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
8804        let mut model = parsed.document_summary.unwrap().models.remove(0);
8805        model.initial_bone_keyframe_summaries[0].next_keyframe_index = 1;
8806        model.bone_keyframe_summaries[0].index = Some(1);
8807        model.initial_morph_keyframe_summaries[0].next_keyframe_index = 1;
8808        model.morph_keyframe_summaries[0].index = Some(1);
8809
8810        let clip = build_pmm_document_model_clip(&model).unwrap();
8811
8812        assert_eq!(clip.bone_tracks().len(), 1);
8813        let bone_track = clip.find_bone_track(BoneIndex(0)).unwrap();
8814        assert_eq!(bone_track.keyframe_count(), 2);
8815        assert_eq!(bone_track.frame_range(), Some((0, 30)));
8816        assert_eq!(bone_track.sample(0.0).unwrap().0, Vec3A::ZERO);
8817        assert_eq!(bone_track.sample(30.0).unwrap().1, Quat::IDENTITY);
8818
8819        assert_eq!(clip.morph_tracks().len(), 1);
8820        let morph_track = clip.find_morph_track(MorphIndex(0)).unwrap();
8821        assert_eq!(morph_track.keyframe_count(), 2);
8822        assert!((morph_track.sample(0.0).unwrap() - 0.5).abs() < 1.0e-6);
8823        assert!((morph_track.sample(30.0).unwrap() - 0.5).abs() < 1.0e-6);
8824    }
8825
8826    #[test]
8827    fn pmm_document_clip_builder_ignores_unlinked_deleted_keyframes() {
8828        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
8829        let mut model = parsed.document_summary.unwrap().models.remove(0);
8830        model.bone_keyframe_summaries[0].index = Some(1);
8831        model.bone_keyframe_summaries[0].previous_keyframe_index = 0;
8832        model.bone_keyframe_summaries[0].next_keyframe_index = 0;
8833        model.morph_keyframe_summaries[0].index = Some(1);
8834        model.morph_keyframe_summaries[0].previous_keyframe_index = 0;
8835        model.morph_keyframe_summaries[0].next_keyframe_index = 0;
8836
8837        let clip = build_pmm_document_model_clip(&model).unwrap();
8838
8839        assert_eq!(
8840            clip.find_bone_track(BoneIndex(0)).unwrap().keyframe_count(),
8841            1
8842        );
8843        assert_eq!(
8844            clip.find_morph_track(MorphIndex(0))
8845                .unwrap()
8846                .keyframe_count(),
8847            1
8848        );
8849    }
8850
8851    #[test]
8852    fn pmm_document_clip_builder_rejects_disconnected_bone_chain() {
8853        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
8854        let mut model = parsed.document_summary.unwrap().models.remove(0);
8855        model.bone_keyframe_summaries[0].index = Some(1);
8856        model.bone_keyframe_summaries[0].previous_keyframe_index = 7;
8857        model.bone_keyframe_summaries[0].next_keyframe_index = 0;
8858        model.morph_keyframe_summaries.clear();
8859
8860        let error = build_pmm_document_model_clip(&model).unwrap_err();
8861
8862        assert!(
8863            error
8864                .to_string()
8865                .contains("1 disconnected additional bone keyframe")
8866        );
8867    }
8868
8869    #[test]
8870    fn pmm_document_clip_builder_rejects_disconnected_morph_chain() {
8871        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
8872        let mut model = parsed.document_summary.unwrap().models.remove(0);
8873        model.bone_keyframe_summaries.clear();
8874        model.morph_keyframe_summaries[0].index = Some(1);
8875        model.morph_keyframe_summaries[0].previous_keyframe_index = 7;
8876        model.morph_keyframe_summaries[0].next_keyframe_index = 0;
8877
8878        let error = build_pmm_document_model_clip(&model).unwrap_err();
8879
8880        assert!(
8881            error
8882                .to_string()
8883                .contains("1 disconnected additional morph keyframe")
8884        );
8885    }
8886
8887    #[test]
8888    fn pmm_document_clip_builder_rejects_missing_linked_keyframe() {
8889        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
8890        let mut model = parsed.document_summary.unwrap().models.remove(0);
8891        model.initial_bone_keyframe_summaries[0].next_keyframe_index = 999;
8892        model.bone_keyframe_summaries[0].index = Some(1);
8893
8894        let error = build_pmm_document_model_clip(&model).unwrap_err();
8895
8896        assert!(error.to_string().contains("missing index 999"));
8897    }
8898
8899    #[test]
8900    fn pmm_document_clip_builder_rejects_negative_bone_keyframe_index() {
8901        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
8902        let mut model = parsed.document_summary.unwrap().models.remove(0);
8903        model.bone_keyframe_summaries[0].index = Some(-1);
8904
8905        let error = build_pmm_document_model_clip(&model).unwrap_err();
8906
8907        assert!(
8908            error
8909                .to_string()
8910                .contains("additional bone keyframe index -1 must be non-negative")
8911        );
8912    }
8913
8914    #[test]
8915    fn pmm_document_clip_builder_rejects_negative_morph_keyframe_index() {
8916        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
8917        let mut model = parsed.document_summary.unwrap().models.remove(0);
8918        model.bone_keyframe_summaries.clear();
8919        model.morph_keyframe_summaries[0].index = Some(-1);
8920
8921        let error = build_pmm_document_model_clip(&model).unwrap_err();
8922
8923        assert!(
8924            error
8925                .to_string()
8926                .contains("additional morph keyframe index -1 must be non-negative")
8927        );
8928    }
8929
8930    #[test]
8931    fn pmm_document_clip_builder_rejects_wrong_bone_keyframe_backlink() {
8932        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
8933        let mut model = parsed.document_summary.unwrap().models.remove(0);
8934        model.initial_bone_keyframe_summaries[0].next_keyframe_index = 1;
8935        model.bone_keyframe_summaries[0].index = Some(1);
8936        model.bone_keyframe_summaries[0].previous_keyframe_index = 99;
8937
8938        let error = build_pmm_document_model_clip(&model).unwrap_err();
8939
8940        assert!(
8941            error
8942                .to_string()
8943                .contains("PMM bone 0 keyframe index 1 has previous index 99; expected 0")
8944        );
8945    }
8946
8947    #[test]
8948    fn pmm_document_clip_builder_rejects_wrong_morph_keyframe_backlink() {
8949        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
8950        let mut model = parsed.document_summary.unwrap().models.remove(0);
8951        model.bone_keyframe_summaries.clear();
8952        model.initial_morph_keyframe_summaries[0].next_keyframe_index = 1;
8953        model.morph_keyframe_summaries[0].index = Some(1);
8954        model.morph_keyframe_summaries[0].previous_keyframe_index = 99;
8955
8956        let error = build_pmm_document_model_clip(&model).unwrap_err();
8957
8958        assert!(
8959            error
8960                .to_string()
8961                .contains("PMM morph 0 keyframe index 1 has previous index 99; expected 0")
8962        );
8963    }
8964
8965    #[test]
8966    fn pmm_document_clip_builder_rejects_bone_index_in_initial_namespace() {
8967        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
8968        let model = parsed.document_summary.unwrap().models.remove(0);
8969
8970        let error = build_pmm_document_model_clip(&model).unwrap_err();
8971
8972        assert!(error.to_string().contains(
8973            "PMM additional bone keyframe index 0 overlaps initial bone track index namespace"
8974        ));
8975    }
8976
8977    #[test]
8978    fn pmm_document_clip_builder_rejects_morph_index_in_initial_namespace() {
8979        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
8980        let mut model = parsed.document_summary.unwrap().models.remove(0);
8981        model.bone_keyframe_summaries.clear();
8982
8983        let error = build_pmm_document_model_clip(&model).unwrap_err();
8984
8985        assert!(error.to_string().contains(
8986            "PMM additional morph keyframe index 0 overlaps initial morph track index namespace"
8987        ));
8988    }
8989
8990    #[test]
8991    fn builds_nonzero_track_two_hop_pmm_chains() {
8992        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
8993        let mut model = parsed.document_summary.unwrap().models.remove(0);
8994
8995        model.bone_count = 2;
8996        model.initial_bone_keyframe_summaries[0].next_keyframe_index = 0;
8997        let mut bone_track_one_initial = model.initial_bone_keyframe_summaries[0].clone();
8998        bone_track_one_initial.next_keyframe_index = 2;
8999        model
9000            .initial_bone_keyframe_summaries
9001            .push(bone_track_one_initial);
9002        let mut bone_first = model.bone_keyframe_summaries[0].clone();
9003        bone_first.index = Some(2);
9004        bone_first.frame_index = 15;
9005        bone_first.previous_keyframe_index = 1;
9006        bone_first.next_keyframe_index = 3;
9007        let mut bone_second = bone_first.clone();
9008        bone_second.index = Some(3);
9009        bone_second.frame_index = 30;
9010        bone_second.previous_keyframe_index = 2;
9011        bone_second.next_keyframe_index = 0;
9012        model.bone_keyframe_summaries = vec![bone_first, bone_second];
9013
9014        model.morph_count = 2;
9015        model.initial_morph_keyframe_summaries[0].next_keyframe_index = 0;
9016        let mut morph_track_one_initial = model.initial_morph_keyframe_summaries[0].clone();
9017        morph_track_one_initial.next_keyframe_index = 2;
9018        model
9019            .initial_morph_keyframe_summaries
9020            .push(morph_track_one_initial);
9021        let mut morph_first = model.morph_keyframe_summaries[0].clone();
9022        morph_first.index = Some(2);
9023        morph_first.frame_index = 15;
9024        morph_first.previous_keyframe_index = 1;
9025        morph_first.next_keyframe_index = 3;
9026        let mut morph_second = morph_first.clone();
9027        morph_second.index = Some(3);
9028        morph_second.frame_index = 30;
9029        morph_second.previous_keyframe_index = 2;
9030        morph_second.next_keyframe_index = 0;
9031        model.morph_keyframe_summaries = vec![morph_first, morph_second];
9032
9033        let clip = build_pmm_document_model_clip(&model).unwrap();
9034
9035        let bone_track = clip.find_bone_track(BoneIndex(1)).unwrap();
9036        assert_eq!(bone_track.keyframe_count(), 3);
9037        assert_eq!(bone_track.frame_range(), Some((0, 30)));
9038        let morph_track = clip.find_morph_track(MorphIndex(1)).unwrap();
9039        assert_eq!(morph_track.keyframe_count(), 3);
9040        assert_eq!(morph_track.frame_range(), Some((0, 30)));
9041    }
9042
9043    #[test]
9044    fn pmm_document_clip_builder_rejects_indexed_initial_bone_keyframe() {
9045        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
9046        let mut model = parsed.document_summary.unwrap().models.remove(0);
9047        model.initial_bone_keyframe_summaries[0].index = Some(0);
9048
9049        let error = build_pmm_document_model_clip(&model).unwrap_err();
9050
9051        assert!(
9052            error
9053                .to_string()
9054                .contains("PMM bone 0 initial keyframe must not have explicit index 0")
9055        );
9056    }
9057
9058    #[test]
9059    fn decodes_pmm_bone_interpolation_as_contiguous_curves() {
9060        let interpolation = [1, 2, 3, 4, 11, 12, 13, 14, 21, 22, 23, 24, 31, 32, 33, 34];
9061
9062        let (position, rotation) = decode_pmm_bone_interpolation(interpolation);
9063
9064        assert_eq!(
9065            position.x,
9066            InterpolationScalar {
9067                x1: 1,
9068                y1: 2,
9069                x2: 3,
9070                y2: 4
9071            }
9072        );
9073        assert_eq!(
9074            position.y,
9075            InterpolationScalar {
9076                x1: 11,
9077                y1: 12,
9078                x2: 13,
9079                y2: 14
9080            }
9081        );
9082        assert_eq!(
9083            position.z,
9084            InterpolationScalar {
9085                x1: 21,
9086                y1: 22,
9087                x2: 23,
9088                y2: 24
9089            }
9090        );
9091        assert_eq!(
9092            rotation,
9093            InterpolationScalar {
9094                x1: 31,
9095                y1: 32,
9096                x2: 33,
9097                y2: 34
9098            }
9099        );
9100    }
9101
9102    #[test]
9103    fn pmm_document_morph_keyframe_summary_json_schema_is_stable() {
9104        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
9105        let document = parsed.document_summary.unwrap();
9106        let model = &document.models[0];
9107        let keys =
9108            json_keys(&serde_json::to_value(&model.initial_morph_keyframe_summaries[0]).unwrap());
9109
9110        assert_eq!(
9111            keys,
9112            vec![
9113                "byteLength",
9114                "frameIndex",
9115                "index",
9116                "nextKeyframeIndex",
9117                "offset",
9118                "payloadByteLength",
9119                "payloadBytes",
9120                "payloadOffset",
9121                "previousKeyframeIndex",
9122                "selected",
9123                "weight"
9124            ]
9125        );
9126    }
9127
9128    #[test]
9129    fn parses_pmm_v2_document_bone_morph_keyframe_summaries() {
9130        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
9131        let document = parsed.document_summary.as_ref().unwrap();
9132        let model = &document.models[0];
9133
9134        assert_eq!(model.initial_bone_keyframe_summaries.len(), 1);
9135        let ibk = &model.initial_bone_keyframe_summaries[0];
9136        assert_eq!(ibk.index, None);
9137        assert_eq!(ibk.frame_index, 0);
9138        assert_eq!(ibk.previous_keyframe_index, 0);
9139        assert_eq!(ibk.next_keyframe_index, 0);
9140        assert_eq!(ibk.interpolation, [20u8; 16]);
9141        assert_eq!(ibk.translation, [0.0, 0.0, 0.0]);
9142        assert_eq!(ibk.orientation, [0.0, 0.0, 0.0, 1.0]);
9143        assert!(!ibk.physics_disabled);
9144        assert!(!ibk.selected);
9145        assert_eq!(ibk.byte_length, 58);
9146        assert_eq!(ibk.offset, model.sections.initial_bone_keyframes_offset);
9147        assert_eq!(ibk.payload_offset, ibk.offset + 12);
9148        assert_eq!(ibk.payload_byte_length, 46);
9149        assert_eq!(ibk.payload_bytes.len(), 46);
9150        assert_eq!(ibk.payload_bytes.len(), ibk.payload_byte_length);
9151        assert_eq!(&ibk.payload_bytes[..16], &ibk.interpolation[..]);
9152
9153        assert_eq!(model.bone_keyframe_summaries.len(), 1);
9154        let bk = &model.bone_keyframe_summaries[0];
9155        assert_eq!(bk.index, Some(0));
9156        assert_eq!(bk.frame_index, 30);
9157        assert_eq!(bk.previous_keyframe_index, 0);
9158        assert_eq!(bk.next_keyframe_index, 0);
9159        assert_eq!(bk.interpolation, [20u8; 16]);
9160        assert_eq!(bk.translation, [0.0, 0.0, 0.0]);
9161        assert_eq!(bk.orientation, [0.0, 0.0, 0.0, 1.0]);
9162        assert!(!bk.physics_disabled);
9163        assert!(!bk.selected);
9164        assert_eq!(bk.byte_length, 62);
9165        assert_eq!(bk.offset, model.sections.bone_keyframes_offset);
9166        assert_eq!(bk.payload_offset, bk.offset + 16);
9167        assert_eq!(bk.payload_byte_length, 46);
9168        assert_eq!(bk.payload_bytes.len(), 46);
9169        assert_eq!(bk.payload_bytes.len(), bk.payload_byte_length);
9170        assert_eq!(&bk.payload_bytes[..16], &bk.interpolation[..]);
9171
9172        assert_eq!(model.initial_morph_keyframe_summaries.len(), 1);
9173        let imk = &model.initial_morph_keyframe_summaries[0];
9174        assert_eq!(imk.index, None);
9175        assert_eq!(imk.frame_index, 0);
9176        assert_eq!(imk.previous_keyframe_index, 0);
9177        assert_eq!(imk.next_keyframe_index, 0);
9178        assert!((imk.weight - 0.5).abs() < 1e-6);
9179        assert!(!imk.selected);
9180        assert_eq!(imk.byte_length, 17);
9181        assert_eq!(imk.offset, model.sections.initial_morph_keyframes_offset);
9182        assert_eq!(imk.payload_offset, imk.offset + 12);
9183        assert_eq!(imk.payload_byte_length, 5);
9184        assert_eq!(imk.payload_bytes.len(), 5);
9185        assert_eq!(imk.payload_bytes.len(), imk.payload_byte_length);
9186        let imk_weight_bytes = f32::from_le_bytes([
9187            imk.payload_bytes[0],
9188            imk.payload_bytes[1],
9189            imk.payload_bytes[2],
9190            imk.payload_bytes[3],
9191        ]);
9192        assert!((imk_weight_bytes - imk.weight).abs() < 1e-6);
9193        assert_eq!(imk.payload_bytes[4], imk.selected as u8);
9194
9195        assert_eq!(model.morph_keyframe_summaries.len(), 1);
9196        let mk = &model.morph_keyframe_summaries[0];
9197        assert_eq!(mk.index, Some(0));
9198        assert_eq!(mk.frame_index, 15);
9199        assert_eq!(mk.previous_keyframe_index, 0);
9200        assert_eq!(mk.next_keyframe_index, 0);
9201        assert!((mk.weight - 0.5).abs() < 1e-6);
9202        assert!(!mk.selected);
9203        assert_eq!(mk.byte_length, 21);
9204        assert_eq!(mk.offset, model.sections.morph_keyframes_offset);
9205        assert_eq!(mk.payload_offset, mk.offset + 16);
9206        assert_eq!(mk.payload_byte_length, 5);
9207        assert_eq!(mk.payload_bytes.len(), 5);
9208        assert_eq!(mk.payload_bytes.len(), mk.payload_byte_length);
9209        let mk_weight_bytes = f32::from_le_bytes([
9210            mk.payload_bytes[0],
9211            mk.payload_bytes[1],
9212            mk.payload_bytes[2],
9213            mk.payload_bytes[3],
9214        ]);
9215        assert!((mk_weight_bytes - mk.weight).abs() < 1e-6);
9216        assert_eq!(mk.payload_bytes[4], mk.selected as u8);
9217    }
9218
9219    #[test]
9220    fn pmm_v2_document_summary_rejects_selected_model_index_out_of_range() {
9221        let mut data = pmm_with_document_summary();
9222        data[53] = 1;
9223
9224        let parsed = parse_pmm_manifest(&data).unwrap();
9225
9226        assert!(parsed.document_summary.is_none());
9227    }
9228
9229    #[test]
9230    fn parses_initial_pmm_model_slot_slice() {
9231        let parsed = parse_pmm_manifest(&pmm_with_initial_model_slot()).unwrap();
9232
9233        assert_eq!(parsed.model_slots.len(), 1);
9234        let slot = &parsed.model_slots[0];
9235        assert_eq!(slot.slot_index, 0);
9236        assert_eq!(slot.display_slot_index, Some(1));
9237        assert_eq!(slot.offset, 56);
9238        assert_eq!(slot.model_path_offset, 71);
9239        assert_eq!(slot.trailing_zero_padding_bytes, 0);
9240        assert_eq!(slot.next_non_zero_offset, Some(slot.offset_end + 1));
9241        assert_eq!(slot.name, "TestModel");
9242        assert_eq!(slot.name_bytes, b"TestModel");
9243        assert_eq!(slot.english_name, "Base");
9244        assert_eq!(slot.english_name_bytes, b"Base");
9245        assert_eq!(
9246            slot.model_path,
9247            "C:\\MMDDev\\data\\unittest\\test_1bone_cube.pmx"
9248        );
9249        assert_eq!(
9250            slot.normalized_path,
9251            "C:/MMDDev/data/unittest/test_1bone_cube.pmx"
9252        );
9253        assert_eq!(slot.asset_reference_index, Some(0));
9254        assert_eq!(slot.confidence, "high");
9255        assert_eq!(parsed.asset_references[0].offset, 56);
9256        assert_eq!(parsed.motion_paths, vec!["UserFile/Motion/walk.vmd"]);
9257        assert!(
9258            parsed
9259                .diagnostics
9260                .iter()
9261                .any(|diagnostic| diagnostic.code == "PMM_MODEL_SLOT_INITIAL_SLICE")
9262        );
9263        assert!(
9264            parsed
9265                .diagnostics
9266                .iter()
9267                .any(|diagnostic| diagnostic.code == "PMM_MODEL_SLOT_COUNT_PARTIAL_DECODE")
9268        );
9269        assert!(
9270            parsed
9271                .diagnostics
9272                .iter()
9273                .any(|diagnostic| diagnostic.code == "PMM_MODEL_SLOT_SCAN_STOPPED")
9274        );
9275    }
9276
9277    #[test]
9278    fn parses_sequential_pmm_model_slot_header_slice() {
9279        let parsed = parse_pmm_manifest(&pmm_with_two_initial_model_slots()).unwrap();
9280
9281        assert_eq!(parsed.model_slots.len(), 2);
9282        assert_eq!(parsed.model_slots[0].slot_index, 0);
9283        assert_eq!(parsed.model_slots[1].slot_index, 1);
9284        assert_eq!(parsed.model_slots[0].display_slot_index, Some(0));
9285        assert_eq!(parsed.model_slots[1].display_slot_index, Some(1));
9286        assert_eq!(parsed.model_slots[0].trailing_zero_padding_bytes, 0);
9287        assert_eq!(
9288            parsed.model_slots[0].next_non_zero_offset,
9289            Some(parsed.model_slots[1].offset)
9290        );
9291        assert_eq!(
9292            parsed.model_slots[1].offset,
9293            parsed.model_slots[0].offset_end + 1
9294        );
9295        assert_eq!(parsed.model_slots[1].name, "Sour");
9296        assert_eq!(
9297            parsed.model_slots[1].model_path,
9298            "G:\\MikuMikuDance\\MMD Models\\Sour\\sour.pmx"
9299        );
9300        assert_eq!(
9301            parsed.model_slots[1].normalized_path,
9302            "G:/MikuMikuDance/MMD Models/Sour/sour.pmx"
9303        );
9304        assert_eq!(parsed.model_slots[1].asset_reference_index, Some(1));
9305        assert_eq!(
9306            parsed.asset_references[1].offset,
9307            parsed.model_slots[1].offset
9308        );
9309        assert!(
9310            !parsed
9311                .diagnostics
9312                .iter()
9313                .any(|diagnostic| diagnostic.code == "PMM_MODEL_SLOT_SCAN_STOPPED")
9314        );
9315    }
9316
9317    #[test]
9318    fn pmm_model_slot_scan_stops_when_next_asset_reference_is_not_matched() {
9319        let data = pmm_with_two_initial_model_slots();
9320        let display_state = parse_display_state(&data, Some(2));
9321        let references = extract_asset_references(&data);
9322        let scan = parse_model_slots_from_header(&data, &references[..1], &display_state);
9323
9324        assert_eq!(scan.slots.len(), 1);
9325        let stop = scan.stop.unwrap();
9326        assert_eq!(stop.offset, scan.slots[0].offset_end + 1);
9327        assert_eq!(stop.reason, "asset_reference_not_matched");
9328    }
9329
9330    #[test]
9331    fn pmm_model_slot_scan_stops_at_implausible_next_name_length() {
9332        let mut data = pmm_with_initial_model_slot();
9333        let slot_end = parse_pmm_manifest(&data).unwrap().model_slots[0].offset_end;
9334        data.truncate(slot_end + 1);
9335        data.push(255);
9336        data.extend_from_slice(b"UserFile\\Motion\\walk.vmd\0");
9337        let parsed = parse_pmm_manifest(&data).unwrap();
9338
9339        assert_eq!(parsed.model_slots.len(), 1);
9340        let diagnostic = parsed
9341            .diagnostics
9342            .iter()
9343            .find(|diagnostic| diagnostic.code == "PMM_MODEL_SLOT_SCAN_STOPPED")
9344            .unwrap();
9345        assert!(diagnostic.message.contains("missing_name"));
9346    }
9347
9348    #[test]
9349    fn pmm_model_slot_scan_stops_at_null_byte_in_next_name() {
9350        let mut data = pmm_with_initial_model_slot();
9351        let slot_end = parse_pmm_manifest(&data).unwrap().model_slots[0].offset_end;
9352        data.truncate(slot_end + 1);
9353        data.push(4);
9354        data.extend_from_slice(b"A\0BC");
9355        data.extend_from_slice(b"UserFile\\Motion\\walk.vmd\0");
9356        let parsed = parse_pmm_manifest(&data).unwrap();
9357
9358        assert_eq!(parsed.model_slots.len(), 1);
9359        let diagnostic = parsed
9360            .diagnostics
9361            .iter()
9362            .find(|diagnostic| diagnostic.code == "PMM_MODEL_SLOT_SCAN_STOPPED")
9363            .unwrap();
9364        assert!(diagnostic.message.contains("missing_name"));
9365    }
9366
9367    #[test]
9368    fn pmm_model_slot_scan_records_zero_padding_before_binary_graph() {
9369        let mut data = pmm_with_initial_model_slot();
9370        let slot_end = parse_pmm_manifest(&data).unwrap().model_slots[0].offset_end;
9371        data.truncate(slot_end + 1);
9372        data.extend_from_slice(&[0; 12]);
9373        data.extend_from_slice(&[15, 34, 3, 0, 0, 8, 145, 128]);
9374        let parsed = parse_pmm_manifest(&data).unwrap();
9375
9376        assert_eq!(parsed.model_slots.len(), 1);
9377        assert_eq!(parsed.model_slots[0].trailing_zero_padding_bytes, 12);
9378        assert_eq!(
9379            parsed.model_slots[0].next_non_zero_offset,
9380            Some(slot_end + 13)
9381        );
9382        let diagnostic = parsed
9383            .diagnostics
9384            .iter()
9385            .find(|diagnostic| diagnostic.code == "PMM_MODEL_SLOT_SCAN_STOPPED")
9386            .unwrap();
9387        assert!(diagnostic.message.contains("missing_name"));
9388    }
9389
9390    #[test]
9391    fn parses_pmm_numeric_version() {
9392        let parsed = parse_pmm_manifest(&pmm_with_project_settings()).unwrap();
9393
9394        assert_eq!(parsed.version, "0002");
9395        assert_eq!(parsed.parsed_version, Some(2));
9396    }
9397
9398    #[test]
9399    fn rejects_invalid_pmm_magic() {
9400        let err = parse_pmm_manifest(b"not a PMM project").unwrap_err();
9401
9402        assert_eq!(err, ImportError::InvalidMagic { format: "PMM" });
9403    }
9404
9405    #[test]
9406    fn parses_pmm_v1_display_count_layout() {
9407        let parsed = parse_pmm_manifest(&pmm_v1_with_display_counts()).unwrap();
9408
9409        assert_eq!(parsed.version, "0001");
9410        assert_eq!(parsed.parsed_version, Some(1));
9411        assert_eq!(parsed.project_settings.screen_width, Some(512));
9412        assert_eq!(parsed.project_settings.screen_height, Some(288));
9413        assert_eq!(parsed.project_settings.timeline_frame_count, Some(435));
9414        assert_eq!(parsed.project_settings.frame_rate, Some(35.0));
9415        assert_eq!(parsed.display_state.layout, "pmm-v1-counts");
9416        assert!(parsed.display_state.model_slot_flags.is_empty());
9417        assert_eq!(parsed.display_state.declared_model_slot_count, Some(8));
9418        assert_eq!(parsed.display_state.model_slot_count, 8);
9419        assert_eq!(parsed.display_state.non_zero_model_slot_count, 8);
9420        assert_eq!(parsed.display_state.accessory_slot_count, Some(9));
9421        assert_eq!(parsed.header_text_entries.len(), 2);
9422        assert_eq!(parsed.header_text_entries[0].index, 0);
9423        assert_eq!(parsed.header_text_entries[0].offset, 54);
9424        assert_eq!(parsed.header_text_entries[0].text, "Dummy");
9425        assert_eq!(parsed.header_text_entries[0].text_bytes, b"Dummy");
9426        assert_eq!(parsed.header_text_entries[1].text, "maker Dummy");
9427        assert!(
9428            !parsed
9429                .diagnostics
9430                .iter()
9431                .any(|diagnostic| diagnostic.code == "PMM_MODEL_SLOT_COUNT_PARTIAL_DECODE")
9432        );
9433        assert!(parsed.model_slots.is_empty());
9434        assert!(
9435            parsed
9436                .diagnostics
9437                .iter()
9438                .any(|diagnostic| diagnostic.code == "PMM_ASSET_COUNT_MISMATCH")
9439        );
9440        assert!(
9441            !parsed
9442                .diagnostics
9443                .iter()
9444                .any(|diagnostic| diagnostic.code == "PMM_DISPLAY_STATE_UNPLAUSIBLE")
9445        );
9446    }
9447
9448    #[test]
9449    fn pmm_v1_invalid_display_counts_do_not_fall_through_to_v2_flags() {
9450        let mut data = pmm_v1_with_display_counts();
9451        data[52] = 255;
9452        data[53] = 255;
9453        data[54] = 1;
9454        data[55] = 1;
9455        let parsed = parse_pmm_manifest(&data).unwrap();
9456
9457        assert_eq!(parsed.display_state.layout, "unknown");
9458        assert!(parsed.display_state.model_slot_flags.is_empty());
9459        assert_eq!(parsed.display_state.declared_model_slot_count, None);
9460        assert_eq!(parsed.display_state.model_slot_count, 0);
9461        assert_eq!(parsed.display_state.accessory_slot_count, None);
9462        assert!(
9463            parsed
9464                .diagnostics
9465                .iter()
9466                .any(|diagnostic| diagnostic.code == "PMM_DISPLAY_STATE_UNPLAUSIBLE")
9467        );
9468    }
9469
9470    #[test]
9471    fn pmm_v1_header_text_entries_require_asset_boundary() {
9472        let mut data = b"Polygon Movie maker 0001".to_vec();
9473        data.resize(54, 0);
9474        data.extend_from_slice(b"Header\0Without asset boundary\0");
9475        let parsed = parse_pmm_manifest(&data).unwrap();
9476
9477        assert!(parsed.asset_references.is_empty());
9478        assert!(parsed.header_text_entries.is_empty());
9479    }
9480
9481    #[test]
9482    fn pmm_v1_header_text_entries_allow_empty_region_before_asset() {
9483        let mut data = b"Polygon Movie maker 0001".to_vec();
9484        data.resize(54, 0);
9485        data.extend_from_slice(b"\0\0\0UserFile\\Model\\miku.pmd\0");
9486        let parsed = parse_pmm_manifest(&data).unwrap();
9487
9488        assert_eq!(parsed.model_paths, vec!["UserFile/Model/miku.pmd"]);
9489        assert!(parsed.header_text_entries.is_empty());
9490    }
9491
9492    #[test]
9493    fn pmm_v1_header_text_entries_ignore_unterminated_chunk_before_asset() {
9494        let mut data = b"Polygon Movie maker 0001".to_vec();
9495        data.resize(54, 0);
9496        data.extend_from_slice(b"Unterminated header UserFile\\Model\\miku.pmd\0");
9497        let parsed = parse_pmm_manifest(&data).unwrap();
9498
9499        assert_eq!(parsed.model_paths, vec!["UserFile/Model/miku.pmd"]);
9500        assert!(parsed.header_text_entries.is_empty());
9501    }
9502
9503    #[test]
9504    fn parses_pmm_bare_asset_file_names() {
9505        let data = b"Polygon Movie maker 0002\0miku.pmx\0walk.vmd\0song.wav\0stage.avi\0.X\0";
9506        let parsed = parse_pmm_manifest(data).unwrap();
9507
9508        assert_eq!(parsed.model_paths, vec!["miku.pmx"]);
9509        assert_eq!(parsed.motion_paths, vec!["walk.vmd"]);
9510        assert_eq!(parsed.audio_paths, vec!["song.wav"]);
9511        assert_eq!(parsed.video_paths, vec!["stage.avi"]);
9512        assert!(parsed.accessory_paths.is_empty());
9513        assert_eq!(parsed.model_assets[0].confidence, "medium");
9514        assert_eq!(parsed.model_assets[0].file_name, "miku.pmx");
9515        assert_eq!(parsed.motion_assets[0].kind_index, 0);
9516        assert_eq!(parsed.audio_assets[0].reference_index, 2);
9517        assert_eq!(parsed.video_assets[0].reference_index, 3);
9518        assert_eq!(parsed.video_assets[0].kind_index, 0);
9519    }
9520
9521    #[test]
9522    fn parses_pmm_background_video_asset_paths() {
9523        let data =
9524            b"Polygon Movie maker 0002\0UserFile\\Background\\stage.avs\0C:\\Video\\movie.avi\0";
9525        let parsed = parse_pmm_manifest(data).unwrap();
9526
9527        assert_eq!(
9528            parsed.video_paths,
9529            vec!["UserFile/Background/stage.avs", "C:/Video/movie.avi"]
9530        );
9531        assert_eq!(parsed.video_assets.len(), 2);
9532        assert_eq!(parsed.video_assets[0].extension, "avs");
9533        assert_eq!(parsed.video_assets[0].confidence, "high");
9534        assert_eq!(parsed.video_assets[1].extension, "avi");
9535        assert_eq!(parsed.video_assets[1].file_name, "movie.avi");
9536    }
9537
9538    #[test]
9539    fn parses_pmm_common_audio_image_and_video_asset_extensions() {
9540        let data = b"Polygon Movie maker 0002\0UserFile\\Audio\\song.mp3\0UserFile\\Audio\\loop.ogg\0UserFile\\Texture\\toon.png\0UserFile\\Texture\\photo.jpg\0UserFile\\Texture\\poster.jpeg\0UserFile\\Texture\\normal.dds\0UserFile\\Movie\\clip.mp4\0UserFile\\Movie\\stage.wmv\0";
9541        let parsed = parse_pmm_manifest(data).unwrap();
9542
9543        assert_eq!(
9544            parsed.audio_paths,
9545            vec!["UserFile/Audio/song.mp3", "UserFile/Audio/loop.ogg"]
9546        );
9547        assert_eq!(
9548            parsed.image_paths,
9549            vec![
9550                "UserFile/Texture/toon.png",
9551                "UserFile/Texture/photo.jpg",
9552                "UserFile/Texture/poster.jpeg",
9553                "UserFile/Texture/normal.dds"
9554            ]
9555        );
9556        assert_eq!(
9557            parsed.video_paths,
9558            vec!["UserFile/Movie/clip.mp4", "UserFile/Movie/stage.wmv"]
9559        );
9560        assert_eq!(parsed.audio_assets[0].extension, "mp3");
9561        assert_eq!(parsed.audio_assets[1].extension, "ogg");
9562        assert_eq!(parsed.image_assets[0].extension, "png");
9563        assert_eq!(parsed.image_assets[1].extension, "jpg");
9564        assert_eq!(parsed.image_assets[2].extension, "jpeg");
9565        assert_eq!(parsed.image_assets[3].extension, "dds");
9566        assert_eq!(parsed.video_assets[0].extension, "mp4");
9567        assert_eq!(parsed.video_assets[1].extension, "wmv");
9568    }
9569
9570    #[test]
9571    fn pmm_asset_scanner_rejects_partial_common_media_extensions() {
9572        let data = b"Polygon Movie maker 0002\0song.mp3extra\0toon.png2\0poster.jpeg_backup\0movie.mp4.tmp\0UserFile\\Audio\\song.mp3\0";
9573        let parsed = parse_pmm_manifest(data).unwrap();
9574
9575        assert_eq!(parsed.audio_paths, vec!["UserFile/Audio/song.mp3"]);
9576        assert!(parsed.image_paths.is_empty());
9577        assert!(parsed.video_paths.is_empty());
9578    }
9579
9580    #[test]
9581    fn parses_pmm_asset_paths_with_spaces() {
9582        let data = b"Polygon Movie maker 0002\0Label C:\\MikuMikuDance\\MMD Models\\Append Miku.pmx\0UserFile\\Motion\\walk cycle.vmd\0";
9583        let parsed = parse_pmm_manifest(data).unwrap();
9584
9585        assert_eq!(
9586            parsed.model_paths,
9587            vec!["C:/MikuMikuDance/MMD Models/Append Miku.pmx"]
9588        );
9589        assert_eq!(parsed.motion_paths, vec!["UserFile/Motion/walk cycle.vmd"]);
9590        assert_eq!(
9591            parsed.model_assets[0].path,
9592            "C:\\MikuMikuDance\\MMD Models\\Append Miku.pmx"
9593        );
9594        assert_eq!(parsed.model_assets[0].file_name, "Append Miku.pmx");
9595        assert_eq!(parsed.motion_assets[0].file_name, "walk cycle.vmd");
9596    }
9597
9598    #[test]
9599    fn pmm_asset_reference_marks_replacement_char_as_low_confidence() {
9600        let data = "Polygon Movie maker 0002\0C:\\Models\\bad�name.pmx\0"
9601            .as_bytes()
9602            .to_vec();
9603        let parsed = parse_pmm_manifest(&data).unwrap();
9604
9605        assert_eq!(parsed.model_assets[0].confidence, "low");
9606    }
9607
9608    #[test]
9609    fn pmm_asset_reference_marks_root_relative_fragment_as_low_confidence() {
9610        let data = b"Polygon Movie maker 0002\0\\Model\\miku.pmx\0";
9611        let parsed = parse_pmm_manifest(data).unwrap();
9612
9613        assert_eq!(parsed.model_assets[0].normalized_path, "/Model/miku.pmx");
9614        assert_eq!(parsed.model_assets[0].confidence, "low");
9615    }
9616
9617    #[test]
9618    fn pmm_asset_reference_marks_missing_drive_letter_fragment_as_low_confidence() {
9619        let data =
9620            b"Polygon Movie maker 0002\0:\\Develop\\MMDDev\\data\\unittest\\test_1bone_cube.pmx\0";
9621        let parsed = parse_pmm_manifest(data).unwrap();
9622
9623        assert_eq!(
9624            parsed.model_assets[0].normalized_path,
9625            ":/Develop/MMDDev/data/unittest/test_1bone_cube.pmx"
9626        );
9627        assert_eq!(parsed.model_assets[0].confidence, "low");
9628    }
9629
9630    #[test]
9631    fn pmm_asset_scan_keeps_complete_and_fragmented_duplicate_paths_separate() {
9632        let data = b"Polygon Movie maker 0002\0C:\\MMDDev\\data\\unittest\\test_1bone_cube.pmx\0:\\MMDDev\\data\\unittest\\test_1bone_cube.pmx\0";
9633        let parsed = parse_pmm_manifest(data).unwrap();
9634
9635        assert_eq!(parsed.model_assets.len(), 2);
9636        assert_eq!(
9637            parsed.model_assets[0].normalized_path,
9638            "C:/MMDDev/data/unittest/test_1bone_cube.pmx"
9639        );
9640        assert_eq!(parsed.model_assets[0].confidence, "high");
9641        assert_eq!(
9642            parsed.model_assets[1].normalized_path,
9643            ":/MMDDev/data/unittest/test_1bone_cube.pmx"
9644        );
9645        assert_eq!(parsed.model_assets[1].confidence, "low");
9646        assert!(
9647            parsed
9648                .diagnostics
9649                .iter()
9650                .any(|diagnostic| diagnostic.code == "PMM_ASSET_REFERENCE_DUPLICATE")
9651        );
9652    }
9653
9654    #[test]
9655    fn pmm_asset_reference_keeps_offset_range() {
9656        let data = b"Polygon Movie maker 0002\0Label UserFile\\Model\\miku.pmx\0";
9657        let parsed = parse_pmm_manifest(data).unwrap();
9658
9659        let reference = &parsed.asset_references[0];
9660        assert_eq!(reference.offset, 25);
9661        assert_eq!(reference.offset_end, data.len() - 1);
9662        assert_eq!(&data[reference.offset_end..reference.offset_end + 1], b"\0");
9663    }
9664
9665    #[test]
9666    fn pmm_asset_reference_offset_end_allows_truncated_final_chunk() {
9667        let data = b"Polygon Movie maker 0002\0UserFile\\Model\\miku.pmx";
9668        let parsed = parse_pmm_manifest(data).unwrap();
9669
9670        assert_eq!(parsed.asset_references[0].offset_end, data.len());
9671    }
9672
9673    #[test]
9674    fn pmm_diagnostics_flags_duplicate_asset_by_filename() {
9675        let data = b"Polygon Movie maker 0002\0C:\\Models\\miku.pmx\0UserFile\\Model\\miku.pmx\0UserFile\\Motion\\walk.vmd\0";
9676        let parsed = parse_pmm_manifest(data).unwrap();
9677
9678        assert!(
9679            parsed
9680                .diagnostics
9681                .iter()
9682                .any(|diagnostic| diagnostic.code == "PMM_ASSET_REFERENCE_DUPLICATE")
9683        );
9684    }
9685
9686    #[test]
9687    fn pmm_diagnostics_flags_missing_motion_when_models_present() {
9688        let data = b"Polygon Movie maker 0002\0UserFile\\Model\\miku.pmx\0";
9689        let parsed = parse_pmm_manifest(data).unwrap();
9690
9691        assert!(
9692            parsed
9693                .diagnostics
9694                .iter()
9695                .any(|diagnostic| diagnostic.code == "PMM_MOTION_REFERENCES_NOT_FOUND_IN_SCAN")
9696        );
9697    }
9698
9699    #[test]
9700    fn pmm_diagnostics_flags_declared_asset_count_mismatch() {
9701        let parsed = parse_pmm_manifest(&pmm_v1_with_display_counts()).unwrap();
9702        let mismatches = parsed
9703            .diagnostics
9704            .iter()
9705            .filter(|diagnostic| diagnostic.code == "PMM_ASSET_COUNT_MISMATCH")
9706            .collect::<Vec<_>>();
9707
9708        assert_eq!(mismatches.len(), 2);
9709        assert_eq!(mismatches[0].level, "info");
9710        assert_eq!(mismatches[1].level, "info");
9711        assert!(mismatches[0].message.contains("8 model slot"));
9712        assert!(mismatches[0].message.contains("1 model reference"));
9713        assert!(mismatches[1].message.contains("9 accessory slot"));
9714        assert!(mismatches[1].message.contains("1 accessory reference"));
9715    }
9716
9717    #[test]
9718    fn pmm_display_state_derives_active_and_empty_slot_indices_from_flags() {
9719        let parsed = parse_pmm_manifest(&pmm_with_project_settings()).unwrap();
9720        let ds = &parsed.display_state;
9721
9722        assert_eq!(ds.layout, "pmm-v2-flags");
9723        assert_eq!(ds.model_slot_flags, vec![1, 1, 2, 0, 0, 0, 0, 0]);
9724        assert_eq!(ds.selected_model_index, Some(0));
9725        assert_eq!(ds.document_model_count, Some(3));
9726        let expand_flags = ds.document_expand_flags.as_ref().unwrap();
9727        assert!(expand_flags.editing_cla);
9728        assert!(expand_flags.camera_panel);
9729        assert!(expand_flags.light_panel);
9730        assert!(!expand_flags.accessory_panel);
9731        assert!(!expand_flags.self_shadow_panel);
9732        assert_eq!(ds.model_slot_flag_entries.len(), 8);
9733        assert_eq!(ds.model_slot_flag_entries[0].slot_index, 0);
9734        assert_eq!(ds.model_slot_flag_entries[0].flag, 1);
9735        assert!(ds.model_slot_flag_entries[0].active);
9736        assert_eq!(ds.model_slot_flag_entries[3].slot_index, 3);
9737        assert_eq!(ds.model_slot_flag_entries[3].flag, 0);
9738        assert!(!ds.model_slot_flag_entries[3].active);
9739        assert_eq!(ds.active_model_slot_indices, vec![0, 1, 2]);
9740        assert_eq!(ds.empty_model_slot_indices, vec![3, 4, 5, 6, 7]);
9741    }
9742
9743    #[test]
9744    fn pmm_display_state_flag_counts_are_grouped_and_stable() {
9745        let parsed = parse_pmm_manifest(&pmm_with_project_settings()).unwrap();
9746        let counts = &parsed.display_state.model_slot_flag_counts;
9747
9748        assert_eq!(counts.get(&0), Some(&5));
9749        assert_eq!(counts.get(&1), Some(&2));
9750        assert_eq!(counts.get(&2), Some(&1));
9751        assert_eq!(counts.len(), 3);
9752
9753        let json = serde_json::to_value(counts).unwrap();
9754        let keys: Vec<&str> = json
9755            .as_object()
9756            .unwrap()
9757            .keys()
9758            .map(|k| k.as_str())
9759            .collect();
9760        assert_eq!(keys, vec!["0", "1", "2"]);
9761    }
9762
9763    #[test]
9764    fn pmm_v1_display_state_derived_arrays_are_empty_when_no_raw_flags() {
9765        let parsed = parse_pmm_manifest(&pmm_v1_with_display_counts()).unwrap();
9766        let ds = &parsed.display_state;
9767
9768        assert_eq!(ds.layout, "pmm-v1-counts");
9769        assert!(ds.model_slot_flags.is_empty());
9770        assert!(ds.model_slot_flag_entries.is_empty());
9771        assert!(ds.document_expand_flags.is_none());
9772        assert_eq!(ds.selected_model_index, None);
9773        assert_eq!(ds.document_model_count, None);
9774        assert!(ds.active_model_slot_indices.is_empty());
9775        assert!(ds.empty_model_slot_indices.is_empty());
9776        assert!(ds.model_slot_flag_counts.is_empty());
9777    }
9778
9779    #[test]
9780    fn pmm_unknown_display_state_derived_arrays_are_empty() {
9781        let mut data = pmm_with_project_settings();
9782        data[46] = 7;
9783        let parsed = parse_pmm_manifest(&data).unwrap();
9784        let ds = &parsed.display_state;
9785
9786        assert_eq!(ds.layout, "unknown");
9787        assert!(ds.active_model_slot_indices.is_empty());
9788        assert!(ds.empty_model_slot_indices.is_empty());
9789        assert!(ds.model_slot_flag_counts.is_empty());
9790        assert!(ds.model_slot_flag_entries.is_empty());
9791    }
9792
9793    #[test]
9794    fn pmm_display_state_all_active_slots() {
9795        let flags: &[u8] = &[1, 1, 1, 1, 1, 1, 1, 1];
9796        let active = active_slot_indices(flags);
9797        let empty = empty_slot_indices(flags);
9798        let counts = slot_flag_counts(flags);
9799
9800        assert_eq!(active, vec![0, 1, 2, 3, 4, 5, 6, 7]);
9801        assert!(empty.is_empty());
9802        assert_eq!(counts.get(&1), Some(&8));
9803        assert_eq!(counts.len(), 1);
9804    }
9805
9806    #[test]
9807    fn pmm_display_state_all_empty_slots() {
9808        let flags: &[u8] = &[0, 0, 0, 0, 0, 0, 0, 0];
9809        let active = active_slot_indices(flags);
9810        let empty = empty_slot_indices(flags);
9811        let counts = slot_flag_counts(flags);
9812
9813        assert!(active.is_empty());
9814        assert_eq!(empty, vec![0, 1, 2, 3, 4, 5, 6, 7]);
9815        assert_eq!(counts.get(&0), Some(&8));
9816        assert_eq!(counts.len(), 1);
9817    }
9818
9819    #[test]
9820    fn pmm_document_bone_state_summary_json_schema_is_stable() {
9821        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
9822        let document = parsed.document_summary.unwrap();
9823        let model = &document.models[0];
9824        let keys = json_keys(&serde_json::to_value(&model.bone_state_summaries[0]).unwrap());
9825
9826        assert_eq!(
9827            keys,
9828            vec![
9829                "dirty",
9830                "orientation",
9831                "physicsDisabled",
9832                "selected",
9833                "translation"
9834            ]
9835        );
9836    }
9837
9838    #[test]
9839    fn pmm_document_morph_state_summary_json_schema_is_stable() {
9840        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
9841        let document = parsed.document_summary.unwrap();
9842        let model = &document.models[0];
9843        let keys = json_keys(&serde_json::to_value(&model.morph_state_summaries[0]).unwrap());
9844
9845        assert_eq!(keys, vec!["weight"]);
9846    }
9847
9848    #[test]
9849    fn pmm_document_constraint_state_summary_json_schema_is_stable() {
9850        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
9851        let document = parsed.document_summary.unwrap();
9852        let model = &document.models[0];
9853        let keys = json_keys(&serde_json::to_value(&model.constraint_state_summaries[0]).unwrap());
9854
9855        assert_eq!(keys, vec!["enabled"]);
9856    }
9857
9858    #[test]
9859    fn pmm_document_outside_parent_state_summary_json_schema_is_stable() {
9860        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
9861        let document = parsed.document_summary.unwrap();
9862        let model = &document.models[0];
9863        let keys =
9864            json_keys(&serde_json::to_value(&model.outside_parent_state_summaries[0]).unwrap());
9865
9866        assert_eq!(
9867            keys,
9868            vec![
9869                "parentModelBoneIndex",
9870                "parentModelIndex",
9871                "subjectBoneIndex",
9872                "targetModelIndex"
9873            ]
9874        );
9875    }
9876
9877    #[test]
9878    fn parses_pmm_v2_document_model_state_block_summaries() {
9879        let parsed = parse_pmm_manifest(&pmm_with_document_summary()).unwrap();
9880        let document = parsed.document_summary.as_ref().unwrap();
9881        let model = &document.models[0];
9882
9883        assert_eq!(model.bone_state_summaries.len(), 1);
9884        let bs = &model.bone_state_summaries[0];
9885        assert_eq!(bs.translation, [1.0, 2.0, 3.0]);
9886        assert_eq!(bs.orientation, [0.0, 0.0, 0.0, 1.0]);
9887        assert!(bs.dirty);
9888        assert!(!bs.physics_disabled);
9889        assert!(bs.selected);
9890
9891        assert_eq!(model.morph_state_summaries.len(), 1);
9892        let ms = &model.morph_state_summaries[0];
9893        assert!((ms.weight - 0.75).abs() < 1e-6);
9894
9895        assert_eq!(model.constraint_state_summaries.len(), 1);
9896        let cs = &model.constraint_state_summaries[0];
9897        assert!(cs.enabled);
9898
9899        assert_eq!(model.outside_parent_state_summaries.len(), 1);
9900        let ops = &model.outside_parent_state_summaries[0];
9901        assert_eq!(ops.parent_model_index, 0);
9902        assert_eq!(ops.parent_model_bone_index, 5);
9903        assert_eq!(ops.subject_bone_index, 7);
9904        assert_eq!(ops.target_model_index, 1);
9905    }
9906
9907    #[test]
9908    fn pmm_asset_scanner_requires_extension_and_userfile_boundaries() {
9909        let data = b"Polygon Movie maker 0002\0C:\\notuserfile\\Model\\fake.pmx\0dummy.x_v2\0model.x_v2.pmd\0";
9910        let parsed = parse_pmm_manifest(data).unwrap();
9911
9912        assert_eq!(
9913            parsed.model_paths,
9914            vec!["C:/notuserfile/Model/fake.pmx", "model.x_v2.pmd"]
9915        );
9916        assert!(parsed.accessory_paths.is_empty());
9917    }
9918
9919    #[test]
9920    fn ignores_implausible_pmm_model_slot_flags() {
9921        let mut data = pmm_with_project_settings();
9922        data[46] = 7;
9923        let parsed = parse_pmm_manifest(&data).unwrap();
9924
9925        assert!(parsed.display_state.model_slot_flags.is_empty());
9926        assert_eq!(parsed.display_state.layout, "unknown");
9927        assert_eq!(parsed.display_state.declared_model_slot_count, None);
9928        assert_eq!(parsed.display_state.model_slot_count, 0);
9929        assert_eq!(parsed.display_state.non_zero_model_slot_count, 0);
9930        assert_eq!(parsed.display_state.accessory_slot_count, None);
9931        assert_eq!(parsed.diagnostics.len(), 3);
9932        assert_eq!(parsed.diagnostics[2].code, "PMM_DISPLAY_STATE_UNPLAUSIBLE");
9933    }
9934
9935    #[test]
9936    fn parses_pmm_v2_accessory_keyframe_scalar_summaries() {
9937        let parsed = parse_pmm_manifest(&pmm_with_document_global_summary()).unwrap();
9938        let global = parsed.document_global_summary.as_ref().unwrap();
9939        let accessory = &global.accessories.accessories[0];
9940
9941        assert_eq!(accessory.keyframe_summaries.len(), 1);
9942
9943        let initial = &accessory.initial_keyframe;
9944        assert_eq!(initial.index, None);
9945        assert_eq!(initial.frame_index, 0);
9946        assert!(initial.visible);
9947        assert_eq!(initial.opacity, 1.0);
9948        assert_eq!(initial.parent_model_index, -1);
9949        assert_eq!(initial.parent_model_bone_index, -1);
9950        assert_eq!(initial.translation, [1.0, 2.0, 3.0]);
9951        assert_eq!(initial.orientation, [0.1, 0.2, 0.3]);
9952        assert_eq!(initial.scale_factor, 10.0);
9953        assert!(initial.shadow_enabled);
9954        assert!(!initial.selected);
9955        assert_eq!(initial.payload_bytes.len(), 39);
9956        assert_eq!(initial.payload_bytes.len(), initial.payload_byte_length);
9957        assert_eq!(initial.payload_bytes[0], 1);
9958        assert_eq!(
9959            &initial.payload_bytes[initial.payload_bytes.len() - 2..],
9960            &[1, 0]
9961        );
9962
9963        // layout metadata for accessory keyframe payload (exporter-prep slice).
9964        // offsets are absolute; prove they address correct bytes inside payload_bytes for initial keyframe.
9965        assert_eq!(
9966            initial.packed_opacity_visible_offset,
9967            initial.payload_offset
9968        );
9969        assert_eq!(
9970            initial.parent_model_index_offset,
9971            initial.payload_offset + 1
9972        );
9973        assert_eq!(
9974            initial.parent_model_bone_index_offset,
9975            initial.payload_offset + 5
9976        );
9977        assert_eq!(initial.translation_offset, initial.payload_offset + 9);
9978        assert_eq!(initial.translation_byte_length, 12);
9979        assert_eq!(initial.orientation_offset, initial.payload_offset + 21);
9980        assert_eq!(initial.orientation_byte_length, 12);
9981        assert_eq!(initial.scale_factor_offset, initial.payload_offset + 33);
9982        assert_eq!(initial.shadow_enabled_offset, initial.payload_offset + 37);
9983        assert_eq!(initial.selected_offset, initial.payload_offset + 38);
9984        let p0 = &initial.payload_bytes;
9985        assert_eq!(
9986            p0[initial.packed_opacity_visible_offset - initial.payload_offset],
9987            1u8
9988        );
9989        let pmi0 = initial.parent_model_index_offset - initial.payload_offset;
9990        assert_eq!(&p0[pmi0..pmi0 + 4], &(-1i32).to_le_bytes());
9991        let pbi0 = initial.parent_model_bone_index_offset - initial.payload_offset;
9992        assert_eq!(&p0[pbi0..pbi0 + 4], &(-1i32).to_le_bytes());
9993        let t0 = initial.translation_offset - initial.payload_offset;
9994        assert_eq!(&p0[t0..t0 + 4], &1.0f32.to_le_bytes());
9995        let o0 = initial.orientation_offset - initial.payload_offset;
9996        assert_eq!(&p0[o0 + 8..o0 + 12], &0.3f32.to_le_bytes());
9997        let sf0 = initial.scale_factor_offset - initial.payload_offset;
9998        assert_eq!(&p0[sf0..sf0 + 4], &10.0f32.to_le_bytes());
9999        assert_eq!(
10000            p0[initial.shadow_enabled_offset - initial.payload_offset],
10001            1
10002        );
10003        assert_eq!(p0[initial.selected_offset - initial.payload_offset], 0);
10004
10005        let kf = &accessory.keyframe_summaries[0];
10006        assert_eq!(kf.index, Some(46));
10007        assert_eq!(kf.frame_index, 46);
10008        assert!(kf.visible);
10009        assert_eq!(kf.opacity, 1.0);
10010        assert_eq!(kf.translation, [1.0, 2.0, 3.0]);
10011        assert_eq!(kf.orientation, [0.1, 0.2, 0.3]);
10012        assert_eq!(kf.scale_factor, 10.0);
10013        assert!(kf.shadow_enabled);
10014        assert!(!kf.selected);
10015        assert_eq!(kf.payload_bytes.len(), 39);
10016        assert_eq!(kf.payload_bytes.len(), kf.payload_byte_length);
10017        assert_eq!(kf.payload_bytes[0], 1);
10018        assert_eq!(&kf.payload_bytes[kf.payload_bytes.len() - 2..], &[1, 0]);
10019
10020        // same layout assertions for indexed (non-initial) accessory keyframe; relative positions inside payload identical.
10021        assert_eq!(kf.packed_opacity_visible_offset, kf.payload_offset);
10022        assert_eq!(kf.parent_model_index_offset, kf.payload_offset + 1);
10023        assert_eq!(kf.parent_model_bone_index_offset, kf.payload_offset + 5);
10024        assert_eq!(kf.translation_offset, kf.payload_offset + 9);
10025        assert_eq!(kf.translation_byte_length, 12);
10026        assert_eq!(kf.orientation_offset, kf.payload_offset + 21);
10027        assert_eq!(kf.orientation_byte_length, 12);
10028        assert_eq!(kf.scale_factor_offset, kf.payload_offset + 33);
10029        assert_eq!(kf.shadow_enabled_offset, kf.payload_offset + 37);
10030        assert_eq!(kf.selected_offset, kf.payload_offset + 38);
10031        let p1 = &kf.payload_bytes;
10032        assert_eq!(
10033            p1[kf.packed_opacity_visible_offset - kf.payload_offset],
10034            1u8
10035        );
10036        let pmi1 = kf.parent_model_index_offset - kf.payload_offset;
10037        assert_eq!(&p1[pmi1..pmi1 + 4], &(-1i32).to_le_bytes());
10038        let pbi1 = kf.parent_model_bone_index_offset - kf.payload_offset;
10039        assert_eq!(&p1[pbi1..pbi1 + 4], &(-1i32).to_le_bytes());
10040        let t1 = kf.translation_offset - kf.payload_offset;
10041        assert_eq!(&p1[t1..t1 + 4], &1.0f32.to_le_bytes());
10042        let o1 = kf.orientation_offset - kf.payload_offset;
10043        assert_eq!(&p1[o1 + 8..o1 + 12], &0.3f32.to_le_bytes());
10044        let sf1 = kf.scale_factor_offset - kf.payload_offset;
10045        assert_eq!(&p1[sf1..sf1 + 4], &10.0f32.to_le_bytes());
10046        assert_eq!(p1[kf.shadow_enabled_offset - kf.payload_offset], 1);
10047        assert_eq!(p1[kf.selected_offset - kf.payload_offset], 0);
10048    }
10049
10050    #[test]
10051    fn parses_ik_multi_bone_pmm_fixture_document_summary_and_keyframes() {
10052        let data = include_bytes!("../fixtures/pmm/ik_multi_bone_from_pmx_vmd.pmm");
10053        let parsed = parse_pmm_manifest(data).unwrap();
10054
10055        let document = parsed
10056            .document_summary
10057            .as_ref()
10058            .expect("document summary must exist for this fixture");
10059        assert_eq!(document.model_count, 1);
10060        assert_eq!(document.counts.models, 1);
10061        assert_eq!(document.counts.bones, 3);
10062        assert_eq!(document.counts.morphs, 0);
10063        assert_eq!(document.counts.initial_bone_keyframes, 3);
10064        assert_eq!(document.counts.bone_keyframes, 2);
10065        assert_eq!(document.counts.initial_morph_keyframes, 0);
10066        assert_eq!(document.counts.morph_keyframes, 0);
10067        assert_eq!(document.counts.initial_model_keyframes, 1);
10068        assert_eq!(document.counts.model_keyframes, 0);
10069
10070        assert_eq!(document.models.len(), 1);
10071        let model = &document.models[0];
10072        assert_eq!(model.bone_count, 3);
10073        assert_eq!(model.morph_count, 0);
10074        assert_eq!(model.initial_bone_keyframes, 3);
10075        assert_eq!(model.bone_keyframes, 2);
10076        assert_eq!(model.initial_morph_keyframes, 0);
10077        assert_eq!(model.morph_keyframes, 0);
10078        assert_eq!(model.initial_model_keyframes, 1);
10079        assert_eq!(model.model_keyframes, 0);
10080
10081        // Parser fixture only: direct MMD GUI load can fail on this relative model path.
10082        // GUI smoke PMMs should be generated by the CLI, which embeds an absolute path.
10083        let model_path = &model.path;
10084        assert_eq!(
10085            model_path, "..\\pmx\\ik_multi_axis_limit.pmx",
10086            "model path must be the corrected resolvable relative path"
10087        );
10088
10089        // non-default additional bone keyframes are present inside the additional keyframe block.
10090        assert_eq!(model.bone_keyframe_summaries.len(), 2);
10091
10092        let link15 = model
10093            .bone_keyframe_summaries
10094            .iter()
10095            .find(|kf| kf.frame_index == 15)
10096            .expect("link_root frame 15 must be present as additional keyframe");
10097        assert_eq!(link15.previous_keyframe_index, 0);
10098        assert_eq!(link15.next_keyframe_index, 0);
10099        assert_eq!(link15.translation, [0.05, 0.0, 0.0]);
10100        assert_eq!(link15.orientation, [0.0, 0.0, 0.0, 1.0]);
10101        assert_eq!(
10102            link15.interpolation,
10103            [
10104                10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160
10105            ]
10106        );
10107        assert!(!link15.physics_disabled);
10108
10109        let eff30 = model
10110            .bone_keyframe_summaries
10111            .iter()
10112            .find(|kf| kf.frame_index == 30)
10113            .expect("effector frame 30 must be present as additional keyframe");
10114        assert_eq!(eff30.previous_keyframe_index, 1);
10115        assert_eq!(eff30.next_keyframe_index, 0);
10116        assert_eq!(eff30.translation, [0.0, 0.1, 0.0]);
10117        assert_eq!(eff30.orientation, [0.0, 0.0, 0.0, 1.0]);
10118        assert!(!eff30.physics_disabled);
10119
10120        // all asserted physics_disabled flags are false
10121        for kf in &model.initial_bone_keyframe_summaries {
10122            assert!(!kf.physics_disabled);
10123        }
10124        for kf in &model.bone_keyframe_summaries {
10125            assert!(!kf.physics_disabled);
10126        }
10127
10128        // relevant section offsets are monotonic (and model offsets)
10129        let s = &model.sections;
10130        assert!(s.initial_bone_keyframes_offset < s.bone_keyframe_count_offset);
10131        assert!(s.bone_keyframe_count_offset < s.bone_keyframes_offset);
10132        assert!(s.bone_keyframes_offset < s.bone_keyframes_end_offset);
10133        assert!(s.initial_morph_keyframes_offset <= s.morph_keyframe_count_offset);
10134        assert!(s.morph_keyframes_offset <= s.morph_keyframes_end_offset);
10135        assert!(model.offset_end > model.offset);
10136        assert!(s.bone_keyframes_end_offset > s.bone_keyframes_offset);
10137    }
10138
10139    #[test]
10140    fn pmm_ik_multi_bone_fixture_has_expected_document_summary() {
10141        let data = include_bytes!("../fixtures/pmm/ik_multi_bone_from_pmx_vmd.pmm");
10142        let parsed = parse_pmm_manifest(data).unwrap();
10143
10144        assert_eq!(
10145            parsed.byte_length, 2125,
10146            "byte_length must match the checked-in PMM fixture"
10147        );
10148
10149        let document = parsed
10150            .document_summary
10151            .as_ref()
10152            .expect("document summary must exist for this fixture");
10153        assert_eq!(document.selected_model_index, 0);
10154
10155        let c = &document.counts;
10156        assert_eq!(c.models, 1);
10157        assert_eq!(c.bones, 3);
10158        assert_eq!(c.morphs, 0);
10159        assert_eq!(c.bone_keyframes, 2);
10160        assert_eq!(c.morph_keyframes, 0);
10161
10162        assert_eq!(document.models.len(), 1);
10163        let model = &document.models[0];
10164        assert_eq!(model.document_model_index, 0);
10165        assert_eq!(model.name, "ik_multi_axis_limit_fixture");
10166        assert_eq!(model.english_name, "ik_multi_axis_limit_fixture");
10167        assert_eq!(model.path, "..\\pmx\\ik_multi_axis_limit.pmx");
10168        assert_eq!(model.path_offset, 112);
10169        assert_eq!(model.bone_count, 3);
10170        assert_eq!(model.morph_count, 0);
10171        assert_eq!(model.last_frame_index, 30);
10172
10173        let s = &model.sections;
10174        assert_eq!(s.initial_bone_keyframes_offset, 449);
10175        assert_eq!(s.bone_keyframe_count_offset, 623);
10176        assert_eq!(s.bone_keyframes_offset, 627);
10177        assert_eq!(s.bone_keyframes_end_offset, 751);
10178
10179        assert_eq!(model.initial_bone_keyframe_summaries.len(), 3);
10180        for ibk in model.initial_bone_keyframe_summaries.iter() {
10181            assert_eq!(ibk.byte_length, 58);
10182            assert_eq!(ibk.payload_offset, ibk.offset + 12);
10183            assert_eq!(ibk.payload_byte_length, 46);
10184            assert_eq!(ibk.payload_bytes.len(), ibk.payload_byte_length);
10185            assert_eq!(&ibk.payload_bytes[..16], &ibk.interpolation[..]);
10186        }
10187        assert_eq!(model.initial_bone_keyframe_summaries[0].offset, 449);
10188        assert_eq!(model.initial_bone_keyframe_summaries[1].offset, 507);
10189        assert_eq!(model.initial_bone_keyframe_summaries[2].offset, 565);
10190
10191        let imkf = &model.initial_model_keyframe;
10192        assert_eq!(imkf.offset, 755);
10193        assert_eq!(imkf.byte_length, 14);
10194
10195        let link15 = model
10196            .bone_keyframe_summaries
10197            .iter()
10198            .find(|kf| kf.frame_index == 15)
10199            .expect("link_root frame 15 must be present");
10200        assert_eq!(link15.offset, 627);
10201        assert_eq!(link15.byte_length, 62);
10202        assert_eq!(link15.payload_offset, link15.offset + 16);
10203        assert_eq!(link15.payload_byte_length, 46);
10204        assert_eq!(link15.payload_bytes.len(), link15.payload_byte_length);
10205        assert_eq!(&link15.payload_bytes[..16], &link15.interpolation[..]);
10206        assert_eq!(link15.previous_keyframe_index, 0);
10207        assert_eq!(link15.next_keyframe_index, 0);
10208        assert_eq!(link15.translation, [0.05, 0.0, 0.0]);
10209
10210        let eff30 = model
10211            .bone_keyframe_summaries
10212            .iter()
10213            .find(|kf| kf.frame_index == 30)
10214            .expect("effector frame 30 must be present");
10215        assert_eq!(eff30.offset, 689);
10216        assert_eq!(eff30.byte_length, 62);
10217        assert_eq!(eff30.payload_offset, eff30.offset + 16);
10218        assert_eq!(eff30.payload_byte_length, 46);
10219        assert_eq!(eff30.payload_bytes.len(), eff30.payload_byte_length);
10220        assert_eq!(&eff30.payload_bytes[..16], &eff30.interpolation[..]);
10221        assert_eq!(eff30.previous_keyframe_index, 1);
10222        assert_eq!(eff30.next_keyframe_index, 0);
10223        assert_eq!(eff30.translation, [0.0, 0.1, 0.0]);
10224    }
10225
10226    #[test]
10227    fn exports_pmm_scene_from_pmx_vmd_roundtrips_ik_multi_bone_nondefault() {
10228        let pmx = include_bytes!("../fixtures/pmx/ik_multi_axis_limit.pmx");
10229        let model = crate::pmx::parse_pmx_model(pmx).unwrap();
10230
10231        let vmd = include_bytes!("../fixtures/vmd/ik_multi_bone_nondefault.vmd");
10232        let motion = crate::vmd::parse_vmd_animation(vmd).unwrap();
10233
10234        let report = export_pmm_scene_from_pmx_vmd(
10235            &model,
10236            &motion,
10237            // Low-level writer preserves the supplied path; CLI GUI smoke canonicalizes it.
10238            "..\\pmx\\ik_multi_axis_limit.pmx",
10239            &PmmSceneExportOptions::default(),
10240        );
10241
10242        let reparsed = parse_pmm_manifest(&report.bytes).unwrap();
10243        let document = reparsed
10244            .document_summary
10245            .as_ref()
10246            .expect("exported PMM must contain document summary");
10247        assert_eq!(document.model_count, 1);
10248        assert_eq!(document.counts.models, 1);
10249        assert_eq!(document.counts.bones, 3);
10250        assert_eq!(document.counts.morphs, 0);
10251
10252        let m = &document.models[0];
10253        assert_eq!(m.bone_count, 3);
10254        assert_eq!(m.morph_count, 0);
10255        assert_eq!(m.path, "..\\pmx\\ik_multi_axis_limit.pmx");
10256
10257        // semantic parity for the non-default additional frames from VMD
10258        let link15 = m
10259            .bone_keyframe_summaries
10260            .iter()
10261            .find(|kf| kf.frame_index == 15)
10262            .expect("exported PMM must contain link_root frame 15");
10263        assert_eq!(link15.translation, [0.05, 0.0, 0.0]);
10264        assert_eq!(link15.orientation, [0.0, 0.0, 0.0, 1.0]);
10265        assert_eq!(
10266            link15.interpolation,
10267            [
10268                10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160
10269            ]
10270        );
10271        assert!(!link15.physics_disabled);
10272
10273        let eff30 = m
10274            .bone_keyframe_summaries
10275            .iter()
10276            .find(|kf| kf.frame_index == 30)
10277            .expect("exported PMM must contain effector frame 30");
10278        assert_eq!(eff30.translation, [0.0, 0.1, 0.0]);
10279        assert_eq!(eff30.orientation, [0.0, 0.0, 0.0, 1.0]);
10280        assert!(!eff30.physics_disabled);
10281
10282        // exporter always emits per-bone initials (fallback identity when absent in VMD) + additionals
10283        assert_eq!(m.initial_bone_keyframes, 3);
10284        assert_eq!(m.bone_keyframes, 2);
10285
10286        assert_eq!(m.constraint_bone_count, 1);
10287        assert_eq!(m.outside_parent_subject_bone_count, 2);
10288        assert_eq!(m.initial_model_keyframe.constraint_states, vec![true]);
10289        for outside_parent in &m.initial_model_keyframe.outside_parent_indices {
10290            assert_eq!(outside_parent.parent_model_index, -1);
10291            assert_eq!(outside_parent.parent_model_bone_index, 0);
10292        }
10293        assert_eq!(m.constraint_state_summaries.len(), 1);
10294        assert!(m.constraint_state_summaries[0].enabled);
10295        assert_eq!(m.outside_parent_state_summaries.len(), 2);
10296        assert_eq!(m.outside_parent_state_summaries[0].parent_model_index, 0);
10297        assert_eq!(
10298            m.outside_parent_state_summaries[0].parent_model_bone_index,
10299            0
10300        );
10301        assert_eq!(m.outside_parent_state_summaries[0].subject_bone_index, -1);
10302        assert_eq!(m.outside_parent_state_summaries[0].target_model_index, 0);
10303
10304        let fixed_tracks_offset = m.path_offset + 256;
10305        assert_eq!(reparsed.source_bytes[fixed_tracks_offset], 2);
10306    }
10307
10308    #[test]
10309    fn pmm_export_model_structure_matches_pmx_bone_flags_and_display_frames() {
10310        let pmx = include_bytes!("../fixtures/pmx/ik_multi_axis_limit.pmx");
10311        let model = crate::pmx::parse_pmx_model(pmx).unwrap();
10312        let structure = pmm_export_model_structure_from_pmx(&model);
10313
10314        assert_eq!(structure.fixed_tracks, 2);
10315        assert_eq!(structure.constraint_bone_indices, vec![2]);
10316        assert_eq!(structure.outside_parent_subject_bone_indices, vec![-1, 2]);
10317        assert!(pmm_is_outside_parent_subject_bone(&model.skeleton.bones[2]));
10318        assert!(!pmm_is_outside_parent_subject_bone(
10319            &model.skeleton.bones[0]
10320        ));
10321        assert!(!pmm_is_outside_parent_subject_bone(
10322            &model.skeleton.bones[1]
10323        ));
10324    }
10325}