Nullability
Engine-owned documentation. This page defines the reusable compiler, runtime, and native-boundary contract. Project-side analyzers may enforce stricter authoring policy, but they are not part of the engine contract.
Convention and runtime enforcement for nullable values across AngelScript, Managed C#, and the native engine boundary. For the broader scripting runtime, see Scripting; for exported native method ownership, see Script Methods Map.
Core principle
Better to not pass
nullat all than to defensively check inside and bail out.A parameter or return may be marked nullable only when the function meaningfully handles both null and non-null cases. Early-exit-on-null guards are a code smell — the contract should be non-null and the caller fixed instead.
This applies symmetrically on both sides of the script-engine boundary.
Managed C# side
Generated Managed sources enable nullable reference types and map the same metadata nullable bit to C# ? on entity, string, and ref-type references. Value types never carry that bit. A native ptr<T> becomes a non-null C# reference contract; nptr<T>, a nullable property flag, or a nullable event/remote-call tag becomes T?. Generated methods, properties, events, delegates, and remote-call caller methods preserve the spelling so Roslyn can diagnose an unchecked dereference or an incompatible assignment at compile time.
C# annotations are not a runtime ownership guarantee. A managed entity wrapper remains borrowed, may refer to an entity destroyed after an await, and must be re-resolved/revalidated together with server cover. Narrow an expected absence with an ordinary is null / is not null branch. For an invariant, use Game.VerifyNotNull(value, message) or Game.Verify(...) and keep the narrowed non-null value; do not suppress a warning with ! unless an external API has made the proof invisible to the compiler.
Metadata declarations remain authoritative across backends. A nullable ///@ Event or ///@ RemoteCall argument generates a nullable C# parameter, and the attributed [Event], [ServerRemoteCall], or [ClientRemoteCall] handler must preserve the same semantic contract. Managed build validation consists of the generated project compile with nullable diagnostics enabled, configured Roslyn analyzers, and a runtime callback/serialization test where null is legitimate. See Managed C# Scripting for generation, async lifetime, analyzers, and packaging.
AngelScript side: T? suffix
AngelScript modules use a Kotlin/C#-style ? suffix on the type to mark nullability. Default is non-nullable.
// Return may be null
Location? GetCritterLocation(Critter cr)
{
if (cr.MapId == ZERO_IDENT) {
return null;
}
Map map = cr.GetMap();
return map != null ? map.GetLocation() : null;
}
// Parameter may be null — body handles both cases
void ResolveTargetHex(Critter cr, Critter? target, mpos fallbackHex)
{
mpos resolvedTargetHex = target != null ? target.Hex : fallbackHex;
// ...
}
? is parsed by the AngelScript front-end itself (see ParseType in ../ThirdParty/AngelScript/sdk/angelscript/source/as_parser.cpp, MakeNullable/isNullable in ../ThirdParty/AngelScript/sdk/angelscript/source/as_datatype.h, and the CreateDataTypeFromNode consumer in ../ThirdParty/AngelScript/sdk/angelscript/source/as_builder.cpp). The marker is no longer rewritten by the preprocessor — the StripNullableTypeSuffix pass was removed once the engine learned the suffix directly. Misplaced markers (e.g. int?) produce a compile-time error: “Nullable marker ‘?’ is only allowed on handle types”.
The AS parser disambiguates the type-suffix ? from the ternary ? by only consuming it inside ParseType. Inside expressions, cond ? a : b continues to parse as the conditional operator.
///@ Event and ///@ RemoteCall declarations
The same ? suffix is supported in ///@ Event and ///@ RemoteCall tag declarations, and the MetadataBaker propagates the per-arg nullable bit into the baked engine metadata (ArgDesc::Nullable on EntityEventDesc::Args / RemoteCallDesc::Args).
///@ Event Server Game OnCritterDamaged(Critter cr, Critter? attacker, int32 damage)
///@ Event Server Game OnCritterDead(Critter critter, Critter? killer)
///@ RemoteCall Server SwitchCharacter(Critter? newCritter)
The declaration is the contract. Every [[Event]] subscriber and every [[ServerRemoteCall]] / [[ClientRemoteCall]] implementation that matches the event/call name must use the same ? marker on each argument. The baker and side-specific handler binding enforce this relationship for remote calls; see Remote Calls. [[AdminRemoteCall]] is a separate command entry point rather than a ///@ RemoteCall target.
// Matches the OnCritterDamaged declaration above.
[[Event]]
void OnCritterDamaged(Critter cr, Critter? attacker, int32 damage) { ... }
// Violates declaration parity: declaration has `Critter?`, handler drops `?`.
[[Event]]
void OnCritterDamaged(Critter cr, Critter attacker, int32 damage) { ... }
Because the AngelScript front-end now tracks the per-type nullable bit, the AS engine itself enforces null contracts on handle writes at runtime (see “Runtime enforcement” below). Event and remote-call declaration parity remains a project-side static-analysis responsibility: the AS engine has no way to know that two otherwise unrelated declarations are supposed to share a contract.
Engine side: ptr<T> / nptr<T> and raw-pointer nullability
Native methods declared with ///@ ExportMethod in ../Source/Scripting/, exported events, FO_ENTITY_EVENT payloads, ///@ ExportRefType methods, and ///@ EngineHook declarations express handle nullability through the smart-pointer vocabulary: a non-null entity/engine/ref pointer is ptr<T>, a nullable one is nptr<T> (see SmartPointers.md, “Script binding boundary”). Raw handle pointers (T*) are not a nullable export spelling anymore: codegen rejects them in exported/event signatures, and the native marshalling templates static-assert if a raw handle pointer reaches the ABI layer. Raw pointers that remain in generic AngelScript plumbing, generated registration strings, handle slots, process argv, or external C callbacks are ordinary low-level ABI values; bind them to make_ptr(raw) / make_nptr(raw) before engine work. The owning method files are mapped in Script Methods Map. (The former empty FO_NULLABLE marker macro has been removed; pointer nullability is now carried by the ptr<T> / nptr<T> spelling.)
For native C++ code outside exported script signatures, use the pointer vocabulary in SmartPointers.md: ptr<T> for borrowed non-null values, nptr<T> for borrowed nullable values, and the matching unique_* / refcount_* owning forms. nptr<T>, unique_nptr<T>, and refcount_nptr<T> are separate nullable wrapper types; the remaining migration work is tightening nullable operations on the non-null spellings. When an owner is only borrowed to perform a dynamic cast, call owner.dyn_cast<T>() directly instead of building an intermediate owner.as_ptr().dyn_cast<T>().
Stored native ScriptFunc signatures are part of the same contract. Their pointer spellings must match the script callback declaration argument by argument: a script callback parameter declared Item? is stored and looked up as nptr<Item>, while Item uses ptr<Item>. Otherwise a legitimate null can cross the exported method boundary successfully and then fail later when the callback wrapper implicitly narrows it.
Prefer the non-null spelling; reach for nptr<T> only when absence is a real, handled state. A nullable wrapper is dead weight when every caller already passes non-null, a function never returns null, or a member is always set before use — convert those to ptr<T> / unique_ptr<T> / refcount_ptr<T>. In particular, do not make a raw pointer + size buffer parameter nullable just so the degenerate empty case may pass nullptr — take a non-null const_span<uint8_t> / span<uint8_t> (the engine’s standard byte-buffer vocabulary) and let .empty() handle the zero-length case. This both removes the spurious nullability and deletes the nptr<T> x = nullptr; if (!c.empty()) x = c.data(); f(x, c.size()) boilerplate at every call site (f(c)). Conversely, leave nptr<T> in place where absence is a real, handled state: the legitimate result of a fallible cast/lookup, a data member with a real transient-null window between construction and assignment, or a defensive boundary helper that deliberately accepts a nullable and asserts. In that case keep the checked value as nptr<T> and dereference it directly after the guard (if (!x) { ... return; } / FO_VERIFY_AND_THROW(x, ...) then x->) rather than copying it into a nullable_x intermediate and narrowing. Past the guard the checked nptr<T> also flows into any ptr<T> parameter, member, or return implicitly — the nptr<T>→ptr<T> conversion asserts non-null at the conversion point. Owning wrappers (refcount_ptr/refcount_nptr, unique_ptr/unique_nptr, unique_del_ptr/unique_del_nptr, unique_arr_ptr, and shared_ptr) likewise borrow implicitly to ptr<T> / nptr<T>; ownership acquisition and nullable-owner ownership narrowing remain explicit (hold_ref, adopt_unique_ptr, make_unique_del_ptr, take_not_null, safe_alloc::make_shared, or a domain factory). Owner dynamic casts should be direct (owner.dyn_cast<T>()) instead of going through an intermediate borrow. Freshly assigned non-null owner factory results can be used through the source owner directly when only a few member accesses follow. When a nullable/nullable-owner local is needed and later requires presence, bind the nullable wrapper with auto, then check it explicitly with FO_VERIFY_AND_THROW(local, ...) (or FO_STRONG_ASSERT(local, ...) inside noexcept) before deref; do not wrap the local in a double-negation expression for these guards. Explicit .as_ptr() / .as_nptr() calls are valid when they clarify the borrow or resolve overload/template deduction; implicit conversion remains available when the destination type is unambiguous. When a raw pointer enters native code, use make_ptr(raw_value) / make_nptr(raw_value). The audit still enforces guarded nullable dereference through NullableLocalDereference; see SmartPointers.md.
///@ ExportMethod
FO_SCRIPT_API nptr<Map> Server_Critter_GetMap(ptr<Critter> self)
{
return self->GetEngine()->EntityMngr.GetMap(self->GetMapId());
}
///@ ExportMethod
FO_SCRIPT_API void Server_Player_SwitchCritter(ptr<Player> self, nptr<Critter> cr)
{
self->GetEngine()->SwitchPlayerCritter(self, cr);
}
The self (first parameter — this receiver) and the implicit engine parameter for global methods are never marked: AS validates this before dispatch.
If an exported method gives a pointer argument the default value nullptr, spell that argument nptr<T> so it is nullable; codegen records the default as script null, and the nullable spelling keeps the generated native-call validation aligned with the callable signature. A non-null ptr<T> argument cannot default to nullptr.
Component accessors are non-nullable and throw; probe with Has<Component>
An entity component getter (item.Weapon, item.MapExit, cr.DialogContext, item.Locker, … — every property declared ///@ Property <Entity> ... <Name> Component) is non-nullable and throws when the component is absent. Entity_GetComponent in ../Source/Scripting/AngelScript/AngelScriptEntity.cpp raises a ScriptException unless the presence bool is set, so the getter is registered as {}@ get_{}() const (no ?). Alongside each component getter a bool get_Has<Component>() const accessor is registered for presence probes.
// item.Weapon is ItemWeaponComponent (non-nullable) — access directly
int dist = item.Weapon.MaxDist; // OK; throws iff the item is not a weapon
// probe presence with Has<Component>, never `== null`
if (item.HasWeapon) {
int d = item.Weapon.MaxDist; // guarded
}
verify(item.HasWeapon, "Item must be a weapon");
This mirrors the throwing global getters below (Chosen / CurMap / …): a missing component in code that already assumes it is present is an invariant violation, not a recoverable null. Do not write item.Weapon == null / != null — the getter would throw at the access; write !item.HasWeapon / item.HasWeapon. The four entity flavors (concrete / Abstract / Proto / Static) and fixed types all share this registration, so proto.HasWeapon, abstractItem.HasWeapon, etc. are all available. (One name clash to keep in mind: Ammo is both an Item component and a nullable property of the weapon component — item.Weapon.Ammo is the loaded ammo item and remains a normal nullable == null check.)
Throwing global getters: Chosen / CurMap / CurLocation / CurPlayer
The client global getters Chosen, CurMap, CurLocation, CurPlayer (registered in ../Source/Scripting/ClientGlobalScriptMethods.cpp) are non-nullable and throw when accessed while absent (e.g. “No chosen critter”). This is deliberate: most client code runs only in contexts where they exist (the in-game UI is disabled without a Chosen), so it should read Chosen.X directly without a null dance. To check presence where absence is a valid state, use the matching bool predicate:
if (!HasChosen) {
return; // no chosen critter right now - handle it
}
Critter cr = Chosen; // OK - non-nullable, guaranteed present here
HasChosen / HasCurMap / HasCurLocation / HasCurPlayer are the presence checks. Do not write Chosen == null / Chosen != null: comparing a non-nullable handle to null both trips the redundant-comparison warning (#5) and throws (evaluating Chosen when absent), so it is doubly wrong - use !HasChosen / HasChosen instead.
Game during shutdown: IsGameDestroying
Game is a non-nullable global handle (GameSingleton@), so script code reads Game.X directly without a null dance. But the game engine is genuinely absent in one window: script-object destructors that run while the scripting backend is being torn down. The AngelScript GC runs object destructors from inside ~AngelScriptBackend after the engine pointer has already been reset (../Source/Scripting/AngelScript/AngelScriptBackend.cpp), and get_Game is a throwing getter — it raises “Game engine is not available” when the backend has no engine. So a ~T() that touches Game.* during shutdown throws from the destructor, and Game != null cannot guard it either (same double-wrong as Chosen != null: redundant-comparison warning #5, plus Game throwing as it is evaluated).
The bool IsGameDestroying global getter (registered next to get_Game() in ../Source/Scripting/AngelScript/AngelScriptGlobals.cpp) is the probe for exactly this case: it is true precisely when Game is unavailable (the backend’s HasGameEngine() is false — the same condition under which get_Game throws), and reading it never evaluates the Game getter. Use it to skip engine-dependent cleanup in destructors:
~Sprite()
{
// Game engine may already be gone during shutdown; freeing the sprite then is both impossible and unnecessary
if (!IsGameDestroying) {
Unload(); // calls Game.FreeSprite(...)
}
}
Reach for IsGameDestroying only in destructors (or other teardown paths that can run during backend destruction). Everywhere else Game is guaranteed present — read it directly.
Throwing proto getters: Game.GetProtoItem/Critter/Map/Location + CheckProtoX
Game.GetProtoItem, GetProtoCritter, GetProtoMap, GetProtoLocation (in ../Source/Scripting/CommonGlobalScriptMethods.cpp) are non-nullable and throw when no proto with that id exists (e.g. “Item proto not found”). Each has a matching Game.CheckProtoItem/Critter/Map/Location(pid) bool predicate for the case where the id may legitimately be missing (stale checkpoint/map data, user-supplied ids, …).
// id known to exist - read directly:
ProtoItem proto = Game.GetProtoItem(Content::Item::Dynamite);
// id may be missing - probe first, or keep a nullable local via a guarded ternary:
if (!Game.CheckProtoMap(mapPid)) {
return; // unknown map proto - handle it
}
ProtoMap proto = Game.GetProtoMap(mapPid);
ProtoLocation? loc = Game.CheckProtoLocation(locPid) ? Game.GetProtoLocation(locPid) : null;
if (loc == null) { /* recover */ }
Do not write Game.GetProtoX(pid) == null / != null (it throws on a missing proto before the comparison) - use !Game.CheckProtoX(pid) / Game.CheckProtoX(pid).
The same applies to the codegen-generated proto/fixed-type getters for custom entities (Game.GetProtoModifier, Game.GetProtoFaction, Game.GetEncounterProfileData, Game.GetWeatherType, Game.GetItemBag, …): they are non-nullable and throw when the id is unknown, with a matching Game.Check<Name>(pid) predicate. Registered in register_entity_protos / register_fixed_type (Game_GetProtoCustomEntity / Game_CheckProtoCustomEntity) in AngelScriptEntity.cpp. Read directly when the id is known (instance.ProtoId, an authored content id); probe with Check<Name> only where the id may legitimately be absent.
Runtime enforcement
There are now two complementary runtime gates:
Managed indexed interop applies the same contract to its packed ABI. Scalar and
plain fixed-value data are non-nullable value slots. Entity, prototype, fixed
entity, and native reference arguments use pointer-sized handle slots whose
nullable bit comes from the generated ManagedInteropAbi manifest. ABI binding
rejects a generated/native manifest mismatch before scripts start, while the
generated wrapper performs the required null check at the call boundary. Do not
encode optional references as zero-valued fixed data or relax a metadata
declaration to work around binding failures.
Script-side: asBC_RefCpyChk on handle assignments
The AngelScript compiler emits a new asBC_RefCpyChk bytecode (defined in ../ThirdParty/AngelScript/sdk/angelscript/include/angelscript.h, handler in ../ThirdParty/AngelScript/sdk/angelscript/source/as_context.cpp) whenever the destination of a handle write is non-nullable (T without ?) and the destination is user-declared (not a compiler-generated temporary). It is a drop-in REFCPY variant that raises a Null assignment to non-nullable handle exception when the source handle is null. Emission sites are PerformAssignment and CompileInitializationWithAssignment in ../ThirdParty/AngelScript/sdk/angelscript/source/as_compiler.cpp; T? declarations fall back to the original asBC_REFCPY and accept null silently.
The non-nullable test is keyed off the declared type of the destination, not a smart-cast narrowed view of it: a declared-nullable local or & parameter that is currently narrowed (see Smart-cast) is still a REFCPY destination, so x = null; inside the narrowing guard is a legal un-narrowing write, not a null write into a non-nullable slot. PerformAssignment restores the declared nullability on the lvalue before choosing the copy instruction and then invalidates the narrowing, so the next read sees T? again. (Before this fix the instruction was chosen from the narrowed type, which compiled the branch into an always-throwing asBC_RefCpyChk — the “Null assignment to non-nullable handle” ScriptExceptions from Combat::DeferredAttackHit and cursor handling were this defect.)
The temp guard matters because PerformAssignment is reused for argument-setup slots whose type is inherited from a native parameter (no ? syntax on the AS side). Nullability of those slots is owned by the native-boundary check below — letting the AS-level check skip temporaries keeps func(null) working for nullable (nptr<T>) natives.
This means script-to-script assignments of null to a non-nullable handle still throw, even when the value originates from another script function rather than a native call.
Native-boundary: codegen-emitted arg/return checks
Native validation continues to be plumbed through codegen-generated MethodDesc::Call lambdas, not the AS-to-native bridge. ../BuildTools/codegen.py emits per-method calls to NativeDataProvider::CheckArgNotNull / CheckReturnNotNull (defined in ../Source/Common/ScriptSystem.h) right before/after the native invocation:
MethodDesc::Call(call)
→ NativeDataProvider::CheckArgNotNull(call, i, "Server_Player_SetCritter", "cr", "Critter") // for each non-nullable entity arg
→ native invocation
→ NativeDataProvider::CheckReturnNotNull(call, "...", "...") // for non-nullable entity return
Array parameters and returns are not blanket-scanned at this boundary. Script arrays can intentionally contain null handle cells (for sparse grids, nullable-element arrays, or caller-owned filtering), and the native export metadata currently has no per-element nullability bit that can distinguish array<T> from array<T?> for generated C++ wrappers. If a particular API requires non-null elements, enforce that invariant in the API-specific producer/consumer and document it there; do not rely on a global codegen check that would reject legitimate nullable arrays.
Doing scalar checks at the MethodDesc::Call boundary means every caller of an ///@ ExportMethod is covered — the AS-to-native bridge, native test harnesses, future Mono-backend dispatch, anyone. Scalar checks cost a single pointer compare.
Violation surface: ScriptException, propagated to the calling AngelScript context. Three distinct messages:
- “Null assignment to non-nullable handle” — raised by
asBC_RefCpyChk(the new AS-side check on bare-handle writes). Distinct from the generic null-deref so stack traces clearly point at the bad assignment rather than a downstream method call. - “Null pointer access” — the original AS message, raised by
asBC_CHKREF/asBC_ChkRefS/asBC_ChkNullV/asBC_ChkNullSfor dereferences, indexing, and method calls on a null handle. - Native-boundary scalar checks emit the method name, parameter name when applicable, and type via the codegen-generated
NativeDataProvider::CheckArgNotNull/CheckReturnNotNull.
Compile-time guarantees
In addition to the runtime asBC_RefCpyChk, the AS front-end raises two compile-time errors on null-unsafe assignments before any bytecode is generated:
-
Bare
nullto a non-nullable handle is always rejected. Always-on, no engine property required. Emits: “Cannot assign ‘null’ to a non-nullable handle of type ‘T’ (use ‘T?’ to allow null)”. This catchesT x = null;,someField = null;, and the implicitT x;form that AS lowers into a null initializer. -
Nullable handle source to a non-nullable destination is rejected when
asEP_DISALLOW_NULLABLE_TO_NON_NULLABLEis enabled. FOnline enables this property in ../Source/Scripting/AngelScript/AngelScriptBackend.cpp. Emits: “Cannot assign nullable ‘T?’ to non-nullable ‘T’ without a null-check (addif (src != null)or change the destination to ‘T?’)”. - Redundant
?on local initializer is warned about. When the destination is declaredT?but the initializer is statically a non-nullable handle (the source type cannot producenull), the front-end emits: “Redundant ‘?’: initializer of type ‘T’ cannot be null; declare destination as ‘T’ instead”. This catches staleT?annotations left behind after an API tightened its return type (e.g.nptr<Critter>→ptr<Critter>), and theif (cr == null)dead branch that usually follows. Emitted fromCompileInitializationin ../ThirdParty/AngelScript/sdk/angelscript/source/as_compiler.cpp. The diagnostic is a warning (compilation succeeds) so existing scripts keep building while authors clean up; the message names both the current source type and the bare destination spelling so the fix is a one-character drop. Like the deref (#4) and redundant-comparison (#5) checks it trusts the static non-nullability of the source, including a non-const handle reference: reading anarr[i]cell of a non-nullable element array, or a non-nullableTfield/local by reference, aliases storage whose non-null invariant is enforced on every write, soT? x = arr[i]is flagged exactly asarr[i] != nullis by #5. Two source shapes are exempted, because a non-null static type does not guarantee a non-null value there:cast<T>(...)andcond ? a : b— a failed reference cast yields null, and ternary branch types may differ (usecast<T?>when the cast can fail).- a const handle reference
T@const&—dict.get(key, default)substitutes its (possibly null) default and yields null on a missing key, so the?onT? v = someDict.get(k, null)is genuinely needed, not redundant.
-
Dereferencing an un-narrowed nullable handle is warned about. When a
T?value is dereferenced (x.Member,x[i],x.Method()) without first narrowing it to non-null, the front-end emits: “Dereference of nullable handle ‘T?’ without a null-check; narrow it first (e.g.if (x == null) return;) so it becomes ‘T’“. Emitted from thettDotbranch ofCompileExpressionPostOpin ../ThirdParty/AngelScript/sdk/angelscript/source/as_compiler.cpp. It is a warning, not an error — the runtime null-deref check (asBC_CHKREFetc.) is still the safety net — so authors clean up at their own pace. Narrowing only tracks named locals/params:x.Method() == null || x.Method().Foostill warns on the second call because each getter call is a fresh expression; bind to a local (auto v = x.Method(); if (v == null) return; v.Foo) to narrow. - Redundant null comparison against a non-nullable handle is warned about. When one operand of
==/!=isnulland the other is a statically non-nullable handle — a named local/param or a temporary (e.g. a property/getter call result) — the comparison has a constant result and the guarded branch is dead. The front-end emits: “Redundant null comparison: ‘T’ is a non-nullable handle and can never be null; remove the check (or make the source nullable if it actually can be null)”. Emitted fromCompileOperatorOnHandlesin ../ThirdParty/AngelScript/sdk/angelscript/source/as_compiler.cpp. This is the inverse of the deref warning (#4): together they push every nullable value toward exactly one null-check at the point it is introduced. Like #3/#4 it is a warning. The “or make the source nullable” hint matters — a hit usually means the source is mis-modeled as non-nullable, and the fix is to spell it nullable rather than delete the check. The common cases:- A throwing getter (
Chosen,CurMap, aitem.Weaponcomponent getter,Game.GetProtoModifier(...)) returns non-null and throws when absent, sogetter() != nullis a footgun — it throws before the comparison. Use the matchingHas*/Check*probe (HasChosen,item.HasWeapon,Game.CheckProtoModifier(...)). - A fallible reference cast
cast<T>(x)is non-null-typed but yields null on a failed downcast — writecast<T?>(x)so the result isT?and the!= nullis a legitimate check (see Reference casts). - A proto / fixed-type property handle that may be unset (
UsableOn.TargetItem,Harvested.SmallBag,Weapon.Ammo) — declare the///@ Propertywith theNullableflag so its getter is registered@?(see Nullable property handles). - A native return that can be null but is spelled non-null
ptr<T>, or a genuinely-non-null source where the check is truly dead — spell itnptr<T>/ remove the check respectively.
Both named operands and temporaries are checked, so binding a fallible value to a local does not silence the warning — only spelling the source nullable (
cast<T?>,Nullableflag,nptr<T>) does. That is deliberate: it forces the type to tell the truth instead of relying on a hidden runtime-null. - A throwing getter (
Conditional expressions propagate nullability. A cond ? a : b result is typed T? when either branch is statically nullable (or a null literal). CompileCondition (FOnline patch in ../ThirdParty/AngelScript/sdk/angelscript/source/as_compiler.cpp) captures this before the branch-type unification can drop the flag, then re-applies it to the result handle. This lets the assignment (#2), deref (#4), and redundant-comparison (#5) checks treat Entity x = cond ? a? : b as a nullable source and flag it at compile time, instead of leaving it solely to the runtime asBC_RefCpyChk. The redundant-? exemption for ternaries in #3 still stands: a ternary whose branches are statically non-null can still yield null at runtime through a failed reference cast in one branch, so T? x = cond ? a : b is not reported as a redundant ?. (The change is compile-time diagnostics only — it does not alter emitted bytecode, since the runtime asBC_RefCpyChk is keyed off the assignment destination, so no compatibility-version bump is required.)
The runtime asBC_RefCpyChk is still the safety net underneath both errors: even when the compile-time pass accepts an assignment (e.g. through a dict<K, V>.get(key, null) that returns a statically-non-nullable reference bound to null at runtime), the bytecode still throws on null write.
Identity comparison: == only, never is / !is
is and !is are banned in .fos by project convention. Use == and != for both null checks and handle-identity comparison. A FOnline patch on CompileOperator (../ThirdParty/AngelScript/sdk/angelscript/source/as_compiler.cpp) falls back to asBC_CmpPtr (handle identity) when no opEquals is registered for the ref type, so the two forms produce the same bytecode:
| Operands | == / != behavior |
|---|---|
Both sides are entity types (Critter, Item, Map, …) with codegen-emitted opEquals |
id-based equality (compares entity id) |
Either side is null |
null-handle compare |
Both sides are ref types without opEquals (Gui::Screen, Object, custom classes, funcdef handles) |
handle-identity (asBC_CmpPtr) - same as is |
| One side is a reference and the other is a handle | implicit conversion to handle, then handle-identity |
This means the choice between “id-based” and “pointer-based” is determined by the type, not the operator, so the operator carries no extra information and is is pure cognitive noise. Projects that adopt the == / != convention should enforce it with a read-only source check in CI.
Smart-cast (flow-sensitive narrowing)
Kotlin-style smart-cast narrows a T? local back to T inside a region that is provably non-null, so the body can use it without an explicit cast. Implemented as a per-scope smartCasts stack in ../ThirdParty/AngelScript/sdk/angelscript/source/as_compiler.cpp; see DetectNullCheckPattern, GetNarrowedTypeForLocal, and the integrations in CompileIfStatement (statement guards), CompileCondition (ternary branches), and CompilePostFixExpression (&& / || short-circuit operands). For the short-circuit case the != / == check tags its result context in CompileOperatorOnHandles; that tag carries a list of narrowed locals (nullCheckNarrowList on asCExprContext), and each chained && (keeps the !=-tags) / || (keeps the ==-tags) accumulates all matching-polarity narrowings from both operands — so a != null && b != null && a.X && b.Y narrows both a and b in the trailing operands, not just the nearest check. When a tagged operand lands on the postfix evaluation stack, ShortCircuitNarrowsRightOperand scans ahead to confirm it is consumed as the left operand of a matching short-circuit, and if so every narrowing in the list is pushed for that operator’s entire right operand (tracked by expr-stack index, popped when the operator is reached) — so x != null && x.Prop == y narrows x.Prop, not just a term sitting immediately before the &&. The tag stores each local’s stack offset, which is negative for parameters — so a dedicated nullCheckNarrowValid flag (not the offset sign) marks “tag present”, otherwise narrowing would silently skip every parameter. For statement guards, a then/else branch counts as “exits without falling through” (so the complementary narrowing applies afterwards) not only for return/throw but also for break / continue (StatementAbortsFallThrough) — so if (x == null) continue; inside a loop narrows x for the rest of the body exactly like an early return. This narrowing-only signal never feeds the function-must-return-value analysis (which still ignores break/continue).
Supported patterns:
// 1) `if (x != null) { ... }` narrows in the then-branch
Item? maybeItem = GetMaybeItem();
if (maybeItem != null) {
Item item = maybeItem; // OK — compiler treats maybeItem as Item here
item.Use();
}
// 2) `if (x == null) { <recover>; return; } <code>` — early-exit narrows after the if
Critter? maybeCr = GetMaybeCr();
if (maybeCr == null) {
Logging::Warning("CharacterRoster", "main_critter_missing player=" + player.Name);
return null;
}
Critter cr = maybeCr; // OK — the early return rules out null
// 2b) break / continue guards narrow the same way (loop bodies)
for (int i = 0; i < ids.length(); i++) {
Critter? probe = Game.GetCritter(ids[i]);
if (probe == null) {
continue; // bails this iteration
}
probe.Use(); // OK — narrowed for the rest of the loop body
}
// 3) Compound `&&` / `||` shapes narrow every recognised atom
if (a != null && b != null && c != null) {
Item ai = a; Item bi = b; Item ci = c; // OK — all three narrowed
}
if (a == null || b == null) {
return;
}
Item ai = a; Item bi = b; // OK — both narrowed after early return
// 4) Assignment invalidates the narrowing on that local. The write itself goes
// through the DECLARED type — narrowing is a read-time refinement only — so
// `x = null;` inside the guard is a legal un-narrowing write (plain REFCPY),
// not a null write into a non-nullable slot.
if (x != null) {
x = GetMaybeNull(); // x becomes nullable again
Item y = x; // compile-time error here
}
if (x != null && x.IsBroken()) {
x = null; // OK — drops the narrowed view, x is `Item?` again
}
// 5) `&&` / `||` short-circuit narrows every later operand in the chain (any
// expression, not just an `if` condition). `&&` consumes a `!=` check (the
// rest of the chain runs only when the check was true); `||` consumes an
// `==` check. The check may sit anywhere in the chain, and works for locals
// and parameters alike.
bool ready = maybeItem != null && maybeItem.IsReady(); // narrowed in RHS
bool ok = maybeItem == null || maybeItem.IsReady(); // narrowed in RHS
bool both = Other() && maybeItem != null && maybeItem.IsReady(); // narrowed after the check
bool tail = maybeItem != null && Other() && maybeItem.IsReady(); // still narrowed at the tail
// the narrowing covers the WHOLE right operand, not just an adjacent term:
bool cmp = maybeItem != null && maybeItem.Id == wanted; // maybeItem.Id narrowed
if (maybeItem != null && maybeItem.Id > 0 && Other()) { ... } // narrowed across the compound
// every checked local in the chain narrows in the later operands, not just the nearest:
if (a != null && b != null && a.Id == b.Id) { ... } // both a and b narrowed
if (a == null || b == null || a.Id != b.Id) { return; } // both narrowed past the ||s
// 6) Ternary branches narrow when the condition is a null-check
int n = maybeItem != null ? maybeItem.Id : 0; // then-branch narrowed
int m = maybeItem == null ? 0 : maybeItem.Id; // else-branch narrowed
Smart-cast deliberately does not narrow:
- Class fields or globals (
obj.Field,g_Var). Snapshot to a local first. - Method return values (
GetMaybe()). Bind to a local. - The result of
cast<T>(...). A barecast<T>is non-nullable-typed but can fail at runtime — see Reference casts for thecast<T?>form and binding to a local. - Conditions with mixed
&&/||at the same precedence level inside anif. Split theif. - A local reassigned inside one of the chain’s operands (rare): the chain narrowing follows the immediate-narrowing contract and does not re-track the new value.
When smart-cast can’t see through the shape, the established fallbacks are: bind the expression to a local, change the destination to T?, or wrap the use in if (x == null) { <recover>; return ...; }.
Reference casts: cast<T?>(x)
A reference cast can fail at runtime: cast<T>(expr) yields null when expr is not a T. Spell the fallible form cast<T?>(x) so the result type is T? and the engine treats it honestly:
cast<T?>(x) != null/== nullis a legitimate null-check (no redundant-comparison warning, #5).cast<T?>(x).Memberrequires narrowing first (bind to a local, thenif (v == null) ...).
A bare cast<T>(x) keeps the non-nullable “this cast is known to succeed” contract (like a static_cast): you may chain cast<T>(x).Member directly, but cast<T>(x) != null is flagged redundant (#5) — switch to cast<T?>(x) there. The ? is honored by CompileConversion in ../ThirdParty/AngelScript/sdk/angelscript/source/as_compiler.cpp, which applies to.IsNullable() to the cast result (including the early-return path where an implicit conversion already produced the target type).
cast<T?>(x) also covers a same-type read that is statically non-null but can be null at runtime: a legacy/sparse field declared array<T> keeps the element type T, yet resize/grid growth can leave empty handle cells as null. Reading such a cell into a non-nullable T (T v = field[i];) throws Null pointer access, and writing T? v = field[i]; is flagged a redundant widening (#3) because the element type is non-null. Prefer declaring nullable-element storage as array<T?> / T?[] when the array is allowed to contain holes; when working with an existing array<T> sparse container, spell the read cast<T?>(field[i]) so the source type tells the truth.
For API parameters, native returns, and sync/lock scopes, null-element policy belongs to that specific API contract. Use nullable-element arrays when null is meaningful; otherwise validate at the producer or at the API entry point that owns the invariant.
Nullable property handles
A ///@ Property whose type is a proto or fixed-type handle (ProtoItem, ItemBag, ProtoMap, …) is non-nullable by default but returns null when the field is unset. When that “unset → null” state is legitimate, add the Nullable flag to the property tag so its AS getter is registered as @?:
///@ Property Item Server ProtoItem UsableOn.TargetItem Nullable
///@ Property Item Common ItemBag Harvested.SmallBag Nullable
///@ Property Critter Server ProtoItem StartWeapon Nullable
The flag is parsed in ../Source/Common/Properties.cpp (_isNullable) and consumed by the entity getter registration in ../Source/Scripting/AngelScript/AngelScriptEntity.cpp (@?); MetadataBaker accepts Nullable only on FixedType / Proto entity properties. Without it, proto.Field != null is flagged redundant (#5) even though the field can legitimately be unset — reach for the flag rather than deleting the check. (Spelling the type ItemBag? in the tag does not work — nullability of a stored property is a flag, not a ? suffix.)
For a Mutable nullable handle property the setter parameter is registered nullable too (@?+), matching the getter — see the set_handle_str branch in AngelScriptEntity.cpp. This is load-bearing, not cosmetic: AngelScript derives a virtual property’s static type from the setter parameter whenever a setter exists (only getter-only / read-only properties fall back to the getter’s return type — see FindPropertyAccessor in as_compiler.cpp). A non-nullable setter parameter alongside an @? getter would make T? local = obj.MutableNullableProp read as a non-nullable handle and wrongly trip the redundant-? warning (#3) — while T local = obj.MutableNullableProp (no ?) still errors via the getter’s nullable return — leaving the read with no warning-free spelling. Keeping the setter parameter nullable resolves both spellings consistently.
Invariant helpers
The Engine no longer ships the former AngelScript Core.fos library. An embedding project that keeps the traditional verify(cond, message, ...) variadic macro owns its definition, visibility, and tests:
#define verify(cond, ...) if (!(cond)) throw(__VA_ARGS__)
When a project provides it, it states an invariant: a condition that holds whenever its own server and client logic behaves correctly. A failure means a bug, so it throws, and the project must keep it enabled in every configuration rather than treating it as a debug-only assertion.
Managed C# has the Engine-owned equivalents Game.Verify, Game.VerifyNotNull, and Game.Unreachable in Source/Scripting/Managed/CoreScripts/Verify.cs; their nullable-flow annotations and throwing behavior are documented in Managed C# Scripting.
Verify vs. graceful recovery
Choose by what a failure represents. Scripts never call throw directly - every failure path goes through verify:
| Shape | Use when |
|---|---|
verify(cond, "...") |
cond is an invariant - it must hold whenever our system behaves correctly. A violation is a bug (or a tampered client; see below). |
verify(false, "...") |
an unconditional failure with no single guard condition: an unreachable branch (switch default, post-loop “not found”), or a guard whose body does more than fail (e.g. log-then-fail). Always throws - the replacement for what used to be a bare throw(...). It is no-return, so no return / break is needed after it (the compiler treats a constant-true if whose body terminates as terminating). |
if (x == null) { <recover>; return ...; } |
the state is an expected, recoverable runtime outcome - log and fall back, do not throw. |
Client input: transport validation and script invariants
Treat every client-originated payload as untrusted at the server boundary. The Engine validates remote-call framing, payload size, collection bounds, encoded value shapes, and complete buffer consumption before synchronization and handler dispatch; see Remote Calls. The project handler still owns authorization, object ownership, ranges, state transitions, and other domain rules, and must validate them before its first mutation.
Inside script code, verify is the always-on rejection mechanism when malformed or forbidden input violates that handler contract. The same check catches both a bug in the project’s normal client and a tampered request, but it complements rather than replaces the native transport checks and handler-specific semantic validation. Use an ordinary recovery branch instead when rejection is an expected gameplay outcome rather than an invariant failure.
Narrowing and arguments
throw(message, ...) is the exception-raising global function (registered in AngelScriptGlobals.cpp; AngelScript has no throw keyword, so the name is free) - but it is only the primitive the verify macro expands to. Scripts do not call throw directly; an unconditional failure is written verify(false, message, ...). Because throw is marked noreturn, a passing verify(x != null, "...") narrows x to non-null for the rest of the scope, exactly like the if (x == null) return; early-exit guard - the macro expands to if (!(x != null)) throw(...), whose then-branch is noreturn. So a verify both documents the invariant and removes the dereference warning that follows.
The macro is variadic: verify(cond, message, ctx...) forwards message and any trailing context values straight to throw, so a verify carries the same diagnostic context a bare throw would (e.g. verify(trader != null, "Barter target not exists", player, traderId)). Author the message as a readable sentence ("No current player", not "Expected: CurPlayer != null"), and pass the entities / ids / values you’d want in the exception as extra arguments.
Scope of enforcement: every script handle crossing the script ↔ native boundary is validated. Concretely codegen emits the check when the meta-type is one of:
- a
///@ ExportEntityname (Critter,Item,Map,Location,Player,Game,ImGui) or the genericEntity, - an entity relative (
Abstract<Entity>,Proto<Entity>,Static<Entity>— currentlyAbstractItem,ProtoCritter,ProtoItem,ProtoLocation,ProtoMap,StaticItem), - a
///@ ExportRefTypeclass (MovingContext,MapSpriteHolder,SpritePattern,VideoPlayback,ScriptImGui).
On the C++ engine side the matching pointer spellings (ptr<Critter> / nptr<Critter>, ptr<CritterView> / nptr<CritterView>, ptr<Map> / nptr<Map>, ptr<ProtoItem>, ptr<StaticItem>, ptr<MovingContext>, …) are all in scope. The membership test lives in is_validated_pointer_meta_type(...) in ../BuildTools/codegen.py, which is now the sole arbiter: with no FO_NULLABLE marker, codegen treats exported ptr<T> handles as non-null and nptr<T> handles as nullable. Bare raw handle pointers are rejected before registration.
Marking ? on a primitive value type (int, bool, mpos, hstring, …) is rejected because those types have no null representation.
Out of scope: script-to-script parameter passing. The asBC_RefCpyChk instruction covers handle assignments and initializations; AS argument passing typically uses copy-on-call patterns that bypass REFCPY. A project-side declaration analyzer plus the native-boundary check can cover the common case where a script value flows through an engine call.
Migration note
The change is source-incompatible for any AS code that assigned null, or a value that might be null at runtime, to a bare handle. After this change, those scripts must mark the destination T?. Inline test scripts embedded in Source/Tests/Test_*.cpp were updated in lock-step and are the engine-owned regression coverage.
The strict compile-time variant (asEP_DISALLOW_NULLABLE_TO_NON_NULLABLE) catches more shapes at build time and should be enabled by projects that have completed the nullable migration. Any remaining runtime “Null assignment to non-nullable handle” indicates a missing marker or an invalid non-null contract. Common shapes that the smart-cast or compile-time checker cannot see through, and therefore require an explicit T? on the destination or a guarding recovery path, include dict<K,V>.get(key, null), output-reference parameters (Map& out), and class fields assigned across method boundaries.
FOnline runs with asEP_ALLOW_IMPLICIT_HANDLE_TYPES, so script code uses the implicit-handle form (Critter, Item?, array<Critter>) rather than the explicit @ form (Critter@, Item@?, array<Critter@>) wherever the type is itself a ref class. The builder rejects an explicit @ on asOBJ_IMPLICIT_HANDLE types when compiling a user-authored script section with the error “Explicit handle ‘@’ is not allowed on implicit-handle type ‘X’“. Funcdef parameters keep the Type@+ form (handle with auto-add-ref) because the trailing + modifier is semantically required and the bare Type+ form is not a valid AS signature.
Native API registrations (RegisterObjectType, RegisterFuncdef, RegisterObjectMethod, GetFunctionByDecl) and engine-generated declaration strings continue to accept the explicit @. The rejection only fires when there is a script module being built (module != 0) and the builder is not in silent lookup mode — see CreateDataTypeFromNode in ../ThirdParty/AngelScript/sdk/angelscript/source/as_builder.cpp.
Project-side tooling
The engine owns compiler/runtime enforcement and the native binding contract. An embedding project can add read-only source analyzers for authoring rules that span otherwise unrelated script declarations. Useful checks include:
?is used only on handle-capable reference types;- event and remote-call handlers match declaration nullability argument by argument;
- a nullable local is narrowed before dereference;
- a guarded native
nptr<T>is dereferenced directly instead of copied into a redundant narrowing alias; - project style does not mix implicit-handle syntax with explicit
@syntax; - forbidden identity operators or redundant defensive null guards do not return.
Keep marker placement explicit. Inferring nullability from body shape is unreliable: a forwarding function may never dereference a non-null parameter, while a nullable return may be produced only through a helper. Authors declare ptr<T> / nptr<T> on native exports and T / T? in scripts; analyzers verify those declarations rather than rewriting contracts heuristically.
Project-side generators and analyzer task names belong in that project’s documentation. If a checker becomes reusable across FOnline games, move it into BuildTools/ with engine-owned tests before citing it here as a standard command.
Adding / editing markers
When you write a new script function or native export:
- Choose the nullable contract explicitly in the declaration (
T?for script handles;nptr<T>for nullable native exported pointers,ptr<T>for non-null) when the function meaningfully accepts or returns null. - Compile the affected script modules so the engine’s nullable diagnostics run.
- Run any project-side declaration-parity and style analyzers required by the embedding project.
Project-side rewriters must not own contract inference: they should preserve author-chosen markers and may remove only guard code made redundant by generated runtime checks. If a nullable case is real (for example a dynamic_cast<X*>(param) path where null is meaningful), keep the marker in the signature and update the analyzer only when its guard-removal pattern is wrong.
See also
- Scripting — overall engine scripting runtime, AngelScript and Managed backends, and method-export organization.
- Managed C# Scripting — generated nullable surface, async lifetime, analyzers, and build/bake validation.
- Remote Calls - remote-call signatures, handlers, serialization, and project catalog generation.
- Script Methods Map — current
///@ ExportMethodfile map and ownership boundaries. - GeneratedApiAndMetadata.md — generated metadata/API flow that must stay aligned with script-visible contracts.
- SmartPointers.md — native C++ pointer ownership/nullability vocabulary.
- Testing — reusable unit, script, integration, package, and smoke-test boundaries.