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..952e45c3 100644 --- a/crates/opy-rs/src/compiler/size_optimization.rs +++ b/crates/opy-rs/src/compiler/size_optimization.rs @@ -4,9 +4,12 @@ use workshop_rs::catalog::{Kind, ParamCoercions}; use workshop_rs::{Action, ModifyOp, Value}; +use self::literal_slots::{Slot, slot}; use super::Compiler; use super::operator_optimization::falsy; +mod literal_slots; + pub(super) struct SizeOptimizer<'a> { compiler: &'a Compiler, } @@ -23,15 +26,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 +167,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 +181,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 +190,32 @@ 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 slot = slot(name, index); + if let Value::Number(number) = value { + 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 coercions.null_vector_as_null { + *value = if slot == Some(Slot::ZeroVectorAsNull) { Value::Null } else { self.zero_vector_sum() @@ -243,11 +252,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 +277,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 +407,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, }) } 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/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..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. 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 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 new file mode 100644 index 00000000..422ebedf --- /dev/null +++ b/tools/overpy/gen_literal_flags.cjs @@ -0,0 +1,71 @@ +// 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 +// 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]); + }); + } + 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"]] +]