diff --git a/Theriapolis.Godot/Main.cs b/Theriapolis.Godot/Main.cs
index e4f8364..a98cb87 100644
--- a/Theriapolis.Godot/Main.cs
+++ b/Theriapolis.Godot/Main.cs
@@ -1,4 +1,5 @@
using Godot;
+using Theriapolis.GodotHost.Platform;
using Theriapolis.GodotHost.Rendering;
namespace Theriapolis.GodotHost;
@@ -18,6 +19,7 @@ public partial class Main : Node
ulong? smokeTestSeed = null;
ulong? worldMapSeed = null;
+ bool runAssetTest = false;
for (int i = 0; i < args.Length; i++)
{
if (args[i] == "--smoke-test")
@@ -28,6 +30,11 @@ public partial class Main : Node
smokeTestSeed = seed;
break;
}
+ if (args[i] == "--asset-test")
+ {
+ runAssetTest = true;
+ break;
+ }
if (args[i] == "--world-map")
{
ulong seed = 12345UL;
@@ -45,6 +52,13 @@ public partial class Main : Node
return;
}
+ if (runAssetTest)
+ {
+ int code = AssetTest.Run();
+ GetTree().Quit(code);
+ return;
+ }
+
if (worldMapSeed.HasValue)
{
// Replace the M0 hello-world children with the M2 world-map view.
diff --git a/Theriapolis.Godot/Platform/AssetTest.cs b/Theriapolis.Godot/Platform/AssetTest.cs
new file mode 100644
index 0000000..a8aea5c
--- /dev/null
+++ b/Theriapolis.Godot/Platform/AssetTest.cs
@@ -0,0 +1,78 @@
+using Godot;
+using System.IO;
+using System.Linq;
+
+namespace Theriapolis.GodotHost.Platform;
+
+///
+/// M3 asset pipeline smoke test. Walks Content/Data and Content/Gfx,
+/// reports counts, and verifies every PNG under Gfx loads via
+/// ContentLoader. Exits non-zero on any failure.
+///
+/// Run with:
+/// godot --headless --path Theriapolis.Godot -- --asset-test
+///
+public static class AssetTest
+{
+ public static int Run()
+ {
+ GD.Print($"[asset-test] Content root: {ContentPaths.ContentRoot}");
+ GD.Print($"[asset-test] Data dir: {ContentPaths.DataDir}");
+ GD.Print($"[asset-test] Gfx dir: {ContentPaths.GfxDir}");
+
+ if (!Directory.Exists(ContentPaths.DataDir))
+ {
+ GD.PrintErr($"[asset-test] FAIL: Data directory missing");
+ return 1;
+ }
+ if (!Directory.Exists(ContentPaths.GfxDir))
+ {
+ GD.PrintErr($"[asset-test] FAIL: Gfx directory missing");
+ return 1;
+ }
+
+ var jsonFiles = Directory.GetFiles(ContentPaths.DataDir, "*.json", SearchOption.AllDirectories);
+ GD.Print($"[asset-test] Data: {jsonFiles.Length} JSON files");
+
+ var pngFiles = Directory.GetFiles(ContentPaths.GfxDir, "*.png", SearchOption.AllDirectories);
+ GD.Print($"[asset-test] Gfx: {pngFiles.Length} PNG files");
+
+ int loaded = 0;
+ int failed = 0;
+ foreach (var absolute in pngFiles)
+ {
+ string relative = Path.GetRelativePath(ContentPaths.GfxDir, absolute)
+ .Replace('\\', '/');
+ var tex = ContentLoader.LoadGfx(relative);
+ if (tex is null)
+ {
+ failed++;
+ }
+ else
+ {
+ loaded++;
+ }
+ }
+
+ GD.Print($"[asset-test] Loaded {loaded}/{pngFiles.Length} PNGs " +
+ $"(cache size {ContentLoader.CacheCount}, failed {failed})");
+
+ if (failed > 0)
+ {
+ GD.PrintErr($"[asset-test] FAIL: {failed} texture(s) failed to load");
+ return 1;
+ }
+
+ // Also report the sub-tree breakdown, matching what M4 will care about
+ var bySubdir = pngFiles
+ .GroupBy(f => Path.GetRelativePath(ContentPaths.GfxDir, Path.GetDirectoryName(f)!)
+ .Replace('\\', '/'))
+ .OrderBy(g => g.Key);
+ GD.Print($"[asset-test] PNG breakdown by sub-directory:");
+ foreach (var g in bySubdir)
+ GD.Print($"[asset-test] {g.Key,-24} {g.Count(),3} files");
+
+ GD.Print($"[asset-test] OK");
+ return 0;
+ }
+}
diff --git a/Theriapolis.Godot/Platform/ContentLoader.cs b/Theriapolis.Godot/Platform/ContentLoader.cs
new file mode 100644
index 0000000..f4184d5
--- /dev/null
+++ b/Theriapolis.Godot/Platform/ContentLoader.cs
@@ -0,0 +1,55 @@
+using Godot;
+using System.Collections.Generic;
+using System.IO;
+
+namespace Theriapolis.GodotHost.Platform;
+
+///
+/// Loads PNGs from Content/Gfx as Godot ImageTextures and caches them by
+/// relative path. Texture filter is set to Nearest (project default for
+/// pixel art).
+///
+/// This bypasses Godot's res:// import pipeline because Content/ lives
+/// outside the project — but for static pixel-art assets at native size
+/// the import pipeline doesn't add anything we need.
+///
+/// Tactical-view tile rendering (M4) will hit this from chunk streamers,
+/// so the cache is per-process and never evicted; the full atlas
+/// (~50 PNGs at 32x32) is well under 1 MB.
+///
+public static class ContentLoader
+{
+ private static readonly Dictionary _cache = new();
+
+ ///
+ /// Loads a PNG from Content/Gfx/<relativePath>. Returns
+ /// null and logs an error if the file is missing or unreadable.
+ ///
+ public static ImageTexture? LoadGfx(string relativePath)
+ {
+ if (_cache.TryGetValue(relativePath, out var cached)) return cached;
+
+ string absolute = Path.Combine(ContentPaths.GfxDir, relativePath);
+ if (!File.Exists(absolute))
+ {
+ GD.PrintErr($"[ContentLoader] Missing texture: {absolute}");
+ return null;
+ }
+
+ var image = Image.LoadFromFile(absolute);
+ if (image is null)
+ {
+ GD.PrintErr($"[ContentLoader] Image.LoadFromFile failed: {absolute}");
+ return null;
+ }
+
+ var tex = ImageTexture.CreateFromImage(image);
+ _cache[relativePath] = tex;
+ return tex;
+ }
+
+ public static int CacheCount => _cache.Count;
+
+ /// Drops every cached texture. Useful for hot-reload tests.
+ public static void ClearCache() => _cache.Clear();
+}
diff --git a/Theriapolis.Godot/Platform/ContentPaths.cs b/Theriapolis.Godot/Platform/ContentPaths.cs
new file mode 100644
index 0000000..33097af
--- /dev/null
+++ b/Theriapolis.Godot/Platform/ContentPaths.cs
@@ -0,0 +1,46 @@
+using Godot;
+using System.IO;
+
+namespace Theriapolis.GodotHost.Platform;
+
+///
+/// Single source of truth for resolving the project's Content/ directory.
+/// Content lives at the repository root (sibling to Theriapolis.Godot/),
+/// not inside res://. This avoids duplicating gigabytes of game data, and
+/// keeps the same Content/Data and Content/Gfx tree the MonoGame branch
+/// and headless Tools all read from.
+///
+/// Resolution walks up from res:// looking for Content/Data; once found,
+/// the parent of Data is the Content root, used for Gfx too. Cached after
+/// first hit so repeated calls are cheap.
+///
+public static class ContentPaths
+{
+ private static string? _cachedContentRoot;
+
+ public static string ContentRoot => _cachedContentRoot ??= ResolveContentRoot();
+ public static string DataDir => Path.Combine(ContentRoot, "Data");
+ public static string GfxDir => Path.Combine(ContentRoot, "Gfx");
+
+ private static string ResolveContentRoot()
+ {
+ // Try res://../Content first — the canonical layout.
+ string fromRes = ProjectSettings.GlobalizePath("res://../Content");
+ if (Directory.Exists(Path.Combine(fromRes, "Data"))) return fromRes;
+
+ // Walk up further in case Theriapolis.Godot is nested deeper.
+ string? dir = ProjectSettings.GlobalizePath("res://").TrimEnd('/', '\\');
+ for (int i = 0; i < 6; i++)
+ {
+ if (string.IsNullOrEmpty(dir)) break;
+ string candidate = Path.Combine(dir, "Content");
+ if (Directory.Exists(Path.Combine(candidate, "Data"))) return candidate;
+ dir = Path.GetDirectoryName(dir);
+ }
+
+ // Last resort — return the canonical path even if it doesn't exist;
+ // callers that care will fail with a useful error.
+ GD.PushWarning($"[ContentPaths] Content root not found; using {fromRes}");
+ return fromRes;
+ }
+}
diff --git a/Theriapolis.Godot/Rendering/WorldMapView.cs b/Theriapolis.Godot/Rendering/WorldMapView.cs
index abc55a2..ad780fc 100644
--- a/Theriapolis.Godot/Rendering/WorldMapView.cs
+++ b/Theriapolis.Godot/Rendering/WorldMapView.cs
@@ -6,6 +6,7 @@ using Theriapolis.Core.Util;
using Theriapolis.Core.World;
using Theriapolis.Core.World.Generation;
using Theriapolis.Core.World.Polylines;
+using Theriapolis.GodotHost.Platform;
namespace Theriapolis.GodotHost.Rendering;
@@ -53,7 +54,7 @@ public partial class WorldMapView : Node2D
public override void _Ready()
{
- string dataDir = ResolveDataDir();
+ string dataDir = ContentPaths.DataDir;
if (!Directory.Exists(dataDir))
{
GD.PrintErr($"[world-map] Data directory not found: {dataDir}");
@@ -285,22 +286,6 @@ public partial class WorldMapView : Node2D
_ => BiomeId.TemperateGrassland,
};
- private static string ResolveDataDir()
- {
- string fromRes = ProjectSettings.GlobalizePath("res://../Content/Data");
- if (Directory.Exists(fromRes)) return fromRes;
-
- string? dir = ProjectSettings.GlobalizePath("res://").TrimEnd('/', '\\');
- for (int i = 0; i < 6; i++)
- {
- if (string.IsNullOrEmpty(dir)) break;
- string candidate = Path.Combine(dir, "Content", "Data");
- if (Directory.Exists(candidate)) return candidate;
- dir = Path.GetDirectoryName(dir);
- }
-
- return fromRes;
- }
}
///
diff --git a/Theriapolis.Godot/SmokeTest.cs b/Theriapolis.Godot/SmokeTest.cs
index 4a707ed..ce0c7c5 100644
--- a/Theriapolis.Godot/SmokeTest.cs
+++ b/Theriapolis.Godot/SmokeTest.cs
@@ -2,6 +2,7 @@ using Godot;
using System.IO;
using System.Linq;
using Theriapolis.Core.World.Generation;
+using Theriapolis.GodotHost.Platform;
namespace Theriapolis.GodotHost;
@@ -15,7 +16,7 @@ public static class SmokeTest
{
public static int Run(ulong seed)
{
- string dataDir = ResolveDataDir();
+ string dataDir = ContentPaths.DataDir;
if (!Directory.Exists(dataDir))
{
GD.PrintErr($"[smoke-test] Data directory not found: {dataDir}");
@@ -39,21 +40,4 @@ public static class SmokeTest
GD.Print($"[smoke-test] {kv.Key,-32} = 0x{kv.Value:X16}");
return 0;
}
-
- private static string ResolveDataDir()
- {
- string fromRes = ProjectSettings.GlobalizePath("res://../Content/Data");
- if (Directory.Exists(fromRes)) return fromRes;
-
- string? dir = ProjectSettings.GlobalizePath("res://").TrimEnd('/', '\\');
- for (int i = 0; i < 6; i++)
- {
- if (string.IsNullOrEmpty(dir)) break;
- string candidate = Path.Combine(dir, "Content", "Data");
- if (Directory.Exists(candidate)) return candidate;
- dir = Path.GetDirectoryName(dir);
- }
-
- return fromRes;
- }
}