Implement indexed party roster sort

This commit is contained in:
gamer147
2026-07-21 11:56:04 -04:00
parent e81a142f23
commit a0df85825e
9 changed files with 251 additions and 28 deletions

View File

@@ -0,0 +1,73 @@
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 IndexedSortOpsTests
{
private const int Immediate = 0, GlobalInt = 3, LocalInt = 9;
[Fact]
public void SortIndicesByKeySum_IsStableAndUsesSignedInt32Overflow()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
int move = table.ByLabel("mov")!.Value;
var ops = new List<(int, Operand[])>();
long[] secondaryKeys = { -1, 1, -1, -2, 1 };
for (int i = 0; i < secondaryKeys.Length; i++)
ops.Add((move, new[] { new Operand(LocalInt, 20 + i), new Operand(Immediate, secondaryKeys[i]) }));
ops.Add((0x12f, new[]
{
new Operand(LocalInt, 0),
new Operand(GlobalInt, 0x100),
new Operand(LocalInt, 20),
new Operand(Immediate, secondaryKeys.Length),
}));
for (int i = 0; i < secondaryKeys.Length; i++)
ops.Add((move, new[] { new Operand(GlobalInt, 0x200 + i), new Operand(LocalInt, i) }));
ops.Add((0x2, Array.Empty<Operand>()));
var vm = new VirtualMachine(
ScriptAssembler.Assemble(table, "INDEX_SORT", ops, Array.Empty<string>()),
table, new RecordingHost());
long[] primaryKeys = { 4, -2, 4, 1, int.MaxValue };
for (int i = 0; i < primaryKeys.Length; i++) vm.Globals[0x100 + i] = primaryKeys[i];
vm.Globals[0x104] = 0;
vm.ExternalGlobals[0x104] = int.MaxValue; // addressed global reads honor the host-owned overlay
vm.Run();
// Sums are 3, -1, 3, -1, and int.MinValue after native signed 32-bit overflow.
// Equal -1 and 3 pairs retain their original relative order.
Assert.Equal(new long[] { 4, 1, 3, 0, 2 },
Enumerable.Range(0, 5).Select(i => vm.Globals[0x200 + i]).ToArray());
}
[Fact]
public void SortIndicesByKeySum_WritesInitialIndexWhenCountIsZero()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
int move = table.ByLabel("mov")!.Value;
var script = ScriptAssembler.Assemble(table, "EMPTY_INDEX_SORT", new List<(int, Operand[])>
{
(move, new[] { new Operand(LocalInt, 0), new Operand(Immediate, 99) }),
(0x12f, new[]
{
new Operand(LocalInt, 0), new Operand(LocalInt, 10),
new Operand(LocalInt, 20), new Operand(Immediate, 0),
}),
(move, new[] { new Operand(GlobalInt, 0x200), new Operand(LocalInt, 0) }),
(0x2, Array.Empty<Operand>()),
}, Array.Empty<string>());
var vm = new VirtualMachine(script, table, new RecordingHost());
vm.Run();
Assert.Equal(0, vm.Globals[0x200]);
}
}

View File

@@ -1,4 +1,5 @@
using Age.Engine.Diagnostics;
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
@@ -6,6 +7,11 @@ using Xunit;
public class NaturalBootIntegrationTests
{
private sealed class ReachedSc0000Exception : Exception { }
private sealed class ReachedChmenuRosterException : Exception { }
private sealed record NaturalBootResult(
OpcodeTable Table, Sys4ScriptProvider Scripts, VirtualMachine Vm,
NewGameInputHost Host, StopAtSc0000Sink Sink);
private sealed class StopAtSc0000Sink : ITraceSink
{
@@ -31,6 +37,29 @@ public class NaturalBootIntegrationTests
}
}
private sealed class StopAfterChmenuRosterSink : ITraceSink
{
public VirtualMachine Vm = null!;
public bool SawIndexSort;
public bool SawIndexSortFallback;
public bool TracingSteps => true;
public void Emit(in TraceEvent e)
{
if (e.Kind == TraceEventKind.Stub && e.Opcode == 0x12f)
SawIndexSortFallback = true;
if (e.Kind != TraceEventKind.Step || e.Ins == null) return;
if (e.Opcode == 0x12f) SawIndexSort = true;
// The step event is emitted before execution. At CHMENU@0x1dbd, the first sort has
// completed and 0x1db8 has copied the chosen roster slot back to G[0x6718].
if (e.Ins.Offset == 0x1dbd
&& string.Equals(Vm.DebugFrame?.CurrentScript, "CHMENU.BIN",
StringComparison.OrdinalIgnoreCase))
throw new ReachedChmenuRosterException();
}
}
private sealed class NewGameInputHost : RecordingHost
{
public VirtualMachine Vm = null!;
@@ -82,8 +111,7 @@ public class NaturalBootIntegrationTests
}
}
[Fact]
public void System4Root_NewGameSelectionNaturallyCallsSc0000()
private static NaturalBootResult RunNaturalNewGameToSc0000()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var scripts = Sys4ScriptProvider.Load(table);
@@ -100,6 +128,16 @@ public class NaturalBootIntegrationTests
var exception = Record.Exception(() => vm.Run());
Assert.True(exception is ReachedSc0000Exception,
$"halt={vm.HaltReason}; title_sleeps={host.TitlePollSleeps}; entered={string.Join(",", sink.Entered)}");
return new NaturalBootResult(table, scripts, vm, host, sink);
}
[Fact]
public void System4Root_NewGameSelectionNaturallyCallsSc0000()
{
var boot = RunNaturalNewGameToSc0000();
var vm = boot.Vm;
var host = boot.Host;
var sink = boot.Sink;
Assert.Equal(new[]
{
@@ -123,4 +161,28 @@ public class NaturalBootIntegrationTests
Assert.False(sink.SawStringEqualsStub);
Assert.False(sink.SawUnitDataCopyStub);
}
[Fact]
public void NaturalNewGamePartyStateSurvivesRealChmenuRosterSort()
{
var boot = RunNaturalNewGameToSc0000();
Assert.Equal(2, boot.Vm.Globals.GetValueOrDefault(0x6718));
Assert.Equal(0x13, boot.Vm.Globals.GetValueOrDefault(0x673c + 2));
Assert.Equal(2, boot.Vm.Globals.GetValueOrDefault(0x67a0 + 2));
var sink = new StopAfterChmenuRosterSink();
var menuVm = new VirtualMachine(boot.Scripts.RequireByName("CHMENU.BIN"), boot.Table,
new RecordingHost(), new VmOptions(MaxSteps: 5_000_000), boot.Scripts, sink);
sink.Vm = menuVm;
foreach (var (address, value) in boot.Vm.Globals) menuVm.Globals[address] = value;
foreach (var (address, value) in boot.Vm.ExternalGlobals) menuVm.ExternalGlobals[address] = value;
foreach (var (address, value) in boot.Vm.GlobalStrings) menuVm.GlobalStrings[address] = value;
var exception = Record.Exception(() => menuVm.Run());
Assert.IsType<ReachedChmenuRosterException>(exception);
Assert.True(sink.SawIndexSort);
Assert.False(sink.SawIndexSortFallback);
Assert.Equal(2, menuVm.Globals.GetValueOrDefault(0x6718));
}
}

View File

@@ -735,6 +735,36 @@ public sealed class VirtualMachine
Write(a[0], match);
return pc + 1;
}
case "sort-indices-by-key-sum": // 0x12f: stable ascending permutation by signed key sum
case "u0041ECB0":
{
VmAddress output = BaseAddr(a[0]);
// Native writes element zero even when count is zero or negative, then builds the
// permutation in place with insertion sort. Read the count for each outer iteration:
// the handler fetches operand 4 repeatedly rather than caching it.
WriteIntCell(output, 0);
for (int sourceIndex = 1; sourceIndex < unchecked((int)Read(a[3])); sourceIndex++)
{
int position = sourceIndex;
while (position > 0)
{
int previousIndex = unchecked((int)ReadIntCell(output.Offset(position - 1)));
int previousKey = unchecked(
unchecked((int)ReadAddressedCell(a[1], previousIndex))
+ unchecked((int)ReadAddressedCell(a[2], previousIndex)));
int sourceKey = unchecked(
unchecked((int)ReadAddressedCell(a[1], sourceIndex))
+ unchecked((int)ReadAddressedCell(a[2], sourceIndex)));
if (sourceKey >= previousKey) break;
WriteIntCell(output.Offset(position), previousIndex);
position--;
}
WriteIntCell(output.Offset(position), sourceIndex);
}
return pc + 1;
}
case "bit-set":
{
long bit = Read(a[1]);