diff --git a/engine/Age.Engine.Tests/GfxStateTests.cs b/engine/Age.Engine.Tests/GfxStateTests.cs
new file mode 100644
index 0000000..13d9e1c
--- /dev/null
+++ b/engine/Age.Engine.Tests/GfxStateTests.cs
@@ -0,0 +1,41 @@
+using Age.Engine.Model;
+using Xunit;
+
+public class GfxStateTests
+{
+ [Fact]
+ public void DistinctHandlesGetDistinctSlots()
+ {
+ var g = new GfxState();
+ int s1 = g.GetOrCreate(0x1000).Slot;
+ int s2 = g.GetOrCreate(0x2000).Slot;
+ Assert.NotEqual(s1, s2);
+ Assert.Equal(s1, g.QuerySlot(0x1000)); // stable
+ Assert.Equal(-1, g.QuerySlot(0x9999)); // unknown -> -1 (matches native 0xffffffff)
+ }
+
+ [Fact]
+ public void VectorsRoundTripPerObject()
+ {
+ var g = new GfxState();
+ g.GetOrCreate(0x1000).V18 = (10, 20, 30);
+ g.GetOrCreate(0x1000).V24 = (40, 50, 60);
+ Assert.Equal((10L, 20L, 30L), g.TryGet(0x1000)!.V18);
+ Assert.Equal((40L, 50L, 60L), g.TryGet(0x1000)!.V24);
+ Assert.Null(g.TryGet(0x2000)); // untouched handle absent
+ }
+
+ [Fact]
+ public void ReleaseFreesTheSlotForReuse()
+ {
+ var g = new GfxState();
+ int s1 = g.GetOrCreate(0x1000).Slot;
+ g.Release(0x1000);
+ Assert.Equal(-1, g.QuerySlot(0x1000));
+ Assert.Equal(s1, g.GetOrCreate(0x2000).Slot); // freed slot reused
+ }
+
+ [Fact]
+ public void PackColorPacksArgb()
+ => Assert.Equal(0x80_112233L, GfxState.PackColor(0x80, 0x112233));
+}
diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs
new file mode 100644
index 0000000..f95fef7
--- /dev/null
+++ b/engine/Age.Engine/Model/GfxState.cs
@@ -0,0 +1,61 @@
+namespace Age.Engine.Model;
+
+/// Host-agnostic model of the AGE native gfx command-buffer (reversed in
+/// docs/engine-re.md, gfx op-contract table). One registry maps an object handle to a GfxObject — the
+/// native ctx+0x408 map that op 0x215 queries and the geometry get/set ops share. Each object carries a
+/// slot (returned by 0x215) and three 3-vectors: V18 (set 0x217 / get 0x218, anchor), V24 (set 0x219 /
+/// get 0x21a, position), V16c (set 0x1ff). The native DirectDraw workers are NOT modelled — only the data
+/// the query ops read back, which is all the bytecode geometry math needs.
+public sealed class GfxState
+{
+ public sealed class GfxObject
+ {
+ public int Slot = -1;
+ public (long X, long Y, long Z) V18, V24, V16c;
+ public long Field64, Field68, Field6c;
+ public long Color;
+ }
+
+ private readonly Dictionary _objects = new();
+ private readonly SortedSet _free = new();
+ private int _nextSlot = 4; // observed native slot range is 4..13
+ private readonly Dictionary _fieldTable = new(); // ctx+0x46d14 (0x216); no family writer -> default 0
+ public long CurrentObject { get; private set; }
+
+ /// Live objects and their slots — for the CLI gfx oracle (Task 3.7).
+ public IEnumerable<(long Handle, int Slot)> Objects
+ {
+ get { foreach (var kv in _objects) yield return (kv.Key, kv.Value.Slot); }
+ }
+
+ private int AcquireSlot()
+ {
+ if (_free.Count > 0) { int s = _free.Min; _free.Remove(s); return s; }
+ return _nextSlot++;
+ }
+
+ public GfxObject GetOrCreate(long handle)
+ {
+ if (!_objects.TryGetValue(handle, out var o))
+ {
+ o = new GfxObject { Slot = AcquireSlot() };
+ _objects[handle] = o;
+ }
+ CurrentObject = handle;
+ return o;
+ }
+
+ public GfxObject? TryGet(long handle) => _objects.TryGetValue(handle, out var o) ? o : null;
+ public int QuerySlot(long handle) => _objects.TryGetValue(handle, out var o) ? o.Slot : -1;
+ public long QueryField(long idx) => _fieldTable.TryGetValue(idx, out var v) ? v : 0;
+
+ public void Release(long handle)
+ {
+ if (_objects.TryGetValue(handle, out var o)) { if (o.Slot >= 0) _free.Add(o.Slot); _objects.Remove(handle); }
+ }
+
+ /// Pack (alpha, rgb) → 0xAARRGGBB, matching op 0x202/0x203's handler bit-manipulation for the
+ /// common (non-negative-sentinel) case. The alpha<0 / color<0 native-fetch path is deferred.
+ public static long PackColor(long alpha, long color)
+ => ((alpha & 0xff) << 24) | (color & 0xffffff);
+}