Implement indexed party roster sort
This commit is contained in:
@@ -2347,6 +2347,26 @@ teardown. The port now retains this engine-owned selector in `GfxState.DefaultOb
|
|||||||
selects slot 8 in the regression. The surveyed MENU/INFO scripts do not themselves call op `0x1d9`, so this
|
selects slot 8 in the regression. The surveyed MENU/INFO scripts do not themselves call op `0x1d9`, so this
|
||||||
state was not the cause of the former missing launch.
|
state was not the cause of the former missing launch.
|
||||||
|
|
||||||
|
### Indexed two-key sort — `0x12f` (2026-07-21)
|
||||||
|
|
||||||
|
`op_0x12f_sort_indices_by_key_sum@0x429360` takes `(out_indices, key_a, key_b, count)`. It resolves the
|
||||||
|
first three operands as integer-array bases, seeds output index zero, and performs a stable insertion sort
|
||||||
|
over source indices `0..count-1`. The comparison is signed
|
||||||
|
`unchecked(key_a[index] + key_b[index])`; a new index moves left only when its sum is strictly smaller, so
|
||||||
|
equal sums preserve source order. The native handler's final loop does not change that result: it encodes
|
||||||
|
the directly written output cells back into AGE's protected integer-bank representation.
|
||||||
|
|
||||||
|
This is the cause of the first observed character-menu discrepancy. Natural New Game correctly runs
|
||||||
|
UNITECH and enters SC0000 with party slot 2 carrying flags `0x13` and character id 2. CHMENU constructs
|
||||||
|
100 party-slot sort keys, calls `0x12f` at `0x1c9b`, and reads the populated tail of the resulting index
|
||||||
|
permutation. The port's fallback no-op left that permutation zero-filled, so CHMENU selected slot zero and
|
||||||
|
rendered an apparently empty party even though the authoritative unit state already existed. The port now
|
||||||
|
implements the generic sort with addressed local/global arrays, native signed 32-bit addition and overflow,
|
||||||
|
stable equal-key ordering, repeated count reads, and the native unconditional output-index-zero write.
|
||||||
|
Focused tests cover stability, overflow, and zero count. A real-script regression carries natural
|
||||||
|
SYSTEM4-to-SC0000 state into CHMENU and stops after `0x1db8`, proving selected slot 2 survives the first
|
||||||
|
release roster sort without a fallback. No startup seed or menu-specific roster injection is involved.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Native walls backlog (targets for this loop)
|
## Native walls backlog (targets for this loop)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<!-- DO NOT EDIT -- generated from vm-map/globals.toml by tools/globals_build.py --build -->
|
<!-- DO NOT EDIT -- generated from vm-map/globals.toml by tools/globals_build.py --build -->
|
||||||
# Global Variable Reference (generated)
|
# Global Variable Reference (generated)
|
||||||
|
|
||||||
16417 globals (73 curated, 16344 auto shape-inferred). Source of truth: `vm-map/globals.toml`.
|
16417 globals (75 curated, 16342 auto shape-inferred). Source of truth: `vm-map/globals.toml`.
|
||||||
|
|
||||||
## choice-output
|
## choice-output
|
||||||
|
|
||||||
@@ -27,6 +27,8 @@
|
|||||||
| address | name | conf | source | usage |
|
| address | name | conf | source | usage |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `0x2e49` | character_voice_suppressed | high | investigation | Base of the per-character voice enable/suppress settings. INITCONFIG zero-fills all 13 cells and registers each with the shared profile service; LOADCONFIG restores them. CONFIG indexes the table to preview a character voice and write 0/1. ROOM reads cell 0 before assigning its selected greeter's greeting/farewell voice ids, so the port's former scalar interpretation of zero-int-range (writing 13 into the base cell) suppressed those voices on every natural boot. This names the script-visible setting array without choosing a persistence backend for op 0x1a2/0x1a3. |
|
| `0x2e49` | character_voice_suppressed | high | investigation | Base of the per-character voice enable/suppress settings. INITCONFIG zero-fills all 13 cells and registers each with the shared profile service; LOADCONFIG restores them. CONFIG indexes the table to preview a character voice and write 0/1. ROOM reads cell 0 before assigning its selected greeter's greeting/farewell voice ids, so the port's former scalar interpretation of zero-int-range (writing 13 into the base cell) suppressed those voices on every natural boot. This names the script-visible setting array without choosing a persistence backend for op 0x1a2/0x1a3. |
|
||||||
|
| `0x673c` | party_slot_flags | high | investigation | Per-party-slot state flags for slots 0..99. UNITECH creates the initial unit by setting slot 2 to 0x13; CALCARR counts slots whose flags intersect 0x6, and CHMENU includes slots with bit 1 set. Exact meanings of the remaining bits are not yet classified. |
|
||||||
|
| `0x67a0` | party_slot_character_id | high | investigation | Character/unit definition id stored for each party slot. UNITECH writes character id 2 into initial slot 2 on a natural New Game; CHMENU reads this table for every active party_slot_flags entry when constructing its roster. |
|
||||||
| `0x5` | — | low | auto-shape | array |
|
| `0x5` | — | low | auto-shape | array |
|
||||||
| `0xd2` | — | low | auto-shape | array |
|
| `0xd2` | — | low | auto-shape | array |
|
||||||
| `0xd7` | — | low | auto-shape | array |
|
| `0xd7` | — | low | auto-shape | array |
|
||||||
@@ -75,8 +77,6 @@
|
|||||||
| `0x671c` | — | low | auto-shape | array |
|
| `0x671c` | — | low | auto-shape | array |
|
||||||
| `0x671f` | — | low | auto-shape | array |
|
| `0x671f` | — | low | auto-shape | array |
|
||||||
| `0x6727` | — | low | auto-shape | array |
|
| `0x6727` | — | low | auto-shape | array |
|
||||||
| `0x673c` | — | low | auto-shape | array |
|
|
||||||
| `0x67a0` | — | low | auto-shape | array |
|
|
||||||
| `0x6804` | — | low | auto-shape | array |
|
| `0x6804` | — | low | auto-shape | array |
|
||||||
| `0x6868` | — | low | auto-shape | array |
|
| `0x6868` | — | low | auto-shape | array |
|
||||||
| `0x6930` | — | low | auto-shape | array |
|
| `0x6930` | — | low | auto-shape | array |
|
||||||
@@ -337,6 +337,7 @@
|
|||||||
|
|
||||||
| address | name | conf | source | usage |
|
| address | name | conf | source | usage |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
|
| `0x6718` | selected_party_slot | high | investigation | Current/selected slot in the 100-entry party-unit arrays. UNITECH chooses a free slot here before populating it; CHMENU replaces it with the selected sorted roster slot, then uses it to index party_slot_flags, party_slot_character_id, and companion per-slot tables. A natural New Game enters SC0000 with slot 2 selected. |
|
||||||
| `0xeff75` | current_entity_index_hi | med | inference | High-purity current-entity row index (purity 0.95 in the auto shape map); dominant 2D-table row selector. |
|
| `0xeff75` | current_entity_index_hi | med | inference | High-purity current-entity row index (purity 0.95 in the auto shape map); dominant 2D-table row selector. |
|
||||||
| `0x152616` | current_entity_index | med | investigation | Primary current-entity row index (RECOVER-confirmed; purity 0.51, 363 row-index uses). |
|
| `0x152616` | current_entity_index | med | investigation | Primary current-entity row index (RECOVER-confirmed; purity 0.51, 363 row-index uses). |
|
||||||
|
|
||||||
@@ -352,7 +353,6 @@
|
|||||||
| `0x3231` | game_mode | med | inference | Game-mode/phase selector in the 0x3231-0x3234 progression-state cluster (chapter_mode is 0x3234). Enum 1..9, written by the gameplay scripts (AIM/ALCHEMY/BTL/BUNKI), branch-read in 136 scenes. Distinct from chapter; likely current sub-mode/screen. INFERENCE — confirm with a listing/sweep before relying on the exact meaning. |
|
| `0x3231` | game_mode | med | inference | Game-mode/phase selector in the 0x3231-0x3234 progression-state cluster (chapter_mode is 0x3234). Enum 1..9, written by the gameplay scripts (AIM/ALCHEMY/BTL/BUNKI), branch-read in 136 scenes. Distinct from chapter; likely current sub-mode/screen. INFERENCE — confirm with a listing/sweep before relying on the exact meaning. |
|
||||||
| `0x3234` | chapter_mode | high | investigation | Progression chapter/mode selector. SCJUMP's top-level switch keys on it; branch-read by progression scripts (FIELD etc.), not directly by SC/SP scenes. |
|
| `0x3234` | chapter_mode | high | investigation | Progression chapter/mode selector. SCJUMP's top-level switch keys on it; branch-read by progression scripts (FIELD etc.), not directly by SC/SP scenes. |
|
||||||
| `0x3275` | — | med | auto-shape | TODO: confirm. Branch-read in 136 scenes / 143 scripts; compared against [0]; writers=['CAMP.BIN', 'DEBUGADV.BIN', 'DEBUGADV2.BIN', 'FIELD.BIN']. |
|
| `0x3275` | — | med | auto-shape | TODO: confirm. Branch-read in 136 scenes / 143 scripts; compared against [0]; writers=['CAMP.BIN', 'DEBUGADV.BIN', 'DEBUGADV2.BIN', 'FIELD.BIN']. |
|
||||||
| `0x6718` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 6 scripts; compared against [0, 2]; writers=['ADDSKILL.BIN', 'CHMENU.BIN', 'DELCH.BIN', 'DRAWCHP.BIN']. |
|
|
||||||
| `0x671b` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 5 scripts; compared against [999]; writers=['ALCHEMY.BIN', 'DEBUGMAP.BIN', 'DEBUGMAP2.BIN', 'DEBUGMAP3.BIN']. |
|
| `0x671b` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 5 scripts; compared against [999]; writers=['ALCHEMY.BIN', 'DEBUGMAP.BIN', 'DEBUGMAP2.BIN', 'DEBUGMAP3.BIN']. |
|
||||||
| `0x671e` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 3 scripts; compared against [5, 6]; writers=['USEMAGIC.BIN']. |
|
| `0x671e` | — | med | auto-shape | TODO: confirm. Branch-read in 0 scenes / 3 scripts; compared against [5, 6]; writers=['USEMAGIC.BIN']. |
|
||||||
| `0x6722` | — | med | auto-shape | TODO: confirm. Branch-read in 13 scenes / 18 scripts; compared against [0, 1, 5, 99]; writers=['GAMESTART.BIN', 'SALLY.BIN', 'SC0010.BIN', 'SC0140.BIN']. |
|
| `0x6722` | — | med | auto-shape | TODO: confirm. Branch-read in 13 scenes / 18 scripts; compared against [0, 1, 5, 99]; writers=['GAMESTART.BIN', 'SALLY.BIN', 'SC0010.BIN', 'SC0140.BIN']. |
|
||||||
|
|||||||
@@ -215,6 +215,13 @@ Operand 2 names the base cell itself: a global-bank operand produces a global re
|
|||||||
- **grounding:** source=investigation, confidence=high
|
- **grounding:** source=investigation, confidence=high
|
||||||
- **evidence:** Ghidra /v2: op_0x6c_handler@0x426d90 resolves operand 1 as a writable VM pointer and fills operand-2 consecutive dwords with ctx->anti_tamper_b, the native encoded representation of logical integer zero. INITCONFIG@0x30 clears G[0x2e49..0x2e55] before registering those 13 profile cells; treating operand 2 as a scalar source incorrectly wrote 13 to G[0x2e49] and disabled ROOM's character greeting/farewell voices. /v2 annotated and saved 2026-07-21.
|
- **evidence:** Ghidra /v2: op_0x6c_handler@0x426d90 resolves operand 1 as a writable VM pointer and fills operand-2 consecutive dwords with ctx->anti_tamper_b, the native encoded representation of logical integer zero. INITCONFIG@0x30 clears G[0x2e49..0x2e55] before registering those 13 profile cells; treating operand 2 as a scalar source incorrectly wrote 13 to G[0x2e49] and disabled ROOM's character greeting/farewell voices. /v2 annotated and saved 2026-07-21.
|
||||||
|
|
||||||
|
### 0x12f `sort-indices-by-key-sum` (sort-indices-by-key-sum, argc 4)
|
||||||
|
- **summary:** (out_indices, key_a, key_b, count) - write a stable ascending permutation of indices 0..count-1, ordered by the signed 32-bit sum key_a[index] + key_b[index].
|
||||||
|
- **grounding:** source=investigation, confidence=high
|
||||||
|
- **evidence:** Ghidra /v2: op_0x12f_sort_indices_by_key_sum@0x429360 resolves operands 1-3 as integer-array bases, seeds out_indices[0]=0, then performs insertion sort. It shifts a prior index only when signed unchecked(key_a[new]+key_b[new]) is strictly less than the prior sum, so equal keys retain source order. Operand 4 is fetched as the exclusive count. The final native loop merely re-encodes direct writes into AGE's protected integer representation. Release corpus: 40 sites. CHMENU@0x1c9b sorts 100 party-slot keys and reads the populated tail; a no-op leaves the initial party slot undiscoverable even though UNITECH created it.
|
||||||
|
|
||||||
|
Implemented with domain-preserving addressed-array access, native signed 32-bit key addition/overflow, stable insertion ordering, repeated count reads, and the native unconditional out_indices[0]=0 write. Focused tests lock stability/overflow/zero-count behavior; a natural SYSTEM4-to-SC0000 state carried into release CHMENU proves the initial slot remains selected after the real roster sort.
|
||||||
|
|
||||||
### 0x135 `bit-set` (bit-set, argc 2)
|
### 0x135 `bit-set` (bit-set, argc 2)
|
||||||
- **summary:** (value)(bit_index) - set the indexed bit in the destination integer.
|
- **summary:** (value)(bit_index) - set the indexed bit in the destination integer.
|
||||||
- **grounding:** source=investigation, confidence=high
|
- **grounding:** source=investigation, confidence=high
|
||||||
@@ -1022,10 +1029,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it "
|
|||||||
- **summary:** —
|
- **summary:** —
|
||||||
- **grounding:** source=kelebek, confidence=med
|
- **grounding:** source=kelebek, confidence=med
|
||||||
|
|
||||||
### 0x12f `u0041ECB0` (u0041ECB0, argc 4)
|
|
||||||
- **summary:** —
|
|
||||||
- **grounding:** source=kelebek, confidence=low
|
|
||||||
|
|
||||||
### 0x132 `u0041EF00` (u0041EF00, argc 1)
|
### 0x132 `u0041EF00` (u0041EF00, argc 1)
|
||||||
- **summary:** —
|
- **summary:** —
|
||||||
- **grounding:** source=kelebek, confidence=low
|
- **grounding:** source=kelebek, confidence=low
|
||||||
|
|||||||
@@ -293,6 +293,18 @@ SYSTEM4's same 16 immediate configuration calls. This is independent of profile/
|
|||||||
Godot currently releases a parked ADV wait before process teardown, so the page map may contain one trailing
|
Godot currently releases a parked ADV wait before process teardown, so the page map may contain one trailing
|
||||||
shutdown-only page; the final timeline `input-wait` is the authoritative stop.
|
shutdown-only page; the final timeline `input-wait` is the authoritative stop.
|
||||||
|
|
||||||
|
**SC0000 character-menu roster discrepancy (2026-07-21; investigated).** A natural-boot state probe at
|
||||||
|
SC0000 entry shows that GAMESTART/UNITECH already created party slot 2 with flags `0x13`, character id 2,
|
||||||
|
and selected slot 2. The empty Character Info page is therefore not missing boot data. CHMENU gathers that
|
||||||
|
active slot, constructs two 100-cell sort-key arrays, calls opcode `0x12f` at `0x1c9b`, and reads the
|
||||||
|
populated tail of the resulting index permutation. The port had been falling through `0x12f`, leaving the
|
||||||
|
output zero-filled and making CHMENU select empty slot zero. Native RE fully identifies `0x12f` as a stable
|
||||||
|
ascending index sort by the signed sum of two key arrays. The generic opcode is now implemented with native
|
||||||
|
32-bit overflow and stable ordering. Focused regressions cover equal keys, overflow, and zero count; a
|
||||||
|
real-script regression carries the naturally booted state into CHMENU and proves the first roster sort keeps
|
||||||
|
slot 2 selected with no `0x12f` fallback. This uses no seed or menu-specific injection. Remaining acceptance
|
||||||
|
is manual: open Character Info at the first stable SC0000 page and confirm the initial character is visible.
|
||||||
|
|
||||||
## Stage B2 — Faithful full boot
|
## Stage B2 — Faithful full boot
|
||||||
|
|
||||||
Replace `--boot`'s diagnostic seeding and separately injected inherited surfaces with normal boot execution.
|
Replace `--boot`'s diagnostic seeding and separately injected inherited surfaces with normal boot execution.
|
||||||
|
|||||||
73
engine/Age.Engine.Tests/IndexedSortOpsTests.cs
Normal file
73
engine/Age.Engine.Tests/IndexedSortOpsTests.cs
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Age.Engine.Diagnostics;
|
using Age.Engine.Diagnostics;
|
||||||
|
using Age.Engine.Model;
|
||||||
using Age.Engine.Sys4;
|
using Age.Engine.Sys4;
|
||||||
using Age.Engine.Vm;
|
using Age.Engine.Vm;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -6,6 +7,11 @@ using Xunit;
|
|||||||
public class NaturalBootIntegrationTests
|
public class NaturalBootIntegrationTests
|
||||||
{
|
{
|
||||||
private sealed class ReachedSc0000Exception : Exception { }
|
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
|
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
|
private sealed class NewGameInputHost : RecordingHost
|
||||||
{
|
{
|
||||||
public VirtualMachine Vm = null!;
|
public VirtualMachine Vm = null!;
|
||||||
@@ -82,8 +111,7 @@ public class NaturalBootIntegrationTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
private static NaturalBootResult RunNaturalNewGameToSc0000()
|
||||||
public void System4Root_NewGameSelectionNaturallyCallsSc0000()
|
|
||||||
{
|
{
|
||||||
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||||
var scripts = Sys4ScriptProvider.Load(table);
|
var scripts = Sys4ScriptProvider.Load(table);
|
||||||
@@ -100,6 +128,16 @@ public class NaturalBootIntegrationTests
|
|||||||
var exception = Record.Exception(() => vm.Run());
|
var exception = Record.Exception(() => vm.Run());
|
||||||
Assert.True(exception is ReachedSc0000Exception,
|
Assert.True(exception is ReachedSc0000Exception,
|
||||||
$"halt={vm.HaltReason}; title_sleeps={host.TitlePollSleeps}; entered={string.Join(",", sink.Entered)}");
|
$"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[]
|
Assert.Equal(new[]
|
||||||
{
|
{
|
||||||
@@ -123,4 +161,28 @@ public class NaturalBootIntegrationTests
|
|||||||
Assert.False(sink.SawStringEqualsStub);
|
Assert.False(sink.SawStringEqualsStub);
|
||||||
Assert.False(sink.SawUnitDataCopyStub);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -735,6 +735,36 @@ public sealed class VirtualMachine
|
|||||||
Write(a[0], match);
|
Write(a[0], match);
|
||||||
return pc + 1;
|
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":
|
case "bit-set":
|
||||||
{
|
{
|
||||||
long bit = Read(a[1]);
|
long bit = Read(a[1]);
|
||||||
|
|||||||
@@ -316,14 +316,36 @@ depends_on = []
|
|||||||
|
|
||||||
[[global]]
|
[[global]]
|
||||||
address = "0x6718"
|
address = "0x6718"
|
||||||
name = ""
|
name = "selected_party_slot"
|
||||||
category = "story-flag"
|
category = "index-pointer"
|
||||||
type = "int"
|
type = "int"
|
||||||
value_domain = "one of {0, 2}"
|
value_domain = "0..99"
|
||||||
usage = "TODO: confirm. Branch-read in 0 scenes / 6 scripts; compared against [0, 2]; writers=['ADDSKILL.BIN', 'CHMENU.BIN', 'DELCH.BIN', 'DRAWCHP.BIN']."
|
usage = "Current/selected slot in the 100-entry party-unit arrays. UNITECH chooses a free slot here before populating it; CHMENU replaces it with the selected sorted roster slot, then uses it to index party_slot_flags, party_slot_character_id, and companion per-slot tables. A natural New Game enters SC0000 with slot 2 selected."
|
||||||
source = "auto-shape"
|
source = "investigation"
|
||||||
confidence = "med"
|
confidence = "high"
|
||||||
depends_on = []
|
depends_on = ["0x673c", "0x67a0"]
|
||||||
|
|
||||||
|
[[global]]
|
||||||
|
address = "0x673c"
|
||||||
|
name = "party_slot_flags"
|
||||||
|
category = "data-table"
|
||||||
|
type = "int[100]"
|
||||||
|
value_domain = "bitfield"
|
||||||
|
usage = "Per-party-slot state flags for slots 0..99. UNITECH creates the initial unit by setting slot 2 to 0x13; CALCARR counts slots whose flags intersect 0x6, and CHMENU includes slots with bit 1 set. Exact meanings of the remaining bits are not yet classified."
|
||||||
|
source = "investigation"
|
||||||
|
confidence = "high"
|
||||||
|
depends_on = ["0x6718", "0x67a0"]
|
||||||
|
|
||||||
|
[[global]]
|
||||||
|
address = "0x67a0"
|
||||||
|
name = "party_slot_character_id"
|
||||||
|
category = "data-table"
|
||||||
|
type = "int[100]"
|
||||||
|
value_domain = "character/unit definition id or 0"
|
||||||
|
usage = "Character/unit definition id stored for each party slot. UNITECH writes character id 2 into initial slot 2 on a natural New Game; CHMENU reads this table for every active party_slot_flags entry when constructing its roster."
|
||||||
|
source = "investigation"
|
||||||
|
confidence = "high"
|
||||||
|
depends_on = ["0x6718", "0x673c"]
|
||||||
|
|
||||||
[[global]]
|
[[global]]
|
||||||
address = "0x671b"
|
address = "0x671b"
|
||||||
|
|||||||
@@ -2578,38 +2578,39 @@ observed_types = ["imm", "g-int", "l-int"]
|
|||||||
|
|
||||||
[[opcode]]
|
[[opcode]]
|
||||||
op = 0x12f
|
op = 0x12f
|
||||||
label = "u0041ECB0"
|
label = "sort-indices-by-key-sum"
|
||||||
argc = 4
|
argc = 4
|
||||||
abi_source = "kelebek+decode-validated"
|
abi_source = "kelebek+decode-validated"
|
||||||
|
|
||||||
[opcode.semantics]
|
[opcode.semantics]
|
||||||
name = "u0041ECB0"
|
name = "sort-indices-by-key-sum"
|
||||||
category = "unknown"
|
category = "compute"
|
||||||
summary = ""
|
summary = "(out_indices, key_a, key_b, count) - write a stable ascending permutation of indices 0..count-1, ordered by the signed 32-bit sum key_a[index] + key_b[index]."
|
||||||
noop_headless = false
|
noop_headless = false
|
||||||
source = "kelebek"
|
source = "investigation"
|
||||||
confidence = "low"
|
confidence = "high"
|
||||||
depends_on = []
|
depends_on = []
|
||||||
evidence = ""
|
evidence = "Ghidra /v2: op_0x12f_sort_indices_by_key_sum@0x429360 resolves operands 1-3 as integer-array bases, seeds out_indices[0]=0, then performs insertion sort. It shifts a prior index only when signed unchecked(key_a[new]+key_b[new]) is strictly less than the prior sum, so equal keys retain source order. Operand 4 is fetched as the exclusive count. The final native loop merely re-encodes direct writes into AGE's protected integer representation. Release corpus: 40 sites. CHMENU@0x1c9b sorts 100 party-slot keys and reads the populated tail; a no-op leaves the initial party slot undiscoverable even though UNITECH created it."
|
||||||
|
details = "Implemented with domain-preserving addressed-array access, native signed 32-bit key addition/overflow, stable insertion ordering, repeated count reads, and the native unconditional out_indices[0]=0 write. Focused tests lock stability/overflow/zero-count behavior; a natural SYSTEM4-to-SC0000 state carried into release CHMENU proves the initial slot remains selected after the real roster sort."
|
||||||
|
|
||||||
[[opcode.semantics.args]]
|
[[opcode.semantics.args]]
|
||||||
i = 1
|
i = 1
|
||||||
role = ""
|
role = "out_index_permutation"
|
||||||
observed_types = ["l-int"]
|
observed_types = ["l-int"]
|
||||||
|
|
||||||
[[opcode.semantics.args]]
|
[[opcode.semantics.args]]
|
||||||
i = 2
|
i = 2
|
||||||
role = ""
|
role = "primary_key_array"
|
||||||
observed_types = ["g-int", "l-int"]
|
observed_types = ["g-int", "l-int"]
|
||||||
|
|
||||||
[[opcode.semantics.args]]
|
[[opcode.semantics.args]]
|
||||||
i = 3
|
i = 3
|
||||||
role = ""
|
role = "secondary_key_array"
|
||||||
observed_types = ["l-int"]
|
observed_types = ["l-int"]
|
||||||
|
|
||||||
[[opcode.semantics.args]]
|
[[opcode.semantics.args]]
|
||||||
i = 4
|
i = 4
|
||||||
role = ""
|
role = "count"
|
||||||
observed_types = ["imm", "l-int"]
|
observed_types = ["imm", "l-int"]
|
||||||
|
|
||||||
[[opcode]]
|
[[opcode]]
|
||||||
|
|||||||
Reference in New Issue
Block a user