Implement native-backed SC0000 SFX lifecycle

This commit is contained in:
gamer147
2026-07-11 02:15:17 -04:00
parent 9c4b331008
commit 2232216832
16 changed files with 413 additions and 64 deletions

View File

@@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
public class SfxOpsTests
{
[Fact]
public void Sc0000SfxLifecycleAndBgmFadeReachHost()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var script = ScriptAssembler.Assemble(table, "SFX",
new List<(int, Operand[])>
{
(0xb4, new[] { new Operand(0, 0x28), new Operand(0, 0) }),
(0xb5, new[] { new Operand(0, 0) }),
(0xb6, new[] { new Operand(0, 0) }),
(0xc2, new[] { new Operand(0, 25), new Operand(0, 3000) }),
(0xd9, Array.Empty<Operand>()),
(0x2, Array.Empty<Operand>()),
}, Array.Empty<string>());
var host = new RecordingHost();
var vm = new VirtualMachine(script, table, host);
vm.Run();
Assert.Equal("exit", vm.HaltReason);
Assert.Equal((0x28L, 0), Assert.Single(host.SfxLoads));
Assert.Equal(0, Assert.Single(host.SfxStarts));
Assert.Equal(0, Assert.Single(host.SfxReleases));
Assert.Equal((25, 3000L), Assert.Single(host.BgmFades));
}
}

View File

@@ -17,6 +17,10 @@ internal sealed class RecordingHost : IHost
public readonly List<(int Slot, int X, int Y)> TextCursors = new();
public readonly List<(int Surface, int X, int Y, string Text)> SurfaceStrings = new();
public readonly List<long> SleptDurations = new();
public readonly List<(long Resource, int Channel)> SfxLoads = new();
public readonly List<int> SfxStarts = new();
public readonly List<int> SfxReleases = new();
public readonly List<(int Target, long Duration)> BgmFades = 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)
@@ -44,6 +48,10 @@ internal sealed class RecordingHost : IHost
public (int Width, int Height) GetTextureSize(int slot) => (0, 0);
public void PlayBgm(long id) { }
public void PlayVoice(long id) { }
public void LoadSoundEffect(long resourceId, int channel) => SfxLoads.Add((resourceId, channel));
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));
}
internal sealed class MapProvider : IScriptProvider

View File

@@ -25,4 +25,8 @@ public interface IHost
(int Width, int Height) GetTextureSize(int slot);
void PlayBgm(long id);
void PlayVoice(long id);
void LoadSoundEffect(long resourceId, int channel) { }
void StartSoundEffect(int channel) { }
void ReleaseSoundEffect(int channel) { }
void FadeBgm(int targetPercent, long durationMs) { }
}

View File

@@ -76,14 +76,15 @@ public sealed class ResourceMap
return null;
}
/// <summary>Loose extracted OGG path for an audio asset (extracted/DATA{n}/{name}), or null.
/// OGG plays natively in Godot. Used for voices (which DO use the per-scene manifest via Resolve).</summary>
/// <summary>Loose extracted OGG/WAV path for an audio asset (extracted/DATA{n}/{name}), or null.
/// Used by the current bootstrap audio backend; voices and SFX use the scene manifest via Resolve.</summary>
public static string? AudioPath(AssetEntry a)
{
if (!a.Name.EndsWith(".OGG", StringComparison.OrdinalIgnoreCase)) return null;
if (!a.Name.EndsWith(".OGG", StringComparison.OrdinalIgnoreCase) &&
!a.Name.EndsWith(".WAV", StringComparison.OrdinalIgnoreCase)) return null;
var dir = a.Archive.EndsWith(".ALF", StringComparison.OrdinalIgnoreCase)
? a.Archive[..^4] : a.Archive; // "DATA3.ALF" -> "DATA3"
var ogg = Path.Combine(Paths.Extracted, dir, a.Name);
return File.Exists(ogg) ? ogg : null;
var audio = Path.Combine(Paths.Extracted, dir, a.Name);
return File.Exists(audio) ? audio : null;
}
}

View File

@@ -25,6 +25,8 @@ public sealed class VirtualMachine
public long CallScriptDispatches { get; private set; }
public Dictionary<int, long> Globals { get; } = new();
/// <summary>Native/profile-owned values read by scripts but maintained outside script-visible writes.</summary>
public Dictionary<int, long> ExternalGlobals { get; } = new();
public Dictionary<int, string> GlobalStrings { get; } = new();
public GfxState Gfx { get; } = new();
public List<(int Offset, string Text, string Script)> Emitted { get; } = new();
@@ -37,6 +39,7 @@ public sealed class VirtualMachine
_sink = sink ?? NullTraceSink.Instance; }
private static long Gi(Dictionary<int, long> d, int k) => d.TryGetValue(k, out var v) ? v : 0;
private long ReadGlobal(int k) => ExternalGlobals.TryGetValue(k, out var v) ? v : Gi(Globals, k);
private static string Gs(Dictionary<int, string> d, int k) => d.TryGetValue(k, out var v) ? v : "";
private static long PyDiv(long a, long b) { if (b == 0) return 0; long q = a / b, r = a % b; if (r != 0 && (r < 0) != (b < 0)) q--; return q; }
private static long PyMod(long a, long b) { if (b == 0) return 0; long r = a % b; if (r != 0 && (r < 0) != (b < 0)) r += b; return r; }
@@ -73,7 +76,7 @@ public sealed class VirtualMachine
private long Read(Operand op) => op.Type switch
{
T_IMM => op.Value,
T_GINT or T_GFLOAT => Gi(Globals, (int)op.Value),
T_GINT or T_GFLOAT => ReadGlobal((int)op.Value),
T_GPTR => Gi(Globals, (int)Gi(Globals, (int)op.Value)),
T_LINT => Gi(_cur.Locals.I, (int)op.Value),
T_LFLOAT => Gi(_cur.Locals.F, (int)op.Value),
@@ -319,6 +322,16 @@ public sealed class VirtualMachine
}
case "play-bgm": _host.PlayBgm(Read(a[0])); return pc + 1;
case "play-voice": _host.PlayVoice(Read(a[0])); 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
_host.StartSoundEffect((int)Read(a[0])); return pc + 1;
case "u0041D080": // 0xb6 / semantics: sfx-release
_host.ReleaseSoundEffect((int)Read(a[0])); return pc + 1;
case "u0041D2B0": // 0xc2 / semantics: fade-bgm
_host.FadeBgm((int)Read(a[0]), Read(a[1])); return pc + 1;
case "u00415880": // 0xd9 / semantics: clear-run-state-0x1000
return pc + 1;
// ---- 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)