From 70dbff3fac0329ded26fa2e718c2f08baf3d20a8 Mon Sep 17 00:00:00 2001 From: Jacobwasbeast Date: Tue, 10 Mar 2026 14:36:23 -0500 Subject: [PATCH] feat(api/runtime): java-style assets and localization sync --- ExampleMod/ExampleMod.cs | 64 ++-- ExampleMod/assets/README.md | 36 ++- .../assets/{ => examplemod}/lang/en-GB.lang | 3 - .../textures/block}/orichalcum_ore.png | Bin .../textures/block}/ruby_lamp.png | Bin .../textures/block}/ruby_lamp_on.png | Bin .../textures/block}/ruby_ore.png | Bin .../textures/block}/ruby_sand.png | Bin .../textures/block}/ruby_stone.png | Bin .../textures/block}/ruby_wood_planks.png | Bin .../textures/item}/ruby.png | Bin .../textures/item}/ruby_axe.png | Bin .../textures/item}/ruby_hoe.png | Bin .../textures/item}/ruby_pickaxe.png | Bin .../textures/item}/ruby_shovel.png | Bin .../textures/item}/ruby_sword.png | Bin .../textures/item}/ruby_wand.png | Bin ExampleMod/assets/lang/de-DE.lang | 5 - README.md | 61 +++- WeaveLoader.API/Block/BlockProperties.cs | 13 +- WeaveLoader.API/Block/BlockRegistry.cs | 8 +- WeaveLoader.API/Item/ItemProperties.cs | 13 +- WeaveLoader.API/Item/ItemRegistry.cs | 12 +- WeaveLoader.API/NativeInterop.cs | 9 + WeaveLoader.API/Text.cs | 286 ++++++++++++++++++ WeaveLoader.API/WeaveLoader.API.csproj | 8 +- WeaveLoader.API/mod_assets/README.md | 6 +- .../textures/block}/missing_block.png | Bin .../textures/item}/missing_item.png | Bin WeaveLoaderRuntime/src/GameHooks.cpp | 166 ++++++++++ WeaveLoaderRuntime/src/HookManager.cpp | 4 + WeaveLoaderRuntime/src/ModAtlas.cpp | 98 ++++-- WeaveLoaderRuntime/src/ModAtlas.h | 3 +- WeaveLoaderRuntime/src/NativeExports.cpp | 81 +++++ WeaveLoaderRuntime/src/NativeExports.h | 7 + WeaveLoaderRuntime/src/SymbolResolver.cpp | 14 + WeaveLoaderRuntime/src/SymbolResolver.h | 3 + WeaveLoaderRuntime/src/WorldIdRemap.cpp | 4 +- 38 files changed, 794 insertions(+), 110 deletions(-) rename ExampleMod/assets/{ => examplemod}/lang/en-GB.lang (79%) rename ExampleMod/assets/{blocks => examplemod/textures/block}/orichalcum_ore.png (100%) rename ExampleMod/assets/{blocks => examplemod/textures/block}/ruby_lamp.png (100%) rename ExampleMod/assets/{blocks => examplemod/textures/block}/ruby_lamp_on.png (100%) rename ExampleMod/assets/{blocks => examplemod/textures/block}/ruby_ore.png (100%) rename ExampleMod/assets/{blocks => examplemod/textures/block}/ruby_sand.png (100%) rename ExampleMod/assets/{blocks => examplemod/textures/block}/ruby_stone.png (100%) rename ExampleMod/assets/{blocks => examplemod/textures/block}/ruby_wood_planks.png (100%) rename ExampleMod/assets/{items => examplemod/textures/item}/ruby.png (100%) rename ExampleMod/assets/{items => examplemod/textures/item}/ruby_axe.png (100%) rename ExampleMod/assets/{items => examplemod/textures/item}/ruby_hoe.png (100%) rename ExampleMod/assets/{items => examplemod/textures/item}/ruby_pickaxe.png (100%) rename ExampleMod/assets/{items => examplemod/textures/item}/ruby_shovel.png (100%) rename ExampleMod/assets/{items => examplemod/textures/item}/ruby_sword.png (100%) rename ExampleMod/assets/{items => examplemod/textures/item}/ruby_wand.png (100%) delete mode 100644 ExampleMod/assets/lang/de-DE.lang create mode 100644 WeaveLoader.API/Text.cs rename WeaveLoader.API/mod_assets/{blocks => weaveloader.api/textures/block}/missing_block.png (100%) rename WeaveLoader.API/mod_assets/{items => weaveloader.api/textures/item}/missing_item.png (100%) diff --git a/ExampleMod/ExampleMod.cs b/ExampleMod/ExampleMod.cs index 31ed0dc..30dd0ee 100644 --- a/ExampleMod/ExampleMod.cs +++ b/ExampleMod/ExampleMod.cs @@ -195,8 +195,8 @@ public class ExampleMod : IMod .Hardness(3.0f) .Resistance(15f) .Sound(SoundType.Stone) - .Icon("examplemod:ruby_ore") // From assets/blocks/ruby_ore.png - .Name("Ruby Ore") + .Icon("examplemod:block/ruby_ore") + .Name(Text.Translatable("block.examplemod.ruby_ore")) .RequiredHarvestLevel(2) .RequiredTool(ToolType.Pickaxe) .InCreativeTab(CreativeTab.BuildingBlocks)); @@ -207,8 +207,8 @@ public class ExampleMod : IMod .Hardness(1.5f) .Resistance(10f) .Sound(SoundType.Stone) - .Icon("examplemod:ruby_stone") - .Name("Ruby Stone") + .Icon("examplemod:block/ruby_stone") + .Name(Text.Translatable("block.examplemod.ruby_stone")) .RequiredHarvestLevel(1) .RequiredTool(ToolType.Pickaxe) .InCreativeTab(CreativeTab.BuildingBlocks)); @@ -219,8 +219,8 @@ public class ExampleMod : IMod .Hardness(2.0f) .Resistance(5f) .Sound(SoundType.Wood) - .Icon("examplemod:ruby_wood_planks") - .Name("Ruby Wood Planks") + .Icon("examplemod:block/ruby_wood_planks") + .Name(Text.Translatable("block.examplemod.ruby_wood_planks")) .InCreativeTab(CreativeTab.BuildingBlocks)); RubySand = Registry.Block.Register("examplemod:ruby_sand", @@ -230,8 +230,8 @@ public class ExampleMod : IMod .Hardness(0.5f) .Resistance(2.5f) .Sound(SoundType.Sand) - .Icon("examplemod:ruby_sand") - .Name("Ruby Sand") + .Icon("examplemod:block/ruby_sand") + .Name(Text.Translatable("block.examplemod.ruby_sand")) .RequiredTool(ToolType.Shovel) .InCreativeTab(CreativeTab.BuildingBlocks)); @@ -242,8 +242,8 @@ public class ExampleMod : IMod .Hardness(1.5f) .Resistance(10f) .Sound(SoundType.Stone) - .Icon("examplemod:ruby_stone") - .Name("Ruby Stone Slab") + .Icon("examplemod:block/ruby_stone") + .Name(Text.Translatable("block.examplemod.ruby_stone_slab")) .RequiredHarvestLevel(1) .RequiredTool(ToolType.Pickaxe) .InCreativeTab(CreativeTab.BuildingBlocks)); @@ -255,8 +255,8 @@ public class ExampleMod : IMod .Hardness(2.0f) .Resistance(5f) .Sound(SoundType.Wood) - .Icon("examplemod:ruby_wood_planks") - .Name("Ruby Wood Slab") + .Icon("examplemod:block/ruby_wood_planks") + .Name(Text.Translatable("block.examplemod.ruby_wood_slab")) .InCreativeTab(CreativeTab.BuildingBlocks)); RubyLamp = Registry.Block.Register("examplemod:ruby_lamp", new RubyLampBlock(false), @@ -265,8 +265,8 @@ public class ExampleMod : IMod .Hardness(0.3f) .Resistance(1.5f) .Sound(SoundType.Glass) - .Icon("examplemod:ruby_lamp") - .Name("Ruby Lamp") + .Icon("examplemod:block/ruby_lamp") + .Name(Text.Translatable("block.examplemod.ruby_lamp")) .RequiredHarvestLevel(0) .RequiredTool(ToolType.Pickaxe) .AcceptsRedstonePower() @@ -283,9 +283,9 @@ public class ExampleMod : IMod .Hardness(0.3f) .Resistance(1.5f) .Sound(SoundType.Glass) - .Icon("examplemod:ruby_lamp_on") + .Icon("examplemod:block/ruby_lamp_on") .LightLevel(1.0f) - .Name("Ruby Lamp") + .Name(Text.Translatable("block.examplemod.ruby_lamp_lit")) .RequiredHarvestLevel(0) .RequiredTool(ToolType.Pickaxe) .AcceptsRedstonePower()); @@ -296,8 +296,8 @@ public class ExampleMod : IMod .Hardness(5.0f) .Resistance(30f) .Sound(SoundType.Metal) - .Icon("examplemod:orichalcum_ore") // From assets/blocks/orichalcum.png - .Name("Orichalcum Ore") + .Icon("examplemod:block/orichalcum_ore") + .Name(Text.Translatable("block.examplemod.orichalcum_ore")) .RequiredHarvestLevel(4) .RequiredTool(ToolType.Pickaxe) .InCreativeTab(CreativeTab.BuildingBlocks)); @@ -305,8 +305,8 @@ public class ExampleMod : IMod Ruby = Registry.Item.Register("examplemod:ruby", new ItemProperties() .MaxStackSize(64) - .Icon("examplemod:ruby") // From assets/items/ruby.png - .Name("Ruby") + .Icon("examplemod:item/ruby") + .Name(Text.Translatable("item.examplemod.ruby")) .InCreativeTab(CreativeTab.Materials)); Registry.Item.RegisterToolMaterial("examplemod:ruby_material", @@ -320,8 +320,8 @@ public class ExampleMod : IMod .MaxStackSize(1) .MaxDamage(512) .AttackDamage(8.0f) - .Icon("examplemod:ruby_sword") - .Name("Ruby Sword") + .Icon("examplemod:item/ruby_sword") + .Name(Text.Translatable("item.examplemod.ruby_sword")) .InCreativeTab(CreativeTab.ToolsAndWeapons)); RubyShovelItem = Registry.Item.Register("examplemod:ruby_shovel", new RubyShovel { CustomMaterialId = "examplemod:ruby_material" }, @@ -329,8 +329,8 @@ public class ExampleMod : IMod .MaxStackSize(1) .MaxDamage(512) .AttackDamage(4.5f) - .Icon("examplemod:ruby_shovel") - .Name("Ruby Shovel") + .Icon("examplemod:item/ruby_shovel") + .Name(Text.Translatable("item.examplemod.ruby_shovel")) .InCreativeTab(CreativeTab.ToolsAndWeapons)); RubyPickaxeItem = Registry.Item.Register("examplemod:ruby_pickaxe", new RubyPickaxe { CustomMaterialId = "examplemod:ruby_material" }, @@ -338,8 +338,8 @@ public class ExampleMod : IMod .MaxStackSize(1) .MaxDamage(512) .AttackDamage(5.0f) - .Icon("examplemod:ruby_pickaxe") // From assets/items/ruby_pickaxe.png - .Name("Ruby Pickaxe") + .Icon("examplemod:item/ruby_pickaxe") + .Name(Text.Translatable("item.examplemod.ruby_pickaxe")) .InCreativeTab(CreativeTab.ToolsAndWeapons)); RubyAxeItem = Registry.Item.Register("examplemod:ruby_axe", new RubyAxe { CustomMaterialId = "examplemod:ruby_material" }, @@ -347,8 +347,8 @@ public class ExampleMod : IMod .MaxStackSize(1) .MaxDamage(512) .AttackDamage(7.0f) - .Icon("examplemod:ruby_axe") - .Name("Ruby Axe") + .Icon("examplemod:item/ruby_axe") + .Name(Text.Translatable("item.examplemod.ruby_axe")) .InCreativeTab(CreativeTab.ToolsAndWeapons)); RubyHoeItem = Registry.Item.Register("examplemod:ruby_hoe", new RubyHoe { CustomMaterialId = "examplemod:ruby_material" }, @@ -356,15 +356,15 @@ public class ExampleMod : IMod .MaxStackSize(1) .MaxDamage(512) .AttackDamage(1.0f) - .Icon("examplemod:ruby_hoe") - .Name("Ruby Hoe") + .Icon("examplemod:item/ruby_hoe") + .Name(Text.Translatable("item.examplemod.ruby_hoe")) .InCreativeTab(CreativeTab.ToolsAndWeapons)); RubyWandItem = Registry.Item.Register("examplemod:ruby_wand", new RubyWand(), new ItemProperties() .MaxStackSize(1) - .Icon("examplemod:ruby_wand") // From assets/items/ruby_wand.png - .Name("Ruby Wand") + .Icon("examplemod:item/ruby_wand") + .Name(Text.Translatable("item.examplemod.ruby_wand")) .InCreativeTab(CreativeTab.ToolsAndWeapons)); Registry.Recipe.AddFurnace("examplemod:ruby_ore", "examplemod:ruby", 1.0f); diff --git a/ExampleMod/assets/README.md b/ExampleMod/assets/README.md index 7883e46..183506b 100644 --- a/ExampleMod/assets/README.md +++ b/ExampleMod/assets/README.md @@ -2,24 +2,40 @@ ## Language files -Language files live in `assets/lang/` with the format `{locale}.lang` (e.g. `en-GB.lang`, `de-DE.lang`). +Language files live in `assets/examplemod/lang/` with the format `{locale}.lang` (e.g. `en-GB.lang`, `de-DE.lang`). -**Current API:** Use `BlockProperties.Name()` and `ItemProperties.Name()` when registering blocks and items. These set the display name shown in-game. The ModLoader hooks into the game's string lookup so your names appear correctly. +Use `Text.Translatable()` with `BlockProperties.Name()` / `ItemProperties.Name()` to pull localized strings +from these `.lang` files. Format: `key=value` per line, with `#` for comments. -**Future:** Multi-locale support may load from these `.lang` files. Format: `key=value` per line, with `#` for comments. +Example: + +```csharp +.Name(Text.Translatable("item.examplemod.ruby")) +.Name(Text.Literal("Ruby")) // literal fallback if you don't want localization +``` ## Textures -Mod textures are supported via the dynamic atlas system. Place PNG files in: +Mod textures use Java-style asset paths. Place PNG files in: -- **Blocks:** `assets/blocks/{name}.png` → icon `{modid}:{name}` (e.g. `ruby_ore.png` → `examplemod:ruby_ore`) -- **Items:** `assets/items/{name}.png` → icon `{modid}:{name}` (e.g. `ruby.png` → `examplemod:ruby`) +- **Blocks:** `assets/examplemod/textures/block/{name}.png` → icon `examplemod:block/{name}` +- **Items:** `assets/examplemod/textures/item/{name}.png` → icon `examplemod:item/{name}` -The mod ID is derived from the mod folder name (lowercase, hyphens removed). Use the namespaced icon in `BlockProperties.Icon()` and `ItemProperties.Icon()`: +Use the Java-style icon in `BlockProperties.Icon()` and `ItemProperties.Icon()`: ```csharp -.Icon("examplemod:ruby_ore") // block from assets/blocks/ruby_ore.png -.Icon("examplemod:ruby") // item from assets/items/ruby.png +.Icon("examplemod:block/ruby_ore") // block from assets/examplemod/textures/block/ruby_ore.png +.Icon("examplemod:item/ruby") // item from assets/examplemod/textures/item/ruby.png ``` -Textures must be 16×16 pixels (or any size; they are scaled). For vanilla icons, use names like `gold_ore`, `diamond`, etc. +Textures must be 16×16 pixels (or any size; they are scaled). + +## Models (Java-style) + +Block and item models are supported using Java-style JSON assets: + +- **Blocks:** `assets/examplemod/models/block/{name}.json` +- **Items:** `assets/examplemod/models/item/{name}.json` +- **Entities (future):** `assets/examplemod/models/entity/{name}.json` + +The `examplemod` namespace should match your mod ID (lowercase). diff --git a/ExampleMod/assets/lang/en-GB.lang b/ExampleMod/assets/examplemod/lang/en-GB.lang similarity index 79% rename from ExampleMod/assets/lang/en-GB.lang rename to ExampleMod/assets/examplemod/lang/en-GB.lang index 6823015..ae08fc8 100644 --- a/ExampleMod/assets/lang/en-GB.lang +++ b/ExampleMod/assets/examplemod/lang/en-GB.lang @@ -1,7 +1,4 @@ # ExampleMod language file (en-GB) -# Display names for blocks and items. -# In the current API, use BlockProperties.Name() and ItemProperties.Name() instead. -# This file documents the expected format for future multi-locale support. block.examplemod.ruby_ore=Ruby Ore block.examplemod.ruby_stone=Ruby Stone diff --git a/ExampleMod/assets/blocks/orichalcum_ore.png b/ExampleMod/assets/examplemod/textures/block/orichalcum_ore.png similarity index 100% rename from ExampleMod/assets/blocks/orichalcum_ore.png rename to ExampleMod/assets/examplemod/textures/block/orichalcum_ore.png diff --git a/ExampleMod/assets/blocks/ruby_lamp.png b/ExampleMod/assets/examplemod/textures/block/ruby_lamp.png similarity index 100% rename from ExampleMod/assets/blocks/ruby_lamp.png rename to ExampleMod/assets/examplemod/textures/block/ruby_lamp.png diff --git a/ExampleMod/assets/blocks/ruby_lamp_on.png b/ExampleMod/assets/examplemod/textures/block/ruby_lamp_on.png similarity index 100% rename from ExampleMod/assets/blocks/ruby_lamp_on.png rename to ExampleMod/assets/examplemod/textures/block/ruby_lamp_on.png diff --git a/ExampleMod/assets/blocks/ruby_ore.png b/ExampleMod/assets/examplemod/textures/block/ruby_ore.png similarity index 100% rename from ExampleMod/assets/blocks/ruby_ore.png rename to ExampleMod/assets/examplemod/textures/block/ruby_ore.png diff --git a/ExampleMod/assets/blocks/ruby_sand.png b/ExampleMod/assets/examplemod/textures/block/ruby_sand.png similarity index 100% rename from ExampleMod/assets/blocks/ruby_sand.png rename to ExampleMod/assets/examplemod/textures/block/ruby_sand.png diff --git a/ExampleMod/assets/blocks/ruby_stone.png b/ExampleMod/assets/examplemod/textures/block/ruby_stone.png similarity index 100% rename from ExampleMod/assets/blocks/ruby_stone.png rename to ExampleMod/assets/examplemod/textures/block/ruby_stone.png diff --git a/ExampleMod/assets/blocks/ruby_wood_planks.png b/ExampleMod/assets/examplemod/textures/block/ruby_wood_planks.png similarity index 100% rename from ExampleMod/assets/blocks/ruby_wood_planks.png rename to ExampleMod/assets/examplemod/textures/block/ruby_wood_planks.png diff --git a/ExampleMod/assets/items/ruby.png b/ExampleMod/assets/examplemod/textures/item/ruby.png similarity index 100% rename from ExampleMod/assets/items/ruby.png rename to ExampleMod/assets/examplemod/textures/item/ruby.png diff --git a/ExampleMod/assets/items/ruby_axe.png b/ExampleMod/assets/examplemod/textures/item/ruby_axe.png similarity index 100% rename from ExampleMod/assets/items/ruby_axe.png rename to ExampleMod/assets/examplemod/textures/item/ruby_axe.png diff --git a/ExampleMod/assets/items/ruby_hoe.png b/ExampleMod/assets/examplemod/textures/item/ruby_hoe.png similarity index 100% rename from ExampleMod/assets/items/ruby_hoe.png rename to ExampleMod/assets/examplemod/textures/item/ruby_hoe.png diff --git a/ExampleMod/assets/items/ruby_pickaxe.png b/ExampleMod/assets/examplemod/textures/item/ruby_pickaxe.png similarity index 100% rename from ExampleMod/assets/items/ruby_pickaxe.png rename to ExampleMod/assets/examplemod/textures/item/ruby_pickaxe.png diff --git a/ExampleMod/assets/items/ruby_shovel.png b/ExampleMod/assets/examplemod/textures/item/ruby_shovel.png similarity index 100% rename from ExampleMod/assets/items/ruby_shovel.png rename to ExampleMod/assets/examplemod/textures/item/ruby_shovel.png diff --git a/ExampleMod/assets/items/ruby_sword.png b/ExampleMod/assets/examplemod/textures/item/ruby_sword.png similarity index 100% rename from ExampleMod/assets/items/ruby_sword.png rename to ExampleMod/assets/examplemod/textures/item/ruby_sword.png diff --git a/ExampleMod/assets/items/ruby_wand.png b/ExampleMod/assets/examplemod/textures/item/ruby_wand.png similarity index 100% rename from ExampleMod/assets/items/ruby_wand.png rename to ExampleMod/assets/examplemod/textures/item/ruby_wand.png diff --git a/ExampleMod/assets/lang/de-DE.lang b/ExampleMod/assets/lang/de-DE.lang deleted file mode 100644 index 279b513..0000000 --- a/ExampleMod/assets/lang/de-DE.lang +++ /dev/null @@ -1,5 +0,0 @@ -# ExampleMod language file (de-DE) -# German translations for ExampleMod content. - -block.examplemod.ruby_ore=Rubinerz -item.examplemod.ruby=Rubin diff --git a/README.md b/README.md index 5d34ced..f827efa 100644 --- a/README.md +++ b/README.md @@ -205,17 +205,17 @@ public class MyMod : IMod .Hardness(3.0f) .Resistance(15.0f) .Sound(SoundType.Stone) - .Icon("mymod:example_ore") + .Icon("mymod:block/example_ore") .InCreativeTab(CreativeTab.BuildingBlocks) - .Name("Example Ore")); + .Name(Text.Translatable("block.mymod.example_ore"))); // Register an item var gem = Registry.Item.Register("mymod:example_gem", new ItemProperties() .MaxStackSize(64) - .Icon("mymod:example_gem") + .Icon("mymod:item/example_gem") .InCreativeTab(CreativeTab.Materials) - .Name("Example Gem")); + .Name(Text.Translatable("item.mymod.example_gem"))); // Add a smelting recipe Registry.Recipe.AddFurnace("mymod:example_ore", "mymod:example_gem", 1.0f); @@ -239,15 +239,43 @@ Place 16x16 PNG textures in your mod's assets folder: ``` MyMod/ ├── assets/ -│ ├── blocks/ -│ │ └── example_ore.png # Block texture -│ └── items/ -│ └── example_gem.png # Item texture +│ └── mymod/ +│ └── textures/ +│ ├── block/ +│ │ └── example_ore.png # Block texture +│ └── item/ +│ └── example_gem.png # Item texture ├── MyMod.cs └── MyMod.csproj ``` -The icon name in `BlockProperties.Icon()` / `ItemProperties.Icon()` uses the format `"modid:texture_name"` where `texture_name` matches the PNG filename (without extension). +The icon name in `BlockProperties.Icon()` / `ItemProperties.Icon()` uses Java-style names like: + +```csharp +.Icon("mymod:block/example_ore") +.Icon("mymod:item/example_gem") +``` + +### 4b. Add models (block/item/entity) + +Place Java-style JSON model files under an `assets//models/` tree inside your mod folder: + +``` +MyMod/ +├── assets/ +│ └── mymod/ +│ └── models/ +│ ├── block/ +│ │ └── example_ore.json +│ ├── item/ +│ │ └── example_gem.json +│ └── entity/ +│ └── example_entity.json # reserved for future entity model support +├── MyMod.cs +└── MyMod.csproj +``` + +The `namespace` folder should match your mod's ID (lowercase). ### 5. Build and run @@ -322,6 +350,13 @@ All log output goes to `logs/weaveloader.log` with timestamps and log level pref Namespaced string IDs follow the `"namespace:path"` convention (e.g., `"mymod:ruby_ore"`). If no namespace is provided, `"minecraft"` is assumed. +### Localization + +Use `Text.Translatable("item.examplemod.ruby")` for localized names or `Text.Literal("Ruby")` for fixed text. +Language files live at `assets//lang/.lang` using `key=value` lines. +By default, WeaveLoader follows the game's current language selection; disable with `Localization.UseGameLanguage = false`. +If the game reports "system default", the locale falls back to the system UI culture; override with `Localization.Locale = "en-GB"`. + ## ID Ranges | Type | Numeric Range | Notes | @@ -338,7 +373,7 @@ The runtime opens the game's PDB file and parses it using [raw_pdb](https://gith ### Texture Atlas Merging -1. Mod textures are discovered in `mods/*/assets/blocks/` and `mods/*/assets/items/` +1. Mod textures are discovered in `mods/*/assets//textures/block/` and `mods/*/assets//textures/item/` 2. The vanilla `terrain.png` (16x32 grid) and `items.png` (16x16 grid) are loaded via stb_image 3. Empty cells are identified by checking for fully transparent pixels 4. Mod textures are placed into empty cells @@ -346,6 +381,12 @@ The runtime opens the game's PDB file and parses it using [raw_pdb](https://gith 6. A `CreateFileW` hook temporarily redirects the game's file opens to the merged atlases during init, then is removed once textures are loaded into GPU memory 7. `SimpleIcon` objects are created for each mod texture with correct UV coordinates +### Model Asset Overlay + +When the game requests `assets//models/...` via `InputStream::getResourceAsStream`, +WeaveLoader will redirect to a matching file inside `mods/*/assets//models/`. +Block and item models are supported today; `models/entity/` is wired up for future use. + ### String Table Injection The game's `CMinecraftApp::GetString(int)` is a thin wrapper around `StringTable::getString(int)`, which does a vector index lookup. Since MSVC's link-time optimization inlines `GetString` at call sites like `Item::getHoverName`, a MinHook detour alone isn't sufficient. The runtime parses the x64 machine code of `GetString` to locate the RIP-relative reference to `app.m_stringTable`, then directly resizes the string table's internal vector and writes mod strings at the allocated indices. diff --git a/WeaveLoader.API/Block/BlockProperties.cs b/WeaveLoader.API/Block/BlockProperties.cs index 6ffad85..e8fcae1 100644 --- a/WeaveLoader.API/Block/BlockProperties.cs +++ b/WeaveLoader.API/Block/BlockProperties.cs @@ -1,3 +1,5 @@ +using WeaveLoader.API; + namespace WeaveLoader.API.Block; /// @@ -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; } - /// 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". + /// + /// 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". + /// 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; } /// Display name shown in-game (e.g. "Ruby Ore"). Used for localization. - public BlockProperties Name(string displayName) { NameValue = displayName; return this; } + public BlockProperties Name(string displayName) { NameValue = Text.Literal(displayName); return this; } + /// Localized display name using a language key (e.g. "block.examplemod.ruby_ore"). + public BlockProperties Name(Text text) { NameValue = text; return this; } /// Minimum harvest level required to properly mine this block (e.g. 3 for obsidian). -1 means no requirement. public BlockProperties RequiredHarvestLevel(int level) { RequiredHarvestLevelValue = level; return this; } /// Tool type required to harvest this block (e.g. Pickaxe for stone-like blocks). diff --git a/WeaveLoader.API/Block/BlockRegistry.cs b/WeaveLoader.API/Block/BlockRegistry.cs index c9a35b7..1f1af70 100644 --- a/WeaveLoader.API/Block/BlockRegistry.cs +++ b/WeaveLoader.API/Block/BlockRegistry.cs @@ -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, diff --git a/WeaveLoader.API/Item/ItemProperties.cs b/WeaveLoader.API/Item/ItemProperties.cs index 3065af5..8ae34d8 100644 --- a/WeaveLoader.API/Item/ItemProperties.cs +++ b/WeaveLoader.API/Item/ItemProperties.cs @@ -1,3 +1,5 @@ +using WeaveLoader.API; + namespace WeaveLoader.API.Item; /// @@ -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; } - /// 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". + /// + /// 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". + /// public ItemProperties Icon(string iconName) { IconValue = iconName; return this; } /// @@ -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; } /// Display name shown in-game (e.g. "Ruby"). Used for localization. - public ItemProperties Name(string displayName) { NameValue = displayName; return this; } + public ItemProperties Name(string displayName) { NameValue = Text.Literal(displayName); return this; } + /// Localized display name using a language key (e.g. "item.examplemod.ruby"). + public ItemProperties Name(Text text) { NameValue = text; return this; } } diff --git a/WeaveLoader.API/Item/ItemRegistry.cs b/WeaveLoader.API/Item/ItemRegistry.cs index ae133ea..80a5b30 100644 --- a/WeaveLoader.API/Item/ItemRegistry.cs +++ b/WeaveLoader.API/Item/ItemRegistry.cs @@ -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) diff --git a/WeaveLoader.API/NativeInterop.cs b/WeaveLoader.API/NativeInterop.cs index 4fcfd07..c9092ed 100644 --- a/WeaveLoader.API/NativeInterop.cs +++ b/WeaveLoader.API/NativeInterop.cs @@ -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, diff --git a/WeaveLoader.API/Text.cs b/WeaveLoader.API/Text.cs new file mode 100644 index 0000000..0c525cf --- /dev/null +++ b/WeaveLoader.API/Text.cs @@ -0,0 +1,286 @@ +using System.Globalization; +using System.Runtime.InteropServices; + +namespace WeaveLoader.API; + +/// +/// Localized text value used for names, tooltips, and UI strings. +/// +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); + } +} + +/// +/// Simple language table loader for mod translations (Java-style .lang files). +/// +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 s_entries = new(StringComparer.Ordinal); + private static int s_lastGameLanguage = int.MinValue; + + /// When true, follow the game's current language selection. + public static bool UseGameLanguage { get; set; } = true; + + /// Active locale used when resolving translatable keys (e.g. "en-GB"). + public static string Locale + { + get => s_locale; + set + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + lock (s_lock) + { + s_locale = value; + s_loadedLocale = null; + } + } + } + + /// Reload language files for the current locale. + 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 LoadLocaleTable(string locale) + { + var entries = new Dictionary(StringComparer.Ordinal); + var locales = BuildLocaleFallbacks(locale); + foreach (var loc in locales) + LoadLocaleFiles(entries, loc); + return entries; + } + + private static List BuildLocaleFallbacks(string locale) + { + var list = new List(); + 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 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 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 GetModsRoots() + { + var roots = new List(); + 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; + } + } +} diff --git a/WeaveLoader.API/WeaveLoader.API.csproj b/WeaveLoader.API/WeaveLoader.API.csproj index 8d097bf..2f46deb 100644 --- a/WeaveLoader.API/WeaveLoader.API.csproj +++ b/WeaveLoader.API/WeaveLoader.API.csproj @@ -15,10 +15,10 @@ - - - - + + + + diff --git a/WeaveLoader.API/mod_assets/README.md b/WeaveLoader.API/mod_assets/README.md index 00139c2..cba63d1 100644 --- a/WeaveLoader.API/mod_assets/README.md +++ b/WeaveLoader.API/mod_assets/README.md @@ -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. diff --git a/WeaveLoader.API/mod_assets/blocks/missing_block.png b/WeaveLoader.API/mod_assets/weaveloader.api/textures/block/missing_block.png similarity index 100% rename from WeaveLoader.API/mod_assets/blocks/missing_block.png rename to WeaveLoader.API/mod_assets/weaveloader.api/textures/block/missing_block.png diff --git a/WeaveLoader.API/mod_assets/items/missing_item.png b/WeaveLoader.API/mod_assets/weaveloader.api/textures/item/missing_item.png similarity index 100% rename from WeaveLoader.API/mod_assets/items/missing_item.png rename to WeaveLoader.API/mod_assets/weaveloader.api/textures/item/missing_item.png diff --git a/WeaveLoaderRuntime/src/GameHooks.cpp b/WeaveLoaderRuntime/src/GameHooks.cpp index 1919663..0be6abe 100644 --- a/WeaveLoaderRuntime/src/GameHooks.cpp +++ b/WeaveLoaderRuntime/src/GameHooks.cpp @@ -4,6 +4,7 @@ #include "MainMenuOverlay.h" #include "ModStrings.h" #include "ModAtlas.h" +#include "NativeExports.h" #include "CustomPickaxeRegistry.h" #include "CustomToolMaterialRegistry.h" #include "CustomBlockRegistry.h" @@ -17,7 +18,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -110,6 +113,10 @@ namespace GameHooks static InventoryRemoveResource_fn s_inventoryRemoveResource = nullptr; static void* s_inventoryVtable = nullptr; static ItemInstanceHurtAndBreak_fn s_itemInstanceHurtAndBreak = nullptr; + static std::string s_modsPath; + static std::unordered_map s_modAssetRoots; + static bool s_modAssetsIndexed = false; + static std::mutex s_modAssetsMutex; // Verified from compiled Player::inventory accesses in this game build. static constexpr ptrdiff_t kPlayerInventoryOffset = 0x340; static constexpr ptrdiff_t kLevelIsClientSideOffset = 0x268; @@ -334,6 +341,150 @@ namespace GameHooks return path.compare(pathLen - suffLen, suffLen, suffix) == 0; } + static std::string ToLowerAscii(const std::string& value) + { + std::string out; + out.reserve(value.size()); + for (char ch : value) + out.push_back((char)tolower((unsigned char)ch)); + return out; + } + + static std::string WStringToLowerAscii(const std::wstring& value) + { + std::string out; + out.reserve(value.size()); + for (wchar_t ch : value) + { + if (ch > 0x7F) + return std::string(); + out.push_back((char)tolower((unsigned char)ch)); + } + return out; + } + + static void BuildModAssetIndexLocked() + { + s_modAssetRoots.clear(); + s_modAssetsIndexed = true; + if (s_modsPath.empty()) + return; + + WIN32_FIND_DATAA fd; + std::string search = s_modsPath + "\\*"; + HANDLE h = FindFirstFileA(search.c_str(), &fd); + if (h == INVALID_HANDLE_VALUE) + return; + + do + { + if (!(fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) continue; + if (fd.cFileName[0] == '.') continue; + + std::string modFolder = fd.cFileName; + std::string assetsPath = s_modsPath + "\\" + modFolder + "\\assets"; + DWORD attr = GetFileAttributesA(assetsPath.c_str()); + if (attr == INVALID_FILE_ATTRIBUTES || !(attr & FILE_ATTRIBUTE_DIRECTORY)) + continue; + + WIN32_FIND_DATAA nsfd; + std::string nsSearch = assetsPath + "\\*"; + HANDLE hNs = FindFirstFileA(nsSearch.c_str(), &nsfd); + if (hNs == INVALID_HANDLE_VALUE) + continue; + do + { + if (!(nsfd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) continue; + if (nsfd.cFileName[0] == '.') continue; + + std::string nsName = ToLowerAscii(nsfd.cFileName); + if (nsName.empty()) + continue; + + if (s_modAssetRoots.find(nsName) == s_modAssetRoots.end()) + { + s_modAssetRoots.emplace(nsName, assetsPath); + } + else + { + LogUtil::Log("[WeaveLoader] ModAssets: duplicate namespace '%s' (folder=%s) ignored", + nsName.c_str(), modFolder.c_str()); + } + } while (FindNextFileA(hNs, &nsfd)); + FindClose(hNs); + } while (FindNextFileA(h, &fd)); + FindClose(h); + } + + static void EnsureModAssetIndex() + { + if (s_modAssetsIndexed) + return; + std::lock_guard guard(s_modAssetsMutex); + if (!s_modAssetsIndexed) + BuildModAssetIndexLocked(); + } + + static bool FileExistsW(const std::wstring& path) + { + DWORD attr = GetFileAttributesW(path.c_str()); + return (attr != INVALID_FILE_ATTRIBUTES) && !(attr & FILE_ATTRIBUTE_DIRECTORY); + } + + static bool TryResolveModAssetPath(const std::wstring& requestPath, std::wstring& outPath) + { + if (s_modsPath.empty()) + return false; + + std::wstring lower = NormalizeLowerPath(requestPath); + if (lower.find(L"://") != std::wstring::npos) + return false; + + const std::wstring kAssets = L"/assets/"; + size_t assetsPos = lower.find(kAssets); + if (assetsPos == std::wstring::npos) + return false; + + size_t nsStart = assetsPos + kAssets.size(); + if (nsStart >= lower.size()) + return false; + size_t nsEnd = lower.find(L'/', nsStart); + if (nsEnd == std::wstring::npos || nsEnd <= nsStart) + return false; + + std::wstring ns = lower.substr(nsStart, nsEnd - nsStart); + if (ns.empty()) + return false; + + size_t relStart = nsEnd + 1; + if (relStart >= lower.size()) + return false; + std::wstring rel = lower.substr(relStart); + + std::string nsKey = WStringToLowerAscii(ns); + if (nsKey.empty()) + return false; + + EnsureModAssetIndex(); + auto it = s_modAssetRoots.find(nsKey); + if (it == s_modAssetRoots.end()) + return false; + + std::wstring rootW(it->second.begin(), it->second.end()); + std::wstring relW = ns + L"/" + rel; + for (wchar_t& ch : relW) + { + if (ch == L'/') + ch = L'\\'; + } + std::wstring fullPath = rootW + L"\\" + relW; + if (!FileExistsW(fullPath)) + return false; + + outPath = fullPath; + return true; + } + static int DetectAtlasTypeFromResource(void* resourcePtr) { if (!resourcePtr) @@ -2284,6 +2435,14 @@ namespace GameHooks } } + std::wstring modAssetPath; + if (TryResolveModAssetPath(*path, modAssetPath)) + { + LogUtil::Log("[WeaveLoader] getResourceAsStream: redirecting %ls -> %ls", + path->c_str(), modAssetPath.c_str()); + return Original_GetResourceAsStream(&modAssetPath); + } + return Original_GetResourceAsStream(fileName); } @@ -2372,6 +2531,13 @@ namespace GameHooks } modsPath = base + "mods"; atlas_done: + s_modsPath = modsPath; + { + std::lock_guard guard(s_modAssetsMutex); + s_modAssetsIndexed = false; + s_modAssetRoots.clear(); + } + NativeExports::SetModsPath(modsPath); ModAtlas::SetBasePaths(modsPath, gameResPath); ModAtlas::EnsureAtlasesBuilt(); diff --git a/WeaveLoaderRuntime/src/HookManager.cpp b/WeaveLoaderRuntime/src/HookManager.cpp index 6f1a9fb..af38de4 100644 --- a/WeaveLoaderRuntime/src/HookManager.cpp +++ b/WeaveLoaderRuntime/src/HookManager.cpp @@ -679,6 +679,10 @@ bool HookManager::Install(const SymbolResolver& symbols) symbols.pServerLevelAddToTickNextTick ? symbols.pServerLevelAddToTickNextTick : symbols.pLevelAddToTickNextTick, symbols.pLevelGetTile); + NativeExports::SetLocalizationSymbols( + symbols.pMinecraftApp, + symbols.pGetMinecraftLanguage, + symbols.pGetMinecraftLocale); if (symbols.pTexturesBindTextureResource) { diff --git a/WeaveLoaderRuntime/src/ModAtlas.cpp b/WeaveLoaderRuntime/src/ModAtlas.cpp index 6fbd8ea..3591c75 100644 --- a/WeaveLoaderRuntime/src/ModAtlas.cpp +++ b/WeaveLoaderRuntime/src/ModAtlas.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #define STB_IMAGE_IMPLEMENTATION #define STB_IMAGE_WRITE_IMPLEMENTATION @@ -113,12 +114,64 @@ namespace ModAtlas return r; } + static bool HasPngExtension(const std::string& name) + { + if (name.size() < 4) return false; + char a = (char)tolower((unsigned char)name[name.size() - 4]); + char b = (char)tolower((unsigned char)name[name.size() - 3]); + char c = (char)tolower((unsigned char)name[name.size() - 2]); + char d = (char)tolower((unsigned char)name[name.size() - 1]); + return a == '.' && b == 'p' && c == 'n' && d == 'g'; + } + + static void ScanPngTree(const std::string& dir, + const std::string& baseDir, + const std::string& iconPrefix, + std::vector>& out, + std::unordered_set& seen) + { + WIN32_FIND_DATAA fd; + std::string search = dir + "\\*"; + HANDLE h = FindFirstFileA(search.c_str(), &fd); + if (h == INVALID_HANDLE_VALUE) return; + do + { + if (fd.cFileName[0] == '.') continue; + std::string fullPath = dir + "\\" + fd.cFileName; + if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) + { + ScanPngTree(fullPath, baseDir, iconPrefix, out, seen); + continue; + } + if (!HasPngExtension(fd.cFileName)) continue; + + if (fullPath.size() <= baseDir.size()) continue; + std::string rel = fullPath.substr(baseDir.size()); + if (!rel.empty() && (rel[0] == '\\' || rel[0] == '/')) + rel.erase(0, 1); + for (char& ch : rel) + { + if (ch == '\\') ch = '/'; + } + if (rel.size() < 4) continue; + rel.resize(rel.size() - 4); + rel = ToLower(rel); + + std::string iconName = iconPrefix + rel; + if (seen.insert(iconName).second) + out.push_back({ iconName, fullPath }); + } while (FindNextFileA(h, &fd)); + FindClose(h); + } + static void FindModTextures(const std::string& modsPath, std::vector>& blocks, std::vector>& items) { blocks.clear(); items.clear(); + std::unordered_set seenBlocks; + std::unordered_set seenItems; WIN32_FIND_DATAA fd; std::string search = modsPath + "\\*"; @@ -134,34 +187,31 @@ namespace ModAtlas std::string assetsPath = modFolder + "\\assets"; if (GetFileAttributesA(assetsPath.c_str()) != INVALID_FILE_ATTRIBUTES) { - std::string modId = ToLower(fd.cFileName); - size_t pos = modId.find('-'); - while (pos != std::string::npos) { modId.erase(pos, 1); pos = modId.find('-'); } - - std::string blocksPath = assetsPath + "\\blocks"; - std::string itemsPath = assetsPath + "\\items"; - - auto scanDir = [&](const std::string& dir, std::vector>& out, const std::string& prefix) + // Java-style assets: assets//textures/block|item/*.png + WIN32_FIND_DATAA nsfd; + std::string nsSearch = assetsPath + "\\*"; + HANDLE hNs = FindFirstFileA(nsSearch.c_str(), &nsfd); + if (hNs != INVALID_HANDLE_VALUE) { - std::string search2 = dir + "\\*.png"; - HANDLE h2 = FindFirstFileA(search2.c_str(), &fd); - if (h2 == INVALID_HANDLE_VALUE) return; do { - if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue; - std::string name = fd.cFileName; - name.resize(name.size() - 4); - std::string iconName = modId + ":" + name; - std::string fullPath = dir + "\\" + fd.cFileName; - out.push_back({ iconName, fullPath }); - } while (FindNextFileA(h2, &fd)); - FindClose(h2); - }; + if (!(nsfd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) continue; + if (nsfd.cFileName[0] == '.') continue; - if (GetFileAttributesA(blocksPath.c_str()) == FILE_ATTRIBUTE_DIRECTORY) - scanDir(blocksPath, blocks, "blocks"); - if (GetFileAttributesA(itemsPath.c_str()) == FILE_ATTRIBUTE_DIRECTORY) - scanDir(itemsPath, items, "items"); + std::string nsFolder = nsfd.cFileName; + std::string nsName = ToLower(nsFolder); + std::string nsPath = assetsPath + "\\" + nsFolder; + std::string texturesPath = nsPath + "\\textures"; + std::string blocksPath = texturesPath + "\\block"; + std::string itemsPath = texturesPath + "\\item"; + + if (GetFileAttributesA(blocksPath.c_str()) == FILE_ATTRIBUTE_DIRECTORY) + ScanPngTree(blocksPath, blocksPath, nsName + ":block/", blocks, seenBlocks); + if (GetFileAttributesA(itemsPath.c_str()) == FILE_ATTRIBUTE_DIRECTORY) + ScanPngTree(itemsPath, itemsPath, nsName + ":item/", items, seenItems); + } while (FindNextFileA(hNs, &nsfd)); + FindClose(hNs); + } } } while (FindNextFileA(h, &fd)); FindClose(h); diff --git a/WeaveLoaderRuntime/src/ModAtlas.h b/WeaveLoaderRuntime/src/ModAtlas.h index 8e2b488..24f3edd 100644 --- a/WeaveLoaderRuntime/src/ModAtlas.h +++ b/WeaveLoaderRuntime/src/ModAtlas.h @@ -6,7 +6,8 @@ /// /// Builds merged terrain.png and items.png atlases from mod assets. -/// Scans mods/*/assets/blocks/*.png and items/*.png, stitches into copies of the +/// Scans Java-style assets under mods/*/assets//textures/block|item/*.png, +/// then stitches into copies of the /// vanilla atlases stored in mods/ModLoader/generated/. A CreateFileW hook redirects /// the game's file opens to the merged copies so vanilla files are never modified. /// diff --git a/WeaveLoaderRuntime/src/NativeExports.cpp b/WeaveLoaderRuntime/src/NativeExports.cpp index 1e8fffb..47c6a3f 100644 --- a/WeaveLoaderRuntime/src/NativeExports.cpp +++ b/WeaveLoaderRuntime/src/NativeExports.cpp @@ -26,6 +26,13 @@ namespace LevelSetTileAndData_fn s_levelSetTileAndData = nullptr; LevelAddToTickNextTick_fn s_levelAddToTickNextTick = nullptr; LevelGetTile_fn s_levelGetTile = nullptr; + + using GetMinecraftLanguage_fn = unsigned char (__fastcall *)(void* thisPtr, int pad); + void* s_minecraftApp = nullptr; + GetMinecraftLanguage_fn s_getMinecraftLanguage = nullptr; + GetMinecraftLanguage_fn s_getMinecraftLocale = nullptr; + bool s_loggedMissingLanguage = false; + std::string s_modsPath; } void NativeExports::SetLevelInteropSymbols(void* hasNeighborSignal, void* setTileAndData, void* addToTickNextTick, void* getTile) @@ -36,6 +43,52 @@ void NativeExports::SetLevelInteropSymbols(void* hasNeighborSignal, void* setTil s_levelGetTile = reinterpret_cast(getTile); } +void NativeExports::SetLocalizationSymbols(void* appPtr, void* getLanguage, void* getLocale) +{ + s_minecraftApp = appPtr; + s_getMinecraftLanguage = reinterpret_cast(getLanguage); + s_getMinecraftLocale = reinterpret_cast(getLocale); +} + +void NativeExports::SetModsPath(const std::string& modsPath) +{ + s_modsPath = modsPath; +} + +static std::string ResolveDefaultModsPath() +{ + HMODULE hMod = nullptr; + std::string modsPath; + if (GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, (LPCSTR)&ResolveDefaultModsPath, &hMod) && hMod) + { + char dllPath[MAX_PATH] = { 0 }; + if (GetModuleFileNameA(hMod, dllPath, MAX_PATH)) + { + std::string dllDir(dllPath); + size_t dllPos = dllDir.find_last_of("\\/"); + if (dllPos != std::string::npos) + { + dllDir.resize(dllPos + 1); + modsPath = dllDir + "mods"; + return modsPath; + } + } + } + + char exePath[MAX_PATH] = { 0 }; + if (GetModuleFileNameA(nullptr, exePath, MAX_PATH)) + { + std::string exeDir(exePath); + size_t exePos = exeDir.find_last_of("\\/"); + if (exePos != std::string::npos) + { + exeDir.resize(exePos + 1); + modsPath = exeDir + "mods"; + } + } + return modsPath; +} + static std::wstring Utf8ToWide(const char* utf8) { if (!utf8 || !utf8[0]) return std::wstring(); @@ -512,6 +565,34 @@ void native_register_string(int descriptionId, const char* displayName) ModStrings::Register(descriptionId, wName.c_str()); } +int native_get_minecraft_language() +{ + if (!s_minecraftApp || !s_getMinecraftLanguage) + { + if (!s_loggedMissingLanguage) + { + s_loggedMissingLanguage = true; + LogUtil::Log("[WeaveLoader] native_get_minecraft_language: symbols unavailable"); + } + return -1; + } + return (int)s_getMinecraftLanguage(s_minecraftApp, 0); +} + +int native_get_minecraft_locale() +{ + if (!s_minecraftApp || !s_getMinecraftLocale) + return -1; + return (int)s_getMinecraftLocale(s_minecraftApp, 0); +} + +const char* native_get_mods_path() +{ + if (s_modsPath.empty()) + s_modsPath = ResolveDefaultModsPath(); + return s_modsPath.c_str(); +} + int native_register_entity( const char* namespacedId, float width, diff --git a/WeaveLoaderRuntime/src/NativeExports.h b/WeaveLoaderRuntime/src/NativeExports.h index c1a3379..d12dce1 100644 --- a/WeaveLoaderRuntime/src/NativeExports.h +++ b/WeaveLoaderRuntime/src/NativeExports.h @@ -1,8 +1,12 @@ #pragma once +#include + namespace NativeExports { void SetLevelInteropSymbols(void* hasNeighborSignal, void* setTileAndData, void* addToTickNextTick, void* getTile); + void SetLocalizationSymbols(void* appPtr, void* getLanguage, void* getLocale); + void SetModsPath(const std::string& modsPath); } /// Exported C functions callable from C# via P/Invoke. @@ -119,6 +123,9 @@ extern "C" __declspec(dllexport) int native_allocate_description_id(); __declspec(dllexport) void native_register_string(int descriptionId, const char* displayName); + __declspec(dllexport) int native_get_minecraft_language(); + __declspec(dllexport) int native_get_minecraft_locale(); + __declspec(dllexport) const char* native_get_mods_path(); __declspec(dllexport) int native_register_entity( const char* namespacedId, diff --git a/WeaveLoaderRuntime/src/SymbolResolver.cpp b/WeaveLoaderRuntime/src/SymbolResolver.cpp index dc0ea20..c874672 100644 --- a/WeaveLoaderRuntime/src/SymbolResolver.cpp +++ b/WeaveLoaderRuntime/src/SymbolResolver.cpp @@ -125,6 +125,8 @@ static const char* SYM_BUFFEREDIMAGE_CTOR_FILE = "??0BufferedImage@@QEAA@AEBV?$b static const char* SYM_BUFFEREDIMAGE_CTOR_DLC = "??0BufferedImage@@QEAA@PEAVDLCPack@@AEBV?$basic_string@_WU?$char_traits@_W@std@@V?$allocator@_W@2@@std@@_N@Z"; static const char* SYM_TEXTUREMANAGER_CREATETEXTURE = "?createTexture@TextureManager@@QEAAPEAVTexture@@AEBV?$basic_string@_WU?$char_traits@_W@std@@V?$allocator@_W@2@@std@@HHHHHHH_NPEAVBufferedImage@@@Z"; static const char* SYM_TEXTURE_TRANSFERFROMIMAGE = "?transferFromImage@Texture@@QEAAXPEAVBufferedImage@@@Z"; +static const char* SYM_GET_MINECRAFT_LANGUAGE = "?GetMinecraftLanguage@CMinecraftApp@@QEAAEH@Z"; +static const char* SYM_GET_MINECRAFT_LOCALE = "?GetMinecraftLocale@CMinecraftApp@@QEAAEH@Z"; static const char* SYM_ABSTRACT_TEXPACK_GETIMAGE = "?getImageResource@AbstractTexturePack@@UEAAPEAVBufferedImage@@AEBV?$basic_string@_WU?$char_traits@_W@std@@V?$allocator@_W@2@@std@@_N10@Z"; static const char* SYM_DLC_TEXPACK_GETIMAGE = "?getImageResource@DLCTexturePack@@UEAAPEAVBufferedImage@@AEBV?$basic_string@_WU?$char_traits@_W@std@@V?$allocator@_W@2@@std@@_N10@Z"; static const char* SYM_MINECRAFT_SETLEVEL = "?setLevel@Minecraft@@QEAAXPEAVMultiPlayerLevel@@HV?$shared_ptr@VPlayer@@@std@@_N2@Z"; @@ -330,6 +332,15 @@ bool SymbolResolver::ResolveGameFunctions() pTextureAtlasLocationBlocks = Resolve(SYM_TEXATLAS_BLOCKS); pTextureAtlasLocationItems = Resolve(SYM_TEXATLAS_ITEMS); pTileTiles = Resolve(SYM_TILE_TILES); + pMinecraftApp = Resolve("?app@@3VCMinecraftApp@@A"); + if (!pMinecraftApp) + pMinecraftApp = ResolveExactProcName(m_moduleBase, "app"); + pGetMinecraftLanguage = Resolve(SYM_GET_MINECRAFT_LANGUAGE); + if (!pGetMinecraftLanguage) + pGetMinecraftLanguage = ResolveExactProcName(m_moduleBase, "CMinecraftApp::GetMinecraftLanguage"); + pGetMinecraftLocale = Resolve(SYM_GET_MINECRAFT_LOCALE); + if (!pGetMinecraftLocale) + pGetMinecraftLocale = ResolveExactProcName(m_moduleBase, "CMinecraftApp::GetMinecraftLocale"); pLevelHasNeighborSignal = Resolve(SYM_LEVEL_HASNEIGHBORSIGNAL); pLevelSetTileAndData = Resolve(SYM_LEVEL_SETTILEANDDATA); pLevelAddToTickNextTick = Resolve(SYM_LEVEL_ADDTOTICKNEXTTICK); @@ -462,6 +473,9 @@ bool SymbolResolver::ResolveGameFunctions() logSym("TextureAtlas::LOCATION_BLOCKS", pTextureAtlasLocationBlocks); logSym("TextureAtlas::LOCATION_ITEMS", pTextureAtlasLocationItems); logSym("Tile::tiles", pTileTiles); + logSym("app (CMinecraftApp)", pMinecraftApp); + logSym("CMinecraftApp::GetMinecraftLanguage", pGetMinecraftLanguage); + logSym("CMinecraftApp::GetMinecraftLocale", pGetMinecraftLocale); logSym("Level::hasNeighborSignal", pLevelHasNeighborSignal); logSym("Level::setTileAndData", pLevelSetTileAndData); logSym("Level::addToTickNextTick", pLevelAddToTickNextTick); diff --git a/WeaveLoaderRuntime/src/SymbolResolver.h b/WeaveLoaderRuntime/src/SymbolResolver.h index 4df2496..83da0e2 100644 --- a/WeaveLoaderRuntime/src/SymbolResolver.h +++ b/WeaveLoaderRuntime/src/SymbolResolver.h @@ -100,6 +100,9 @@ public: void* pTextureAtlasLocationBlocks = nullptr; // TextureAtlas::LOCATION_BLOCKS void* pTextureAtlasLocationItems = nullptr; // TextureAtlas::LOCATION_ITEMS void* pTileTiles = nullptr; // Tile::tiles (Tile*[]) for tile id lookup + void* pMinecraftApp = nullptr; // global CMinecraftApp app + void* pGetMinecraftLanguage = nullptr; // CMinecraftApp::GetMinecraftLanguage(int) + void* pGetMinecraftLocale = nullptr; // CMinecraftApp::GetMinecraftLocale(int) void* pLevelHasNeighborSignal = nullptr; // Level::hasNeighborSignal(int,int,int) void* pLevelSetTileAndData = nullptr; // Level::setTileAndData(int,int,int,int,int,int) void* pLevelAddToTickNextTick = nullptr; // Level::addToTickNextTick(int,int,int,int,int) diff --git a/WeaveLoaderRuntime/src/WorldIdRemap.cpp b/WeaveLoaderRuntime/src/WorldIdRemap.cpp index d7d0b4d..61aa2b5 100644 --- a/WeaveLoaderRuntime/src/WorldIdRemap.cpp +++ b/WeaveLoaderRuntime/src/WorldIdRemap.cpp @@ -662,7 +662,7 @@ namespace WorldIdRemap 1.0f, 1.0f, 1, - L"weaveloader.api:missing_block", + L"weaveloader.api:block/missing_block", 0.0f, 15, kMissingBlockDescriptionId); @@ -676,7 +676,7 @@ namespace WorldIdRemap missingItemId, 64, 0, - L"weaveloader.api:missing_item", + L"weaveloader.api:item/missing_item", kMissingItemDescriptionId); IdRegistry::Instance().SetMissingFallback(IdRegistry::Type::Item, missingItemId); }