mirror of
https://github.com/dart-lang/sdk
synced 2024-07-05 09:20:04 +00:00
[dart2wasm] Move yield finder to a shared library
- Generalize `YieldFinder` to consider both `await` and `yield` as a suspension point. - Move it to `state_machine.dart`, reuse it in async and sync* code generators. This is a change from https://dart-review.googlesource.com/c/sdk/+/366663, moved to a spearate CL to make revieweing easier. The end goal is to share code generation for async and sync* code generators. Change-Id: I4c75c746f85b2fedf7c1117a20fcd32152573c6d Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/367021 Reviewed-by: Martin Kustermann <kustermann@google.com> Commit-Queue: Ömer Ağacan <omersa@google.com>
This commit is contained in:
parent
035be2b0d4
commit
3863e78e80
|
@ -8,180 +8,7 @@ import 'package:wasm_builder/wasm_builder.dart' as w;
|
|||
import 'class_info.dart';
|
||||
import 'closures.dart';
|
||||
import 'code_generator.dart';
|
||||
import 'sync_star.dart' show StateTarget, StateTargetPlacement;
|
||||
|
||||
/// Identify which statements contain `await` statements, and assign target
|
||||
/// indices to all control flow targets of these.
|
||||
///
|
||||
/// Target indices are assigned in program order.
|
||||
class _YieldFinder extends RecursiveVisitor {
|
||||
final List<StateTarget> targets = [];
|
||||
final bool enableAsserts;
|
||||
|
||||
// The number of `await` statements seen so far.
|
||||
int yieldCount = 0;
|
||||
|
||||
_YieldFinder(this.enableAsserts);
|
||||
|
||||
List<StateTarget> find(FunctionNode function) {
|
||||
// Initial state
|
||||
addTarget(function.body!, StateTargetPlacement.Inner);
|
||||
assert(function.body is Block || function.body is ReturnStatement);
|
||||
recurse(function.body!);
|
||||
// Final state
|
||||
addTarget(function.body!, StateTargetPlacement.After);
|
||||
return targets;
|
||||
}
|
||||
|
||||
/// Recurse into a statement and then remove any targets added by the
|
||||
/// statement if it doesn't contain any `await` statements.
|
||||
void recurse(Statement statement) {
|
||||
final yieldCountIn = yieldCount;
|
||||
final targetsIn = targets.length;
|
||||
statement.accept(this);
|
||||
if (yieldCount == yieldCountIn) {
|
||||
targets.length = targetsIn;
|
||||
}
|
||||
}
|
||||
|
||||
void addTarget(TreeNode node, StateTargetPlacement placement) {
|
||||
targets.add(StateTarget(targets.length, node, placement));
|
||||
}
|
||||
|
||||
@override
|
||||
void visitBlock(Block node) {
|
||||
for (Statement statement in node.statements) {
|
||||
recurse(statement);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void visitDoStatement(DoStatement node) {
|
||||
addTarget(node, StateTargetPlacement.Inner);
|
||||
recurse(node.body);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitForStatement(ForStatement node) {
|
||||
addTarget(node, StateTargetPlacement.Inner);
|
||||
recurse(node.body);
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitIfStatement(IfStatement node) {
|
||||
recurse(node.then);
|
||||
if (node.otherwise != null) {
|
||||
addTarget(node, StateTargetPlacement.Inner);
|
||||
recurse(node.otherwise!);
|
||||
}
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitLabeledStatement(LabeledStatement node) {
|
||||
recurse(node.body);
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitSwitchStatement(SwitchStatement node) {
|
||||
for (SwitchCase c in node.cases) {
|
||||
addTarget(c, StateTargetPlacement.Inner);
|
||||
recurse(c.body);
|
||||
}
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitTryFinally(TryFinally node) {
|
||||
// [TryFinally] blocks are always compiled to as CFG, even when they don't
|
||||
// have awaits. This is to keep the code size small: with normal
|
||||
// compilation finalizer blocks need to be duplicated based on
|
||||
// continuations, which we don't need in the CFG implementation.
|
||||
yieldCount += 1;
|
||||
recurse(node.body);
|
||||
addTarget(node, StateTargetPlacement.Inner);
|
||||
recurse(node.finalizer);
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitTryCatch(TryCatch node) {
|
||||
// Also always compile [TryCatch] blocks to the CFG to be able to set
|
||||
// finalizer continuations.
|
||||
yieldCount += 1;
|
||||
recurse(node.body);
|
||||
for (Catch c in node.catches) {
|
||||
addTarget(c, StateTargetPlacement.Inner);
|
||||
recurse(c.body);
|
||||
}
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitWhileStatement(WhileStatement node) {
|
||||
addTarget(node, StateTargetPlacement.Inner);
|
||||
recurse(node.body);
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitYieldStatement(YieldStatement node) {
|
||||
throw 'Yield statement in async function: $node (${node.location})';
|
||||
}
|
||||
|
||||
// Handle awaits. After the await transformation await can only appear in a
|
||||
// RHS of a top-level assignment, or as a top-level statement.
|
||||
@override
|
||||
void visitVariableSet(VariableSet node) {
|
||||
if (node.value is AwaitExpression) {
|
||||
yieldCount++;
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
} else {
|
||||
super.visitVariableSet(node);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void visitExpressionStatement(ExpressionStatement node) {
|
||||
if (node.expression is AwaitExpression) {
|
||||
yieldCount++;
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
} else {
|
||||
super.visitExpressionStatement(node);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void visitFunctionExpression(FunctionExpression node) {}
|
||||
|
||||
@override
|
||||
void visitFunctionDeclaration(FunctionDeclaration node) {}
|
||||
|
||||
// Any other await expression means the await transformer is buggy and didn't
|
||||
// transform the expression as expected.
|
||||
@override
|
||||
void visitAwaitExpression(AwaitExpression node) {
|
||||
// Await expressions should've been converted to `VariableSet` statements
|
||||
// by `_AwaitTransformer`.
|
||||
throw 'Unexpected await expression: $node (${node.location})';
|
||||
}
|
||||
|
||||
@override
|
||||
void visitAssertStatement(AssertStatement node) {
|
||||
if (enableAsserts) {
|
||||
super.visitAssertStatement(node);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void visitAssertBlock(AssertBlock node) {
|
||||
if (enableAsserts) {
|
||||
super.visitAssertBlock(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
import 'state_machine.dart';
|
||||
|
||||
class _ExceptionHandlerStack {
|
||||
/// Current exception handler stack. A CFG block generated when this is not
|
||||
|
@ -593,7 +420,7 @@ class AsyncCodeGenerator extends CodeGenerator {
|
|||
|
||||
void _generateBodies(FunctionNode functionNode) {
|
||||
// Number and categorize CFG targets.
|
||||
targets = _YieldFinder(translator.options.enableAsserts).find(functionNode);
|
||||
targets = YieldFinder(translator.options.enableAsserts).find(functionNode);
|
||||
for (final target in targets) {
|
||||
switch (target.placement) {
|
||||
case StateTargetPlacement.Inner:
|
||||
|
@ -1344,7 +1171,7 @@ class AsyncCodeGenerator extends CodeGenerator {
|
|||
}
|
||||
|
||||
// Set state target to label after await.
|
||||
final StateTarget after = afterTargets[node.parent]!;
|
||||
final StateTarget after = afterTargets[node]!;
|
||||
b.local_get(suspendStateLocal);
|
||||
b.i32_const(after.index);
|
||||
b.struct_set(
|
||||
|
|
213
pkg/dart2wasm/lib/state_machine.dart
Normal file
213
pkg/dart2wasm/lib/state_machine.dart
Normal file
|
@ -0,0 +1,213 @@
|
|||
// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'package:kernel/ast.dart';
|
||||
|
||||
/// Placement of a control flow graph target within a statement. This
|
||||
/// distinction is necessary since some statements need to have two targets
|
||||
/// associated with them.
|
||||
///
|
||||
/// The meanings of the variants are:
|
||||
///
|
||||
/// - [Inner]: Loop entry of a [DoStatement], condition of a [ForStatement] or
|
||||
/// [WhileStatement], the `else` branch of an [IfStatement], or the
|
||||
/// initial entry target for a function body.
|
||||
///
|
||||
/// - [After]: After a statement, the resumption point of a suspension point
|
||||
/// ([YieldStatement] or [AwaitExpression]), or the final state
|
||||
/// (iterator done) of a function body.
|
||||
enum StateTargetPlacement { Inner, After }
|
||||
|
||||
/// Representation of target in the `sync*` control flow graph.
|
||||
class StateTarget {
|
||||
final int index;
|
||||
final TreeNode node;
|
||||
final StateTargetPlacement placement;
|
||||
|
||||
StateTarget(this.index, this.node, this.placement);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
String place = placement == StateTargetPlacement.Inner ? "in" : "after";
|
||||
return "$index: $place $node";
|
||||
}
|
||||
}
|
||||
|
||||
/// Identify which statements contain `await` or `yield` statements, and assign
|
||||
/// target indices to all control flow targets of these.
|
||||
///
|
||||
/// Target indices are assigned in program order.
|
||||
class YieldFinder extends RecursiveVisitor {
|
||||
final List<StateTarget> targets = [];
|
||||
final bool enableAsserts;
|
||||
|
||||
// The number of `await` statements seen so far.
|
||||
int yieldCount = 0;
|
||||
|
||||
YieldFinder(this.enableAsserts);
|
||||
|
||||
List<StateTarget> find(FunctionNode function) {
|
||||
// Initial state
|
||||
addTarget(function.body!, StateTargetPlacement.Inner);
|
||||
assert(function.body is Block || function.body is ReturnStatement);
|
||||
recurse(function.body!);
|
||||
// Final state
|
||||
addTarget(function.body!, StateTargetPlacement.After);
|
||||
return targets;
|
||||
}
|
||||
|
||||
/// Recurse into a statement and then remove any targets added by the
|
||||
/// statement if it doesn't contain any `await` statements.
|
||||
void recurse(Statement statement) {
|
||||
final yieldCountIn = yieldCount;
|
||||
final targetsIn = targets.length;
|
||||
statement.accept(this);
|
||||
if (yieldCount == yieldCountIn) {
|
||||
targets.length = targetsIn;
|
||||
}
|
||||
}
|
||||
|
||||
void addTarget(TreeNode node, StateTargetPlacement placement) {
|
||||
targets.add(StateTarget(targets.length, node, placement));
|
||||
}
|
||||
|
||||
@override
|
||||
void visitBlock(Block node) {
|
||||
for (Statement statement in node.statements) {
|
||||
recurse(statement);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void visitDoStatement(DoStatement node) {
|
||||
addTarget(node, StateTargetPlacement.Inner);
|
||||
recurse(node.body);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitForStatement(ForStatement node) {
|
||||
addTarget(node, StateTargetPlacement.Inner);
|
||||
recurse(node.body);
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitIfStatement(IfStatement node) {
|
||||
recurse(node.then);
|
||||
if (node.otherwise != null) {
|
||||
addTarget(node, StateTargetPlacement.Inner);
|
||||
recurse(node.otherwise!);
|
||||
}
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitLabeledStatement(LabeledStatement node) {
|
||||
recurse(node.body);
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitSwitchStatement(SwitchStatement node) {
|
||||
for (SwitchCase c in node.cases) {
|
||||
addTarget(c, StateTargetPlacement.Inner);
|
||||
recurse(c.body);
|
||||
}
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitTryFinally(TryFinally node) {
|
||||
// [TryFinally] blocks are always compiled to as CFG, even when they don't
|
||||
// have awaits. This is to keep the code size small: with normal
|
||||
// compilation finalizer blocks need to be duplicated based on
|
||||
// continuations, which we don't need in the CFG implementation.
|
||||
yieldCount++;
|
||||
recurse(node.body);
|
||||
addTarget(node, StateTargetPlacement.Inner);
|
||||
recurse(node.finalizer);
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitTryCatch(TryCatch node) {
|
||||
// Also always compile [TryCatch] blocks to the CFG to be able to set
|
||||
// finalizer continuations.
|
||||
yieldCount++;
|
||||
recurse(node.body);
|
||||
for (Catch c in node.catches) {
|
||||
addTarget(c, StateTargetPlacement.Inner);
|
||||
recurse(c.body);
|
||||
}
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitWhileStatement(WhileStatement node) {
|
||||
addTarget(node, StateTargetPlacement.Inner);
|
||||
recurse(node.body);
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitYieldStatement(YieldStatement node) {
|
||||
yieldCount++;
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
}
|
||||
|
||||
// Handle awaits. After the await transformation await can only appear in a
|
||||
// RHS of a top-level assignment, or as a top-level statement.
|
||||
@override
|
||||
void visitExpressionStatement(ExpressionStatement node) {
|
||||
final expression = node.expression;
|
||||
|
||||
// Handle awaits in RHS of assignments.
|
||||
if (expression is VariableSet) {
|
||||
final value = expression.value;
|
||||
if (value is AwaitExpression) {
|
||||
yieldCount++;
|
||||
addTarget(value, StateTargetPlacement.After);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle top-level awaits.
|
||||
if (expression is AwaitExpression) {
|
||||
yieldCount++;
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
return;
|
||||
}
|
||||
|
||||
super.visitExpressionStatement(node);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitFunctionExpression(FunctionExpression node) {}
|
||||
|
||||
@override
|
||||
void visitFunctionDeclaration(FunctionDeclaration node) {}
|
||||
|
||||
// Any other await expression means the await transformer is buggy and didn't
|
||||
// transform the expression as expected.
|
||||
@override
|
||||
void visitAwaitExpression(AwaitExpression node) {
|
||||
// Await expressions should've been converted to `VariableSet` statements
|
||||
// by `_AwaitTransformer`.
|
||||
throw 'Unexpected await expression: $node (${node.location})';
|
||||
}
|
||||
|
||||
@override
|
||||
void visitAssertStatement(AssertStatement node) {
|
||||
if (enableAsserts) {
|
||||
super.visitAssertStatement(node);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void visitAssertBlock(AssertBlock node) {
|
||||
if (enableAsserts) {
|
||||
super.visitAssertBlock(node);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -8,155 +8,7 @@ import 'package:wasm_builder/wasm_builder.dart' as w;
|
|||
import 'class_info.dart';
|
||||
import 'closures.dart';
|
||||
import 'code_generator.dart';
|
||||
|
||||
/// Placement of a control flow graph target within a statement. This
|
||||
/// distinction is necessary since some statements need to have two targets
|
||||
/// associated with them.
|
||||
///
|
||||
/// The meanings of the variants are:
|
||||
///
|
||||
/// - [Inner]: Loop entry of a [DoStatement], condition of a [ForStatement] or
|
||||
/// [WhileStatement], the `else` branch of an [IfStatement], or the
|
||||
/// initial entry target for a function body.
|
||||
/// - [After]: After a statement, the resumption point of a [YieldStatement],
|
||||
/// or the final state (iterator done) of a function body.
|
||||
enum StateTargetPlacement { Inner, After }
|
||||
|
||||
/// Representation of target in the `sync*` control flow graph.
|
||||
class StateTarget {
|
||||
int index;
|
||||
TreeNode node;
|
||||
StateTargetPlacement placement;
|
||||
|
||||
StateTarget(this.index, this.node, this.placement);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
String place = placement == StateTargetPlacement.Inner ? "in" : "after";
|
||||
return "$index: $place $node";
|
||||
}
|
||||
}
|
||||
|
||||
/// Identify which statements contain `yield` or `yield*` statements, and assign
|
||||
/// target indices to all control flow targets of these.
|
||||
///
|
||||
/// Target indices are assigned in program order.
|
||||
class _YieldFinder extends StatementVisitor<void>
|
||||
with StatementVisitorDefaultMixin<void> {
|
||||
final SyncStarCodeGenerator codeGen;
|
||||
|
||||
// The number of `yield` or `yield*` statements seen so far.
|
||||
int yieldCount = 0;
|
||||
|
||||
_YieldFinder(this.codeGen);
|
||||
|
||||
List<StateTarget> get targets => codeGen.targets;
|
||||
|
||||
void find(FunctionNode function) {
|
||||
// Initial state
|
||||
addTarget(function.body!, StateTargetPlacement.Inner);
|
||||
assert(function.body is Block || function.body is ReturnStatement);
|
||||
recurse(function.body!);
|
||||
// Final state
|
||||
addTarget(function.body!, StateTargetPlacement.After);
|
||||
}
|
||||
|
||||
/// Recurse into a statement and then remove any targets added by the
|
||||
/// statement if it doesn't contain any `yield` or `yield*` statements.
|
||||
void recurse(Statement statement) {
|
||||
int yieldCountIn = yieldCount;
|
||||
int targetsIn = targets.length;
|
||||
statement.accept(this);
|
||||
if (yieldCount == yieldCountIn) targets.length = targetsIn;
|
||||
}
|
||||
|
||||
void addTarget(TreeNode node, StateTargetPlacement placement) {
|
||||
targets.add(StateTarget(targets.length, node, placement));
|
||||
}
|
||||
|
||||
@override
|
||||
void defaultStatement(Statement node) {
|
||||
// Statements not explicitly handled in this visitor can never contain any
|
||||
// `yield` or `yield*` statements. It is assumed that this holds for all
|
||||
// [BlockExpression]s in the function.
|
||||
}
|
||||
|
||||
@override
|
||||
void visitBlock(Block node) {
|
||||
for (Statement statement in node.statements) {
|
||||
recurse(statement);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void visitDoStatement(DoStatement node) {
|
||||
addTarget(node, StateTargetPlacement.Inner);
|
||||
recurse(node.body);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitForStatement(ForStatement node) {
|
||||
addTarget(node, StateTargetPlacement.Inner);
|
||||
recurse(node.body);
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitIfStatement(IfStatement node) {
|
||||
recurse(node.then);
|
||||
if (node.otherwise != null) {
|
||||
addTarget(node, StateTargetPlacement.Inner);
|
||||
recurse(node.otherwise!);
|
||||
}
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitLabeledStatement(LabeledStatement node) {
|
||||
recurse(node.body);
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitSwitchStatement(SwitchStatement node) {
|
||||
for (SwitchCase c in node.cases) {
|
||||
addTarget(c, StateTargetPlacement.Inner);
|
||||
recurse(c.body);
|
||||
}
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitTryCatch(TryCatch node) {
|
||||
recurse(node.body);
|
||||
for (Catch c in node.catches) {
|
||||
addTarget(c, StateTargetPlacement.Inner);
|
||||
recurse(c.body);
|
||||
}
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitTryFinally(TryFinally node) {
|
||||
recurse(node.body);
|
||||
addTarget(node, StateTargetPlacement.Inner);
|
||||
recurse(node.finalizer);
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitWhileStatement(WhileStatement node) {
|
||||
addTarget(node, StateTargetPlacement.Inner);
|
||||
recurse(node.body);
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitYieldStatement(YieldStatement node) {
|
||||
yieldCount++;
|
||||
addTarget(node, StateTargetPlacement.After);
|
||||
}
|
||||
}
|
||||
import 'state_machine.dart';
|
||||
|
||||
/// A specialized code generator for generating code for `sync*` functions.
|
||||
///
|
||||
|
@ -177,7 +29,7 @@ class SyncStarCodeGenerator extends CodeGenerator {
|
|||
SyncStarCodeGenerator(super.translator, super.function, super.reference);
|
||||
|
||||
/// Targets of the CFG, indexed by target index.
|
||||
final List<StateTarget> targets = [];
|
||||
late final List<StateTarget> targets;
|
||||
|
||||
// Targets categorized by placement and indexed by node.
|
||||
final Map<TreeNode, StateTarget> innerTargets = {};
|
||||
|
@ -209,20 +61,20 @@ class SyncStarCodeGenerator extends CodeGenerator {
|
|||
void generate() {
|
||||
closures = Closures(translator, member);
|
||||
setupParametersAndContexts(member.reference);
|
||||
generateBodies(member.function!);
|
||||
_generateBodies(member.function!);
|
||||
}
|
||||
|
||||
@override
|
||||
w.BaseFunction generateLambda(Lambda lambda, Closures closures) {
|
||||
this.closures = closures;
|
||||
setupLambdaParametersAndContexts(lambda);
|
||||
generateBodies(lambda.functionNode);
|
||||
_generateBodies(lambda.functionNode);
|
||||
return function;
|
||||
}
|
||||
|
||||
void generateBodies(FunctionNode functionNode) {
|
||||
void _generateBodies(FunctionNode functionNode) {
|
||||
// Number and categorize CFG targets.
|
||||
_YieldFinder(this).find(functionNode);
|
||||
targets = YieldFinder(translator.options.enableAsserts).find(functionNode);
|
||||
for (final target in targets) {
|
||||
switch (target.placement) {
|
||||
case StateTargetPlacement.Inner:
|
||||
|
|
Loading…
Reference in New Issue
Block a user