feat(api/runtime): java-style assets and localization sync

This commit is contained in:
Jacobwasbeast
2026-03-10 14:36:23 -05:00
parent 36094e0ea9
commit 70dbff3fac
38 changed files with 794 additions and 110 deletions

View File

@@ -1,3 +1,5 @@
using WeaveLoader.API;
namespace WeaveLoader.API.Block;
/// <summary>
@@ -62,7 +64,7 @@ public class BlockProperties
internal float LightEmissionValue = 0.0f;
internal int LightBlockValue = 255;
internal CreativeTab CreativeTabValue = CreativeTab.None;
internal string? NameValue;
internal Text? NameValue;
internal int RequiredHarvestLevelValue = -1;
internal ToolType RequiredToolValue = ToolType.None;
internal bool AcceptsRedstonePowerValue;
@@ -71,14 +73,19 @@ public class BlockProperties
public BlockProperties Hardness(float hardness) { HardnessValue = hardness; return this; }
public BlockProperties Resistance(float resistance) { ResistanceValue = resistance; return this; }
public BlockProperties Sound(SoundType sound) { SoundValue = sound; return this; }
/// <summary>Icon name in the terrain atlas. Use namespaced ID for mod textures (e.g. "examplemod:ruby_ore" from assets/blocks/ruby_ore.png), or vanilla names like "stone", "gold_ore".</summary>
/// <summary>
/// Icon name in the terrain atlas. Use Java-style names like "examplemod:block/ruby_ore"
/// from assets/examplemod/textures/block/ruby_ore.png, or vanilla names like "stone", "gold_ore".
/// </summary>
public BlockProperties Icon(string iconName) { IconValue = iconName; return this; }
public BlockProperties LightLevel(float level) { LightEmissionValue = level; return this; }
public BlockProperties LightBlocking(int level) { LightBlockValue = level; return this; }
public BlockProperties Indestructible() { HardnessValue = -1.0f; ResistanceValue = 6000000f; return this; }
public BlockProperties InCreativeTab(CreativeTab tab) { CreativeTabValue = tab; return this; }
/// <summary>Display name shown in-game (e.g. "Ruby Ore"). Used for localization.</summary>
public BlockProperties Name(string displayName) { NameValue = displayName; return this; }
public BlockProperties Name(string displayName) { NameValue = Text.Literal(displayName); return this; }
/// <summary>Localized display name using a language key (e.g. "block.examplemod.ruby_ore").</summary>
public BlockProperties Name(Text text) { NameValue = text; return this; }
/// <summary>Minimum harvest level required to properly mine this block (e.g. 3 for obsidian). -1 means no requirement.</summary>
public BlockProperties RequiredHarvestLevel(int level) { RequiredHarvestLevelValue = level; return this; }
/// <summary>Tool type required to harvest this block (e.g. Pickaxe for stone-like blocks).</summary>

View File

@@ -59,7 +59,7 @@ public static class BlockRegistry
properties.IconValue,
properties.LightEmissionValue,
properties.LightBlockValue,
properties.NameValue ?? "",
properties.NameValue?.Resolve() ?? "",
properties.RequiredHarvestLevelValue,
(int)properties.RequiredToolValue,
properties.AcceptsRedstonePowerValue ? 1 : 0);
@@ -98,7 +98,7 @@ public static class BlockRegistry
properties.IconValue,
properties.LightEmissionValue,
properties.LightBlockValue,
properties.NameValue ?? "",
properties.NameValue?.Resolve() ?? "",
properties.RequiredHarvestLevelValue,
(int)properties.RequiredToolValue,
properties.AcceptsRedstonePowerValue ? 1 : 0);
@@ -144,7 +144,7 @@ public static class BlockRegistry
properties.IconValue,
properties.LightEmissionValue,
properties.LightBlockValue,
properties.NameValue ?? "",
properties.NameValue?.Resolve() ?? "",
properties.RequiredHarvestLevelValue,
(int)properties.RequiredToolValue,
properties.AcceptsRedstonePowerValue ? 1 : 0);
@@ -191,7 +191,7 @@ public static class BlockRegistry
properties.IconValue,
properties.LightEmissionValue,
properties.LightBlockValue,
properties.NameValue ?? "",
properties.NameValue?.Resolve() ?? "",
properties.RequiredHarvestLevelValue,
(int)properties.RequiredToolValue,
properties.AcceptsRedstonePowerValue ? 1 : 0,

View File

@@ -1,3 +1,5 @@
using WeaveLoader.API;
namespace WeaveLoader.API.Item;
/// <summary>
@@ -10,10 +12,13 @@ public class ItemProperties
internal float AttackDamageValue = 0.0f;
internal string IconValue = "";
internal CreativeTab CreativeTabValue = CreativeTab.None;
internal string? NameValue;
internal Text? NameValue;
public ItemProperties MaxStackSize(int size) { MaxStackSizeValue = size; return this; }
/// <summary>Icon name in the items atlas. Use namespaced ID for mod textures (e.g. "examplemod:ruby" from assets/items/ruby.png), or vanilla names like "diamond", "ingotIron".</summary>
/// <summary>
/// Icon name in the items atlas. Use Java-style names like "examplemod:item/ruby"
/// from assets/examplemod/textures/item/ruby.png, or vanilla names like "diamond", "ingotIron".
/// </summary>
public ItemProperties Icon(string iconName) { IconValue = iconName; return this; }
/// <summary>
@@ -25,5 +30,7 @@ public class ItemProperties
public ItemProperties AttackDamage(float damage) { AttackDamageValue = damage; return this; }
public ItemProperties InCreativeTab(CreativeTab tab) { CreativeTabValue = tab; return this; }
/// <summary>Display name shown in-game (e.g. "Ruby"). Used for localization.</summary>
public ItemProperties Name(string displayName) { NameValue = displayName; return this; }
public ItemProperties Name(string displayName) { NameValue = Text.Literal(displayName); return this; }
/// <summary>Localized display name using a language key (e.g. "item.examplemod.ruby").</summary>
public ItemProperties Name(Text text) { NameValue = text; return this; }
}

View File

@@ -116,7 +116,7 @@ public static class ItemRegistry
(int)nativeTier,
maxDamage,
properties.IconValue,
properties.NameValue ?? "");
properties.NameValue?.Resolve() ?? "");
if (numericId >= 0)
ConfigureToolMaterial(id, numericId, ToolKind.Pickaxe, material, properties);
@@ -129,7 +129,7 @@ public static class ItemRegistry
(int)(material?.BaseTierValue ?? shovelItem.Tier),
properties.MaxDamageValue,
properties.IconValue,
properties.NameValue ?? "");
properties.NameValue?.Resolve() ?? "");
if (numericId >= 0)
ConfigureToolMaterial(id, numericId, ToolKind.Shovel, material, properties);
@@ -142,7 +142,7 @@ public static class ItemRegistry
(int)(material?.BaseTierValue ?? hoeItem.Tier),
properties.MaxDamageValue,
properties.IconValue,
properties.NameValue ?? "");
properties.NameValue?.Resolve() ?? "");
if (numericId >= 0)
ConfigureToolMaterial(id, numericId, ToolKind.Hoe, material, properties);
@@ -155,7 +155,7 @@ public static class ItemRegistry
(int)(material?.BaseTierValue ?? axeItem.Tier),
properties.MaxDamageValue,
properties.IconValue,
properties.NameValue ?? "");
properties.NameValue?.Resolve() ?? "");
if (numericId >= 0)
ConfigureToolMaterial(id, numericId, ToolKind.Axe, material, properties);
@@ -168,7 +168,7 @@ public static class ItemRegistry
(int)(material?.BaseTierValue ?? swordItem.Tier),
properties.MaxDamageValue,
properties.IconValue,
properties.NameValue ?? "");
properties.NameValue?.Resolve() ?? "");
if (numericId >= 0)
ConfigureToolMaterial(id, numericId, ToolKind.Sword, material, properties);
@@ -180,7 +180,7 @@ public static class ItemRegistry
properties.MaxStackSizeValue,
properties.MaxDamageValue,
properties.IconValue,
properties.NameValue ?? "");
properties.NameValue?.Resolve() ?? "");
}
if (numericId < 0)

View File

@@ -145,6 +145,15 @@ internal static class NativeInterop
[DllImport(RuntimeDll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
internal static extern void native_register_string(int descriptionId, string displayName);
[DllImport(RuntimeDll, CallingConvention = CallingConvention.Cdecl)]
internal static extern int native_get_minecraft_language();
[DllImport(RuntimeDll, CallingConvention = CallingConvention.Cdecl)]
internal static extern int native_get_minecraft_locale();
[DllImport(RuntimeDll, CallingConvention = CallingConvention.Cdecl)]
internal static extern nint native_get_mods_path();
[DllImport(RuntimeDll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
internal static extern int native_register_entity(
string namespacedId,

286
WeaveLoader.API/Text.cs Normal file
View File

@@ -0,0 +1,286 @@
using System.Globalization;
using System.Runtime.InteropServices;
namespace WeaveLoader.API;
/// <summary>
/// Localized text value used for names, tooltips, and UI strings.
/// </summary>
public sealed class Text
{
public enum TextKind
{
Literal = 0,
Translatable = 1
}
public TextKind Kind { get; }
public string Value { get; }
private Text(TextKind kind, string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
Kind = kind;
Value = value;
}
public static Text Literal(string value) => new(TextKind.Literal, value);
public static Text Translatable(string key) => new(TextKind.Translatable, key);
internal string Resolve()
{
return Kind == TextKind.Literal ? Value : Localization.Resolve(Value);
}
}
/// <summary>
/// Simple language table loader for mod translations (Java-style .lang files).
/// </summary>
public static class Localization
{
private static readonly object s_lock = new();
private static string s_locale = GetDefaultLocale();
private static string? s_loadedLocale;
private static Dictionary<string, string> s_entries = new(StringComparer.Ordinal);
private static int s_lastGameLanguage = int.MinValue;
/// <summary>When true, follow the game's current language selection.</summary>
public static bool UseGameLanguage { get; set; } = true;
/// <summary>Active locale used when resolving translatable keys (e.g. "en-GB").</summary>
public static string Locale
{
get => s_locale;
set
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
lock (s_lock)
{
s_locale = value;
s_loadedLocale = null;
}
}
}
/// <summary>Reload language files for the current locale.</summary>
public static void Reload()
{
lock (s_lock)
{
s_loadedLocale = null;
}
}
internal static string Resolve(string key)
{
EnsureLoaded();
return s_entries.TryGetValue(key, out var value) ? value : key;
}
private static void EnsureLoaded()
{
lock (s_lock)
{
if (UseGameLanguage)
{
string? gameLocale = TryGetGameLocale();
if (!string.IsNullOrWhiteSpace(gameLocale) && !string.Equals(gameLocale, s_locale, StringComparison.OrdinalIgnoreCase))
{
s_locale = gameLocale;
s_loadedLocale = null;
}
}
if (s_loadedLocale == s_locale)
return;
s_entries = LoadLocaleTable(s_locale);
s_loadedLocale = s_locale;
}
}
private static string GetDefaultLocale()
{
string? gameLocale = TryGetGameLocale();
if (!string.IsNullOrWhiteSpace(gameLocale))
return gameLocale!;
var env = Environment.GetEnvironmentVariable("WEAVELOADER_LOCALE");
if (!string.IsNullOrWhiteSpace(env))
return env;
try
{
var culture = CultureInfo.CurrentUICulture;
if (!string.IsNullOrWhiteSpace(culture.Name))
return culture.Name.Replace('_', '-');
}
catch
{
// ignore
}
return "en-GB";
}
private static string? TryGetGameLocale()
{
try
{
int lang = NativeInterop.native_get_minecraft_language();
if (lang == s_lastGameLanguage)
return null;
s_lastGameLanguage = lang;
return MapLanguageToLocale(lang);
}
catch
{
return null;
}
}
private static string? MapLanguageToLocale(int lang)
{
// 0 = default (system); return null so we fall back to system culture / env.
return lang switch
{
1 => "en-GB",
2 => "ja-JP",
3 => "de-DE",
4 => "fr-FR",
5 => "es-ES",
6 => "it-IT",
7 => "ko-KR",
8 => "zh-TW",
9 => "pt-PT",
10 => "pt-BR",
11 => "ru-RU",
12 => "nl-NL",
13 => "fi-FI",
14 => "sv-SE",
15 => "da-DK",
16 => "no-NO",
17 => "pl-PL",
18 => "tr-TR",
19 => "es-MX",
20 => "el-GR",
_ => null
};
}
private static Dictionary<string, string> LoadLocaleTable(string locale)
{
var entries = new Dictionary<string, string>(StringComparer.Ordinal);
var locales = BuildLocaleFallbacks(locale);
foreach (var loc in locales)
LoadLocaleFiles(entries, loc);
return entries;
}
private static List<string> BuildLocaleFallbacks(string locale)
{
var list = new List<string>();
void Add(string value)
{
if (string.IsNullOrWhiteSpace(value))
return;
if (!list.Contains(value, StringComparer.OrdinalIgnoreCase))
list.Add(value);
}
Add(locale);
Add(locale.Replace('_', '-'));
Add(locale.Replace('-', '_'));
Add("en-GB");
Add("en-US");
return list;
}
private static void LoadLocaleFiles(Dictionary<string, string> entries, string locale)
{
foreach (var modsPath in GetModsRoots())
{
foreach (var modDir in Directory.EnumerateDirectories(modsPath))
{
var assetsDir = Path.Combine(modDir, "assets");
if (!Directory.Exists(assetsDir))
continue;
foreach (var nsDir in Directory.EnumerateDirectories(assetsDir))
{
var langFile = Path.Combine(nsDir, "lang", $"{locale}.lang");
if (!File.Exists(langFile))
continue;
ParseLangFile(langFile, entries);
}
}
}
}
private static void ParseLangFile(string path, Dictionary<string, string> entries)
{
foreach (var rawLine in File.ReadLines(path))
{
var line = rawLine.Trim();
if (line.Length == 0 || line.StartsWith('#'))
continue;
int idx = line.IndexOf('=');
if (idx <= 0 || idx >= line.Length - 1)
continue;
string key = line[..idx].Trim();
string value = line[(idx + 1)..].Trim();
if (key.Length == 0)
continue;
entries[key] = value;
}
}
private static List<string> GetModsRoots()
{
var roots = new List<string>();
void AddCandidate(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return;
string full;
try
{
full = Path.GetFullPath(path);
}
catch
{
return;
}
if (!Directory.Exists(full))
return;
if (!roots.Contains(full, StringComparer.OrdinalIgnoreCase))
roots.Add(full);
}
AddCandidate(GetNativeModsPath());
var baseDir = AppContext.BaseDirectory;
AddCandidate(Path.Combine(baseDir, "mods"));
AddCandidate(Path.Combine(baseDir, "..", "mods"));
AddCandidate(Path.Combine(baseDir, "..", "..", "mods"));
var cwd = Directory.GetCurrentDirectory();
AddCandidate(Path.Combine(cwd, "mods"));
AddCandidate(Path.Combine(cwd, "..", "mods"));
return roots;
}
private static string? GetNativeModsPath()
{
try
{
var ptr = NativeInterop.native_get_mods_path();
if (ptr == nint.Zero)
return null;
return Marshal.PtrToStringAnsi(ptr);
}
catch
{
return null;
}
}
}

View File

@@ -15,10 +15,10 @@
<!-- Mod textures: use Target so they only copy when API builds to mods/, not when copied as dependency -->
<Target Name="CopyModAssets" AfterTargets="Build">
<MakeDir Directories="$(OutputPath)assets\blocks" />
<MakeDir Directories="$(OutputPath)assets\items" />
<Copy SourceFiles="mod_assets\blocks\missing_block.png" DestinationFolder="$(OutputPath)assets\blocks\" SkipUnchangedFiles="true" />
<Copy SourceFiles="mod_assets\items\missing_item.png" DestinationFolder="$(OutputPath)assets\items\" SkipUnchangedFiles="true" />
<MakeDir Directories="$(OutputPath)assets\weaveloader.api\textures\block" />
<MakeDir Directories="$(OutputPath)assets\weaveloader.api\textures\item" />
<Copy SourceFiles="mod_assets\weaveloader.api\textures\block\missing_block.png" DestinationFolder="$(OutputPath)assets\weaveloader.api\textures\block\" SkipUnchangedFiles="true" />
<Copy SourceFiles="mod_assets\weaveloader.api\textures\item\missing_item.png" DestinationFolder="$(OutputPath)assets\weaveloader.api\textures\item\" SkipUnchangedFiles="true" />
</Target>
</Project>

View File

@@ -2,9 +2,9 @@
Placeholder textures for the missing block and missing item (unresolved mod content).
Stored in `mod_assets/` to avoid conflict with the `Assets/` C# source folder (Windows case-insensitivity).
Copied to `assets/blocks/` and `assets/items/` in the build output.
Copied to `assets/weaveloader.api/textures/block/` and `assets/weaveloader.api/textures/item/` in the build output.
- **Blocks:** `mod_assets/blocks/missing_block.png` → icon `weaveloader.api:missing_block`
- **Items:** `mod_assets/items/missing_item.png` → icon `weaveloader.api:missing_item`
- **Blocks:** `mod_assets/weaveloader.api/textures/block/missing_block.png` → icon `weaveloader.api:block/missing_block`
- **Items:** `mod_assets/weaveloader.api/textures/item/missing_item.png` → icon `weaveloader.api:item/missing_item`
These are used when a world contains blocks or items from mods that are no longer installed.

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB