Implement DEBUGMAP combat frontier

This commit is contained in:
gamer147
2026-07-21 19:30:56 -04:00
parent cc6db721db
commit 6e147014ec
15 changed files with 1318 additions and 207 deletions

View File

@@ -0,0 +1,127 @@
using System;
using System.Collections.Generic;
using Age.Engine.Hosting;
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
public class BattleFrontierOpsTests
{
private const int Immediate = 0, InlineString = 2, GlobalInt = 3, GlobalString = 5,
LocalString = 11;
private static OpcodeTable Table() => OpcodeTableJson.Load(Paths.OpcodesJson);
private static Operand I(long value) => new(Immediate, value);
private static Operand G(int address) => new(GlobalInt, address);
[Fact]
public void SurfaceRectCopy_ForwardsTheCompleteBlitRequest()
{
var table = Table();
var script = ScriptAssembler.Assemble(table, "SURFACE_COPY", new List<(int, Operand[])>
{
(0x207, new[] { I(72), I(67), I(12), I(15), I(3), I(4), I(30), I(33) }),
(0x2, Array.Empty<Operand>()),
}, Array.Empty<string>());
var host = new RecordingHost();
new VirtualMachine(script, table, host).Run();
Assert.Equal(new SurfaceRectCopy(72, 67, 12, 15, 3, 4, 30, 33),
Assert.Single(host.SurfaceCopies));
}
[Fact]
public void ScalarBattleHelpers_PreserveNativeSignedBehaviorAndStringAliasing()
{
var table = Table();
var script = ScriptAssembler.Assemble(table, "BATTLE_SCALARS", new List<(int, Operand[])>
{
(0x191, new[] { G(0x100), I(int.MinValue) }),
(0x193, new[] { new Operand(GlobalString, 0x500), new Operand(GlobalString, 0x500),
new Operand(InlineString, 0) }),
(0x1c8, new[] { new Operand(LocalString, 0), I(-42) }),
(0x55, new[] { new Operand(GlobalString, 0x501), new Operand(LocalString, 0) }),
(0x2, Array.Empty<Operand>()),
}, new[] { "/99" });
var vm = new VirtualMachine(script, table, new RecordingHost());
vm.GlobalStrings[0x500] = "17";
vm.Run();
Assert.Equal(int.MinValue, vm.Globals[0x100]);
Assert.Equal("17/99", vm.GlobalStrings[0x500]);
Assert.Equal("-42", vm.GlobalStrings[0x501]);
}
[Fact]
public void MonotonicAndFrameTimeOps_StoreNativeLowDwordSamples()
{
var table = Table();
var host = new SequencedClockHost(0x1_0000_0005, 100, 116);
var script = ScriptAssembler.Assemble(table, "BATTLE_CLOCK", new List<(int, Operand[])>
{
(0xd0, new[] { G(0x100) }),
(0x23c, Array.Empty<Operand>()),
(0x23c, Array.Empty<Operand>()),
(0x2, Array.Empty<Operand>()),
}, Array.Empty<string>());
var vm = new VirtualMachine(script, table, host);
vm.Run();
Assert.Equal(5, vm.Globals[0x100]);
Assert.Equal(100u, vm.Gfx.PreviousFrameTimeMilliseconds);
Assert.Equal(116u, vm.Gfx.CurrentFrameTimeMilliseconds);
}
[Fact]
public void MovieActivityAndAnimationServiceFlags_ControlBattlePollingAndReset()
{
var table = Table();
var host = new RecordingHost();
host.ActiveMovieSurfaces.Add(7);
var script = ScriptAssembler.Assemble(table, "BATTLE_CONTROL", new List<(int, Operand[])>
{
(0x23a, new[] { G(0x100), I(7) }),
(0x23a, new[] { G(0x101), I(8) }),
(0x238, new[] { I(400) }),
(0x24e, new[] { I(2) }),
(0x243, Array.Empty<Operand>()),
(0x2, Array.Empty<Operand>()),
}, Array.Empty<string>());
var vm = new VirtualMachine(script, table, host);
vm.Run();
Assert.Equal(1, vm.Globals[0x100]);
Assert.Equal(0, vm.Globals[0x101]);
Assert.Equal(400, vm.Gfx.AnimClockDurationTicks);
Assert.Equal(1, vm.Gfx.AnimClockGeneration);
Assert.Equal(2, vm.Gfx.AnimationServiceFlags);
}
[Fact]
public void DelayedCombatVoice_ForwardsAllSchedulingOperands()
{
var table = Table();
var host = new RecordingHost();
var script = ScriptAssembler.Assemble(table, "BATTLE_VOICE", new List<(int, Operand[])>
{
(0x2c0, new[] { I(123), I(0), I(275) }),
(0x2, Array.Empty<Operand>()),
}, Array.Empty<string>());
new VirtualMachine(script, table, host).Run();
Assert.Equal((123L, 0, 275L), Assert.Single(host.ScheduledVoiceRequests));
}
private sealed class SequencedClockHost : RecordingHost
{
private readonly Queue<long> _samples;
public SequencedClockHost(params long[] samples) => _samples = new Queue<long>(samples);
public override long InputClockMilliseconds => _samples.Dequeue();
}
}

View File

@@ -0,0 +1,188 @@
using System.Collections.Generic;
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
public class IntegerQueueOpsTests
{
private const int Immediate = 0, GlobalInt = 3, LocalInt = 9;
private static Operand I(long value) => new(Immediate, value);
private static Operand G(int address) => new(GlobalInt, address);
private static Operand L(int address) => new(LocalInt, address);
[Fact]
public void IntegerQueues_AreIndependentFifosAndResetDiscardsPendingValues()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var script = ScriptAssembler.Assemble(table, "INT_QUEUE", new List<(int, Operand[])>
{
(0x132, new[] { I(0) }),
(0x132, new[] { I(1) }),
(0x133, new[] { I(0), I(11) }),
(0x133, new[] { I(0), I(-22) }),
(0x133, new[] { I(1), I(33) }),
(0x134, new[] { I(0), L(0), L(1) }),
(0x55, new[] { G(0x100), L(0) }),
(0x55, new[] { G(0x101), L(1) }),
(0x134, new[] { I(0), L(0), L(1) }),
(0x55, new[] { G(0x102), L(0) }),
(0x55, new[] { G(0x103), L(1) }),
(0x134, new[] { I(1), L(0), L(1) }),
(0x55, new[] { G(0x104), L(0) }),
(0x55, new[] { G(0x105), L(1) }),
(0x55, new[] { L(1), I(777) }),
(0x134, new[] { I(0), L(0), L(1) }),
(0x55, new[] { G(0x106), L(0) }),
(0x55, new[] { G(0x107), L(1) }),
(0x133, new[] { I(1), I(44) }),
(0x132, new[] { I(1) }),
(0x134, new[] { I(1), L(0), L(1) }),
(0x55, new[] { G(0x108), L(0) }),
(0x2, System.Array.Empty<Operand>()),
}, System.Array.Empty<string>());
var vm = new VirtualMachine(script, table, new RecordingHost());
vm.Run();
Assert.Equal(1, vm.Globals[0x100]);
Assert.Equal(11, vm.Globals[0x101]);
Assert.Equal(1, vm.Globals[0x102]);
Assert.Equal(-22, vm.Globals[0x103]);
Assert.Equal(1, vm.Globals[0x104]);
Assert.Equal(33, vm.Globals[0x105]);
Assert.Equal(0, vm.Globals[0x106]);
Assert.Equal(777, vm.Globals[0x107]);
Assert.Equal(0, vm.Globals[0x108]);
}
[Fact]
public void PreloadedScriptSlot_RestartsCodeAndRetainsItsLocalBankAcrossCalls()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var root = ScriptAssembler.Assemble(table, "PRELOADED_ROOT", new List<(int, Operand[])>
{
(0x6, new[] { I(0x500), I(0x1f) }),
(0x8, new[] { I(0x1f) }),
(0x8, new[] { I(0x1f) }),
(0x2, System.Array.Empty<Operand>()),
}, System.Array.Empty<string>());
var worker = ScriptAssembler.Assemble(table, "PRELOADED_WORKER", new List<(int, Operand[])>
{
(0x50, new[] { L(0), L(0), I(1) }),
(0x55, new[] { G(0x120), L(0) }),
(0x2, System.Array.Empty<Operand>()),
}, System.Array.Empty<string>());
var vm = new VirtualMachine(root, table, new RecordingHost(),
provider: new MapProvider(new Dictionary<long, Script> { [0x500] = worker }));
vm.Run();
Assert.Equal("exit", vm.HaltReason);
Assert.Equal(2, vm.Globals[0x120]);
Assert.Equal(2, vm.CallScriptDispatches);
}
[Fact]
public void RegisteredRealMvseek_ExpandsMovementCostsThroughTheSystem4ServiceAbi()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var mvseek = Sys4Loader.Load(Paths.Scripts()["MVSEEK.BIN"], table);
var root = ScriptAssembler.Assemble(table, "SYSTEM4_SERVICE_BRIDGE", new List<(int, Operand[])>
{
(0x6, new[] { I(0x3381), I(0x1f) }),
(0x8, new[] { I(0x1f) }),
(0x2, System.Array.Empty<Operand>()),
}, System.Array.Empty<string>());
var vm = new VirtualMachine(root, table, new RecordingHost(),
provider: new MapProvider(new Dictionary<long, Script> { [0x3381] = mvseek }));
SeedSearchGrid(vm, movementPoints: 3);
vm.Run();
Assert.Equal("exit", vm.HaltReason);
Assert.Equal(3, vm.Globals[MovementCell(6, 5)]);
Assert.Equal(3, vm.Globals[MovementCell(4, 5)]);
Assert.Equal(3, vm.Globals[MovementCell(5, 6)]);
Assert.Equal(3, vm.Globals[MovementCell(5, 4)]);
}
[Fact]
public void RealMvseek_ExpandsMovementCostsBeyondTheOrigin()
{
var vm = RealSearchVm("MVSEEK.BIN");
SeedSearchGrid(vm, movementPoints: 3);
vm.Run();
Assert.Equal("exit", vm.HaltReason);
Assert.Equal(3, vm.Globals[MovementCell(6, 5)]);
Assert.Equal(3, vm.Globals[MovementCell(4, 5)]);
Assert.Equal(3, vm.Globals[MovementCell(5, 6)]);
Assert.Equal(3, vm.Globals[MovementCell(5, 4)]);
}
[Fact]
public void RealAtseek_ExpandsAttackDistancesBeyondTheOrigin()
{
var vm = RealSearchVm("ATSEEK.BIN");
SeedSearchGrid(vm, movementPoints: 0);
vm.Globals[0xcc9f3] = 2;
vm.Run();
Assert.Equal("exit", vm.HaltReason);
Assert.Equal(1, vm.Globals[AttackCell(6, 5)]);
Assert.Equal(1, vm.Globals[AttackCell(4, 5)]);
Assert.Equal(1, vm.Globals[AttackCell(5, 6)]);
Assert.Equal(1, vm.Globals[AttackCell(5, 4)]);
}
private static VirtualMachine RealSearchVm(string name)
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var script = Sys4Loader.Load(Paths.Scripts()[name], table);
return new VirtualMachine(script, table, new RecordingHost());
}
private static void SeedSearchGrid(VirtualMachine vm, int movementPoints)
{
const int entity = 0;
const int x = 5, y = 5;
vm.Globals[0x66713] = entity;
vm.Globals[0x5231f + entity] = x;
vm.Globals[0x52351 + entity] = y;
vm.Globals[0x4e11b + entity * 14 + 10] = movementPoints;
vm.Globals[0xccbcf + 1] = 1;
vm.Globals[0xccbcf + 2] = -1;
vm.Globals[0xccbcf + 3] = 0;
vm.Globals[0xccbcf + 4] = 0;
vm.Globals[0xccbd4 + 1] = 0;
vm.Globals[0xccbd4 + 2] = 0;
vm.Globals[0xccbd4 + 3] = 1;
vm.Globals[0xccbd4 + 4] = -1;
// Terrain id 1 is passable. MVSEEK checks destination centers; ATSEEK checks the
// fine-grid edge between the origin and each logical neighbor.
vm.Globals[0xe6ae0 + 1] = 1;
SetTerrain(vm, x * 2, y * 2);
SetTerrain(vm, (x + 1) * 2, y * 2);
SetTerrain(vm, (x - 1) * 2, y * 2);
SetTerrain(vm, x * 2, (y + 1) * 2);
SetTerrain(vm, x * 2, (y - 1) * 2);
SetTerrain(vm, x * 2 + 1, y * 2);
SetTerrain(vm, x * 2 - 1, y * 2);
SetTerrain(vm, x * 2, y * 2 + 1);
SetTerrain(vm, x * 2, y * 2 - 1);
}
private static void SetTerrain(VirtualMachine vm, int x, int y)
=> vm.Globals[0x341ab + y * 53 + x] = 1;
private static int MovementCell(int x, int y) => 0xaba96 + y * 27 + x;
private static int AttackCell(int x, int y) => 0xb8d86 + y * 27 + x;
}

View File

@@ -93,6 +93,25 @@ public class RenderObjectBlendTests
Assert.False(rendered.MultiplyTint);
}
[Fact]
public void CreatedSurfaceMode0_UsesPackedAlphaAsOpacityAndRgbAsModulation()
{
var g = new GfxState();
g.CreateSurface(3);
g.BindDraw(0x100, 3, 0, 0, 10, 10, 0, 0);
g.SetStaticObjectColorResolved(0x100, 0, 0x40, 0x102030);
var rendered = g.SnapshotVisibleObjects().Single();
Assert.Equal(0, rendered.SurfaceResId);
Assert.Equal(-1, rendered.ColorKey);
Assert.Equal(0x40, rendered.Alpha);
Assert.Equal(0, rendered.TintStrength);
Assert.Equal(0x102030, rendered.Tint);
Assert.True(rendered.MultiplyTint);
Assert.Equal(BlendKind.Alpha, rendered.Blend);
}
[Fact]
public void Mode1_UsesAdditiveBlendWithArgbSourceScaleAndRgbModulation()
{

View File

@@ -0,0 +1,80 @@
using Age.Engine.Sys4;
using Age.Engine.Model;
using Xunit;
public class RgbaSurfaceOpsTests
{
[Fact]
public void FillRect_ReplacesClippedPixelsIncludingAlpha()
{
var surface = Row(1, 2, 3, 4);
Assert.True(RgbaSurfaceOps.FillRect(surface, -1, 0, 3, 1, 0xd0, 0x112233));
Assert.Equal(new byte[] { 0x11, 0x22, 0x33, 0xd0 }, surface.Pixels[..4]);
Assert.Equal(new byte[] { 0x11, 0x22, 0x33, 0xd0 }, surface.Pixels[4..8]);
Assert.Equal(3, Values(surface)[2]);
Assert.Equal(4, Values(surface)[3]);
}
[Fact]
public void GeneratedSurfaceBinding_DefaultsToNoColorKey()
{
var gfx = new GfxState();
gfx.CreateSurface(67);
gfx.BindDraw(1, 67, 0, 0, 4, 1, 0, 0);
Assert.Equal(-1, Assert.Single(gfx.SnapshotVisibleObjects()).ColorKey);
}
[Fact]
public void CopyRect_ClipsSourceAndDestinationAsOnePairedRectangle()
{
var source = Row(10, 20, 30, 40);
var destination = Row(1, 2, 3, 4);
Assert.True(RgbaSurfaceOps.CopyRect(source, destination, -1, 0, 4, 1, 1, 0));
Assert.Equal(new byte[] { 1, 2, 10, 20 }, Values(destination));
}
[Fact]
public void CopyRect_UsesStableSourcePixelsForOverlappingSelfCopy()
{
var surface = Row(10, 20, 30, 40);
Assert.True(RgbaSurfaceOps.CopyRect(surface, surface, 0, 0, 3, 1, 1, 0));
Assert.Equal(new byte[] { 10, 10, 20, 30 }, Values(surface));
}
[Fact]
public void WithColorKey_MakesMatchingSourcePixelsTransparentBeforeComposition()
{
var source = new RgbaImage(2, 1, new byte[] { 1, 2, 3, 255, 4, 5, 6, 255 });
var keyed = RgbaSurfaceOps.WithColorKey(source, 0x010203);
Assert.Equal(0, keyed.Pixels[3]);
Assert.Equal(255, keyed.Pixels[7]);
Assert.Equal(255, source.Pixels[3]);
}
private static RgbaImage Row(params byte[] values)
{
var pixels = new byte[values.Length * 4];
for (int index = 0; index < values.Length; index++)
{
pixels[index * 4] = values[index];
pixels[index * 4 + 3] = 255;
}
return new RgbaImage(values.Length, 1, pixels);
}
private static byte[] Values(RgbaImage image)
{
var values = new byte[image.Width];
for (int index = 0; index < values.Length; index++) values[index] = image.Pixels[index * 4];
return values;
}
}

View File

@@ -24,6 +24,7 @@ internal class RecordingHost : IHost
public int HistoryPresentationEnds;
public readonly List<int> ClearedTextLayouts = new();
public readonly List<SurfaceRectFill> SurfaceFills = new();
public readonly List<SurfaceRectCopy> SurfaceCopies = new();
public readonly List<(long First, long Count)> PresentedRanges = new();
public readonly List<AdvWaitIndicatorConfig> WaitIndicators = new();
public readonly List<bool> WaitIndicatorEnabledChanges = new();
@@ -32,6 +33,7 @@ internal class RecordingHost : IHost
public readonly List<(long Resource, int Channel)> SfxLoads = new();
public readonly List<long> Voices = new();
public readonly List<(long Id, int PlaybackVariant)> VoiceRequests = new();
public readonly List<(long Id, int PlaybackVariant, long DelayMs)> ScheduledVoiceRequests = new();
public readonly List<long> VoiceBgmDuckControls = new();
public readonly List<int> SfxStarts = new();
public readonly List<(int Channel, int StartMode, long DelayMs)> ScheduledSfxStarts = new();
@@ -40,6 +42,7 @@ internal class RecordingHost : IHost
public readonly List<(long Resource, int Surface, long Flags, long SyncMask)> Movies = new();
public System.Action? OnPlayMovie;
public long? MovieStopTimeMs;
public readonly HashSet<int> ActiveMovieSurfaces = new();
public readonly List<(long Resource, int Surface, long Flags)> ModalMovies = new();
public readonly List<int> ClearedRenderTargets = new();
public readonly List<(int First, int Count)> ReleasedSurfaceRanges = new();
@@ -78,6 +81,7 @@ internal class RecordingHost : IHost
}
public int MessageWindowAlphaSetting { get; set; }
public void FillSurfaceRect(SurfaceRectFill fill) => SurfaceFills.Add(fill);
public void CopySurfaceRect(SurfaceRectCopy copy) => SurfaceCopies.Add(copy);
public void PresentObjectRange(GfxState gfx, long firstHandle, long count)
=> PresentedRanges.Add((firstHandle, count));
public void ConfigureAdvWaitIndicator(AdvWaitIndicatorConfig config) => WaitIndicators.Add(config);
@@ -143,6 +147,8 @@ internal class RecordingHost : IHost
VoiceRequests.Add((id, playbackVariant));
}
public void SetVoiceBgmDuckControl(long flags) => VoiceBgmDuckControls.Add(flags);
public void ScheduleVoicePlayback(long id, int playbackVariant, long delayMs)
=> ScheduledVoiceRequests.Add((id, playbackVariant, delayMs));
public void LoadSoundEffect(long resourceId, int channel) => SfxLoads.Add((resourceId, channel));
public void StartSoundEffect(int channel) => SfxStarts.Add(channel);
public void ScheduleSoundEffectStart(int channel, int startMode, long delayMs)
@@ -155,6 +161,7 @@ internal class RecordingHost : IHost
OnPlayMovie?.Invoke();
return MovieStopTimeMs;
}
public bool IsMovieSurfaceActive(int surfaceSlot) => ActiveMovieSurfaces.Contains(surfaceSlot);
public void PlayModalMovieToSurface(long rawResourceId, int surfaceSlot, long movieFlags)
=> ModalMovies.Add((rawResourceId, surfaceSlot, movieFlags));
}

View File

@@ -13,6 +13,10 @@ public readonly record struct AdvAutoWaitState(
public readonly record struct SurfaceRectFill(
int SurfaceSlot, int X, int Y, int Width, int Height, int Alpha, long Rgb);
public readonly record struct SurfaceRectCopy(
int SourceSurface, int DestinationSurface, int SourceX, int SourceY,
int Width, int Height, int DestinationX, int DestinationY);
public interface IHost
{
/// <summary>Report a recoverable runtime discrepancy while allowing script execution to continue.</summary>
@@ -36,6 +40,7 @@ public interface IHost
void EndTextHistoryPresentation() { }
int MessageWindowAlphaSetting => 0;
void FillSurfaceRect(SurfaceRectFill fill) { }
void CopySurfaceRect(SurfaceRectCopy copy) { }
void PresentObjectRange(GfxState gfx, long firstHandle, long count) { }
void ConfigureAdvWaitIndicator(AdvWaitIndicatorConfig config) { }
// Op 0x1ce explicitly starts/stops the same animated marker that op 0x72 starts for an ADV wait.
@@ -87,6 +92,7 @@ public interface IHost
void CrossfadeSurfaces(GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument) { }
void CreateTexture(int slot, int width, int height);
void SetTexture(long resourceId, int slot);
void SetTexture(long resourceId, int slot, long colorKey) => SetTexture(resourceId, slot);
void ReleaseSurface(int slot) { }
/// <summary>Clear the selected target's pixels; -1 denotes the main backbuffer.</summary>
void ClearRenderTarget(int surfaceSlot) { }
@@ -101,6 +107,7 @@ public interface IHost
// Native voice playback retains a second start argument: ordinary dialogue passes 0,
// while History replay (0x1bd) passes 1. Existing non-audio hosts may ignore it.
void PlayVoice(long id, int playbackVariant) => PlayVoice(id);
void ScheduleVoicePlayback(long id, int playbackVariant, long delayMs) { }
// Native op 0x1cf stores a transient control mask. Bit 0 suppresses the automatic
// BGM attenuation normally applied when a voice starts.
void SetVoiceBgmDuckControl(long flags) { }
@@ -117,6 +124,7 @@ public interface IHost
/// when the host could not obtain usable timing metadata. Native op 0x23f queries this state
/// immediately after 0x236 returns.</returns>
long? PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask) => null;
bool IsMovieSurfaceActive(int surfaceSlot) => false;
// Native op 0x20f uses a universal raw-catalog id and parks script execution until the movie
// reaches EOF or the player cancels it. The decoder remains asynchronous; the interactive host
// owns the modal wait so its render loop can continue publishing frames.

View File

@@ -145,6 +145,9 @@ public sealed class GfxState
// Retained for its opcode family; 0x21e scale and 0x220 translation use frame-time directly instead. ----
public long AnimClockDurationTicks { get; private set; }
public long AnimClockGeneration { get; private set; }
public long AnimationServiceFlags { get; private set; }
public uint PreviousFrameTimeMilliseconds { get; private set; }
public uint CurrentFrameTimeMilliseconds { get; private set; }
/// <summary>Live geometry objects and the surface slot they draw from — for the CLI gfx oracle.</summary>
public IEnumerable<(long Handle, int Slot)> Objects
@@ -307,6 +310,7 @@ public sealed class GfxState
_objects.Clear();
_fieldTable.Clear();
_surfaces.Clear();
_createdSurfaces.Clear();
_movieStopTimesMs.Clear();
_surfaceTransitions.Clear();
CurrentObject = 0;
@@ -316,6 +320,9 @@ public sealed class GfxState
_rangeTransform = new GfxObject();
AnimClockDurationTicks = 0;
AnimClockGeneration++;
AnimationServiceFlags = 0;
PreviousFrameTimeMilliseconds = 0;
CurrentFrameTimeMilliseconds = 0;
}
}
@@ -323,6 +330,9 @@ public sealed class GfxState
// ---- surfaces (image buffers per slot): ctx+0x52bd4[slot], from create/set-texture ----
private readonly Dictionary<int, (long ResId, long ColorKey)> _surfaces = new();
// Created surfaces have real pixels but no asset resource id. Keep their class separate from both
// loaded textures and truly surfaceless objects because native mode-0 consumes packed alpha differently.
private readonly HashSet<int> _createdSurfaces = new();
// A separate entry models the native CMovieToTexture object attached to a surface. A null value means
// the movie object exists but its host decoder supplied no usable IMediaPosition stop time.
private readonly Dictionary<int, long?> _movieStopTimesMs = new();
@@ -332,6 +342,7 @@ public sealed class GfxState
lock (_lock)
{
_surfaces[slot] = (resId, colorKey);
_createdSurfaces.Remove(slot);
_movieStopTimesMs.Remove(slot);
}
}
@@ -366,6 +377,7 @@ public sealed class GfxState
for (int slot = firstSlot; slot < end; slot++)
{
_surfaces.Remove(slot);
_createdSurfaces.Remove(slot);
_movieStopTimesMs.Remove(slot);
_surfaceTransitions.Remove(slot);
}
@@ -463,12 +475,24 @@ public sealed class GfxState
o.ColorAnim = true;
}
}
public void CreateSurface(int slot)
{
lock (_lock)
{
_surfaces[slot] = (0, -1); // create-texture: real mutable pixels, no asset id or color key
_createdSurfaces.Add(slot);
_movieStopTimesMs.Remove(slot);
}
}
public void ClearSurface(int slot)
{
lock (_lock)
{
_surfaces[slot] = (0, 0); // create-texture (blank)
_surfaces.Remove(slot);
_createdSurfaces.Remove(slot);
_movieStopTimesMs.Remove(slot);
_surfaceTransitions.Remove(slot);
}
}
@@ -801,12 +825,27 @@ public sealed class GfxState
lock (_lock) { AnimClockDurationTicks = durationTicks; AnimClockGeneration++; }
}
public void SetAnimationServiceFlags(long flags)
{
lock (_lock) AnimationServiceFlags = unchecked((uint)flags);
}
public void SampleFrameTime(long nowMilliseconds)
{
lock (_lock)
{
PreviousFrameTimeMilliseconds = CurrentFrameTimeMilliseconds;
CurrentFrameTimeMilliseconds = unchecked((uint)nowMilliseconds);
}
}
/// <summary>Op 0x243: force ordinary finite channels to their endpoints and reset the separate
/// global animation-service clock. Op-0x242-detached objects ignore the completion request.</summary>
public void ResetAnimClock()
{
lock (_lock)
{
if ((AnimationServiceFlags & 2) != 0) return;
ForceCompleteOneShotChannels();
AnimClockDurationTicks = 0;
AnimClockGeneration++;
@@ -856,7 +895,10 @@ public sealed class GfxState
if (!o.Visible) continue;
bool hadOneShot = o.OneShotColorEnabled || o.ScaleEnabled ||
o.RotationChannelEnabled || o.TranslationEnabled;
var (resId, ck) = _surfaces.TryGetValue(o.SourceSlot, out var s) ? s : (0L, 0L);
// Created/mutable surfaces have no file resource id but remain a distinct surface class.
// Zero is an active "key black" value, so created and absent slots both default to -1.
var (resId, ck) = _surfaces.TryGetValue(o.SourceSlot, out var s) ? s : (0L, -1L);
bool createdSurface = _createdSurfaces.Contains(o.SourceSlot);
// ---- packed color: a static mode 0 treats alpha as tint/fill strength. Once op 0x202 has
// armed the one-shot channel, its current/target ARGB instead supplies opacity and D3D-style
@@ -898,6 +940,13 @@ public sealed class GfxState
// not a request to replace every texel with white.
alpha = a; strength = 0; blend = BlendKind.Alpha;
}
else if (createdSurface)
{
// Native created/render-target surfaces use packed alpha as object opacity.
// SYSTEM4/BUNKI relies on this for translucent panels; FIELD uses the same surface
// at alpha 0x40 beneath the minimap so the paper chrome remains visible.
alpha = a; strength = 0; blend = BlendKind.Alpha; multiplyTint = true;
}
else if (resId != 0)
{
// Native mode 0 is the opaque textured path. RGB modulates the source and the

View File

@@ -0,0 +1,82 @@
namespace Age.Engine.Sys4;
/// <summary>Platform-neutral mutation helpers for AGE's software-modeled RGBA surfaces.</summary>
public static class RgbaSurfaceOps
{
public static bool FillRect(RgbaImage destination, int x, int y, int width, int height,
byte alpha, long rgb)
{
long left = x, top = y, right = left + width, bottom = top + height;
if (width <= 0 || height <= 0) return false;
left = System.Math.Max(0, left);
top = System.Math.Max(0, top);
right = System.Math.Min(destination.Width, right);
bottom = System.Math.Min(destination.Height, bottom);
if (right <= left || bottom <= top) return false;
byte red = (byte)((rgb >> 16) & 0xff);
byte green = (byte)((rgb >> 8) & 0xff);
byte blue = (byte)(rgb & 0xff);
for (int row = (int)top; row < (int)bottom; row++)
{
int offset = checked((row * destination.Width + (int)left) * 4);
for (int column = (int)left; column < (int)right; column++, offset += 4)
{
destination.Pixels[offset] = red;
destination.Pixels[offset + 1] = green;
destination.Pixels[offset + 2] = blue;
destination.Pixels[offset + 3] = alpha;
}
}
return true;
}
public static RgbaImage WithColorKey(RgbaImage source, long colorKey)
{
if (!Age.Engine.Model.BlendMath.HasColorKey(colorKey)) return source;
byte[] pixels = (byte[])source.Pixels.Clone();
for (int index = 0; index < pixels.Length; index += 4)
if (Age.Engine.Model.BlendMath.ColorKeyMatches(
pixels[index], pixels[index + 1], pixels[index + 2], colorKey))
pixels[index + 3] = 0;
return new RgbaImage(source.Width, source.Height, pixels);
}
/// <summary>Copy a rectangle while clipping source and destination together. A temporary buffer
/// preserves native blit behavior when the two rectangles overlap in the same surface.</summary>
public static bool CopyRect(RgbaImage source, RgbaImage destination,
int sourceX, int sourceY, int width, int height,
int destinationX, int destinationY)
{
long sx = sourceX, sy = sourceY, dx = destinationX, dy = destinationY;
long w = width, h = height;
if (w <= 0 || h <= 0) return false;
if (sx < 0) { long n = -sx; sx = 0; dx += n; w -= n; }
if (sy < 0) { long n = -sy; sy = 0; dy += n; h -= n; }
if (dx < 0) { long n = -dx; dx = 0; sx += n; w -= n; }
if (dy < 0) { long n = -dy; dy = 0; sy += n; h -= n; }
w = System.Math.Min(w, source.Width - sx);
h = System.Math.Min(h, source.Height - sy);
w = System.Math.Min(w, destination.Width - dx);
h = System.Math.Min(h, destination.Height - dy);
if (w <= 0 || h <= 0) return false;
int clippedWidth = checked((int)w);
int clippedHeight = checked((int)h);
int rowBytes = checked(clippedWidth * 4);
byte[] pixels = new byte[checked(rowBytes * clippedHeight)];
for (int row = 0; row < clippedHeight; row++)
{
int sourceOffset = checked(((int)sy + row) * source.Width * 4 + (int)sx * 4);
source.Pixels.AsSpan(sourceOffset, rowBytes).CopyTo(pixels.AsSpan(row * rowBytes, rowBytes));
}
for (int row = 0; row < clippedHeight; row++)
{
int destinationOffset = checked(((int)dy + row) * destination.Width * 4 + (int)dx * 4);
pixels.AsSpan(row * rowBytes, rowBytes)
.CopyTo(destination.Pixels.AsSpan(destinationOffset, rowBytes));
}
return true;
}
}

View File

@@ -53,6 +53,12 @@ public sealed class VirtualMachine
private volatile bool _advSkipServiceEnabled;
private AdvTextStyle _advTextStyle = AdvTextStyle.Default;
private readonly Dictionary<string, int> _valueSwitchTargets = new(StringComparer.Ordinal);
// Native EngineCtx owns 11 lazily allocated integer FIFOs at +0x55130. ATSEEK/MVSEEK use
// slot zero as their packed-coordinate flood-fill worklist; op 0x132 replaces a slot.
private readonly Queue<int>?[] _intQueues = new Queue<int>?[11];
// Opcodes 0x06/0x08 load scripts into numbered EngineCtx frame slots and invoke them later.
// Unlike ordinary call-script frames, native non-adjacent slots survive return with locals intact.
private readonly Dictionary<int, PreloadedScriptSlot> _preloadedScriptSlots = new();
public long CallScriptDispatches { get; private set; }
public Dictionary<int, long> Globals { get; } = new();
@@ -493,6 +499,7 @@ public sealed class VirtualMachine
private sealed class RootReloadRequestedException : Exception { }
private sealed class ProcessExitRequestedException : Exception { }
private sealed record DebugFrameReturnRequest(ExecFrame Frame, IReadOnlyDictionary<int, long> GlobalWrites);
private sealed record PreloadedScriptSlot(long ScriptId, ExecFrame Frame);
private enum FrameOutcome { Returned, DebugReturned, RootReload, ExitRequested, Halted, RanOff }
public void Run(int entryOffset = 0)
@@ -537,6 +544,7 @@ public sealed class VirtualMachine
{
Gfx.ResetSceneContext();
_valueSwitchTargets.Clear();
_preloadedScriptSlots.Clear();
lock (_interactiveLock)
{
_interactiveFrame = null;
@@ -717,6 +725,25 @@ public sealed class VirtualMachine
case "string-not-equals":
Write(a[0], string.Equals(ReadStr(a[1]), ReadStr(a[2]), StringComparison.Ordinal) ? 0 : 1);
return pc + 1;
case "concat":
{
string left = ReadStr(a[1]);
string right = ReadStr(a[2]);
WriteStr(a[0], left + right);
return pc + 1;
}
case "toString":
WriteStr(a[0], unchecked((int)Read(a[1])).ToString(System.Globalization.CultureInfo.InvariantCulture));
return pc + 1;
case "absolute-value":
{
int value = unchecked((int)Read(a[1]));
int sign = value >> 31;
Write(a[0], unchecked((value ^ sign) - sign));
return pc + 1;
}
case "get-monotonic-time-ms":
Write(a[0], unchecked((int)_host.InputClockMilliseconds)); return pc + 1;
case "lt": Write(a[0], Read(a[1]) < Read(a[2]) ? 1 : 0); return pc + 1;
case "lte": Write(a[0], Read(a[1]) <= Read(a[2]) ? 1 : 0); return pc + 1;
case "gr": Write(a[0], Read(a[1]) > Read(a[2]) ? 1 : 0); return pc + 1;
@@ -840,6 +867,62 @@ public sealed class VirtualMachine
}
return pc + 1;
}
case "u0041EF00":
case "reset-int-queue": // 0x132 (queue_id): destroy/recreate one of 11 native FIFO slots
{
int queueId = unchecked((int)Read(a[0]));
if ((uint)queueId >= (uint)_intQueues.Length)
{
HaltReason ??= $"int-queue-id-out-of-range:{queueId}";
return HALT;
}
_intQueues[queueId] = new Queue<int>(0x100);
return pc + 1;
}
case "u0041EFF0":
case "enqueue-int": // 0x133 (queue_id, value)
{
int queueId = unchecked((int)Read(a[0]));
if ((uint)queueId >= (uint)_intQueues.Length)
{
HaltReason ??= $"int-queue-id-out-of-range:{queueId}";
return HALT;
}
if (_intQueues[queueId] is not { } queue)
{
HaltReason ??= $"int-queue-uninitialized:{queueId}";
return HALT;
}
queue.Enqueue(unchecked((int)Read(a[1])));
return pc + 1;
}
case "u0041F050":
case "try-dequeue-int": // 0x134 (queue_id, out_success, out_value)
{
int queueId = unchecked((int)Read(a[0]));
if ((uint)queueId >= (uint)_intQueues.Length)
{
HaltReason ??= $"int-queue-id-out-of-range:{queueId}";
return HALT;
}
if (_intQueues[queueId] is not { } queue)
{
HaltReason ??= $"int-queue-uninitialized:{queueId}";
return HALT;
}
if (queue.TryDequeue(out int value))
{
Write(a[1], 1);
Write(a[2], value);
}
else
{
// Native writes success=0 and an implementation pointer to operand 3. Shipped
// callers branch on success before reading it, so retain the prior destination.
Write(a[1], 0);
}
return pc + 1;
}
case "bit-set":
{
long bit = Read(a[1]);
@@ -977,6 +1060,60 @@ public sealed class VirtualMachine
if (outcome == FrameOutcome.ExitRequested) throw new ProcessExitRequestedException();
return pc + 1; // Returned / RanOff: resume caller
}
case "u00417E80":
case "preload-script-slot": // 0x06 (script_id, frame_slot), valid slots 0..39
{
long id = Read(a[0]);
int slot = unchecked((int)Read(a[1]));
if ((uint)slot >= 40)
{
HaltReason ??= $"preloaded-script-slot-out-of-range:{slot}";
return HALT;
}
if (_provider == null)
{
HaltReason ??= $"preloaded-script-provider-unavailable:0x{id:x}";
return HALT;
}
var script = _provider.GetById(id);
if (script == null)
{
HaltReason ??= $"preloaded-script-unresolved:0x{id:x}";
return HALT;
}
int entry = script.IndexByOffset.TryGetValue(0, out int loadedEntry) ? loadedEntry : 0;
_preloadedScriptSlots[slot] = new PreloadedScriptSlot(id, new ExecFrame(script, entry));
return pc + 1;
}
case "u00417FC0":
case "call-preloaded-script-slot": // 0x08 (frame_slot)
{
int slot = unchecked((int)Read(a[0]));
if ((uint)slot >= 40)
{
HaltReason ??= $"preloaded-script-slot-out-of-range:{slot}";
return HALT;
}
if (!_preloadedScriptSlots.TryGetValue(slot, out var loaded))
{
HaltReason ??= $"preloaded-script-slot-empty:{slot}";
return HALT;
}
if (_depth >= _o.CallDepthCap) { HaltReason ??= "call-depth-exceeded"; return HALT; }
CallScriptDispatches++;
_sink.Emit(TraceEvent.CallScript(loaded.ScriptId, loaded.Frame.Script.Name));
// PC restarts at codebase while the native slot's local banks remain allocated.
// Balanced local calls leave this empty; clearing the port-only emission guard makes
// each invocation an independent diagnostic activation.
loaded.Frame.CallStack.Clear();
loaded.Frame.EmitSeen.Clear();
var outcome = RunFrame(loaded.Frame, FrameCause.CallScript, loaded.ScriptId);
if (outcome == FrameOutcome.Halted) return HALT;
if (outcome == FrameOutcome.RootReload) return ROOT_RELOAD;
if (outcome == FrameOutcome.ExitRequested) throw new ProcessExitRequestedException();
return pc + 1;
}
case "show-text":
foreach (var o in a)
{
@@ -1398,7 +1535,7 @@ public sealed class VirtualMachine
return pc + 1;
case "create-texture": // 0x1f8 (slot)(w)(h) — allocate a blank surface at the slot
_host.ReleaseSurface((int)Read(a[0]));
Gfx.ClearSurface((int)Read(a[0]));
Gfx.CreateSurface((int)Read(a[0]));
_host.CreateTexture((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2])); return pc + 1;
case "set-texture": // 0x1f9 (resId)(slot)(colorkey) — load a file into the slot's surface
{
@@ -1408,8 +1545,9 @@ public sealed class VirtualMachine
System.Console.Error.WriteLine($"[settex] resId=0x{requestedResourceId:x}->0x{resolvedResourceId:x} slot={(int)Read(a[1])} " +
$"slotOp=(type={a[1].Type} val=0x{a[1].Value:x}){(a[1].Type == 3 ? $" G[0x{a[1].Value:x}]" : "")}");
_host.ReleaseSurface((int)Read(a[1]));
Gfx.SetSurface((int)Read(a[1]), resolvedResourceId, a.Count > 2 ? Read(a[2]) : 0);
_host.SetTexture(resolvedResourceId, (int)Read(a[1]));
long colorKey = a.Count > 2 ? Read(a[2]) : -1;
Gfx.SetSurface((int)Read(a[1]), resolvedResourceId, colorKey);
_host.SetTexture(resolvedResourceId, (int)Read(a[1]), colorKey);
return pc + 1; // host still tracks dims for get-texture-size
}
case "u00422EB0": // pre-reference compatibility
@@ -1423,7 +1561,7 @@ public sealed class VirtualMachine
int surfaceSlot = (int)Read(a[1]);
_host.ReleaseSurface(surfaceSlot);
Gfx.SetSurface(surfaceSlot, rawResourceId, Read(a[2]));
_host.SetTexture(rawResourceId, surfaceSlot);
_host.SetTexture(rawResourceId, surfaceSlot, Read(a[2]));
return pc + 1;
}
case "draw-texture": // 0x1fb (handle)(slot)(srcX)(srcY)(w)(h)(dstX)(dstY) — bind object -> surface + rect + pos
@@ -1471,6 +1609,11 @@ public sealed class VirtualMachine
(int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), (int)Read(a[3]), (int)Read(a[4]),
(int)System.Math.Min(Read(a[5]), 255), Read(a[6]) & 0x00ff_ffff));
return pc + 1;
case "copy-surface-rect": // 0x207: paired-clipped source-to-destination surface copy
_host.CopySurfaceRect(new SurfaceRectCopy(
(int)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])));
return pc + 1;
case "clear-retained-gfx-objects": // 0x1f6: erase object records, but preserve surfaces
Gfx.ClearRetainedObjects(); return pc + 1;
case "select-render-target": // 0x20d: slot <1000 selects a surface; >=1000 restores backbuffer
@@ -1492,6 +1635,8 @@ public sealed class VirtualMachine
_host.PlayVoice(Read(a[0]), 1); return pc + 1;
case "set-voice-bgm-duck-control": // 0x1cf: bit 0 suppresses automatic voice ducking
_host.SetVoiceBgmDuckControl(Read(a[0])); return pc + 1;
case "schedule-voice-playback": // 0x2c0: replace the pending delayed combat voice request
_host.ScheduleVoicePlayback(Read(a[0]), (int)Read(a[1]), Read(a[2])); return pc + 1;
case "play-sound-effect": // 0xb4 / semantics: sfx-load
_host.LoadSoundEffect(Read(a[0]), (int)Read(a[1])); return pc + 1;
case "u0041D050": // 0xb5 / semantics: sfx-start
@@ -1626,6 +1771,10 @@ public sealed class VirtualMachine
Write(a[0], stopTimeMs.Value);
return pc + 1;
}
case "query-movie-surface-active": // 0x23a (out)(surface slot)
Write(a[0], _host.IsMovieSurfaceActive((int)Read(a[1])) ? 1 : 0); return pc + 1;
case "sample-frame-time": // 0x23c: previous <- current; current <- monotonic time
Gfx.SampleFrameTime(_host.InputClockMilliseconds); return pc + 1;
case "set-gfx-geom3-c": // 0x1ff: set current translation matrix
Gfx.SetCurrentTranslation(Read(a[0]), (Read(a[1]), Read(a[2]), Read(a[3]))); return pc + 1;
case "u00420620": // upstream ABI label
@@ -1666,6 +1815,8 @@ public sealed class VirtualMachine
Gfx.SetOneShotAnimationControl(Read(a[0]), Read(a[1])); return pc + 1;
case "reset-anim-clock": // 0x243: force unprotected one-shots and reset the global service clock
Gfx.ResetAnimClock(); return pc + 1;
case "set-gfx-animation-service-flags": // 0x24e: bit 1 suppresses op 0x243
Gfx.SetAnimationServiceFlags(Read(a[0])); return pc + 1;
case "queue-surface-alpha-transition": // 0x223: target surface crossfade over two object ranges
Gfx.QueueSurfaceAlphaTransition(Read(a[0]), (int)Read(a[1]), Read(a[2]), (int)Read(a[3]),
Read(a[4]), (int)Read(a[5]), Read(a[6]), Read(a[7])); return pc + 1;