๐ฎ Openplanet Plugin Development
๐ Overview
Openplanet is a plugin/script development platform for Nadeo games (Trackmania 2020, Maniaplanet). Plugins are written in AngelScript (.as), a C++-like scripting language. This skill covers everything from project structure to deep API quirks, performance patterns, and debugging.
๐ก Hard-won lessons from building: Grid Explorer & Tracker, Event Calendar, Vehicle Detector, Apeiron Galaxy, Competition Companion.
๐๏ธ Plugin Architecture (callback-style)
Openplanet plugins can be either:
- Coroutine-style: one
void Main()that loops withyield()/sleep(ms) - Callback-style: optional
Main()+ auto-calledvoid Update(float dt)andvoid Render()
For most overlays, callback-style is simpler and lower-latency โ you don't need a Main() at all. Just define Update() and/or Render().
| Callback | When it fires |
|---|---|
| void Main() | Plugin load. Yieldable coroutine. |
| void Update(float dt) | Every frame. dt is delta in milliseconds. |
| void Render() | Every frame, even with overlay closed. |
| void RenderInterface() | Every frame, only when overlay is open. |
| void RenderMenu() | For Openplanet menu items. |
| void OnEnabled/OnDisabled/OnDestroyed() | Plugin lifecycle. |
| void OnSettingsChanged() | After user changes any [Setting] value. |
๐ Project Layout
๐ Folder-based (development) โ PREFERRED
Openplanet4/Plugins/<plugin-name>/
โโโ info.toml # Metadata (required)
โโโ Main.as # Entry point (required)
โโโ src/ # Optional modules
โ โโโ core/
โ โโโ ui/
โ โโโ utils/
โโโ README.md
โโโ tests/ # Optional Python test scripts
All .as files in the folder are compiled together as a single module โ no manual imports needed.
๐๏ธ Filesystem layout
Openplanet4/
โโโ docs/ # API documentation
โโโ Plugins/ # Runtime plugins (what Openplanet loads)
โโโ Plugins-Developer/ # Source-of-truth development tree
โโโ PluginStorage/ # Per-plugin persistent data (IO::FromStorageFolder)
โโโ Openplanet.log # Debug log โ check for compilation errors
When iterating, edit Plugins-Developer/, then copy to Plugins/ for live test.
๐ฆ Packaged (.op) โ distribution
.op files are ZIP archives. Do NOT edit them directly โ extract, develop as folder, re-zip for release.
Build:
cd path/to/MyPlugin
7z a MyPlugin.zip info.toml Main.as src/
ren MyPlugin.zip MyPlugin.op # rename to .op
โ๏ธ info.toml
[meta]
name = "My Plugin"
author = "yourname"
version = "1.0.0"
category = "Tools"
[script]
timeout = 0
dependencies = [ "VehicleState", "Camera" ] # namespaces your code uses
defines = [] # Preprocessor defines for dev
๐ Common namespaces and which plugin owns them
| Namespace/function | Plugin to add to dependencies |
|---|---|
| VehicleState::ViewingPlayerState() | VehicleState |
| Camera::ToScreenSpace(vec3) -> vec2 | Camera |
| Camera::IsBehind(vec3) -> bool | Camera |
| NadeoServices::AddAudience(...) | NadeoServices |
| Dashboard::ViewingPlayerState() | Dashboard (optional) |
โ ๏ธ Symptom of missing dependency: ERR : No matching symbol 'X::Y' at compile time.
๐๏ธ Settings
[Setting name="Display name" description="Tooltip"]
bool S_MySetting = true;
[Setting name="Slider value" min=0 max=100]
int S_Slider = 50;
[Setting hidden]
string S_InternalData = "";
๐จ CRITICAL โ API Quirks & Pitfalls
๐ค AngelScript Language Quirks
These bite everyone. AngelScript โ C++, โ C#, โ Java.
๐ข Integer literal suffixes
u, l, ul, ull suffixes are not supported.
// โ ERR: Unexpected token '<identifier>'
const uint64 MASK = 0x3FFFFFFu;
const int BIG = 1ul;
// โ
OK: bare literal; compiler picks the type
const uint64 MASK = 0x3FFFFFF;
const int BIG = 1;
const uint64 KEY = uint64(0x123); // explicit cast
๐ const on value-type arrays
const works for primitives, but NOT for fixed-size arrays of value types like int2[] or vec3[].
// โ ERR: Expected '('
const int2 EDGES[12] = { int2(0,1), ... };
const vec3 CORNERS[8] = { ... };
// โ
OK: dynamic, no const
int2[] g_Edges = { int2(0,1), ... };
Also: const on array<T> is unreliable โ keep globals un-const and use a g_ prefix instead.
๐ Fixed-size local arrays
Local fixed-size arrays are not supported:
void Foo() {
// โ ERR: Expected ';' Instead found identifier 'pts'
vec2 pts[8];
float depth[8];
}
// โ
WORKAROUND: individual variables
void Foo() {
vec2 p0, p1, p2, p3, p4, p5, p6, p7;
// compiler will register-allocate them
}
Or use dynamic arrays:
vec2[] GetCorners() {
vec2[] cs = { vec2(0,0), vec2(1,0), ... };
return cs;
}
๐๏ธ dictionary key types
Most surprising limitation: dictionary only accepts const string&in keys.
dictionary d;
// โ ERR: No matching signatures to 'dictionary::Exists(uint64)'
d[uint64(123)] = true;
d[int(456)] = true;
// โ
OK: build a string key
d["123"] = true;
d[gx + "," + gy + "," + gz] = true;
If you need a fast non-string key, roll your own hash table (parallel arrays + linear scan, or small open-addressed probe table). For most plugins, string keys are fine.
๐ uint vs int in comparisons
array<T>.Length returns uint. Comparing a signed int loop counter produces a warning treated as error in strict mode:
// โ ๏ธ WARN: Signed/Unsigned mismatch
for (int e = 0; e < arr.Length; e++) { ... }
// โ
CLEAN:
for (uint e = 0; e < arr.Length; e++) { ... }
๐ค out parameter naming
Match the parameter name exactly:
// โ ERR: No matching symbol 'outDepth'
bool Project(vec3 &in p, vec2 &out screen, float &out depth) {
outDepth = 0.0f;
}
// โ
OK:
bool Project(vec3 &in p, vec2 &out screen, float &out depth) {
depth = 0.0f;
}
๐จ int2, vec2, vec3, vec4 constructors
int2 a = int2(1, 2);
vec3 v = vec3(1.0f, 2.0f, 3.0f);
vec4 red = vec4(1.0f, 0.0f, 0.0f, 0.5f);
They are value types โ no @ needed for storage.
๐ &inout on primitive types is NOT allowed
// โ ERR:
void ConfigRow(const string &in label, bool &inout value) { }
// โ
OK:
void ConfigRow(const string &in label, bool value) { }
๐ Array initialization โ inline int t[] = {...} fails inside functions
// โ ERR inside functions:
// int t[] = {0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4};
// โ
OK: use array<T> with InsertLast
array<int64> items;
items.InsertLast(123);
// โ
OK: pre-allocate at global scope
int[] g_Array;
void Main() { g_Array.Resize(16); }
// โ
OK: inline array init works at global scope
int[] monthDays = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
๐ string::IndexOf โ takes exactly ONE parameter
// โ ERR:
int idx = text.IndexOf("[", startPos);
// โ
OK:
int idx = text.IndexOf("[");
// For offset: use SubStr first
int idx = text.SubStr(startPos).IndexOf("[");
๐ Text::Format takes exactly ONE value argument
// โ ERR:
Text::Format("%.6f (%.2f%%)", sidereal, sidereal * 100.0);
// โ
OK:
Text::Format("%.6f", sidereal) + " (" + Text::Format("%.2f%%", sidereal * 100.0) + ")";
โฐ Time API Quirks
๐ ฐ๏ธ Time::Info uses PascalCase, NOT lowercase
'year' is not a member of 'Time::Info'
// โ ERR:
info.year, info.month, info.day, info.hour, info.minute, info.second
// โ
OK:
info.Year, info.Month, info.Day, info.Hour, info.Minute, info.Second
๐ Weekday is NOT a member of Time::Info
info.Weekday will fail. Use Zeller's formula (0=Sun..6=Sat):
int GetDayOfWeek(int y, int m, int d) {
if (m < 3) { m += 12; y -= 1; }
int K = y % 100;
int J = y / 100;
int h = (d + (13 * (m + 1)) / 5 + K + K / 4 + J / 4 + 5 * J) % 7;
return (h + 6) % 7; // 0=Sun
}
๐ Time functions
int64 now = Time::Stamp; // Epoch seconds
uint64 gameTime = Time::Now; // ms since game start
string formatted = Time::FormatString("%H:%M", now); // strftime format
Time::Info info = Time::Parse(now); // Local time
๐ฅ๏ธ UI API Quirks
๐ค No UI::Font enum โ use PushFontSize
// โ
OK:
UI::PushFontSize(22.0);
UI::Text("Big text");
UI::PopFontSize();
// โ ERR (does not exist):
UI::PushFont(UI::Font::OpenSansBold);
๐จ No UI::TextColored โ use PushStyleColor
// โ
OK:
UI::PushStyleColor(UI::Col::Text, vec4(0.3f, 1.0f, 0.5f, 1.0f));
UI::Text("Green text");
UI::PopStyleColor();
// โ ERR:
UI::TextColored(color, "text");
๐ Window position uses int coords
// โ
OK (cast floats to int):
UI::SetNextWindowPos(int(posX), int(posY), UI::Cond::Appearing);
๐ช UI::Begin takes a bool reference
bool S_WindowOpen = false;
if (!UI::Begin("My Window", S_WindowOpen, UI::WindowFlags::NoSavedSettings)) {
UI::End();
return;
}
โจ๏ธ UI::InputText โ return type vs. bool&out
// โ ERR โ InputText ALWAYS returns string, can't use in if():
if (UI::InputText("##Input", g_Text, changed, flags)) { }
// โ
OK:
bool changed = false;
UI::InputText("##Input", g_Text, changed, flags);
if (changed) { /* Enter was pressed */ }
๐๏ธ NanoVG Essentials
The drawing API is per-frame immediate-mode. State persists until changed.
nvg::BeginPath();
nvg::MoveTo(p1);
nvg::LineTo(p2);
nvg::Stroke(); // โ actually draws the path
nvg::FontSize(13.0f);
nvg::FillColor(vec4(1.0f, 1.0f, 1.0f, 0.9f));
nvg::TextAlign(nvg::Align::Middle | nvg::Align::Center);
nvg::Text(p, "label");
nvg::BeginPath()is required forLineTo/Rect/Circle/etc.nvg::Text()does NOT needBeginPath.- For minimum state changes, batch draws that share a state.
๐ท Camera API
๐ฏ Camera::ToScreenSpace returns vec2, not vec3
vec2 screenPos = Camera::ToScreenSpace(worldPos); // 2D only
// NO z/depth component!
For behind-camera test:
if (Camera::IsBehind(worldPos)) {
// skip this point
}
โ ๏ธ Don't assume a vec3 overload exists. The vec2 return is the only signature.
๐ VehicleState::ViewingPlayerState() returns CSmPlayer
auto state = VehicleState::ViewingPlayerState();
if (state is null) return; // null when not in a vehicle / not in a map
vec3 pos = state.Position; // world-space player position
Use is null โ AngelScript's null-comparison operator for handles.
๐งฑ Trackmania Block Dimensions
Standard blocks are 32 ร 32 ร 8 (X ร Z ร Y) in world units.
const float BLOCK_XZ = 32.0f;
const float BLOCK_Y = 8.0f;
int gx = int(Math::Floor(worldPos.x / BLOCK_XZ));
int gy = int(Math::Floor(worldPos.y / BLOCK_Y));
int gz = int(Math::Floor(worldPos.z / BLOCK_XZ));
โก Performance Patterns
๐จ Minimize NVG state changes
Batch draws by state โ the single biggest FPS win:
// โ BAD: stroke state changes per block
for (each block) {
nvg::StrokeColor(...);
nvg::StrokeWidth(...);
DrawEdgesOfBlock(...);
}
// โ
GOOD: sort by state, then batch
nvg::StrokeColor(green); // once per "visited" pass
for (each visited block) DrawEdgesOfBlock(...);
nvg::StrokeColor(red); // once per "unvisited" pass
for (each unvisited block) DrawEdgesOfBlock(...);
State-change guard:
bool g_LastDrewVisited = false;
void ApplyStroke(bool visited) {
if (visited == g_LastDrewVisited) return;
g_LastDrewVisited = visited;
nvg::StrokeColor(visited ? green : red);
nvg::StrokeWidth(visited ? 3.0f : 2.0f);
}
๐ง Don't recompute in Render()
Anything that can be computed in Update() and cached as a global is one less per-frame allocation.
๐๏ธ Spatial culling
For 3D grids:
- Per-block AABB cull: project all 8 corners; if 0 in front of camera, skip block
- Edge-level cull: only draw edges whose two endpoints were both visible
๐ค Avoid string concatenation in hot loops
"a" + "b" + "c" allocates 3 new strings. For cache keys hit thousands of times per frame, cache the per-frame key. For 4โ8 char keys, the cost is acceptable โ the lookup hash dominates.
๐ Diagnostic UI
The UI::* namespace is immediate-mode (like Dear ImGui):
UI::SetNextWindowSize(width, height, UI::Cond::FirstUseEver);
if (UI::Begin("My Diagnostics")) {
UI::Text("Static label: " + value);
if (UI::Button("Reset")) {
// handle click
}
UI::Separator();
}
UI::End();
Window titles with icons: UI::Begin(Icons::Eye + " Diagnostics")
The Icons:: namespace has 600+ Unicode glyphs (Icons::Eye, Icons::Cog, Icons::Trash, Icons::Clock, Icons::Car, Icons::Info, Icons::Calendar, Icons::Star, Icons::QuestionCircle, etc.).
๐๏ธ Config & Debug Window Pattern
void RenderDebugWindow() {
UI::SetNextWindowSize(580, 520, UI::Cond::FirstUseEver);
if (!UI::Begin("Config", g_ShowDebugWindow)) { UI::End(); return; }
UI::BeginTabBar("Tabs");
if (UI::BeginTabItem("Config")) { RenderConfigTab(); UI::EndTabItem(); }
if (UI::BeginTabItem("Status")) { RenderStatusTab(); UI::EndTabItem(); }
if (UI::BeginTabItem("History")) { RenderHistoryTab(); UI::EndTabItem(); }
UI::EndTabBar(); UI::End();
}
| Tab | Content |
|---|---|
| โ๏ธ Config | All [Setting] toggles in a table with ON/OFF |
| ๐ Status | Live values, cache queues, computed data |
| ๐ History | Data tables, map visits, recorded events |
| ๐ Reference | Static reference data (nodes, calibration tables) |
๐ Lunar Calendar & Date Conversion
๐ Gregorian โ Day of Year
// Day of year from Gregorian (1-based)
int GetDayOfYear(int year, int month, int day) {
int[] daysBefore = {0, 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334};
int doy = daysBefore[month] + day;
if (month > 2 && IsLeapYear(year)) doy++;
return doy;
}
// Day of year back to Gregorian
void DayOfYearToGregorian(int year, int dayOfYear, int &out month, int &out day) {
int[] mdays = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
if (IsLeapYear(year)) mdays[1] = 29;
month = 1;
int remaining = dayOfYear;
for (int i = 0; i < 12; i++) {
if (remaining <= mdays[i]) { month = i + 1; day = remaining; return; }
remaining -= mdays[i];
}
month = 12; day = 31;
}
// Unix timestamp from Gregorian date (midnight UTC)
uint64 UnixFromGregorian(int year, int month, int day) {
Time::Info info;
info.Year = year;
info.Month = month;
info.Day = day;
info.Hour = 0;
info.Minute = 0;
info.Second = 0;
return Time::Unix(info);
}
// Gregorian from Unix timestamp
void GetGregorianFromUnix(uint64 unixTime, int &out year, int &out month, int &out day) {
Time::Info info = Time::Parse(unixTime);
year = info.Year;
month = info.Month;
day = info.Day;
}
bool IsLeapYear(int year) {
return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
}
๐ Moon phase (synodic ~29.53 days)
// Returns 0.0 (new moon) to 1.0 (next new moon)
float GetMoonPhase(uint64 unixTime) {
// Known new moon: 2000-01-06 18:14 UTC = 947182440
const float SYNODIC = 29.53058867;
double daysSince = double(unixTime - 947182440) / 86400.0;
return float(fmod(daysSince, SYNODIC) / SYNODIC);
}
// Moon texture for current phase (requires Moon plugin textures)
auto@ GetMoonTexture(uint64 unixTime) {
float phase = GetMoonPhase(unixTime);
// 0.0=new, 0.25=first quarter, 0.5=full, 0.75=last quarter
// Map to your texture array index
int idx = int(phase * 8.0) % 8;
return Moon::GetTexture(idx);
}
๐ Calendar Day Indicators
๐ข Event dots โ mark days that have events
bool[] g_DaysWithEvents;
void Main() {
g_DaysWithEvents.Resize(31);
while (true) {
RebuildEventDays();
yield();
}
}
void RebuildEventDays() {
for (int i = 0; i < 31; i++) g_DaysWithEvents[i] = false;
Time::Info now = Time::Parse(Time::Stamp);
int curDOW = GetDayOfWeek(now.Year, now.Month, now.Day);
// Convert Sun=0 to Mon=1..Sun=7 convention if needed
curDOW = (curDOW == 0) ? 7 : curDOW;
int curDay = now.Day;
for (int i = 0; i < g_EventCount; i++) {
int diff = g_WeekDay[i] - curDOW;
int eventDay = curDay + diff;
if (eventDay >= 1 && eventDay <= 31) g_DaysWithEvents[eventDay - 1] = true;
}
}
In your calendar draw loop:
// Highlight days with event dots
if (g_DaysWithEvents[day - 1])
UI::PushStyleColor(UI::Col::Button, vec4(0.4f, 0.8f, 0.4f, 0.25f));
UI::Button(dayStr);
if (g_DaysWithEvents[day - 1])
UI::PopStyleColor();
๐ Inline data in cells โ show computed value without hover
// In each calendar cell, after drawing the day number button:
UI::TextDisabled(Text::Format("%.0f%%", progress * 100.0)); // e.g. "23%" = 23%
// Moon phase icon alongside:
auto@ tex = GetMoonTexture(Time::Stamp);
if (tex !is null) {
UI::SameLine();
UI::Image(tex, vec2(14, 14));
}
โฑ๏ธ Upcoming Events Countdown
array<int64> eTs; array<string> eLabel;
void BuildUpcomingList() {
eTs.Resize(0);
eLabel.Resize(0);
int64 now = Time::Stamp;
for (int i = 0; i < g_Count; i++) {
int64 ets = GetNextEventTs(g_WeekDay[i], g_Hour[i], g_Min[i]);
if (ets > now) {
eTs.InsertLast(ets);
eLabel.InsertLast(g_Label[i]);
}
}
// Bubble sort by timestamp (ascending)
for (uint i = 0; i < eTs.Length - 1; i++) {
for (uint j = 0; j < eTs.Length - 1 - i; j++) {
if (eTs[j] > eTs[j + 1]) {
int64 tmpT = eTs[j]; eTs[j] = eTs[j + 1]; eTs[j + 1] = tmpT;
string tmpL = eLabel[j]; eLabel[j] = eLabel[j + 1]; eLabel[j + 1] = tmpL;
}
}
}
}
// Display first 5 in UI:
void RenderUpcoming() {
BuildUpcomingList();
uint count = eTs.Length;
if (count > 5) count = 5;
for (uint i = 0; i < count; i++) {
int64 ago = eTs[i] - Time::Stamp;
string countdown = FormatCountdown(ago);
UI::Text(eLabel[i] + " โ " + countdown);
}
}
string FormatCountdown(int64 seconds) {
int h = int(seconds / 3600);
int m = int((seconds % 3600) / 60);
return Text::Format("%dh %dm", h, m);
}
๐ Recurring Events Pattern
const int MAX_EVENTS = 16;
int g_Count = 0;
int[] g_WeekDay; int[] g_Hour; int[] g_Min; string[] g_Label;
void AddEvent(int d, int h, int m, const string &in l) {
if (g_Count >= MAX_EVENTS) return;
g_WeekDay[g_Count] = d; g_Hour[g_Count] = h;
g_Min[g_Count] = m; g_Label[g_Count] = l; g_Count++;
}
โฑ๏ธ Upcoming events with countdown:
array<int64> eTs; array<string> eLabel;
for (int i = 0; i < g_EventCount; i++) {
int64 ets = GetNextEventTs(g_WeekDay[i], g_Hour[i], g_Min[i]);
if (ets > now) { eTs.InsertLast(ets); eLabel.InsertLast(g_Label[i]); }
}
// Bubble sort, display first 5
๐ Preprocessor Directives
#if TMNEXT
// Trackmania (2020) only
#elif MP4
// Maniaplanet 4 only
#endif
๐ Common Build/Compile Errors
| Error message | Cause | Fix |
|---|---|---|
| ERR : No matching symbol 'X::Y' at function call | Missing dependencies in info.toml | Add the owning plugin to [script].dependencies |
| ERR : Unexpected token '<identifier>' after numeric literal | Integer suffix (u, l, etc.) not supported | Drop suffix or use uint64(x) cast |
| ERR : Expected '(' Instead found '[' on const TYPE name[N] | const on fixed-size value-type array | Use TYPE[] name = { ... }; (dynamic, no const) |
| ERR : Expected ';' Instead found identifier 'pts' on vec2 pts[8]; | Local fixed-size array not supported | Use individual variables |
| ERR : No matching signatures to 'dictionary::Exists(uint64)' | Dictionary only takes string keys | Convert to string key |
| WARN : Signed/Unsigned mismatch in for loop | int i vs uint Length | Use uint i |
| ERR : Can't implicitly convert from 'vec2' to 'vec3' | Camera::ToScreenSpace returns vec2 | Use vec2; test Camera::IsBehind for cull |
| 'year' is not a member of 'Time::Info' | Wrong case on member | Use PascalCase: info.Year, info.Month, info.Day, etc. |
| 'Weekday' is not a member of 'Time::Info' | Weekday doesn't exist on Time::Info | Use Zeller's formula (see Time API section) |
| No matching symbol 'UI::Font::...' | Font enum doesn't exist | Use PushFontSize/PopFontSize |
| No matching symbol 'UI::TextColored' | Function doesn't exist | Use PushStyleColor(UI::Col::Text, ...) |
| Float value truncated in implicit conversion | float where int expected | Cast: int(value) |
| ERR : No matching symbol 'outDepth' | out param name mismatch | Match parameter name exactly |
| ERR : Can't implicitly convert from 'string' to 'bool' | UI::InputText return in if() | Call separately, check changed bool after |
| ERR on '&inout' with primitive | &inout not allowed on primitives | Pass by value for reads |
| ERR on 'IndexOf' with 2 args | string::IndexOf takes 1 param | Use SubStr first for offset |
| ERR on 'Text::Format' with 2 values | Text::Format takes 1 value arg | Chain multiple Text::Format calls |
๐ง Debugging Tips
- ๐
F3 โ Logโprint("hello")lands here - ๐ Reload scripts after every save via
F3 โ Developer โ Reload Scriptsโ no restart needed - ๐ F3 โ Developer โ Plugin Manager โ shows load order and compile errors
- ๐ Nod Explorer (
F3 โ Developer โ Nod Explorer) โ browse liveCGameCtnApptree - ๐ Openplanet.log (
%USERPROFILE%\OpenplanetNext\Openplanet.log) โ stack traces for runtime crashes
๐ Quick-Start Template
# info.toml
[meta]
name = "My Plugin"
author = "Me"
category = "Tools"
version = "1.0.0"
[script]
timeout = 0
dependencies = [ "VehicleState" ]
// main.as
[Setting name="Enabled" category="General"]
bool S_Enabled = true;
void Update(float dt) {
if (!S_Enabled) return;
auto state = VehicleState::ViewingPlayerState();
if (state is null) return;
// ... per-frame work ...
}
void Render() {
if (!S_Enabled) return;
if (UI::Begin(Icons::Cog + " My Plugin")) {
UI::Text("Hello world");
}
UI::End();
}
๐งน Cleanup When Removing a Feature
Check EVERY .as file:
grep -rn "DeletedName" Plugins/<name>/
๐ Reference
Full API reference: ๐ https://openplanet.dev/docs
Hermes skills repo reference files: ๐ https://github.com/tomekdot/hermes-skills/tree/main/skills/software-development/openplanet-plugin-dev/references
Last updated: 2026-06-12. Covers AngelScript build as of OpenplanetNext 2026.
Scan to join WeChat group