Skip to content

Commit fa9912a

Browse files
committed
Simplify state field filtering
1 parent 28f3448 commit fa9912a

27 files changed

Lines changed: 213 additions & 209 deletions

‎TODO.md‎

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,15 @@ General rules:
2727
- Remove completed work from this file during the next roadmap update and
2828
summarize the milestone in the archive.
2929

30-
## Now — gate lifecycle evaluation without rolling back state
31-
32-
- [ ] Add an explicit `shouldEvaluate` lifecycle block returning `bool` and
33-
defaulting to `true`. It runs after snapshot commit and `whileAttached`; a
34-
false result skips `isLoading`, `gameTime`, `reset`, `split`, and `start` for
35-
that tick without rolling back state or suppressing ordinary per-tick work.
36-
- [ ] Port a representative ASL script whose boolean `update` block returns
37-
false, and verify host-executed ordering for snapshot rotation,
38-
`whileAttached`, the evaluation gate, and timer decisions.
39-
- [ ] Document the distinction between state-read failure, per-field
40-
`normalize`, and `shouldEvaluate`, with focused migration diagnostics for an
41-
ASL `update { return false; }` pattern.
30+
## Now — unblock the next representative native port
31+
32+
- [ ] Select the next manually reviewed ASL port whose blocker is shared by
33+
multiple scripts, then implement the smallest ordinary language or
34+
source-defined standard-library feature that makes the port faithful.
35+
- [ ] Keep boolean ASL `update { return false; }` behavior as corpus evidence,
36+
but do not introduce a dedicated lifecycle keyword or block until a
37+
maintained port proves that state-field expressions and `whileAttached`
38+
cannot represent the behavior clearly.
4239

4340
## P0 — unblock the next representative native ports
4441

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -537,18 +537,18 @@ pub struct StateField {
537537
pub documentation: Option<String>,
538538
pub annotation: Option<TypeRef>,
539539
pub source: StateSource,
540-
pub normalizer: Option<StateNormalizer>,
540+
pub transform: Option<StateTransform>,
541541
pub span: Span,
542542
}
543543

544-
/// A pure transformation applied to one successfully read state-field value
545-
/// before the transactional snapshot is committed.
544+
/// An optional ordinary expression that selects the value committed for one
545+
/// successfully read state-field candidate.
546546
#[derive(Debug, Clone)]
547-
pub struct StateNormalizer {
547+
pub struct StateTransform {
548548
/// Implicit `value` binding containing the newly read candidate.
549549
pub value: ValueId,
550-
/// Implicit `previous` binding containing the last accepted value.
551-
pub previous: ValueId,
550+
/// Implicit `old` binding containing the last accepted value.
551+
pub old: ValueId,
552552
pub expression: Expr,
553553
pub span: Span,
554554
}

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

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -410,11 +410,8 @@ pub const CONCEPTS: &[MigrationConcept] = &[
410410
name: "MemoryWatcher",
411411
sources: ASL,
412412
support: MigrationSupport::TypedPattern,
413-
summary: "Declare polled memory in `state`; use per-field `normalize` when a transient candidate should retain its last accepted value.",
414-
targets: &[
415-
MigrationTarget::Language("state"),
416-
MigrationTarget::Language("normalize"),
417-
],
413+
summary: "Declare polled memory in `state`; use a trailing field `if` with `value` and `old` when a transient candidate should retain its last accepted value.",
414+
targets: &[MigrationTarget::Language("state")],
418415
cookbook_anchor: Some("retaining-the-last-accepted-field-value"),
419416
spellings: &[],
420417
},

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ use crate::{
1616
Parameter, PatternBinding, PatternId, PointerPath, Program, RecordDecl, RecordField,
1717
RecordFieldId, RecordId, ResultTypeDecl, ResultTypeId, SettingChoiceOption,
1818
SettingChoiceOptionId, SettingDecl, SettingExternalKey, SettingFileFilter, SettingKind,
19-
Span, StateDecl, StateField, StateLayoutDecl, StateMemoryDecoder, StateNormalizer,
20-
StateProviderRef, StateSource, Stmt, SuspensionMode, TypeNameId, TypeRef, UnaryOp, ValueId,
19+
Span, StateDecl, StateField, StateLayoutDecl, StateMemoryDecoder, StateProviderRef,
20+
StateSource, StateTransform, Stmt, SuspensionMode, TypeNameId, TypeRef, UnaryOp, ValueId,
2121
VariableDecl,
2222
},
2323
diagnostic::Diagnostic,

‎crates/splitscript-syntax/src/parser/declarations.rs‎

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ use super::{
66
Action, ActionKind, Diagnostic, EnumDecl, EnumId, EnumReference, EnumVariant, FunctionDecl,
77
FunctionId, Parameter, Parser, PointerPath, RecordDecl, RecordField, RecordId,
88
SettingChoiceOption, SettingDecl, SettingExternalKey, SettingFileFilter, SettingKind, Span,
9-
StateDecl, StateField, StateLayoutDecl, StateMemoryDecoder, StateNormalizer, StateProviderRef,
10-
StateSource, TokenKind, TypeRef,
9+
StateDecl, StateField, StateLayoutDecl, StateMemoryDecoder, StateProviderRef, StateSource,
10+
StateTransform, TokenKind, TypeRef,
1111
};
1212
use crate::{
1313
diagnostic::{DiagnosticFix, FixApplicability, TextEdit},
@@ -494,28 +494,30 @@ impl Parser<'_> {
494494
decoder,
495495
})
496496
};
497-
let normalizer = self.eat_ident("normalize").map(|start| {
498-
let value = self.new_value_id();
499-
let previous = self.new_value_id();
500-
let expression = self.root_expression();
501-
StateNormalizer {
502-
value,
503-
previous,
504-
span: Span {
505-
start: start.start,
506-
end: expression.span.end,
507-
},
508-
expression,
509-
}
510-
});
497+
let transform =
498+
(matches!(&source, StateSource::Pointer(_)) && self.at_ident("if")).then(|| {
499+
let start = self.current().span;
500+
let value = self.new_value_id();
501+
let old = self.new_value_id();
502+
let expression = self.root_expression();
503+
StateTransform {
504+
value,
505+
old,
506+
span: Span {
507+
start: start.start,
508+
end: expression.span.end,
509+
},
510+
expression,
511+
}
512+
});
511513
let end = self.previous().span.end;
512514
Ok(StateField {
513515
id: self.new_value_id(),
514516
name,
515517
documentation,
516518
annotation,
517519
source,
518-
normalizer,
520+
transform,
519521
span: Span {
520522
start: field_start.start,
521523
end,
@@ -998,7 +1000,7 @@ impl Parser<'_> {
9981000
offsets,
9991001
decoder: None,
10001002
}),
1001-
normalizer: None,
1003+
transform: None,
10021004
span: Span {
10031005
start: field_start.start,
10041006
end,

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,8 @@ pub fn walk_state_field<'ast, V: Visitor<'ast>>(visitor: &mut V, field: &'ast St
141141
if let StateSource::Expression(expression) = &field.source {
142142
visitor.visit_expr(expression);
143143
}
144-
if let Some(normalizer) = &field.normalizer {
145-
visitor.visit_expr(&normalizer.expression);
144+
if let Some(transform) = &field.transform {
145+
visitor.visit_expr(&transform.expression);
146146
}
147147
}
148148

@@ -477,8 +477,8 @@ pub fn walk_state_field_mut<F: Folder>(folder: &mut F, field: &mut StateField) {
477477
if let StateSource::Expression(expression) = &mut field.source {
478478
folder.fold_expr(expression);
479479
}
480-
if let Some(normalizer) = &mut field.normalizer {
481-
folder.fold_expr(&mut normalizer.expression);
480+
if let Some(transform) = &mut field.transform {
481+
folder.fold_expr(&mut transform.expression);
482482
}
483483
}
484484

‎docs/ASL_PORTING.md‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -144,33 +144,34 @@ the sentinel into a failed state read: a failed read rejects every field in the
144144
transaction, while the original script may still accept unrelated values from
145145
that tick.
146146

147-
Use a field normalizer instead:
147+
Use an ordinary trailing `if` on that pointer-path field instead:
148148

149149
```splitscript
150150
state "game.exe" {
151-
scene: i32 at "engine.dll", 0x1000 normalize if value == 7 || value == 8 {
152-
previous
151+
scene: i32 at "engine.dll", 0x1000 if value == 7 || value == 8 {
152+
old
153153
} else {
154154
value
155155
};
156156
entities: i32 at "engine.dll", 0x2000;
157157
}
158158
```
159159

160-
`value` is the successfully read candidate and `previous` is the last value
160+
`value` is the successfully read candidate and `old` is the last value
161161
accepted for that field. Both are read-only and have the field's inferred
162162
type. On the first successful poll after each attachment, both names contain
163163
the candidate, so no stale value leaks across processes. Each field is
164-
normalized independently and then the complete resulting snapshot commits
164+
filtered independently and then the complete resulting snapshot commits
165165
atomically.
166166

167167
The maintained
168168
[`examples/aawcb.split`](../examples/aawcb.split) port uses this to retain its
169169
scene during loading scenes 7 and 8 while the entity count continues to
170170
advance. By contrast, an ASL `update` block that returns `false` does not roll
171-
back state at all; it skips lifecycle decisions after the refresh. That pattern
172-
will map to the separate `shouldEvaluate` lifecycle gate rather than
173-
`normalize`.
171+
back state at all; it skips lifecycle decisions after the refresh. SplitScript
172+
does not add a separate lifecycle concept for that behavior until a maintained
173+
port demonstrates that ordinary field expressions and `whileAttached` cannot
174+
represent the required result clearly.
174175

175176
## Run-scoped one-shot splits
176177

‎docs/LANGUAGE.md‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,25 +60,27 @@ Only when every required field succeeds does it rotate `current` to `old` and
6060
commit the candidate as `current`. The fields form a WebAssembly GC struct, so
6161
the action code uses typed references rather than a linear-memory state layout.
6262

63-
A field can normalize its successful raw candidate before that atomic commit:
63+
A pointer-path field can use an ordinary trailing `if` expression to choose the
64+
value that is accepted before that atomic commit. Expression-backed fields
65+
already have an ordinary right-hand side and should put the `if` there instead:
6466

6567
```text
6668
state "game.exe" {
67-
scene: i32 at 0x1000 normalize if value == 7 || value == 8 {
68-
previous
69+
scene: i32 at 0x1000 if value == 7 || value == 8 {
70+
old
6971
} else {
7072
value
7173
};
7274
entities: i32 at 0x2000;
7375
}
7476
```
7577

76-
Inside `normalize`, the read-only `value` and `previous` bindings both have the
77-
field's inferred type. `value` is the raw candidate; `previous` is the last
78-
committed value for that field. On the first successful poll after attachment,
79-
both are the raw candidate. Normalization is per field, so retaining `scene`
80-
does not discard a new `entities` value from the same otherwise-valid tick.
81-
`current` and `old` stay read-only.
78+
Inside this field-local expression, the read-only `value` and `old` bindings
79+
both have the field's inferred type. `value` is the raw candidate; `old` is the
80+
last committed value for that field. On the first successful poll after
81+
attachment, both are the raw candidate. The expression applies to one field,
82+
so retaining `scene` does not discard a new `entities` value from the same
83+
otherwise-valid tick. Snapshot `current` and `old` values stay read-only.
8284

8385
Games with multiple supported memory layouts can name each layout inside one
8486
state declaration. Fields present in every layout with a compatible type form

‎docs/MIGRATION_CAPABILITIES.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ This index maps common source-language concepts to canonical SplitScript APIs an
1414
| `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). |
1515
| `asl.state.version-label` — Version-labelled state blocks | ASL | Use a typed pattern | Use named layouts in one state block and return the selected layout from `onAttach`. Canonical targets: `state`. [Recipe](ASL_PORTING.md#version-labelled-asl-states). |
1616
| `asl.memory.deep-pointer` — DeepPointer | ASL | Supported directly | Use typed state paths for polled fields or `process.follow` for discovered paths. Canonical targets: `state`, `Process.follow`. |
17-
| `asl.state.memory-watcher` — MemoryWatcher | ASL | Use a typed pattern | Declare polled memory in `state`; use per-field `normalize` when a transient candidate should retain its last accepted value. Canonical targets: `state`, `normalize`. [Recipe](ASL_PORTING.md#retaining-the-last-accepted-field-value). |
17+
| `asl.state.memory-watcher` — MemoryWatcher | ASL | Use a typed pattern | Declare polled memory in `state`; use a trailing field `if` with `value` and `old` when a transient candidate should retain its last accepted value. Canonical targets: `state`. [Recipe](ASL_PORTING.md#retaining-the-last-accepted-field-value). |
1818
| `asl.timer.on-start` — timer.OnStart | ASL | Use a typed pattern | Observe the `timer.state()` transition in `whileAttached` and reset run-scoped script state there. Canonical targets: `whileAttached`, `timer.state`. [Recipe](ASL_PORTING.md#run-scoped-one-shot-splits). |
1919
| `asl.lifecycle.exit` — exit game-time cleanup | ASL | Supported directly | Use guarded `onDetached` cleanup and `timer.pauseGameTime()`. Canonical targets: `onDetached`, `timer.pauseGameTime`. [Recipe](ASL_PORTING.md#process-exit-game-time-cleanup). |
2020
| `asl.settings.dynamic-lookup` — Dynamic settings lookup | ASL | Supported directly | Declare an exact string key with `key "..."`, then use `settings.enabled(key)` or `oldSettings.enabled(key)` for boolean settings. Choice and file settings remain statically typed. Canonical targets: `settings`, `oldSettings`. |

‎docs/ROADMAP_ARCHIVE.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
# SplitScript roadmap
22

3-
## 2026-08-03: immutable per-field state normalization
3+
## 2026-08-03: immutable per-field state filtering
44

55
- Audited AAWCB, Aragami, and the wider ASL corpus and separated three
66
previously conflated behaviors. Aragami is ordinary persistent derived state
77
already modeled by `whileAttached`; AAWCB requires retaining one field's
88
last accepted value while other fields advance; boolean ASL `update` results
99
are a later lifecycle-evaluation gate and do not roll state back.
10-
- Added the postfix `normalize expression` state-field clause. Its read-only
11-
`value` and `previous` bindings have the field's inferred type; the first
10+
- Added an ordinary trailing `if` expression to state fields. Its read-only
11+
`value` and `old` bindings have the field's inferred type; the first
1212
successful poll passes the raw candidate as both, and later polls use the
1313
last committed field value. `current` and `old` remain immutable and the
14-
complete normalized snapshot still commits atomically.
14+
complete filtered snapshot still commits atomically.
1515
- Added the faithful And All Would Cry Beware port. Its transient scenes 7 and
1616
8 are filtered without discarding entity-count changes from the same tick,
1717
eliminating the original mutation of `current.Scene`.

0 commit comments

Comments
 (0)