//! Text extraction from parsed Unity serialized objects. use crate::serialized::{ managed_reference_metadata_from_fields, UnityManagedReferenceMetadata, UnityManagedReferenceRecord, UnitySerializedField, UnitySerializedValue, }; use crate::types::ParsedAssetBundle; use std::collections::BTreeMap; /// One text unit used by translation, glossary and patch pipelines. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct TextUnit { /// Original source text. pub source_text: String, /// Logical AssetBundle path, when provided by the caller. pub bundle_path: Option, /// ZIP/archive entry containing the bundle, when known. pub archive_entry: Option, /// Unity serialized file path. pub serialized_file: Option, /// Unity object path ID. #[serde(default, skip_serializing_if = "Option::is_none")] pub path_id: Option, /// Unity class ID, for example `49` for `TextAsset`. #[serde(default, skip_serializing_if = "Option::is_none")] pub class_id: Option, /// TypeTree field path. `TextAsset` is used for a whole TextAsset payload. #[serde(default, skip_serializing_if = "Option::is_none")] pub field_path: Option, /// Byte offset relative to the beginning of the Unity object payload. #[serde(default, skip_serializing_if = "Option::is_none")] pub field_offset: Option, /// Number of bytes consumed by this field, including alignment padding. #[serde(default, skip_serializing_if = "Option::is_none")] pub field_byte_size: Option, /// Unity version associated with the source. pub version: String, /// Stable context for format, asset name and extraction details. pub context: BTreeMap, } /// Non-fatal diagnostic generated while extracting text units. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct TextUnitExtractionError { /// Serialized file containing the failed object. pub serialized_file: Option, /// Object path ID, when known. #[serde(default, skip_serializing_if = "Option::is_none")] pub path_id: Option, /// Unity class ID, when known. #[serde(default, skip_serializing_if = "Option::is_none")] pub class_id: Option, /// TypeTree field path, when known. #[serde(default, skip_serializing_if = "Option::is_none")] pub field_path: Option, /// Byte offset relative to the beginning of the Unity object payload. #[serde(default, skip_serializing_if = "Option::is_none")] pub offset: Option, /// Human-readable error. pub error: String, } /// Result of extracting text units from one parsed bundle. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct TextUnitExtractionReport { /// Extracted text units in deterministic traversal order. pub units: Vec, /// Non-fatal object-level errors. pub errors: Vec, /// TextAsset payloads that were binary or invalid UTF-8. pub skipped_binary_text_assets: usize, } /// Extracts translation-ready text from Unity bundle data. #[derive(Debug, Default, Clone, Copy)] pub struct TextUnitExtractor; impl TextUnitExtractor { /// Creates an extractor. pub fn new() -> Self { Self } /// Extracts TextAsset and TypeTree string fields from a parsed bundle. pub fn extract_bundle( &self, bundle: &ParsedAssetBundle, bundle_path: Option<&str>, ) -> TextUnitExtractionReport { self.extract_bundle_with_context(bundle, bundle_path, None) } /// Extracts text with both logical bundle and archive-entry context. pub fn extract_bundle_with_context( &self, bundle: &ParsedAssetBundle, bundle_path: Option<&str>, archive_entry: Option<&str>, ) -> TextUnitExtractionReport { let mut report = TextUnitExtractionReport { units: Vec::new(), errors: Vec::new(), skipped_binary_text_assets: 0, }; for asset in &bundle.text_assets { if let Some((format, text)) = decode_text_payload(&asset.bytes) { let mut context = BTreeMap::new(); context.insert("format".to_string(), format.to_string()); context.insert("asset_name".to_string(), asset.name.clone()); context.insert("source_kind".to_string(), "TextAsset".to_string()); report.units.push(TextUnit { source_text: text, bundle_path: bundle_path.map(ToOwned::to_owned), archive_entry: archive_entry.map(ToOwned::to_owned), serialized_file: asset.source_path.clone(), path_id: Some(asset.path_id), class_id: Some(49), field_path: Some("TextAsset".to_string()), field_offset: None, field_byte_size: None, version: bundle.unity_version.clone(), context, }); } else { report.skipped_binary_text_assets += 1; } } for serialized_file in &bundle.serialized_files { for object in &serialized_file.objects { if object.class_id == 49 || !serialized_file.object_has_type_tree(object) { continue; } let fields = match serialized_file.fields_for_object_entry(object) { Ok(fields) => fields, Err(error) => { report.errors.push(TextUnitExtractionError { serialized_file: serialized_file.source_path.clone(), path_id: Some(object.path_id), class_id: Some(object.class_id), field_path: None, offset: None, error: error.to_string(), }); continue; } }; let context = FieldTextContext { serialized_file_path: serialized_file.source_path.as_deref(), path_id: object.path_id, class_id: object.class_id, version: &bundle.unity_version, bundle_path, archive_entry, managed_reference: None, }; for field in fields { collect_field_text(&mut report.units, &context, &field); } } } report } } /// Serializes text units as one stable JSON object per line. pub fn text_units_to_jsonl(units: &[TextUnit]) -> Result { let mut output = String::new(); for unit in units { output.push_str(&serde_json::to_string(unit)?); output.push('\n'); } Ok(output) } #[derive(Clone)] struct FieldTextContext<'a> { serialized_file_path: Option<&'a str>, path_id: i64, class_id: i32, version: &'a str, bundle_path: Option<&'a str>, archive_entry: Option<&'a str>, managed_reference: Option, } impl<'a> FieldTextContext<'a> { fn with_managed_reference(&self, metadata: Option<&UnityManagedReferenceMetadata>) -> Self { Self { serialized_file_path: self.serialized_file_path, path_id: self.path_id, class_id: self.class_id, version: self.version, bundle_path: self.bundle_path, archive_entry: self.archive_entry, managed_reference: metadata.cloned().or_else(|| self.managed_reference.clone()), } } } fn collect_field_text<'a>( units: &mut Vec, context: &FieldTextContext<'a>, field: &'a UnitySerializedField, ) { match &field.value { UnitySerializedValue::String(text) if !text.is_empty() => { let mut unit_context = BTreeMap::new(); unit_context.insert("format".to_string(), "plain".to_string()); unit_context.insert( "source_kind".to_string(), if context.managed_reference.is_some() { "ManagedReferenceField" } else { "TypeTreeField" } .to_string(), ); unit_context.insert("type_name".to_string(), field.type_name.clone()); if let Some(metadata) = &context.managed_reference { insert_managed_reference_context(&mut unit_context, metadata); } units.push(TextUnit { source_text: text.clone(), bundle_path: context.bundle_path.map(ToOwned::to_owned), archive_entry: context.archive_entry.map(ToOwned::to_owned), serialized_file: context.serialized_file_path.map(ToOwned::to_owned), path_id: Some(context.path_id), class_id: Some(context.class_id), field_path: Some(field.path.clone()), field_offset: Some(field.offset), field_byte_size: Some(field.byte_size), version: context.version.to_string(), context: unit_context, }); } UnitySerializedValue::Object(fields) => { for field in fields { collect_field_text(units, context, field); } } UnitySerializedValue::ManagedReference { metadata, fields, .. } => { let managed_context = context.with_managed_reference(metadata.as_ref()); for field in fields { collect_managed_reference_child_text(units, &managed_context, field); } } UnitySerializedValue::ManagedReferenceRegistry { references, fields } => { if references.is_empty() { let fallback_metadata = managed_reference_metadata_from_fields(fields); for field in fields { let sibling_metadata = managed_reference_metadata_from_sibling_fields(fields, field); let managed_context = context.with_managed_reference( sibling_metadata.as_ref().or(fallback_metadata.as_ref()), ); collect_managed_reference_child_text(units, &managed_context, field); } } else { for reference in references { collect_managed_reference_record_text(units, context, reference); } } } UnitySerializedValue::Array(values) | UnitySerializedValue::Map(values) => { for field in values { collect_field_text(units, context, field); } } _ => {} } } fn collect_managed_reference_record_text<'a>( units: &mut Vec, context: &FieldTextContext<'a>, reference: &'a UnityManagedReferenceRecord, ) { let managed_context = context.with_managed_reference(Some(&reference.metadata)); for field in &reference.fields { collect_field_text(units, &managed_context, field); } } fn collect_managed_reference_child_text<'a>( units: &mut Vec, context: &FieldTextContext<'a>, field: &'a UnitySerializedField, ) { let key = normalized_text_metadata_key(&field.name); if is_managed_reference_metadata_text_key(&key) { return; } if is_managed_reference_payload_text_key(&key) { if let Some(children) = serialized_field_children(field) { for child in children { collect_field_text(units, context, child); } } else { collect_field_text(units, context, field); } return; } if let Some(children) = serialized_field_children(field) { if let Some(metadata) = managed_reference_metadata_from_fields(children) { let managed_context = context.with_managed_reference(Some(&metadata)); for child in children { collect_managed_reference_child_text(units, &managed_context, child); } return; } } collect_field_text(units, context, field); } fn managed_reference_metadata_from_sibling_fields( fields: &[UnitySerializedField], field: &UnitySerializedField, ) -> Option { let record_prefix = managed_reference_record_path_prefix(&field.path)?; let grouped_fields = fields .iter() .filter(|candidate| { candidate.path == record_prefix || candidate .path .strip_prefix(record_prefix) .is_some_and(|suffix| suffix.starts_with('.')) }) .cloned() .collect::>(); managed_reference_metadata_from_fields(&grouped_fields) } fn managed_reference_record_path_prefix(path: &str) -> Option<&str> { if let Some(index_end) = path.rfind(']') { return Some(&path[..=index_end]); } path.rsplit_once('.') .map(|(parent, _)| parent) .filter(|parent| !parent.is_empty()) } fn insert_managed_reference_context( context: &mut BTreeMap, metadata: &UnityManagedReferenceMetadata, ) { if let Some(reference_id) = metadata.reference_id { context.insert("managed_reference_id".to_string(), reference_id.to_string()); } if let Some(value) = &metadata.full_type_name { context.insert( "managed_reference_full_type_name".to_string(), value.clone(), ); } if let Some(value) = &metadata.type_name { context.insert("managed_reference_type".to_string(), value.clone()); } if let Some(value) = &metadata.namespace { context.insert("managed_reference_namespace".to_string(), value.clone()); } if let Some(value) = &metadata.assembly_name { context.insert("managed_reference_assembly".to_string(), value.clone()); } } fn serialized_field_children(field: &UnitySerializedField) -> Option<&[UnitySerializedField]> { match &field.value { UnitySerializedValue::Object(fields) | UnitySerializedValue::Array(fields) | UnitySerializedValue::Map(fields) | UnitySerializedValue::ManagedReference { fields, .. } | UnitySerializedValue::ManagedReferenceRegistry { fields, .. } => Some(fields), _ => None, } } fn normalized_text_metadata_key(name: &str) -> String { name.strip_prefix("m_") .unwrap_or(name) .chars() .filter(|character| character.is_ascii_alphanumeric()) .flat_map(char::to_lowercase) .collect() } fn is_managed_reference_metadata_text_key(key: &str) -> bool { matches!( key, "rid" | "id" | "identifier" | "refid" | "referenceid" | "managedreferenceid" | "managedreferenceids" | "managedreferencesid" | "managedreferencesids" | "serializedreferenceid" | "serializedreferenceids" | "refids" | "type" | "typeid" | "typeinfo" | "typename" | "fullname" | "fulltypename" | "class" | "classname" | "managedreferenceclassname" | "serializedreferenceclassname" | "klass" | "managedtype" | "managedreferencetype" | "managedreferencefullname" | "managedreferencefulltypename" | "serializedreferencetype" | "serializedreferencefullname" | "serializedreferencefulltypename" | "assemblyqualifiedname" | "ns" | "namespace" | "namespacename" | "managedreferencenamespace" | "managedreferencenamespacename" | "serializedreferencenamespace" | "serializedreferencenamespacename" | "asm" | "asmname" | "assembly" | "assemblyname" | "managedreferenceassembly" | "managedreferenceassemblyname" | "serializedreferenceassembly" | "serializedreferenceassemblyname" ) } fn is_managed_reference_payload_text_key(key: &str) -> bool { matches!( key, "data" | "payload" | "value" | "object" | "instance" | "managedreferencepayload" | "referencepayload" | "serializedreferencepayload" | "managedreferencevalue" | "referencevalue" | "serializedreferencevalue" | "managedreferenceobject" | "referenceobject" | "serializedreferenceobject" | "managedreferencedata" | "referencedata" | "serializeddata" | "serializedreferencedata" ) } fn decode_text_payload(bytes: &[u8]) -> Option<(&'static str, String)> { let bytes = bytes.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(bytes); if bytes.contains(&0) { return None; } let text = std::str::from_utf8(bytes).ok()?.to_string(); if text.trim().is_empty() || text .chars() .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t')) { return None; } let trimmed = text.trim(); if serde_json::from_str::(trimmed).is_ok() { return Some(("json", text)); } if text.lines().any(|line| line.contains('\t')) { return Some(("tsv", text)); } if text.lines().count() > 1 && text.lines().any(|line| line.contains(',')) { return Some(("csv", text)); } Some(("plain", text)) } #[cfg(test)] mod tests { use super::*; use crate::serialized::UnitySerializedTextAsset; use crate::types::{ ParsedAssetBundle, UnityFsBlockInfo, UnityFsDirectoryInfo, UnityFsHeader, UnitySerializedParseError, }; #[test] fn extracts_textasset_and_writes_jsonl() { let bundle = ParsedAssetBundle { unity_version: "2021.3.56f2".to_string(), assets: vec!["CAB-test".to_string()], raw_data: Vec::new(), unityfs_header: Some(UnityFsHeader { format_version: 8, target_version: "5.x.x".to_string(), unity_version: "2021.3.56f2".to_string(), total_size: 0, compressed_blocks_info_size: 0, uncompressed_blocks_info_size: 0, flags: 0, }), blocks: vec![UnityFsBlockInfo { uncompressed_size: 0, compressed_size: 0, flags: 0, compression: crate::types::UnityFsCompression::None, }], directories: vec![UnityFsDirectoryInfo { offset: 0, size: 0, flags: 0, path: "CAB-test".to_string(), }], files: Vec::new(), serialized_files: Vec::new(), text_assets: vec![UnitySerializedTextAsset { source_path: Some("CAB-test".to_string()), path_id: 1, name: "dialogue.json".to_string(), bytes: br#"{"text":"hello"}"#.to_vec(), }], serialized_parse_errors: Vec::::new(), }; let report = TextUnitExtractor::new().extract_bundle_with_context( &bundle, Some("dialogue.bundle"), Some("assets/dialogue.bundle"), ); assert_eq!(report.units.len(), 1); assert_eq!( report.units[0].archive_entry.as_deref(), Some("assets/dialogue.bundle") ); assert_eq!( report.units[0].context.get("format"), Some(&"json".to_string()) ); assert_eq!( text_units_to_jsonl(&report.units).unwrap(), format!("{}\n", serde_json::to_string(&report.units[0]).unwrap()) ); } #[test] fn extracts_managed_reference_payload_without_metadata_strings() { let payload_field = string_field( "m_ManagedReferences.references[0].data.message", "message", "こんにちは", ); let metadata_field = string_field( "m_ManagedReferences.references[0].managedReferenceFullTypeName", "managedReferenceFullTypeName", "Game BA.Text.ScenarioLine", ); let registry_field = UnitySerializedField { path: "m_ManagedReferences".to_string(), name: "m_ManagedReferences".to_string(), type_name: "ManagedReferenceRegistry".to_string(), offset: 0, byte_size: 64, type_tree_node_index: None, value: UnitySerializedValue::ManagedReferenceRegistry { references: vec![UnityManagedReferenceRecord { metadata: UnityManagedReferenceMetadata { reference_id: Some(42), full_type_name: Some("Game BA.Text.ScenarioLine".to_string()), type_name: Some("ScenarioLine".to_string()), namespace: Some("BA.Text".to_string()), assembly_name: Some("Game".to_string()), }, fields: vec![payload_field.clone()], }], fields: vec![metadata_field], }, }; let context = FieldTextContext { serialized_file_path: Some("CAB-test"), path_id: 7, class_id: 114, version: "2021.3.56f2", bundle_path: Some("scenario.bundle"), archive_entry: None, managed_reference: None, }; let mut units = Vec::new(); collect_field_text(&mut units, &context, ®istry_field); assert_eq!(units.len(), 1); assert_eq!(units[0].source_text, "こんにちは"); assert_eq!( units[0].field_path.as_deref(), Some("m_ManagedReferences.references[0].data.message") ); assert_eq!( units[0].context.get("source_kind"), Some(&"ManagedReferenceField".to_string()) ); assert_eq!( units[0].context.get("managed_reference_id"), Some(&"42".to_string()) ); assert_eq!( units[0].context.get("managed_reference_full_type_name"), Some(&"Game BA.Text.ScenarioLine".to_string()) ); assert_eq!( units[0].context.get("managed_reference_type"), Some(&"ScenarioLine".to_string()) ); assert_eq!( units[0].context.get("managed_reference_namespace"), Some(&"BA.Text".to_string()) ); assert_eq!( units[0].context.get("managed_reference_assembly"), Some(&"Game".to_string()) ); } #[test] fn extracts_fallback_managed_reference_payload_alias_without_metadata_strings() { let payload_field = string_field( "m_ManagedReferences.RefIds[0].managedReferenceData.message", "message", "こんにちは", ); let registry_field = UnitySerializedField { path: "m_ManagedReferences".to_string(), name: "m_ManagedReferences".to_string(), type_name: "ManagedReferencesRegistry".to_string(), offset: 0, byte_size: 96, type_tree_node_index: None, value: UnitySerializedValue::ManagedReferenceRegistry { references: Vec::new(), fields: vec![ string_field( "m_ManagedReferences.RefIds[0].managedReferenceClassName", "managedReferenceClassName", "ScenarioLine", ), string_field( "m_ManagedReferences.RefIds[0].managedReferenceNamespaceName", "managedReferenceNamespaceName", "BA.Text", ), string_field( "m_ManagedReferences.RefIds[0].managedReferenceAssemblyName", "managedReferenceAssemblyName", "Game", ), string_field( "m_ManagedReferences.RefIds[0].serializedReferenceFullTypeName", "serializedReferenceFullTypeName", "Game BA.Text.ScenarioLine", ), string_field( "m_ManagedReferences.RefIds[0].typeInfo", "typeInfo", "Game BA.Text.ScenarioLine", ), string_field( "m_ManagedReferences.RefIds[0].typeID.className", "className", "ScenarioLine", ), string_field( "m_ManagedReferences.RefIds[0].typeID.namespaceName", "namespaceName", "BA.Text", ), string_field( "m_ManagedReferences.RefIds[0].typeID.asmName", "asmName", "Game", ), UnitySerializedField { path: "m_ManagedReferences.RefIds[0].managedReferenceData".to_string(), name: "managedReferenceData".to_string(), type_name: "managedReference".to_string(), offset: 64, byte_size: 24, type_tree_node_index: None, value: UnitySerializedValue::Object(vec![payload_field]), }, ], }, }; let context = FieldTextContext { serialized_file_path: Some("CAB-test"), path_id: 7, class_id: 114, version: "2021.3.56f2", bundle_path: Some("scenario.bundle"), archive_entry: None, managed_reference: None, }; let mut units = Vec::new(); collect_field_text(&mut units, &context, ®istry_field); assert_eq!(units.len(), 1); assert_eq!(units[0].source_text, "こんにちは"); assert_eq!( units[0].field_path.as_deref(), Some("m_ManagedReferences.RefIds[0].managedReferenceData.message") ); assert_eq!( units[0].context.get("source_kind"), Some(&"ManagedReferenceField".to_string()) ); assert_eq!( units[0].context.get("managed_reference_type"), Some(&"ScenarioLine".to_string()) ); assert_eq!( units[0].context.get("managed_reference_namespace"), Some(&"BA.Text".to_string()) ); assert_eq!( units[0].context.get("managed_reference_assembly"), Some(&"Game".to_string()) ); assert_eq!( units[0].context.get("managed_reference_full_type_name"), Some(&"Game BA.Text.ScenarioLine".to_string()) ); } #[test] fn extracts_fallback_managed_reference_payload_family_alias_with_context() { let payload_field = string_field( "m_ManagedReferences.RefIds[0].serializedReferencePayload.message", "message", "こんにちは", ); let registry_field = UnitySerializedField { path: "m_ManagedReferences".to_string(), name: "m_ManagedReferences".to_string(), type_name: "ManagedReferencesRegistry".to_string(), offset: 0, byte_size: 96, type_tree_node_index: None, value: UnitySerializedValue::ManagedReferenceRegistry { references: Vec::new(), fields: vec![ string_field( "m_ManagedReferences.RefIds[0].managedReferenceClassName", "managedReferenceClassName", "ScenarioLine", ), string_field( "m_ManagedReferences.RefIds[0].managedReferenceNamespaceName", "managedReferenceNamespaceName", "BA.Text", ), string_field( "m_ManagedReferences.RefIds[0].managedReferenceAssemblyName", "managedReferenceAssemblyName", "Game", ), UnitySerializedField { path: "m_ManagedReferences.RefIds[0].serializedReferencePayload" .to_string(), name: "serializedReferencePayload".to_string(), type_name: "managedReference".to_string(), offset: 64, byte_size: 24, type_tree_node_index: None, value: UnitySerializedValue::Object(vec![payload_field]), }, ], }, }; let context = FieldTextContext { serialized_file_path: Some("CAB-test"), path_id: 7, class_id: 114, version: "2021.3.56f2", bundle_path: Some("scenario.bundle"), archive_entry: None, managed_reference: None, }; let mut units = Vec::new(); collect_field_text(&mut units, &context, ®istry_field); assert_eq!(units.len(), 1); assert_eq!(units[0].source_text, "こんにちは"); assert_eq!( units[0].field_path.as_deref(), Some("m_ManagedReferences.RefIds[0].serializedReferencePayload.message") ); assert_eq!( units[0].context.get("source_kind"), Some(&"ManagedReferenceField".to_string()) ); assert_eq!( units[0].context.get("managed_reference_type"), Some(&"ScenarioLine".to_string()) ); assert_eq!( units[0].context.get("managed_reference_namespace"), Some(&"BA.Text".to_string()) ); assert_eq!( units[0].context.get("managed_reference_assembly"), Some(&"Game".to_string()) ); } #[test] fn extracts_fallback_managed_reference_sibling_records_with_separate_context() { let registry_field = UnitySerializedField { path: "m_ManagedReferences".to_string(), name: "m_ManagedReferences".to_string(), type_name: "ManagedReferencesRegistry".to_string(), offset: 0, byte_size: 160, type_tree_node_index: None, value: UnitySerializedValue::ManagedReferenceRegistry { references: Vec::new(), fields: vec![ string_field( "m_ManagedReferences.RefIds[0].managedReferenceClassName", "managedReferenceClassName", "ScenarioLine", ), string_field( "m_ManagedReferences.RefIds[0].managedReferenceNamespaceName", "managedReferenceNamespaceName", "BA.Text", ), string_field( "m_ManagedReferences.RefIds[0].managedReferenceAssemblyName", "managedReferenceAssemblyName", "Game", ), UnitySerializedField { path: "m_ManagedReferences.RefIds[0].managedReferenceData".to_string(), name: "managedReferenceData".to_string(), type_name: "managedReference".to_string(), offset: 64, byte_size: 24, type_tree_node_index: None, value: UnitySerializedValue::Object(vec![string_field( "m_ManagedReferences.RefIds[0].managedReferenceData.message", "message", "こんにちは", )]), }, string_field( "m_ManagedReferences.RefIds[1].managedReferenceClassName", "managedReferenceClassName", "ChoiceLine", ), string_field( "m_ManagedReferences.RefIds[1].managedReferenceNamespaceName", "managedReferenceNamespaceName", "BA.Text", ), string_field( "m_ManagedReferences.RefIds[1].managedReferenceAssemblyName", "managedReferenceAssemblyName", "Game", ), UnitySerializedField { path: "m_ManagedReferences.RefIds[1].referencePayload".to_string(), name: "referencePayload".to_string(), type_name: "managedReference".to_string(), offset: 120, byte_size: 24, type_tree_node_index: None, value: UnitySerializedValue::Object(vec![string_field( "m_ManagedReferences.RefIds[1].referencePayload.message", "message", "選択肢", )]), }, ], }, }; let context = FieldTextContext { serialized_file_path: Some("CAB-test"), path_id: 7, class_id: 114, version: "2021.3.56f2", bundle_path: Some("scenario.bundle"), archive_entry: None, managed_reference: None, }; let mut units = Vec::new(); collect_field_text(&mut units, &context, ®istry_field); assert_eq!(units.len(), 2); assert_eq!( units[0].field_path.as_deref(), Some("m_ManagedReferences.RefIds[0].managedReferenceData.message") ); assert_eq!(units[0].source_text, "こんにちは"); assert_eq!( units[0].context.get("managed_reference_type"), Some(&"ScenarioLine".to_string()) ); assert_eq!( units[1].field_path.as_deref(), Some("m_ManagedReferences.RefIds[1].referencePayload.message") ); assert_eq!(units[1].source_text, "選択肢"); assert_eq!( units[1].context.get("managed_reference_type"), Some(&"ChoiceLine".to_string()) ); } fn string_field(path: &str, name: &str, value: &str) -> UnitySerializedField { UnitySerializedField { path: path.to_string(), name: name.to_string(), type_name: "string".to_string(), offset: 0, byte_size: value.len() + 4, type_tree_node_index: None, value: UnitySerializedValue::String(value.to_string()), } } }