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,

View File

@@ -15,6 +15,11 @@ public readonly record struct SurfaceRectFill(
public interface IHost
{
// Script-local resource ids resolve against the currently executing frame's SYS4INI section.
// Interactive hosts track this stack; headless hosts may keep the no-op/default identity behavior.
void EnterScriptContext(string scriptName) { }
void ExitScriptContext() { }
long ResolveTextureResourceId(long resourceId) => resourceId;
void ShowText(int offset, string text);
// Native ADV text subsystem: op 0x7a updates the selected layout's last 20-byte cursor record;
// op 0x204 rasterizes a string into a numbered surface before 0x1fb binds that surface.

View File

@@ -1,6 +1,5 @@
namespace Age.Engine.Model;
/// <summary>How an object's surface composites onto the canvas. Opaque = straight copy; Alpha = source-alpha
/// blend (fades). Additive (glow/flash, native blit mode 2/3) is a documented seam — NOT implemented in the
/// blend/transparency slice; see docs/superpowers/specs/2026-07-08-blend-transparency-design.md.</summary>
/// blend (fades); Additive = native SRCALPHA/ONE glow composition.</summary>
public enum BlendKind { Opaque, Alpha, Additive }

View File

@@ -665,10 +665,15 @@ public sealed class GfxState
{
var (a, r, g, b) = BlendMath.UnpackArgb(sampledColor);
tint = ((long)r << 16) | ((long)g << 8) | (long)b;
if (o.StaticColorMode == 1 || (o.StaticColorMode == 0 && o.OneShotColorBlend))
if (o.StaticColorMode == 1)
{
// Native mode 1 enables SRCALPHA/ONE additive blending and passes packed ARGB as
// D3D modulation. Black therefore contributes nothing (TITLE's SO022 flames),
// while the high byte scales the additive source contribution.
alpha = a; strength = 0; blend = BlendKind.Additive; multiplyTint = true;
}
else if (o.StaticColorMode == 0 && o.OneShotColorBlend)
{
// Native mode 1 enables SRCALPHA/INVSRCALPHA and passes packed ARGB as D3D
// modulation. Its high byte is opacity, not mode-0 tint/fill strength.
alpha = a; strength = 0; blend = BlendKind.Alpha; multiplyTint = true;
}
else if (o.StaticColorMode == 2)

View File

@@ -5,7 +5,8 @@ public static class SoftwareAffineRasterizer
{
public static void BlitRgba(byte[] dst, int dstW, int dstH, byte[] src, int srcW, int srcH,
int srcX, int srcY, int width, int height, Affine2D localToDest,
long tint, float tintStrength, float opacity, bool multiplyTint = false)
long tint, float tintStrength, float opacity, bool multiplyTint = false,
BlendKind blend = BlendKind.Alpha)
{
if (width <= 0 || height <= 0) return;
int istr = (int)(System.Math.Clamp(tintStrength, 0f, 1f) * 255);
@@ -15,7 +16,7 @@ public static class SoftwareAffineRasterizer
if (TryIntegerTranslation(localToDest, out int tx, out int ty))
{
BlitTranslated(dst, dstW, dstH, src, srcW, srcX, srcY, width, height,
tx, ty, tr, tg, tb, istr, ia, multiplyTint);
tx, ty, tr, tg, tb, istr, ia, multiplyTint, blend);
return;
}
if (!localToDest.TryInverse(out var inv)) return;
@@ -30,7 +31,7 @@ public static class SoftwareAffineRasterizer
int sr=multiplyTint ? src[si]*tr/255 : (src[si]*(255-istr)+tr*istr)/255;
int sg=multiplyTint ? src[si+1]*tg/255 : (src[si+1]*(255-istr)+tg*istr)/255;
int sb=multiplyTint ? src[si+2]*tb/255 : (src[si+2]*(255-istr)+tb*istr)/255;
Blend(dst,di,sr,sg,sb,sa);
Blend(dst,di,sr,sg,sb,sa,blend);
}
}
@@ -67,7 +68,8 @@ public static class SoftwareAffineRasterizer
private static void BlitTranslated(byte[] dst, int dstW, int dstH, byte[] src, int srcW,
int srcX, int srcY, int width, int height, int tx, int ty,
int tr, int tg, int tb, int istr, int ia, bool multiplyTint)
int tr, int tg, int tb, int istr, int ia, bool multiplyTint,
BlendKind blend)
{
int x0 = System.Math.Max(0, tx), y0 = System.Math.Max(0, ty);
int x1 = (int)System.Math.Min(dstW, (long)tx + width);
@@ -84,7 +86,7 @@ public static class SoftwareAffineRasterizer
int sr = multiplyTint ? src[si] * tr / 255 : (src[si] * (255 - istr) + tr * istr) / 255;
int sg = multiplyTint ? src[si + 1] * tg / 255 : (src[si + 1] * (255 - istr) + tg * istr) / 255;
int sb = multiplyTint ? src[si + 2] * tb / 255 : (src[si + 2] * (255 - istr) + tb * istr) / 255;
Blend(dst, di, sr, sg, sb, sa);
Blend(dst, di, sr, sg, sb, sa, blend);
}
}
}
@@ -108,7 +110,12 @@ public static class SoftwareAffineRasterizer
x1=System.Math.Min(dw,(int)System.Math.Ceiling(System.Math.Max(System.Math.Max(a.X,b.X),System.Math.Max(c.X,d.X))));
y1=System.Math.Min(dh,(int)System.Math.Ceiling(System.Math.Max(System.Math.Max(a.Y,b.Y),System.Math.Max(c.Y,d.Y))));
}
private static void Blend(byte[] d,int i,int r,int g,int b,int a){
private static void Blend(byte[] d,int i,int r,int g,int b,int a,BlendKind blend=BlendKind.Alpha){
if(blend==BlendKind.Additive){
d[i]=(byte)System.Math.Min(255,d[i]+r*a/255);d[i+1]=(byte)System.Math.Min(255,d[i+1]+g*a/255);
d[i+2]=(byte)System.Math.Min(255,d[i+2]+b*a/255);d[i+3]=(byte)System.Math.Min(255,d[i+3]+a);
return;
}
d[i]=(byte)((r*a+d[i]*(255-a))/255);d[i+1]=(byte)((g*a+d[i+1]*(255-a))/255);
d[i+2]=(byte)((b*a+d[i+2]*(255-a))/255);d[i+3]=(byte)System.Math.Min(255,d[i+3]+a);
}

View File

@@ -34,6 +34,14 @@ public sealed class ResourceMap
entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null;
}
/// <summary>Resolve an already-normalized raw catalog id without applying a scene section base.</summary>
public AssetEntry? ResolveRawTexture(long rawId)
{
var entry = _catalog.ResolveRaw(rawId);
return entry is { IsPlaceholder: false } &&
entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null;
}
/// <summary>Decode an AGF directly from loose-first VFS bytes.</summary>
public RgbaImage DecodeTexture(AssetEntry entry) => AgfDecoder.Decode(_store, entry);

View File

@@ -341,32 +341,48 @@ public sealed class VirtualMachine
previousRawInputFrame = _rawInputFrame;
}
var prev = _cur; _cur = frame; _depth++;
_sink.Emit(TraceEvent.FrameEnter(frame.Script.Name, _depth, cause, callId));
var outcome = FrameOutcome.RanOff;
int pc = frame.Pc;
while (pc >= 0 && pc < frame.Script.Instructions.Count)
bool hostContextEntered = false;
try
{
if (Steps >= _o.MaxSteps) { HaltReason ??= "STEP-LIMIT"; outcome = FrameOutcome.Halted; break; }
Steps++;
if (_sink.TracingSteps) _sink.Emit(TraceEvent.Step(pc, frame.Script.Instructions[pc], _depth));
int next = Step(frame.Script.Instructions[pc], pc);
_host.FrameYield();
if (next == FRAME_RETURN) { outcome = FrameOutcome.Returned; break; }
if (next == HALT) { outcome = FrameOutcome.Halted; break; }
pc = next;
_host.EnterScriptContext(frame.Script.Name);
hostContextEntered = true;
_sink.Emit(TraceEvent.FrameEnter(frame.Script.Name, _depth, cause, callId));
var outcome = FrameOutcome.RanOff;
int pc = frame.Pc;
while (pc >= 0 && pc < frame.Script.Instructions.Count)
{
if (Steps >= _o.MaxSteps) { HaltReason ??= "STEP-LIMIT"; outcome = FrameOutcome.Halted; break; }
Steps++;
if (_sink.TracingSteps) _sink.Emit(TraceEvent.Step(pc, frame.Script.Instructions[pc], _depth));
int next = Step(frame.Script.Instructions[pc], pc);
_host.FrameYield();
if (next == FRAME_RETURN) { outcome = FrameOutcome.Returned; break; }
if (next == HALT) { outcome = FrameOutcome.Halted; break; }
pc = next;
}
_sink.Emit(TraceEvent.FrameExit(frame.Script.Name, _depth, outcome.ToString()));
return outcome;
}
_sink.Emit(TraceEvent.FrameExit(frame.Script.Name, _depth, outcome.ToString()));
lock (_interactiveLock)
finally
{
if (cause == FrameCause.CallScript)
_interactiveFrame = previousInteractiveFrame?.Hotspots.Armed == true
? previousInteractiveFrame : null;
else if (ReferenceEquals(_interactiveFrame, frame))
_interactiveFrame = null;
if (ReferenceEquals(_rawInputFrame, frame)) _rawInputFrame = previousRawInputFrame;
lock (_interactiveLock)
{
if (cause == FrameCause.CallScript)
_interactiveFrame = previousInteractiveFrame?.Hotspots.Armed == true
? previousInteractiveFrame : null;
else if (ReferenceEquals(_interactiveFrame, frame))
_interactiveFrame = null;
if (ReferenceEquals(_rawInputFrame, frame)) _rawInputFrame = previousRawInputFrame;
}
try
{
if (hostContextEntered) _host.ExitScriptContext();
}
finally
{
_cur = prev; _depth--;
}
}
_cur = prev; _depth--;
return outcome;
}
private bool ServiceHotspotCallback()
@@ -951,12 +967,17 @@ public sealed class VirtualMachine
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
{
long requestedResourceId = Read(a[0]);
long resolvedResourceId = _host.ResolveTextureResourceId(requestedResourceId);
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])} " +
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]), 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
Gfx.SetSurface((int)Read(a[1]), resolvedResourceId, a.Count > 2 ? Read(a[2]) : 0);
_host.SetTexture(resolvedResourceId, (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
Gfx.BindDraw(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]));