Implement ROOM fades and character voices
This commit is contained in:
@@ -353,9 +353,9 @@ sealed class AudioTraceHost : IHost
|
||||
var entry = _res.ResolveBgm(id);
|
||||
Events.Add(("play-bgm", id, entry != null ? $"{entry.Archive} {entry.Name}" : $"BGM{id:D3}.OGG <missing>"));
|
||||
}
|
||||
public void PlayVoice(long id) // voice: per-scene manifest
|
||||
public void PlayVoice(long id) // voice: SC section, then frontend raw id
|
||||
{
|
||||
var e = _res.Resolve(_scene, id);
|
||||
var e = _res.ResolveVoice(_scene, id);
|
||||
Events.Add(("play-voice", id, e == null ? "<unresolved>" : $"{e.Archive} {e.Name}"));
|
||||
}
|
||||
public void ShowText(int offset, string text) { }
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Age.Engine.Model;
|
||||
using Age.Engine.Sys4;
|
||||
using Age.Engine.Vm;
|
||||
using Xunit;
|
||||
|
||||
public class CoreScalarAndScreenTransitionOpsTests
|
||||
{
|
||||
private const int Imm = 0, GlobalInt = 3, LocalInt = 9;
|
||||
|
||||
[Fact]
|
||||
public void ZeroIntRange_ClearsEveryConsecutiveDestinationCell()
|
||||
{
|
||||
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
(int, Operand[]) Mov(int address, long value)
|
||||
=> (0x55, new[] { new Operand(GlobalInt, address), new Operand(Imm, value) });
|
||||
var ops = new List<(int, Operand[])>
|
||||
{
|
||||
Mov(0x2e49, 7), Mov(0x2e4a, 8), Mov(0x2e4b, 9),
|
||||
(0x6c, new[] { new Operand(GlobalInt, 0x2e49), new Operand(Imm, 3) }),
|
||||
(0x2, Array.Empty<Operand>()),
|
||||
};
|
||||
|
||||
var vm = new VirtualMachine(ScriptAssembler.Assemble(table, "ZERO_RANGE", ops, Array.Empty<string>()), table,
|
||||
new RecordingHost());
|
||||
vm.Run();
|
||||
|
||||
Assert.All(Enumerable.Range(0x2e49, 3), address => Assert.Equal(0, vm.Globals[address]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RandomModulo_WritesAValueInsideTheNativeRange()
|
||||
{
|
||||
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
var ops = new List<(int, Operand[])>
|
||||
{
|
||||
(0x60, new[] { new Operand(LocalInt, 0), new Operand(Imm, 4) }),
|
||||
(0x55, new[] { new Operand(GlobalInt, 0x100), new Operand(LocalInt, 0) }),
|
||||
(0x2, Array.Empty<Operand>()),
|
||||
};
|
||||
|
||||
var vm = new VirtualMachine(ScriptAssembler.Assemble(table, "RANDOM_MODULO", ops, Array.Empty<string>()), table,
|
||||
new RecordingHost());
|
||||
vm.Run();
|
||||
|
||||
Assert.InRange(vm.Globals[0x100], 0, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CrossfadeSurfaces_ForwardsBothCapturedSlotsAndTimingArgument()
|
||||
{
|
||||
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
var script = ScriptAssembler.Assemble(table, "SURFACE_CROSSFADE", new List<(int, Operand[])>
|
||||
{
|
||||
(0x25, new[] { new Operand(Imm, 1), new Operand(Imm, 2), new Operand(Imm, 10) }),
|
||||
(0x2, Array.Empty<Operand>()),
|
||||
}, Array.Empty<string>());
|
||||
var host = new RecordingHost();
|
||||
|
||||
new VirtualMachine(script, table, host).Run();
|
||||
|
||||
Assert.Equal((1, 2, 10L), Assert.Single(host.SurfaceCrossfades));
|
||||
Assert.Equal(160, LegacyScreenTransitionTiming.DurationMilliseconds(10));
|
||||
Assert.Equal(480, LegacyScreenTransitionTiming.DurationMilliseconds(30));
|
||||
Assert.Equal(1024, LegacyScreenTransitionTiming.DurationMilliseconds(65));
|
||||
}
|
||||
}
|
||||
@@ -168,7 +168,7 @@ public class Sys4AssetStoreTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sc0000AndTitleAudioPayloadsReadDirectlyFromTheirNativeAddressSpaces()
|
||||
public void Sc0000RoomAndTitleAudioPayloadsReadDirectlyFromTheirNativeAddressSpaces()
|
||||
{
|
||||
var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
|
||||
var archiveOnly = new Sys4AssetStore(catalog, Paths.GameDir);
|
||||
@@ -178,10 +178,15 @@ public class Sys4AssetStoreTests
|
||||
Assert.Equal("BGM005.OGG", bgm?.Name);
|
||||
AssertOgg(resources.ReadAudio(bgm!));
|
||||
|
||||
var voice = resources.Resolve("SC0000", 0x24);
|
||||
var voice = resources.ResolveVoice("SC0000", 0x24);
|
||||
Assert.Equal("MAN999.OGG", voice?.Name);
|
||||
AssertOgg(resources.ReadAudio(voice!));
|
||||
|
||||
var roomVoice = resources.ResolveVoice("ROOM", 0x3365);
|
||||
Assert.Equal("EUA0016.OGG", roomVoice?.Name);
|
||||
AssertOgg(resources.ReadAudio(roomVoice!));
|
||||
Assert.Null(resources.ResolveVoice("ROOM", 0x337e)); // SO001.AGF is not voice audio.
|
||||
|
||||
var sfx = resources.ResolveSoundEffect(0x28);
|
||||
Assert.Equal("E0808.WAV", sfx?.Name);
|
||||
var wav = resources.ReadAudio(sfx!);
|
||||
|
||||
@@ -39,6 +39,7 @@ internal class RecordingHost : IHost
|
||||
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();
|
||||
public readonly List<(int Source, int Target, long Interval)> SurfaceCrossfades = new();
|
||||
public readonly List<bool> MessageSkipChanges = new();
|
||||
public readonly List<long> CursorResources = new();
|
||||
public readonly List<bool> AdvPagePresentationSuspended = new();
|
||||
@@ -109,6 +110,8 @@ internal class RecordingHost : IHost
|
||||
gfx.StartForegroundTransitions(100);
|
||||
gfx.CompleteForegroundTransitions(100);
|
||||
}
|
||||
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 ClearRenderTarget(int surfaceSlot) => ClearedRenderTargets.Add(surfaceSlot);
|
||||
|
||||
@@ -77,6 +77,9 @@ public interface IHost
|
||||
// read/message-skip branch reaches op 0x20c and presents the completed endpoint immediately.
|
||||
void WaitForForegroundTransition(GfxState gfx) { }
|
||||
void PresentFrame(GfxState gfx) { }
|
||||
// Legacy SYS4 screen-transition family (op 0x25): scripts render two complete frames into
|
||||
// numbered surfaces, then block while the engine alpha-composites target over source.
|
||||
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 ReleaseSurface(int slot) { }
|
||||
|
||||
13
engine/Age.Engine/Model/LegacyScreenTransitionTiming.cs
Normal file
13
engine/Age.Engine/Model/LegacyScreenTransitionTiming.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Age.Engine.Model;
|
||||
|
||||
/// <summary>Native timing conversion shared by the legacy op-0x25 screen-transition family.</summary>
|
||||
public static class LegacyScreenTransitionTiming
|
||||
{
|
||||
public static long DurationMilliseconds(long argument)
|
||||
{
|
||||
long interval = argument <= 64 ? argument : argument / 16;
|
||||
long step = argument <= 64 ? 16 : 1;
|
||||
interval = System.Math.Max(1, interval);
|
||||
return System.Math.Clamp(((256 + step - 1) / step) * interval, 1, 60_000);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,14 @@ public sealed class ResourceMap
|
||||
entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null;
|
||||
}
|
||||
|
||||
/// <summary>Resolve voice audio through the active SC section when one exists, then through the
|
||||
/// universal raw catalog used by non-SC frontend scripts such as ROOM.</summary>
|
||||
public AssetEntry? ResolveVoice(string scene, long resId)
|
||||
{
|
||||
var entry = _catalog.ResolveScene(scene, resId) ?? _catalog.ResolveRaw(resId);
|
||||
return entry is { IsPlaceholder: false } && IsAudio(entry) ? entry : null;
|
||||
}
|
||||
|
||||
/// <summary>Resolve an already-normalized raw catalog id without applying a scene section base.</summary>
|
||||
public AssetEntry? ResolveRawTexture(long rawId)
|
||||
{
|
||||
|
||||
@@ -649,7 +649,26 @@ public sealed class VirtualMachine
|
||||
Write(a[0], Read(a[0]) & ~(1L << (int)bit)); return pc + 1;
|
||||
}
|
||||
case "check-bit": Write(a[0], (Read(a[1]) >> (int)(Read(a[2]) & 31)) & 1); return pc + 1;
|
||||
case "copy-to-global": Write(a[0], Read(a[1])); return pc + 1;
|
||||
case "zero-int-range":
|
||||
case "copy-to-global": // pre-reference compatibility for opcode 0x6c
|
||||
{
|
||||
int count = System.Math.Max(0, checked((int)Read(a[1])));
|
||||
for (int i = 0; i < count; i++) WriteConsecutive(a[0], i, 0);
|
||||
return pc + 1;
|
||||
}
|
||||
case "random-modulo": // 0x60: native CRT rand() % bound
|
||||
case "u0041A270":
|
||||
{
|
||||
long bound = Read(a[1]);
|
||||
if (bound == 0)
|
||||
{
|
||||
Write(a[0], 0);
|
||||
HaltReason ??= "random-modulo-zero";
|
||||
return HALT;
|
||||
}
|
||||
Write(a[0], System.Random.Shared.Next(0x8000) % bound);
|
||||
return pc + 1;
|
||||
}
|
||||
case "jmp": return _cur.Script.IndexByOffset.GetValueOrDefault((int)a[0].Value, pc + 1);
|
||||
case "call": _cur.CallStack.Add(pc + 1); return _cur.Script.IndexByOffset.GetValueOrDefault((int)a[0].Value, pc + 1);
|
||||
case "ret":
|
||||
@@ -1301,6 +1320,10 @@ public sealed class VirtualMachine
|
||||
Read(a[4]), (int)Read(a[5]), Read(a[6]), Read(a[7])); return pc + 1;
|
||||
case "present-frame": // 0x20c: read/message-skip path snaps a queued transition to its endpoint
|
||||
_host.PresentFrame(Gfx); return pc + 1;
|
||||
case "crossfade-surfaces": // 0x25: legacy full-frame surface alpha transition
|
||||
case "u00418B40":
|
||||
_host.CrossfadeSurfaces(Gfx, (int)Read(a[0]), (int)Read(a[1]), Read(a[2]));
|
||||
return pc + 1;
|
||||
case "mark-frame-yield": // 0x21c: normal foreground-transition scheduler/resume boundary
|
||||
_host.WaitForForegroundTransition(Gfx); return pc + 1;
|
||||
case "clear-gfx-command-queue": // 0x224: retained compositor does not use this native queue
|
||||
|
||||
Reference in New Issue
Block a user