Expose mod.yaml content to localisation.

Mod metadata, load screens and mod content is all now sourced from ftl files, allowing these items to be translated.

Translations are now initialized as part of ModData creation, as currently they are made available too late for the usage we need here.

The "modcontent" mod learns a new parameter for "Content.TranslationFile" - this allows a mod to provide the path of a translation file to the mod which it can load. This allows mods such as ra, cnc, d2k, ts to own the translations for their ModContent, yet still make them accessible to the modcontent mod.

CheckFluentReference learns to validate all these new fields to ensure translations have been set.
This commit is contained in:
RoosterDragon
2024-09-23 19:58:33 +01:00
committed by Gustas
parent d1583e8587
commit bb17cfa179
36 changed files with 292 additions and 144 deletions

View File

@@ -48,7 +48,12 @@ namespace OpenRA.Mods.Common.FileSystem
if (contentInstalled)
return false;
Game.InitializeMod(content.ContentInstallerMod, new Arguments("Content.Mod=" + modData.Manifest.Id));
string translationPath;
using (var fs = (FileStream)modData.DefaultFileSystem.Open(content.Translation))
translationPath = fs.Name;
Game.InitializeMod(
content.ContentInstallerMod,
new Arguments(new[] { "Content.Mod=" + modData.Manifest.Id, "Content.TranslationFile=" + translationPath }));
return true;
}
}

View File

@@ -43,16 +43,17 @@ namespace OpenRA.Mods.Common.Lint
var mapTranslations = FieldLoader.GetValue<string[]>("value", map.TranslationDefinitions.Value);
foreach (var language in GetModLanguages(modData))
var allModTranslations = modData.Manifest.Translations.Append(modData.Manifest.Get<ModContent>().Translation).ToArray();
foreach (var language in GetModLanguages(allModTranslations))
{
// Check keys and variables are not missing across all language files.
// But for maps we don't warn on unused keys. They might be unused on *this* map,
// but the mod or another map may use them and we don't have sight of that.
CheckKeys(
modData.Manifest.Translations.Concat(mapTranslations), map.Open, usedKeys,
allModTranslations.Concat(mapTranslations), map.Open, usedKeys,
language, _ => false, emitError, emitWarning);
var modFluentBundle = new FluentBundle(language, modData.Manifest.Translations, modData.DefaultFileSystem, _ => { });
var modFluentBundle = new FluentBundle(language, allModTranslations, modData.DefaultFileSystem, _ => { });
var mapFluentBundle = new FluentBundle(language, mapTranslations, map, error => emitError(error.Message));
foreach (var group in usedKeys.KeysWithContext)
@@ -78,14 +79,15 @@ namespace OpenRA.Mods.Common.Lint
foreach (var context in usedKeys.EmptyKeyContexts)
emitWarning($"Empty key in mod translation files required by {context}");
foreach (var language in GetModLanguages(modData))
var allModTranslations = modData.Manifest.Translations.Append(modData.Manifest.Get<ModContent>().Translation).ToArray();
foreach (var language in GetModLanguages(allModTranslations))
{
Console.WriteLine($"Testing language: {language}");
CheckModWidgets(modData, usedKeys, testedFields);
// With the fully populated keys, check keys and variables are not missing and not unused across all language files.
var keyWithAttrs = CheckKeys(
modData.Manifest.Translations, modData.DefaultFileSystem.Open, usedKeys,
allModTranslations, modData.DefaultFileSystem.Open, usedKeys,
language,
file =>
!modData.Manifest.AllowUnusedTranslationsInExternalPackages ||
@@ -113,9 +115,9 @@ namespace OpenRA.Mods.Common.Lint
$"`{field.ReflectedType.Name}.{field.Name}` - previous warnings may be incorrect");
}
static IEnumerable<string> GetModLanguages(ModData modData)
static IEnumerable<string> GetModLanguages(IEnumerable<string> translations)
{
return modData.Manifest.Translations
return translations
.Select(filename => FilenameRegex.Match(filename).Groups["language"].Value)
.Distinct()
.OrderBy(l => l);
@@ -249,51 +251,55 @@ namespace OpenRA.Mods.Common.Lint
.Where(t => t.IsSubclassOf(typeof(TraitInfo)) || t.IsSubclassOf(typeof(Warhead)))
.SelectMany(t => t.GetFields().Where(f => f.HasAttribute<FluentReferenceAttribute>())));
// HACK: Need to hardcode the custom loader for GameSpeeds.
var gameSpeeds = modData.Manifest.Get<GameSpeeds>();
var gameSpeedNameField = typeof(GameSpeed).GetField(nameof(GameSpeed.Name));
var gameSpeedFluentReference = Utility.GetCustomAttributes<FluentReferenceAttribute>(gameSpeedNameField, true)[0];
testedFields.Add(gameSpeedNameField);
foreach (var speed in gameSpeeds.Speeds.Values)
usedKeys.Add(speed.Name, gameSpeedFluentReference, $"`{nameof(GameSpeed)}.{nameof(GameSpeed.Name)}`");
// TODO: linter does not work with LoadUsing
GetUsedTranslationKeysFromFieldsWithTranslationReferenceAttribute(
usedKeys, testedFields, Utility.GetFields(typeof(GameSpeed)), modData.Manifest.Get<GameSpeeds>().Speeds.Values);
// TODO: linter does not work with LoadUsing
foreach (var actorInfo in modData.DefaultRules.Actors)
GetUsedTranslationKeysFromFieldsWithTranslationReferenceAttribute(
usedKeys, testedFields,
Utility.GetFields(typeof(ResourceRendererInfo.ResourceTypeInfo)),
modData.DefaultRules.Actors
.SelectMany(actorInfo => actorInfo.Value.TraitInfos<ResourceRendererInfo>())
.SelectMany(info => info.ResourceTypes.Values));
const BindingFlags Binding = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static;
var constFields = modData.ObjectCreator.GetTypes().SelectMany(modType => modType.GetFields(Binding)).Where(f => f.IsLiteral);
GetUsedTranslationKeysFromFieldsWithTranslationReferenceAttribute(
usedKeys, testedFields, constFields, new[] { (object)null });
var modMetadataFields = typeof(ModMetadata).GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
GetUsedTranslationKeysFromFieldsWithTranslationReferenceAttribute(
usedKeys, testedFields, modMetadataFields, new[] { modData.Manifest.Metadata });
var modContent = modData.Manifest.Get<ModContent>();
GetUsedTranslationKeysFromFieldsWithTranslationReferenceAttribute(
usedKeys, testedFields, Utility.GetFields(typeof(ModContent)), new[] { modContent });
GetUsedTranslationKeysFromFieldsWithTranslationReferenceAttribute(
usedKeys, testedFields, Utility.GetFields(typeof(ModContent.ModPackage)), modContent.Packages.Values);
return (usedKeys, testedFields);
}
static void GetUsedTranslationKeysFromFieldsWithTranslationReferenceAttribute(
Keys usedKeys, List<FieldInfo> testedFields,
IEnumerable<FieldInfo> newFields, IEnumerable<object> objects)
{
var fieldsWithAttribute =
newFields
.Select(f => (Field: f, FluentReference: Utility.GetCustomAttributes<FluentReferenceAttribute>(f, true).SingleOrDefault()))
.Where(x => x.FluentReference != null)
.ToArray();
testedFields.AddRange(fieldsWithAttribute.Select(x => x.Field));
foreach (var obj in objects)
{
foreach (var info in actorInfo.Value.TraitInfos<ResourceRendererInfo>())
foreach (var (field, fluentReference) in fieldsWithAttribute)
{
var resourceTypeNameField = typeof(ResourceRendererInfo.ResourceTypeInfo).GetField(nameof(ResourceRendererInfo.ResourceTypeInfo.Name));
var resourceTypeFluentReference = Utility.GetCustomAttributes<FluentReferenceAttribute>(resourceTypeNameField, true)[0];
testedFields.Add(resourceTypeNameField);
foreach (var resourceTypes in info.ResourceTypes)
usedKeys.Add(
resourceTypes.Value.Name,
resourceTypeFluentReference,
$"`{nameof(ResourceRendererInfo.ResourceTypeInfo)}.{nameof(ResourceRendererInfo.ResourceTypeInfo.Name)}`");
}
}
foreach (var modType in modData.ObjectCreator.GetTypes())
{
const BindingFlags Binding = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static;
foreach (var field in modType.GetFields(Binding))
{
// Checking for constant string fields.
if (!field.IsLiteral)
continue;
var fluentReference = Utility.GetCustomAttributes<FluentReferenceAttribute>(field, true).SingleOrDefault();
if (fluentReference == null)
continue;
testedFields.Add(field);
var keys = LintExts.GetFieldValues(null, field, fluentReference.DictionaryReference);
var keys = LintExts.GetFieldValues(obj, field, fluentReference.DictionaryReference);
foreach (var key in keys)
usedKeys.Add(key, fluentReference, $"`{field.ReflectedType.Name}.{field.Name}`");
}
}
return (usedKeys, testedFields);
}
static void CheckModWidgets(ModData modData, Keys usedKeys, List<FieldInfo> testedFields)

View File

@@ -30,10 +30,11 @@ namespace OpenRA.Mods.Common.Lint
void ILintPass.Run(Action<string> emitError, Action<string> emitWarning, ModData modData)
{
Run(emitError, emitWarning, modData.DefaultFileSystem, modData.Manifest.Translations);
var allModTranslations = modData.Manifest.Translations.Append(modData.Manifest.Get<ModContent>().Translation);
Run(emitError, emitWarning, modData.DefaultFileSystem, allModTranslations);
}
static void Run(Action<string> emitError, Action<string> emitWarning, IReadOnlyFileSystem fileSystem, string[] paths)
static void Run(Action<string> emitError, Action<string> emitWarning, IReadOnlyFileSystem fileSystem, IEnumerable<string> paths)
{
foreach (var path in paths)
{

View File

@@ -9,7 +9,9 @@
*/
#endregion
using System;
using System.Collections.Generic;
using System.Linq;
using OpenRA.Graphics;
using OpenRA.Mods.Common.Widgets;
using OpenRA.Primitives;
@@ -18,6 +20,9 @@ namespace OpenRA.Mods.Common.LoadScreens
{
public sealed class LogoStripeLoadScreen : SheetLoadScreen
{
[FluentReference]
const string Loading = "loadscreen-loading";
Rectangle stripeRect;
float2 logoPos;
Sprite stripe, logo;
@@ -26,14 +31,13 @@ namespace OpenRA.Mods.Common.LoadScreens
int lastDensity;
Size lastResolution;
string[] messages = { "Loading..." };
string[] messages = Array.Empty<string>();
public override void Init(ModData modData, Dictionary<string, string> info)
{
base.Init(modData, info);
if (info.TryGetValue("Text", out var text))
messages = text.Split(',');
messages = FluentProvider.GetString(Loading).Split(',').Select(x => x.Trim()).ToArray();
}
public override void DisplayInner(Renderer r, Sheet s, int density)
@@ -59,7 +63,7 @@ namespace OpenRA.Mods.Common.LoadScreens
if (logo != null)
r.RgbaSpriteRenderer.DrawSprite(logo, logoPos);
if (r.Fonts != null)
if (r.Fonts != null && messages.Length > 0)
{
var text = messages.Random(Game.CosmeticRandom);
var textSize = r.Fonts["Bold"].Measure(text);

View File

@@ -52,6 +52,10 @@ namespace OpenRA.Mods.Common.LoadScreens
if (modId == null || !Game.Mods.TryGetValue(modId, out var selectedMod))
throw new InvalidOperationException("Invalid or missing Content.Mod argument.");
var translationFilePath = args.GetValue("Content.TranslationFile", null);
if (translationFilePath == null || !File.Exists(translationFilePath))
throw new InvalidOperationException("Invalid or missing Content.TranslationFile argument.");
var content = selectedMod.Get<ModContent>(Game.ModData.ObjectCreator);
Ui.LoadWidget("MODCONTENT_BACKGROUND", Ui.Root, new WidgetArgs());
@@ -63,6 +67,7 @@ namespace OpenRA.Mods.Common.LoadScreens
{ "continueLoading", () => Game.RunAfterTick(() => Game.InitializeMod(modId, new Arguments())) },
{ "mod", selectedMod },
{ "content", content },
{ "translationFilePath", translationFilePath },
};
Ui.OpenWindow("CONTENT_PROMPT_PANEL", widgetArgs);
@@ -71,9 +76,10 @@ namespace OpenRA.Mods.Common.LoadScreens
{
var widgetArgs = new WidgetArgs
{
{ "onCancel", () => Game.RunAfterTick(() => Game.InitializeMod(modId, new Arguments())) },
{ "mod", selectedMod },
{ "content", content },
{ "onCancel", () => Game.RunAfterTick(() => Game.InitializeMod(modId, new Arguments())) }
{ "translationFilePath", translationFilePath },
};
Ui.OpenWindow("CONTENT_PANEL", widgetArgs);

View File

@@ -21,6 +21,7 @@ namespace OpenRA
{
public class ModPackage
{
[FluentReference]
public readonly string Title;
public readonly string Identifier;
public readonly string[] TestFiles = Array.Empty<string>();
@@ -100,10 +101,13 @@ namespace OpenRA
}
}
[FluentReference]
public readonly string InstallPromptMessage;
public readonly string QuickDownload;
[FluentReference]
public readonly string HeaderMessage;
public readonly string ContentInstallerMod = "modcontent";
public readonly string Translation;
[FieldLoader.LoadUsing(nameof(LoadPackages))]
public readonly Dictionary<string, ModPackage> Packages = new();

View File

@@ -62,7 +62,7 @@ namespace OpenRA.Mods.Common.UtilityCommands
var maps = new List<(IReadWritePackage Package, string Map)>();
if (args.Length < 2)
{
Console.WriteLine($"Testing mod: {modData.Manifest.Metadata.Title}");
Console.WriteLine($"Testing mod: {modData.Manifest.Metadata.TitleTranslated}");
// Run all rule checks on the default mod rules.
CheckRules(modData, modData.DefaultRules);

View File

@@ -193,7 +193,7 @@ namespace OpenRA.Mods.Common.Widgets.Logic
var switchButton = panel.Get<ButtonWidget>("SWITCH_BUTTON");
var mod = CurrentServerSettings.ServerExternalMod;
var modTitle = mod.Title;
var modTitle = mod.Id;
var modVersion = mod.Version;
switchButton.OnClick = () =>

View File

@@ -87,6 +87,7 @@ namespace OpenRA.Mods.Common.Widgets.Logic
readonly ModData modData;
readonly ModContent content;
readonly Dictionary<string, ModContent.ModSource> sources;
readonly FluentBundle externalFluentBundle;
readonly Widget panel;
readonly LabelWidget titleLabel;
@@ -116,11 +117,13 @@ namespace OpenRA.Mods.Common.Widgets.Logic
Mode visible = Mode.Progress;
[ObjectCreator.UseCtor]
public InstallFromSourceLogic(Widget widget, ModData modData, ModContent content, Dictionary<string, ModContent.ModSource> sources)
public InstallFromSourceLogic(
Widget widget, ModData modData, ModContent content, Dictionary<string, ModContent.ModSource> sources, FluentBundle externalFluentBundle)
{
this.modData = modData;
this.content = content;
this.sources = sources;
this.externalFluentBundle = externalFluentBundle;
Log.AddChannel("install", "install.log");
@@ -339,7 +342,8 @@ namespace OpenRA.Mods.Common.Widgets.Logic
{
var containerWidget = (ContainerWidget)checkboxListTemplate.Clone();
var checkboxWidget = containerWidget.Get<CheckboxWidget>("PACKAGE_CHECKBOX");
checkboxWidget.GetText = () => package.Title;
var title = externalFluentBundle.GetString(package.Title);
checkboxWidget.GetText = () => title;
checkboxWidget.IsDisabled = () => package.Required;
checkboxWidget.IsChecked = () => selectedPackages[package.Identifier];
checkboxWidget.OnClick = () => selectedPackages[package.Identifier] = !selectedPackages[package.Identifier];

View File

@@ -11,6 +11,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using OpenRA.FileSystem;
using OpenRA.Widgets;
@@ -30,10 +31,12 @@ namespace OpenRA.Mods.Common.Widgets.Logic
readonly Dictionary<string, ModContent.ModSource> sources = new();
readonly Dictionary<string, ModContent.ModDownload> downloads = new();
readonly FluentBundle externalFluentBundle;
bool sourceAvailable;
[ObjectCreator.UseCtor]
public ModContentLogic(Widget widget, Manifest mod, ModContent content, Action onCancel)
public ModContentLogic(Widget widget, Manifest mod, ModContent content, Action onCancel, string translationFilePath)
{
this.content = content;
@@ -58,20 +61,25 @@ namespace OpenRA.Mods.Common.Widgets.Logic
modFileSystem.UnmountAll();
externalFluentBundle = new FluentBundle(Game.Settings.Player.Language, File.ReadAllText(translationFilePath), _ => { });
scrollPanel = panel.Get<ScrollPanelWidget>("PACKAGES");
template = scrollPanel.Get<ContainerWidget>("PACKAGE_TEMPLATE");
var headerTemplate = panel.Get<LabelWidget>("HEADER_TEMPLATE");
var headerLines = !string.IsNullOrEmpty(content.HeaderMessage) ? content.HeaderMessage.Replace("\\n", "\n").Split('\n') : Array.Empty<string>();
var headerLines =
!string.IsNullOrEmpty(content.HeaderMessage)
? externalFluentBundle.GetString(content.HeaderMessage)
: null;
var headerHeight = 0;
foreach (var l in headerLines)
if (headerLines != null)
{
var line = (LabelWidget)headerTemplate.Clone();
line.GetText = () => l;
line.Bounds.Y += headerHeight;
panel.AddChild(line);
var label = (LabelWidget)headerTemplate.Clone();
label.GetText = () => headerLines;
label.IncreaseHeightToFitCurrentText();
panel.AddChild(label);
headerHeight += headerTemplate.Bounds.Height;
headerHeight += label.Bounds.Height;
}
panel.Bounds.Height += headerHeight;
@@ -85,7 +93,8 @@ namespace OpenRA.Mods.Common.Widgets.Logic
sourceButton.OnClick = () => Ui.OpenWindow("SOURCE_INSTALL_PANEL", new WidgetArgs
{
{ "sources", sources },
{ "content", content }
{ "content", content },
{ "externalFluentBundle", externalFluentBundle },
});
var backButton = panel.Get<ButtonWidget>("BACK_BUTTON");
@@ -109,7 +118,7 @@ namespace OpenRA.Mods.Common.Widgets.Logic
{
var container = template.Clone();
var titleWidget = container.Get<LabelWidget>("TITLE");
var title = p.Value.Title;
var title = externalFluentBundle.GetString(p.Value.Title);
titleWidget.GetText = () => title;
var requiredWidget = container.Get<LabelWidget>("REQUIRED");

View File

@@ -27,14 +27,17 @@ namespace OpenRA.Mods.Common.Widgets.Logic
const string Quit = "button-quit";
readonly ModContent content;
readonly FluentBundle externalFluentBundle;
bool requiredContentInstalled;
[ObjectCreator.UseCtor]
public ModContentPromptLogic(ModData modData, Widget widget, Manifest mod, ModContent content, Action continueLoading)
public ModContentPromptLogic(ModData modData, Widget widget, Manifest mod, ModContent content, Action continueLoading, string translationFilePath)
{
this.content = content;
CheckRequiredContentInstalled();
externalFluentBundle = new FluentBundle(Game.Settings.Player.Language, File.ReadAllText(translationFilePath), _ => { });
var continueMessage = FluentProvider.GetString(Continue);
var quitMessage = FluentProvider.GetString(Quit);
@@ -42,17 +45,17 @@ namespace OpenRA.Mods.Common.Widgets.Logic
var headerTemplate = panel.Get<LabelWidget>("HEADER_TEMPLATE");
var headerLines =
!string.IsNullOrEmpty(content.InstallPromptMessage)
? content.InstallPromptMessage.Replace("\\n", "\n").Split('\n')
: Array.Empty<string>();
? externalFluentBundle.GetString(content.InstallPromptMessage)
: null;
var headerHeight = 0;
foreach (var l in headerLines)
if (headerLines != null)
{
var line = (LabelWidget)headerTemplate.Clone();
line.GetText = () => l;
line.Bounds.Y += headerHeight;
panel.AddChild(line);
var label = (LabelWidget)headerTemplate.Clone();
label.GetText = () => headerLines;
label.IncreaseHeightToFitCurrentText();
panel.AddChild(label);
headerHeight += headerTemplate.Bounds.Height;
headerHeight += label.Bounds.Height;
}
panel.Bounds.Height += headerHeight;
@@ -64,9 +67,10 @@ namespace OpenRA.Mods.Common.Widgets.Logic
{
Ui.OpenWindow("CONTENT_PANEL", new WidgetArgs
{
{ "onCancel", CheckRequiredContentInstalled },
{ "mod", mod },
{ "content", content },
{ "onCancel", CheckRequiredContentInstalled }
{ "translationFilePath", translationFilePath },
});
};

View File

@@ -93,7 +93,12 @@ namespace OpenRA.Mods.Common.Widgets.Logic
return;
var content = modData.Manifest.Get<ModContent>();
Game.InitializeMod(content.ContentInstallerMod, new Arguments(new[] { "Content.Mod=" + modData.Manifest.Id }));
string translationPath;
using (var fs = (FileStream)modData.DefaultFileSystem.Open(content.Translation))
translationPath = fs.Name;
Game.InitializeMod(
content.ContentInstallerMod,
new Arguments(new[] { "Content.Mod=" + modData.Manifest.Id, "Content.TranslationFile=" + translationPath }));
});
};
}