From 41dd465903db5225f5fb72f3edffa5a14b26ec6a Mon Sep 17 00:00:00 2001 From: Teakowa Date: Sat, 26 Sep 2026 15:13:29 +0800 Subject: [PATCH 1/2] fix(compiler): apply OverPy's per-parameter False/True substitutions under optimizeForSize Replace the hand-written literal special cases with a table of OverPy 9.7.10's canReplace0ByFalse, canReplace1ByTrue, canReplace0ByNull and canReplaceNullVectorByNull flags, generated by tools/overpy/gen_literal_flags.cjs and applied to every call argument, array element and vector component. Replacement policy stays in opy-rs and no longer reads the catalog's broader acceptance coercions. Stop normalizing authored literals while lowering (0 to Null, False to 0, empty array to empty string), which only converged because the workshop-rs parser did the same. Unoptimized output now keeps the authored spelling, as the pinned reference does. Adopt workshop-rs 0.8.0, which preserves contextual literals. Fixes #388 --- Cargo.lock | 4 +- Cargo.toml | 2 +- crates/opy-rs/src/compiler/lowering.rs | 145 +----------------- crates/opy-rs/src/compiler/mod.rs | 2 +- .../opy-rs/src/compiler/size_optimization.rs | 126 ++++++++++----- .../size_optimization/literal_flags.rs | 98 ++++++++++++ crates/opy-rs/src/compiler/tests/builtins.rs | 7 +- .../src/compiler/tests/catalog_lowering.rs | 14 +- .../opy-rs/src/compiler/tests/control_flow.rs | 2 +- .../src/compiler/tests/indexed_assignments.rs | 8 +- .../src/compiler/tests/wait_lowering.rs | 8 +- docs/language-support/tooling-and-backend.md | 2 +- tools/overpy/gen_literal_flags.cjs | 86 +++++++++++ 13 files changed, 301 insertions(+), 203 deletions(-) create mode 100644 crates/opy-rs/src/compiler/size_optimization/literal_flags.rs create mode 100644 tools/overpy/gen_literal_flags.cjs diff --git a/Cargo.lock b/Cargo.lock index 004aecfb..70796059 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -631,9 +631,9 @@ dependencies = [ [[package]] name = "workshop-rs" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaa3944ecaca598ac3f1c7d31c5cf4aa4a6c649fc121a70e86c22642439f7a7" +checksum = "4ee5e37a14f5f59d7948eb87537494472509a3446f18cb55eebbdfca3b74425c" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 50512741..5ed477ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ libquickjs-ng-sys = "0.13.0" serde = "1" serde_json = "1" sha2 = "0.10" -workshop-rs = "=0.7.0" +workshop-rs = "=0.8.0" [workspace.lints.rust] unsafe_op_in_unsafe_fn = "deny" diff --git a/crates/opy-rs/src/compiler/lowering.rs b/crates/opy-rs/src/compiler/lowering.rs index 70d87355..03bee601 100644 --- a/crates/opy-rs/src/compiler/lowering.rs +++ b/crates/opy-rs/src/compiler/lowering.rs @@ -3783,70 +3783,6 @@ impl<'a> Lowering<'a> { self.normalize_contextual_arguments(call_id, values) } - fn contextual_coercions(&self, call_id: &str, arg_index: usize) -> Option { - [Kind::Action, Kind::Value].into_iter().find_map(|kind| { - self.compiler - .catalog - .entry(kind, call_id) - .and_then(|entry| entry.param_coercions(arg_index)) - .copied() - }) - } - - fn normalize_value_with_coercions( - &mut self, - coercions: ParamCoercions, - value_id: ValueId, - ) -> ValueId { - let Some(node) = self.values.get(value_id) else { - return value_id; - }; - let replacement = match node { - Value::Bool(false) if coercions.false_as_number => Some(Value::Number(0.0)), - Value::Bool(true) if coercions.true_as_number => Some(Value::Number(1.0)), - Value::Number(value) if coercions.zero_as_null && *value == 0.0 => Some(Value::Null), - Value::Vector { x, y, z } - if coercions.null_vector_as_null - && self.value_is_number(*x, 0.0) - && self.value_is_number(*y, 0.0) - && self.value_is_number(*z, 0.0) => - { - Some(Value::Null) - } - Value::Call { name, args } - if coercions.null_vector_as_null - && name == "vector" - && args.len() == 3 - && args.iter().all(|value| self.value_is_number(*value, 0.0)) => - { - Some(Value::Null) - } - Value::Call { name, args } - if coercions.empty_array_as_string && name == "emptyArray" && args.is_empty() => - { - Some(Value::String(String::new())) - } - Value::Call { name, args } - if coercions.empty_array_as_string - && name == "customString" - && args.len() == 1 - && self.value_is_empty_string(args[0]) => - { - Some(Value::String(String::new())) - } - Value::Array(elements) if coercions.empty_array_as_string && elements.is_empty() => { - Some(Value::String(String::new())) - } - _ => None, - }; - let Some(value) = replacement else { - return value_id; - }; - let coerced = self.push_value(value); - self.authored_values.insert(coerced, value_id); - coerced - } - fn normalize_contextual_argument( &mut self, call_id: &str, @@ -3872,58 +3808,7 @@ impl<'a> Lowering<'a> { }); } } - let Some(coercions) = self.contextual_coercions(call_id, arg_index) else { - return value_id; - }; - self.normalize_value_with_coercions(coercions, value_id) - } - - #[allow(unreachable_patterns)] - fn normalize_modify_value(&mut self, op: ModifyOp, value_id: ValueId) -> ValueId { - let coercions = match op { - ModifyOp::Add - | ModifyOp::Subtract - | ModifyOp::Modulo - | ModifyOp::Min - | ModifyOp::Max - | ModifyOp::RemoveFromArrayByIndex => ParamCoercions { - false_as_number: true, - true_as_number: true, - ..Default::default() - }, - ModifyOp::AppendToArray | ModifyOp::RemoveFromArrayByValue => ParamCoercions { - zero_as_null: true, - ..Default::default() - }, - ModifyOp::Multiply | ModifyOp::Divide | ModifyOp::RaiseToPower => { - return value_id; - } - _ => return value_id, - }; - self.normalize_value_with_coercions(coercions, value_id) - } - - fn modify_op_from_value(&self, value_id: ValueId) -> Option { - let Value::Call { name, args } = self.values.get(value_id)? else { - return None; - }; - if !args.is_empty() { - return None; - } - match name.as_str() { - "add" => Some(ModifyOp::Add), - "subtract" => Some(ModifyOp::Subtract), - "multiply" => Some(ModifyOp::Multiply), - "divide" => Some(ModifyOp::Divide), - "modulo" => Some(ModifyOp::Modulo), - "min" => Some(ModifyOp::Min), - "max" => Some(ModifyOp::Max), - "raiseToPower" => Some(ModifyOp::RaiseToPower), - "appendToArray" => Some(ModifyOp::AppendToArray), - "removeFromArray" | "removeFromArrayByValue" => Some(ModifyOp::RemoveFromArrayByValue), - "removeFromArrayByIndex" => Some(ModifyOp::RemoveFromArrayByIndex), - _ => None, - } + value_id } fn normalize_contextual_arguments( @@ -3934,18 +3819,6 @@ impl<'a> Lowering<'a> { let mut index = 0; while index < args.len() { args[index] = self.normalize_contextual_argument(call_id, index, args[index]); - if matches!( - call_id, - "modifyGlobalVariableAtIndex" | "modifyPlayerVariableAtIndex" - ) && index == 3 - { - if let Some(op) = args - .get(2) - .and_then(|value_id| self.modify_op_from_value(*value_id)) - { - args[index] = self.normalize_modify_value(op, args[index]); - } - } index += 1; } args @@ -4141,7 +4014,6 @@ impl<'a> Lowering<'a> { let index = self.lower_value(indices[0])?; if indices.len() == 1 { let op = ModifyOp::RemoveFromArrayByIndex; - let index = self.normalize_modify_value(op, index); return Ok(if action_name == "modifyGlobalVariableAtIndex" { let variable = match self.values.get(root_value) { Some(Value::GlobalVariable(variable)) => variable.clone(), @@ -4255,7 +4127,7 @@ impl<'a> Lowering<'a> { if left_name == name { if let Some(modify_op) = modify_op_from_str(op) { let right = self.lower_value(right)?; - let val = self.normalize_modify_value(modify_op, right); + let val = right; return Ok(self.push_action(Action::ModifyGlobalVariable { variable: self.global_names[variable].clone(), op: modify_op, @@ -4294,7 +4166,7 @@ impl<'a> Lowering<'a> { if left_name == name && left_player.as_ref() == player.as_ref() { if let Some(modify_op) = modify_op_from_str(op) { let right = self.lower_value(right)?; - let val = self.normalize_modify_value(modify_op, right); + let val = right; return Ok(self.push_action(Action::ModifyPlayerVariable { player: player_val, variable: self.player_names[variable].clone(), @@ -4340,13 +4212,13 @@ impl<'a> Lowering<'a> { { if left_arr.as_ref() == array.as_ref() && left_idx.as_ref() == index.as_ref() + && modify_op_from_str(op).is_some() { - if let Some(modify_op) = modify_op_from_str(op) { let op_id = modify_catalog_name_from_str(op) .expect("known modify operator has a catalog name"); let op_node = self.push_call(op_id, Vec::new()); let right = self.lower_value(right)?; - let right_val = self.normalize_modify_value(modify_op, right); + let right_val = right; let args = self.normalize_contextual_arguments( "modifyGlobalVariableAtIndex", vec![var_node, index_val, op_node, right_val], @@ -4355,7 +4227,6 @@ impl<'a> Lowering<'a> { "modifyGlobalVariableAtIndex", &args, )); - } } } } @@ -4393,13 +4264,13 @@ impl<'a> Lowering<'a> { { if left_arr.as_ref() == array.as_ref() && left_idx.as_ref() == index.as_ref() + && modify_op_from_str(op).is_some() { - if let Some(modify_op) = modify_op_from_str(op) { let op_id = modify_catalog_name_from_str(op) .expect("known modify operator has a catalog name"); let op_node = self.push_call(op_id, Vec::new()); let right = self.lower_value(right)?; - let right_val = self.normalize_modify_value(modify_op, right); + let right_val = right; let args = self.normalize_contextual_arguments( "modifyPlayerVariableAtIndex", vec![var_node, index_val, op_node, right_val], @@ -4412,7 +4283,6 @@ impl<'a> Lowering<'a> { "modifyPlayerVariableAtIndex", &args, )); - } } } } @@ -5021,7 +4891,6 @@ impl<'a> Lowering<'a> { }; let value_span = value.span().copied(); let value = self.lower_value(value)?; - let value = self.normalize_modify_value(op, value); return match receiver { Expr::GlobalVar { name, diff --git a/crates/opy-rs/src/compiler/mod.rs b/crates/opy-rs/src/compiler/mod.rs index 8c28f363..4c875c8d 100644 --- a/crates/opy-rs/src/compiler/mod.rs +++ b/crates/opy-rs/src/compiler/mod.rs @@ -11,7 +11,7 @@ use crate::hir::{self, Expr, RuleEntry, Span as HirSpan, Stmt, SwitchArm, defaul use crate::manifest::{FunctionKind, Manifest}; use serde::Serialize; use workshop_rs::Program; -use workshop_rs::catalog::{Catalog, CatalogIdentity, Kind, Locale, ParamCoercions}; +use workshop_rs::catalog::{Catalog, CatalogIdentity, Kind, Locale}; use workshop_rs::program::MappedText; use workshop_rs::program::SourceMap; diff --git a/crates/opy-rs/src/compiler/size_optimization.rs b/crates/opy-rs/src/compiler/size_optimization.rs index 3c5033e5..d19a61fa 100644 --- a/crates/opy-rs/src/compiler/size_optimization.rs +++ b/crates/opy-rs/src/compiler/size_optimization.rs @@ -4,9 +4,14 @@ use workshop_rs::catalog::{Kind, ParamCoercions}; use workshop_rs::{Action, ModifyOp, Value}; +use self::literal_flags::{ + LITERAL_FLAGS, NULL_VECTOR_BY_NULL, ONE_BY_TRUE, ZERO_BY_FALSE, ZERO_BY_NULL, +}; use super::Compiler; use super::operator_optimization::falsy; +mod literal_flags; + pub(super) struct SizeOptimizer<'a> { compiler: &'a Compiler, } @@ -23,15 +28,19 @@ impl<'a> SizeOptimizer<'a> { } Action::ModifyGlobalVariable { op, value, .. } | Action::ModifyPlayerVariable { op, value, .. } => self.modified(*op, value), - Action::ForGlobalVariable { step, .. } | Action::ForPlayerVariable { step, .. } => { - if matches!(step, Value::Number(one) if *one == 1.0) { - *step = Value::Bool(true); + Action::ForGlobalVariable { stop, step, .. } + | Action::ForPlayerVariable { stop, step, .. } => { + for bound in [stop, step] { + match bound { + Value::Number(zero) if *zero == 0.0 => *bound = Value::Bool(false), + Value::Number(one) if *one == 1.0 => *bound = Value::Bool(true), + _ => {} + } } } Action::Call { name, args } => { self.indexed_variable_call(name, args); self.chase_call(name, args); - Self::throttle_call(name, args); Self::hud_text_call(name, args); Self::beam_call(name, args); Self::progress_bar_call(name, args); @@ -160,7 +169,6 @@ impl<'a> SizeOptimizer<'a> { /// Chase destinations and rates spell `0` and `1` as `False` and `True`. fn chase_call(&self, name: &str, args: &mut [Value]) { let positions: &[usize] = match name { - "chaseAtRate" | "chaseOverTime" => &[1, 2], "chasePlayerVariableAtRate" | "chasePlayerVariableOverTime" => &[2, 3], _ => return, }; @@ -175,20 +183,6 @@ impl<'a> SizeOptimizer<'a> { } } - /// Throttle limits spell `0` and `1` as `False` and `True`. - fn throttle_call(name: &str, args: &mut [Value]) { - if name != "startForcingThrottle" { - return; - } - for arg in args.iter_mut().skip(1).take(6) { - match arg { - Value::Number(number) if *number == 0.0 => *arg = Value::Bool(false), - Value::Number(number) if *number == 1.0 => *arg = Value::Bool(true), - _ => {} - } - } - } - fn call_arguments(&self, kind: Kind, name: &str, args: &mut [Value]) { let entry = self.compiler.catalog.entry(kind, name); for (index, arg) in args.iter_mut().enumerate() { @@ -198,15 +192,36 @@ impl<'a> SizeOptimizer<'a> { .unwrap_or_default(); // The reference also writes an empty separator as an empty array. coercions.empty_array_as_string |= (name, index) == ("stringSplit", 1); - self.argument(coercions, arg); + self.argument(name, index, coercions, arg); } } - fn argument(&self, coercions: ParamCoercions, value: &mut Value) { - if is_empty_string(value) { + /// Where OverPy replaces a literal is a per-parameter fact of its own + /// argument tables, not the catalog's broader acceptance coercions. + fn argument(&self, name: &str, index: usize, coercions: ParamCoercions, value: &mut Value) { + // OverPy reads every element of an array, and every substitution of a + // custom string, from the parameter of its first repeated position. + let index = match name { + "array" => 0, + "customString" => 1, + _ => index, + }; + let flags = LITERAL_FLAGS + .iter() + .find(|(candidate, position, _)| *candidate == name && *position == index) + .map_or(0, |(_, _, flags)| *flags); + if let Value::Number(number) = value { + if *number == 0.0 && flags & ZERO_BY_FALSE != 0 { + *value = Value::Bool(false); + } else if *number == 0.0 && flags & ZERO_BY_NULL != 0 { + *value = Value::Null; + } else if *number == 1.0 && flags & ONE_BY_TRUE != 0 { + *value = Value::Bool(true); + } + } else if is_empty_string(value) { *value = self.empty_string(coercions.empty_array_as_string); } else if is_zero_vector(value) { - *value = if coercions.null_vector_as_null { + *value = if flags & NULL_VECTOR_BY_NULL != 0 { Value::Null } else { self.zero_vector_sum() @@ -243,11 +258,13 @@ impl<'a> SizeOptimizer<'a> { fn nested(&self, value: &mut Value) { match value { Value::Call { name, args } if name == "vector" && args.len() == 3 => { - for arg in args.iter_mut() { - self.nested(arg); - } if let Some(form) = compact_vector(&args[0], &args[1], &args[2]) { *value = form; + return self.nested(value); + } + self.call_arguments(Kind::Value, "vector", args); + for arg in args.iter_mut() { + self.nested(arg); } } Value::Call { name, args } => { @@ -266,16 +283,18 @@ impl<'a> SizeOptimizer<'a> { .copied() .unwrap_or_default(); for element in elements { - self.argument(coercions, element); + self.argument("array", 0, coercions, element); self.nested(element); } } Value::Vector { x, y, z } => { - self.nested(x); - self.nested(y); - self.nested(z); if let Some(form) = compact_vector(x, y, z) { *value = form; + return self.nested(value); + } + for (index, component) in [&mut **x, &mut **y, &mut **z].into_iter().enumerate() { + self.argument("vector", index, ParamCoercions::default(), component); + self.nested(component); } } Value::PlayerVariable { player, .. } => self.nested(player), @@ -394,18 +413,20 @@ fn is_zero_vector(value: &Value) -> bool { } fn modify_op_of(value: &Value) -> Option { - let Value::Enum { value, .. } = value else { - return None; + let operation = match value { + Value::Enum { value, .. } => value.as_str(), + Value::Call { name, args } if args.is_empty() => name.as_str(), + _ => return None, }; - Some(match value.as_str() { - "ADD" => ModifyOp::Add, - "SUBTRACT" => ModifyOp::Subtract, - "MODULO" => ModifyOp::Modulo, - "MAX" => ModifyOp::Max, - "MIN" => ModifyOp::Min, - "REMOVE_FROM_ARRAY_BY_INDEX" => ModifyOp::RemoveFromArrayByIndex, - "APPEND_TO_ARRAY" => ModifyOp::AppendToArray, - "REMOVE_FROM_ARRAY_BY_VALUE" => ModifyOp::RemoveFromArrayByValue, + Some(match operation { + "ADD" | "add" => ModifyOp::Add, + "SUBTRACT" | "subtract" => ModifyOp::Subtract, + "MODULO" | "modulo" => ModifyOp::Modulo, + "MAX" | "max" => ModifyOp::Max, + "MIN" | "min" => ModifyOp::Min, + "REMOVE_FROM_ARRAY_BY_INDEX" | "removeFromArrayByIndex" => ModifyOp::RemoveFromArrayByIndex, + "APPEND_TO_ARRAY" | "appendToArray" => ModifyOp::AppendToArray, + "REMOVE_FROM_ARRAY_BY_VALUE" | "removeFromArrayByValue" => ModifyOp::RemoveFromArrayByValue, _ => return None, }) } @@ -434,3 +455,26 @@ pub(super) fn action_values(action: &mut Action) -> Vec<&mut Value> { _ => Vec::new(), } } + +#[cfg(test)] +mod tests { + use super::literal_flags::LITERAL_FLAGS; + use workshop_rs::catalog::{Catalog, Kind}; + + /// A name that is not a catalog entry is a table row that can never apply. + #[test] + fn literal_flag_names_resolve_to_catalog_entries() { + let catalog = Catalog::builtin().unwrap(); + let unresolved: Vec<&str> = LITERAL_FLAGS + .iter() + .map(|(name, _, _)| *name) + .filter(|name| { + *name != "array" + && [Kind::Action, Kind::Value] + .into_iter() + .all(|kind| catalog.entry(kind, name).is_none()) + }) + .collect(); + assert_eq!(unresolved, Vec::<&str>::new()); + } +} diff --git a/crates/opy-rs/src/compiler/size_optimization/literal_flags.rs b/crates/opy-rs/src/compiler/size_optimization/literal_flags.rs new file mode 100644 index 00000000..d31c3698 --- /dev/null +++ b/crates/opy-rs/src/compiler/size_optimization/literal_flags.rs @@ -0,0 +1,98 @@ +//! Generated by `tools/overpy/gen_literal_flags.cjs` from OverPy 9.7.10. Do not edit. + +pub(super) const ZERO_BY_FALSE: u8 = 1; +pub(super) const ONE_BY_TRUE: u8 = 2; +pub(super) const ZERO_BY_NULL: u8 = 4; +pub(super) const NULL_VECTOR_BY_NULL: u8 = 8; + +/// Argument positions where OverPy replaces a literal under `#!optimizeForSize`. +#[rustfmt::skip] +pub(super) const LITERAL_FLAGS: &[(&str, usize, u8)] = &[ + ("add", 0, ONE_BY_TRUE), + ("add", 1, ONE_BY_TRUE), + ("addToScore", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("addToTeamScore", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("appendToArray", 1, ZERO_BY_NULL), + ("array", 0, ZERO_BY_NULL), + ("arrayContains", 1, ZERO_BY_NULL), + ("attachTo", 2, NULL_VECTOR_BY_NULL), + ("bigMessage", 1, ZERO_BY_NULL), + ("charAt", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("chaseAtRate", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("chaseAtRate", 2, ZERO_BY_FALSE | ONE_BY_TRUE), + ("chaseOverTime", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("chaseOverTime", 2, ZERO_BY_FALSE | ONE_BY_TRUE), + ("createDummyBot", 2, ZERO_BY_FALSE | ONE_BY_TRUE), + ("createDummyBot", 3, NULL_VECTOR_BY_NULL), + ("createDummyBot", 4, NULL_VECTOR_BY_NULL), + ("createEffect", 4, ZERO_BY_FALSE | ONE_BY_TRUE), + ("createHudText", 5, ZERO_BY_FALSE | ONE_BY_TRUE), + ("createInWorldText", 1, ZERO_BY_NULL), + ("createInWorldText", 2, NULL_VECTOR_BY_NULL), + ("createInWorldText", 3, ZERO_BY_FALSE | ONE_BY_TRUE), + ("customString", 1, ZERO_BY_NULL), + ("customString", 2, ZERO_BY_NULL), + ("customString", 3, ZERO_BY_NULL), + ("destroyDummy", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("divide", 0, ONE_BY_TRUE), + ("getAmmo", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("getMaxAmmo", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("getObjectivePosition", 0, ZERO_BY_FALSE | ONE_BY_TRUE), + ("getPlayersInSlot", 0, ZERO_BY_FALSE | ONE_BY_TRUE), + ("ifThenElse", 1, ZERO_BY_NULL), + ("ifThenElse", 2, ZERO_BY_NULL), + ("indexOfArrayValue", 1, ZERO_BY_NULL), + ("isObjectiveComplete", 0, ZERO_BY_FALSE | ONE_BY_TRUE), + ("max", 0, ZERO_BY_FALSE | ONE_BY_TRUE), + ("max", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("min", 0, ZERO_BY_FALSE | ONE_BY_TRUE), + ("min", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("modulo", 0, ONE_BY_TRUE), + ("modulo", 1, ONE_BY_TRUE), + ("randomInteger", 0, ZERO_BY_FALSE | ONE_BY_TRUE), + ("randomInteger", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("removeFromArray", 1, ZERO_BY_NULL), + ("setAbilityCharge", 2, ZERO_BY_FALSE | ONE_BY_TRUE), + ("setAbilityCooldown", 2, ZERO_BY_FALSE | ONE_BY_TRUE), + ("setAbilityResource", 2, ZERO_BY_FALSE | ONE_BY_TRUE), + ("setAmmo", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("setAmmo", 2, ZERO_BY_FALSE | ONE_BY_TRUE), + ("setGravity", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("setMatchTime", 0, ZERO_BY_FALSE | ONE_BY_TRUE), + ("setMaxAmmo", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("setMaxAmmo", 2, ZERO_BY_FALSE | ONE_BY_TRUE), + ("setMoveSpeed", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("setObjectiveDescription", 1, ZERO_BY_NULL), + ("setProjectileGravity", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("setProjectileSpeed", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("setRespawnTime", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("setScore", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("setStatusEffect", 3, ZERO_BY_FALSE | ONE_BY_TRUE), + ("setTeamScore", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("setUltCharge", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("setWeapon", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("skip", 0, ZERO_BY_FALSE | ONE_BY_TRUE), + ("skipIf", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("slice", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("slice", 2, ZERO_BY_FALSE | ONE_BY_TRUE), + ("smallMessage", 1, ZERO_BY_NULL), + ("startForcingPosition", 1, ZERO_BY_NULL), + ("startForcingSpawn", 1, ZERO_BY_FALSE), + ("startForcingThrottle", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("startForcingThrottle", 2, ZERO_BY_FALSE | ONE_BY_TRUE), + ("startForcingThrottle", 3, ZERO_BY_FALSE | ONE_BY_TRUE), + ("startForcingThrottle", 4, ZERO_BY_FALSE | ONE_BY_TRUE), + ("startForcingThrottle", 5, ZERO_BY_FALSE | ONE_BY_TRUE), + ("startForcingThrottle", 6, ZERO_BY_FALSE | ONE_BY_TRUE), + ("startModifyingVoicelinePitch", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("startScalingBarriers", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("startScalingSize", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("subtract", 0, ZERO_BY_FALSE | ONE_BY_TRUE), + ("subtract", 1, ONE_BY_TRUE), + ("teleport", 1, ZERO_BY_NULL), + ("valueInArray", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("vector", 0, ZERO_BY_FALSE | ONE_BY_TRUE), + ("vector", 1, ZERO_BY_FALSE | ONE_BY_TRUE), + ("vector", 2, ZERO_BY_FALSE | ONE_BY_TRUE), + ("wait", 0, ZERO_BY_FALSE | ONE_BY_TRUE), +]; diff --git a/crates/opy-rs/src/compiler/tests/builtins.rs b/crates/opy-rs/src/compiler/tests/builtins.rs index 4d11f829..b622193f 100644 --- a/crates/opy-rs/src/compiler/tests/builtins.rs +++ b/crates/opy-rs/src/compiler/tests/builtins.rs @@ -383,8 +383,11 @@ fn create_dummy_uses_the_reference_facing_default() { .expect("released Workshop contract must load") .compile_hir(&hir) .expect("createDummy's facing default must lower"); - assert!(artifact.emitted.contains("Create Dummy Bot")); - assert!(artifact.emitted.contains("Null")); + assert!( + artifact + .emitted + .contains("-1, Vector(0, 0, 0), Vector(0, 0, 0));") + ); } #[test] diff --git a/crates/opy-rs/src/compiler/tests/catalog_lowering.rs b/crates/opy-rs/src/compiler/tests/catalog_lowering.rs index 9186151f..45888a78 100644 --- a/crates/opy-rs/src/compiler/tests/catalog_lowering.rs +++ b/crates/opy-rs/src/compiler/tests/catalog_lowering.rs @@ -118,19 +118,17 @@ fn bastion_condition_folding_matches_pinned_reference_contract() { assert!( artifact .emitted - .contains("Set Global Variable At Index(values, 1, Null);") + .contains("Set Global Variable At Index(values, True, Null);") ); assert!( artifact .emitted - .contains("Set Status(Event Player, Null, Invincible, 0);") + .contains("Set Status(Event Player, Null, Invincible, False);") ); - assert!( - artifact - .emitted - .contains("Create Effect(Null, Ring, Color(Red), Vector(0, 1, 0), 0, Visible To);") - ); - assert!(artifact.emitted.contains("Wait(0, Ignore Condition);")); + assert!(artifact.emitted.contains( + "Create Effect(Null, Ring, Color(Red), Vector(False, True, False), False, Visible To);" + )); + assert!(artifact.emitted.contains("Wait(False, Ignore Condition);")); assert!( artifact .emitted diff --git a/crates/opy-rs/src/compiler/tests/control_flow.rs b/crates/opy-rs/src/compiler/tests/control_flow.rs index aa93c406..19e73264 100644 --- a/crates/opy-rs/src/compiler/tests/control_flow.rs +++ b/crates/opy-rs/src/compiler/tests/control_flow.rs @@ -128,7 +128,7 @@ fn aggressive_size_optimization_lowers_a_tail_comparison_to_skip_if() { assert!( artifact .emitted - .contains("Skip If(Not(Compare(Global.g, ==, 1)), 1);") + .contains("Skip If(Not(Compare(Global.g, ==, 1)), True);") ); assert!(!artifact.emitted.contains("If(Compare(Global.g, ==, 1));")); } diff --git a/crates/opy-rs/src/compiler/tests/indexed_assignments.rs b/crates/opy-rs/src/compiler/tests/indexed_assignments.rs index d6b12698..9bcb856f 100644 --- a/crates/opy-rs/src/compiler/tests/indexed_assignments.rs +++ b/crates/opy-rs/src/compiler/tests/indexed_assignments.rs @@ -85,7 +85,7 @@ rule "delete nested player value": } #[test] -fn indexed_assignment_indices_use_catalog_numeric_coercions() { +fn indexed_assignment_indices_keep_the_authored_boolean_spelling() { let source = r#" globalvar values = [0] globalvar nested = [[0]] @@ -101,17 +101,17 @@ rule "boolean indices": assert!( artifact .emitted - .contains("Set Global Variable At Index(values, 1, 1);") + .contains("Set Global Variable At Index(values, True, 1);") ); assert!( artifact .emitted - .contains("Modify Global Variable At Index(values, 0, Add, 1);") + .contains("Modify Global Variable At Index(values, False, Add, 1);") ); assert!( artifact .emitted - .contains("Set Global Variable At Index(nested, 1,") + .contains("Set Global Variable At Index(nested, True,") ); } diff --git a/crates/opy-rs/src/compiler/tests/wait_lowering.rs b/crates/opy-rs/src/compiler/tests/wait_lowering.rs index 61aea486..d932acf5 100644 --- a/crates/opy-rs/src/compiler/tests/wait_lowering.rs +++ b/crates/opy-rs/src/compiler/tests/wait_lowering.rs @@ -37,12 +37,12 @@ fn optimized_wait_forms_match_the_pinned_oracle() { assert_eq!( artifact .emitted - .matches("Wait(0, Ignore Condition);") + .matches("Wait(False, Ignore Condition);") .count(), 2 ); - assert!(artifact.emitted.contains("Wait(1, Ignore Condition);")); - assert!(artifact.emitted.contains("Wait(1, Abort When False);")); + assert!(artifact.emitted.contains("Wait(True, Ignore Condition);")); + assert!(artifact.emitted.contains("Wait(True, Abort When False);")); } #[test] @@ -97,7 +97,7 @@ fn included_size_optimization_applies_to_following_rules() { assert_eq!( artifact .emitted - .matches("Wait(0, Ignore Condition);") + .matches("Wait(False, Ignore Condition);") .count(), 2 ); diff --git a/docs/language-support/tooling-and-backend.md b/docs/language-support/tooling-and-backend.md index 88f019d5..cd84a34f 100644 --- a/docs/language-support/tooling-and-backend.md +++ b/docs/language-support/tooling-and-backend.md @@ -8,7 +8,7 @@ | `#!allowMacroRedeclaration` | ✅ Supported | Duplicate-definition policy is explicit in preprocessing state. | | `#!mainFile`, `#!include`, `#!excludeVariablesInCompilation` | ✅ Supported | File selection, include closure and output filtering are separate operations. | | `#!rulePrefix` and `#!rulePrefixTemplate` | ✅ Supported | Rule names are transformed before lowering. | -| Optimization controls such as `#!enableOptimizations`, `#!disableOptimizations`, `#!optimizeForSize`, `#!optimizeForSizeAggressive` and `#!optimizeStrict` | ✅ Supported | Optimization state is scoped to source spans; size, aggressive skip, strict-folding and wait-duration effects are lowered. The emitted structure converges on upstream; the internal optimizer implementation is not a contract. | +| Optimization controls such as `#!enableOptimizations`, `#!disableOptimizations`, `#!optimizeForSize`, `#!optimizeForSizeAggressive` and `#!optimizeStrict` | ✅ Supported | Optimization state is scoped to source spans; size, aggressive skip, strict-folding and wait-duration effects are lowered. Under `#!optimizeForSize`, `0`, `1`, `Null` and zero-vector arguments are spelled as `False`, `True` and `Null` per parameter, from a table generated from pinned OverPy 9.7.10 (`tools/overpy/gen_literal_flags.cjs`); without it authored literals are kept. The emitted structure converges on upstream, and the Bastion `main.opy` and `externalMain.opy` structural gate reports no differences; the internal optimizer implementation is not a contract. | | Replacement directives such as `#!replace0By*`, `#!replace1ByMatchRound`, team and empty-string replacements | ✅ Supported | Observable replacements are lowered when size optimization is active and are excluded from Workshop-setting constructors, matching the pinned upstream boundary. | | `#!extension` | ✅ Supported | The extension name is checked against the canonical Workshop schema. | | `#!disableInspector`, `#!excludeVariablesInCompilation`, `#!setupTags`, `#!setupTx`, `#!globalvarInitRuleName` and `#!playervarInitRuleName` | ✅ Supported | Inspector/setup rules, output declaration filtering and generated initialization rule names affect forward Workshop output. | diff --git a/tools/overpy/gen_literal_flags.cjs b/tools/overpy/gen_literal_flags.cjs new file mode 100644 index 00000000..2f0abc17 --- /dev/null +++ b/tools/overpy/gen_literal_flags.cjs @@ -0,0 +1,86 @@ +// Regenerates crates/opy-rs/src/compiler/size_optimization/literal_flags.rs from +// the pinned OverPy 9.7.10 argument tables (`canReplace0ByFalse`, +// `canReplace1ByTrue`, `canReplace0ByNull`, `canReplaceNullVectorByNull`). +// +// pnpm install --dir tools/overpy/oracle +// node tools/overpy/gen_literal_flags.cjs +// +// Comparison operators, `__for__`, the indexed +// variable calls, `__assignTo__` and the HUD macros are excluded: the compiler applies OverPy's +// rules for those on the typed actions and rule conditions directly. +const fs = require("fs"); +const path = require("path"); +const { createRequire } = require("module"); + +const oracle = path.join(__dirname, "oracle"); +const pkg = path.dirname( + createRequire(path.join(oracle, "package.json")).resolve("overpy/package.json"), +); +const scratch = fs.mkdtempSync(path.join(oracle, ".flags-")); +fs.cpSync(pkg, scratch, { recursive: true }); +const entry = path.join(scratch, "overpy.js"); +fs.writeFileSync( + entry, + fs.readFileSync(entry, "utf8").replace("var funcKw;", "var funcKw; globalThis.__funcKw = () => funcKw;"), +); +require(entry); + +const excluded = new Set([ + "__for__", "__setGlobalVariableAtIndex__", + "__setPlayerVariableAtIndex__", "__assignTo__", "__equals__", "__inequals__", + "__greaterThan__", "__greaterThanOrEquals__", "__lessThan__", "__lessThanOrEquals__", + // Macros expand to `hudText` before this pass runs. + "hudHeader", "hudSubheader", "hudSubtext", + // Localized strings lower to Custom String without their own catalog entry. + "__localizedString__", +]); +// OverPy spells some functions differently from the catalog; the manifest +// records where an OverPy name resolves to another catalog id. +const manifest = JSON.parse( + fs.readFileSync(path.join(__dirname, "../../crates/opy-rs/src/manifest/data/manifest.json"), "utf8"), +); +const catalogId = new Map([ + ...manifest.functions.filter((entry) => entry.catalogId).map((entry) => [entry.id, entry.catalogId]), + // Lowered specially onto the catalog's array values. + ["concat", "appendToArray"], + ["exclude", "removeFromArray"], +]); +const flagOf = { + canReplace0ByFalse: "ZERO_BY_FALSE", + canReplace1ByTrue: "ONE_BY_TRUE", + canReplace0ByNull: "ZERO_BY_NULL", + canReplaceNullVectorByNull: "NULL_VECTOR_BY_NULL", +}; + +setTimeout(() => { + const rows = []; + for (const [name, info] of Object.entries(globalThis.__funcKw())) { + if (excluded.has(name)) continue; + const bare = name.replace(/^\./, "").replace(/^__(.*)__$/, "$1"); + const canonical = catalogId.get(bare) ?? bare; + (info.args ?? []).forEach((arg, index) => { + const flags = Object.keys(flagOf).filter((key) => arg[key]).map((key) => flagOf[key]); + if (flags.length) rows.push(` ("${canonical}", ${index}, ${flags.join(" | ")}),`); + }); + } + rows.sort(); + const out = path.join(__dirname, "../../crates/opy-rs/src/compiler/size_optimization/literal_flags.rs"); + fs.writeFileSync( + out, + `//! Generated by \`tools/overpy/gen_literal_flags.cjs\` from OverPy 9.7.10. Do not edit. + +pub(super) const ZERO_BY_FALSE: u8 = 1; +pub(super) const ONE_BY_TRUE: u8 = 2; +pub(super) const ZERO_BY_NULL: u8 = 4; +pub(super) const NULL_VECTOR_BY_NULL: u8 = 8; + +/// Argument positions where OverPy replaces a literal under \`#!optimizeForSize\`. +#[rustfmt::skip] +pub(super) const LITERAL_FLAGS: &[(&str, usize, u8)] = &[ +${rows.join("\n")} +]; +`, + ); + fs.rmSync(scratch, { recursive: true }); + process.exit(0); +}, 3000); From 5eb8b5c019478608a6726beb94b53e045680116a Mon Sep 17 00:00:00 2001 From: Teakowa Date: Sat, 26 Sep 2026 16:18:41 +0800 Subject: [PATCH 2/2] refactor(compiler): encode optimizeForSize literal slots in typed Rust Address review: the generated canReplace table drove lowering policy directly. Write the per-parameter policy as typed Rust in literal_slots.rs and keep the upstream flags only as verification data (tools/overpy/upstream-literal-flags.json), checked by slots_cover_the_upstream_flags in both directions. Verified with tools/overpy/probe_builtins.py (unexplained: 0) and the Bastion structural gate (0 differences). --- .../opy-rs/src/compiler/size_optimization.rs | 47 ++----- .../size_optimization/literal_flags.rs | 98 -------------- .../size_optimization/literal_slots.rs | 126 ++++++++++++++++++ docs/language-support/tooling-and-backend.md | 2 +- tools/overpy/gen_literal_flags.cjs | 27 +--- tools/overpy/upstream-literal-flags.json | 89 +++++++++++++ 6 files changed, 231 insertions(+), 158 deletions(-) delete mode 100644 crates/opy-rs/src/compiler/size_optimization/literal_flags.rs create mode 100644 crates/opy-rs/src/compiler/size_optimization/literal_slots.rs create mode 100644 tools/overpy/upstream-literal-flags.json diff --git a/crates/opy-rs/src/compiler/size_optimization.rs b/crates/opy-rs/src/compiler/size_optimization.rs index d19a61fa..952e45c3 100644 --- a/crates/opy-rs/src/compiler/size_optimization.rs +++ b/crates/opy-rs/src/compiler/size_optimization.rs @@ -4,13 +4,11 @@ use workshop_rs::catalog::{Kind, ParamCoercions}; use workshop_rs::{Action, ModifyOp, Value}; -use self::literal_flags::{ - LITERAL_FLAGS, NULL_VECTOR_BY_NULL, ONE_BY_TRUE, ZERO_BY_FALSE, ZERO_BY_NULL, -}; +use self::literal_slots::{Slot, slot}; use super::Compiler; use super::operator_optimization::falsy; -mod literal_flags; +mod literal_slots; pub(super) struct SizeOptimizer<'a> { compiler: &'a Compiler, @@ -206,22 +204,18 @@ impl<'a> SizeOptimizer<'a> { "customString" => 1, _ => index, }; - let flags = LITERAL_FLAGS - .iter() - .find(|(candidate, position, _)| *candidate == name && *position == index) - .map_or(0, |(_, _, flags)| *flags); + let slot = slot(name, index); if let Value::Number(number) = value { - if *number == 0.0 && flags & ZERO_BY_FALSE != 0 { - *value = Value::Bool(false); - } else if *number == 0.0 && flags & ZERO_BY_NULL != 0 { - *value = Value::Null; - } else if *number == 1.0 && flags & ONE_BY_TRUE != 0 { - *value = Value::Bool(true); + match (slot, *number) { + (Some(Slot::Boolean | Slot::FalseOnly), 0.0) => *value = Value::Bool(false), + (Some(Slot::ZeroAsNull), 0.0) => *value = Value::Null, + (Some(Slot::Boolean | Slot::TrueOnly), 1.0) => *value = Value::Bool(true), + _ => {} } } else if is_empty_string(value) { *value = self.empty_string(coercions.empty_array_as_string); } else if is_zero_vector(value) { - *value = if flags & NULL_VECTOR_BY_NULL != 0 { + *value = if slot == Some(Slot::ZeroVectorAsNull) { Value::Null } else { self.zero_vector_sum() @@ -455,26 +449,3 @@ pub(super) fn action_values(action: &mut Action) -> Vec<&mut Value> { _ => Vec::new(), } } - -#[cfg(test)] -mod tests { - use super::literal_flags::LITERAL_FLAGS; - use workshop_rs::catalog::{Catalog, Kind}; - - /// A name that is not a catalog entry is a table row that can never apply. - #[test] - fn literal_flag_names_resolve_to_catalog_entries() { - let catalog = Catalog::builtin().unwrap(); - let unresolved: Vec<&str> = LITERAL_FLAGS - .iter() - .map(|(name, _, _)| *name) - .filter(|name| { - *name != "array" - && [Kind::Action, Kind::Value] - .into_iter() - .all(|kind| catalog.entry(kind, name).is_none()) - }) - .collect(); - assert_eq!(unresolved, Vec::<&str>::new()); - } -} diff --git a/crates/opy-rs/src/compiler/size_optimization/literal_flags.rs b/crates/opy-rs/src/compiler/size_optimization/literal_flags.rs deleted file mode 100644 index d31c3698..00000000 --- a/crates/opy-rs/src/compiler/size_optimization/literal_flags.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! Generated by `tools/overpy/gen_literal_flags.cjs` from OverPy 9.7.10. Do not edit. - -pub(super) const ZERO_BY_FALSE: u8 = 1; -pub(super) const ONE_BY_TRUE: u8 = 2; -pub(super) const ZERO_BY_NULL: u8 = 4; -pub(super) const NULL_VECTOR_BY_NULL: u8 = 8; - -/// Argument positions where OverPy replaces a literal under `#!optimizeForSize`. -#[rustfmt::skip] -pub(super) const LITERAL_FLAGS: &[(&str, usize, u8)] = &[ - ("add", 0, ONE_BY_TRUE), - ("add", 1, ONE_BY_TRUE), - ("addToScore", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("addToTeamScore", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("appendToArray", 1, ZERO_BY_NULL), - ("array", 0, ZERO_BY_NULL), - ("arrayContains", 1, ZERO_BY_NULL), - ("attachTo", 2, NULL_VECTOR_BY_NULL), - ("bigMessage", 1, ZERO_BY_NULL), - ("charAt", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("chaseAtRate", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("chaseAtRate", 2, ZERO_BY_FALSE | ONE_BY_TRUE), - ("chaseOverTime", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("chaseOverTime", 2, ZERO_BY_FALSE | ONE_BY_TRUE), - ("createDummyBot", 2, ZERO_BY_FALSE | ONE_BY_TRUE), - ("createDummyBot", 3, NULL_VECTOR_BY_NULL), - ("createDummyBot", 4, NULL_VECTOR_BY_NULL), - ("createEffect", 4, ZERO_BY_FALSE | ONE_BY_TRUE), - ("createHudText", 5, ZERO_BY_FALSE | ONE_BY_TRUE), - ("createInWorldText", 1, ZERO_BY_NULL), - ("createInWorldText", 2, NULL_VECTOR_BY_NULL), - ("createInWorldText", 3, ZERO_BY_FALSE | ONE_BY_TRUE), - ("customString", 1, ZERO_BY_NULL), - ("customString", 2, ZERO_BY_NULL), - ("customString", 3, ZERO_BY_NULL), - ("destroyDummy", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("divide", 0, ONE_BY_TRUE), - ("getAmmo", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("getMaxAmmo", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("getObjectivePosition", 0, ZERO_BY_FALSE | ONE_BY_TRUE), - ("getPlayersInSlot", 0, ZERO_BY_FALSE | ONE_BY_TRUE), - ("ifThenElse", 1, ZERO_BY_NULL), - ("ifThenElse", 2, ZERO_BY_NULL), - ("indexOfArrayValue", 1, ZERO_BY_NULL), - ("isObjectiveComplete", 0, ZERO_BY_FALSE | ONE_BY_TRUE), - ("max", 0, ZERO_BY_FALSE | ONE_BY_TRUE), - ("max", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("min", 0, ZERO_BY_FALSE | ONE_BY_TRUE), - ("min", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("modulo", 0, ONE_BY_TRUE), - ("modulo", 1, ONE_BY_TRUE), - ("randomInteger", 0, ZERO_BY_FALSE | ONE_BY_TRUE), - ("randomInteger", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("removeFromArray", 1, ZERO_BY_NULL), - ("setAbilityCharge", 2, ZERO_BY_FALSE | ONE_BY_TRUE), - ("setAbilityCooldown", 2, ZERO_BY_FALSE | ONE_BY_TRUE), - ("setAbilityResource", 2, ZERO_BY_FALSE | ONE_BY_TRUE), - ("setAmmo", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("setAmmo", 2, ZERO_BY_FALSE | ONE_BY_TRUE), - ("setGravity", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("setMatchTime", 0, ZERO_BY_FALSE | ONE_BY_TRUE), - ("setMaxAmmo", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("setMaxAmmo", 2, ZERO_BY_FALSE | ONE_BY_TRUE), - ("setMoveSpeed", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("setObjectiveDescription", 1, ZERO_BY_NULL), - ("setProjectileGravity", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("setProjectileSpeed", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("setRespawnTime", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("setScore", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("setStatusEffect", 3, ZERO_BY_FALSE | ONE_BY_TRUE), - ("setTeamScore", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("setUltCharge", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("setWeapon", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("skip", 0, ZERO_BY_FALSE | ONE_BY_TRUE), - ("skipIf", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("slice", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("slice", 2, ZERO_BY_FALSE | ONE_BY_TRUE), - ("smallMessage", 1, ZERO_BY_NULL), - ("startForcingPosition", 1, ZERO_BY_NULL), - ("startForcingSpawn", 1, ZERO_BY_FALSE), - ("startForcingThrottle", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("startForcingThrottle", 2, ZERO_BY_FALSE | ONE_BY_TRUE), - ("startForcingThrottle", 3, ZERO_BY_FALSE | ONE_BY_TRUE), - ("startForcingThrottle", 4, ZERO_BY_FALSE | ONE_BY_TRUE), - ("startForcingThrottle", 5, ZERO_BY_FALSE | ONE_BY_TRUE), - ("startForcingThrottle", 6, ZERO_BY_FALSE | ONE_BY_TRUE), - ("startModifyingVoicelinePitch", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("startScalingBarriers", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("startScalingSize", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("subtract", 0, ZERO_BY_FALSE | ONE_BY_TRUE), - ("subtract", 1, ONE_BY_TRUE), - ("teleport", 1, ZERO_BY_NULL), - ("valueInArray", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("vector", 0, ZERO_BY_FALSE | ONE_BY_TRUE), - ("vector", 1, ZERO_BY_FALSE | ONE_BY_TRUE), - ("vector", 2, ZERO_BY_FALSE | ONE_BY_TRUE), - ("wait", 0, ZERO_BY_FALSE | ONE_BY_TRUE), -]; diff --git a/crates/opy-rs/src/compiler/size_optimization/literal_slots.rs b/crates/opy-rs/src/compiler/size_optimization/literal_slots.rs new file mode 100644 index 00000000..8a6595a6 --- /dev/null +++ b/crates/opy-rs/src/compiler/size_optimization/literal_slots.rs @@ -0,0 +1,126 @@ +//! The argument positions where pinned OverPy 9.7.10 rewrites a literal under +//! `#!optimizeForSize`, keyed by catalog call name and argument position. +//! `tools/overpy/upstream-literal-flags.json` records OverPy's own flags and +//! `slots_cover_the_upstream_flags` checks this policy against them. + +/// How OverPy rewrites a literal in one argument position. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Slot { + /// `0` becomes `False` and `1` becomes `True`. + Boolean, + /// Only `1` becomes `True`. + TrueOnly, + /// Only `0` becomes `False`. + FalseOnly, + /// `0` becomes `Null`. + ZeroAsNull, + /// A zero vector becomes `Null`. + ZeroVectorAsNull, +} + +pub(super) fn slot(name: &str, index: usize) -> Option { + Some(match (name, index) { + ("startForcingSpawn", 1) => Slot::FalseOnly, + ("add", 0 | 1) | ("divide", 0) | ("modulo", 0 | 1) | ("subtract", 1) => Slot::TrueOnly, + ("attachTo", 2) | ("createDummyBot", 3 | 4) | ("createInWorldText", 2) => { + Slot::ZeroVectorAsNull + } + ("appendToArray" | "arrayContains" | "indexOfArrayValue" | "removeFromArray", 1) + | ("array", 0) + | ("customString", 1..=3) + | ("ifThenElse", 1 | 2) + | ("bigMessage" | "smallMessage" | "setObjectiveDescription", 1) + | ("createInWorldText", 1) + | ("startForcingPosition" | "teleport", 1) => Slot::ZeroAsNull, + _ if is_boolean_slot(name, index) => Slot::Boolean, + _ => return None, + }) +} + +fn is_boolean_slot(name: &str, index: usize) -> bool { + match name { + // Waits, skips and arithmetic. + "wait" + | "skip" + | "setMatchTime" + | "getObjectivePosition" + | "getPlayersInSlot" + | "isObjectiveComplete" => index == 0, + "skipIf" | "charAt" | "valueInArray" | "addToScore" | "addToTeamScore" | "setScore" + | "setTeamScore" | "destroyDummy" | "getAmmo" | "getMaxAmmo" => index == 1, + "max" | "min" | "randomInteger" => index <= 1, + "subtract" => index == 0, + "slice" => matches!(index, 1 | 2), + "vector" => index <= 2, + // Chase destinations and rates. + "chaseAtRate" | "chaseOverTime" => matches!(index, 1 | 2), + // Player stats and abilities. + "setAbilityCharge" | "setAbilityCooldown" | "setAbilityResource" => index == 2, + "setAmmo" | "setMaxAmmo" => matches!(index, 1 | 2), + "setGravity" + | "setMoveSpeed" + | "setProjectileGravity" + | "setProjectileSpeed" + | "setRespawnTime" + | "setUltCharge" + | "setWeapon" + | "startModifyingVoicelinePitch" + | "startScalingBarriers" + | "startScalingSize" => index == 1, + "setStatusEffect" => index == 3, + "startForcingThrottle" => (1..=6).contains(&index), + // Effects, HUD and dummies. + "createDummyBot" => index == 2, + "createEffect" => index == 4, + "createHudText" => index == 5, + "createInWorldText" => index == 3, + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::{Slot, slot}; + + /// Every flag pinned OverPy sets is honored, and no position it leaves + /// unflagged is rewritten. + #[test] + fn slots_cover_the_upstream_flags() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../tools/overpy/upstream-literal-flags.json" + ); + let upstream: Vec<(String, usize, Vec)> = + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + for (name, index, flags) in &upstream { + let has = |flag: &str| flags.iter().any(|candidate| candidate == flag); + let expected = match ( + has("ZERO_BY_FALSE"), + has("ONE_BY_TRUE"), + has("ZERO_BY_NULL"), + has("NULL_VECTOR_BY_NULL"), + ) { + (true, true, ..) => Slot::Boolean, + (false, true, ..) => Slot::TrueOnly, + (true, false, ..) => Slot::FalseOnly, + (_, _, true, _) => Slot::ZeroAsNull, + (_, _, _, true) => Slot::ZeroVectorAsNull, + _ => unreachable!("{name}:{index} carries no flag"), + }; + assert_eq!(slot(name, *index), Some(expected), "{name}:{index}"); + } + let listed = |name: &str, index: usize| { + upstream + .iter() + .any(|(candidate, position, _)| candidate == name && *position == index) + }; + for name in upstream.iter().map(|(name, ..)| name.as_str()) { + for index in 0..8 { + assert!( + listed(name, index) || slot(name, index).is_none(), + "{name}:{index} is rewritten but not flagged upstream" + ); + } + } + } +} diff --git a/docs/language-support/tooling-and-backend.md b/docs/language-support/tooling-and-backend.md index cd84a34f..d24168a1 100644 --- a/docs/language-support/tooling-and-backend.md +++ b/docs/language-support/tooling-and-backend.md @@ -8,7 +8,7 @@ | `#!allowMacroRedeclaration` | ✅ Supported | Duplicate-definition policy is explicit in preprocessing state. | | `#!mainFile`, `#!include`, `#!excludeVariablesInCompilation` | ✅ Supported | File selection, include closure and output filtering are separate operations. | | `#!rulePrefix` and `#!rulePrefixTemplate` | ✅ Supported | Rule names are transformed before lowering. | -| Optimization controls such as `#!enableOptimizations`, `#!disableOptimizations`, `#!optimizeForSize`, `#!optimizeForSizeAggressive` and `#!optimizeStrict` | ✅ Supported | Optimization state is scoped to source spans; size, aggressive skip, strict-folding and wait-duration effects are lowered. Under `#!optimizeForSize`, `0`, `1`, `Null` and zero-vector arguments are spelled as `False`, `True` and `Null` per parameter, from a table generated from pinned OverPy 9.7.10 (`tools/overpy/gen_literal_flags.cjs`); without it authored literals are kept. The emitted structure converges on upstream, and the Bastion `main.opy` and `externalMain.opy` structural gate reports no differences; the internal optimizer implementation is not a contract. | +| Optimization controls such as `#!enableOptimizations`, `#!disableOptimizations`, `#!optimizeForSize`, `#!optimizeForSizeAggressive` and `#!optimizeStrict` | ✅ Supported | Optimization state is scoped to source spans; size, aggressive skip, strict-folding and wait-duration effects are lowered. Under `#!optimizeForSize`, `0`, `1`, `Null` and zero-vector arguments are spelled as `False`, `True` and `Null` per parameter as pinned OverPy 9.7.10 does, encoded in typed Rust (`tools/overpy/gen_literal_flags.cjs` records the upstream flags that a test checks the policy against); without it authored literals are kept. The emitted structure converges on upstream, and the Bastion `main.opy` and `externalMain.opy` structural gate reports no differences; the internal optimizer implementation is not a contract. | | Replacement directives such as `#!replace0By*`, `#!replace1ByMatchRound`, team and empty-string replacements | ✅ Supported | Observable replacements are lowered when size optimization is active and are excluded from Workshop-setting constructors, matching the pinned upstream boundary. | | `#!extension` | ✅ Supported | The extension name is checked against the canonical Workshop schema. | | `#!disableInspector`, `#!excludeVariablesInCompilation`, `#!setupTags`, `#!setupTx`, `#!globalvarInitRuleName` and `#!playervarInitRuleName` | ✅ Supported | Inspector/setup rules, output declaration filtering and generated initialization rule names affect forward Workshop output. | diff --git a/tools/overpy/gen_literal_flags.cjs b/tools/overpy/gen_literal_flags.cjs index 2f0abc17..422ebedf 100644 --- a/tools/overpy/gen_literal_flags.cjs +++ b/tools/overpy/gen_literal_flags.cjs @@ -1,5 +1,5 @@ -// Regenerates crates/opy-rs/src/compiler/size_optimization/literal_flags.rs from -// the pinned OverPy 9.7.10 argument tables (`canReplace0ByFalse`, +// Records tools/overpy/upstream-literal-flags.json from the pinned OverPy 9.7.10 +// argument tables (`canReplace0ByFalse`, // `canReplace1ByTrue`, `canReplace0ByNull`, `canReplaceNullVectorByNull`). // // pnpm install --dir tools/overpy/oracle @@ -60,27 +60,12 @@ setTimeout(() => { const canonical = catalogId.get(bare) ?? bare; (info.args ?? []).forEach((arg, index) => { const flags = Object.keys(flagOf).filter((key) => arg[key]).map((key) => flagOf[key]); - if (flags.length) rows.push(` ("${canonical}", ${index}, ${flags.join(" | ")}),`); + if (flags.length) rows.push([canonical, index, flags]); }); } - rows.sort(); - const out = path.join(__dirname, "../../crates/opy-rs/src/compiler/size_optimization/literal_flags.rs"); - fs.writeFileSync( - out, - `//! Generated by \`tools/overpy/gen_literal_flags.cjs\` from OverPy 9.7.10. Do not edit. - -pub(super) const ZERO_BY_FALSE: u8 = 1; -pub(super) const ONE_BY_TRUE: u8 = 2; -pub(super) const ZERO_BY_NULL: u8 = 4; -pub(super) const NULL_VECTOR_BY_NULL: u8 = 8; - -/// Argument positions where OverPy replaces a literal under \`#!optimizeForSize\`. -#[rustfmt::skip] -pub(super) const LITERAL_FLAGS: &[(&str, usize, u8)] = &[ -${rows.join("\n")} -]; -`, - ); + rows.sort((a, b) => a[0].localeCompare(b[0]) || a[1] - b[1]); + const out = path.join(__dirname, "upstream-literal-flags.json"); + fs.writeFileSync(out, `[\n${rows.map((row) => JSON.stringify(row)).join(",\n")}\n]\n`); fs.rmSync(scratch, { recursive: true }); process.exit(0); }, 3000); diff --git a/tools/overpy/upstream-literal-flags.json b/tools/overpy/upstream-literal-flags.json new file mode 100644 index 00000000..6efaaca4 --- /dev/null +++ b/tools/overpy/upstream-literal-flags.json @@ -0,0 +1,89 @@ +[ +["add",0,["ONE_BY_TRUE"]], +["add",1,["ONE_BY_TRUE"]], +["addToScore",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["addToTeamScore",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["appendToArray",1,["ZERO_BY_NULL"]], +["array",0,["ZERO_BY_NULL"]], +["arrayContains",1,["ZERO_BY_NULL"]], +["attachTo",2,["NULL_VECTOR_BY_NULL"]], +["bigMessage",1,["ZERO_BY_NULL"]], +["charAt",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["chaseAtRate",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["chaseAtRate",2,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["chaseOverTime",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["chaseOverTime",2,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["createDummyBot",2,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["createDummyBot",3,["NULL_VECTOR_BY_NULL"]], +["createDummyBot",4,["NULL_VECTOR_BY_NULL"]], +["createEffect",4,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["createHudText",5,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["createInWorldText",1,["ZERO_BY_NULL"]], +["createInWorldText",2,["NULL_VECTOR_BY_NULL"]], +["createInWorldText",3,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["customString",1,["ZERO_BY_NULL"]], +["customString",2,["ZERO_BY_NULL"]], +["customString",3,["ZERO_BY_NULL"]], +["destroyDummy",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["divide",0,["ONE_BY_TRUE"]], +["getAmmo",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["getMaxAmmo",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["getObjectivePosition",0,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["getPlayersInSlot",0,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["ifThenElse",1,["ZERO_BY_NULL"]], +["ifThenElse",2,["ZERO_BY_NULL"]], +["indexOfArrayValue",1,["ZERO_BY_NULL"]], +["isObjectiveComplete",0,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["max",0,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["max",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["min",0,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["min",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["modulo",0,["ONE_BY_TRUE"]], +["modulo",1,["ONE_BY_TRUE"]], +["randomInteger",0,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["randomInteger",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["removeFromArray",1,["ZERO_BY_NULL"]], +["setAbilityCharge",2,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["setAbilityCooldown",2,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["setAbilityResource",2,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["setAmmo",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["setAmmo",2,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["setGravity",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["setMatchTime",0,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["setMaxAmmo",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["setMaxAmmo",2,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["setMoveSpeed",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["setObjectiveDescription",1,["ZERO_BY_NULL"]], +["setProjectileGravity",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["setProjectileSpeed",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["setRespawnTime",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["setScore",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["setStatusEffect",3,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["setTeamScore",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["setUltCharge",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["setWeapon",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["skip",0,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["skipIf",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["slice",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["slice",2,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["smallMessage",1,["ZERO_BY_NULL"]], +["startForcingPosition",1,["ZERO_BY_NULL"]], +["startForcingSpawn",1,["ZERO_BY_FALSE"]], +["startForcingThrottle",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["startForcingThrottle",2,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["startForcingThrottle",3,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["startForcingThrottle",4,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["startForcingThrottle",5,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["startForcingThrottle",6,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["startModifyingVoicelinePitch",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["startScalingBarriers",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["startScalingSize",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["subtract",0,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["subtract",1,["ONE_BY_TRUE"]], +["teleport",1,["ZERO_BY_NULL"]], +["valueInArray",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["vector",0,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["vector",1,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["vector",2,["ZERO_BY_FALSE","ONE_BY_TRUE"]], +["wait",0,["ZERO_BY_FALSE","ONE_BY_TRUE"]] +]