Land SYSTEM4-rooted Godot boot

This commit is contained in:
gamer147
2026-07-20 18:13:32 -04:00
parent 7250de7fea
commit 097473fe16
18 changed files with 385 additions and 111 deletions

View File

@@ -9,16 +9,19 @@ public class CallScriptTests
{
// Opcodes (from build/opcodes.json): exit=0x2, call-script=0x3(argc1), mov=0x55(argc2).
// Operand types: imm=0, global-int=3, local-int=9.
private const uint OP_EXIT = 0x2, OP_CALLSCRIPT = 0x3, OP_MOV = 0x55;
private const uint OP_EXIT = 0x2, OP_CALLSCRIPT = 0x3, OP_MOV = 0x55, OP_SETTEXTURE = 0x1f9;
private sealed class NullHost : IHost
private class NullHost : IHost
{
public virtual void EnterScriptContext(string scriptName) { }
public virtual void ExitScriptContext() { }
public virtual long ResolveTextureResourceId(long resourceId) => resourceId;
public void ShowText(int o, string t) { }
public void WaitForInput() { }
public void Sleep(long duration) { }
public void FrameYield() { }
public void CreateTexture(int s, int w, int h) { }
public void SetTexture(long r, int s) { }
public virtual void SetTexture(long r, int s) { }
public void DrawTexture(int s, int sx, int sy, int w, int h, int dx, int dy) { }
public (int Width, int Height) GetTextureSize(int s) => (0, 0);
public void PlayBgm(long id) { }
@@ -32,6 +35,29 @@ public class CallScriptTests
public Script? GetById(long id) => _m.TryGetValue(id, out var s) ? s : null;
}
private sealed class ContextHost : NullHost
{
private readonly Stack<string> _contexts = new();
public List<string> Events { get; } = new();
public List<(long ResourceId, int Slot)> Textures { get; } = new();
public override void EnterScriptContext(string scriptName)
{
_contexts.Push(scriptName);
Events.Add($"enter:{scriptName}");
}
public override void ExitScriptContext()
{
Events.Add($"exit:{_contexts.Pop()}");
}
public override long ResolveTextureResourceId(long resourceId)
=> resourceId + (_contexts.Peek() == "CALLEE" ? 700 : 70);
public override void SetTexture(long resourceId, int slot) => Textures.Add((resourceId, slot));
}
// Build a Script from raw dwords via the real loader (guarantees identical decode).
private static Script Asm(OpcodeTable t, string name, params uint[] body)
{
@@ -105,4 +131,25 @@ public class CallScriptTests
vm.Run();
Assert.Equal(1, vm.Globals[0x20]); // caller's local 0 unchanged by callee's local 0
}
[Fact]
public void ScriptLocalTextureIdsFollowTheActiveNestedFrame()
{
var t = Table();
var callee = Asm(t, "CALLEE",
OP_SETTEXTURE, 0, 7, 0, 2, 0, uint.MaxValue,
OP_EXIT);
var caller = Asm(t, "CALLER",
OP_SETTEXTURE, 0, 7, 0, 1, 0, uint.MaxValue,
OP_CALLSCRIPT, 0, 5,
OP_SETTEXTURE, 0, 7, 0, 3, 0, uint.MaxValue,
OP_EXIT);
var host = new ContextHost();
var vm = new VirtualMachine(caller, t, host, null, new MapProvider(new() { [5] = callee }));
vm.Run();
Assert.Equal(new[] { (77L, 1), (707L, 2), (77L, 3) }, host.Textures);
Assert.Equal(new[] { "enter:CALLER", "enter:CALLEE", "exit:CALLEE", "exit:CALLER" }, host.Events);
}
}

View File

@@ -0,0 +1,111 @@
using Age.Engine.Diagnostics;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
public class NaturalBootIntegrationTests
{
private sealed class ReachedSc0000Exception : Exception { }
private sealed class StopAtSc0000Sink : ITraceSink
{
public readonly List<string> Entered = new();
public Action<string>? OnEnter;
public bool TracingSteps => false;
public void Emit(in TraceEvent e)
{
if (e.Kind == TraceEventKind.FrameEnter && e.Name != null)
{
Entered.Add(e.Name);
OnEnter?.Invoke(e.Name);
if (e.Name.Equals("SC0000.BIN", StringComparison.OrdinalIgnoreCase))
throw new ReachedSc0000Exception();
}
}
}
private sealed class NewGameInputHost : RecordingHost
{
public VirtualMachine Vm = null!;
private long _now;
public int TitlePollSleeps;
private bool _inGameStart;
private int _gameStartPollSleeps;
public override long InputClockMilliseconds => _now;
public void BeginGameStart()
{
_inGameStart = true;
_gameStartPollSleeps = 0;
Vm.UpdateMouseButtonState(0x1, false);
Vm.UpdateInputCallbackState(4, false);
Vm.QueueInputCallback(10);
}
public override void Sleep(long duration)
{
base.Sleep(duration);
_now += Math.Max(1, duration);
if (duration > 1) return;
// TITLE's first menu entry is Game Start. Hold the pointer over its native 800x600
// rectangle, then provide one complete primary-button edge to its raw input callback.
int polls = _inGameStart ? ++_gameStartPollSleeps : ++TitlePollSleeps;
int cycle = polls % 200;
if (_inGameStart && polls <= 200) return;
if (cycle == 1)
{
int input = _inGameStart && polls <= 400 ? 0 : 4;
if (!_inGameStart)
{
Vm.UpdatePointer(400, 300);
Vm.UpdateMouseButtonState(0x1, true);
}
Vm.UpdateInputCallbackState(input, true);
}
// TITLE polls its registered mouse callback every 50 ms and activates on the release edge.
// Keep the button down through one callback, then release it before the next.
else if (cycle == 120)
{
int input = _inGameStart && polls <= 400 ? 0 : 4;
if (!_inGameStart) Vm.UpdateMouseButtonState(0x1, false);
Vm.UpdateInputCallbackState(input, false);
Vm.QueueInputCallback(10); // native/main-thread release callback unlocks the menu input gate
}
}
}
[Fact]
public void System4Root_NewGameSelectionNaturallyCallsSc0000()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var scripts = Sys4ScriptProvider.Load(table);
var host = new NewGameInputHost();
var sink = new StopAtSc0000Sink();
var vm = new VirtualMachine(scripts.RequireByName("SYSTEM4.BIN"), table, host,
new VmOptions(MaxSteps: 5_000_000), scripts, sink);
host.Vm = vm;
sink.OnEnter = name =>
{
if (name.Equals("GAMESTART.BIN", StringComparison.OrdinalIgnoreCase)) host.BeginGameStart();
};
var exception = Record.Exception(() => vm.Run());
Assert.True(exception is ReachedSc0000Exception,
$"halt={vm.HaltReason}; title_sleeps={host.TitlePollSleeps}; entered={string.Join(",", sink.Entered)}");
Assert.Equal(new[]
{
"SYSTEM4.BIN", "INITCONFIG.BIN", "INIT2.BIN",
}, sink.Entered.Take(3));
Assert.Contains("TITLE.BIN", sink.Entered);
Assert.Contains("GAMESTART.BIN", sink.Entered);
Assert.Contains("UNITECH.BIN", sink.Entered);
Assert.Contains("CALCARR.BIN", sink.Entered);
Assert.Equal("SC0000.BIN", sink.Entered[^1]);
Assert.Equal(1, vm.Globals.GetValueOrDefault(0));
Assert.Equal(0x22, vm.Globals.GetValueOrDefault(0x699));
Assert.Equal(1, vm.Globals.GetValueOrDefault(0x6c1));
}
}

View File

@@ -94,7 +94,7 @@ public class RenderObjectBlendTests
}
[Fact]
public void Mode1_UsesArgbAlphaAsOpacityAndRgbAsMultiplicativeModulation()
public void Mode1_UsesAdditiveBlendWithArgbSourceScaleAndRgbModulation()
{
var g = WithVisibleObject(0x100, resId: 5, colorKey: -1);
g.SetStaticObjectColorResolved(0x100, 1, 0x40, 0x80ff40);
@@ -103,7 +103,7 @@ public class RenderObjectBlendTests
Assert.Equal(0, ro.TintStrength);
Assert.Equal(0x80ff40, ro.Tint);
Assert.True(ro.MultiplyTint);
Assert.Equal(BlendKind.Alpha, ro.Blend);
Assert.Equal(BlendKind.Additive, ro.Blend);
}
[Fact]

View File

@@ -99,6 +99,22 @@ public class SoftwareAffineRasterizerTests
Assert.Equal(new byte[4], hidden);
}
[Fact]
public void BlitRgba_AdditiveMakesBlackTransparentAndAddsScaledColor()
{
var identity = new Affine2D(1, 0, 0, 1, 0, 0);
byte[] background = { 40, 50, 60, 255 };
byte[] black = { 0, 0, 0, 255 };
SoftwareAffineRasterizer.BlitRgba(background, 1, 1, black, 1, 1, 0, 0, 1, 1,
identity, 0xffffff, 0, 1, multiplyTint: true, blend: BlendKind.Additive);
Assert.Equal(new byte[] { 40, 50, 60, 255 }, background);
byte[] blueGlow = { 16, 32, 200, 128 };
SoftwareAffineRasterizer.BlitRgba(background, 1, 1, blueGlow, 1, 1, 0, 0, 1, 1,
identity, 0xffffff, 0, 0.5f, multiplyTint: true, blend: BlendKind.Additive);
Assert.Equal(new byte[] { 43, 57, 109, 255 }, background);
}
// Pre-fast-path affine algorithm retained here as an independent differential oracle.
private static void ReferenceBlit(byte[] dst, int dstW, int dstH, byte[] src, int srcW,
int srcX, int srcY, int width, int height, Affine2D transform,