diff --git a/Sources/armory/system/Assert.hx b/Sources/armory/system/Assert.hx new file mode 100644 index 00000000..abb0ec20 --- /dev/null +++ b/Sources/armory/system/Assert.hx @@ -0,0 +1,131 @@ +package armory.system; + +import haxe.Exception; +import haxe.PosInfos; +import haxe.exceptions.PosException; +import haxe.macro.Context; +import haxe.macro.Expr; + +using haxe.macro.ExprTools; + +class Assert { + + /** + Checks whether the given expression evaluates to true. If this is not + the case, an `ArmAssertionException` with additional information is + thrown. + + The assert level describes the severity of the assertion. If the + severity is lower than the level stored in the `arm_assert_level` flag, + the assertion is omitted from the code so that it doesn't decrease the + runtime performance. + + @param level The severity of this assertion. + @param condition The conditional expression to test. + @param message Optional message to display when the assertion fails. + + @see `AssertLevel` + **/ + macro public static function assert(level: ExprOf, condition: ExprOf, message: String = ""): Expr { + final levelVal: AssertLevel = AssertLevel.fromExpr(level); + final assertThreshold = AssertLevel.fromString(Context.definedValue("arm_assert_level")); + + if (levelVal < assertThreshold) { + return macro {}; + } + + switch (levelVal) { + case Warning: + return macro { + if (!$condition) { + @:pos(condition.pos) + trace(@:privateAccess armory.system.Assert.ArmAssertionException.formatMessage($v{condition.toString()}, $v{message})); + } + } + case Error: + return macro { + if (!$condition) { + #if arm_assert_quit kha.System.stop(); #end + + @:pos(condition.pos) + @:privateAccess throwAssertionError($v{condition.toString()}, $v{message}); + } + } + default: + throw new Exception('Unsupported assert level: $levelVal'); + } + } + + /** + Helper function to prevent Haxe "bug" that actually throws an error + even when using `macro throw` (inlining this method also does not work). + **/ + static function throwAssertionError(exprString: String, message: String, ?pos: PosInfos) { + throw new ArmAssertionException(exprString, message, pos); + } +} + +/** + Exception that is thrown when an assertion fails. + + @see `Assert` +**/ +class ArmAssertionException extends PosException { + + /** + @param exprString The string representation of the failed assert condition. + @param message Custom error message, use an empty string to omit this. + **/ + public inline function new(exprString: String, message: String, ?previous: Exception, ?pos: Null) { + super('\n${formatMessage(exprString, message)}', previous, pos); + } + + static inline function formatMessage(exprString: String, message: String): String { + final optMsg = message != "" ? '\n\tMessage: $message' : ""; + + return 'Failed assertion:$optMsg\n\tExpression: ($exprString)'; + } +} + +enum abstract AssertLevel(Int) from Int to Int { + /** + Assertions with this severity don't throw exceptions and only print to + the console. + **/ + var Warning: AssertLevel = 0; + + /** + Assertions with this severity throw an `ArmAssertionException` if they + fail, and optionally quit the game if the `arm_assert_quit` flag is set. + **/ + var Error: AssertLevel = 1; + + /** + Completely disable assertions. Don't use this level in `assert()` calls! + **/ + var NoAssertions: AssertLevel = 2; + + public static function fromExpr(e: ExprOf): AssertLevel { + switch (e.expr) { + case EConst(CIdent(v)): return fromString(v); + default: throw new Exception('Unsupported expression: $e'); + }; + } + + /** + Converts a string into an `AssertLevel`, the string must be spelled + exactly as the assert level. `null` defaults to + `AssertLevel.NoAssertions`. + **/ + public static function fromString(s: Null): AssertLevel { + return switch (s) { + case "Warning": Warning; + case "Error": Error; + case "NoAssertions" | null: NoAssertions; + default: throw new Exception('Could not convert "$s" to AssertLevel'); + } + } + + @:op(A < B) static function lt(a: AssertLevel, b: AssertLevel): Bool; + @:op(A > B) static function gt(a: AssertLevel, b: AssertLevel): Bool; +} diff --git a/blender/arm/props.py b/blender/arm/props.py index 7d71f07c..f18349e3 100755 --- a/blender/arm/props.py +++ b/blender/arm/props.py @@ -233,6 +233,14 @@ def init_properties(): bpy.types.World.arm_lod_gen_levels = IntProperty(name="Levels", description="Number of levels to generate", default=3, min=1) bpy.types.World.arm_lod_gen_ratio = FloatProperty(name="Decimate Ratio", description="Decimate ratio", default=0.8) bpy.types.World.arm_cache_build = BoolProperty(name="Cache Build", description="Cache build files to speed up compilation", default=True) + bpy.types.World.arm_assert_level = EnumProperty( + items=[ + ('Warning', 'Warning', 'Warning level, warnings don\'t throw an ArmAssertException'), + ('Error', 'Error', 'Error level. If assertions with this level fail, an ArmAssertException is thrown'), + ('NoAssertions', 'No Assertions', 'Ignore all assertions'), + ], + name="Assertion Level", description="Ignore all assertions below this level (assertions are turned off completely for published builds)", default='Warning', update=assets.invalidate_compiler_cache) + bpy.types.World.arm_assert_quit = BoolProperty(name="Quit On Assertion Fail", description="Whether to close the game when an 'Error' level assertion fails", default=False, update=assets.invalidate_compiler_cache) bpy.types.World.arm_live_patch = BoolProperty(name="Live Patch", description="Live patching for Krom", default=False) bpy.types.World.arm_play_camera = EnumProperty( items=[('Scene', 'Scene', 'Scene'), diff --git a/blender/arm/props_ui.py b/blender/arm/props_ui.py index 74e489aa..14450fc0 100644 --- a/blender/arm/props_ui.py +++ b/blender/arm/props_ui.py @@ -925,17 +925,19 @@ class ARM_PT_ProjectFlagsPanel(bpy.types.Panel): layout.use_property_decorate = False wrd = bpy.data.worlds['Arm'] - col = layout.column(heading='Debug') + col = layout.column(heading='Debug', align=True) col.prop(wrd, 'arm_verbose_output') col.prop(wrd, 'arm_cache_build') + col.prop(wrd, 'arm_assert_level') + col.prop(wrd, 'arm_assert_quit') - col = layout.column(heading='Runtime') + col = layout.column(heading='Runtime', align=True) col.prop(wrd, 'arm_live_patch') col.prop(wrd, 'arm_stream_scene') col.prop(wrd, 'arm_loadscreen') col.prop(wrd, 'arm_write_config') - col = layout.column(heading='Renderer') + col = layout.column(heading='Renderer', align=True) col.prop(wrd, 'arm_batch_meshes') col.prop(wrd, 'arm_batch_materials') col.prop(wrd, 'arm_deinterleaved_buffers') diff --git a/blender/arm/write_data.py b/blender/arm/write_data.py index 3bb2ef50..0fa22895 100755 --- a/blender/arm/write_data.py +++ b/blender/arm/write_data.py @@ -159,7 +159,9 @@ project.addSources('Sources'); if wrd.arm_asset_compression: assets.add_khafile_def('arm_compress') else: - pass + assets.add_khafile_def(f'arm_assert_level={wrd.arm_assert_level}') + if wrd.arm_assert_quit: + assets.add_khafile_def('arm_assert_quit') # khafile.write("""project.addParameter("--macro include('armory.trait')");\n""") # khafile.write("""project.addParameter("--macro include('armory.trait.internal')");\n""") # if export_physics: