Render DEBUGMAP tiled field surfaces

This commit is contained in:
gamer147
2026-07-21 13:02:02 -04:00
parent a0df85825e
commit 44bbf5b6b4
14 changed files with 465 additions and 112 deletions

View File

@@ -81,6 +81,22 @@ public class AgfDecoderTests
Assert.Contains(image.Pixels.Where((_, i) => (i & 3) == 3), a => a is > 0 and < 255);
}
[Theory]
[InlineData(0x32da, "SO005.AGF")]
[InlineData(0x32db, "SO007.AGF")]
[InlineData(0x32dc, "SO008A.AGF")]
[InlineData(0x32dd, "SO007A.AGF")]
public void InstalledFieldMapSheetsResolveAndDecodeByRawCatalogIndex(int rawId, string name)
{
var resources = ResourceMap.Load();
var asset = resources.ResolveRawTexture(rawId);
Assert.NotNull(asset);
Assert.Equal(name, asset.Name);
var image = resources.DecodeTexture(asset);
Assert.True(image.Width > 0);
Assert.True(image.Height > 0);
}
private sealed class MemoryStore(byte[] bytes) : IAssetStore
{
public Stream Open(AssetEntry entry) => new MemoryStream(bytes, writable: false);

View File

@@ -102,6 +102,8 @@ public class GfxCommandBufferTests
=> (0x1fb, new[] { G(handle), G(slot), I(0), I(0), G(w), G(h), G(dx), G(dy) });
private static (int, Operand[]) SetTex(int resId, int slot) => (0x1f9, new[] { G(resId), G(slot), I(0) });
private static (int, Operand[]) SetRawTex(long resId, long slot, long colorKey)
=> (0x249, new[] { I(resId), I(slot), I(colorKey) });
[Fact]
public void SetThenDrawTextureMakesAVisibleObjectFromTheSurface()
@@ -122,4 +124,26 @@ public class GfxCommandBufferTests
Assert.Equal(0x25, vis[0].SurfaceResId); // resolved from the object's live source slot
Assert.Equal((800, 600, 0, 0), (vis[0].W, vis[0].H, vis[0].DstX, vis[0].DstY));
}
[Fact]
public void RawTextureLoadBypassesSceneResourceNormalizationAndFeedsRetainedDraws()
{
var t = T();
var scene = ScriptAssembler.Assemble(t, "RAW-GFX", new List<(int, Operand[])>
{
SetRawTex(0x32da, 0x3e, 0),
(0x1fb, new[] { I(0x100), I(0x3e), I(0), I(0), I(100), I(100), I(20), I(30) }),
Exit(),
}, System.Array.Empty<string>());
var host = new RecordingHost { TextureResourceIdOffset = 0x1000 };
var vm = new VirtualMachine(scene, t, host);
vm.Run();
Assert.Equal((0x32daL, 0x3e), Assert.Single(host.Textures));
var visible = Assert.Single(vm.Gfx.SnapshotVisibleObjects());
Assert.Equal(0x32da, visible.SurfaceResId);
Assert.Equal(0, visible.ColorKey);
Assert.Equal((100, 100, 20, 30), (visible.W, visible.H, visible.DstX, visible.DstY));
}
}

View File

@@ -0,0 +1,76 @@
using System.Collections.Generic;
using System.Linq;
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
public class GfxRangeTransformTests
{
[Fact]
public void RangeCameraCentersItsAnchorAndLeavesUiHandlesUnchanged()
{
var gfx = new GfxState();
gfx.SetSurface(1, 1, -1);
gfx.BindDraw(10, 1, 0, 0, 1, 1, 500, 350); // selected map object at the camera anchor
gfx.BindDraw(20, 1, 0, 0, 1, 1, 500, 350); // screen-fixed UI object outside the range
gfx.SetRangeTransform(10, 1, (500, 350, 0));
gfx.SetRangeTranslationCurrent((-100, -50, 0));
gfx.SetRangeScaleCurrent((150, 150, 100));
var objects = gfx.SnapshotVisibleObjects();
var map = objects.Single(x => x.Handle == 10);
var ui = objects.Single(x => x.Handle == 20);
var mapOrigin = Transform2DMath.Build(map.Transform).FromLocalOrigin(map.DstX, map.DstY)
.Then(map.RangeTransform!.Value).Apply(0, 0);
var uiOrigin = Transform2DMath.Build(ui.Transform).FromLocalOrigin(ui.DstX, ui.DstY).Apply(0, 0);
Assert.Equal((400.0, 300.0), mapOrigin);
Assert.Null(ui.RangeTransform);
Assert.Equal((500.0, 350.0), uiOrigin);
}
[Fact]
public void RangeScaleTargetUsesTheOrdinaryOneShotClock()
{
var gfx = new GfxState();
gfx.SetSurface(1, 1, -1);
gfx.BindDraw(10, 1, 0, 0, 1, 1, 410, 300);
gfx.SetRangeTransform(10, 1, (400, 300, 0));
gfx.SetRangeScaleCurrent((100, 100, 100));
gfx.SetRangeScaleChannel(delayMs: 0, durationMs: 300, percent: (200, 200, 100));
gfx.SnapshotVisibleObjects(1000); // seeds the native-style shared channel start
var halfway = gfx.SnapshotVisibleObjects(1150).Single();
var p = Transform2DMath.Build(halfway.Transform).FromLocalOrigin(halfway.DstX, halfway.DstY)
.Then(halfway.RangeTransform!.Value).Apply(0, 0);
Assert.Equal((415.0, 300.0), p);
Assert.True(gfx.HasActiveTimedPresentation(1150));
gfx.SnapshotVisibleObjects(1300);
Assert.False(gfx.HasActiveTimedPresentation(1300));
}
[Fact]
public void Opcode229SelectsRangeWithoutCreatingAnOrdinaryObject()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var script = ScriptAssembler.Assemble(table, "RANGE", new List<(int, Operand[])>
{
(0x229, new[] { I(1), I(100), I(400), I(300), I(0) }),
(0x22c, new[] { I(0), I(0), I(0) }),
(0x22a, new[] { I(100), I(100), I(100) }),
(0x2, System.Array.Empty<Operand>()),
}, System.Array.Empty<string>());
var vm = new VirtualMachine(script, table, new RecordingHost());
vm.Run();
Assert.Null(vm.Gfx.TryGet(1));
vm.Gfx.SetSurface(1, 1, -1);
vm.Gfx.BindDraw(1, 1, 0, 0, 1, 1, 400, 300);
Assert.NotNull(vm.Gfx.SnapshotVisibleObjects().Single().RangeTransform);
}
private static Operand I(long v) => new(0, v);
}

View File

@@ -42,12 +42,15 @@ internal class RecordingHost : IHost
public readonly List<int> ClearedRenderTargets = new();
public readonly List<(int First, int Count)> ReleasedSurfaceRanges = new();
public readonly List<(int Source, int Target, long Interval)> SurfaceCrossfades = new();
public readonly List<(long Resource, int Slot)> Textures = new();
public readonly List<bool> MessageSkipChanges = new();
public readonly List<bool> PhysicalMessageSkipChanges = new();
public readonly List<long> CursorResources = new();
public readonly List<bool> AdvPagePresentationSuspended = new();
public int CursorClearCount;
public int SceneContextResets;
public long TextureResourceIdOffset;
public long ResolveTextureResourceId(long resourceId) => resourceId + TextureResourceIdOffset;
public void ShowText(int offset, string text) => Lines.Add((offset, text));
public void SetAdvTextCursor(int layoutSlot, int x, int y) => TextCursors.Add((layoutSlot, x, y));
public void DrawStringToSurface(int surfaceSlot, int x, int y, string text)
@@ -123,7 +126,7 @@ internal class RecordingHost : IHost
public void CrossfadeSurfaces(GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument)
=> SurfaceCrossfades.Add((sourceSurface, targetSurface, intervalArgument));
public void CreateTexture(int slot, int w, int h) { }
public void SetTexture(long resId, int slot) { }
public void SetTexture(long resId, int slot) => Textures.Add((resId, slot));
public void ClearRenderTarget(int surfaceSlot) => ClearedRenderTargets.Add(surfaceSlot);
public void ReleaseSurfaceRange(int firstSlot, int count) => ReleasedSurfaceRanges.Add((firstSlot, count));
public void DrawTexture(int slot, int sx, int sy, int w, int h, int dx, int dy) { }

View File

@@ -38,7 +38,8 @@ public readonly record struct RenderObject(long Handle, long SurfaceResId, long
int Alpha, long Tint, int TintStrength, BlendKind Blend,
bool MultiplyTint,
SurfaceTransitionState? SurfaceTransition = null,
ColorTransitionState? ColorTransition = null);
ColorTransitionState? ColorTransition = null,
Affine2D? RangeTransform = null);
/// <summary>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
@@ -119,6 +120,12 @@ public sealed class GfxState
// returns the object's live source slot (obj+4), or -1 when the handle has not been drawn/bound yet.
private readonly Dictionary<long, GfxObject> _objects = new();
// Ops 0x229-0x22e address one embedded gfx-object record outside the ordinary object map. Its sampled
// matrix is post-multiplied onto only the selected handle range during native composition. FIELD uses
// this as its map camera while the surrounding dungeon UI remains screen-fixed.
private long _rangeTransformFirst, _rangeTransformCount;
private GfxObject _rangeTransform = new();
private readonly Dictionary<long, long> _fieldTable = new(); // ctx+0x46d14 (0x216); no family writer -> default 0
public long CurrentObject { get; private set; }
/// <summary>EngineCtx+0x14e08, selected by op 0x80 and used by op 0x1d9 when its slot is zero.</summary>
@@ -155,6 +162,44 @@ public sealed class GfxState
lock (_lock) DefaultObjectSlot = slot;
}
/// <summary>Op 0x229: reset the embedded range transform, select [first, first+count), and set its
/// anchor/pivot. This does not create or mutate an ordinary retained object.</summary>
public void SetRangeTransform(long first, long count, (long X, long Y, long Z) anchor)
{
lock (_lock)
{
_rangeTransformFirst = first;
_rangeTransformCount = System.Math.Max(0, count);
_rangeTransform = new GfxObject { V18 = anchor };
}
}
/// <summary>Op 0x22a: immediately replace the embedded range transform's current scale.</summary>
public void SetRangeScaleCurrent((long X, long Y, long Z) percent)
{
lock (_lock)
_rangeTransform.ScaleCurrent = (percent.X / 100.0, percent.Y / 100.0, percent.Z / 100.0);
}
/// <summary>Op 0x22c: immediately replace the embedded range transform's current translation.</summary>
public void SetRangeTranslationCurrent((long X, long Y, long Z) translation)
{
lock (_lock) _rangeTransform.TranslationCurrent = translation;
}
/// <summary>Op 0x22d: arm the range transform's delayed one-shot scale target.</summary>
public void SetRangeScaleChannel(long delayMs, long durationMs, (long X, long Y, long Z) percent)
{
lock (_lock)
{
_rangeTransform.ScaleDelayMs = delayMs;
_rangeTransform.ScaleDurationMs = durationMs;
_rangeTransform.ScaleTarget = (percent.X / 100.0, percent.Y / 100.0, percent.Z / 100.0);
_rangeTransform.ScaleEnabled = durationMs > 0;
_rangeTransform.OneShotStartMs = -1;
}
}
/// <summary>Op 0x21d: clone the native 0x2d4-byte retained-object record from source to destination.</summary>
public bool CloneObject(long sourceHandle, long destinationHandle)
{
@@ -257,6 +302,9 @@ public sealed class GfxState
_surfaceTransitions.Clear();
CurrentObject = 0;
CurrentRenderTargetSlot = -1;
_rangeTransformFirst = 0;
_rangeTransformCount = 0;
_rangeTransform = new GfxObject();
AnimClockDurationTicks = 0;
AnimClockGeneration++;
}
@@ -424,6 +472,8 @@ public sealed class GfxState
{
lock (_lock)
return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) ||
_rangeTransform.ScaleEnabled || _rangeTransform.RotationChannelEnabled ||
_rangeTransform.TranslationEnabled ||
_objects.Values.Any(o => o.Visible &&
(o.OneShotAnimationControlFlags & 1) == 0 &&
(o.OneShotColorEnabled || o.ScaleEnabled ||
@@ -441,42 +491,48 @@ public sealed class GfxState
/// marked by op 0x242 bit 0 keep sampling asynchronously.</summary>
private void ForceCompleteOneShotChannels()
{
CommitOneShotChannels(_rangeTransform);
foreach (var o in _objects.Values)
{
if ((o.OneShotAnimationControlFlags & 1) != 0) continue;
if (o.OneShotColorEnabled)
{
o.Color = o.OneShotColorTarget & 0xffffffff;
o.OneShotColorTarget = -1;
o.ColorDelayMs = 0;
o.ColorDurationMs = 0;
o.OneShotColorEnabled = false;
}
if (o.ScaleEnabled)
{
o.ScaleCurrent = o.ScaleTarget;
o.ScaleDelayMs = 0;
o.ScaleDurationMs = 0;
o.ScaleEnabled = false;
}
if (o.RotationChannelEnabled)
{
o.RotationCurrent = o.RotationTarget;
o.RotationDelayMs = 0;
o.RotationDurationMs = 0;
o.RotationChannelEnabled = false;
}
if (o.TranslationEnabled)
{
o.TranslationCurrent = o.TranslationTarget;
o.TranslationDelayMs = 0;
o.TranslationDurationMs = 0;
o.TranslationEnabled = false;
}
o.OneShotStartMs = -1;
CommitOneShotChannels(o);
}
}
private static void CommitOneShotChannels(GfxObject o)
{
if (o.OneShotColorEnabled)
{
o.Color = o.OneShotColorTarget & 0xffffffff;
o.OneShotColorTarget = -1;
o.ColorDelayMs = 0;
o.ColorDurationMs = 0;
o.OneShotColorEnabled = false;
}
if (o.ScaleEnabled)
{
o.ScaleCurrent = o.ScaleTarget;
o.ScaleDelayMs = 0;
o.ScaleDurationMs = 0;
o.ScaleEnabled = false;
}
if (o.RotationChannelEnabled)
{
o.RotationCurrent = o.RotationTarget;
o.RotationDelayMs = 0;
o.RotationDurationMs = 0;
o.RotationChannelEnabled = false;
}
if (o.TranslationEnabled)
{
o.TranslationCurrent = o.TranslationTarget;
o.TranslationDelayMs = 0;
o.TranslationDurationMs = 0;
o.TranslationEnabled = false;
}
o.OneShotStartMs = -1;
}
/// <summary>Whether sampling the retained scene at a later frame can change its pixels without another
/// VM mutation. Includes finite presentation work plus the ambient channels that may remain active while
/// the interpreter is parked at an input wait. Static waits themselves are deliberately not animation.</summary>
@@ -484,6 +540,8 @@ public sealed class GfxState
{
lock (_lock)
return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) ||
_rangeTransform.ScaleEnabled || _rangeTransform.RotationChannelEnabled ||
_rangeTransform.TranslationEnabled ||
_objects.Values.Any(o => o.Visible &&
(o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled ||
o.TranslationEnabled ||
@@ -651,7 +709,8 @@ public sealed class GfxState
/// <summary>Visible objects in ascending-handle order (= z-order), each with its source surface resolved
/// and its active channels interpolated at <paramref name="nowMs"/>. Position is the base V24 (a direct
/// transform, ops 0x22f/0x229); scale and translation are independent one-shot matrix channels. The
/// transform (op 0x22f); scale and translation are independent one-shot matrix channels. Ops
/// 0x229-0x22e contribute a second sampled matrix only to their selected handle range. The
/// src-rect channel (0x239/0x231) selects the spritesheet cell; the color channel (0x232) ping-pongs the
/// alpha/tint. Channel Start fields seed to nowMs on first sight.</summary>
public IReadOnlyList<RenderObject> SnapshotVisibleObjects(long nowMs)
@@ -659,6 +718,28 @@ public sealed class GfxState
lock (_lock)
{
var list = new List<RenderObject>();
Affine2D? rangeAffine = null;
if (_rangeTransformCount > 0)
{
var r = _rangeTransform;
bool hadRangeOneShot = r.ScaleEnabled || r.RotationChannelEnabled || r.TranslationEnabled;
if (hadRangeOneShot && r.OneShotStartMs < 0) r.OneShotStartMs = nowMs;
var rangeScale = SampleMatrixChannel(ref r.ScaleCurrent, r.ScaleTarget, r.ScaleDelayMs,
r.ScaleDurationMs, r.OneShotStartMs, ref r.ScaleEnabled, nowMs);
var rangeRotation = SampleRotationChannel(ref r.RotationCurrent, r.RotationTarget,
r.RotationDelayMs, r.RotationDurationMs, r.OneShotStartMs,
ref r.RotationChannelEnabled, nowMs);
var rangeTranslation = SampleMatrixChannel(ref r.TranslationCurrent, r.TranslationTarget,
r.TranslationDelayMs, r.TranslationDurationMs, r.OneShotStartMs,
ref r.TranslationEnabled, nowMs);
if (!r.ScaleEnabled && !r.RotationChannelEnabled && !r.TranslationEnabled)
r.OneShotStartMs = -1;
rangeAffine = Transform2DMath.Build(new TransformState(
rangeScale.X, rangeScale.Y, rangeScale.Z,
rangeTranslation.X, rangeTranslation.Y, rangeTranslation.Z,
r.V18.X, r.V18.Y, r.V18.Z,
rangeRotation.X, rangeRotation.Y, rangeRotation.Z, rangeRotation.Angle));
}
foreach (var kv in _objects.OrderBy(k => k.Key))
{
var o = kv.Value;
@@ -762,6 +843,9 @@ public sealed class GfxState
SurfaceTransitionState? transition = _surfaceTransitions.TryGetValue(o.SourceSlot, out var st)
? SampleTransition(st, nowMs) : null;
Affine2D? objectRangeTransform = rangeAffine is { } ra &&
kv.Key >= _rangeTransformFirst && kv.Key - _rangeTransformFirst < _rangeTransformCount
? ra : null;
list.Add(new RenderObject(kv.Key, resId, ck, srcX, srcY, w, h,
(int)o.V24.X, (int)o.V24.Y,
new TransformState(scale.X, scale.Y, scale.Z,
@@ -771,7 +855,8 @@ public sealed class GfxState
new RotationCycleState(o.RotationEnabled, o.RotationPeriodMs,
o.RotationAxis.X, o.RotationAxis.Y,
o.RotationAxis.Z, cycleAngle),
alpha, tint, strength, blend, multiplyTint, transition, colorTransition));
alpha, tint, strength, blend, multiplyTint, transition,
colorTransition, objectRangeTransform));
}
return list;
}

View File

@@ -12,6 +12,17 @@ public readonly record struct Affine2D(double XX, double XY, double YX, double Y
return new(XX, XY, YX, YY, p.X, p.Y);
}
/// <summary>Compose this row-vector transform followed by <paramref name="next"/>. Native uses this
/// order when it post-multiplies an object's matrix by the selected retained-gfx range transform.</summary>
public Affine2D Then(Affine2D next)
=> new(
XX * next.XX + XY * next.YX,
XX * next.XY + XY * next.YY,
YX * next.XX + YY * next.YX,
YX * next.XY + YY * next.YY,
TX * next.XX + TY * next.YX + next.TX,
TX * next.XY + TY * next.YY + next.TY);
public bool TryInverse(out Affine2D inverse)
{
double det = XX * YY - XY * YX;

View File

@@ -41,10 +41,10 @@ public sealed class ResourceMap
return entry is { IsPlaceholder: false } && IsAudio(entry) ? entry : null;
}
/// <summary>Resolve an already-normalized raw catalog id without applying a scene section base.</summary>
/// <summary>Resolve an already-normalized packed catalog id without applying a scene section base.</summary>
public AssetEntry? ResolveRawTexture(long rawId)
{
var entry = _catalog.ResolveRaw(rawId);
var entry = _catalog.ResolvePacked(rawId);
return entry is { IsPlaceholder: false } &&
entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null;
}

View File

@@ -1328,6 +1328,20 @@ public sealed class VirtualMachine
_host.SetTexture(resolvedResourceId, (int)Read(a[1]));
return pc + 1; // host still tracks dims for get-texture-size
}
case "u00422EB0": // pre-reference compatibility
case "load-raw-texture-surface": // 0x249 (raw catalog id)(slot)(colorkey)
{
// Native shares 0x1f9's release/load/colorkey path, but constructs its mode-1
// surface subclass and receives an already-global SYS4INI catalog index. The
// CPU compositor does not need the D3D subclass distinction; it does need the
// resource id to bypass the executing script's scene-section normalization.
long rawResourceId = Read(a[0]);
int surfaceSlot = (int)Read(a[1]);
_host.ReleaseSurface(surfaceSlot);
Gfx.SetSurface(surfaceSlot, rawResourceId, Read(a[2]));
_host.SetTexture(rawResourceId, surfaceSlot);
return pc + 1;
}
case "draw-texture": // 0x1fb (handle)(slot)(srcX)(srcY)(w)(h)(dstX)(dstY) — bind object -> surface + rect + pos
Gfx.BindDraw(Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), (int)Read(a[3]),
(int)Read(a[4]), (int)Read(a[5]), (int)Read(a[6]), (int)Read(a[7]));
@@ -1445,8 +1459,21 @@ public sealed class VirtualMachine
// ---- SC0000 anim/transform/spritesheet cluster (docs/engine-re.md §"SC0000 anim ... cluster") ----
case "u00421DD0": // 0x22f set-position: (handle)(op2)(x)(y)(z) -> base position (direct set)
case "u004219E0": // 0x229 set-position2: same shape, direct position
Gfx.GetOrCreate(Read(a[0])).V24 = (Read(a[2]), Read(a[3]), Read(a[4])); return pc + 1;
case "u004219E0": // pre-reference compatibility
case "set-gfx-range-transform": // 0x229 (first)(count)(anchor x/y/z)
Gfx.SetRangeTransform(Read(a[0]), Read(a[1]), (Read(a[2]), Read(a[3]), Read(a[4])));
return pc + 1;
case "u00421A90": // pre-reference compatibility
case "set-gfx-range-scale-current": // 0x22a (sx%)(sy%)(sz%)
Gfx.SetRangeScaleCurrent((Read(a[0]), Read(a[1]), Read(a[2]))); return pc + 1;
case "u00421BD0": // pre-reference compatibility
case "set-gfx-range-translation-current": // 0x22c (tx)(ty)(tz)
Gfx.SetRangeTranslationCurrent((Read(a[0]), Read(a[1]), Read(a[2]))); return pc + 1;
case "u00421C60": // pre-reference compatibility
case "set-gfx-range-scale-target": // 0x22d (delay)(duration)(sx%)(sy%)(sz%)
Gfx.SetRangeScaleChannel(Read(a[0]), Read(a[1]), (Read(a[2]), Read(a[3]), Read(a[4])));
return pc + 1;
case "u004223C0": // 0x239 spritesheet cell: (handle)(delay)(duration)(frame count)(columns)(cell)
Gfx.SetSrcRect(Read(a[0]), Read(a[3]), Read(a[4]), Read(a[5]), 0); return pc + 1;
case "u00421EA0": // 0x231 looping spritesheet: (handle)(ms per frame)(frame count)(columns)