From d4b246b29ba7002ca5686ffe677d6494ddba842f Mon Sep 17 00:00:00 2001
From: KrrKs <117111846+KrrKsThunder@users.noreply.github.com>
Date: Mon, 31 Oct 2022 20:04:05 +0100
Subject: [PATCH 01/21] Commit full version 1.1.0.5 of the BW Localization
Plugin
# Conflicts:
# Plugins/BiowareLocalizationPlugin/BiowareLocalizedStringDatabase.cs
# Plugins/BiowareLocalizationPlugin/LocalizedStringResource.cs
---
.../BW LocaliziationResourceBits.txt | 68 +
...oWareLocalizedStringEditorMenuExtension.cs | 28 +
.../BiowareLocalizationCustomActionHandler.cs | 181 +++
.../BiowareLocalizationPlugin.csproj | 4 +
...wareLocalizationPluginModManagerOptions.cs | 30 +
.../BiowareLocalizedStringDatabase.cs | 362 +++++-
.../Controls/AddEditWindow.xaml | 114 ++
.../Controls/AddEditWindow.xaml.cs | 213 ++++
.../Controls/BiowareLocalizedStringEditor.cs | 452 +++++++
.../Controls/ImportTargetDialog.xaml | 109 ++
.../Controls/ImportTargetDialog.xaml.cs | 276 ++++
.../Controls/ListBoxUtils.cs | 56 +
.../Controls/ResourceSelectionWindow.xaml | 30 +
.../Controls/ResourceSelectionWindow.xaml.cs | 50 +
.../Controls/SearchFindWindow.xaml | 63 +
.../Controls/SearchFindWindow.xaml.cs | 83 ++
.../Controls/TextInfoWindow.xaml | 72 ++
.../Controls/TextInfoWindow.xaml.cs | 85 ++
.../ExportImport/TextRepresentation.cs | 28 +
.../ExportImport/XmlExporter.cs | 82 ++
.../ExportImport/XmlImporter.cs | 150 +++
.../LocalizedResources/LanguageTextsDB.cs | 416 ++++++
.../LocalizedStringResource.cs | 1117 +++++++++++++++++
.../ResourceComponentsHelper.cs | 377 ++++++
.../LocalizedResources/ResourceTestUtils.cs | 279 ++++
.../LocalizedResources/ResourceUtils.cs | 636 ++++++++++
.../LocalizedStringResource.cs | 220 ----
.../Properties/AssemblyInfo.cs | 14 +-
.../Themes/Generic.xaml | 90 +-
29 files changed, 5389 insertions(+), 296 deletions(-)
create mode 100644 Plugins/BiowareLocalizationPlugin/BW LocaliziationResourceBits.txt
create mode 100644 Plugins/BiowareLocalizationPlugin/BioWareLocalizedStringEditorMenuExtension.cs
create mode 100644 Plugins/BiowareLocalizationPlugin/BiowareLocalizationCustomActionHandler.cs
create mode 100644 Plugins/BiowareLocalizationPlugin/BiowareLocalizationPluginModManagerOptions.cs
create mode 100644 Plugins/BiowareLocalizationPlugin/Controls/AddEditWindow.xaml
create mode 100644 Plugins/BiowareLocalizationPlugin/Controls/AddEditWindow.xaml.cs
create mode 100644 Plugins/BiowareLocalizationPlugin/Controls/BiowareLocalizedStringEditor.cs
create mode 100644 Plugins/BiowareLocalizationPlugin/Controls/ImportTargetDialog.xaml
create mode 100644 Plugins/BiowareLocalizationPlugin/Controls/ImportTargetDialog.xaml.cs
create mode 100644 Plugins/BiowareLocalizationPlugin/Controls/ListBoxUtils.cs
create mode 100644 Plugins/BiowareLocalizationPlugin/Controls/ResourceSelectionWindow.xaml
create mode 100644 Plugins/BiowareLocalizationPlugin/Controls/ResourceSelectionWindow.xaml.cs
create mode 100644 Plugins/BiowareLocalizationPlugin/Controls/SearchFindWindow.xaml
create mode 100644 Plugins/BiowareLocalizationPlugin/Controls/SearchFindWindow.xaml.cs
create mode 100644 Plugins/BiowareLocalizationPlugin/Controls/TextInfoWindow.xaml
create mode 100644 Plugins/BiowareLocalizationPlugin/Controls/TextInfoWindow.xaml.cs
create mode 100644 Plugins/BiowareLocalizationPlugin/ExportImport/TextRepresentation.cs
create mode 100644 Plugins/BiowareLocalizationPlugin/ExportImport/XmlExporter.cs
create mode 100644 Plugins/BiowareLocalizationPlugin/ExportImport/XmlImporter.cs
create mode 100644 Plugins/BiowareLocalizationPlugin/LocalizedResources/LanguageTextsDB.cs
create mode 100644 Plugins/BiowareLocalizationPlugin/LocalizedResources/LocalizedStringResource.cs
create mode 100644 Plugins/BiowareLocalizationPlugin/LocalizedResources/ResourceComponentsHelper.cs
create mode 100644 Plugins/BiowareLocalizationPlugin/LocalizedResources/ResourceTestUtils.cs
create mode 100644 Plugins/BiowareLocalizationPlugin/LocalizedResources/ResourceUtils.cs
delete mode 100644 Plugins/BiowareLocalizationPlugin/LocalizedStringResource.cs
diff --git a/Plugins/BiowareLocalizationPlugin/BW LocaliziationResourceBits.txt b/Plugins/BiowareLocalizationPlugin/BW LocaliziationResourceBits.txt
new file mode 100644
index 000000000..628fd5db7
--- /dev/null
+++ b/Plugins/BiowareLocalizationPlugin/BW LocaliziationResourceBits.txt
@@ -0,0 +1,68 @@
+Part: Type and what Byte position after this
+
+MetaData (16 bytes, located in Frosty at Resource.resMeta, not part of the byte count )
+ {
+ uint dataOffset
+ 3x byte value 0x0
+ }
+Header
+ {
+ uint magic = 0xd78b40eb (pos 4)
+ uint ??? (pos 8) // doesn't seem to affect anything
+ uint dataOffset (pos 12) // not actually used, instead the one from the metadata is the base for the game
+ 3x uint ??? (pos 24) // doens't seem to affect anything
+
+ uint nodeCount (pos 28)
+ // nodeCount is an even integer! The rootNode as would-be last node in the node list is *not* actually part of the list!
+
+ uint nodeOffset (pos 32)
+
+ uint stringsCount (pos 36)
+ uint stringsOffset (pos 40)
+
+ // Until the nodeOffset is reached
+ // If there are three or more entries in here,
+ // then the corresponding 8ByteBlockData after the second one contain ids and bit offsets for the declinated adjectives for text parts used for crafted items in DA:I
+ N times Unknown8ByteBlockCountAndOffset
+ {
+ uint unknownCounts
+ uint unknownOffset
+ }
+ }
+
+ // Position = nodeOffset -> pos most likely either 56 or 64
+HuffmanCoding
+ {
+ nodeCount x uint value == bitFlip char
+ }
+ (size = 4 per node)
+
+ // Position = stringsOffset
+StringData
+ {
+ stringsCount x
+ {
+ uint stringId
+ int stringIndex / positionOffset
+ }
+ (size = 8 per string)
+ }
+
+ // Position = Unknown8ByteBlockCountAndOffset[0].unknownOffset
+ // The next data blocks appears the same N times as their Unknown8ByteBlockCountAndOffset counterpart in the header
+ // Everything past the second of these blocks contains the bit offsets of the text pieces used for crafted item names in DA:I
+N times 8ByteBlockData
+ {
+ // Position = Unknown8ByteBlockCountAndOffset[index].unknownOffset
+ byte[].Length = Unknown8ByteBlockCountAndOffset[index].unknownCounts * 8
+ }
+
+ // Position = dataOffset
+Strings
+ {
+ stringsCount * HuffmanEncodedChars
+ }
+ // stringIndex / positionOffset = bit offset from dataOffset == textBefore bitOffset + textBefore bitlentgh
+ // last symbol (only symbol of empty string) is huffman node with letter 0x00! I.e., _value_ = 0xFF
+
+Remaining positions to full byte filled with 0s
\ No newline at end of file
diff --git a/Plugins/BiowareLocalizationPlugin/BioWareLocalizedStringEditorMenuExtension.cs b/Plugins/BiowareLocalizationPlugin/BioWareLocalizedStringEditorMenuExtension.cs
new file mode 100644
index 000000000..6be0a7b4b
--- /dev/null
+++ b/Plugins/BiowareLocalizationPlugin/BioWareLocalizedStringEditorMenuExtension.cs
@@ -0,0 +1,28 @@
+using BiowareLocalizationPlugin.Controls;
+using Frosty.Core;
+using FrostySdk;
+
+namespace BiowareLocalizationPlugin
+{
+ public class BioWareLocalizedStringEditorMenuExtension : MenuExtension
+ {
+
+ private const string ITEM_NAME = "Bioware Localized String Editor";
+
+ public override string TopLevelMenuName => "View";
+
+ public override string SubLevelMenuName => null;
+ public override string MenuItemName => ITEM_NAME;
+
+ public override RelayCommand MenuItemClicked => new RelayCommand((o) =>
+ {
+ if (ProfilesLibrary.DataVersion == (int)ProfileVersion.Anthem)
+ {
+ App.Logger.Log("Not applicable for Anthem, sorry for the inconvenience!");
+ return;
+ }
+ var textDb = (BiowareLocalizedStringDatabase) LocalizedStringDatabase.Current;
+ App.EditorWindow.OpenEditor(ITEM_NAME, new BiowareLocalizedStringEditor(textDb));
+ });
+ }
+}
diff --git a/Plugins/BiowareLocalizationPlugin/BiowareLocalizationCustomActionHandler.cs b/Plugins/BiowareLocalizationPlugin/BiowareLocalizationCustomActionHandler.cs
new file mode 100644
index 000000000..4a7a49352
--- /dev/null
+++ b/Plugins/BiowareLocalizationPlugin/BiowareLocalizationCustomActionHandler.cs
@@ -0,0 +1,181 @@
+using BiowareLocalizationPlugin.LocalizedResources;
+using Frosty.Core;
+using Frosty.Core.IO;
+using Frosty.Core.Mod;
+using Frosty.Hash;
+using FrostySdk;
+using FrostySdk.IO;
+using FrostySdk.Managers;
+using FrostySdk.Resources;
+using System.Collections.Generic;
+using System.Text;
+
+namespace BiowareLocalizationPlugin
+{
+ public class BiowareLocalizationCustomActionHandler : ICustomActionHandler
+ {
+
+ public HandlerUsage Usage => HandlerUsage.Merge;
+
+ // A mod is comprised of a series of base resources, embedded, ebx, res, and chunks. Embedded are used internally
+ // for the icon and images of a mod. Ebx/Res/Chunks are the core resources used for applying data to the game.
+ // When you create a custom handler, you need to provide your own resources for your custom handled data. This
+ // resource is unique however it is based on one of the three core types.
+ private class BiowareLocalizationModResource : EditorModResource
+ {
+
+ // Defines which type of resource this resource is.
+ public override ModResourceType Type => ModResourceType.Res;
+
+
+ // The resType is vital to be kept (its always LocalizedStringResource, but whatever)
+ private readonly uint _resType;
+
+ // these other two fields may have to be written to the mod as well
+ private readonly ulong _resRid;
+ private readonly byte[] _resMeta;
+
+
+ public BiowareLocalizationModResource(ResAssetEntry entry, FrostyModWriter.Manifest manifest) : base(entry)
+ {
+
+ // This constructor does the exact same thing as the ones in the TestPlugin
+
+ // obtain the modified data
+ ModifiedLocalizationResource md = entry.ModifiedEntry.DataObject as ModifiedLocalizationResource;
+ byte[] data = md.Save();
+
+ // store data and details about resource
+ name = entry.Name.ToLower();
+ sha1 = Utils.GenerateSha1(data);
+ resourceIndex = manifest.Add(sha1, data);
+ size = data.Length;
+
+ // set the handler hash to the hash of the res type name
+ handlerHash = Fnv1.HashString(entry.Type.ToLower());
+
+ _resType = entry.ResType;
+ _resRid = entry.ResRid;
+ _resMeta = entry.ResMeta;
+ }
+
+ ///
+ /// This method is calles when writing the mod. For Res Types it is vital that some additional information is persisted that is not written by the base method.
+ /// Mainly that is the ResourceType as uint
+ /// Additional data that is read, but I'm not sure whether it is actually necessary:
+ ///
+ ///
ResRid as ulong (not sure if this is really necessary, i.e., actually read)
+ ///
resMeta length
+ ///
resMeta as byte array
+ ///
+ ///
+ ///
+ public override void Write(NativeWriter writer)
+ {
+ base.Write(writer);
+
+ // write the required res type:
+ writer.Write(_resType);
+
+ writer.Write(_resRid);
+ writer.Write((_resMeta != null) ? _resMeta.Length : 0);
+ if (_resMeta != null)
+ {
+ writer.Write(_resMeta);
+ }
+ }
+ }
+
+ #region -- Editor Specific --
+
+ // This function is for writing resources to the mod file, this is where you would add your custom
+ // resources to be written.
+ public void SaveToMod(FrostyModWriter writer, AssetEntry entry)
+ {
+ writer.AddResource(new BiowareLocalizationModResource(entry as ResAssetEntry, writer.ResourceManifest));
+ }
+ #endregion
+
+ #region -- Mod Specific --
+
+ // This function is for the mod managers action view, to allow a handler to describe detailed actions performed
+ // format of the action string is ;; where action can be Modify or Merge (or Add!)
+ // and ResourceType can be Ebx,Res,Chunk
+ public IEnumerable GetResourceActions(string name, byte[] data)
+ {
+
+ if( !Config.Get(BiowareLocalizationPluginModManagerOptions.SHOW_INDIVIDUAL_TEXTIDS_OPTION_NAME, false, ConfigScope.Global))
+ {
+ return new List();
+ }
+
+ ModifiedLocalizationResource modified = ModifiedResource.Read(data) as ModifiedLocalizationResource;
+
+ List textIds = new List(modified.AlteredTexts.Keys);
+ textIds.Sort();
+
+ List resourceActions = new List(textIds.Count);
+ foreach (uint textId in textIds)
+ {
+ string resourceName = new StringBuilder(name).Append(" (0x").Append(textId.ToString("X8")).Append(')').ToString();
+ string resourceType = "res";
+ string action = "Modify";
+
+ resourceActions.Add(resourceName + ";" + resourceType + ";" + action);
+ }
+
+ return resourceActions;
+ }
+
+ // This function is invoked when a mod with such a handler is loaded, if a previous mod with a handler for this
+ // particular asset was loaded previously, then existing will be populated with that data, allowing this function
+ // the chance to merge the two datasets together
+ public object Load(object existing, byte[] newData)
+ {
+
+ ModifiedLocalizationResource edited = (ModifiedLocalizationResource)existing;
+ ModifiedLocalizationResource newTexts = (ModifiedLocalizationResource) ModifiedResource.Read(newData);
+
+ if(edited == null)
+ {
+ return newTexts;
+ }
+
+ edited.Merge(newTexts);
+
+ return edited;
+ }
+
+ // This function is invoked at the end of the mod loading, to actually modify the existing game data with the end
+ // result of the mod loaded data, it also allows for a handler to add new Resources to be replaced.
+ // ie. an Ebx handler might want to add a new Chunk resource that it is dependent on.
+ public void Modify(AssetEntry origEntry, AssetManager am, RuntimeResources runtimeResources, object data, out byte[] outData)
+ {
+
+ // no idea what frosty does if the resource does not exist in the local game, so first check for null:
+ if(origEntry == null)
+ {
+ outData = System.Array.Empty();
+ return;
+ }
+
+ // load the original resource
+ ResAssetEntry originalResAsset = am.GetResEntry(origEntry.Name);
+ ModifiedLocalizationResource modified = data as ModifiedLocalizationResource;
+ LocalizedStringResource resource = am.GetResAs(originalResAsset, modified);
+
+ byte[] uncompressedData = resource.SaveBytes();
+ outData = Utils.CompressFile(uncompressedData);
+
+ // update the metadata
+ byte[] alteredMetaData = resource.ResourceMeta;
+ ((ResAssetEntry)origEntry).ResMeta = alteredMetaData;
+
+ // update relevant asset entry values
+ origEntry.OriginalSize = uncompressedData.Length;
+ origEntry.Size = outData.Length;
+ origEntry.Sha1 = Utils.GenerateSha1(outData);
+ }
+ #endregion
+ }
+}
diff --git a/Plugins/BiowareLocalizationPlugin/BiowareLocalizationPlugin.csproj b/Plugins/BiowareLocalizationPlugin/BiowareLocalizationPlugin.csproj
index f84291f44..84f1caf1c 100644
--- a/Plugins/BiowareLocalizationPlugin/BiowareLocalizationPlugin.csproj
+++ b/Plugins/BiowareLocalizationPlugin/BiowareLocalizationPlugin.csproj
@@ -53,5 +53,9 @@
false
+
+ false
+
+
\ No newline at end of file
diff --git a/Plugins/BiowareLocalizationPlugin/BiowareLocalizationPluginModManagerOptions.cs b/Plugins/BiowareLocalizationPlugin/BiowareLocalizationPluginModManagerOptions.cs
new file mode 100644
index 000000000..cb6be4c7d
--- /dev/null
+++ b/Plugins/BiowareLocalizationPlugin/BiowareLocalizationPluginModManagerOptions.cs
@@ -0,0 +1,30 @@
+
+using Frosty.Core;
+using FrostySdk.Attributes;
+
+namespace BiowareLocalizationPlugin
+{
+ [DisplayName("Bioware Localization Options")]
+ public class BiowareLocalizationPluginModManagerOptions : OptionsExtension
+ {
+
+ // The name for the global mod manager variable.
+ public static readonly string SHOW_INDIVIDUAL_TEXTIDS_OPTION_NAME = "BwLoMoShowIndividualTextIds";
+
+ [Category("General")]
+ [Description("If enabled, all individual text ids in each resource (res) are shown in the Actions tab. Otherwise only the resource iteself is shown as merged.")]
+ [DisplayName("Show Individual Text Ids")]
+ [EbxFieldMeta(FrostySdk.IO.EbxFieldType.Boolean)]
+ public bool ShowIndividualTextIds { get; set; } = false;
+
+ public override void Load()
+ {
+ ShowIndividualTextIds = Config.Get(SHOW_INDIVIDUAL_TEXTIDS_OPTION_NAME, false, ConfigScope.Global);
+ }
+
+ public override void Save()
+ {
+ Config.Add(SHOW_INDIVIDUAL_TEXTIDS_OPTION_NAME, ShowIndividualTextIds, ConfigScope.Global);
+ }
+ }
+}
diff --git a/Plugins/BiowareLocalizationPlugin/BiowareLocalizedStringDatabase.cs b/Plugins/BiowareLocalizationPlugin/BiowareLocalizedStringDatabase.cs
index ad2145047..f59afd69c 100644
--- a/Plugins/BiowareLocalizationPlugin/BiowareLocalizedStringDatabase.cs
+++ b/Plugins/BiowareLocalizationPlugin/BiowareLocalizedStringDatabase.cs
@@ -1,124 +1,352 @@
-using Frosty.Core;
+using BiowareLocalizationPlugin.Controls;
+using BiowareLocalizationPlugin.LocalizedResources;
+using Frosty.Core;
using FrostySdk.Managers;
using System;
using System.Collections.Generic;
+using System.Globalization;
+using System.Windows;
namespace BiowareLocalizationPlugin
{
public class BiowareLocalizedStringDatabase : ILocalizedStringDatabase
{
- private Dictionary strings = new Dictionary();
+ ///
+ /// The default language to operate with if no other one is given.
+ ///
+ public string DefaultLanguage { get; private set; }
+
+ ///
+ /// Holds all the languages supported by the local game and their bundles
+ ///
+ private SortedDictionary> _languageLocalizationBundles;
+
+ ///
+ /// Dictionary of all currently loaded localized texts.
+ ///
+ private readonly Dictionary _loadedLocalizedTextDBs = new Dictionary();
+
+ ///
+ /// Initializes the db.
+ ///
public void Initialize()
{
- LoadLocalizedStringConfiguration("LocalizedStringTranslationsConfiguration");
- LoadLocalizedStringConfiguration("LocalizedStringPatchTranslationsConfiguration");
+
+ DefaultLanguage = "LanguageFormat_" + Config.Get("Language", "English", scope: ConfigScope.Game);
+
+ _languageLocalizationBundles = GetLanguageDictionary();
+
+ LanguageTextsDB defaultLocalizedTexts = new LanguageTextsDB();
+ defaultLocalizedTexts.Init(DefaultLanguage, _languageLocalizationBundles[DefaultLanguage]);
+
+ _loadedLocalizedTextDBs.Add(DefaultLanguage, defaultLocalizedTexts);
+ }
+
+ ///
+ /// Fills the language dictionary with all available languages and their bundles.
+ ///
+ /// Sorted Dictionary of LangugeFormat names and their text super bundles paths.
+ private static SortedDictionary> GetLanguageDictionary()
+ {
+
+ var languagesRepository = new SortedDictionary>();
+
+ // There is no need to also search for 'LocalizedStringPatchTranslationsConfiguration', these are also found via their base type
+ foreach (EbxAssetEntry entry in App.AssetManager.EnumerateEbx("LocalizedStringTranslationsConfiguration"))
+ {
+ // read localization config
+ dynamic localizationAsset = App.AssetManager.GetEbx(entry).RootObject;
+
+ // iterate through language to bundle lists
+ foreach (dynamic languageBundleListEntry in localizationAsset.LanguagesToBundlesList)
+ {
+ string languageName = languageBundleListEntry.Language.ToString();
+ HashSet bundleNames;
+ if (languagesRepository.ContainsKey(languageName))
+ {
+ bundleNames = languagesRepository[languageName];
+ }
+ else
+ {
+ bundleNames = new HashSet();
+ languagesRepository[languageName] = bundleNames;
+ }
+
+ foreach(string bundlepath in languageBundleListEntry.BundlePaths)
+ {
+ bundleNames.Add(bundlepath);
+ }
+ }
+ }
+
+ return languagesRepository;
+ }
+
+ ///
+ /// Tries to return the text for the given uid, throws an exception if the text id is not known.
+ /// @see #FindText
+ ///
+ ///
+ ///
+ public string GetString(uint id)
+ {
+ return GetText(DefaultLanguage, id);
+ }
+
+ public string GetString(string stringId)
+ {
+
+ bool canRead = uint.TryParse(stringId, NumberStyles.HexNumber, null, out uint textId);
+ if(canRead)
+ {
+ return GetString(textId);
+ }
+
+ App.Logger.LogError("Cannot read given textId <{0}>", stringId);
+ return stringId;
+
+ }
+
+ ///
+ /// Returns the language db for the requested language format, loading it if necessary.
+ ///
+ ///
+ ///
+ private LanguageTextsDB GetLocalizedTextDB(string languageFormat)
+ {
+ bool isLoaded = _loadedLocalizedTextDBs.TryGetValue(languageFormat, out LanguageTextsDB localizedTextDb);
+ if (!isLoaded)
+ {
+ if(!_languageLocalizationBundles.ContainsKey(languageFormat))
+ {
+ throw new ArgumentException(string.Format("LanguageFormat <{0}> does not exist in this game!", languageFormat));
+ }
+
+ localizedTextDb = new LanguageTextsDB();
+ localizedTextDb.Init(languageFormat, _languageLocalizationBundles[languageFormat]);
+
+ _loadedLocalizedTextDBs.Add(languageFormat, localizedTextDb);
+ }
+ return localizedTextDb;
+ }
+
+ ///
+ /// Tries to return the text for the given uid. Returns an error message if the text does not exist.
+ ///
+ ///
+ ///
+ ///
+ public string GetText(string languageFormat, uint textId)
+ {
+ return GetLocalizedTextDB(languageFormat).GetText(textId);
}
public IEnumerable EnumerateStrings()
{
- foreach (uint key in strings.Keys)
- yield return key;
+ return GetAllTextIds(DefaultLanguage);
}
- public IEnumerable EnumerateModifiedStrings()
+ ///
+ /// Returns a language specific list of all text ids.
+ ///
+ ///
+ ///
+ public IEnumerable GetAllTextIds(string languageFormat)
{
- throw new NotImplementedException();
+ return GetLocalizedTextDB(languageFormat).GetAllTextIds();
}
- public string GetString(uint id)
+ ///
+ /// Returns only the ids of modified or new texts.
+ ///
+ ///
+ ///
+ public IEnumerable GetAllModifiedTextsIds(string languageFormat)
{
- if (!strings.ContainsKey(id))
+ return GetLocalizedTextDB(languageFormat).GetAllModifiedTextsIds();
+ }
+
+ ///
+ /// Tries to return the text for the given uid, returns null if the textid does not exist.
+ /// @see #GetString
+ ///
+ ///
+ ///
+ ///
+ public string FindText(string languageFormat, uint textId)
+ {
+ return GetLocalizedTextDB(languageFormat).FindText(textId);
+ }
+
+ ///
+ /// Returns the list of LocalizedStringResource in which the given text id can be found.
+ ///
+ ///
+ /// The text id to look for.
+ /// All resources in which the text id can be found.
+ public IEnumerable GetAllLocalizedStringResourcesForTextId(string languageFormat, uint textId)
+ {
+ return GetLocalizedTextDB(languageFormat).GetAllResourcesForTextId(textId);
+ }
+
+ ///
+ /// Returns the list of LocalizedStringResource in which the given text id can be found by default.
+ ///
+ ///
+ /// The text id to look for.
+ /// All resources in which the text id can be found by default.
+ public IEnumerable GetDefaultLocalizedStringResourcesForTextId(string languageFormat, uint textId)
+ {
+ return GetLocalizedTextDB(languageFormat).GetDefaultResourcesForTextId(textId);
+ }
+
+ ///
+ /// Returns the list of LocalizedStringResource in which the given text id can be found due to a mod.
+ ///
+ ///
+ /// The text id to look for.
+ /// All resources in which the text id can be found due to a mod.
+ public IEnumerable GetAddedLocalizedStringResourcesForTextId(string languageFormat, uint textId)
+ {
+ return GetLocalizedTextDB(languageFormat).GetAddedResourcesForTextId(textId);
+ }
+
+ ///
+ /// Returns the names of all found resources
+ ///
+ ///
+ ///
+ public IEnumerable GetAllResourceNames(string languageFormat)
+ {
+ return GetLocalizedTextDB(languageFormat).GetAllResourceNames();
+ }
+
+ ///
+ /// Sets a text into a single resource
+ ///
+ ///
+ ///
+ ///
+ ///
+ public void SetText(string languageFormat, IEnumerable resourceNames, uint textId, string text)
+ {
+
+ LanguageTextsDB localizedDB = GetLocalizedTextDB(languageFormat);
+ foreach (string resourceName in resourceNames)
{
- if (id == 0)
- return "";
- return string.Format("Invalid StringId: {0}", id.ToString("X8"));
+ localizedDB.SetText(resourceName, textId, text);
}
- return strings[id];
+
+ localizedDB.UpdateTextCache(textId, text);
}
- public string GetString(string stringId)
+ ///
+ /// Removes the given text with the given id from the given resources for the given language.
+ ///
+ ///
+ ///
+ ///
+ public void RemoveText(string languageFormat, IEnumerable resourceNames, uint textId)
+ {
+ LanguageTextsDB localizedDB = GetLocalizedTextDB(languageFormat);
+ foreach (string resourceName in resourceNames)
+ {
+ localizedDB.RemoveText(resourceName, textId);
+ }
+
+ localizedDB.RemoveTextFromCache(textId);
+ }
+
+ public void RevertText(string languageFormat, uint textId)
{
- throw new NotImplementedException();
+ LanguageTextsDB localizedDB = GetLocalizedTextDB(languageFormat);
+ localizedDB.RevertText(textId);
}
+ public IEnumerable GellAllLanguages()
+ {
+ return new List(_languageLocalizationBundles.Keys);
+ }
+
+ // basically identical to SetText, this method was added in the 1.06 beta interface
public void SetString(uint id, string value)
{
- throw new NotImplementedException();
+ LanguageTextsDB localizedDB = GetLocalizedTextDB(DefaultLanguage);
+ IEnumerable allTextResources = localizedDB.GetAllResourcesForTextId(id);
+
+ foreach(LocalizedStringResource textresource in allTextResources)
+ {
+ localizedDB.SetText(textresource.Name, id, value);
+ }
}
+ // // Basically identical to SetText, this method was added in the 1.06 beta interface
public void SetString(string id, string value)
{
- throw new NotImplementedException();
+ bool canRead = uint.TryParse(id, NumberStyles.HexNumber, null, out uint textId);
+ if (canRead)
+ {
+ SetString(textId, value);
+ }
+
+ App.Logger.LogError("Cannot read given textId <{0}>", id);
}
+ // // Basically identical to RevertText, this method was added in the 1.06 beta interface
public void RevertString(uint id)
{
- throw new NotImplementedException();
+ RevertText(DefaultLanguage, id);
}
+ // Returns whether the text with the given id was altered.
+ // Implements the interface method added in 1.0.6beta
public bool isStringEdited(uint id)
{
+ LanguageTextsDB localizedDB = GetLocalizedTextDB(DefaultLanguage);
+
+ IEnumerable modifiedTextsIds = localizedDB.GetAllModifiedTextsIds();
+ foreach(uint textId in modifiedTextsIds)
+ {
+ if(textId == id)
+ {
+ return true;
+ }
+ }
+
return false;
}
+ ///
+ /// Opens a window to add strings to the localized string database.
+ ///
+ /// Note This method came with the 1.06.beta1 and i feel really uncomfortable displaying an edit dialog directly from what is supposed to be abackend class >_<
public void AddStringWindow()
{
- throw new NotImplementedException();
+
+ AddEditWindow editWindow = new AddEditWindow(this, DefaultLanguage)
+ {
+ Owner = Application.Current.MainWindow
+ };
+ editWindow.Init(0);
+ _ = editWindow.ShowDialog();
}
+ // This method came with 1.06.beta1, and i still believe bulk operations to be more a risk of breaking texts than working properly
+ // - or at least my implementation of that function would be ;D
public void BulkReplaceWindow()
{
- throw new NotImplementedException();
+ App.Logger.LogWarning("Bulk replacement is not supported for bioware games");
}
- private void LoadLocalizedStringConfiguration(string type)
+ ///
+ /// Retrieves a collection of string IDs that were modified from the localized string database.
+ /// This method came into the interface in 1.06.beta1, and is virtually identical to GetAllModifiedTextsIds
+ ///
+ /// A collection of string IDs, or an empty collection if no modified strings exist.
+ public IEnumerable EnumerateModifiedStrings()
{
- foreach (EbxAssetEntry entry in App.AssetManager.EnumerateEbx(type))
- {
- // read localization config
- dynamic localizationAsset = App.AssetManager.GetEbx(entry).RootObject;
-
- // iterate thru language to bundle lists
- foreach (dynamic languageBundleList in localizationAsset.LanguagesToBundlesList)
- {
- if (languageBundleList.Language.ToString().Equals("LanguageFormat_English"))
- {
- foreach (string bundlePath in languageBundleList.BundlePaths)
- {
- string bundleFullPath = "win32/" + bundlePath.ToLower();
- foreach (ResAssetEntry resEntry in App.AssetManager.EnumerateRes(resType: (uint)ResourceType.LocalizedStringResource))
- {
- bool bFound = false;
- foreach (int bindex in resEntry.EnumerateBundles())
- {
- BundleEntry be = App.AssetManager.GetBundleEntry(bindex);
- if (be.Name.StartsWith(bundleFullPath, StringComparison.OrdinalIgnoreCase))
- {
- bFound = true;
- break;
- }
- }
-
- if (bFound)
- {
- LocalizedStringResource resource = App.AssetManager.GetResAs(resEntry);
- if (resource != null)
- {
- foreach (KeyValuePair kvp in resource.Strings)
- {
- if (!strings.ContainsKey(kvp.Key))
- {
- strings.Add(kvp.Key, kvp.Value);
- }
- }
- }
- }
- }
- }
- }
- }
- }
+ return GetAllModifiedTextsIds(DefaultLanguage);
}
}
}
diff --git a/Plugins/BiowareLocalizationPlugin/Controls/AddEditWindow.xaml b/Plugins/BiowareLocalizationPlugin/Controls/AddEditWindow.xaml
new file mode 100644
index 000000000..179fbe318
--- /dev/null
+++ b/Plugins/BiowareLocalizationPlugin/Controls/AddEditWindow.xaml
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Plugins/BiowareLocalizationPlugin/Controls/AddEditWindow.xaml.cs b/Plugins/BiowareLocalizationPlugin/Controls/AddEditWindow.xaml.cs
new file mode 100644
index 000000000..0df2502ef
--- /dev/null
+++ b/Plugins/BiowareLocalizationPlugin/Controls/AddEditWindow.xaml.cs
@@ -0,0 +1,213 @@
+using BiowareLocalizationPlugin.LocalizedResources;
+using Frosty.Controls;
+using Frosty.Core;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+
+namespace BiowareLocalizationPlugin.Controls
+{
+
+ ///
+ /// Via this window the user can add or edit existing texts.
+ /// This is a positive logic only window, i.e., you can add texts to additional resource, but cannot deselect it again.
+ ///
+ [TemplatePart(Name = "textIdField", Type = typeof(TextBox))]
+ [TemplatePart(Name = "localizedTextBox", Type = typeof(TextBox))]
+ [TemplatePart(Name = "addedResourcesListBox", Type = typeof(ListBox))]
+ [TemplatePart(Name = "defaultResourcesListBox", Type = typeof(ListBox))]
+ [TemplatePart(Name = "saveButton", Type = typeof(Button))]
+ [TemplatePart(Name = "cancelButton", Type = typeof(Button))]
+ public partial class AddEditWindow : FrostyDockableWindow
+ {
+
+ private readonly string _selectedLanguageFormat;
+ private readonly BiowareLocalizedStringDatabase _stringDb;
+
+ ///
+ /// List to keep track of resources where a text id was removed from.
+ ///
+ private readonly List _removedResources = new List();
+
+ ///
+ /// The save value tuple consiting of the text id, and text. This is only available after the save action!
+ ///
+ public Tuple SaveValue { get; private set; }
+
+ public AddEditWindow(BiowareLocalizedStringDatabase stringDb, string languageFormat)
+ {
+ InitializeComponent();
+
+ _selectedLanguageFormat = languageFormat;
+ _stringDb = stringDb;
+ }
+
+ ///
+ /// Initializes the window with the currently selected textid and text.
+ ///
+ ///
+ public void Init(uint textId)
+ {
+
+ if(textId != 0)
+ {
+ textIdField.Text = textId.ToString("X8");
+ }
+ UpdateData(textId);
+ }
+
+ private void Update(object sender, RoutedEventArgs e)
+ {
+ uint textId = ReadTextId();
+ UpdateData(textId);
+ }
+
+ ///
+ /// Updates the listed data
+ ///
+ private void UpdateData(uint textId)
+ {
+
+ if (textId == 0)
+ {
+ // revert;
+ localizedTextBox.Text = "";
+ DeselectAllResources();
+ }
+ else
+ {
+ localizedTextBox.Text = _stringDb.FindText(_selectedLanguageFormat, textId);
+ DeselectAllResources();
+
+ IEnumerable addedResources = _stringDb.GetAddedLocalizedStringResourcesForTextId(_selectedLanguageFormat, textId);
+ foreach (LocalizedStringResource res in addedResources)
+ {
+ addedResourcesListBox.Items.Add(res.Name);
+ }
+
+ IEnumerable defaultResources = _stringDb.GetDefaultLocalizedStringResourcesForTextId(_selectedLanguageFormat, textId);
+ ListBoxUtils.SortListIntoListBox(defaultResources.Select(r => r.Name), defaultResourcesListBox);
+ }
+ }
+
+ private void DeselectAllResources()
+ {
+ defaultResourcesListBox.Items.Clear();
+ addedResourcesListBox.Items.Clear();
+
+ _removedResources.Clear();
+ }
+
+ private void Save(object sender, RoutedEventArgs e)
+ {
+ uint textId = ReadTextId();
+
+ if(textId != 0)
+ {
+
+ // TODO move all this text handling into a dedicated handler or something.
+ // This is currently all over the place >_<
+
+ _stringDb.RemoveText(_selectedLanguageFormat, _removedResources, textId);
+
+ List resources = new List();
+ foreach (string resourceName in defaultResourcesListBox.Items)
+ {
+ resources.Add(resourceName);
+ }
+ foreach (string resourceName in addedResourcesListBox.Items)
+ {
+ resources.Add(resourceName);
+ }
+
+ string text = localizedTextBox.Text;
+ _stringDb.SetText(_selectedLanguageFormat, resources, textId, text);
+
+ SaveValue = Tuple.Create(textId, text);
+
+ DialogResult = true;
+ Close();
+ }
+ }
+
+ ///
+ /// Closes the dialog without saving
+ ///
+ ///
+ ///
+ private void Close(object sender, RoutedEventArgs e)
+ {
+ DialogResult = false;
+ SaveValue = null;
+ Close();
+ }
+
+ ///
+ /// Reads the set hexadecimal text id and sets the lokal variable with the parsed result.
+ ///
+ /// _textId
+ private uint ReadTextId()
+ {
+ string text = textIdField.Text;
+ bool canRead = uint.TryParse(text, NumberStyles.HexNumber, null, out uint textId);
+ if (canRead)
+ {
+ return textId;
+ }
+
+ textIdField.Text = "";
+ App.Logger.LogWarning("Bad Input!");
+ return 0;
+ }
+
+ ///
+ /// Shows a popup dialog to select one or more resources where to add the current text into.
+ ///
+ ///
+ ///
+ private void AddResources(object sender, RoutedEventArgs e)
+ {
+
+ IEnumerable selectableResources = _stringDb.GetAllResourceNames(_selectedLanguageFormat)
+ .Where(r => !defaultResourcesListBox.Items.Contains(r)
+ && !addedResourcesListBox.Items.Contains(r) );
+
+ ResourceSelectionWindow selectionDialog = new ResourceSelectionWindow(selectableResources);
+ bool? saved = selectionDialog.ShowDialog();
+
+ if(saved != null && saved.Value)
+ {
+ foreach(string resourceName in selectionDialog.SelectedResources)
+ {
+ addedResourcesListBox.Items.Add(resourceName);
+ }
+ }
+ }
+
+ private void RemoveResources(object sender, RoutedEventArgs e)
+ {
+
+ List selectedToRemove = addedResourcesListBox.SelectedItems.OfType().ToList();
+ foreach(string itemToRemove in selectedToRemove)
+ {
+ addedResourcesListBox.Items.Remove(itemToRemove);
+ }
+
+ _removedResources.AddRange(selectedToRemove);
+ }
+
+ ///
+ /// Called to copy the selected values of either listbox to the clipboard.
+ ///
+ ///
+ ///
+ private void CopySelectionToClipboard(object sender, ExecutedRoutedEventArgs e)
+ {
+ ListBoxUtils.CopySelectionToClipboard(sender, e);
+ }
+ }
+}
diff --git a/Plugins/BiowareLocalizationPlugin/Controls/BiowareLocalizedStringEditor.cs b/Plugins/BiowareLocalizationPlugin/Controls/BiowareLocalizedStringEditor.cs
new file mode 100644
index 000000000..d95ab3d40
--- /dev/null
+++ b/Plugins/BiowareLocalizationPlugin/Controls/BiowareLocalizedStringEditor.cs
@@ -0,0 +1,452 @@
+using BiowareLocalizationPlugin.ExportImport;
+using Frosty.Core;
+using Frosty.Core.Controls;
+using Frosty.Core.Windows;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+
+namespace BiowareLocalizationPlugin.Controls
+{
+
+ ///
+ /// This is basically a copy of the FrostyLocalizedStringViewer, with some added functionality
+ ///
+ [TemplatePart(Name = PART_LocalizedString, Type = typeof(TextBox))]
+ [TemplatePart(Name = PART_StringIdList, Type = typeof(ListBox))]
+ [TemplatePart(Name = PART_Searchfield, Type = typeof(TextBox))]
+ [TemplatePart(Name = PART_SearchButton, Type = typeof(Button))]
+ [TemplatePart(Name = PART_SearchTextButton, Type = typeof(Button))]
+ [TemplatePart(Name = PART_ModifiedOnlyCB, Type = typeof(CheckBox))]
+ [TemplatePart(Name = PART_UpdateTextIdFieldCB, Type = typeof(CheckBox))]
+ [TemplatePart(Name = PART_ShowTextInfo, Type = typeof(Button))]
+ [TemplatePart(Name = PART_AddEdit, Type = typeof(Button))]
+ [TemplatePart(Name = PART_Remove, Type = typeof(Button))]
+ [TemplatePart(Name = PART_LanguageSelector, Type = typeof(ComboBox))]
+ [TemplatePart(Name = PART_RefreshButton, Type = typeof(Button))]
+ [TemplatePart(Name = PART_Export, Type = typeof(Button))]
+ [TemplatePart(Name = PART_Import, Type = typeof(Button))]
+ class BiowareLocalizedStringEditor : FrostyBaseEditor
+ {
+
+ private const string PART_LocalizedString = "PART_LocalizedString";
+
+ private const string PART_StringIdList = "PART_StringIdList";
+
+ private const string PART_Searchfield = "PART_Searchfield";
+ private const string PART_SearchButton = "PART_SearchButton";
+ private const string PART_SearchTextButton = "PART_SearchTextButton";
+ private const string PART_ModifiedOnlyCB = "PART_ModifiedOnlyCB";
+ private const string PART_UpdateTextIdFieldCB = "PART_UpdateTextIdFieldCB";
+
+ private const string PART_ShowTextInfo = "PART_ShowTextInfo";
+
+ private const string PART_AddEdit = "PART_AddEdit";
+
+ private const string PART_Remove = "PART_Remove";
+
+ private const string PART_LanguageSelector = "PART_LanguageSelector";
+ private const string PART_RefreshButton = "PART_RefreshButton";
+
+ private const string PART_Export = "PART_Export";
+ private const string PART_Import = "PART_Import";
+
+ //#############################################
+
+ // TODO ReplaceAll function?
+
+ private TextBox localizedStringTb;
+
+ private ListBox stringIdListBox;
+
+ private TextBox searchfieldTb;
+
+ private CheckBox modifiedOnlyCB;
+
+ private CheckBox updateTextIdFieldCB;
+
+ private Button textInfoBt;
+
+ private Button removeButton;
+
+ private ComboBox languageSelectorCb;
+
+ private List _textIdsList = new List();
+
+ ///
+ /// The text db instance, stored as variable for convenience
+ ///
+ private readonly BiowareLocalizedStringDatabase _textDB;
+
+ private string _selectedLanguageFormat;
+
+ ///
+ /// handler to support closing child windows if this editor is closed.
+ ///
+ private readonly ClosingHandler _closingHandler;
+
+ private bool _firstTimeInitialization = true;
+
+ static BiowareLocalizedStringEditor()
+ {
+ DefaultStyleKeyProperty.OverrideMetadata(typeof(BiowareLocalizedStringEditor), new FrameworkPropertyMetadata(typeof(BiowareLocalizedStringEditor)));
+ }
+
+ public BiowareLocalizedStringEditor(BiowareLocalizedStringDatabase textDb)
+ {
+ _textDB = textDb;
+ _selectedLanguageFormat = textDb.DefaultLanguage;
+ _closingHandler = new ClosingHandler();
+ }
+
+ public override void OnApplyTemplate()
+ {
+ base.OnApplyTemplate();
+
+ stringIdListBox = GetTemplateChild(PART_StringIdList) as ListBox;
+ stringIdListBox.SelectionChanged += StringIdListbox_SelectionChanged;
+
+ localizedStringTb = GetTemplateChild(PART_LocalizedString) as TextBox;
+
+ searchfieldTb = GetTemplateChild(PART_Searchfield) as TextBox;
+ searchfieldTb.PreviewKeyDown += SearchFieldActualized;
+ Button btSearchButton = GetTemplateChild(PART_SearchButton) as Button;
+ btSearchButton.Click += SearchButtonClicked;
+
+ Button btSearchTextButton = GetTemplateChild(PART_SearchTextButton) as Button;
+ btSearchTextButton.Click += ShowSearchDialog;
+
+ modifiedOnlyCB = GetTemplateChild(PART_ModifiedOnlyCB) as CheckBox;
+ modifiedOnlyCB.Click += ReLoadTexts;
+
+ updateTextIdFieldCB = GetTemplateChild(PART_UpdateTextIdFieldCB) as CheckBox;
+
+ textInfoBt = GetTemplateChild(PART_ShowTextInfo) as Button;
+ textInfoBt.IsEnabled = false; // initially disabled until a text is selected
+ textInfoBt.Click += ShowTextInfo;
+
+ Button addButton = GetTemplateChild(PART_AddEdit) as Button;
+ addButton.Click += ShowAddEditWindow;
+
+ removeButton = GetTemplateChild(PART_Remove) as Button;
+ removeButton.IsEnabled = false; // initially disabled until a text is selected
+ removeButton.Click += Remove;
+
+ Button refreshButton = GetTemplateChild(PART_RefreshButton) as Button;
+ refreshButton.Click += ReLoadTexts;
+
+ languageSelectorCb = GetTemplateChild(PART_LanguageSelector) as ComboBox;
+ languageSelectorCb.ItemsSource = _textDB.GellAllLanguages();
+ languageSelectorCb.SelectedItem = _selectedLanguageFormat;
+ languageSelectorCb.SelectionChanged += SelectLanguage;
+
+ Button exportButton = GetTemplateChild(PART_Export) as Button;
+ exportButton.Click += Export;
+
+ Button importButton = GetTemplateChild(PART_Import) as Button;
+ importButton.Click += Import;
+
+ Loaded += LoadFirstTime;
+
+ }
+
+ private void LoadFirstTime(object sender, RoutedEventArgs e)
+ {
+ if(_firstTimeInitialization)
+ {
+ LoadTexts(sender, e);
+ _firstTimeInitialization = false;
+ }
+ }
+
+ private void LoadTexts(object sender, RoutedEventArgs e)
+ {
+
+ bool? nullableModifiedOnly = modifiedOnlyCB.IsChecked;
+ bool modifiedOnly = nullableModifiedOnly.HasValue && nullableModifiedOnly.Value;
+
+ FrostyTaskWindow.Show("Loading texts", "", (task) =>
+ {
+
+ if(modifiedOnly)
+ {
+ _textIdsList = _textDB.GetAllModifiedTextsIds(_selectedLanguageFormat).ToList();
+ }
+ else
+ {
+ _textIdsList = _textDB.GetAllTextIds(_selectedLanguageFormat).ToList();
+ }
+
+ _textIdsList.Sort();
+ });
+
+ if (_textIdsList.Count == 0)
+ {
+ return;
+ }
+
+ foreach (uint textId in _textIdsList)
+ {
+ stringIdListBox.Items.Add(textId.ToString("X8") + " - " + _textDB.GetText(_selectedLanguageFormat, textId));
+ }
+ }
+
+ private void StringIdListbox_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ uint selectedTextId = GetCurrentStringId();
+ PopulateLocalizedString(selectedTextId);
+
+ bool isTextSelected = e.AddedItems.Count > 0;
+ textInfoBt.IsEnabled = isTextSelected;
+ removeButton.IsEnabled = isTextSelected;
+
+ if (isTextSelected && updateTextIdFieldCB.IsChecked == true)
+ {
+ searchfieldTb.Text = selectedTextId.ToString("X8");
+ }
+ }
+
+ private void PopulateLocalizedString(uint textId)
+ {
+ localizedStringTb.Text = _textDB.GetText(_selectedLanguageFormat, textId);
+ }
+
+ void SearchButtonClicked(object sender, RoutedEventArgs e)
+ {
+ DoSearch();
+ }
+
+ private void SearchFieldActualized(object sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Return)
+ {
+ DoSearch();
+ }
+ }
+
+ ///
+ /// Searches the list of string ids for the text id (assumed a hexadecimal value!) given in the search box.
+ ///
+ private void DoSearch()
+ {
+ string stringIdAsText = searchfieldTb.Text;
+
+ bool canRead = uint.TryParse(stringIdAsText, NumberStyles.HexNumber, null, out uint textId);
+ if (!canRead)
+ {
+ App.Logger.LogWarning("Bad Input! Cannot read <{0}> as text Id", stringIdAsText);
+ }
+ else
+ {
+
+ SearchTextId(textId);
+ }
+ }
+
+ private void SearchTextId(uint textId)
+ {
+ int index = _textIdsList.IndexOf(textId);
+ if (index < 0 && index < stringIdListBox.Items.Count)
+ {
+ stringIdListBox.UnselectAll();
+ return;
+ }
+
+ stringIdListBox.SelectedIndex = index;
+ stringIdListBox.ScrollIntoView(stringIdListBox.SelectedItem);
+
+ searchfieldTb.Text = textId.ToString("X8");
+ }
+
+ ///
+ /// Opens another window that details extra information about the text that is not normally necessary to have.
+ ///
+ ///
+ ///
+ private void ShowTextInfo(object sender, RoutedEventArgs e)
+ {
+ uint stringId = GetCurrentStringId();
+
+ TextInfoWindow infoWindow = new TextInfoWindow
+ {
+ Owner = Application.Current.MainWindow
+ };
+ infoWindow.Init(_selectedLanguageFormat, stringId, _textDB);
+ infoWindow.Show();
+
+ _closingHandler.AddChildWindow(infoWindow);
+ }
+
+ ///
+ /// Returns the id of the currently selected text, or zero 0, if no text is currently selected.
+ ///
+ ///
+ private uint GetCurrentStringId()
+ {
+ int selectedIndex = stringIdListBox.SelectedIndex;
+ return selectedIndex >= 0 && selectedIndex < _textIdsList.Count ? _textIdsList[selectedIndex] : 0;
+ }
+
+ ///
+ /// Shows the edit window, taking the results into the currently displayed entries.
+ ///
+ ///
+ ///
+ private void ShowAddEditWindow(object sender, RoutedEventArgs e)
+ {
+
+ uint stringId = GetCurrentStringId();
+ AddEditWindow editWindow = new AddEditWindow(_textDB, _selectedLanguageFormat)
+ {
+ Owner = Application.Current.MainWindow
+ };
+ editWindow.Init(stringId);
+
+ bool? save = editWindow.ShowDialog();
+ if (save.HasValue && save.Value)
+ {
+ Tuple saveValue = editWindow.SaveValue;
+
+ // textId is not necessarily the stringId originally given to the dialog!
+ uint textId = saveValue.Item1;
+ string text = saveValue.Item2;
+
+ string entry = textId.ToString("X8") + " - " + text;
+
+ int entryIndex = _textIdsList.IndexOf(textId);
+
+ if (entryIndex < 0)
+ {
+ stringIdListBox.Items.Add(entry);
+ _textIdsList.Add(textId);
+ }
+ else
+ {
+ stringIdListBox.Items[entryIndex] = entry;
+ }
+
+ SearchTextId(textId);
+ }
+ }
+
+ ///
+ /// Removes / Reverts the selected text.
+ ///
+ ///
+ ///
+ private void Remove(object sender, RoutedEventArgs e)
+ {
+
+ int index = stringIdListBox.SelectedIndex;
+
+ if(index < 0 || index >= _textIdsList.Count)
+ {
+ // not sure how this should be possible...
+ App.Logger.LogWarning("Entered impossible state : Remove Operation did not complete!");
+ return;
+ }
+
+ uint textId = _textIdsList[index];
+
+ _textDB.RevertText(_selectedLanguageFormat, textId);
+
+ string text = _textDB.FindText(_selectedLanguageFormat, textId);
+
+ if(text!= null)
+ {
+ string entry = textId.ToString("X8") + " - " + text;
+ stringIdListBox.Items[index] = entry;
+ }
+ else
+ {
+ stringIdListBox.Items.RemoveAt(index);
+ _textIdsList.RemoveAt(index);
+ }
+
+ SearchTextId(textId);
+ }
+
+ private void SelectLanguage(object sender, SelectionChangedEventArgs e)
+ {
+
+ string newLanguageFormat = (string)languageSelectorCb.SelectedItem;
+
+ if( !_selectedLanguageFormat.Equals(newLanguageFormat) )
+ {
+ _selectedLanguageFormat = newLanguageFormat;
+
+ ReLoadTexts(sender, e);
+ }
+ }
+
+ private void ReLoadTexts(object sender, RoutedEventArgs e)
+ {
+ _textIdsList.Clear();
+ stringIdListBox.Items.Clear();
+
+ LoadTexts(sender, e);
+ }
+
+ private void ShowSearchDialog(object sender, RoutedEventArgs e)
+ {
+
+ if(stringIdListBox != null && stringIdListBox.Items.Count>0)
+ {
+ SearchFindWindow searchWindow = new SearchFindWindow(stringIdListBox);
+ searchWindow.Show();
+
+ _closingHandler.AddChildWindow(searchWindow);
+ }
+ }
+
+ private void Export(object sender, RoutedEventArgs e)
+ {
+ XmlExporter.Export(_textDB, _selectedLanguageFormat);
+ }
+
+ private void Import(object sender, RoutedEventArgs e)
+ {
+ XmlImporter.Import(_textDB);
+
+ ReLoadTexts(sender, e);
+ }
+
+ public override void Closed()
+ {
+ _closingHandler.OnEditorClose();
+ }
+
+ #region -- Closing Handler --
+ internal class ClosingHandler
+ {
+
+ private readonly List nonModalChildren = new List();
+
+ public void AddChildWindow(Window nonModalWindow)
+ {
+ nonModalChildren.Add(nonModalWindow);
+ nonModalWindow.Closed += OnChildClose;
+ }
+
+ public void OnChildClose(object sender, EventArgs e)
+ {
+ nonModalChildren.Remove((Window) sender);
+ }
+
+ public void OnEditorClose()
+ {
+ foreach(Window childWindow in nonModalChildren)
+ {
+ childWindow.Closed -= OnChildClose;
+ childWindow.Close();
+ }
+ }
+ }
+ #endregion
+
+ }
+}
diff --git a/Plugins/BiowareLocalizationPlugin/Controls/ImportTargetDialog.xaml b/Plugins/BiowareLocalizationPlugin/Controls/ImportTargetDialog.xaml
new file mode 100644
index 000000000..ccbc67e09
--- /dev/null
+++ b/Plugins/BiowareLocalizationPlugin/Controls/ImportTargetDialog.xaml
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Plugins/BiowareLocalizationPlugin/Controls/ImportTargetDialog.xaml.cs b/Plugins/BiowareLocalizationPlugin/Controls/ImportTargetDialog.xaml.cs
new file mode 100644
index 000000000..62dd61755
--- /dev/null
+++ b/Plugins/BiowareLocalizationPlugin/Controls/ImportTargetDialog.xaml.cs
@@ -0,0 +1,276 @@
+using BiowareLocalizationPlugin.ExportImport;
+using Frosty.Controls;
+using FrostySdk;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Windows;
+
+namespace BiowareLocalizationPlugin.Controls
+{
+ ///
+ /// Interaktionslogik für ImportTargetDialog.xaml
+ ///
+ public partial class ImportTargetDialog : FrostyDockableWindow
+ {
+
+ ///
+ /// Both ME:A and DA:I have this with the language as part of their (or most) text resources.
+ ///
+ private static readonly string TEXTTABLE_PATTERN = "/texttable/[a-z]+/";
+ private static readonly string TEXTTABLE_EN_PATH = "/texttable/en/";
+
+ // These resources change name depending on whether english or any other localization is used.
+ private static readonly string GLOBALMASTER = "globalmaster";
+ private static readonly string GLOBALTRANSLATED = "globaltranslated";
+
+ public TextFile SaveValue { get; set; }
+
+ public ObservableCollection GridSource = new ObservableCollection();
+
+ public List TargetResourceList { get; } = new List();
+
+ private readonly BiowareLocalizedStringDatabase _textDb;
+ private readonly TextFile _importTextFile;
+
+ private string _selectedImportLanguageFormat;
+
+ public ImportTargetDialog(BiowareLocalizedStringDatabase textDB, TextFile textFile)
+ {
+ InitializeComponent();
+ Owner = Application.Current.MainWindow;
+
+ _textDb = textDB;
+ _importTextFile = textFile;
+
+ // TODO disable import button while not all texts are set!
+
+ InitLanguage(_importTextFile.LanguageFormat);
+ InitResources();
+ }
+
+ private void InitLanguage(string importLanguage)
+ {
+ languageTextBox.Text = importLanguage;
+
+ var availableLanguages = _textDb.GellAllLanguages();
+ languageSelector.ItemsSource = availableLanguages;
+
+ languageSelector.SelectionChanged += LanguageFormatChanged;
+
+ // index of uses compare, contains equality
+ int languageIndex = availableLanguages.ToList().IndexOf(importLanguage);
+ if (languageIndex >= 0)
+ {
+ languageSelector.SelectedIndex = languageIndex;
+ }
+ }
+
+ private void InitResources()
+ {
+ List importResourcesList = GetTextResources(_importTextFile);
+
+ FillTargetResourceList();
+
+ string targetTextTablePath = GetTargetTexttableSubstring();
+
+ GridSource.Clear();
+ foreach (string importResourceName in importResourcesList)
+ {
+
+ GridSource.Add(new ResourceRow()
+ {
+ TextResource = importResourceName,
+ TargetResource = GetTargetResourceFor(importResourceName, targetTextTablePath)
+ });
+ }
+
+ datagrid.ItemsSource = GridSource;
+ targetResources.ItemsSource = TargetResourceList;
+ }
+
+
+ private static List GetTextResources(TextFile textFile)
+ {
+ HashSet importResourcesSet = new HashSet();
+ foreach (var text in textFile.Texts)
+ {
+ importResourcesSet.UnionWith(text.Resources);
+ }
+
+ List importResourcesList = importResourcesSet.ToList();
+ importResourcesList.Sort();
+ return importResourcesList;
+ }
+
+ private void FillTargetResourceList()
+ {
+ TargetResourceList.Clear();
+
+ if(_selectedImportLanguageFormat != null)
+ {
+ TargetResourceList.AddRange(GetTargetResourceList(_selectedImportLanguageFormat));
+ }
+ }
+
+ private List GetTargetResourceList(string languageFormat)
+ {
+ return _textDb.GetAllResourceNames(languageFormat).ToList();
+ }
+
+ private string GetTargetResourceFor(string importResource, string targetTextTablePath)
+ {
+ if (TargetResourceList.Count > 0)
+ {
+ // indexOf uses equals instead of identity compare - which should result in less false negatives
+ int index = TargetResourceList.IndexOf(importResource);
+ if (index >= 0)
+ {
+ return TargetResourceList[index];
+ }
+
+ // MEA and DAI have the localization files (or most of them) within paths such as /dlc/../texttable/{language_short}/...
+ // -> Try to replace substring '/texttable/{language_short}/' with whatever is appropriate...
+ string importTextTablePath = GetTextTableSubString(importResource);
+ if (importTextTablePath != null && targetTextTablePath != null)
+ {
+ return FindWithReplacedPathInfo(importResource, importTextTablePath, targetTextTablePath);
+ }
+ }
+
+ return null;
+ }
+
+ private string GetTargetTexttableSubstring()
+ {
+ if (TargetResourceList.Count > 0)
+ {
+ var firstEntry = TargetResourceList[0];
+ return GetTextTableSubString(firstEntry);
+ }
+ return null;
+ }
+
+ private static string GetTextTableSubString(string textDonor)
+ {
+ Match match = Regex.Match(textDonor, TEXTTABLE_PATTERN);
+ return match.Success ? match.Value : null;
+ }
+
+ private string FindWithReplacedPathInfo(string importResource, string importTextTablePath, string targetTextTablePath)
+ {
+ string targetResource = importResource.Replace(importTextTablePath, targetTextTablePath);
+
+ // globalmaster resources in english are named globaltranslated for other languages
+ // for ME:A the globaltranslated resources are also in a subfolder, whereas globalmaster is not!
+ if (importResource.Contains(GLOBALMASTER) && !targetTextTablePath.Equals(TEXTTABLE_EN_PATH))
+ {
+ targetResource = targetResource.Replace(GLOBALMASTER, GLOBALTRANSLATED);
+
+ if (ProfilesLibrary.DataVersion == ((int)ProfileVersion.MassEffectAndromeda))
+ {
+ targetResource = targetResource.Replace("/game/globaltranslated", "/game/localization/config/globaltranslated");
+ }
+ }
+ else if (importResource.Contains(GLOBALTRANSLATED) && targetTextTablePath.Equals(TEXTTABLE_EN_PATH))
+ {
+ targetResource = targetResource.Replace(GLOBALTRANSLATED, GLOBALMASTER);
+
+ if (ProfilesLibrary.DataVersion == ((int)ProfileVersion.MassEffectAndromeda))
+ {
+ targetResource = targetResource.Replace("/game/localization/config/globalmaster", "/game/globalmaster");
+ }
+ }
+
+ int index = TargetResourceList.IndexOf(targetResource);
+
+ return index >= 0 ? TargetResourceList[index] : null;
+ }
+
+ public void LanguageFormatChanged(object sender, RoutedEventArgs e)
+ {
+ _selectedImportLanguageFormat = (string)languageSelector.SelectedItem;
+
+ FillTargetResourceList();
+
+ string targetTextTablePath = GetTargetTexttableSubstring();
+ foreach (ResourceRow entry in GridSource)
+ {
+ entry.TargetResource = GetTargetResourceFor(entry.TextResource, targetTextTablePath);
+ }
+ }
+
+ public void Import(object sender, RoutedEventArgs e)
+ {
+
+ Dictionary resourceTranslation = new Dictionary();
+ foreach (ResourceRow resourceRow in GridSource)
+ {
+
+ if (resourceRow.TargetResource == null)
+ {
+
+ string msg = string.Format("No target resource for <{0}> selected!", resourceRow.TextResource);
+ MessageBox.Show(msg, "Missing Entry", MessageBoxButton.OK);
+
+ return;
+ }
+
+ resourceTranslation.Add(resourceRow.TextResource, resourceRow.TargetResource);
+ }
+
+ TextFile updatedTarget = new TextFile()
+ {
+ LanguageFormat = _selectedImportLanguageFormat
+ };
+
+ List targetRepesentations = new List();
+ foreach (TextRepresentation importRepresentation in _importTextFile.Texts)
+ {
+ TextRepresentation updatedRepresentation = new TextRepresentation()
+ {
+ TextId = importRepresentation.TextId,
+ Text = importRepresentation.Text,
+ Resources = importRepresentation.Resources.Select(r => resourceTranslation[r]).ToArray()
+ };
+
+ targetRepesentations.Add(updatedRepresentation);
+ }
+
+ updatedTarget.Texts = targetRepesentations.ToArray();
+
+ SaveValue = updatedTarget;
+ DialogResult = true;
+
+ Close();
+ }
+
+ public void Abort(object sender, RoutedEventArgs e)
+ {
+ SaveValue = null;
+ DialogResult = false;
+ Close();
+ }
+
+ }
+
+ #region -- ui data field stuff --
+ public class ResourceRow : INotifyPropertyChanged
+ {
+ public string TextResource { get; set; }
+
+ private string _targetResource;
+ public string TargetResource {
+ get { return _targetResource; }
+
+ set {
+ _targetResource = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(TargetResource)));
+ } }
+
+ public event PropertyChangedEventHandler PropertyChanged;
+ }
+ #endregion
+}
diff --git a/Plugins/BiowareLocalizationPlugin/Controls/ListBoxUtils.cs b/Plugins/BiowareLocalizationPlugin/Controls/ListBoxUtils.cs
new file mode 100644
index 000000000..d3aa53c1f
--- /dev/null
+++ b/Plugins/BiowareLocalizationPlugin/Controls/ListBoxUtils.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Text;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+
+namespace BiowareLocalizationPlugin.Controls
+{
+ ///
+ /// Class with some utility methods for handling io out of listboxes
+ ///
+ public class ListBoxUtils
+ {
+
+ private ListBoxUtils()
+ {
+ // prevent instantiation
+ }
+
+ public static void SortListIntoListBox(IEnumerable entries, ListBox listBox) where T : IComparable
+ {
+ var entryList = new List(entries);
+ entryList.Sort();
+
+ foreach(T entry in entryList)
+ {
+ listBox.Items.Add(entry);
+ }
+ }
+
+ ///
+ /// Called to copy the selected values of either listbox to the clipboard.
+ ///
+ /// The originator of the event
+ /// The event
+ public static void CopySelectionToClipboard(object listBoxEventOriginator, ExecutedRoutedEventArgs eventArgs)
+ {
+ ListBox origin = (ListBox)listBoxEventOriginator;
+
+ IList selection = origin.SelectedItems;
+
+ if (selection.Count > 0)
+ {
+ StringBuilder sb = new StringBuilder();
+ foreach (object selected in selection)
+ {
+ sb.AppendLine(selected.ToString());
+ }
+
+ Clipboard.SetText(sb.ToString());
+ }
+ }
+ }
+}
diff --git a/Plugins/BiowareLocalizationPlugin/Controls/ResourceSelectionWindow.xaml b/Plugins/BiowareLocalizationPlugin/Controls/ResourceSelectionWindow.xaml
new file mode 100644
index 000000000..2bbac826b
--- /dev/null
+++ b/Plugins/BiowareLocalizationPlugin/Controls/ResourceSelectionWindow.xaml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Plugins/BiowareLocalizationPlugin/Controls/ResourceSelectionWindow.xaml.cs b/Plugins/BiowareLocalizationPlugin/Controls/ResourceSelectionWindow.xaml.cs
new file mode 100644
index 000000000..e364020ed
--- /dev/null
+++ b/Plugins/BiowareLocalizationPlugin/Controls/ResourceSelectionWindow.xaml.cs
@@ -0,0 +1,50 @@
+using Frosty.Controls;
+using System.Collections.Generic;
+using System.Windows;
+using System.Windows.Controls;
+
+namespace BiowareLocalizationPlugin.Controls
+{
+ ///
+ /// Interaktionslogik für ResourceSelectionWindow.xaml
+ ///
+ [TemplatePart(Name = "resourcesListBox", Type =typeof(ListBox))]
+ public partial class ResourceSelectionWindow : FrostyDockableWindow
+ {
+
+ ///
+ /// The list of selected resources that is the return value of this dialog.
+ ///
+ public List SelectedResources { get; private set; }
+
+ ///
+ /// Creates a new resource selection dialog.
+ ///
+ /// The db of localized strings.
+ /// The current language format.
+ /// The list of already assigned resources, that do not need to be displayed or selectable here.
+ public ResourceSelectionWindow(IEnumerable resourcesToShow)
+ {
+ InitializeComponent();
+ Loaded += (s, e) => ListBoxUtils.SortListIntoListBox(resourcesToShow, resourcesListBox);
+ }
+
+ private void Save(object sender, RoutedEventArgs e)
+ {
+
+ SelectedResources = new List();
+ foreach(string selected in resourcesListBox.SelectedItems)
+ {
+ SelectedResources.Add(selected);
+ }
+ DialogResult = true;
+ Close();
+ }
+
+ private void Cancel(object sender, RoutedEventArgs e)
+ {
+ DialogResult = false;
+ Close();
+ }
+ }
+}
diff --git a/Plugins/BiowareLocalizationPlugin/Controls/SearchFindWindow.xaml b/Plugins/BiowareLocalizationPlugin/Controls/SearchFindWindow.xaml
new file mode 100644
index 000000000..ff68d6882
--- /dev/null
+++ b/Plugins/BiowareLocalizationPlugin/Controls/SearchFindWindow.xaml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Plugins/BiowareLocalizationPlugin/Controls/SearchFindWindow.xaml.cs b/Plugins/BiowareLocalizationPlugin/Controls/SearchFindWindow.xaml.cs
new file mode 100644
index 000000000..c17c15b51
--- /dev/null
+++ b/Plugins/BiowareLocalizationPlugin/Controls/SearchFindWindow.xaml.cs
@@ -0,0 +1,83 @@
+using Frosty.Controls;
+using System;
+using System.Windows;
+using System.Windows.Controls;
+
+namespace BiowareLocalizationPlugin.Controls
+{
+ ///
+ /// This is a non blocking dialog used to search for strings in the currently displayed texts.
+ ///
+ public partial class SearchFindWindow : FrostyDockableWindow
+ {
+
+ // this is the list box from the main edit window - we search and select directly on there!
+ private readonly ListBox _mainWindowTextSelectionBox;
+
+ public SearchFindWindow(ListBox stringSelectionBox)
+ {
+ InitializeComponent();
+ Owner = Application.Current.MainWindow;
+ _mainWindowTextSelectionBox = stringSelectionBox;
+
+ searchTextField.Focus();
+ }
+
+ private void Close(object sender, RoutedEventArgs e)
+ {
+ Close();
+ }
+
+ ///
+ /// Searches the text, if found the entry is selected and scrolled into view.
+ ///
+ ///
+ ///
+ private void Search(object sender, RoutedEventArgs e)
+ {
+
+ string searchText = searchTextField.Text;
+ if(searchText == null || searchText.Length == 0)
+ {
+ return;
+ }
+
+ bool? nullableSearchBackwards = backSearchCB.IsChecked;
+ bool searchBackwards = nullableSearchBackwards.HasValue && nullableSearchBackwards.Value;
+
+ bool? nullableSearchCaseSensitive = caseSensitiveSearchCB.IsChecked;
+ bool searchCaseSensitive = nullableSearchCaseSensitive.HasValue && nullableSearchCaseSensitive.Value;
+
+ Func updFctn;
+ if( searchBackwards)
+ {
+ updFctn = (int i) => --i;
+ }
+ else
+ {
+ updFctn = (int j) => ++j;
+ }
+
+ StringComparison comparisonType = searchCaseSensitive ? StringComparison.InvariantCulture : StringComparison.InvariantCultureIgnoreCase;
+
+ int searchIndex = _mainWindowTextSelectionBox.SelectedIndex;
+ searchIndex = updFctn(searchIndex);
+
+ while(searchIndex >=0 && searchIndex < _mainWindowTextSelectionBox.Items.Count)
+ {
+ string queriedText = (string)_mainWindowTextSelectionBox.Items[searchIndex];
+
+ // first 8 chars are the text Id, followed by 3 chars delimiter -> text starts at index 10
+ int searchTextPosition = queriedText.IndexOf(searchText, 10, comparisonType);
+ if(searchTextPosition > 0)
+ {
+ _mainWindowTextSelectionBox.SelectedIndex = searchIndex;
+ _mainWindowTextSelectionBox.ScrollIntoView(_mainWindowTextSelectionBox.SelectedItem);
+ return;
+ }
+
+ searchIndex = updFctn(searchIndex);
+ }
+ }
+ }
+}
diff --git a/Plugins/BiowareLocalizationPlugin/Controls/TextInfoWindow.xaml b/Plugins/BiowareLocalizationPlugin/Controls/TextInfoWindow.xaml
new file mode 100644
index 000000000..e0cb3b58e
--- /dev/null
+++ b/Plugins/BiowareLocalizationPlugin/Controls/TextInfoWindow.xaml
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Plugins/BiowareLocalizationPlugin/Controls/TextInfoWindow.xaml.cs b/Plugins/BiowareLocalizationPlugin/Controls/TextInfoWindow.xaml.cs
new file mode 100644
index 000000000..2fb3da14e
--- /dev/null
+++ b/Plugins/BiowareLocalizationPlugin/Controls/TextInfoWindow.xaml.cs
@@ -0,0 +1,85 @@
+using BiowareLocalizationPlugin.LocalizedResources;
+using Frosty.Controls;
+using System.Collections.Generic;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+
+namespace BiowareLocalizationPlugin.Controls
+{
+ ///
+ /// This is a popup window that displays in which resources the text of the selected id is found, it also displays what other text ids are stored at the same position(s) in those resources.
+ ///
+ [TemplatePart(Name = "resourceList", Type = typeof(ListBox))]
+ [TemplatePart(Name = "stringIdList", Type = typeof(ListBox))]
+ [TemplatePart(Name = "charactersList", Type = typeof(ListBox))]
+ [TemplatePart(Name = "PART_OkButton", Type = typeof(Button))]
+ public partial class TextInfoWindow : FrostyDockableWindow
+ {
+
+ public TextInfoWindow()
+ {
+ InitializeComponent();
+ Owner = Application.Current.MainWindow;
+ }
+
+ public void Init(string languageFormat, uint textId, BiowareLocalizedStringDatabase localizedStringsDb)
+ {
+
+ Title = "TextInfo: " + textId.ToString("X8") + " - " + localizedStringsDb.GetString(textId);
+
+ IEnumerable localizedResources = localizedStringsDb.GetAllLocalizedStringResourcesForTextId(languageFormat, textId);
+
+ // multiple text ids can occur in multiple places
+ var allResourceNames = new List();
+ var allUniqueTextIds = new SortedSet();
+ SortedSet allSupportedCharacters = null;
+ foreach(LocalizedStringResource resource in localizedResources)
+ {
+ // add the resource name...
+ allResourceNames.Add(resource.Name);
+
+ // ...add the other texts ids that share the position and such text...
+ IEnumerable textIds = resource.GetAllTextIdsAtPositionOf(textId);
+ foreach(string anotherTextId in textIds)
+ {
+ allUniqueTextIds.Add(anotherTextId);
+ }
+
+ // ... add or rather retain the limited set of characters supported in all the of the resources
+ var supporedCharacters = resource.GetSupportedCharacters();
+ if(allSupportedCharacters == null)
+ {
+ allSupportedCharacters = new SortedSet(supporedCharacters);
+ }
+ else
+ {
+ allSupportedCharacters.IntersectWith(supporedCharacters);
+ }
+ }
+
+ ListBoxUtils.SortListIntoListBox(allResourceNames, resourceList);
+ ListBoxUtils.SortListIntoListBox(allUniqueTextIds, stringIdList);
+
+ if(allSupportedCharacters != null)
+ {
+ ListBoxUtils.SortListIntoListBox(allSupportedCharacters, charactersList);
+ }
+ }
+
+ private void OkButtonClicked(object sender, RoutedEventArgs e)
+ {
+ Close();
+ }
+
+ ///
+ /// Called to copy the selected values of either listbox to the clipboard.
+ ///
+ ///
+ ///
+ private void CopySelection(object sender, ExecutedRoutedEventArgs e)
+ {
+ ListBoxUtils.CopySelectionToClipboard(sender, e);
+ }
+ }
+}
diff --git a/Plugins/BiowareLocalizationPlugin/ExportImport/TextRepresentation.cs b/Plugins/BiowareLocalizationPlugin/ExportImport/TextRepresentation.cs
new file mode 100644
index 000000000..f8ea67258
--- /dev/null
+++ b/Plugins/BiowareLocalizationPlugin/ExportImport/TextRepresentation.cs
@@ -0,0 +1,28 @@
+using System;
+using System.Xml.Serialization;
+
+namespace BiowareLocalizationPlugin.ExportImport
+{
+ [Serializable()]
+ [XmlRootAttribute("TextFile", Namespace = "", IsNullable = false)]
+ public class TextFile
+ {
+
+ public string LanguageFormat { get; set; }
+
+ [XmlArray("Texts")]
+ [XmlArrayItem("TextRepresentation")]
+ public TextRepresentation[] Texts { get; set; }
+ }
+
+ [Serializable()]
+ public class TextRepresentation
+ {
+ public string TextId { get; set; }
+ public string Text { get; set; }
+
+ [XmlArray("Resources")]
+ [XmlArrayItem("TextResource")]
+ public string[] Resources { get; set; }
+ }
+}
diff --git a/Plugins/BiowareLocalizationPlugin/ExportImport/XmlExporter.cs b/Plugins/BiowareLocalizationPlugin/ExportImport/XmlExporter.cs
new file mode 100644
index 000000000..e26dfa4d8
--- /dev/null
+++ b/Plugins/BiowareLocalizationPlugin/ExportImport/XmlExporter.cs
@@ -0,0 +1,82 @@
+
+using Frosty.Core;
+using Frosty.Core.Controls;
+using Frosty.Core.Windows;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Xml;
+using System.Xml.Serialization;
+
+namespace BiowareLocalizationPlugin.ExportImport
+{
+ public class XmlExporter
+ {
+ public static void Export(BiowareLocalizedStringDatabase textDB, string languageFormat)
+ {
+
+ FrostySaveFileDialog saveDialog = new FrostySaveFileDialog("Save Custom Texts", "*.xml (XML File)|*.xml", "LocalizedTexts_", languageFormat+"_texts");
+ if (saveDialog.ShowDialog())
+ {
+ FrostyTaskWindow.Show("Exporting Custom Texts", "", (task) =>
+ {
+
+ TextFile textFile = FillTextFile(textDB, languageFormat);
+
+ XmlSerializer serializer = new XmlSerializer(typeof(TextFile));
+
+ XmlWriterSettings settings = new XmlWriterSettings()
+ {
+ Encoding = Encoding.UTF8,
+ Indent = true
+ };
+
+ using (FileStream fileStream = new FileStream(saveDialog.FileName, FileMode.Create))
+ {
+ using (XmlWriter writer = XmlWriter.Create(fileStream, settings))
+ {
+ serializer.Serialize(writer, textFile, new XmlSerializerNamespaces(new[] { XmlQualifiedName.Empty }));
+ }
+ }
+ });
+
+ App.Logger.Log("Custom texts saved to {0}", saveDialog.FileName);
+ }
+ }
+
+ private static TextFile FillTextFile(BiowareLocalizedStringDatabase textDB, string languageFormat)
+ {
+ TextFile textFile = new TextFile
+ {
+ LanguageFormat = languageFormat
+ };
+
+ List textList = new List();
+
+ List textIdList = textDB.GetAllModifiedTextsIds(languageFormat).ToList();
+ textIdList.Sort();
+ foreach (uint textId in textIdList)
+ {
+ TextRepresentation textRepresentation = new TextRepresentation()
+ {
+ TextId = textId.ToString("X8"),
+ Text = textDB.GetText(languageFormat, textId)
+ };
+
+ List resourceNames = new List();
+ foreach(var resource in textDB.GetAllLocalizedStringResourcesForTextId(languageFormat, textId))
+ {
+ resourceNames.Add(resource.Name);
+ }
+ textRepresentation.Resources = resourceNames.ToArray();
+
+ textList.Add(textRepresentation);
+ }
+
+ textFile.Texts = textList.ToArray();
+
+ return textFile;
+ }
+ }
+}
diff --git a/Plugins/BiowareLocalizationPlugin/ExportImport/XmlImporter.cs b/Plugins/BiowareLocalizationPlugin/ExportImport/XmlImporter.cs
new file mode 100644
index 000000000..2dbb65b58
--- /dev/null
+++ b/Plugins/BiowareLocalizationPlugin/ExportImport/XmlImporter.cs
@@ -0,0 +1,150 @@
+
+
+using BiowareLocalizationPlugin.Controls;
+using Frosty.Core;
+using Frosty.Core.Controls;
+using Frosty.Core.Windows;
+using System;
+using System.Globalization;
+using System.IO;
+using System.Security;
+using System.Xml.Serialization;
+
+namespace BiowareLocalizationPlugin.ExportImport
+{
+ public class XmlImporter
+ {
+
+
+ public static void Import(BiowareLocalizedStringDatabase textDb)
+ {
+ FrostyOpenFileDialog openDialog = new FrostyOpenFileDialog("Import Custom Texts", "*.xml (XML File)|*.xml", "LocalizedTexts_");
+ if (openDialog.ShowDialog())
+ {
+
+ string fileName = openDialog.FileName;
+ TextFile textFile = ReadTextFile(fileName);
+
+ if(textFile != null)
+ {
+ App.Logger.Log("Importing localized texts form File <{0}>", fileName);
+ textFile = AdaptTextFile(textDb, textFile);
+ if (textFile != null)
+ {
+ ImportTexts(textDb, textFile);
+ }
+ }
+ }
+ }
+
+ ///
+ /// Step one: Try to parse the file - if there are problems they likely occur here, resulting in an exception that rolls back the whole process.
+ ///
+ ///
+ ///
+ private static TextFile ReadTextFile(string fileUri)
+ {
+
+ TextFile textFile = null;
+ FrostyTaskWindow.Show("Reading Custom Texts", "", (task) =>
+ {
+
+ XmlSerializer deserializer = new XmlSerializer(typeof(TextFile));
+ try
+ {
+ using (FileStream stream = new FileStream(fileUri, FileMode.Open, FileAccess.Read))
+ {
+ textFile = deserializer.Deserialize(stream) as TextFile;
+ }
+ }
+ catch(Exception e) when
+ (
+ e is IOException
+ || e is SecurityException
+ || e is UnauthorizedAccessException
+ || e is InvalidOperationException
+ )
+ {
+ textFile = null;
+ App.Logger.LogError("Could not read the given file <{0}> or deserialize it!", fileUri);
+ App.Logger.LogError("Exception was: <{0}> {1}", e.GetType(), e.Message);
+ }
+ } );
+
+ return textFile;
+ }
+
+ ///
+ /// Step two: It will likely be necessary to replace certain entries during import, like language, or resource names. This is the place to do that.
+ ///
+ ///
+ /// The adapted textfile or null, if the import should be aborted.
+ private static TextFile AdaptTextFile(BiowareLocalizedStringDatabase textDb, TextFile originalFile)
+ {
+
+ ImportTargetDialog importDialog = new ImportTargetDialog(textDb, originalFile);
+
+ bool? alteredResult = importDialog.ShowDialog();
+ if(alteredResult == true)
+ {
+ return importDialog.SaveValue;
+ }
+
+ App.Logger.Log("Import Aborted");
+ return null;
+
+ }
+
+ ///
+ /// Step three: Actually import the parsed texts. There still might be errors preventing certain texts, but that should only prevent individual texts from importing properly.
+ ///
+ ///
+ ///
+ private static void ImportTexts(BiowareLocalizedStringDatabase textDb, TextFile textFile)
+ {
+
+ FrostyTaskWindow.Show("Importing Custom Texts", "", (task) =>
+ {
+
+ string language = textFile.LanguageFormat;
+
+ try
+ {
+ foreach(TextRepresentation textRepresentation in textFile.Texts)
+ {
+ ImportText(textDb, language, textRepresentation);
+ }
+
+ App.Logger.Log("Texts imported into <{0}>", language);
+ }
+ catch(InvalidOperationException e)
+ {
+ // this is thrown e.g., if the language does not exist in the local game copy
+ App.Logger.LogError("Could not import the texts: {0}", e.Message);
+ }
+
+ });
+ }
+
+ private static void ImportText(BiowareLocalizedStringDatabase textDb, string language, TextRepresentation textRepresentation)
+ {
+ string stringIdAsText = textRepresentation.TextId;
+ bool canRead = uint.TryParse(stringIdAsText, NumberStyles.HexNumber, null, out uint textId);
+ if (!canRead)
+ {
+ App.Logger.LogWarning("Text with id <{0}> could not be imported! Text Id cannot be parsed!", stringIdAsText);
+ return;
+ }
+
+ try
+ {
+ textDb.SetText(language, textRepresentation.Resources, textId, textRepresentation.Text);
+ }
+ catch (ArgumentException e)
+ {
+ // this is thrown if the resource does not exist
+ App.Logger.LogError("Text with id <{0}> could not be imported: {1}", stringIdAsText, e.Message);
+ }
+ }
+ }
+}
diff --git a/Plugins/BiowareLocalizationPlugin/LocalizedResources/LanguageTextsDB.cs b/Plugins/BiowareLocalizationPlugin/LocalizedResources/LanguageTextsDB.cs
new file mode 100644
index 000000000..bc6d7889f
--- /dev/null
+++ b/Plugins/BiowareLocalizationPlugin/LocalizedResources/LanguageTextsDB.cs
@@ -0,0 +1,416 @@
+using Frosty.Core;
+using FrostySdk.Managers;
+using System;
+using System.Collections.Generic;
+
+namespace BiowareLocalizationPlugin.LocalizedResources
+{
+
+ ///
+ /// Most of the actual get/set and find Texts methods per language are located in here. This class then forwards calls to the correct LocalizedStringResource instances.
+ ///
+ public class LanguageTextsDB
+ {
+
+ #region -- HelperClass
+ ///
+ /// Stores information about which resources a text is located in in the vanilla game, and which resources where added by mods.
+ ///
+ private class TextLocation
+ {
+ ///
+ /// The id of the specific text.
+ ///
+ public uint TextId { get; }
+
+ ///
+ /// The resource names with occurences of that text in the vanilla game.
+ ///
+ public ISet DefaultResourceNames { get; }
+
+ ///
+ /// The resource names where a mod added the text to.
+ ///
+ public ISet AddedResourceNames = new HashSet();
+
+ ///
+ /// Constructor
+ ///
+ ///
+ public TextLocation(uint textId)
+ {
+ TextId = textId;
+ DefaultResourceNames = new HashSet();
+ }
+
+ ///
+ /// Constructor
+ ///
+ ///
+ ///
+ public TextLocation(uint textId, string defaultResource)
+ {
+ TextId = textId;
+ DefaultResourceNames = new HashSet() { defaultResource };
+ }
+
+ ///
+ /// Constructor
+ ///
+ ///
+ ///
+ /// /// true if the resourcename belongs to a modified resource, false if it is amodified resource
+ public TextLocation(uint textId, string resourceName, bool isAddedResource)
+ {
+ TextId = textId;
+ DefaultResourceNames = new HashSet();
+
+ if(isAddedResource)
+ {
+ AddedResourceNames.Add(resourceName);
+ }
+ else
+ {
+ DefaultResourceNames.Add(resourceName);
+ }
+ }
+
+ ///
+ /// Adds the given resourcename to the list of added resources, if it is not already present.
+ ///
+ ///
+ public void AddLocation(string resourceName)
+ {
+ if(!DefaultResourceNames.Contains(resourceName))
+ {
+ AddedResourceNames.Add(resourceName);
+ }
+ }
+ }
+
+ // Enum specifying which resource location to retrieve for a text id.
+ private enum ResourceLocationRequest { DEFAULT_ONLY, ADDED, ALL };
+ #endregion
+
+ ///
+ /// The name of the language format in the game.
+ ///
+ public string LanguageIdentifier { get; private set; }
+
+ ///
+ /// This dictionary contains all the found text ids as well as their text.
+ ///
+ private readonly Dictionary _textsForId = new Dictionary();
+
+ ///
+ /// This dictionary contains all resource assets where a string was found.
+ ///
+ private readonly Dictionary _resourcesForStringId = new Dictionary();
+
+ ///
+ /// The resources available
+ ///
+ private readonly SortedDictionary _resourcesByName = new SortedDictionary();
+
+ public void Init(string languageName, IEnumerable bundlePaths)
+ {
+ LanguageIdentifier = languageName;
+ LoadTextResources(bundlePaths);
+ }
+
+ ///
+ /// Loads the localizedSTringResources from the given budles.
+ ///
+ ///
+ private void LoadTextResources(IEnumerable bundlePaths)
+ {
+
+ foreach (string superBundlePathPart in bundlePaths)
+ {
+ string superBundlePath = "win32/" + superBundlePathPart.ToLowerInvariant();
+ foreach (ResAssetEntry resEntry in App.AssetManager.EnumerateRes(resType: (uint)ResourceType.LocalizedStringResource, bundleSubPath: superBundlePath))
+ {
+
+ LocalizedStringResource resource = App.AssetManager.GetResAs(resEntry);
+ if (resource != null)
+ {
+
+ _resourcesByName[resource.Name] = resource;
+ FetchResourceTexts(resource);
+
+ resource.ResourceEventHandlers += (s, e) =>
+ {
+ UpdateResourceTexts((LocalizedStringResource)s);
+ };
+ }
+ }
+ }
+ }
+
+ ///
+ /// Gets all texts in the resource and updates the caches.
+ ///
+ ///
+ private void FetchResourceTexts(LocalizedStringResource resource)
+ {
+ foreach (var entry in resource.GetAllPrimaryTexts())
+ {
+ uint textId = entry.Item1;
+ bool isModifiedText = entry.Item3;
+
+ _textsForId[textId] = entry.Item2;
+
+ bool textLocationExists = _resourcesForStringId.TryGetValue(textId, out TextLocation textlocation);
+
+ if(!textLocationExists)
+ {
+ textlocation = new TextLocation(textId, resource.Name, isModifiedText);
+ _resourcesForStringId.Add(textId, textlocation);
+ }
+ else if (isModifiedText)
+ {
+ textlocation.AddLocation(resource.Name);
+ }
+ else
+ {
+ textlocation.DefaultResourceNames.Add(resource.Name);
+ }
+ }
+ }
+
+ private void UpdateResourceTexts(LocalizedStringResource resource)
+ {
+
+ // need to remove all texts wiht only resource as location...
+ string resourceName = resource.Name;
+
+ // remove via explizit loop, too tired to figure out another way
+ List textIdsToRemove = new List();
+ foreach(TextLocation textloc in _resourcesForStringId.Values)
+ {
+ if(textloc.AddedResourceNames.Remove(resourceName) && textloc.DefaultResourceNames.Count == 0)
+ {
+ if(textloc.AddedResourceNames.Count == 0)
+ {
+ textIdsToRemove.Add(textloc.TextId);
+ }
+ }
+ }
+ foreach(uint toRemove in textIdsToRemove)
+ {
+ _resourcesForStringId.Remove(toRemove);
+ _textsForId.Remove(toRemove);
+ }
+
+ FetchResourceTexts(resource);
+ }
+
+ ///
+ /// Tries to return the text for the given uid. Returns an error message if the text does not exist.
+ /// @see #FindText
+ ///
+ ///
+ ///
+ public string GetText(uint id)
+ {
+ if (!_textsForId.ContainsKey(id))
+ {
+ return id == 0 ? "" : string.Format("Invalid StringId: {0}", id.ToString("X8"));
+ }
+ return _textsForId[id];
+ }
+
+ ///
+ /// Tries to return the text for the given uid, returns null if the textid does not exist.
+ /// @see #GetString
+ ///
+ ///
+ ///
+ public string FindText(uint textId)
+ {
+ bool textExists = _textsForId.TryGetValue(textId, out string text);
+ return textExists ? text : null;
+ }
+
+ ///
+ /// Returns the list of LocalizedStringResource in which the given text id can be found by default.
+ ///
+ /// The text id to look for.
+ /// All resources in which the text id can be found by default.
+ public IEnumerable GetDefaultResourcesForTextId(uint textId)
+ {
+ bool exists = _resourcesForStringId.TryGetValue(textId, out TextLocation textLocation);
+ return exists ? GetResources(textLocation, ResourceLocationRequest.DEFAULT_ONLY) : new List();
+ }
+
+ ///
+ /// Returns the list of LocalizedStringResource in which the given text id can be found.
+ ///
+ /// The text id to look for.
+ /// All resources in which the text id can be found.
+ public IEnumerable GetAllResourcesForTextId(uint textId)
+ {
+ bool exists = _resourcesForStringId.TryGetValue(textId, out TextLocation textLocation);
+ return exists ? GetResources(textLocation, ResourceLocationRequest.ALL) : new List();
+ }
+
+ ///
+ /// Returns the list of LocalizedStringResource in which the given text id was inserted by a mod.
+ ///
+ /// The text id to look for.
+ /// All non default resources in which the text id can be found.
+ public IEnumerable GetAddedResourcesForTextId(uint textId)
+ {
+ bool exists = _resourcesForStringId.TryGetValue(textId, out TextLocation textLocation);
+ return exists ? GetResources(textLocation, ResourceLocationRequest.ADDED) : new List();
+ }
+
+ ///
+ /// Returns all resources from the given TextLocation that match the given resourceLocation- variant.
+ ///
+ ///
+ ///
+ ///
+ private IEnumerable GetResources(TextLocation textLocation, ResourceLocationRequest resourceLocations)
+ {
+ if(ResourceLocationRequest.ALL == resourceLocations || ResourceLocationRequest.DEFAULT_ONLY == resourceLocations)
+ {
+ foreach (string resourceName in textLocation.DefaultResourceNames)
+ {
+ yield return _resourcesByName[resourceName];
+ }
+ }
+
+ if (ResourceLocationRequest.ALL == resourceLocations || ResourceLocationRequest.ADDED == resourceLocations)
+ {
+ foreach(string addedResourceName in textLocation.AddedResourceNames)
+ {
+ yield return _resourcesByName[addedResourceName];
+ }
+ }
+ }
+
+ ///
+ /// Returns the names of all found resources
+ ///
+ ///
+ public IEnumerable GetAllResourceNames()
+ {
+ return _resourcesByName.Keys;
+ }
+
+ ///
+ /// Returns all text ids
+ ///
+ ///
+ public IEnumerable GetAllTextIds()
+ {
+ foreach (uint key in _textsForId.Keys)
+ {
+ yield return key;
+ }
+ }
+
+ public IEnumerable GetAllModifiedTextsIds()
+ {
+ HashSet modifiedTextIds = new HashSet();
+
+ foreach(LocalizedStringResource resource in _resourcesByName.Values)
+ {
+ modifiedTextIds.UnionWith(resource.GetAllModifiedTextsIds());
+ }
+
+ return modifiedTextIds;
+ }
+
+ ///
+ /// Sets a text into a single resource.
+ ///
+ ///
+ ///
+ ///
+ public void SetText(string resourceName, uint textId, string text)
+ {
+
+ bool resourceExists = _resourcesByName.TryGetValue(resourceName, out LocalizedStringResource resource);
+ if(!resourceExists)
+ {
+ throw new InvalidOperationException(string.Format("Resource of name <{0}> does not exist for language <{1}>!", resourceName, LanguageIdentifier));
+ }
+
+ resource.SetText(textId, text);
+
+ bool locExists = _resourcesForStringId.TryGetValue(textId, out TextLocation textLocation);
+ if(!locExists)
+ {
+ textLocation = new TextLocation(textId);
+ _resourcesForStringId.Add(textId, textLocation);
+ }
+ textLocation.AddLocation(resourceName);
+ }
+
+ ///
+ /// Removes a single text from a modified resource.
+ ///
+ ///
+ ///
+ public void RemoveText(string resourceName, uint textId)
+ {
+ LocalizedStringResource resource = _resourcesByName[resourceName];
+ resource.RemoveText(textId);
+
+ TextLocation textLocation = _resourcesForStringId[textId];
+ textLocation.AddedResourceNames.Remove(resourceName);
+
+ if(textLocation.AddedResourceNames.Count == 0 && textLocation.DefaultResourceNames.Count == 0)
+ {
+ _resourcesForStringId.Remove(textId);
+ }
+ }
+
+ ///
+ /// Removes a text from the textDB's cache. This usually is only a intermediary operation, until the updated text is reinserted.
+ ///
+ ///
+ public void RemoveTextFromCache(uint textId)
+ {
+ _textsForId.Remove(textId);
+ }
+
+ ///
+ /// Updates the textDb's cache with the new value for the text id.
+ ///
+ ///
+ ///
+ public void UpdateTextCache(uint textId, string text)
+ {
+ _textsForId[textId] = text;
+ }
+
+ public void RevertText(uint textId)
+ {
+ string defaultText = null;
+ ISet nonDefaultResourceNames = _resourcesForStringId[textId].AddedResourceNames;
+ var resources = new List( GetAllResourcesForTextId(textId));
+
+ foreach (LocalizedStringResource resource in resources)
+ {
+ if(defaultText == null)
+ {
+ defaultText = resource.GetDefaultText(textId);
+ }
+ resource.RemoveText(textId);
+ nonDefaultResourceNames.Remove(resource.Name);
+ }
+
+ if(defaultText != null)
+ {
+ UpdateTextCache(textId, defaultText);
+ }
+ else
+ {
+ RemoveTextFromCache(textId);
+ }
+ }
+ }
+}
diff --git a/Plugins/BiowareLocalizationPlugin/LocalizedResources/LocalizedStringResource.cs b/Plugins/BiowareLocalizationPlugin/LocalizedResources/LocalizedStringResource.cs
new file mode 100644
index 000000000..42478eb1b
--- /dev/null
+++ b/Plugins/BiowareLocalizationPlugin/LocalizedResources/LocalizedStringResource.cs
@@ -0,0 +1,1117 @@
+using Frosty.Core;
+using FrostySdk;
+using FrostySdk.IO;
+using FrostySdk.Managers;
+using FrostySdk.Resources;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Linq;
+using System;
+using System.Collections;
+
+namespace BiowareLocalizationPlugin.LocalizedResources
+{
+
+ public class LocalizedStringResource : Resource
+ {
+
+ ///
+ /// Toggle to enable / disable further debug log messages -Remember to turn this off before release!
+ ///
+ private static readonly bool PrintVerificationTexts = false;
+
+ ///
+ /// Possible variants of where to position the reader in the case that the stated data offset does not match the data in the resource.
+ /// None of these actually work, in the case of discrepancy you are screwed anyway :(
+ ///
+ private enum PositionOffsetErrorHandling
+ {
+ ///
+ /// Use the current reader position going forward
+ ///
+ READER_POSITON,
+
+ ///
+ /// Set reader to the position stated in the header
+ ///
+ HEADER_DATAOFFSET,
+
+ ///
+ /// Set the reader to the position stated in the metadata
+ ///
+ METADATA_DATAOFFSET
+ }
+
+ ///
+ /// How to handle incorrect metadata offsets in the resource header.
+ ///
+ private static readonly PositionOffsetErrorHandling ContinueAfterOffsetErrorVariant = PositionOffsetErrorHandling.HEADER_DATAOFFSET;
+
+ ///
+ /// The default texts
+ ///
+ private readonly List _localizedStrings = new List();
+
+ ///
+ /// List of supported characters, ordered by their position within the node list, i.e., their frequency within all texts
+ ///
+ private List _supportedCharacters = new List();
+
+ ///
+ /// Lists all the string ids that are stored at the key position
+ ///
+ private readonly Dictionary> _stringIdsAtPositionOffset = new Dictionary>();
+
+ ///
+ /// If any text is altered, the altered text entry will be kept in the modfiedResource.
+ ///
+ private ModifiedLocalizationResource _modifiedResource = null;
+
+ ///
+ /// The header information of the localization resource.
+ ///
+ private ResourceHeader _headerData;
+
+ ///
+ /// The default encoding's root node. Note that the rootNode itself is not part of the serialized data!
+ ///
+ private HuffmanNode _encodingRootNode;
+
+ ///
+ /// Byte array of currently unknown data packed between the header list of string positions, and the actual text entries.
+ ///
+ private List _unknownData;
+
+ ///
+ /// Ids and bit offst possitions of Declinated adjectives for creafted items in DA:I
+ /// This has internal access only for the test utils
+ ///
+ internal List> DragonAgeDeclinatedCraftingNames { get; private set; }
+
+ ///
+ /// The (display) name of the resource this belongs to
+ ///
+ public string Name { get; private set; } = "not_yet_initialized";
+
+ ///
+ /// Event handler to be informed whenever the state of the modified resource changes drastically.
+ ///
+ public event EventHandler ResourceEventHandlers;
+
+ // TODO this currently stores a lot of redundant information, clean up at a later stage!
+
+ public LocalizedStringResource()
+ {
+ // nothing to do?!
+ }
+
+ public override void Read(NativeReader reader, AssetManager am, ResAssetEntry entry, ModifiedResource modifiedData)
+ {
+
+ // Profile Version MEA = 20170321
+ // Profile Version DAI = 20141118
+
+ Name = new StringBuilder(entry.Filename)
+ .Append(" - ")
+ .Append(entry.Name)
+ .ToString();
+
+ base.Read(reader, am, entry, modifiedData);
+
+ if (ProfilesLibrary.DataVersion == (int)ProfileVersion.Anthem)
+ {
+ ReadAnthemStrings(reader, entry);
+ }
+ else
+ {
+ Read_MassEffect_DragonAge_Strings(reader);
+ }
+
+ _modifiedResource = modifiedData as ModifiedLocalizationResource;
+ if(_modifiedResource != null)
+ {
+ _modifiedResource.InitResourceId(resRid);
+ }
+
+ // keep informed about changes...
+ entry.AssetModified += (s, e) => OnModified( (ResAssetEntry)s );
+ }
+
+ private void OnModified(ResAssetEntry assetEntry)
+ {
+ // There is an unhandled edge case here:
+ // When a resource is completely replaced by anoher one in a mod, then this method will not pick that up!
+
+ ModifiedAssetEntry modifiedAsset = assetEntry.ModifiedEntry;
+ ModifiedLocalizationResource newModifiedResource = modifiedAsset?.DataObject as ModifiedLocalizationResource;
+
+ if(PrintVerificationTexts)
+ {
+ App.Logger.Log("Asset <{0}> entered onModified", assetEntry.DisplayName);
+ }
+
+ if(newModifiedResource != _modifiedResource)
+ {
+ _modifiedResource = newModifiedResource;
+ ResourceEventHandlers?.Invoke(this, new EventArgs());
+ }
+
+ // revert the metadata just in case
+ ReplaceMetaData(_headerData.DataOffset);
+ }
+
+ ///
+ /// Fills the String list for Anthem.
+ ///
+ /// the data reader.
+ /// the res asset.
+ private void ReadAnthemStrings(NativeReader reader, ResAssetEntry entry)
+ {
+ _ = reader.ReadUInt();
+ _ = reader.ReadUInt();
+ _ = reader.ReadUInt();
+
+ // initialize these, so there is no accidental crash in anthem
+ _headerData = new ResourceHeader();
+ _unknownData = new List();
+ DragonAgeDeclinatedCraftingNames = new List>();
+
+ long numStrings = reader.ReadLong();
+ reader.Position += 0x18;
+
+ Dictionary> hashToStringIdMapping = new Dictionary>();
+
+ for (int i = 0; i < numStrings; i++)
+ {
+ uint hash = reader.ReadUInt();
+ uint stringId = reader.ReadUInt();
+ reader.Position += 8;
+ if (!hashToStringIdMapping.ContainsKey(hash))
+ hashToStringIdMapping.Add(hash, new List());
+ hashToStringIdMapping[hash].Add(stringId);
+ }
+
+ reader.Position += 0x18;
+
+ while (reader.Position < reader.Length)
+ {
+ uint hash = reader.ReadUInt();
+ int stringLen = reader.ReadInt();
+ string str = reader.ReadSizedString(stringLen);
+ int stringPosition = (int)reader.Position; // anthem is not really supported anyways...
+
+ if (hashToStringIdMapping.ContainsKey(hash))
+ {
+ foreach (uint stringId in hashToStringIdMapping[hash])
+ _localizedStrings.Add(new LocalizedString(stringId, stringPosition, str ));
+ }
+ else
+ {
+ App.Logger.Log("Cannot find {0} in {1}", hash.ToString("x8"), entry.Name);
+ }
+ }
+ }
+
+ ///
+ /// Creates the localized string list from the huffman encoded Mass Effect and Dragon Age bundle entries.
+ ///
+ ///
+ private void Read_MassEffect_DragonAge_Strings(NativeReader reader)
+ {
+
+ _headerData = ResourceUtils.ReadHeader(reader);
+
+ if(PrintVerificationTexts)
+ {
+ App.Logger.Log("Read header data for <{0}>: {1}", Name, _headerData.ToString());
+ }
+
+ // position of huffman nodes is header.nodeOffset
+ PositionSanityCheck(reader, _headerData.NodeOffset, "Header");
+ _encodingRootNode = ResourceUtils.ReadNodes(reader, _headerData.NodeCount, out List leafCharacters);
+ _supportedCharacters = leafCharacters;
+
+ // position of string id and position is right after huffman nodes: header.stringsOffset
+ PositionSanityCheck(reader, _headerData.StringsOffset, "HuffmanCoding");
+ ReadStringData(reader, _headerData.StringsCount);
+
+ // position after string data is the start of header.unknownDataDef[0].offset
+ PositionSanityCheck(reader, _headerData.FirstUnknownDataDefSegments[0].Offset, "StringData");
+ _unknownData = new List();
+ foreach (DataCountAndOffsets dataCountAndOffset in _headerData.FirstUnknownDataDefSegments)
+ {
+ _unknownData.Add(ResourceUtils.ReadUnkownSegment(reader, dataCountAndOffset));
+ }
+
+ DragonAgeDeclinatedCraftingNames = new List>();
+ foreach(DataCountAndOffsets dataCountAndOffset in _headerData.DragonAgeDeclinatedCraftingNamePartsCountAndOffset)
+ {
+
+ List declinatedAdjectives = ReadDragonAgeDeclinatedItemNamePartIdsAndOffsets(reader, dataCountAndOffset);
+ DragonAgeDeclinatedCraftingNames.Add(declinatedAdjectives);
+ }
+
+ DataOffsetReaderPositionSanityCheck(reader);
+
+
+ ReadStrings(reader, _encodingRootNode, GetAllLocalizedStrings());
+ }
+
+ ///
+ /// Returns the list of all LocalizedString entries found in this resource.
+ /// This list isbeing comprised of the main texts with unique ids,
+ /// and the set of declinated adjective strings used in dragon age, which all share the same id for several declinations of a word.
+ ///
+ ///
+ private List GetAllLocalizedStrings()
+ {
+ List allLocalizedStrings = new List(_localizedStrings);
+ foreach (var anotherListOfStrings in DragonAgeDeclinatedCraftingNames)
+ {
+ allLocalizedStrings.AddRange(anotherListOfStrings);
+ }
+
+ return allLocalizedStrings;
+ }
+
+ ///
+ /// Returns a list of tuples with the id and bit offset for declinated adjectives used when crafting items in DA:I
+ ///
+ ///
+ ///
+ ///
+ ///
+ private static List ReadDragonAgeDeclinatedItemNamePartIdsAndOffsets(NativeReader reader, DataCountAndOffsets countAndOffset)
+ {
+
+ List itemCraftingNameParts= new List();
+ for (int i = 0; i < countAndOffset.Count; i++)
+ {
+ uint textId = reader.ReadUInt();
+ int defaultPosition = reader.ReadInt();
+ LocalizedString namePartInfo = new LocalizedString(textId, defaultPosition);
+ itemCraftingNameParts.Add(namePartInfo);
+ }
+
+ if(PrintVerificationTexts && countAndOffset.Count > 0)
+ {
+ App.Logger.Log("... Read <{0}> declinated adjectives in a block", countAndOffset.Count);
+ }
+
+ return itemCraftingNameParts;
+ }
+
+ ///
+ /// Sets the reader to the expected position, if the current position is somewhere else
+ ///
+ ///
+ ///
+ /// the name of the position as used in the warning message
+ private void PositionSanityCheck(NativeReader reader, uint expectedPosition, string positionName)
+ {
+ if(reader.Position != expectedPosition)
+ {
+ App.Logger.LogWarning("Reader for resource <{0}> is at the wrong position after {1}!", Name, positionName);
+ }
+ }
+
+ ///
+ /// Reads the tuples of string id and string bit offset from the dataOffset.
+ /// I.e., it fills the given int array with the offset for the string id, creates an empty _strings entry for the string id
+ /// and also creates a dictionary with the information read from the list.
+ ///
+ /// Note that several String Ids may point towards the same string position!
+ /// Also note that the strings seem to be ordered by their ID, smallest to largest
+ ///
+ ///
+ ///
+ private void ReadStringData(NativeReader reader, uint stringsCount)
+ {
+ for (int i = 0; i < stringsCount; i++)
+ {
+ uint stringId = reader.ReadUInt();
+ int positionOffset = reader.ReadInt();
+
+ _localizedStrings.Add(new LocalizedString(stringId, positionOffset ));
+
+ // memorize which ids are all stored at the same position
+ List idList;
+ if ( !_stringIdsAtPositionOffset.ContainsKey(positionOffset))
+ {
+ idList = new List();
+ _stringIdsAtPositionOffset.Add(positionOffset, idList);
+ }
+ else
+ {
+ idList = _stringIdsAtPositionOffset[positionOffset];
+ }
+ idList.Add(stringId);
+
+ }
+ }
+
+ ///
+ /// Checks that the current position matches the offset as given in the header or at least the metadata.
+ /// If it does not match, the given reader's position is updated to the value in the metadata.
+ ///
+ ///
+ private void DataOffsetReaderPositionSanityCheck(NativeReader reader)
+ {
+
+ uint currentPosition = (uint) reader.Position;
+ uint dataOffsetFromHeader = _headerData.DataOffset;
+ if (currentPosition != dataOffsetFromHeader)
+ {
+
+ uint dataOffsetFromMeta = BitConverter.ToUInt32(resMeta, 0);
+ if(currentPosition == dataOffsetFromMeta)
+ {
+ App.Logger.LogWarning("Header data for for resource <{0}> is incorrect. 8ByteBlockData is stated to end at <{1}>, instead current reader position is <{2}>, as stated in the metadata!", Name, _headerData.DataOffset, currentPosition);
+ return;
+ }
+
+ string expectedOffsetInsert = (dataOffsetFromHeader == dataOffsetFromMeta) ?
+ dataOffsetFromHeader.ToString() :
+ string.Format("{0} or {1}", dataOffsetFromHeader, dataOffsetFromMeta);
+
+ uint newPosition;
+ string continueWithOffsetText;
+ switch (ContinueAfterOffsetErrorVariant)
+ {
+ case PositionOffsetErrorHandling.READER_POSITON:
+ newPosition = currentPosition;
+ continueWithOffsetText = "Continuing with current position";
+ break;
+ case PositionOffsetErrorHandling.HEADER_DATAOFFSET:
+ newPosition = dataOffsetFromHeader;
+ continueWithOffsetText = string.Format("Continuing with stated position from header: <{0}>", newPosition);
+ break;
+ case PositionOffsetErrorHandling.METADATA_DATAOFFSET:
+ newPosition = dataOffsetFromMeta;
+ continueWithOffsetText = string.Format("Continuing with stated position from MetaData: <{0}>", newPosition);
+ break;
+ default:
+ throw new ArgumentException("Unknown PositionOffsetErrorHandling variant " + ContinueAfterOffsetErrorVariant);
+ }
+
+ App.Logger.LogWarning("Expected 8ByteBlockData DataSegment for <{0}> to end at <{1}>, instead current reader position is <{2}>! {3}",
+ Name, expectedOffsetInsert, currentPosition, continueWithOffsetText);
+
+ reader.Position = newPosition;
+ }
+ }
+
+ ///
+ /// Reads the actual texts as sequence of huffman encoded characters.
+ /// The texts are read at the positions given in the list of LocalizedString, updating each of the entries afterwards.
+ ///
+ ///
+ ///
+ ///
+ private void ReadStrings(NativeReader reader, HuffmanNode rootNode, List allLocalizedStrings)
+ {
+ byte[] values = reader.ReadBytes((int)(reader.Length - reader.Position));
+ int textLengthInBytes = values.Length;
+
+ using (BitReader bitReader = new BitReader(new MemoryStream(values)))
+ {
+
+ foreach(LocalizedString stringDefinition in allLocalizedStrings)
+ {
+ int bitOffset = stringDefinition.DefaultPosition;
+
+ bool sanitiyCheckSuccess = CheckPositionExists(textLengthInBytes, bitOffset, stringDefinition.Id);
+ if(sanitiyCheckSuccess)
+ {
+ bitReader.SetPosition(bitOffset);
+ stringDefinition.Value = ReadSingleText(bitReader, rootNode);
+
+ } else
+ {
+ // mark that the text could not be read - a generic warning might be bad, because it conflates all the different texts!
+ uint textId = stringDefinition.Id;
+ string dummy = string.Format("Text <{0}> could not be read!", textId.ToString("X8"));
+ stringDefinition.Value = dummy;
+ }
+ }
+ }
+ }
+
+ ///
+ /// Reads a single text from the given bit reader's current position
+ ///
+ ///
+ ///
+ /// text
+ private static string ReadSingleText(BitReader bitReader, HuffmanNode rootNode)
+ {
+ StringBuilder sb = new StringBuilder();
+ while (true)
+ {
+ HuffmanNode n = rootNode;
+ while (n.Left != null && n.Right != null && !bitReader.EndOfStream)
+ {
+ bool b = bitReader.GetBit();
+ if (b) n = n.Right;
+ else n = n.Left;
+ }
+
+ if (n.Letter == 0x00 || bitReader.EndOfStream)
+ {
+ return sb.ToString();
+ }
+ // else
+ sb.Append(n.Letter);
+ }
+ }
+
+ ///
+ /// Sanity Check for Dragon age position offsets.
+ /// There can be instance where the position appears as negative (int), though i don't yet know if the uint position is also outside the array
+ ///
+ ///
+ ///
+ ///
+ /// true if the position is ok
+ private bool CheckPositionExists(int textLengthInBytes, int bitPosition, uint textId)
+ {
+
+ int bytePosition = (bitPosition >> 5) * 4;
+ if( (bytePosition >= 0)
+ && (bytePosition < textLengthInBytes))
+ {
+ return true;
+ }
+
+ App.Logger.LogError(
+ "Could not read text <{0}> in resource <{1}>! The stated position is outside the data array of byte length <{4}>!",
+ textId.ToString("X8"), Name, bitPosition, bytePosition, textLengthInBytes);
+
+ if(bitPosition <0 && PrintVerificationTexts)
+ {
+ uint unsignedBitPosition = (uint)bitPosition;
+ uint unsignedBytePosition = (unsignedBitPosition >> 5) * 4;
+
+ App.Logger.LogError("... unsigned bit position would be <{0}> in byte <{1}>", unsignedBitPosition, unsignedBytePosition);
+ }
+
+ if(PrintVerificationTexts)
+ {
+ App.Logger.LogWarning("...There are a total of <{0}> texts in this Resource!", _localizedStrings.Count);
+
+ int maxId = 3;
+ maxId = _localizedStrings.Count < maxId ? _localizedStrings.Count : maxId;
+
+ string[] someAffectedIdsList = new string[maxId];
+ for(int i = 0; i
+ /// Returns all the characters supported by this resource.
+ ///
+ /// List of chars.
+ public IEnumerable GetSupportedCharacters()
+ {
+ return _supportedCharacters;
+ }
+
+ ///
+ /// Iterates over all (primary) texts in this resource and returns them in the tuple as follows:
+ /// Tuple#0: text id (uint)
+ /// Tuple#1: text (string)
+ /// Tuple#2: whether the text is modified or default (bool). True if modified.
+ /// The same text can be encountered twice in this enumeration, first in its vanilla state, and then the modified version.
+ /// Note: This method does *NOT* return the texts referenced in the 8ByteBlockData between the StringData and the Strings parts of the resource.
+ ///
+ /// An enumerable (stream) of the primary text entries
+ public IEnumerable> GetAllPrimaryTexts()
+ {
+ for (int i = 0; i < _localizedStrings.Count; i++)
+ {
+ yield return new Tuple(_localizedStrings[i].Id, _localizedStrings[i].Value, false);
+ }
+
+ if(_modifiedResource != null)
+ {
+ foreach ( KeyValuePair modifiedEntry in _modifiedResource.AlteredTexts)
+ {
+ yield return new Tuple(modifiedEntry.Key, modifiedEntry.Value, true);
+ }
+ }
+ }
+
+ ///
+ /// Returns the list of all other vanilla string ids (as hex representation) that share the same text and position.
+ /// To Reiterate: This only lists vanilla unmodified strings and positions!
+ ///
+ ///
+ /// Enumeration of hexadecimal string ids
+ public IEnumerable GetAllTextIdsAtPositionOf(uint textId)
+ {
+
+ LocalizedString textEntry = null;
+ foreach (LocalizedString searchTextEntry in _localizedStrings)
+ {
+ if(searchTextEntry.Id == textId)
+ {
+ textEntry = searchTextEntry;
+ break;
+ }
+ }
+
+ if(textEntry != null && (textEntry.DefaultPosition >=0))
+ {
+ bool valuePresent = _stringIdsAtPositionOffset.TryGetValue(textEntry.DefaultPosition, out List allStringIds);
+ if(valuePresent)
+ {
+ return GetAllStringsIdsAsHex(allStringIds);
+ }
+ }
+ return Enumerable.Empty();
+ }
+
+ private static IEnumerable GetAllStringsIdsAsHex(List allStringIds)
+ {
+ foreach (uint stringId in allStringIds)
+ {
+ yield return stringId.ToString("X8");
+ }
+ }
+
+ // This is only here for test purposes!
+ public HuffmanNode GetRootNode()
+ {
+ return _encodingRootNode;
+ }
+
+ public override byte[] SaveBytes()
+ {
+
+ // remove these logs
+ if (PrintVerificationTexts) { App.Logger.Log("Writing Texts for <{0}>", Name); }
+
+ /*Plan of Action:
+ * -recalculate Huffman encoding
+ * -- getHuffman codes for each character
+ * -recompute list of string ids and string positions with altered texts
+ * -- need to already encoded strings for the position calculation!
+ * -recompute other offsets based on the last step info
+ * -replace the meta data with the new dataOffset (!)
+ * -write header with altered offsets
+ * -write stuff and things between header and strings
+ * -write strings with the new huffman encoding
+ */
+
+ List> allTexts =GetAllSortedTextsToWrite();
+ HuffmanNode newRootNode = GetEncodingRootNode(allTexts);
+
+ uint nodeOffset = _headerData.NodeOffset;
+
+ // flatten the tree, we need to list representation again...
+ List nodeList = ResourceUtils.GetNodeListToWrite(newRootNode);
+ uint newNodeCount = (uint)nodeList.Count;
+
+ uint encodingNodesSize = newNodeCount * 4;
+ uint newStringsOffset = nodeOffset + encodingNodesSize;
+
+ Dictionary> encoding = ResourceUtils.GetCharEncoding(nodeList);
+ EncodedTextPositionGrouping encodedTextsGrouping = ResourceUtils.GetEncodedTextsToWrite(allTexts, encoding);
+
+ uint newStringsCount = (uint)encodedTextsGrouping.PrimaryTextIdsAndPositions.Count;
+
+ uint blockOffset = newStringsOffset + (newStringsCount*8);
+ uint lastBlockSize = 0;
+ List recalculatedAdditionalOffsets = new List();
+
+ // one for the money: No idea what this is
+ foreach (DataCountAndOffsets unknownDef in _headerData.FirstUnknownDataDefSegments)
+ {
+ uint byteBlockCount8 = unknownDef.Count;
+ blockOffset += lastBlockSize;
+ recalculatedAdditionalOffsets.Add(new DataCountAndOffsets()
+ {
+ Count = byteBlockCount8,
+ Offset = blockOffset
+
+ });
+
+ lastBlockSize = byteBlockCount8 * 8;
+ }
+
+ // two for the show: These are the ids and positions of the declinated adjectives used in DA:I crafting
+ foreach(var declinatedAdjectivesBlock in encodedTextsGrouping.DeclinatedAdjectivesIdsAndPositions)
+ {
+ uint byteBlockCount8 = (uint) declinatedAdjectivesBlock.Count;
+ blockOffset += lastBlockSize;
+ recalculatedAdditionalOffsets.Add(new DataCountAndOffsets()
+ {
+ Count = byteBlockCount8,
+ Offset = blockOffset
+
+ });
+ lastBlockSize = byteBlockCount8 * 8;
+ }
+
+ uint newDataOffset = blockOffset + lastBlockSize;
+ // replace the metadata which is where the game actually reads the dataoffset from.
+ ReplaceMetaData(newDataOffset);
+
+ using (NativeWriter writer = new NativeWriter(new MemoryStream()))
+ {
+
+ // Then write the type dependent header data
+ writer.Write(ResourceHeader.Magic);
+
+ writer.Write(_headerData.Unknown1);
+
+ writer.Write(newDataOffset);
+
+ writer.Write(_headerData.Unknown2);
+ writer.Write(_headerData.Unknown3);
+ writer.Write(_headerData.Unknown4);
+
+ writer.Write(newNodeCount);
+ writer.Write(nodeOffset);
+ writer.Write(newStringsCount);
+ writer.Write(newStringsOffset);
+
+ foreach( DataCountAndOffsets uds in recalculatedAdditionalOffsets)
+ {
+ writer.Write(uds.Count);
+ writer.Write(uds.Offset);
+ }
+
+ if (PrintVerificationTexts)
+ { App.Logger.Log(".. Writer Position before <{0}> nodes is <{1}>, expected <{2}> ", nodeList.Count, writer.Position, nodeOffset); }
+
+ // Write huffman nodes
+ foreach (HuffmanNode node in nodeList)
+ {
+ writer.Write(node.Value);
+ }
+
+ long actualStringsOffset = writer.Position;
+ if (PrintVerificationTexts)
+ { App.Logger.Log(".. Writer Position before textlocations is <{0}>, expected <{1}> ", writer.Position, newStringsOffset); }
+
+ //Write string id positions
+ foreach (KeyValuePair entry in encodedTextsGrouping.PrimaryTextIdsAndPositions)
+ {
+ writer.Write(entry.Key);
+ writer.Write(entry.Value.Position);
+ }
+
+ if (PrintVerificationTexts)
+ {
+ App.Logger.Log(".. Writer Position after <{0}> textlocations is <{1}>, expected <{2}>. Length of last part was <{3}>",
+ encodedTextsGrouping.PrimaryTextIdsAndPositions.Count, writer.Position, recalculatedAdditionalOffsets[0].Offset, writer.Position - actualStringsOffset);
+ }
+
+ //Write unknownDataSegments
+ foreach (byte[] someData in _unknownData)
+ {
+ writer.Write(someData);
+ }
+
+ // write the ids and positions of the declinated adjectives.
+ foreach( var declinationBlock in encodedTextsGrouping.DeclinatedAdjectivesIdsAndPositions)
+ {
+ foreach (KeyValuePair entry in declinationBlock)
+ {
+ writer.Write(entry.Key);
+ writer.Write(entry.Value.Position);
+ }
+ }
+
+ if (PrintVerificationTexts)
+ { App.Logger.Log(".. Writer Position before texts is <{0}>, expected <{1}>", writer.Position, newDataOffset); }
+
+ // Write encoded texts
+ byte[] bitTexts = ResourceUtils.GetTextRepresentationToWrite(encodedTextsGrouping.AllEncodedTextPositions);
+ writer.Write(bitTexts);
+
+ if (PrintVerificationTexts)
+ {
+ App.Logger.Log(".. Writer Position after encoded texts is <{0}>. EncodedTexts size was <{1}> byte", writer.Position, bitTexts.Length);
+ }
+
+ return writer.ToByteArray();
+ }
+ }
+
+ ///
+ /// Returns the sorted dictionary of texts by their id, as they should be written into the resource.
+ /// Each of the dictionarys sorted by id is in turn found in a list based on where the text ids originate from,
+ /// i.e., the primary text id definition, or one of the blocks for declinated crafting adjective name-parts.
+ ///
+ /// the texts sorted by their id
+ private List> GetAllSortedTextsToWrite()
+ {
+
+ // contains the texts string sorted by their id, with position in the list by their id encountere in the resource block
+ List> allTextsToWrite = new List>();
+
+ SortedDictionary primaryTextsToWrite = new SortedDictionary();
+ allTextsToWrite.Add(primaryTextsToWrite);
+
+ foreach (var entry in GetAllPrimaryTexts())
+ {
+ primaryTextsToWrite[entry.Item1] = entry.Item2;
+ }
+
+ if(PrintVerificationTexts)
+ {
+ App.Logger.Log("..Preparing to write resource <{0}>. Added <{1}> primary texts.", Name, primaryTextsToWrite.Count);
+ }
+
+ foreach(var declinationTypeStrings in DragonAgeDeclinatedCraftingNames)
+ {
+
+ SortedDictionary declinatedTextsToWrite = new SortedDictionary();
+ allTextsToWrite.Add(declinatedTextsToWrite);
+
+ foreach(var declinatedString in declinationTypeStrings)
+ {
+ declinatedTextsToWrite[declinatedString.Id] = declinatedString.Value;
+ }
+ }
+
+ if (PrintVerificationTexts)
+ {
+ PrintDeclinatedAdjectivesWritingVerifications(allTextsToWrite);
+ }
+
+
+ return allTextsToWrite;
+ }
+
+ ///
+ /// Prints the verification for writing declinated adjectives.
+ ///
+ ///
+ private static void PrintDeclinatedAdjectivesWritingVerifications(List> allTextsToWrite)
+ {
+ if (allTextsToWrite.Count > 1)
+ {
+ int min = int.MaxValue;
+ int max = 0;
+
+ for (int groupId = 1; groupId < allTextsToWrite.Count; groupId++)
+ {
+ var group = allTextsToWrite[groupId];
+ int groupSize = group.Count;
+ min = min > groupSize ? groupSize : min;
+ max = max < groupSize ? groupSize : max;
+ }
+
+ string groupSizeText;
+ if (min == max)
+ {
+ groupSizeText = $"of {max} number of text ids";
+ }
+ else
+ {
+ groupSizeText = $"of beween {min} and {max} number of texts";
+ }
+
+ App.Logger.Log("... Added <{0}> groups of declinated crafting adjectives {1}.", allTextsToWrite.Count - 1, groupSizeText);
+ }
+ }
+
+ ///
+ /// Returns the encoding originally used, if all characters are included. Otherwise recalculates a new encoding.
+ ///
+ /// The enumeration of all texts and their id
+ /// The root huffman node for the encoding
+ private HuffmanNode GetEncodingRootNode(List> allSortedTexts)
+ {
+
+ if(_modifiedResource == null)
+ {
+ return _encodingRootNode;
+ }
+
+ // compare added texts chars to allowed chars, if new ones, recalculate encoding
+ IEnumerable alteredTexts = _modifiedResource.AlteredTexts.Values;
+ bool includesOnlySupported = ResourceUtils.IncludesOnlySupportedCharacters(alteredTexts, _supportedCharacters);
+
+ if (includesOnlySupported)
+ {
+ return _encodingRootNode;
+ }
+
+ if (PrintVerificationTexts)
+ {
+ App.Logger.Log("Recalculating encoding for resource: <{0}>", Name);
+ }
+
+ var allTexts = new HashSet();
+ foreach(var entry in allSortedTexts)
+ {
+ allTexts.UnionWith(entry.Values);
+ }
+
+ return ResourceUtils.CalculateHuffmanEncoding(allTexts);
+ }
+
+ ///
+ /// Replaces the current metadata with newly computed ones with the correct data offset
+ ///
+ ///
+ private void ReplaceMetaData(uint newDataOffset)
+ {
+ using (NativeWriter metaDataWriter = new NativeWriter(new MemoryStream(16) ) )
+ {
+ metaDataWriter.Write(newDataOffset);
+ metaDataWriter.Write(0);
+ metaDataWriter.Write(0);
+ metaDataWriter.Write(0);
+
+ resMeta = metaDataWriter.ToByteArray();
+ }
+ }
+
+ public override ModifiedResource SaveModifiedResource()
+ {
+ return _modifiedResource;
+ }
+
+ ///
+ /// Adds or edits the text of the given id.
+ ///
+ ///
+ ///
+ public void SetText(uint textId, string text)
+ {
+
+ // Try to revert if text equals original
+ // -> drawback is long iteration over all texts or another huge instance of textid to text dictionary :(
+
+ // have to try anyway as long as no dedicated remove is present..
+ foreach(var entry in _localizedStrings)
+ {
+ if(textId == entry.Id)
+ {
+ // found the right one
+ // neither the entryValue nor the given text can be null
+ if (entry.Value.Equals(text))
+ {
+ // It is the original text, remove instead
+ RemoveText(textId);
+ return;
+ }
+ break;
+ }
+ }
+
+ SetText0(textId, text);
+ }
+
+ private void SetText0(uint textId, string text)
+ {
+ if (_modifiedResource == null)
+ {
+ _modifiedResource = new ModifiedLocalizationResource();
+ _modifiedResource.InitResourceId(resRid);
+
+ App.AssetManager.ModifyRes(resRid, this);
+ }
+ _modifiedResource.SetText(textId, text);
+ }
+
+ public void RemoveText(uint textId)
+ {
+ if(_modifiedResource != null)
+ {
+ _modifiedResource.RemoveText(textId);
+
+ if(_modifiedResource.AlteredTexts.Count == 0)
+ {
+ // remove this resource, it isn't needed anymore
+ // This is also done via the listener, but whatever
+ _modifiedResource = null;
+
+ AssetManager assetManager = App.AssetManager;
+ ResAssetEntry entry = assetManager.GetResEntry(resRid);
+ App.AssetManager.RevertAsset(entry);
+ }
+ }
+ }
+
+ public string GetDefaultText(uint textId)
+ {
+ // FIXME hopefully this isn't used often...
+ foreach (var entry in _localizedStrings)
+ {
+ if (textId == entry.Id)
+ {
+ return entry.Value;
+ }
+ }
+ return null;
+ }
+
+ public IEnumerable GetAllModifiedTextsIds()
+ {
+ if(_modifiedResource == null)
+ {
+ return new List();
+ }
+
+ return new List(_modifiedResource.AlteredTexts.Keys);
+ }
+ }
+
+
+ ///
+ /// This modified resource is used to store the altered texts only in the project file and mods.
+ ///
+ public class ModifiedLocalizationResource : ModifiedResource
+ {
+
+ ///
+ /// Version number that is incremented with changes to how modfiles are persisted.
+ /// This should allow to detect outdated mods and maybe even read them correctly if mod writing is ever changed.
+ ///
+ private static readonly uint MOD_PERSISTENCE_VERSION= 1;
+
+ // Just to make sure we write / overwrite and merge the correct asset!
+ private ulong _resRid = 0x0;
+
+ ///
+ /// The dictionary of altered or new texts in this modified resource.
+ ///
+ public Dictionary AlteredTexts { get; } = new Dictionary();
+
+ ///
+ /// Sets a modified text into the dictionary.
+ ///
+ /// The uint id of the string
+ /// The new string
+ public void SetText(uint textId, string text)
+ {
+ AlteredTexts[textId] = text;
+ }
+
+ ///
+ /// Verbose remove method accessor.
+ ///
+ ///
+ public void RemoveText(uint textId)
+ {
+ AlteredTexts.Remove(textId);
+ }
+
+ ///
+ /// Initializes the resource id, this is used to make sure we modify and overwrite the correct resource.
+ ///
+ ///
+ public void InitResourceId(ulong otherResRid)
+ {
+ if(_resRid != 0x0 && _resRid != otherResRid)
+ {
+ string errorMsg = string.Format(
+ "Trying to initialize modified resource for resRid <{0}> with contents of resource resRid <{1}> - This may indicate a mod made for a different game version!",
+ _resRid.ToString("X"), otherResRid.ToString("X"));
+ App.Logger.LogWarning(errorMsg);
+ }
+ _resRid = otherResRid;
+ }
+
+ ///
+ /// Merges this resource with the given other resource by talking all of the other resources texts, overwriting already present texts for the same id if they exist.
+ /// This method alters the state of this resource.
+ ///
+ /// The other, higher priority resource, to merge into this one.
+ public void Merge(ModifiedLocalizationResource higherPriorityModifiedResource)
+ {
+
+ if(_resRid != higherPriorityModifiedResource._resRid)
+ {
+ String errorMsg = string.Format(
+ "Trying to merge resource with resRid <{0}> into resource for resRid <{1}> - This may indicate a mod made for a different game version!",
+ higherPriorityModifiedResource._resRid.ToString("X"), _resRid.ToString("X"));
+ App.Logger.LogWarning(errorMsg);
+ }
+
+ foreach(KeyValuePair textEntry in higherPriorityModifiedResource.AlteredTexts)
+ {
+ SetText(textEntry.Key, textEntry.Value);
+ }
+ }
+
+ ///
+ /// This function is responsible for reading in the modified data from the project file.
+ ///
+ ///
+ public override void ReadInternal(NativeReader reader)
+ {
+
+ uint modPersistenceVersion = reader.ReadUInt();
+ InitResourceId(reader.ReadULong());
+
+ if(modPersistenceVersion != MOD_PERSISTENCE_VERSION)
+ {
+ ResAssetEntry asset = App.AssetManager.GetResEntry(_resRid);
+ string assetName = asset != null ? asset.Path : "";
+
+ App.Logger.LogWarning("ABORT: Mod for localization resource <{0}> was written with a different version and cannot be read!", assetName);
+ return;
+ }
+
+ int numberOfEntries = reader.ReadInt();
+
+ byte[] entryBytes = reader.ReadBytes((int) (reader.Length - reader.Position));
+ using (BinaryReader textReader = new BinaryReader(new MemoryStream(entryBytes), Encoding.UTF8))
+ {
+ for (int i = 0; i < numberOfEntries; i++)
+ {
+ uint textId = textReader.ReadUInt32();
+ string text = textReader.ReadString();
+
+ SetText(textId, text);
+ }
+ }
+ }
+
+ ///
+ /// This function is responsible for writing out the modified data to the project file.
+ /// I.e., the written data is this:
+ /// [uint: resRid][int: numberOfEntries] {numberOfEntries * [[uint: stringId]['nullTerminatedString': String]]}
+ ///
+ ///
+ ///
+ /// If called without having initialized the resource
+ public override void SaveInternal(NativeWriter writer)
+ {
+
+ // assert this is for a valid resource!
+ if(_resRid == 0x0)
+ {
+ throw new InvalidOperationException("Modified resource not bound to any resource!");
+ }
+
+ writer.Write(MOD_PERSISTENCE_VERSION);
+
+ writer.Write(_resRid);
+ writer.Write(AlteredTexts.Count);
+
+ // use a binary writer from here to write all the texts in utf-8
+ writer.Write(ResourceUtils.ConvertTextEntriesToBytes(AlteredTexts));
+ }
+
+ public ulong GetResRid()
+ {
+ return _resRid;
+ }
+ }
+}
diff --git a/Plugins/BiowareLocalizationPlugin/LocalizedResources/ResourceComponentsHelper.cs b/Plugins/BiowareLocalizationPlugin/LocalizedResources/ResourceComponentsHelper.cs
new file mode 100644
index 000000000..0d21e495c
--- /dev/null
+++ b/Plugins/BiowareLocalizationPlugin/LocalizedResources/ResourceComponentsHelper.cs
@@ -0,0 +1,377 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace BiowareLocalizationPlugin.LocalizedResources
+{
+ class ResourceComponentsHelper
+ {
+ // not needed
+ }
+
+ ///
+ /// A class containing header information from a resource.
+ ///
+ public class ResourceHeader
+ {
+ public const uint Magic = 0xd78b40eb;
+
+ // no idea what this does, doesn't seem to affect anything
+ public uint Unknown1;
+
+ // Note: If nodeCount changes due to added Chars, then dataOffset changes!
+ // Additional Note: This offset is also part of the metadata, the value in this header is not guaranteed to be correct!
+ public uint DataOffset;
+
+ // also no idea, can set these to zero and nothing bad happens
+ public uint Unknown2;
+ public uint Unknown3;
+ public uint Unknown4;
+
+ // // nodeCount is an even integer! The rootNode as would-be last node in the node list is *not* actually part of the list
+ public uint NodeCount;
+ public uint NodeOffset;
+ public uint StringsCount;
+ public uint StringsOffset;
+
+ // Note: If one of nodeCount or stringsCount changes, then the offsets herein also change! This list has a length of 2
+ public List FirstUnknownDataDefSegments = new List();
+
+ // These are only available for very few resources, they contain the count and offset for the strings used when crafting items in DA:I
+ // This starts at the 3rd of the DataCountAndOffsets, potentially this contains only zeros.
+ public List DragonAgeDeclinatedCraftingNamePartsCountAndOffset = new List();
+
+ public override string ToString()
+ {
+ string uk1AsHex = Unknown1.ToString("X");
+ string uk2AsHex = Unknown2.ToString("X");
+ string uk3AsHex = Unknown3.ToString("X");
+ string uk4AsHex = Unknown4.ToString("X");
+
+ StringBuilder sb = new StringBuilder();
+ sb.Append($"unknown1: <{Unknown1} | 0x{uk1AsHex}>\n")
+ .Append($"unknown2: <{Unknown2} | 0x{uk2AsHex}>\n")
+ .Append($"unknown3: <{Unknown3} | 0x{uk3AsHex}>\n")
+ .Append($"unknown4: <{Unknown4} | 0x{uk4AsHex}>\n")
+ .Append($"NodeCount: <{NodeCount}> starting at <{NodeOffset}>\n")
+ .Append($"StringCount: <{StringsCount}> starting at <{StringsOffset}>\n");
+
+ foreach (var ukd in FirstUnknownDataDefSegments)
+ {
+ uint byte8Count = ukd.Count;
+ if(byte8Count>0)
+ {
+ uint totalsize = byte8Count * 8;
+ sb.Append($" Additional data of {byte8Count} 8Bytes, or {totalsize} bytes starts at <{ukd.Offset}>\n");
+ }
+ }
+
+ foreach (var craftingNamePartCounts in DragonAgeDeclinatedCraftingNamePartsCountAndOffset)
+ {
+ uint byte8Count = craftingNamePartCounts.Count;
+ if (byte8Count > 0)
+ {
+ uint totalsize = byte8Count * 8;
+ sb.Append($" Declinated crafting name parts of {byte8Count} 8Bytes, or {totalsize} bytes starts at <{craftingNamePartCounts.Offset}>\n");
+ }
+ }
+
+ sb.Append($"DataOffset is: <{DataOffset}>\n");
+
+ return sb.ToString();
+ }
+ }
+
+ ///
+ /// Another poco containing offsets to remember
+ ///
+ public class DataCountAndOffsets
+ {
+ public uint Count;
+ public uint Offset;
+ }
+
+ ///
+ /// A node in the huffman coding scheme
+ ///
+ public class HuffmanNode : IComparable
+ {
+ public char Letter => (char)(~Value);
+
+ public uint Value;
+ public HuffmanNode Left { get; private set; }
+ public HuffmanNode Right { get; private set; }
+
+ public HuffmanNode Parent { get; private set; }
+
+ public void SetLeftNode(HuffmanNode leftNode)
+ {
+ this.Left = leftNode;
+ Left.Parent = this;
+ }
+
+ public void SetRightNode(HuffmanNode rightNode)
+ {
+ this.Right = rightNode;
+ Right.Parent = this;
+ }
+
+ public override string ToString()
+ {
+ string printLetter;
+
+ switch(Value)
+ {
+ case uint.MaxValue:
+ printLetter = "endDelimeter";
+ break;
+ case 4294967285:
+ printLetter = "newLine";
+ break;
+ default:
+ printLetter = Letter.ToString();
+ break;
+ }
+
+ return string.Format("[Value = <{0}> | Letter = <{1}>]", Value.ToString(), printLetter);
+ }
+
+ ///
+ /// Returns the bit representation of this node, to be used in tests.
+ ///
+ ///
+ public string GetBitRepresentation()
+ {
+ if (Parent == null)
+ {
+ return "";
+ }
+
+ string bitVal;
+ if (this == Parent.Left)
+ bitVal = "0";
+ else if (this == Parent.Right)
+ {
+ bitVal = "1";
+ }
+ else
+ {
+ bitVal = "ERROR!";
+ }
+ return Parent.GetBitRepresentation() + bitVal;
+ }
+
+ public int CompareTo(HuffmanNode other)
+ {
+ return Value.CompareTo(other.Value);
+ }
+ }
+
+ ///
+ /// Represents a huffman encoded text.
+ ///
+ public class EncodedText
+ {
+ public List Value { get; }
+
+ private readonly int _hashValue;
+
+ public EncodedText(List encodedText)
+ {
+ this.Value = encodedText ?? throw new InvalidOperationException("\"encodedText\" must not be null!");
+ _hashValue = ComputeHash(encodedText);
+ }
+
+ public int GetLength()
+ { return Value.Count; }
+
+
+ public override int GetHashCode()
+ {
+ return _hashValue;
+ }
+
+ public override bool Equals(object obj)
+ {
+ if (this.GetType() == obj.GetType())
+ {
+ EncodedText other = (EncodedText)obj;
+
+ List otherValue = other.Value;
+ if (Value.Count.Equals(otherValue.Count))
+ {
+ for(int i = 0; i < Value.Count; i++)
+ {
+ if(Value[i] != otherValue[i])
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+ }
+ return false;
+ }
+
+ ///
+ /// Compute the hashcode once per text, instead of all the time when requested
+ ///
+ ///
+ ///
+ private static int ComputeHash(List encodedText)
+ {
+ int hash = 1;
+ foreach(bool b in encodedText)
+ {
+ hash = 31*hash + b.GetHashCode();
+ }
+ return hash;
+ }
+ }
+
+ ///
+ /// POCO to store an EncodedText and a position or offset value where this text can be found or is to be written.
+ ///
+ public class EncodedTextPosition : IComparable
+ {
+ public EncodedText EncodedText { get; }
+ public int Position { get; set;} = -1;
+
+ public EncodedTextPosition(EncodedText encodedText)
+ {
+ EncodedText = encodedText;
+ }
+
+ public int GetLength()
+ { return EncodedText.GetLength(); }
+
+ public int CompareTo(EncodedTextPosition other)
+ {
+ return Position.CompareTo(other.Position);
+ }
+
+ public override int GetHashCode()
+ {
+ return EncodedText.GetHashCode() * 31 + Position;
+ }
+
+ public override bool Equals(object obj)
+ {
+ if (this.GetType() == obj.GetType())
+ {
+ EncodedTextPosition other = (EncodedTextPosition)obj;
+
+ return
+ Position.Equals(other.Position)
+ && EncodedText.Equals(other.EncodedText);
+ }
+ return false;
+ }
+
+ }
+
+ public class HuffManConstructionNode : HuffmanNode, IComparable
+ {
+ public int Occurences { get; set; }
+
+ public new HuffManConstructionNode Left { get; private set; }
+
+ public new HuffManConstructionNode Right { get; private set; }
+
+ public HuffManConstructionNode()
+ {
+ Occurences = 0;
+ }
+
+ public void SetLeftNode(HuffManConstructionNode leftNode)
+ {
+ base.SetLeftNode(leftNode);
+ Left = leftNode;
+ Occurences += leftNode.Occurences;
+ }
+
+ public void SetRightNode(HuffManConstructionNode rightNode)
+ {
+ base.SetRightNode(rightNode);
+ Right = rightNode;
+ Occurences += rightNode.Occurences;
+ }
+
+ public int CompareTo(HuffManConstructionNode other)
+ {
+ int cmp = Occurences.CompareTo(other.Occurences);
+ if(cmp == 0)
+ {
+ cmp = GetDepth().CompareTo(other.GetDepth());
+ }
+ return cmp;
+ }
+
+ public int GetDepth()
+ {
+ int ld = Left != null ? Left.GetDepth() : 0;
+ int rd = Right != null ? Right.GetDepth() : 0;
+
+ return Math.Max(ld, rd);
+ }
+ }
+
+ public class LocalizedString
+ {
+ public readonly uint Id;
+ public string Value { get; set; }
+ public readonly int DefaultPosition;
+
+ public LocalizedString (uint id, int defaultPosition)
+ {
+ this.Id = id;
+ this.DefaultPosition = defaultPosition;
+ }
+
+ public LocalizedString(uint id, int defaultPosition, string text)
+ : this(id, defaultPosition)
+ {
+ Value = text;
+ }
+
+ public override string ToString()
+ {
+ return Value;
+ }
+ }
+
+ ///
+ /// This is the return object of the ResourceUtils.GetEncodedTextsToWrite(...) method.
+ /// It contains all the texts to write in the order to write them.
+ /// It also contains the set of text ids and their positions for the stringData block to write
+ /// And since DA:I is weird it also now contains all the sets of text ids (?) and their positions for each of the declinated adjectives used in crafting.
+ ///
+ public class EncodedTextPositionGrouping
+ {
+
+ ///
+ /// The ids and encoded texts with positions of all the primarily used texts.
+ ///
+ public SortedDictionary PrimaryTextIdsAndPositions { get; private set; }
+
+ ///
+ /// The ids and encoded texts with positions of all the declinated adjectives used in DAI crafting
+ ///
+ public List> DeclinatedAdjectivesIdsAndPositions { get; private set; }
+
+ ///
+ /// Just all of the encoded texts with position again.
+ ///
+ public SortedSet AllEncodedTextPositions { get; private set; }
+
+ public EncodedTextPositionGrouping(
+ SortedDictionary primaryTextIdsAndPositions,
+ List> declinatedAdjectiveIdsAndPositions,
+ SortedSet allEncodedTextPositions )
+ {
+ this.PrimaryTextIdsAndPositions = primaryTextIdsAndPositions;
+ this.DeclinatedAdjectivesIdsAndPositions = declinatedAdjectiveIdsAndPositions;
+ this.AllEncodedTextPositions = allEncodedTextPositions;
+ }
+ }
+}
diff --git a/Plugins/BiowareLocalizationPlugin/LocalizedResources/ResourceTestUtils.cs b/Plugins/BiowareLocalizationPlugin/LocalizedResources/ResourceTestUtils.cs
new file mode 100644
index 000000000..2c41249aa
--- /dev/null
+++ b/Plugins/BiowareLocalizationPlugin/LocalizedResources/ResourceTestUtils.cs
@@ -0,0 +1,279 @@
+using Frosty.Core;
+using FrostySdk.IO;
+using FrostySdk.Managers;
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+
+namespace BiowareLocalizationPlugin.LocalizedResources
+{
+ public class ResourceTestUtils
+ {
+ private ResourceTestUtils() { }
+
+ ///
+ /// Verifys that the Validity of the recreated flattened tree-nodes list. This is a debug method not intended to be called in production code
+ ///
+ ///
+ ///
+ public static void VerifySame(List original, List recreation)
+ {
+
+ bool same = true;
+
+ int originalCount = original.Count;
+ if (originalCount != recreation.Count)
+ {
+ App.Logger.Log("Original had {0} entries, recreation has {1} instead!", originalCount, recreation.Count);
+ same = false;
+ return;
+ }
+
+ for (int i = 0; i < originalCount; i++)
+ {
+ HuffmanNode node = original[i];
+ HuffmanNode recreationNode = recreation[i];
+
+ if (recreationNode.Value != node.Value)
+ {
+ int recreationIndex = recreation.FindIndex((HuffmanNode toFind) => { return toFind.Value == node.Value; });
+ App.Logger.Log("Recreated Index of Node <{0}> is {1} instead of {2}!", node, recreationIndex, i);
+ same = false;
+
+ if (recreationIndex >= 0)
+ {
+ recreationNode = recreation[recreationIndex];
+ }
+ else
+ {
+ recreationNode = null;
+ }
+ }
+ if (recreationNode != null && (!node.GetBitRepresentation().Equals(recreationNode.GetBitRepresentation())))
+ {
+ App.Logger.Log("Encoding of Node <{0}> is {1} instead of {2}!", node, recreationNode.GetBitRepresentation(), node.GetBitRepresentation());
+ same = false;
+ }
+ }
+
+ if (same)
+ {
+ App.Logger.Log("The recreation matches the original!");
+ }
+ }
+
+ ///
+ /// Calculates dummy strings that roughly match the original character distribution for the character encoding.
+ ///
+ ///
+ /// list of strings
+ public static List CalculateTestStrings(HuffmanNode rootNode)
+ {
+ List> characterProbabilitySet = new List>();
+
+ List levelList = new List() {rootNode};
+
+ while(levelList.Count > 0)
+ {
+ List levelChars = new List();
+ foreach(var node in levelList)
+ {
+ if(node.Left == null && node.Right == null)
+ {
+ levelChars.Add(node.Letter);
+ }
+ else if (! (node.Left != null && node.Right != null ))
+ {
+ App.Logger.Log("Uneven tree detected!");
+ }
+ }
+
+ characterProbabilitySet.Add(levelChars);
+ levelList = ResourceUtils.GetNextLevel(levelList);
+ }
+
+ int multiplier = 1;
+ StringBuilder charString = new StringBuilder();
+ for(int level = characterProbabilitySet.Count - 1; level >=0; level--)
+ {
+ List levelChars = characterProbabilitySet[level];
+
+ //flip levelchars to recreate original encoding
+ levelChars.Reverse();
+
+ foreach(char c in levelChars)
+ {
+ charString.Append( string.Join("", Enumerable.Repeat(c, multiplier)));
+ }
+
+ multiplier *= 2;
+ }
+
+ return new List() { charString.ToString() };
+ }
+
+ public static void VerifyDefaultEncoding(HuffmanNode rootNode, string ResourceName)
+ {
+ App.Logger.Log("###################################################");
+ App.Logger.Log("Comparing recreated encoding for resource <{0}>", ResourceName);
+
+ var originalList = ResourceUtils.GetNodeList(rootNode);
+ var originalCharDistribution = ResourceTestUtils.CalculateTestStrings(rootNode);
+
+ HuffmanNode recalculatedRootNode = ResourceUtils.CalculateHuffmanEncoding(originalCharDistribution);
+ var recalculatedList = ResourceUtils.GetNodeList(recalculatedRootNode);
+
+ ResourceTestUtils.VerifySame(originalList, recalculatedList);
+ }
+
+ ///
+ /// Verifies that the final text bit offsets in the encoded text-bit stream match.
+ ///
+ ///
+ public static void VerifyTextPositions(SortedDictionary texts)
+ {
+ byte[] byteTexts = ResourceUtils.GetTextRepresentationToWrite( new SortedSet( texts.Values));
+ bool[] bitTexts = new bool[byteTexts.Length * 8];
+
+ BitArray bitArray = new BitArray(byteTexts);
+ bitArray.CopyTo(bitTexts, 0);
+
+ int missMatches = 0;
+ foreach (var entry in texts)
+ {
+ EncodedTextPosition text = entry.Value;
+
+ bool[] textArray = new bool[text.GetLength()];
+ Array.Copy(bitTexts, text.Position, textArray, 0, text.GetLength());
+
+ if(!textArray.SequenceEqual(text.EncodedText.Value))
+ {
+ //App.Logger.Log("Text with id <{0}> does not match in position!", entry.Key.ToString("X8"));
+ missMatches++;
+ }
+ }
+
+ if(missMatches == texts.Count)
+ {
+ App.Logger.Log("None of the texts at the positions match!");
+ }
+ else if(missMatches == 0)
+ {
+ App.Logger.Log("All of the text positions fit");
+ }
+ else
+ {
+ App.Logger.Log("There are <{0}> texts out of <{1}> positioned incorrectly!", missMatches, texts.Count);
+ }
+ }
+
+ ///
+ /// Tests that, when a modified resource is written to a byte array that same byte array can be read again and produce the exact same texts.
+ ///
+ ///
+ public static void ReadWriteTest(LocalizedStringResource resource)
+ {
+ App.Logger.Log("Test Rereading saved Data for <{0}>", resource.Name);
+
+ SortedDictionary currentData = new SortedDictionary();
+ foreach(var entry in resource.GetAllPrimaryTexts())
+ {
+ currentData[entry.Item1] = entry.Item2;
+ }
+
+ byte[] savedOutput = resource.SaveBytes();
+
+ byte[] copyMetaData = new byte[resource.ResourceMeta.Length];
+ resource.ResourceMeta.CopyTo(copyMetaData, 0);
+ ResAssetEntry mockEntry = new ResAssetEntry() {ResRid=0, Name="dummy", ResMeta=copyMetaData };
+ LocalizedStringResource recreation = new LocalizedStringResource();
+
+ using(NativeReader reader = new NativeReader(new MemoryStream(savedOutput)))
+ {
+ recreation.Read(reader, null, mockEntry, null);
+ }
+
+ // // Note that recreated node list does still not match exactly! No matter wheter GetNodeList or GetNodeListToWrite is used <_<
+ //var recreatedNodeList = ResourceUtils.GetNodeList(resource.GetRootNode());
+ //var reRecreatedNodeList = ResourceUtils.GetNodeListToWrite(recreation.GetRootNode());
+ //VerifySame(recreatedNodeList, reRecreatedNodeList);
+
+ SortedDictionary recreatedData = new SortedDictionary();
+ foreach (var entry in recreation.GetAllPrimaryTexts())
+ {
+ recreatedData[entry.Item1] = entry.Item2;
+ }
+
+ TestTextsFromReReadResource(currentData, recreatedData, "primary");
+
+ int dragonAgeBlocksCount = resource.DragonAgeDeclinatedCraftingNames.Count;
+ int recreatedDragonAgeBlocksCount = recreation.DragonAgeDeclinatedCraftingNames.Count;
+
+ if(dragonAgeBlocksCount != recreatedDragonAgeBlocksCount)
+ {
+ App.Logger.Log("... Expected <{0}> blocks of declinated adjectives, got <{1}> instead!", dragonAgeBlocksCount, recreatedDragonAgeBlocksCount);
+ }
+ else
+ {
+ for(int i = 0; i< dragonAgeBlocksCount; i++)
+ {
+ SortedDictionary originalBlockData = new SortedDictionary();
+ SortedDictionary recreatedBlockData = new SortedDictionary();
+
+ resource.DragonAgeDeclinatedCraftingNames[i].ForEach(ls => originalBlockData[ls.Id] = ls.Value);
+ recreation.DragonAgeDeclinatedCraftingNames[i].ForEach(ls => recreatedBlockData[ls.Id] = ls.Value);
+
+ TestTextsFromReReadResource(originalBlockData, recreatedBlockData, $"declinatedAdjectives[{i}]");
+ }
+ }
+ }
+
+ private static void TestTextsFromReReadResource(
+ SortedDictionary originalData, SortedDictionary recreatedData,
+ string blockNameForErrorMessages
+ )
+ {
+ if (originalData.Count != recreatedData.Count)
+ {
+ App.Logger.Log("...Incorrect Number of {2} Texts! Expected {0}, got {1}", originalData.Count, recreatedData.Count, blockNameForErrorMessages);
+ }
+ else
+ {
+ int missMatching = 0;
+ int missingIds = 0;
+ foreach (uint key in originalData.Keys)
+ {
+ string originalText = originalData[key];
+
+ bool textExistsInRecreation = recreatedData.TryGetValue(key, out string recreationText);
+ if(!textExistsInRecreation)
+ {
+ missingIds++;
+ }
+
+ if (!originalText.Equals(recreationText))
+ {
+ missMatching++;
+ }
+ }
+
+ if (missMatching == originalData.Count)
+ {
+ App.Logger.Log("...None of the {0} texts match! {1} Text(s) were missing!", blockNameForErrorMessages, missingIds);
+ }
+ else if (missMatching == 0)
+ {
+ App.Logger.Log("...All of the {0} texts match", blockNameForErrorMessages);
+ }
+ else
+ {
+ App.Logger.Log("...<{0}> texts missmatch and {3} missing out of all <{1}> {2} texts", missMatching, originalData.Count, blockNameForErrorMessages, missingIds);
+ }
+
+ }
+ }
+ }
+}
diff --git a/Plugins/BiowareLocalizationPlugin/LocalizedResources/ResourceUtils.cs b/Plugins/BiowareLocalizationPlugin/LocalizedResources/ResourceUtils.cs
new file mode 100644
index 000000000..8bd439f46
--- /dev/null
+++ b/Plugins/BiowareLocalizationPlugin/LocalizedResources/ResourceUtils.cs
@@ -0,0 +1,636 @@
+
+using Frosty.Core;
+using FrostySdk.IO;
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+
+namespace BiowareLocalizationPlugin.LocalizedResources
+{
+ ///
+ /// Helper class for methods that must not necessarily be inlcuded in the resource class.
+ ///
+ public class ResourceUtils
+ {
+ private ResourceUtils()
+ {
+ // prevent instantiation
+ }
+
+ ///
+ /// Reads the header information from the given reader and asset entry.
+ ///
+ ///
+ ///
+ public static ResourceHeader ReadHeader(NativeReader reader)
+ {
+
+ uint magic = reader.ReadUInt();
+ if (magic != ResourceHeader.Magic)
+ throw new InvalidDataException();
+
+ ResourceHeader header = new ResourceHeader
+ {
+ Unknown1 = reader.ReadUInt(),
+ DataOffset = reader.ReadUInt(),
+ Unknown2 = reader.ReadUInt(),
+ Unknown3 = reader.ReadUInt(),
+ Unknown4 = reader.ReadUInt(),
+
+ NodeCount = reader.ReadUInt(),
+ NodeOffset = reader.ReadUInt(),
+
+ StringsCount = reader.ReadUInt(),
+ StringsOffset = reader.ReadUInt()
+ };
+
+ // this block's first offset is the position after the stringId and positions are parsed, subsequent offsets (and the dataoffset) are 8 bytes * count further than the last
+ for(int i = 0; i<2 && reader.Position < header.NodeOffset; i++)
+ {
+ header.FirstUnknownDataDefSegments.Add(ReadCountAndOffset(reader));
+ }
+
+ // The remainder until the node offset is reached is filled by ids and positions of declinated articles for dragon age crafting.
+ while (reader.Position < header.NodeOffset)
+ {
+ header.DragonAgeDeclinatedCraftingNamePartsCountAndOffset.Add(ReadCountAndOffset(reader));
+ }
+
+ return header;
+ }
+
+ private static DataCountAndOffsets ReadCountAndOffset(NativeReader reader)
+ {
+ DataCountAndOffsets somePointer = new DataCountAndOffsets
+ {
+ Count = reader.ReadUInt(),
+ Offset = reader.ReadUInt()
+ };
+
+ return somePointer;
+ }
+
+ public static byte[] ReadUnkownSegment(NativeReader reader, DataCountAndOffsets countAndOffset)
+ {
+ return reader.ReadBytes(((int)countAndOffset.Count) * 8);
+ }
+
+ ///
+ /// Creates a list of huffman nodes from the given reader and node count.
+ ///
+ /// The reader.
+ /// The total number of nodes to read
+ /// The list of characters encoded.
+ /// The root node
+ public static HuffmanNode ReadNodes(NativeReader reader, uint nodeCount, out List supportedCharacters)
+ {
+
+ HuffmanNode rootNode = null;
+ HuffmanNode leftNode = null;
+ HuffmanNode rightNode = null;
+
+ List nodes = new List();
+ int nodeValue = 0;
+
+ for (int i = 0; i < nodeCount; i++)
+ {
+ HuffmanNode n = new HuffmanNode() { Value = reader.ReadUInt() };
+
+ int idx = nodes.FindIndex((HuffmanNode a) => { return a.Value == n.Value; });
+ if (idx != -1)
+ n = nodes[idx];
+
+ if (leftNode == null)
+ {
+ leftNode = n;
+ }
+ else if (rightNode == null)
+ {
+ rightNode = n;
+ if (idx == -1)
+ nodes.Add(rightNode);
+
+ n = new HuffmanNode
+ {
+ Value = (uint)nodeValue++,
+ };
+ n.SetLeftNode(leftNode);
+ n.SetRightNode(rightNode);
+
+ rootNode = n;
+
+ leftNode = null;
+ rightNode = null;
+ idx = -1;
+
+ }
+
+ if (idx == -1)
+ nodes.Add(n);
+ }
+
+ supportedCharacters = GetLeafCharacters(nodes);
+
+ return rootNode;
+ }
+
+ private static List GetLeafCharacters(List nodes)
+ {
+ List leafCharacters = new List();
+ foreach (HuffmanNode node in nodes)
+ {
+ if (node.Left == null && node.Right == null
+ // exclude letter 0x00 / value 0xFFFF as that is used as end text marker
+ && (node.Value != uint.MaxValue))
+ {
+ leafCharacters.Add(node.Letter);
+ }
+ }
+
+ leafCharacters.Sort();
+ return leafCharacters;
+ }
+
+ ///
+ /// For the sub tree starting at the given node, this method returns all the tree elements without the root.
+ /// Note that the list representation tries to represent the tree bottom-up, starting with the most left side node at leach depth level.
+ ///
+ /// The root of the tree of which to return all nodes as list.
+ /// The list of all nodes in the tree, excluding the root.
+ /// This does not work for creating the list to Write! Use GetNodeListToWrite for that instead!
+ public static List GetNodeList(HuffmanNode rootNode)
+ {
+
+ List nodesSansRoot = new List();
+
+ if(rootNode == null)
+ {
+ App.Logger.Log("Given Root Node was null!");
+ return nodesSansRoot;
+ }
+
+ bool hasNextLevel = true;
+ List nextLevel = new List { rootNode };
+ while (hasNextLevel)
+ {
+ nextLevel = GetNextLevel(nextLevel);
+ nodesSansRoot.AddRange(nextLevel);
+
+ hasNextLevel = nextLevel.Any();
+ }
+
+ nodesSansRoot.Reverse();
+
+ return nodesSansRoot;
+ }
+
+ ///
+ /// Returns a list with all the children of the nodes in the given list. For each node in the given list the right child is added before the left one.
+ ///
+ /// the list of currently selected nodes
+ /// the list of child nodes
+ public static List GetNextLevel(List currentLevel)
+ {
+ List nextLevel = new List(currentLevel.Count * 2);
+ foreach (HuffmanNode n in currentLevel)
+ {
+ if (n.Right != null)
+ nextLevel.Add(n.Right);
+
+ if (n.Left != null)
+ nextLevel.Add(n.Left);
+ }
+ return nextLevel;
+ }
+
+ ///
+ /// Recalculates the nodelist to write to the resource based on the single remembered root node.
+ ///
+ ///
+ /// list of nodes in the order to write
+ public static List GetNodeListToWrite(HuffmanNode rootNode)
+ {
+ List nodesSansRoot = new List();
+
+ if (rootNode == null)
+ {
+ App.Logger.Log("Given root node was null!");
+ return nodesSansRoot;
+ }
+
+ // get all branches
+ List branches = GetAllBranchNodes( new List() { rootNode });
+
+ // sort branches by their value, so that the write out can happen in the correct order
+ branches.Sort();
+
+ // add all the children in the order of their parent's value
+ foreach(HuffmanNode branch in branches)
+ {
+ nodesSansRoot.Add(branch.Left);
+ nodesSansRoot.Add(branch.Right);
+ }
+
+ return nodesSansRoot;
+ }
+
+ private static List GetAllBranchNodes(List currentNodes)
+ {
+ List branchNodes = new List();
+
+ foreach(HuffmanNode currentNode in currentNodes)
+ {
+ if(currentNode.Left != null && currentNode.Right != null)
+ {
+ branchNodes.Add(currentNode);
+ branchNodes.AddRange(
+ GetAllBranchNodes( new List() { currentNode.Left, currentNode.Right }));
+ }
+ }
+
+ return branchNodes;
+ }
+
+ ///
+ /// Returns a dictionary of characters to their encoded bit representation.
+ ///
+ /// The (limited) list of Huffman code nodes used.
+ /// Dictionary of char - code values
+ public static Dictionary> GetCharEncoding(List encodingNodes)
+ {
+
+ Dictionary> charEncodings = new Dictionary>();
+
+ foreach (HuffmanNode node in encodingNodes)
+ {
+ if (node.Left == null && node.Right == null)
+ {
+ char c = node.Letter;
+ List path = GetCharEncoding(node);
+
+ charEncodings.Add(c, path);
+ }
+ }
+ return charEncodings;
+ }
+
+ ///
+ /// Return the encoding for the given node as path in the tree.
+ ///
+ /// The node for which to find the encoding.
+ /// the encoding as list of bools.
+ private static List GetCharEncoding(HuffmanNode node)
+ {
+ HuffmanNode parent = node.Parent;
+ if (parent == null)
+ {
+ return new List();
+ }
+
+ List encoding = GetCharEncoding(parent);
+
+ if (node == parent.Left)
+ {
+ encoding.Add(false);
+ }
+ else if (node == parent.Right)
+ {
+ encoding.Add(true);
+ }
+ else
+ {
+ throw new InvalidOperationException(
+ string.Format(
+ "Trying to find encoding for node <{0}> failed to to incorrect setup tree!",
+ node.ToString()));
+ }
+
+ return encoding;
+ }
+
+ ///