Skip to content

Commit a22da05

Browse files
committed
Add fallible numeric string parsing
Autosplitters commonly read numeric values as native text, but ports currently need ad hoc conversion logic. Add an inferred Numeric String.parse method with exact integer range checks, strict decimal float parsing, ordinary Result semantics, editor/catalog integration, migration guidance, and a host-executed boundary regression.
1 parent 1fc8041 commit a22da05

19 files changed

Lines changed: 838 additions & 3 deletions

‎TODO.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,8 @@ or behavioral parity.
157157
async polling. A compile-only port is never labelled faithful.
158158
- [ ] Fill the remaining corpus-proven `String` gaps through source-defined
159159
APIs where possible: byte/scalar inspection consistent with the
160-
language's byte-index policy, and fallible numeric parsing. ASCII case
161-
conversion is complete. Improve missing-member recovery so one unknown
160+
language's byte-index policy. ASCII case conversion and fallible numeric
161+
parsing are complete. Improve missing-member recovery so one unknown
162162
method does not create unrelated unknown-local cascades.
163163
- [ ] Add an explicit `f32`/`f64` bit-pattern construction form and expose the
164164
exact rounded bits for decimal literals in tooling. Decimal exponent syntax,

‎crates/splitscript-syntax/src/migration.rs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,16 @@ pub const CONCEPTS: &[MigrationConcept] = &[
449449
cookbook_anchor: Some("c-string-operations"),
450450
spellings: STRING_ASCII_LOWER_SPELLINGS,
451451
},
452+
MigrationConcept {
453+
id: MigrationConceptId::new("string.numeric-parse"),
454+
name: "Numeric string parsing",
455+
sources: CSHARP,
456+
support: MigrationSupport::Direct,
457+
summary: "Replace static Parse/TryParse calls and output parameters with fallible `text.parse()` and ordinary Result handling.",
458+
targets: &[MigrationTarget::StandardLibraryItem("String.parse")],
459+
cookbook_anchor: Some("c-string-operations"),
460+
spellings: &[],
461+
},
452462
MigrationConcept {
453463
id: MigrationConceptId::new("type.duration"),
454464
name: "Timer durations",

‎docs/ASL_PORTING.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,21 @@ The empty delimiter is an error rather than a request to split UTF-8 bytes.
105105
uses this mapping to parse names such as `01_02_1.dds`; its host fixture proves
106106
the resulting start, reset, split, and loading transitions.
107107

108+
C# `Int32.Parse`, `Double.Parse`, and their fixed-width relatives map to
109+
`text.parse()`. Put the SplitScript numeric type at the receiving boundary and
110+
let inference flow backward:
111+
112+
```splitscript
113+
let percentage: f64 = current.percentageText.parse() else 0.0
114+
```
115+
116+
Unlike an exception-catching `Parse` call, failure is an ordinary `Result`.
117+
Use `else` for a fallback, `?` to propagate the error from a function or state
118+
field, or `match` when failure needs its own behavior. C# `TryParse` therefore
119+
does not need an output parameter. Parsing consumes the complete ASCII decimal
120+
string and rejects whitespace, separators, trailing text, non-finite float
121+
names, and values outside the chosen numeric type.
122+
108123
## Version-labelled ASL states
109124

110125
The second argument in `state("game.exe", "Steam")` is a layout label, not

‎docs/LANGUAGE.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1388,6 +1388,7 @@ is involved:
13881388
| `equalsIgnoreAsciiCase(text)` | Equality folding only ASCII letters |
13891389
| `toAsciiLowerCase()` | Lowercase ASCII letters; preserve every other UTF-8 byte |
13901390
| `split(delimiter)` | Fallible exact split preserving leading, repeated, and trailing empty segments |
1391+
| `parse<T>()` | Strict fallible ASCII decimal parsing into an inferred numeric type |
13911392
| `slice(start, end)` | Fallible half-open UTF-8 byte range; offsets must be code-point boundaries |
13921393
| `replaceAll(search, replacement)` | Fallible exact non-overlapping replacement |
13931394
| `String.concat(values)` | Concatenate an array of strings |
@@ -1397,6 +1398,22 @@ only when at least one ASCII uppercase letter changes. It intentionally has an
13971398
ASCII-specific name: full Unicode lowercasing can change byte length and
13981399
requires a separate, explicitly specified API.
13991400

1401+
`text.parse<T>() -> T!` parses the complete string as a numeric value. The
1402+
target type is normally inferred from the assignment, return value, or
1403+
fallback, and can be written explicitly when needed:
1404+
1405+
```splitscript
1406+
let percentage: f64 = current.percentageText.parse() else 0.0
1407+
let lives = current.livesText.parse<u8>()?
1408+
```
1409+
1410+
Parsing accepts an optional ASCII sign and decimal digits. Floating-point
1411+
targets additionally accept a decimal point and `e`/`E` exponent. Whitespace,
1412+
digit separators, trailing text, `NaN`, and `Infinity` are rejected; integer
1413+
overflow and target-width floating-point overflow are errors. This strictness
1414+
keeps text read from game memory deterministic and makes malformed input use
1415+
ordinary `Result` handling rather than silently producing a partial value.
1416+
14001417
`process.readManagedString(address, maxLength)` reads a bounded IL2CPP managed
14011418
string, decodes UTF-16 (including surrogate pairs), and returns a GC UTF-8
14021419
`String!`. Memory-access failures are ordinary errors that can be handled with

‎docs/MIGRATION_CAPABILITIES.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ This index maps common source-language concepts to canonical SplitScript APIs an
1010
| `declaration.function` — Function declarations | C#, JavaScript | Supported directly | Functions and methods use the `fn` declaration keyword. Canonical targets: `fn`. |
1111
| `type.string` — String type | C# | Supported directly | The immutable UTF-8 string type is named `String`. Canonical targets: `String`. |
1212
| `string.ascii-lowercase` — ASCII string lowercasing | C# | Supported directly | Use `toAsciiLowerCase` when game identifiers require ASCII-only normalization; this is not culture-sensitive Unicode lowercasing. Canonical targets: `String.toAsciiLowerCase`. [Recipe](ASL_PORTING.md#c-string-operations). |
13+
| `string.numeric-parse` — Numeric string parsing | C# | Supported directly | Replace static Parse/TryParse calls and output parameters with fallible `text.parse()` and ordinary Result handling. Canonical targets: `String.parse`. [Recipe](ASL_PORTING.md#c-string-operations). |
1314
| `type.duration` — Timer durations | C# | Supported directly | Use `Duration` instead of C#'s `TimeSpan`. Canonical targets: `Duration`. |
1415
| `type.fixed-width-number` — Fixed-width numeric types | C# | Supported directly | Memory-facing numbers use explicit signedness and bit widths. Canonical targets: `i8`, `u8`, `i16`, `u16`, `i32`, `u32`, `i64`, `u64`, `f32`, `f64`. |
1516
| `asl.state.string-n` — Bounded native stringN state | ASL | Use a typed pattern | Use an explicitly decoded state path such as `as utf8(50)`; choose the encoding from evidence. Canonical targets: `state`. [Recipe](ASL_PORTING.md#bounded-native-stringn-state). |

‎docs/ROADMAP_ARCHIVE.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2453,3 +2453,14 @@ language catalog document the refinement rule.
24532453
The port reads the original bounded `Game.dll` level-picture field, parses
24542454
identifiers such as `01_02_1.dds`, and proves start, reset, split, loading,
24552455
attachment, and executable/module-name behavior.
2456+
2457+
# 2026-08-09: strict fallible numeric parsing
2458+
2459+
- Added `String.parse<T: Numeric>() -> T!`, with bidirectional target-type
2460+
inference and ordinary `Result` fallback, propagation, and matching.
2461+
- Integer parsing is exact across every signed and unsigned width (including
2462+
`address`); floating-point parsing supports decimal points and exponents and
2463+
rejects non-finite names and target-width overflow.
2464+
- Added compiler/editor catalog coverage, ASL/C# migration guidance, and a
2465+
host-executed regression for boundaries, malformed input, inference,
2466+
floating-point syntax, overflow, and underflow.

‎src/bin/xtask.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,13 @@ const RUNTIME_FIXTURES: &[RuntimeFixture] = &[
464464
harness: "tests/string_predicates_runtime.mjs",
465465
extra_arguments: &[],
466466
},
467+
RuntimeFixture {
468+
source: "tests/string_parsing.split",
469+
output: "string_parsing.wasm",
470+
profile: "release",
471+
harness: "tests/string_parsing_runtime.mjs",
472+
extra_arguments: &[],
473+
},
467474
RuntimeFixture {
468475
source: "examples/tiberian_sun.split",
469476
output: "tiberian_sun.wasm",

‎src/codegen/expression.rs‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2166,6 +2166,65 @@ fn compile_expr_unconverted(
21662166
context,
21672167
);
21682168
}
2169+
IntrinsicId::StringParse => {
2170+
let Type::Result(result) = ty else {
2171+
unreachable!("String.parse produces a Result value")
2172+
};
2173+
let parsed_type = result_value_type(result, context.semantics);
2174+
compile_receiver(function, target, context);
2175+
match parsed_type {
2176+
Type::I8
2177+
| Type::U8
2178+
| Type::I16
2179+
| Type::U16
2180+
| Type::I32
2181+
| Type::U32
2182+
| Type::I64
2183+
| Type::U64
2184+
| Type::Address => {
2185+
let (allow_negative, positive_limit, negative_limit) =
2186+
integer_parse_limits(parsed_type);
2187+
function
2188+
.instruction(&Instruction::I32Const(i32::from(allow_negative)))
2189+
.instruction(&Instruction::I64Const(positive_limit))
2190+
.instruction(&Instruction::I64Const(negative_limit))
2191+
.instruction(&Instruction::Call(
2192+
context
2193+
.runtime_helpers
2194+
.function(RuntimeHelperId::StringParseInteger),
2195+
));
2196+
if matches!(
2197+
parsed_type,
2198+
Type::I8 | Type::U8 | Type::I16 | Type::U16 | Type::I32 | Type::U32
2199+
) {
2200+
function.instruction(&Instruction::I32WrapI64);
2201+
emit_narrow_i32(function, parsed_type);
2202+
}
2203+
}
2204+
Type::F32 | Type::F64 => {
2205+
function
2206+
.instruction(&Instruction::I32Const(i32::from(
2207+
parsed_type == Type::F32,
2208+
)))
2209+
.instruction(&Instruction::Call(
2210+
context
2211+
.runtime_helpers
2212+
.function(RuntimeHelperId::StringParseFloat),
2213+
));
2214+
if parsed_type == Type::F32 {
2215+
function.instruction(&Instruction::F32DemoteF64);
2216+
}
2217+
}
2218+
_ => unreachable!("Numeric parsing requires a concrete numeric type"),
2219+
}
2220+
emit_status_result(
2221+
function,
2222+
expression,
2223+
parsed_type,
2224+
"string is not valid decimal text for the inferred numeric type",
2225+
context,
2226+
);
2227+
}
21692228
IntrinsicId::StringSlice => {
21702229
compile_receiver(function, target, context);
21712230
compile_expr(function, args[0], context);
@@ -2851,6 +2910,47 @@ fn emit_sentinel_result(
28512910
function.instruction(&Instruction::End);
28522911
}
28532912

2913+
/// Converts a helper's `(success, value)` multi-value result into `T!`. The
2914+
/// value is stored first, leaving the success flag on the operand stack.
2915+
fn emit_status_result(
2916+
function: &mut Function,
2917+
expression: ExprId,
2918+
value_type: Type,
2919+
message: &str,
2920+
context: &ExprContext<'_>,
2921+
) {
2922+
let Type::Result(result) = context.expression_type(expression) else {
2923+
unreachable!("status-bearing helpers produce Result values")
2924+
};
2925+
let value_local = context.matches.intrinsic_temps[&expression][0];
2926+
function
2927+
.instruction(&Instruction::LocalSet(value_local))
2928+
.instruction(&Instruction::I32Eqz)
2929+
.instruction(&Instruction::If(BlockType::Result(
2930+
context.gc.val_type(Type::Result(result)),
2931+
)));
2932+
emit_result_error(function, result, value_type, message, context.gc);
2933+
function
2934+
.instruction(&Instruction::Else)
2935+
.instruction(&Instruction::LocalGet(value_local));
2936+
emit_result_success(function, result, context.gc);
2937+
function.instruction(&Instruction::End);
2938+
}
2939+
2940+
fn integer_parse_limits(ty: Type) -> (bool, i64, i64) {
2941+
match ty {
2942+
Type::I8 => (true, i8::MAX as i64, 1_i64 << 7),
2943+
Type::U8 => (false, u8::MAX as i64, u8::MAX as i64),
2944+
Type::I16 => (true, i16::MAX as i64, 1_i64 << 15),
2945+
Type::U16 => (false, u16::MAX as i64, u16::MAX as i64),
2946+
Type::I32 => (true, i32::MAX as i64, 1_i64 << 31),
2947+
Type::U32 => (false, u32::MAX as i64, u32::MAX as i64),
2948+
Type::I64 => (true, i64::MAX, i64::MIN),
2949+
Type::U64 | Type::Address => (false, -1, -1),
2950+
_ => unreachable!("integer parse limits require an integer type"),
2951+
}
2952+
}
2953+
28542954
fn emit_cast(function: &mut Function, expression: ExprId, target: Type, context: &ExprContext<'_>) {
28552955
let source = context.expression_type(expression);
28562956
compile_expr(function, expression, context);

‎src/codegen/runtime_helper_registry.rs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ impl RuntimeHelperPlan {
4141
pub(super) enum HelperValueType {
4242
I32,
4343
I64,
44+
F64,
4445
String,
4546
Standard(StdlibTypeId),
4647
StringArray,
@@ -80,7 +81,7 @@ macro_rules! helper {
8081
};
8182
}
8283

83-
use HelperValueType::{I32, I64, I64Array, Standard, String as StringValue, StringArray};
84+
use HelperValueType::{F64, I32, I64, I64Array, Standard, String as StringValue, StringArray};
8485

8586
/// Canonical function-index and body order. Dependencies always occur before
8687
/// the helpers that call them, which is validated by the registry tests.
@@ -94,6 +95,8 @@ pub(super) const DESCRIPTORS: &[RuntimeHelperDescriptor] = &[
9495
helper!(StringToAsciiLowerCase, (StringValue) -> (StringValue), deps [], imports [], build_string_to_ascii_lower_case),
9596
helper!(StringReplaceAll, (StringValue, StringValue, StringValue) -> (StringValue), deps [StringFind], imports [], build_string_replace_all),
9697
helper!(StringSplit, (StringValue, StringValue) -> (StringArray), deps [StringFind], imports [], build_string_split),
98+
helper!(StringParseInteger, (StringValue, I32, I64, I64) -> (I32, I64), deps [], imports [], build_string_parse_integer),
99+
helper!(StringParseFloat, (StringValue, I32) -> (I32, F64), deps [], imports [], build_string_parse_float),
97100
helper!(StringSlice, (StringValue, I32, I32) -> (StringValue), deps [], imports [], build_string_slice),
98101
helper!(ScanProcessRange, (I64, I64, I64, I32, I32, I32) -> (I64), deps [], imports [ProcessRead], build_scan_process_range),
99102
helper!(ReadRelative32, (I64, I64) -> (I64), deps [], imports [ProcessRead], build_read_relative32),
@@ -216,6 +219,7 @@ pub(super) fn resolve_signature(
216219
let resolve = |ty| match ty {
217220
HelperValueType::I32 => ValType::I32,
218221
HelperValueType::I64 => ValType::I64,
222+
HelperValueType::F64 => ValType::F64,
219223
HelperValueType::String => gc.val_type(Type::Standard(StdlibTypeId::String)),
220224
HelperValueType::Standard(standard) => gc.val_type(Type::Standard(standard)),
221225
HelperValueType::StringArray | HelperValueType::I64Array => gc.val_type(Type::Array(
@@ -255,6 +259,7 @@ fn required_array_layout(
255259
HelperValueType::I64Array => Type::I64,
256260
HelperValueType::I32
257261
| HelperValueType::I64
262+
| HelperValueType::F64
258263
| HelperValueType::String
259264
| HelperValueType::Standard(_) => return None,
260265
};

‎src/codegen/runtime_helpers.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ pub(super) fn build_string_split(inputs: &RuntimeHelperInputs<'_>) -> Function {
101101
)
102102
}
103103

104+
pub(super) fn build_string_parse_integer(inputs: &RuntimeHelperInputs<'_>) -> Function {
105+
strings::compile_string_parse_integer(inputs.gc)
106+
}
107+
108+
pub(super) fn build_string_parse_float(inputs: &RuntimeHelperInputs<'_>) -> Function {
109+
strings::compile_string_parse_float(inputs.gc)
110+
}
111+
104112
pub(super) fn build_string_slice(inputs: &RuntimeHelperInputs<'_>) -> Function {
105113
strings::compile_string_slice(inputs.gc)
106114
}

0 commit comments

Comments
 (0)