diff --git a/engine/Age.Engine.Tests/Sys4ScriptProviderTests.cs b/engine/Age.Engine.Tests/Sys4ScriptProviderTests.cs
new file mode 100644
index 0000000..b419f76
--- /dev/null
+++ b/engine/Age.Engine.Tests/Sys4ScriptProviderTests.cs
@@ -0,0 +1,20 @@
+using Age.Engine.Sys4;
+using Xunit;
+
+public class Sys4ScriptProviderTests
+{
+ [Fact]
+ public void ResolvesKnownIdsToTheirScripts()
+ {
+ var table = OpcodeTableJson.Load(Paths.OpcodesJson);
+ var provider = Sys4ScriptProvider.Load(table);
+
+ var additem = provider.GetById(0x1ab); // ADDITEM.BIN
+ var mes = provider.GetById(0x2ae7); // MES.BIN
+ Assert.NotNull(additem);
+ Assert.NotNull(mes);
+ Assert.True(additem!.Instructions.Count > 0);
+ Assert.Same(additem, provider.GetById(0x1ab)); // cached: same instance
+ Assert.Null(provider.GetById(long.MaxValue)); // unknown id
+ }
+}
diff --git a/engine/Age.Engine/Hosting/IScriptProvider.cs b/engine/Age.Engine/Hosting/IScriptProvider.cs
new file mode 100644
index 0000000..bb75aaa
--- /dev/null
+++ b/engine/Age.Engine/Hosting/IScriptProvider.cs
@@ -0,0 +1,10 @@
+using Age.Engine.Model;
+namespace Age.Engine.Hosting;
+
+/// Resolves a call-script id (a raw SYS4INI file index) to a loaded .
+/// Implemented in Sys4; injected into the VM so the Vm layer never references Sys4.
+public interface IScriptProvider
+{
+ /// The script for this id, or null if the id maps to no known script.
+ Script? GetById(long id);
+}
diff --git a/engine/Age.Engine/Sys4/Paths.cs b/engine/Age.Engine/Sys4/Paths.cs
index 2533566..fda219b 100644
--- a/engine/Age.Engine/Sys4/Paths.cs
+++ b/engine/Age.Engine/Sys4/Paths.cs
@@ -10,6 +10,7 @@ public static class Paths
public static string OpcodesJson => Path.Combine(Build, "opcodes.json");
public static string AssetSectionsJson => Path.Combine(Build, "asset-sections.json");
public static string AssetIndexJson => Path.Combine(Build, "asset-index.json");
+ public static string CallscriptNamesJson => Path.Combine(Build, "callscript-names.json");
public static string Textures => Path.Combine(Build, "textures");
private static string FindRepo()
diff --git a/engine/Age.Engine/Sys4/Sys4ScriptProvider.cs b/engine/Age.Engine/Sys4/Sys4ScriptProvider.cs
new file mode 100644
index 0000000..9ef9990
--- /dev/null
+++ b/engine/Age.Engine/Sys4/Sys4ScriptProvider.cs
@@ -0,0 +1,39 @@
+using System.Text.Json;
+using Age.Engine.Hosting;
+using Age.Engine.Model;
+namespace Age.Engine.Sys4;
+
+/// Resolves call-script ids (raw SYS4INI file indices) to loaded scripts, using
+/// build/callscript-names.json (id→name) + Paths.Scripts() (name→path). Cached per id.
+/// The native resolver prefers a loose override before the archive; Paths.Scripts() already
+/// shadows extracted/DATA1 with root overrides, so that behavior is preserved.
+public sealed class Sys4ScriptProvider : IScriptProvider
+{
+ private readonly OpcodeTable _table;
+ private readonly IReadOnlyDictionary _idToName;
+ private readonly Dictionary _byName; // NAME(UPPER) -> path
+ private readonly Dictionary _cache = new();
+
+ public Sys4ScriptProvider(OpcodeTable table, IReadOnlyDictionary idToName,
+ Dictionary byName)
+ { _table = table; _idToName = idToName; _byName = byName; }
+
+ public static Sys4ScriptProvider Load(OpcodeTable table)
+ {
+ var raw = JsonSerializer.Deserialize>(
+ File.ReadAllText(Paths.CallscriptNamesJson)) ?? new();
+ var idToName = raw.ToDictionary(kv => long.Parse(kv.Key), kv => kv.Value);
+ return new Sys4ScriptProvider(table, idToName, Paths.Scripts());
+ }
+
+ public Script? GetById(long id)
+ {
+ if (_cache.TryGetValue(id, out var cached)) return cached;
+ Script? s = null;
+ if (_idToName.TryGetValue(id, out var name) &&
+ _byName.TryGetValue(name.ToUpperInvariant(), out var path))
+ s = Sys4Loader.Load(path, _table);
+ _cache[id] = s;
+ return s;
+ }
+}