Skip to content

Commit e3dd220

Browse files
committed
Promote Tiberian Sun string port
Port the localized mission-completion logic with explicit UTF-8 decoding, ASCII normalization, and fallible byte-boundary slices. Add a deterministic host fixture for process and module identity, 64-bit traversal, full-bound and NUL-terminated text, timer actions, and detach.
1 parent ca88f1f commit e3dd220

6 files changed

Lines changed: 238 additions & 1 deletion

File tree

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,9 @@ native UTF-16 case in
205205
timer-metadata and monotonic-delay case is recorded in
206206
[`docs/DARK_SASI_PORT.md`](docs/DARK_SASI_PORT.md), and the ASL process-name
207207
migration and multi-layout case in
208-
[`docs/NIOH_RTA_NO_LOAD_PORT.md`](docs/NIOH_RTA_NO_LOAD_PORT.md).
208+
[`docs/NIOH_RTA_NO_LOAD_PORT.md`](docs/NIOH_RTA_NO_LOAD_PORT.md). The focused
209+
ASCII string-normalization case is recorded in
210+
[`docs/TIBERIAN_SUN_PORT.md`](docs/TIBERIAN_SUN_PORT.md).
209211

210212
## What works now
211213

‎docs/ROADMAP_ARCHIVE.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# SplitScript roadmap
22

3+
## 2026-08-09: explicit ASCII string normalization
4+
5+
- Added immutable `String.toAsciiLowerCase()` with an intentionally narrow
6+
ASCII contract. It changes only `A` through `Z`, preserves all other UTF-8
7+
bytes, and reuses the receiver without allocation when no byte changes.
8+
- Kept the API in the source-defined standard-library catalog with its docs,
9+
example, must-use obligation, signature, effects, completion, and hover.
10+
Rust owns only the validated Wasm GC byte-transform helper.
11+
- Extended the string runtime fixture across mixed ASCII, non-ASCII UTF-8, and
12+
already-normalized input, and documented C# `ToLower()` migration without
13+
pretending to provide culture-sensitive Unicode casing.
14+
- Promoted Tiberian Sun as the motivating maintained port, covering its
15+
localized completion-text patterns, explicit native decoding, fallible byte
16+
slices, timer behavior, and process lifecycle in a deterministic host.
17+
318
## 2026-08-09: faithful Nioh multi-layout port
419

520
- Replaced the campaign's unsafe newest-version fallback with a maintained

‎docs/TIBERIAN_SUN_PORT.md‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Tiberian Sun autosplitter port
2+
3+
This maintained port is based on `TiberianSun.asl` from the reviewed corpus.
4+
It exercises the new ASCII normalization operation against the source's actual
5+
localized mission-completion logic rather than treating a compile probe as
6+
behavioral evidence.
7+
8+
## Preserved behavior
9+
10+
| ASL behavior | SplitScript representation |
11+
| --- | --- |
12+
| Attach to ASL's extensionless `game` process | `state "game.exe"` for the current Windows host contract |
13+
| Remove loads when `isPlaying` is zero | Original `isLoading` predicate |
14+
| Start on the vanilla or Firestorm menu-to-game transition | Original menu and game-state edge |
15+
| Lowercase localized splash text | `toAsciiLowerCase()` with explicit ASCII semantics |
16+
| Match English, German, and French completion text | Byte-boundary slices at offsets 7 and 11 |
17+
| Match Spanish `MISION CUMPLIDA` | Lowercase `c` at byte offset 7 |
18+
19+
The source uses ASL `string20`, whose runtime guesses UTF-16LE from the second
20+
byte. The known splash identifiers used by this script are ASCII, so the port
21+
chooses `utf8(20)` explicitly. This makes the memory contract auditable and
22+
keeps byte offsets equivalent to the source's .NET character indexes for the
23+
supported patterns. A future non-ASCII localization would require revisiting
24+
that evidence rather than silently relying on these offsets.
25+
26+
`slice` is fallible because arbitrary UTF-8 byte offsets can land inside a code
27+
point. Both accesses therefore return `false` on malformed or unexpectedly
28+
short text instead of trapping or manufacturing a character value.
29+
30+
## Runtime status
31+
32+
The deterministic host fixture covers the exact `.exe` process/module names,
33+
64-bit pointer traversal, a full-bound 20-byte English string without a NUL,
34+
a shorter NUL-terminated Spanish string, a rejected non-completion message,
35+
vanilla start, loading pause/resume order, two splits, and detach.

‎examples/tiberian_sun.split‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Port of TiberianSun.asl. ASL's extensionless state name is translated to
2+
// the current Windows host's executable name, and its string20 field is known
3+
// to contain localized ASCII/UTF-8 splash text.
4+
state "game.exe" {
5+
isPlaying: u8 at "game.exe", 0x3e48fc;
6+
mainMenuIndex: u8 at "game.exe", 0x408c4c;
7+
gameState: u8 at "game.exe", 0x3e224c;
8+
splashVisible: u8 at "game.exe", 0x34c5f4, 0x14;
9+
splashText at "game.exe", 0x34c5f4, 0x14 as utf8(20);
10+
}
11+
12+
isLoading {
13+
return current.isPlaying == 0
14+
}
15+
16+
start {
17+
return old.mainMenuIndex != 0
18+
&& current.mainMenuIndex == 0
19+
&& (old.gameState == 0 || old.gameState == 30)
20+
&& current.gameState == 255
21+
}
22+
23+
split {
24+
if old.splashVisible == 0 && current.splashVisible > 0 {
25+
let text = current.splashText.toAsciiLowerCase()
26+
let eighth = text.slice(7, 8) else return false
27+
if eighth == "c" {
28+
// Spanish `MISION CUMPLIDA` pattern.
29+
return true
30+
}
31+
if eighth != " " {
32+
return false
33+
}
34+
35+
// Common English, German, and French completion-text pattern.
36+
let twelfth = text.slice(11, 12) else return false
37+
return twelfth == "o"
38+
}
39+
}

‎src/bin/xtask.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,13 @@ const RUNTIME_FIXTURES: &[RuntimeFixture] = &[
457457
harness: "tests/string_predicates_runtime.mjs",
458458
extra_arguments: &[],
459459
},
460+
RuntimeFixture {
461+
source: "examples/tiberian_sun.split",
462+
output: "tiberian_sun.wasm",
463+
profile: "release",
464+
harness: "tests/tiberian_sun_runtime.mjs",
465+
extra_arguments: &[],
466+
},
460467
RuntimeFixture {
461468
source: "tests/while_loop.split",
462469
output: "while_loop.wasm",

‎tests/tiberian_sun_runtime.mjs‎

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import fs from "node:fs";
2+
3+
const wasmPath = process.argv[2];
4+
if (!wasmPath) {
5+
throw new Error("usage: node tests/tiberian_sun_runtime.mjs <tiberian-sun.wasm>");
6+
}
7+
8+
const moduleBase = 0x10000000n;
9+
const splashPointer = 0x20000000n;
10+
const decoder = new TextDecoder();
11+
const encoder = new TextEncoder();
12+
const processNames = [];
13+
const moduleNames = [];
14+
let instance;
15+
let processOpen = true;
16+
let timerState = 0;
17+
let isPlaying = 1;
18+
let mainMenuIndex = 1;
19+
let gameState = 0;
20+
let splashVisible = 0;
21+
let splashText = "";
22+
let starts = 0;
23+
let splits = 0;
24+
let pauses = 0;
25+
let resumes = 0;
26+
let detaches = 0;
27+
28+
const text = (pointer, length) => decoder.decode(
29+
new Uint8Array(instance.exports.memory.buffer, pointer, length),
30+
);
31+
32+
function writeSplash(destination) {
33+
const output = new Uint8Array(instance.exports.memory.buffer, destination, 20);
34+
output.fill(0);
35+
const encoded = encoder.encode(splashText);
36+
if (encoded.length > output.length) {
37+
throw new Error(`fixture splash text exceeds string20: ${splashText}`);
38+
}
39+
output.set(encoded);
40+
}
41+
42+
const env = {
43+
timer_get_state: () => timerState,
44+
timer_start() { starts += 1; timerState = 1; },
45+
timer_split() { splits += 1; },
46+
timer_reset() {},
47+
timer_set_game_time() {},
48+
timer_pause_game_time() { pauses += 1; },
49+
timer_resume_game_time() { resumes += 1; },
50+
timer_set_variable() {},
51+
runtime_set_tick_rate() {},
52+
runtime_print_message() {},
53+
process_attach(pointer, length) {
54+
const name = text(pointer, length);
55+
processNames.push(name);
56+
return name === "game.exe" ? 1n : 0n;
57+
},
58+
process_detach() { detaches += 1; },
59+
process_is_open: () => processOpen ? 1 : 0,
60+
process_get_module_address(_process, pointer, length) {
61+
const name = text(pointer, length);
62+
moduleNames.push(name);
63+
return name === "game.exe" ? moduleBase : 0n;
64+
},
65+
process_read(_process, address, destination, size) {
66+
const view = new DataView(instance.exports.memory.buffer);
67+
if (address === moduleBase + 0x3e48fcn && size === 1) {
68+
view.setUint8(destination, isPlaying);
69+
} else if (address === moduleBase + 0x408c4cn && size === 1) {
70+
view.setUint8(destination, mainMenuIndex);
71+
} else if (address === moduleBase + 0x3e224cn && size === 1) {
72+
view.setUint8(destination, gameState);
73+
} else if (address === moduleBase + 0x34c5f4n && size === 8) {
74+
view.setBigUint64(destination, splashPointer, true);
75+
} else if (address === splashPointer + 0x14n && size === 1) {
76+
view.setUint8(destination, splashVisible);
77+
} else if (address === splashPointer + 0x14n && size === 20) {
78+
writeSplash(destination);
79+
} else {
80+
throw new Error(`unexpected Tiberian Sun read ${address.toString(16)} (${size})`);
81+
}
82+
return 1;
83+
},
84+
user_settings_add_bool: () => 1,
85+
user_settings_add_title() {},
86+
user_settings_add_choice() {},
87+
user_settings_add_choice_option: () => 0,
88+
user_settings_add_file_select() {},
89+
user_settings_add_file_select_name_filter() {},
90+
user_settings_add_file_select_mime_filter() {},
91+
user_settings_set_tooltip() {},
92+
settings_map_load: () => 1n,
93+
settings_map_free() {},
94+
settings_map_get: () => 0n,
95+
setting_value_free() {},
96+
setting_value_get_bool: () => 0,
97+
setting_value_get_string: () => 0,
98+
};
99+
100+
({ instance } = await WebAssembly.instantiate(fs.readFileSync(wasmPath), { env }));
101+
instance.exports._start();
102+
instance.exports.update();
103+
104+
mainMenuIndex = 0;
105+
gameState = 255;
106+
instance.exports.update();
107+
if (starts !== 1) throw new Error(`start transition differed: ${starts}`);
108+
109+
isPlaying = 0;
110+
instance.exports.update();
111+
isPlaying = 1;
112+
instance.exports.update();
113+
114+
splashText = "MISSION ACCOMPLISHED";
115+
splashVisible = 1;
116+
instance.exports.update();
117+
splashVisible = 0;
118+
instance.exports.update();
119+
splashText = "MISION CUMPLIDA";
120+
splashVisible = 1;
121+
instance.exports.update();
122+
splashVisible = 0;
123+
instance.exports.update();
124+
splashText = "MISSION FAILED";
125+
splashVisible = 1;
126+
instance.exports.update();
127+
128+
if (splits !== 2 || pauses !== 1 || resumes !== 6) {
129+
throw new Error(`timer behavior differed: ${JSON.stringify({ splits, pauses, resumes })}`);
130+
}
131+
if (processNames.join(",") !== "game.exe" || moduleNames.some(name => name !== "game.exe")) {
132+
throw new Error(`process/module identity differed: ${JSON.stringify({ processNames, moduleNames })}`);
133+
}
134+
135+
processOpen = false;
136+
instance.exports.update();
137+
if (detaches !== 1) throw new Error(`detach behavior differed: ${detaches}`);
138+
139+
console.log(JSON.stringify({ starts, splits, pauses, resumes, detaches, moduleLookups: moduleNames.length }));

0 commit comments

Comments
 (0)