Purpose and how to read this guide
Enma is an embeddable, ahead-of-time compiled language with C++ semantics and script-grade containment: whole modules compile to native x64, with no bytecode and no interpreter, and scripts can reach only what the host registers. This guide covers both sides of an Enma project:
- Script authors writing
.emmodules. - Host authors embedding the Enma SDK in a C++ application, registering natives and addons, and running scripts.
Enma is deliberately C++-faithful, so the C++ Style Guide applies to Enma scripts wherever the language allows it, and in full to host code. This guide adds what is specific to Enma: its sized types, its ownership and escape rules, its module system, and the rules for embedding it safely. Examples use a fictional host, Project, whose prefix is PRJ. Sections 18–21 cover Perception, the host these scripts are primarily written for.
Rule levels
| Level | Meaning |
|---|---|
| MUST / MUST NOT | Required. CI or review blocks the change. |
| SHOULD / SHOULD NOT | The default. Deviate only with a short comment explaining why. |
| MAY | Allowed when it helps; no justification needed. |
| AVOID | Legal but discouraged; expect a review question. |
Principles
- Write Enma, not C++. Enma is C++-shaped but has its own spellings (
int64, notlong) and its own standard surface. Code that only compiles as C++ is a bug. - The host decides. Scripts get exactly the natives, addons and permissions the host grants, and nothing else.
- Deterministic ownership. No garbage collector: every allocation has an owner and a scope.
- Fail inside the script. A script fault fails
execute(); it never takes the host down. - Measure before tuning. Enma is already native code; profile before reaching for annotations and intrinsics.
Baseline and toolchain
| Area | Standard |
|---|---|
| Language | Enma, tracking the newest C++ standard semantics (no pinned edition); pin the SDK release in the host repo |
| Platform | Windows x64 (the compiler emits x64 machine code) |
| Host compiler | MSVC cl or clang-cl, C++20 or newer (needed for ENMA_SIG) |
| SDK libraries | MSVC-ABI static libraries; one matching the host's CRT (/MT or /MD) |
| Host build | Premake5, per the C++ guide |
| Scripts | .em source; .emb precompiled artifacts for shipping |
| Editor | Enma Studio or an editor wired to the Enma language server (diagnostics, completion, navigation) |
Rules
- MUSTENMA-02.1 link the SDK library that matches the host's CRT setting. Mixing
/MTand/MDfails at link time; pick one for the whole host. - MUSTENMA-02.2 give every thread that calls
compile()(orcompile_file,link,reload) at least 2 MB of stack. Raise the main thread's reserve with the linker's/STACKoption and create compile threads with an explicit stack size. - MUSTENMA-02.3 compile only the addon source files the host registers; unused addons stay out of the build.
- MUSTENMA-02.4 keep the SDK version pinned (vendored or by exact release) and upgrade it deliberately, reading the changelog, since the language tracks the newest C++ semantics.
- SHOULDENMA-02.5 turn on the language server in every editor used for
.emfiles, and treat its diagnostics as build errors. - SHOULDENMA-02.6 keep
set_optimize(e, true)(the default) in every build; useset_debug(e, true)only in development hosts.
Project layout
project/
premake5.lua
Project/ # Host application (C++)
src/Project/
Scripting/
ScriptEngine.cpp # Owns the engine: config, addons, permissions
ScriptEngine.test.cpp # Tests beside the code
Natives/ # One file per native area: InputNatives.cpp
Addons/ # Custom addons
scripts/ # Enma sources
Lib/ # Shared modules imported by others
MathUtils.em
MathUtils.test.em # Script tests beside the script
Gameplay/
Spawner.em
Main.em # Entry module: int64 main()
build/emb/ # Generated .emb artifacts (not committed)
vendor/enma/ # Pinned SDK: headers, static libs, addon sources
Rules
- MUSTENMA-03.1 keep Enma sources under one
scripts/root and register it withadd_module_path, so imports resolve the same way in every build. - MUSTENMA-03.2 keep tests beside the code they test:
<name>.test.emfor scripts and<File>.test.cppfor host code. - MUST NOTENMA-03.3 commit
.embartifacts; build them in CI from.emsources and ship them as release assets. - MUSTENMA-03.4 keep every native registration in the host's
Scripting/Natives/folder, one file per area, so the full script-visible surface can be reviewed in one place. - SHOULDENMA-03.5 keep one module per file and name the file after the namespace it provides (
MathUtils.em→MathUtils::). - SHOULDENMA-03.6 keep
Main.em(or the host's named entry functions) thin: it wires modules together and holds no game or business logic.
Naming
Scripts use the C++ guide's naming, so host and script code read the same. The Enma standard surface (println, to_string, push_back) stays snake_case because it is Enma's, not ours; the contrast shows at a glance which calls are your own.
| Thing | Convention | Example |
|---|---|---|
| Module file | PascalCase .em, one module per file |
MathUtils.em, MathUtils.test.em |
| Module namespace | PascalCase, comes from the file name | MathUtils::Clamp() |
| Struct / class / enum / alias | PascalCase noun | SpawnPoint, enum class WaveState |
| Enumerator | PascalCase | WaveState::Running |
| Function / method | PascalCase verb phrase | SpawnWave(), GetHealth() |
| Boolean query | Is / Has / Can |
IsAlive(), HasTarget() |
| Local / parameter | camelCase | waveSize, outPoints |
| Data member | m_ + PascalCase |
m_Health, m_NextId |
| Plain-data struct field | PascalCase, no m_ |
struct Hit { int64 Damage; }; |
| Static member / global | s_ / g_ + PascalCase (and rare, §9) |
s_Instance, g_Score |
| Compile-time constant | PascalCase constexpr |
constexpr int64 MaxWaves = 10; |
| Template parameter | T or T + PascalCase |
T, TItem |
| Macro | Host prefix + UPPER_SNAKE | PRJ_DEBUG |
| Host-registered native | PascalCase, area first | InputIsDown, AudioPlay |
| Custom addon | PascalCase area | Net, Telemetry |
| Test function | Test + behaviour |
TestWaveSizeGrows() |
Rules
- MUST NOTENMA-04.1 name anything with a leading double underscore;
__is reserved for Enma intrinsics (__asm_rdtsc,__reflect_count). - MUST NOTENMA-04.2 shadow standard-surface names or builtins (
println,assert,time_ms,set_budget). - MUSTENMA-04.3 start every native's name with its area (
Input,Audio,World) and register each area from one host file, so a reader knows the call crosses into the host. - MUSTENMA-04.4 keep the module file name and its namespace identical; an
importalias (import "Lib/MathUtils.em" as Mu;) is only for resolving a clash.
Formatting and linting
- MUSTENMA-05.1 format
.emfiles withclang-formatusing the same.clang-formatas the host (2-space indent, 100 columns, attached braces); Enma's syntax is close enough to C++ that the formatter handles it. Exclude files that usematchexpressions if the formatter mangles them, and format those by hand to the same style. - MUSTENMA-05.2 treat every compiler and language-server diagnostic as an error. A script that compiles with warnings does not merge.
- MUSTENMA-05.3 run a compile of every
.emfile in CI, through the same host configuration (addons, permissions, defines) that ships. - MUSTENMA-05.4 run the review checklist from Enma's own guidance on every change (Appendix C): no
short/long/unsigned, no standard-header#include, no->on values, onlystd::names the STD Library page lists, everynewpaired with adelete. - SHOULDENMA-05.5 brace every
if,forandwhilebody, even one-liners. - SHOULDENMA-05.6 keep one declaration per line; Enma allows
int64 x = 1, y, z = 3;but it hides the uninitialisedy. - SHOULDENMA-05.7 order a module: imports,
#includes,usingaliases, constants, types, then functions, withmainlast in an entry module.
Types and values
Sized types
- MUSTENMA-06.1 use Enma's sized types:
int8–int64,uint8–uint64,float32,float64,bool,char,wchar. MUST NOT writeshort,long,unsignedorsigned; they are not type names. - SHOULDENMA-06.2 use
int64for counts, sizes, IDs and anything returned to the host; use narrower types only for storage that is packed or sent somewhere. - AVOIDENMA-06.3 the aliases
int,floatanddoublein new code. They are legal (int32,float32,float64), but writing the width removes any doubt about what size a value is. Use the<cstdint>spellings (int32_t,size_t) only in code shared with C++. - MUSTENMA-06.4 remember unsuffixed literals: integers are
int32,1.5isfloat64,1.5fisfloat32. Suffix wide constants (18446744073709551615ull).
Conversions
Enma converts more freely than C++: signed/unsigned conversions wrap, float to int truncates, and int64 to int32 wraps at 32 bits, all implicitly.
- MUSTENMA-06.5 make every narrowing or sign-changing conversion explicit with
static_cast, even where Enma would accept it silently. The cast is the reviewer's signal that the wrap is intended. - MUSTENMA-06.6 use brace initialisation for new variables (
int32 x{42};). Braces reject narrowing (uint8 u{-1};is a compile error), which catches the silent wraps above. - MUSTENMA-06.7 use
enum classfor new enums; convert to integers only withstatic_cast.
Strings, optionals and atomics
- MUSTENMA-06.8 use
std::string/std::wstringfor owned text; there is no barestring.const char*is only for literals and native signatures. - MUSTENMA-06.9 build output text before printing:
printlntakes onestd::stringorconst char*, so usestd::formatorstd::to_string. - SHOULDENMA-06.10 use raw strings for paths and patterns:
R"(C:\dir\file)". - SHOULDENMA-06.11 return
std::optional<T>for "maybe a value" instead of sentinel values like-1. - MUSTENMA-06.12 use
aint8–aint64for integer globals touched by more than one context (§15); compound operations on them are atomic.
using EntityId = int64;
enum class WaveState { Idle, Running, Done };
std::optional<EntityId> FindTarget(const std::vector<EntityId>& ids, EntityId wanted) {
for (const EntityId& id : ids) {
if (id == wanted) {
return id;
}
}
return {};
}
int64 main() {
int32 waves{3};
int64 total = static_cast<int64>(waves) * 1000;
println(std::format("total {}", total));
return 0;
}Functions, lambdas and control flow
Functions
- MUSTENMA-07.1 declare the entry point as
int64 main()(orint32 main()): no parameters, notstatic. - SHOULDENMA-07.2 keep functions short (about 40 lines) with at most four parameters; group more into a struct.
- MUSTENMA-07.3 pass large structs and containers by
const&; Enma structs are value types and copy on pass-by-value. - MUSTENMA-07.4 keep deduced (
auto) return types consistent across everyreturn. When a function mixes widths, declare the return type instead of relying on deduction. - MUST NOTENMA-07.5 declare overloads that differ only in return type.
- MUST NOTENMA-07.6 rely on argument evaluation order even though Enma fixes it left to right (unlike C++); a call like
Pair(Next(), Next())reads as a bug to anyone who knows C++. Evaluate into named locals first. - SHOULDENMA-07.7 use default parameters only for genuinely optional settings, and never refer to a later parameter.
Lambdas
- MUSTENMA-07.8 use
autoordelegateto hold capturing lambdas; only non-capturing lambdas convert to function pointers (+[]{ ... }). - MUST NOTENMA-07.9 return a lambda that captures a local by reference; the compiler rejects it, and the by-value case with a pointer to a local is not caught. Capture values, or own the data with
new. - SHOULDENMA-07.10 keep lambdas under about ten lines; name anything longer.
Control flow
- SHOULDENMA-07.11 use
matchfor value-to-value mappings, always with a_arm:
int32 PointsFor(int32 tier) {
return match (tier) {
1 => 10,
2 => 25,
3 => 50,
_ => 0
};
}
- SHOULDENMA-07.12 use range
forwithconst&(for (const auto& [name, score] : scores)) instead of index loops when the index is not needed. - SHOULDENMA-07.13 use
defer { ... }right after acquiring a resource that has no destructor of its own; it runs at scope exit and during unwinding. - MUST NOTENMA-07.14 use
gotoexcept to break out of nested loops, and never into atry, handler ordeferbody (the compiler refuses it). - MUSTENMA-07.15 return early instead of nesting; no
elseafterreturn.
Structs, classes and templates
Structs and classes
- MUSTENMA-08.1 use
structfor plain data with public PascalCase fields and no invariants, andclassfor types with private state (m_members) and methods that keep invariants. They differ only in default access, so the keyword is how you signal intent. - MUSTENMA-08.2 declare objects on the stack as
T x;orT x{...};, and on the heap asT* x = new T(...);.T x = new T();is rejected;T x();declares a function. - MUSTENMA-08.3 use
.on values and references and->only on pointers or types that declareoperator->(); the wrong one is a compile error. - MUSTENMA-08.4 give every member a default initialiser or set it in the constructor's initialiser list; aggregates with excess initialisers are rejected.
- SHOULDENMA-08.5 keep inheritance single and shallow. Use
virtualonly when you need runtime dispatch, mark overrides withoverride, and give polymorphic bases avirtualdestructor. - AVOIDENMA-08.6 multiple and virtual inheritance; Enma supports them, but a non-virtual diamond silently creates two base subobjects.
- MUSTENMA-08.7 convert class types at the call site; Enma does not apply class-to-class user conversions to function arguments.
- MUST NOTENMA-08.8 make const data members in classes that need copy assignment; Enma removes copy assignment from such classes.
class Spawner {
public:
explicit Spawner(int64 maxAlive) : m_MaxAlive(maxAlive) {}
bool CanSpawn() const { return m_Alive < m_MaxAlive; }
void OnSpawned() { m_Alive += 1; }
private:
int64 m_MaxAlive = 0;
int64 m_Alive = 0;
};
Templates
- SHOULDENMA-08.9 use templates for genuinely generic code (containers, math over numeric types) and constrain them with concepts.
- MUSTENMA-08.10 write
typenamebefore a dependent type name inside a template (typename T::Type). - MUSTENMA-08.11 remember that two instantiations of one template are different types: a value crossing a parameter, return or
newboundary must match exactly. - AVOIDENMA-08.12 deep template metaprogramming; Enma caps instantiations at 16,384 per compile and nesting at 1,024, and every instantiation is monomorphised into machine code.
- SHOULDENMA-08.13 prefer
constexprfunctions andif constexprover recursive template tricks for compile-time work.
Memory and ownership
Enma has no garbage collector. Stack objects, std containers and class members are destroyed deterministically at scope exit, including during exception unwinding.
- MUSTENMA-09.1 prefer stack values and
stdcontainers. Reach fornewonly when an object must outlive its scope or be shared by address. - MUSTENMA-09.2 pair every
newwith exactly onedeleteand everynew T[N]withdelete[], and make the owner obvious: one struct or function owns each heap object and deletes it in its destructor or withdefer. - SHOULDENMA-09.3 put
defer { delete p; }on the line after anewwhose object never leaves the function. - MUST NOTENMA-09.4 return, store in a global or field, or capture by reference the address of a local. The compiler rejects the obvious cases; its escape analysis is conservative, so do not lean on it for the rest.
- SHOULDENMA-09.5 mark functions that must never leak allocations with
[[noescape]]; the compiler then rejects any allocation that outlives the call. - MUSTENMA-09.6 index with
.at(i)when the index comes from input or computation that could be wrong.v[i],a[i]andp[i]are not bounds-checked; a near miss is undefined behaviour. - MUSTENMA-09.7 treat a pointer from a native as borrowed unless the native is documented as transferring ownership, and never
deleteit. - AVOIDENMA-09.8 mutable globals. They are shared by every context of a module (§15); keep state in structs passed by reference, or in context userdata set by the host.
- SHOULDENMA-09.9 check
heap_count()in tests before and after a scenario to catch leaks.
int64 SumWave(const std::vector<int64>& damage) {
int64* scratch = new int64[damage.size()];
defer { delete[] scratch; };
int64 total = 0;
for (int64 i = 0; i < damage.size(); i++) {
scratch[i] = damage.at(i) * 2;
total += scratch[i];
}
return total;
}Errors and exceptions
In scripts
- MUSTENMA-10.1 throw exceptions for failures the caller cannot reasonably ignore, and return
std::optional<T>(or aboolplus out-parameter) for expected misses such as "not found". - MUSTENMA-10.2 throw types derived from
std::runtime_errorwith a message that names what failed and the value involved. - MUSTENMA-10.3 catch by reference (
catch (const std::runtime_error& e)), most specific first, withcatch (...)last; Enma rejects misordered handlers. - MUSTENMA-10.4 catch only where you can recover or add context; otherwise let the exception reach the host, where
execute()reports it. - MUST NOTENMA-10.5 use exceptions for control flow in per-frame code.
- MUSTENMA-10.6 treat null dereference and division by zero as bugs, even though they are catchable checked faults. Check first; do not write code that catches them on purpose.
- MUSTENMA-10.7 remember that stack overflow, wild out-of-bounds writes and use-after-free that hits unmapped memory are not catchable: they end the run without unwinding.
In hosts
- MUSTENMA-10.8 check the result of every SDK call:
compile()anddeserialize()returnnullptr,execute()andcall()returnfalseon failure. - MUSTENMA-10.9 report compile failures with
last_error()— code, message, file, line and column — so the script author sees where the problem is. - MUSTENMA-10.10 keep the host running when a script fails: log the error, mark the script as failed, and carry on.
- MUSTENMA-10.11 report faults from natives with
runtime_error(message)(orruntime_exception) rather than C++ exceptions, and keep every nativenoexceptat its boundary. - MUSTENMA-10.12 call
mark_native_raises(e, name, false)only for natives that genuinely can never raise; the per-call exception check is cheap insurance.
enma::module_t* mod = enma::compile(m_Engine, source.data(), source.size(), "Spawner.em");
if (mod == nullptr) {
const auto& err = enma::last_error(m_Engine);
PRJ_LOG_ERROR("{}:{}:{}: {}", err.file, err.line, err.column, err.message);
return std::unexpected(ScriptError::CompileFailed);
}Modules, namespaces and the preprocessor
Imports
- MUSTENMA-11.1 share code between files with
import, not#include. Imported declarations keep their exact types and are reached through the module's namespace (MathUtils::Clamp). - MUSTENMA-11.2 import by module name (
import MathUtils) when the module is on a host-registered module path, and by quoted relative path (import "Lib/MathUtils.em";) only for files that are not. - SHOULDENMA-11.3 alias an import (
as Mu) only to resolve a name clash or to shorten a deep path; never alias the same file twice. - MUST NOTENMA-11.4
#includestandard headers; the standard surface is always available.#includeis only for sharing macros between your own files. - MUSTENMA-11.5 keep a module's private helpers
staticor in an anonymous namespace so they do not leak to importers.
Namespaces
- MUSTENMA-11.6 keep each module's public declarations inside the namespace its file name gives it; do not add extra top-level namespaces in a module.
- SHOULDENMA-11.7 use
using namespaceonly inside function bodies, and namespace aliases (namespace Rd = Engine::Render::Detail;) for long paths. A namespace alias cannot point at an imported module.
Preprocessor
- AVOIDENMA-11.8 macros in scripts. Use
constexprconstants,if constexprand functions instead. - MAYENMA-11.9 use
#ifdefon host-supplied defines (define(e, "PRJ_DEBUG", "1")) to compile debug-only checks. - MUSTENMA-11.10 prefix any macro with the host prefix (
PRJ_), and keep macros in one shared include file.
Precompiled modules
- MUSTENMA-11.11 ship scripts as
.embartifacts built in CI withserialize(), and load them withdeserialize(), checking fornullptr. - SHOULDENMA-11.12 build
.embfiles withkeep_debug = truefor development and internal builds, andfalseonly for public releases where the source must not be recoverable. - MUSTENMA-11.13 rebuild every
.embwhen the SDK version changes.
Annotations and data layout
Enma accepts a closed set of annotations, each only in its own position; anything else is a compile error.
| Annotation | On | Use it when |
|---|---|---|
[[packed]] |
struct | Matching a wire or file format byte for byte |
[[align(N)]] |
struct, member | Data handed to SIMD code or hardware that needs alignment |
[[offset(N)]] |
member | Mirroring a native struct layout exactly |
[[reflect]] |
struct | Tools, editors or serializers that enumerate fields |
[[serialize]] |
struct | Generating to_bytes / from_bytes for flat records |
[[no_vtable]] |
struct | Rare; dropping the dispatch table from a type with virtuals |
[[inline]] / [[noinline]] |
function | A profile shows the call matters |
[[noopt]] |
function | Debugging only; never committed |
[[noescape]] |
function | Guaranteeing a function leaks no allocation |
[[dll(...)]] |
extern function |
Binding a DLL export; needs PERM_FFI |
- MUSTENMA-12.1 use layout annotations (
[[packed]],[[align]],[[offset]]) only for types that cross a boundary (files, network, natives), with a comment naming the format they match. - MUST NOTENMA-12.2 commit
[[noopt]]. - SHOULDENMA-12.3 add
[[reflect]]to structs that host tools or editors inspect, instead of hand-written field tables. - MUSTENMA-12.4 keep
[[serialize]]structs flat: no bit-fields, inline arrays or nested structs, and no pointers (their addresses would be written out). - MUST NOTENMA-12.5 use
[[dll(...)]]in gameplay or user scripts. FFI belongs in host natives, where it can be reviewed; scripts that need it are compiled only by engines grantedPERM_FFI. - MUSTENMA-12.6 remember that the standard attributes (
[[nodiscard]],[[likely]],[[deprecated]]and others) are parsed and ignored; do not rely on them to enforce anything.
// Matches the 6-byte packet header sent by the host's net layer.
[[packed]] struct PacketHeader {
uint8 Type;
uint8 Flags;
int32 Length;
};Embedding: engine, module and context lifecycle
| Object | Created by | Owns | Lifetime |
|---|---|---|---|
Engine (engine_t) |
enma::create() |
Configuration, registered natives and addons, permissions, compile state | The host's scripting subsystem |
Module (module_t) |
compile, compile_file, deserialize, link |
Compiled code, script globals, instruction budget, debug hook, breakpoints | Until reloaded or destroyed |
| Context | create_context(mod) |
Heap, stack, RNG, in-flight exception, 16 userdata slots | One per thread that runs the module |
Rules
- MUSTENMA-13.1 wrap the engine, each module and each context in an RAII owner in the host (
ScriptEngine,ScriptModule,ScriptContext) that releases in reverse order: contexts, then modules, then the engine. - MUSTENMA-13.2 configure the engine completely — optimisation, debug, defines, include and module paths, resolvers, permissions, addons, natives — before the first compile. Configuration after compiling is a bug.
- MUSTENMA-13.3 destroy every module and context before
destroy(engine); the engine does not track its children. - MUSTENMA-13.4 call
enma::shutdown()once when the scripting subsystem is torn down; in a DLL that may unload, call it fromDLL_PROCESS_DETACHand run no script code afterwards. - SHOULDENMA-13.5 load scripts through
set_import_resolver/set_include_resolverbacked by the host's virtual file system, so packaged builds and development builds resolve the same way. - MUSTENMA-13.6 run entry points by name with
execute()orcall(), and read results with the matchingreturn_value,return_stringorreturn_float. - SHOULDENMA-13.7 use
reloadfor hot reload in development builds only, and drop every cached pointer into the old module (function addresses, annotation info, strings) after a reload.
namespace Project {
std::expected<void, ScriptError> ScriptEngine::Init() {
m_Engine = enma::create();
enma::set_optimize(m_Engine, true);
enma::set_debug(m_Engine, PRJ_DEBUG_BUILD);
enma::set_permissions(m_Engine, 0); // nothing extra by default
enma::register_all_addons(m_Engine, /*enable_filesystem=*/false);
enma::add_module_path(m_Engine, "scripts");
RegisterInputNatives(m_Engine);
RegisterAudioNatives(m_Engine);
return {};
}
ScriptEngine::~ScriptEngine() {
m_Modules.clear(); // destroys contexts, then modules
if (m_Engine != nullptr) {
enma::destroy(m_Engine);
}
}
} // namespace ProjectNative functions and addons
Natives are the script-visible API of the host. Review them like a public API: each one is a door out of the sandbox.
Registering natives
- MUSTENMA-14.1 register natives with
enma::register_typed<&Fn>(e, signature)where possible, and wrap every signature inENMA_SIG("...")so a malformed signature fails the host build instead of logging at run time. - MUSTENMA-14.2 use Enma's sized types in signatures (
int64,float64,const char*) and keep them to plain values, pointers and references; signatures withstruct,class, lambdas or varargs are refused. - MUSTENMA-14.3 keep every native function alive for as long as any module that calls it exists; the engine does not own
fn_ptr. - MUSTENMA-14.4 validate every argument inside the native: natives are not protected by Enma's checks. Use
heap_is_tracked(ptr)before trusting a pointer that claims to be script heap memory. - MUSTENMA-14.5 declare ownership:
mark_native_returns_borrowfor handles the host keeps,mark_native_deallocatesfor natives that free a parameter. Undeclared ownership is a leak or a double free waiting to happen. - MUSTENMA-14.6 gate natives that touch files, the network, processes or other unsafe areas with a permission flag at registration (
PERM_FILE,PERM_FFI, or a host-defined bit), so scripts compiled without the grant fail to compile. - SHOULDENMA-14.7 keep natives coarse: one call that does a whole job (
WorldSpawnWave(count)) beats ten fine-grained calls per frame.
namespace Project::Natives {
static bool InputIsDown(int64_t key) noexcept {
if (key < 0 || key >= Input::KeyCount) {
enma::runtime_error("InputIsDown: key out of range");
return false;
}
return Input::IsDown(static_cast<Key>(key));
}
void RegisterInputNatives(enma::engine_t* e) {
enma::register_typed<&InputIsDown>(e, ENMA_SIG("bool InputIsDown(int64 key)"));
}
} // namespace Project::Natives
Addons
- MUSTENMA-14.8 register only the addons a product needs (
register_core,register_math, ...) instead ofregister_all_addonsin shipping hosts, and disable the filesystem addon unless scripts truly need it. - MUSTENMA-14.9 build host-specific capabilities (networking, telemetry, game systems) as custom addons with their own namespace, not as loose natives, when they have more than a handful of functions.
- MUSTENMA-14.10 document every addon's functions, permission and threading rules in its header and on the docs site.
- MUST NOTENMA-14.11 expose anything from an addon that lets a script register further natives or change permissions.
Threading and contexts
- MUSTENMA-15.1 compile on one thread per engine at a time. Concurrent
compile,link,reloadordeserializecalls on one engine are refused; use one engine per compiling thread or serialise compiles. - MUSTENMA-15.2 give every thread that runs a module its own context from
create_context(mod), created before the thread starts and destroyed after it is joined. - MUST NOTENMA-15.3 share a context between threads at the same time; a second thread entering a busy context is refused. Hand a context to another thread only after the first has finished and been joined.
- MUSTENMA-15.4 treat script globals as shared by every context of a module. Guard them with
aint*types or amutex+lock_guardfrom the Thread addon, or write them from one thread only. - MUSTENMA-15.5 open an
execution_scopeon host threads that call into script memory outside a run (allocating on the script heap, invoking a script function pointer, raising an exception). - SHOULDENMA-15.6 give workers that need their own budget, breakpoints or globals their own module; budgets and debug state are per module, not per context.
- MUST NOTENMA-15.7 use script threads (the Thread addon) for work the host can schedule; prefer host-side job systems that call into contexts.
- MUSTENMA-15.8 remember that async/await, futures and coroutines do not exist in Enma; long work is split across frames or moved to a worker context.
// One module, one context per worker.
std::vector<enma::context_t*> contexts;
for (int32_t i = 0; i < workerCount; ++i) {
contexts.push_back(enma::create_context(mod));
}
// ... start workers, each calling enma::call(contexts[i], ...) ...
// ... join workers ...
for (enma::context_t* ctx : contexts) {
enma::destroy_context(mod, ctx);
}Safety: permissions, budgets and containment
Enma's containment comes from the host: scripts reach only what is registered, permission flags gate dangerous features at compile time, and budgets cap what a script can consume.
Permissions
| Flag | Gates | Default in PROJECT |
|---|---|---|
PERM_FFI (0x01) |
[[dll(...)]] externs and natives registered with it |
Off everywhere; on only for trusted tooling hosts |
PERM_FILE (0x02) |
All Filesystem addon operations | Off for user or untrusted scripts; on for first-party tools |
| Host-defined bits | Natives the host gates itself | Documented beside each native |
- MUSTENMA-16.1 start every engine with
set_permissions(e, 0)and grant flags explicitly, per engine, for the scripts that engine compiles. - MUSTENMA-16.2 compile untrusted scripts (user content, mods, downloaded code) on a separate engine with no permissions, no filesystem addon and only the natives they are meant to have.
- MUSTENMA-16.3 remember that permissions check what a module binds, not what its machine code does: never load
.embfiles from untrusted sources without checking their origin, since they are native code.
Budgets
- MUSTENMA-16.4 set an instruction budget (
set_budget) and a heap budget (set_memory_budget) on every module that runs untrusted or user-editable scripts, and treat exceeding them as a failed run. - SHOULDENMA-16.5 set budgets from the host, not from the script; a script can call
set_budgetitself, so untrusted scripts must not be able to raise their own limits (do not rely on scripts calling it). - SHOULDENMA-16.6 size budgets from measurements of real content plus headroom, and log the measured use in development builds.
Containment checklist
- Natives validate every argument and never trust script pointers without
heap_is_tracked. - No native lets a script register code, change permissions or reach the host's allocator directly.
- Every script run has a timeout path: budgets for untrusted code, a watchdog for long-running trusted code.
- A failed
execute()disables that script and leaves the host running.
Performance
Enma compiles whole modules to native x64, so script code runs at C++-like speed. Most slowness comes from crossing the host boundary or from allocation.
- MUSTENMA-17.1 measure before optimising: time with
time_ms()or__asm_rdtsc()in scripts, and the host profiler aroundexecute()/call(). - SHOULDENMA-17.2 compile modules once and cache them (or ship
.embfiles);compile()is far more expensive than running. - SHOULDENMA-17.3 create contexts once per worker and reuse them, rather than creating one per call.
- SHOULDENMA-17.4 keep per-frame script code free of heap allocation: reuse vectors (
clear()keeps capacity), pass byconst&, and avoid building strings unless they are printed. - SHOULDENMA-17.5 batch native calls: one call that processes an array is cheaper than one call per element.
- MAYENMA-17.6 use the SIMD and Math:3D addons,
[[align(16)]]data and[[inline]]on hot paths, after a profile shows the need. - SHOULDENMA-17.7 use
hold_compile_caches(true)around a batch of compiles (for example, loading a level's scripts), paired with exactly onehold_compile_caches(false). - AVOIDENMA-17.8 deep template instantiation in hot modules; every instantiation is separate machine code, which grows the module and compile time.
Perception: scope, script structure and lifecycle
Perception is the host these scripts run in: it loads Enma scripts, gives them a process API, a render API and the default Enma addons, and runs their routines every frame. Sections 18–21 apply to Perception scripts; everything before them still applies.
Scope
- MUSTENMA-18.1 use Perception scripts only with single-player, offline games and your own software. MUST NOT attach to games with online play, shared leaderboards or anti-cheat, or to any process you do not have the right to modify.
- MUSTENMA-18.2 check the target at startup (process name and module) and unload if it is not the expected single-player title.
Lifecycle
| Stage | Function | Rules |
|---|---|---|
| Load | int64 main() |
Attach to the process, create fonts and resources, register routines. Return 1 to stay loaded, <= 0 to unload |
| Every frame | Routines: void Callback(int64 data) |
Read state, draw, return promptly |
| Unload | void unload() (optional) |
Runs after every routine has stopped; release what main set up and restore anything you changed |
- MUSTENMA-18.3 do one-time work (attaching, pattern scans, font creation, shader and buffer creation) in
main(), never in a routine. - MUSTENMA-18.4 return
0frommain()with a logged reason when setup fails (process not found, pattern not found), instead of leaving routines running against bad state. - MUSTENMA-18.5 keep each routine short and non-blocking; split slow work across frames.
- MUSTENMA-18.6 treat routines as concurrent: they may run at the same time and share globals, so shared mutable state is atomic (
aint64) or behind amutex. - MUSTENMA-18.7 restore every value a script wrote to the target in
unload(), so unloading leaves the game as it was. - SHOULDENMA-18.8 keep each routine's handle and unregister it when its feature is turned off, rather than branching on a flag every frame.
using namespace render;
std::optional<proc_t> g_Game;
int64 g_HudRoutine = -1;
int64 g_Font = 0;
void DrawHud(int64 data) {
if (!g_Game || !g_Game->alive()) {
return;
}
draw_text("trainer active", vec2(40.0f, 40.0f), color(255, 255, 255, 255), g_Font, 1, 1.0f);
}
int64 main() {
g_Game = ref_process("game.exe");
if (!g_Game) {
println("game.exe not running");
return 0;
}
g_Font = create_font("Inter", 20);
g_HudRoutine = register_routine(reinterpret_cast<int64>(DrawHud), 0);
return g_HudRoutine == -1 ? 0 : 1;
}
void unload() {
g_Game.reset();
}
Check each call's exact signature against the Perception API pages for your version; the shape above is the pattern to follow.
Perception: process access and permissions
proc_t is a move-only reference to a process that releases itself when it leaves scope. Reads are always available; writes and heavier operations are gated by permissions.
| Permission | Gates | Default for single-player scripts |
|---|---|---|
| (none) | Attaching, reading memory, modules, exports, pattern and value scans, memory regions | Allowed |
write_memory |
w* writes, wvm |
Only for scripts whose purpose is to change values (trainers), and only those values |
virtual_memory_operations |
alloc_vm, free_vm in the target |
Off; single-player mods rarely need it |
kernel_rw_access |
The kernel namespace, physical memory, DTB overrides |
Off. Single-player games do not need kernel access |
Rules
- MUSTENMA-19.1 request only the permissions a script uses, and state why at the top of the script in a comment.
- MUST NOTENMA-19.2 use
kernel_rw_access, DTB overrides or physical-memory reads and writes. If a target only works through them, it has protections you should not be working around. - MUSTENMA-19.3 check
alive()before using a storedproc_tin a routine; the game may have closed. - MUSTENMA-19.4 check every pointer read from the target with
is_valid_addressbefore following it, and treat a zero or invalid pointer as "not ready", not as an error to retry every frame. - MUSTENMA-19.5 locate code and data with
find_code_pattern(byte signatures with??wildcards) and module-relative offsets, never with absolute addresses. - MUSTENMA-19.6 keep signatures and offsets in one table at the top of the script (or a shared
Offsets.emmodule), each with the game version it was verified on, and fail closed inmain()when any of them is not found. - SHOULDENMA-19.7 read structs in one bulk call (
rvm) instead of many single reads, and cache values that do not change every frame. - MUSTENMA-19.8 write the smallest change possible, remember the original value, and restore it in
unload()and when the feature is turned off.
// Offsets.em: verified on game.exe 1.4.2
namespace Offsets {
constexpr const char* PlayerManager = "48 8B 05 ?? ?? ?? ?? 48 85 C0 74 ?? 8B 40 10";
constexpr int64 Health = 0x10;
constexpr int64 Ammo = 0x24;
}Perception: rendering and overlays
The render API (namespace render) records draw calls during a routine and executes them at the end of the frame.
- MUSTENMA-20.1 create fonts, bitmaps, shaders and buffers once in
main()and reuse them. Load each font at the size it is drawn; fonts and bitmaps are cached for the whole process and never freed. - MUSTENMA-20.2 check every
create_*/load_*handle:0means failure. Readget_last_render_error()and fail setup with its message. - MUSTENMA-20.3 use
color(...)for RGBA values andvec2(...)for positions and sizes; keep shared colours as named constants (Colors::Text,Colors::Warning). - SHOULDENMA-20.4 batch similar shapes with the batch forms (
draw_rects,draw_lines) instead of one call per item. - SHOULDENMA-20.5 upload static geometry once to resident buffers and draw it with
custom_draw_buffer*; use dynamic buffers only for data that changes every frame. - MUSTENMA-20.6 set pipeline state (viewport, blend, targets) right before each custom draw, and end every custom sequence with
custom_restore_state(); state does not carry over between draws or routines. - SHOULDENMA-20.7 keep overlays readable and out of the way: one corner by default, outlined or shadowed text (
effect1 or 2), and a toggle to hide everything. - SHOULDENMA-20.8 measure overlay cost with
get_render_thread_frame_ms()and GPU timers, and keep a script's drawing well under a millisecond per frame. - MAYENMA-20.9 build settings panels with the GUI API rather than hand-drawn widgets, so every script's controls look and behave the same.
Perception: API conventions and tooling
Perception's own conventions, which every script follows:
- MUSTENMA-21.1 use
static_cast<T>(x)for numeric conversions andreinterpret_cast<int64>(fn)to pass a function as a routine or callback handle. - MUSTENMA-21.2 suffix
float32literals withf(0.2f,40.0f); a bare0.2isfloat64. - MUSTENMA-21.3 treat values from
create_*andload_*as opaque handles and pass them only to the matching API. - MUSTENMA-21.4 use
std::stringfor owned text andconst char*for read-only parameters; convert numbers withstd::to_stringorstd::format. - MUSTENMA-21.5 use
std::vector<T>for dynamic storage andstd::array<T, N>for fixed-size storage. - MUSTENMA-21.6 use
print_consolefor chatty debug output so the main log stays readable, andprintlnfor lifecycle messages.
Tooling
- MUSTENMA-21.7 edit scripts with the Perception language server enabled (diagnostics, hover, completion, go-to-definition) and fix every diagnostic before loading a script.
- MAYENMA-21.8 use the Perception MCP interface to let an AI agent load, run and inspect scripts locally; review any agent-written script against this guide before running it against a game.
- SHOULDENMA-21.9 keep each script in its own folder with a short
README.md: the target game and version, the permissions it requests and why, and how to turn each feature off.
Testing
Script tests
- MUSTENMA-22.1 keep tests beside the script as
<Name>.test.em, importing the module under test. - MUSTENMA-22.2 write each test as an
int64 Test<Behaviour>()function that returns0on success, using theassert(cond, msg)builtin for checks. The host's test runner finds and calls everyTest*function. - MUSTENMA-22.3 check for leaks: record
heap_count()before and after each test and fail if it changed. - MUSTENMA-22.4 test error paths too: that the right exception is thrown, and that
.at()throws on a bad index. - SHOULDENMA-22.5 run tests on an engine configured like production (same addons, permissions and defines) plus a tighter budget, so runaway loops fail fast.
import "MathUtils.em";
int64 TestClampKeepsValueInRange() {
int64 before = heap_count();
assert(MathUtils::Clamp(15, 0, 10) == 10, "clamps above max");
assert(MathUtils::Clamp(-3, 0, 10) == 0, "clamps below min");
assert(MathUtils::Clamp(4, 0, 10) == 4, "keeps values in range");
assert(heap_count() == before, "no leaks");
return 0;
}
Host tests
- MUSTENMA-22.6 test every native with good, boundary and bad arguments, from a script, and check that bad arguments raise instead of crashing.
- MUSTENMA-22.7 test that a script without a permission fails to compile when it calls a gated native.
- MUSTENMA-22.8 test budgets: an infinite loop and a runaway allocation each fail
execute()while the host keeps running. - SHOULDENMA-22.9 run host and script tests under the same CI job on Windows x64, with ASan builds of the host where the SDK allows it.
- SHOULDENMA-22.10 compile every
.emfile and round-trip it throughserialize/deserializein CI to catch artifact problems before release.
Git, releases and review
- MUSTENMA-23.1 follow Conventional Commits:
type(scope): summary, imperative, lower case, no trailing period, under 72 characters. Use the module or host area as scope:feat(spawner): add wave cooldown,fix(natives): validate key range. - MUSTENMA-23.2 mark breaking changes to natives, addons or module APIs with
!and aBREAKING CHANGE:footer. - MUSTENMA-23.3 keep commits small and focused, each compiling every script and passing tests.
- MUSTENMA-23.4 record the SDK version in the host's release notes, and rebuild every
.embartifact for each release.
Review checklist: scripts
- Sized types only; no
short,long,unsigned, or standard-header#include. - Narrowing and sign changes are explicit casts; new variables use braces.
.on values,->only on pointers; onlystd::names the STD Library page lists.- Every
newhas an owner and adelete; no address of a local escapes. - Untrusted indexes use
.at(); globals touched by several contexts are atomic or locked.
Review checklist: hosts
- Every native validates its arguments, declares ownership, and is gated if it touches files, network or processes.
- Signatures use
ENMA_SIG; natives arenoexceptand report faults withruntime_error. - Engines are fully configured before compiling; contexts and modules are destroyed before engines.
- Untrusted scripts run on a separate, permissionless engine with budgets set by the host.
Appendix
A. Minimal runnable script
// Main.em
int32 Fib(int32 n) {
if (n <= 1) {
return n;
}
return Fib(n - 1) + Fib(n - 2);
}
int64 main() {
println(std::to_string(Fib(30)));
return 0;
}
B. Host skeleton
int main() {
enma::engine_t* engine = enma::create();
enma::register_all_addons(engine, /*enable_filesystem=*/false);
const std::string source = Project::ReadFile("scripts/Main.em");
enma::module_t* mod = enma::compile(engine, source.data(), source.size(), "Main.em");
if (mod == nullptr) {
std::println(stderr, "{}", enma::last_error_message(engine));
enma::destroy(engine);
return 1;
}
enma::context_t* ctx = enma::create_context(mod);
const bool ok = enma::execute(ctx, "main");
const int64_t result = ok ? enma::return_value(ctx) : -1;
enma::destroy_context(mod, ctx);
enma::module_destroy(mod);
enma::destroy(engine);
enma::shutdown();
return ok ? static_cast<int>(result) : 1;
}
Check exact SDK signatures against the API Reference for your pinned SDK version.
C. Script review checklist (from Enma's own guidance)
- No
short,longorunsigned. - No
#includeof a standard header. - No
->on a value whose type declares nooperator->(). - No
std::names that the STD Library page does not list. - Every
newhas adelete; nothing returns the address of a local.