ModernUO/Projects/Server/Localization/Localization.cs
Kamron Batman 79f736aa83
fix: keep the fallback localization table registered when emptied (#2724)
## Summary
`Localization.Remove` drops a language's dictionary from `_localizations` once its last entry is removed. For the fallback language (`enu`), `_fallbackEntries` still pointed at the dropped dictionary. A later `Add("enu", ...)` then created a new dictionary, and because `_fallbackEntries` was not null, `Add`'s `??=` left it on the old one. Every fallback lookup missed the new entries.

The fallback table now stays registered when it empties. Later adds land in the dictionary lookups read, and removed entries stay removed. Nulling `_fallbackEntries` instead would have made the next lookup reload `cliloc.enu` from disk and bring back the removed entries.

Nothing in production calls `Remove` today. It is public API for shards and tests; #2722 adds a test cleanup that removes entries.

## Tests
- `LocalizationEntryTests.RemovingTheLastFallbackEntry_KeepsLaterAddsVisible` clears the tables, adds then removes one `enu` entry, adds another, and checks that the fallback lookup finds the new entry and not the removed one. It clears again afterwards so later tests reload the real tables. It fails without the fix.
- Server.Tests: all pass.
2026-10-18 20:10:39 -07:00

236 lines
9.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: Localization.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text;
using Server.Buffers;
namespace Server;
public static class Localization
{
private const bool _loadLocalizationOnStartup = false;
public const string FallbackLanguage = "enu";
private static Dictionary<int, LocalizationEntry> _fallbackEntries;
private static readonly Dictionary<string, Dictionary<int, LocalizationEntry>> _localizations = new();
public static void Configure()
{
if (_loadLocalizationOnStartup)
{
foreach (var file in Core.FindDataFileByPattern("cliloc.*"))
{
var fi = new FileInfo(file);
LoadClilocs(fi.Extension.ToLowerInvariant(), file);
}
}
}
public static void Add(string lang, int number, string text)
{
lang = lang?.ToLower() ?? FallbackLanguage;
var entry = new LocalizationEntry(lang, number, text);
if (!_localizations.TryGetValue(lang, out var entries))
{
entries = new Dictionary<int, LocalizationEntry>();
_localizations[lang] = entries;
if (lang == FallbackLanguage)
{
_fallbackEntries ??= entries;
}
}
entries.Add(number, entry);
}
public static bool Remove(string lang, int number)
{
lang = lang?.ToLower() ?? FallbackLanguage;
if (!_localizations.TryGetValue(lang, out var entries) || !entries.Remove(number))
{
return false;
}
// The fallback table stays registered even when empty: _fallbackEntries points at it, and a
// replacement created by a later Add would be invisible to fallback lookups.
if (entries.Count == 0 && entries != _fallbackEntries)
{
_localizations.Remove(lang);
}
return true;
}
public static void Clear()
{
_localizations.Clear();
_fallbackEntries = null;
}
public static Dictionary<int, LocalizationEntry> LoadClilocs(string lang) =>
LoadClilocs(lang, Core.FindDataFile($"cliloc.{lang}", false));
private static Dictionary<int, LocalizationEntry> LoadClilocs(string lang, string file)
{
lang = lang?.ToLower() ?? FallbackLanguage;
var entries = _localizations[lang] = new Dictionary<int, LocalizationEntry>();
if (lang == FallbackLanguage)
{
_fallbackEntries = entries;
}
if (File.Exists(file))
{
using var fs = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read);
Span<byte> header = stackalloc byte[6];
_ = fs.Read(header);
byte[] data;
BufferReader br;
if (BinaryPrimitives.ReadInt32LittleEndian(header) != 2 || BinaryPrimitives.ReadInt16LittleEndian(header[4..]) != 1)
{
// Skip header
fs.Position = 4;
data = BwtDecompress.Decompress(fs, (int)fs.Length - 4);
br = new BufferReader(data);
var header2 = br.ReadInt(); // Header 2
var header1 = br.ReadShort(); // Header 1
if (header2 != 2 || header1 != 1)
{
throw new Exception($"Invalid cliloc header in {file}");
}
}
else
{
data = GC.AllocateUninitializedArray<byte>((int)fs.Length - 6);
_ = fs.Read(data);
br = new BufferReader(data);
}
byte[] buffer = null;
while (br.Position < data.Length)
{
var number = br.ReadInt();
var flag = br.ReadByte(); // Original, Custom, Modified
var length = br.ReadShort();
if (buffer == null || buffer.Length < length)
{
buffer = GC.AllocateUninitializedArray<byte>(length);
}
br.Read(buffer.AsSpan(0, length));
var text = Encoding.UTF8.GetString(buffer.AsSpan(0, length));
entries[number] = new LocalizationEntry(lang, number, text);
}
}
return entries;
}
/// <summary>
/// Returns the original text for a localization entry.
/// </summary>
/// <param name="number">Localization number</param>
/// <param name="lang">Language in ISO 639‑2 format</param>
/// <returns>Original text for the localization entry</returns>
public static string GetText(int number, string lang = FallbackLanguage) =>
TryGetLocalization(lang, number, out var entry) ? entry.Text : null;
/// <summary>
/// Gets a localization entry using the <see cref="FallbackLanguage" />.
/// </summary>
/// <param name="number">Localization number</param>
/// <param name="entry">Localization entry retrieved</param>
/// <returns>True if the entry exists, otherwise false.</returns>
public static bool TryGetLocalization(int number, out LocalizationEntry entry) =>
TryGetLocalization(FallbackLanguage, number, out entry);
/// <summary>
/// Gets a localization entry.
/// </summary>
/// <param name="lang">Language in ISO 639-2 format</param>
/// <param name="number">Localization number</param>
/// <param name="entry">Localization entry retrieved</param>
/// <returns>True if the entry exists, otherwise false.</returns>
public static bool TryGetLocalization(string lang, int number, out LocalizationEntry entry)
{
lang = lang?.ToLower() ?? FallbackLanguage;
if (lang != FallbackLanguage)
{
if (!_localizations.TryGetValue(lang, out var entries))
{
entries = LoadClilocs(lang);
}
if (entries.TryGetValue(number, out entry))
{
return true;
}
}
_fallbackEntries ??= LoadClilocs(FallbackLanguage);
return _fallbackEntries.TryGetValue(number, out entry);
}
/// <summary>
/// Creates a formatted string of the localization entry using the specified language.
/// Uses string interpolation under the hood. This method is preferably relative to the object array method signature.
/// Example:
/// Localization.Format(1073841, "jpn", $"{totalItems}{maxItems}{totalWeight}");
/// </summary>
/// <param name="lang">Language in ISO 639-2 format</param>
/// <param name="number">Localization number</param>
/// <param name="handler">interpolated string handler used by the compiler as a string builder during compilation</param>
/// <returns>A copy of the localization text where the placeholder arguments have been replaced with string representations of the provided interpolation arguments</returns>
public static PooledArraySpanFormattable Format(
int number, string lang,
[InterpolatedStringHandlerArgument("number", "lang")]
ref LocalizationEntry.LocalizationInterpolationHandler handler
)
{
var chars = handler.ToPooledArray(out var length);
handler = default; // Defensive clear
return new PooledArraySpanFormattable(chars, length);
}
/// <summary>
/// Creates a formatted string of the localization entry using the <see cref="FallbackLanguage" />.
/// Uses string interpolation under the hood. This method is preferably relative to the object array method signature.
/// Example:
/// Localization.Format(1073841, $"{totalItems}{maxItems}{totalWeight}");
/// </summary>
/// <param name="number">Localization number</param>
/// <param name="handler">interpolated string handler used by the compiler as a string builder during compilation</param>
/// <returns>A copy of the localization text where the placeholder arguments have been replaced with string representations of the provided interpolation arguments</returns>
public static PooledArraySpanFormattable Format(
int number,
[InterpolatedStringHandlerArgument("number")]
ref LocalizationEntry.LocalizationInterpolationHandler handler
)
{
var chars = handler.ToPooledArray(out var length);
handler = default; // Defensive clear
return new PooledArraySpanFormattable(chars, length);
}
}