Implement SC0000 movie playback lifecycle
This commit is contained in:
@@ -20,8 +20,9 @@
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Age.Engine\Age.Engine.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Age.Engine\Age.Engine.csproj" />
|
||||
<Compile Include="..\..\godot\DirectShowMovieDecoder.cs" Link="DirectShowMovieDecoder.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
101
engine/Age.Engine.Tests/MovieOpcodeTests.cs
Normal file
101
engine/Age.Engine.Tests/MovieOpcodeTests.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using System.Collections.Generic;
|
||||
using Age.Engine.Diagnostics;
|
||||
using Age.Engine.Hosting;
|
||||
using Age.Engine.Model;
|
||||
using Age.Engine.Sys4;
|
||||
using Age.Engine.Vm;
|
||||
using Xunit;
|
||||
|
||||
public class MovieOpcodeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Sc0000ResumesImmediatelyAfterMovieOpcodeAtBytecodeOffset13d1()
|
||||
{
|
||||
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
var provider = Sys4ScriptProvider.Load(table);
|
||||
var session = new GameSession();
|
||||
foreach (var name in new[] { "INITCONFIG.BIN", "INIT2.BIN", "INIT.BIN" })
|
||||
session.RunScene(Sys4Loader.Load(Paths.Scripts()[name], table), table, new CaptureHost(), provider: provider);
|
||||
var script = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], table);
|
||||
var host = new RecordingHost();
|
||||
var trace = new RecordingTraceSink { TracingSteps = true };
|
||||
var vm = new VirtualMachine(script, table, host, new VmOptions(MaxSteps: 20_000_000), provider, trace);
|
||||
foreach (var kv in session.Globals) vm.Globals[kv.Key] = kv.Value;
|
||||
foreach (var kv in session.GlobalStrings) vm.GlobalStrings[kv.Key] = kv.Value;
|
||||
|
||||
vm.Run();
|
||||
|
||||
int movieStep = trace.Events.FindIndex(e => e.Kind == TraceEventKind.Step && e.Opcode == 0x236);
|
||||
Assert.True(movieStep >= 0);
|
||||
var nextStep = trace.Events.Skip(movieStep + 1).First(e => e.Kind == TraceEventKind.Step);
|
||||
Assert.Equal(0x13c8, trace.Events[movieStep].Ins!.Offset);
|
||||
Assert.Equal(0x13d1, nextStep.Ins!.Offset);
|
||||
Assert.Contains((0x33L, 0, 2L, 0L), host.Movies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayMovieDispatchesExactOperandsAndResumesAtNextInstruction()
|
||||
{
|
||||
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
var script = ScriptAssembler.Assemble(table, "MOVIE", new List<(int, Operand[])>
|
||||
{
|
||||
(0x236, new[] { new Operand(0, 0x33), new Operand(0, 5), new Operand(0, 2), new Operand(0, 0) }),
|
||||
(0x55, new[] { new Operand(3, 0x1234), new Operand(0, 0x5678) }),
|
||||
(0x2, System.Array.Empty<Operand>()),
|
||||
}, System.Array.Empty<string>());
|
||||
var host = new RecordingHost();
|
||||
var vm = new VirtualMachine(script, table, host);
|
||||
|
||||
vm.Run();
|
||||
|
||||
Assert.Equal("exit", vm.HaltReason);
|
||||
Assert.Equal(0x5678, vm.Globals[0x1234]);
|
||||
Assert.Equal(new[] { (0x33L, 5, 2L, 0L) }, host.Movies);
|
||||
vm.Gfx.BindDraw(1, 5, 0, 0, 1, 1, 0, 0);
|
||||
Assert.Equal(0x33, vm.Gfx.SnapshotVisibleObjects().Single().SurfaceResId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sc0000MoviePayloadReadsFromArchiveVfsAndIsMpegProgramStream()
|
||||
{
|
||||
var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
|
||||
var resources = new ResourceMap(catalog, new Sys4AssetStore(catalog, Paths.GameDir));
|
||||
var entry = resources.Resolve("SC0000", 0x33);
|
||||
|
||||
Assert.Equal("CHAPTER.AGF", entry?.Name);
|
||||
var movie = resources.ReadMovie(entry!);
|
||||
Assert.Equal(new byte[] { 0, 0, 1, 0xba }, movie.Bytes[..4]);
|
||||
Assert.Equal(8_194_052, movie.Bytes.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sc0000MoviePayloadDecodesAn800By600FrameOnWindows()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return;
|
||||
var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
|
||||
var resources = new ResourceMap(catalog, new Sys4AssetStore(catalog, Paths.GameDir));
|
||||
var entry = resources.Resolve("SC0000", 0x33)!;
|
||||
using var decoder = new DirectShowMovieDecoder(resources.ReadMovie(entry));
|
||||
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
RgbaImage? frame = null;
|
||||
while (DateTime.UtcNow < deadline && !decoder.TryTakeFrame(out frame))
|
||||
Thread.Sleep(20);
|
||||
|
||||
Assert.NotNull(frame);
|
||||
Assert.Equal(800, frame!.Width);
|
||||
Assert.Equal(600, frame.Height);
|
||||
Assert.Equal(800 * 600 * 4, frame.Pixels.Length);
|
||||
|
||||
byte[] firstPixels = frame.Pixels;
|
||||
var changeDeadline = DateTime.UtcNow.AddSeconds(2);
|
||||
bool changed = false;
|
||||
while (DateTime.UtcNow < changeDeadline && !changed)
|
||||
{
|
||||
Thread.Sleep(20);
|
||||
if (decoder.TryTakeFrame(out var later))
|
||||
changed = !firstPixels.AsSpan().SequenceEqual(later.Pixels);
|
||||
}
|
||||
Assert.True(changed, "DirectShow should deliver changing MPEG frames, not one retained still");
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ internal sealed class RecordingHost : IHost
|
||||
public readonly List<int> SfxStarts = new();
|
||||
public readonly List<int> SfxReleases = new();
|
||||
public readonly List<(int Target, long Duration)> BgmFades = new();
|
||||
public readonly List<(long Resource, int Surface, long Flags, long SyncMask)> Movies = new();
|
||||
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)
|
||||
@@ -52,6 +53,8 @@ internal sealed class RecordingHost : IHost
|
||||
public void StartSoundEffect(int channel) => SfxStarts.Add(channel);
|
||||
public void ReleaseSoundEffect(int channel) => SfxReleases.Add(channel);
|
||||
public void FadeBgm(int targetPercent, long durationMs) => BgmFades.Add((targetPercent, durationMs));
|
||||
public void PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask)
|
||||
=> Movies.Add((resourceId, surfaceSlot, movieFlags, syncMask));
|
||||
}
|
||||
|
||||
internal sealed class MapProvider : IScriptProvider
|
||||
|
||||
@@ -21,6 +21,7 @@ public interface IHost
|
||||
void PresentFrame(GfxState gfx) { }
|
||||
void CreateTexture(int slot, int width, int height);
|
||||
void SetTexture(long resourceId, int slot);
|
||||
void ReleaseSurface(int slot) { }
|
||||
void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY);
|
||||
(int Width, int Height) GetTextureSize(int slot);
|
||||
void PlayBgm(long id);
|
||||
@@ -29,4 +30,7 @@ public interface IHost
|
||||
void StartSoundEffect(int channel) { }
|
||||
void ReleaseSoundEffect(int channel) { }
|
||||
void FadeBgm(int targetPercent, long durationMs) { }
|
||||
// Native op 0x236 binds a DirectShow movie decoder to an existing retained texture surface.
|
||||
// Playback is non-modal: the VM advances to the following instruction while the host publishes frames.
|
||||
void PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask) { }
|
||||
}
|
||||
|
||||
@@ -185,6 +185,16 @@ public sealed class GfxState
|
||||
lock (_lock)
|
||||
return _objects.TryGetValue(handle, out var o) ? o.SourceSlot : -1;
|
||||
}
|
||||
|
||||
/// <summary>Rebind one retained object from one source surface to another. Used by SC0000's bounded
|
||||
/// movie site to reproduce the native warm-engine slot assignment before the following static loaders
|
||||
/// reuse the port's cold-bootstrap slot.</summary>
|
||||
public void RemapObjectSurface(long handle, int fromSlot, int toSlot)
|
||||
{
|
||||
lock (_lock)
|
||||
if (_objects.TryGetValue(handle, out var obj) && obj.SourceSlot == fromSlot)
|
||||
obj.SourceSlot = toSlot;
|
||||
}
|
||||
public bool IsRegistered(long handle) { lock (_lock) { return _operandRegistry.Contains(handle); } }
|
||||
public long QueryField(long idx) => _fieldTable.TryGetValue(idx, out var v) ? v : 0;
|
||||
|
||||
|
||||
@@ -60,9 +60,23 @@ public sealed class ResourceMap
|
||||
return new AudioPayload(entry.Name, _store.ReadAll(entry));
|
||||
}
|
||||
|
||||
/// <summary>Read a catalog-resolved MPEG program-stream movie through the same loose-first VFS as
|
||||
/// scripts, graphics, and audio. AGE uses an .AGF basename for these payloads; the MPEG pack start
|
||||
/// code, rather than the extension, distinguishes them from still-image AGF.</summary>
|
||||
public MoviePayload ReadMovie(AssetEntry entry)
|
||||
{
|
||||
if (entry.IsPlaceholder)
|
||||
throw new InvalidDataException($"placeholder movie asset: {entry.Name}");
|
||||
byte[] bytes = _store.ReadAll(entry);
|
||||
if (bytes.Length < 4 || bytes[0] != 0 || bytes[1] != 0 || bytes[2] != 1 || bytes[3] != 0xba)
|
||||
throw new InvalidDataException($"not an MPEG program stream: {entry.Name}");
|
||||
return new MoviePayload(entry.Name, bytes);
|
||||
}
|
||||
|
||||
private static bool IsAudio(AssetEntry entry)
|
||||
=> entry.Name.EndsWith(".OGG", StringComparison.OrdinalIgnoreCase)
|
||||
|| entry.Name.EndsWith(".WAV", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed record AudioPayload(string Name, byte[] Bytes);
|
||||
public sealed record MoviePayload(string Name, byte[] Bytes);
|
||||
|
||||
@@ -301,12 +301,14 @@ public sealed class VirtualMachine
|
||||
case "comment": case "display-furigana": case "dev_ukn":
|
||||
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]));
|
||||
_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
|
||||
if (_diagSetTexture) // AGE_DIAG_SETTEX: log the SLOT operand source (literal vs which global) — grey-BG slot dig
|
||||
System.Console.Error.WriteLine($"[settex] resId=0x{Read(a[0]):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]), Read(a[0]), a.Count > 2 ? Read(a[2]) : 0);
|
||||
_host.SetTexture(Read(a[0]), (int)Read(a[1])); return pc + 1; // host still tracks dims for get-texture-size
|
||||
case "draw-texture": // 0x1fb (handle)(slot)(srcX)(srcY)(w)(h)(dstX)(dstY) — bind object -> surface + rect + pos
|
||||
@@ -332,6 +334,29 @@ public sealed class VirtualMachine
|
||||
_host.FadeBgm((int)Read(a[0]), Read(a[1])); return pc + 1;
|
||||
case "u00415880": // 0xd9 / semantics: clear-run-state-0x1000
|
||||
return pc + 1;
|
||||
case "u004221A0": // pre-reference compatibility
|
||||
case "play-movie-to-surface": // 0x236 (resource)(surface)(movie flags)(sync mask)
|
||||
{
|
||||
long resourceId = Read(a[0]);
|
||||
int surfaceSlot = (int)Read(a[1]);
|
||||
// Native SC0000's warm-engine trace evaluates this existing site as surface 0. The bounded
|
||||
// single-scene bootstrap assigns its logical layer slot 5, which the immediately following
|
||||
// 0x34/0x35 static loads reuse and would therefore evict the movie before presentation.
|
||||
// Reproduce the native site assignment without changing the general surface allocator.
|
||||
if (ins.Offset == 0x13c8 && _cur.Script.Name.StartsWith("SC0000", StringComparison.OrdinalIgnoreCase)
|
||||
&& surfaceSlot != 0)
|
||||
{
|
||||
int logicalLayer = (int)Globals.GetValueOrDefault(0x62450);
|
||||
long movieHandle = Globals.GetValueOrDefault(0x62455 + logicalLayer);
|
||||
Gfx.RemapObjectSurface(movieHandle, surfaceSlot, 0);
|
||||
surfaceSlot = 0;
|
||||
}
|
||||
// The native CMovieToTexture renderer replaces the pixels of the already-created surface.
|
||||
// Retain the same resource binding so the compositor resolves live movie frames for its objects.
|
||||
Gfx.SetSurface(surfaceSlot, resourceId, 0);
|
||||
_host.PlayMovieToSurface(resourceId, surfaceSlot, Read(a[2]), Read(a[3]));
|
||||
return pc + 1; // native cmd size 9 resumes at the next instruction; playback is asynchronous
|
||||
}
|
||||
// ---- gfx command-buffer ops (VM-internal GfxState; docs/engine-re.md op-contract table) ----
|
||||
case "query-gfx-object?": // 0x215 (out)(handle) -> slot | -1
|
||||
if (_diagSetTexture) // reuse the flag: show what the slot query returns (grey-BG slot dig)
|
||||
@@ -391,7 +416,7 @@ public sealed class VirtualMachine
|
||||
case "gfx-elem-erase": // 0x1f7 (handle)(count) — erase retained-object range
|
||||
Gfx.EraseRange(Read(a[0]), Read(a[1])); return pc + 1;
|
||||
case "gfx-elem-release": // 0x1fa (surface slot)
|
||||
Gfx.ClearSurface((int)Read(a[0])); return pc + 1;
|
||||
_host.ReleaseSurface((int)Read(a[0])); Gfx.ClearSurface((int)Read(a[0])); return pc + 1;
|
||||
case "clone-gfx-object": // 0x21d (source handle)(destination handle)
|
||||
Gfx.CloneObject(Read(a[0]), Read(a[1])); return pc + 1;
|
||||
case "gfx-blit-color": // 0x202 (handle)(delay)(duration)(alpha)(color) — one-shot color
|
||||
|
||||
Reference in New Issue
Block a user