openplanet-plugin-dev by tomekdot

Dev Tools

Create, debug, and structure Openplanet AngelScript plugins for Trackmania/Maniaplanet.

Install

openclaw skills install openplanet-plugin-dev

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 creating folder-based dev plugins, debugging compilation errors, and working around API quirks.

Full API Documentation

The complete, up-to-date Openplanet API reference is available online: https://openplanet.dev/docs

This includes all namespaces (UI, Time, IO, Net, Json, Math, nvg, etc.), function signatures, enums, and callback documentation. Always check the online docs for the latest API additions and changes.

Project Layout

Two layouts exist:

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.

Packaged (.op) — distribution

.op files are ZIP archives. Do NOT edit them directly — extract, develop as folder, re-zip for release.

info.toml

[meta]
name = "My Plugin"
author = "yourname"
version = "1.0.0"
category = "Utility"

[script]
imports = []           # Scripts from Openplanet's Scripts/ folder
dependencies = []      # Other plugin identifiers
defines = []           # Preprocessor defines for dev

Entry Points (callbacks)

FunctionWhenYieldable
void Main()Plugin startsYes
void Render()Every frame (even with overlay closed)No
void RenderInterface()Every frame (overlay open only)No
void RenderMenu()Overlay menu itemsNo
void Update(float dt)Every frame, dt in msNo
void OnEnabled() / void OnDisabled()Plugin toggledNo
void OnDestroyed()Plugin unloadedNo

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

1. Time::Info uses PascalCase, NOT lowercase

Error if wrong: 'year' is not a member of 'Time::Info'

CorrectWrong
info.Yearinfo.year
info.Monthinfo.month
info.Dayinfo.day
info.Hourinfo.hour
info.Minuteinfo.minute
info.Secondinfo.second

2. Weekday is NOT a member of Time::Info

info.Weekday will fail with 'Weekday' is not a member of 'Time::Info'.

Use Zeller's formula (0=Sun..6=Sat) — inline array init int t[] = {...} does NOT work:

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
}

For converting from Unix timestamp directly:

int GetWeekdayFromUnix(uint64 unixTime) {
    uint64 daysSinceEpoch = unixTime / 86400;
    return int((daysSinceEpoch + 4) % 7); // 0=Sun..6=Sat (Jan 1 1970 = Thu = 4)
}

3. No UI::Font enum — use PushFontSize

Error if wrong: No matching symbol 'UI::Font::OpenSansBold'

// CORRECT:
UI::PushFontSize(22.0);
UI::Text("Big text");
UI::PopFontSize();

// WRONG (does not exist):
UI::PushFont(UI::Font::OpenSansBold);     // ERROR
UI::PushFont(UI::Font::DefaultBold);      // ERROR

4. No UI::TextColored — use PushStyleColor

Error if wrong: No matching symbol 'UI::TextColored'

// CORRECT:
UI::PushStyleColor(UI::Col::Text, vec4(0.3f, 1.0f, 0.5f, 1.0f));
UI::Text("Green text");
UI::PopStyleColor();

// WRONG:
UI::TextColored(color, "text");  // ERROR

5. Window position uses int coords

// CORRECT (cast floats to int):
UI::SetNextWindowPos(int(posX), int(posY), UI::Cond::Appearing);

// Triggers float-truncation warning:
UI::SetNextWindowPos(posX, posY, UI::Cond::Appearing);

6. UI::Begin takes a bool reference

bool S_WindowOpen = false;

// The bool ref lets the user close the window with X button:
if (!UI::Begin("My Window", S_WindowOpen, UI::WindowFlags::NoSavedSettings)) {
    UI::End();
    return;
}

7. 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
Time::Info utcInfo = Time::ParseUTC(stamp);       // UTC time
int64 parsed = Time::ParseFormatString("%Y-%m-%d %H:%M", "2026-05-26 20:00");

Debugging Compilation Errors

  1. Check the log file — errors appear in Openplanet/Openplanet.log
  2. Look for [ERROR] lines with your plugin name
  3. Common errors and their fixes:
ErrorLikely causeFix
'xxx' is not a member of 'Time::Info'Wrong caseUse PascalCase: Year, Month, Day, etc.
No matching symbol 'UI::Font::...'Font enum doesn't existUse PushFontSize/PopFontSize
No matching symbol 'UI::TextColored'Function doesn't existUse PushStyleColor(UI::Col::Text, ...)
Float value truncated in implicit conversionfloat where int expectedCast: int(value)
Signed/Unsigned mismatch warningMixing int and uint in comparisonsCast to match: uint(idx) or int(arr.Length)
Signed/Unsigned mismatch warningMixing int and uintCast: uint(idx) or int(arr.Length)
No matching signatures to 'UI::InputText(...)'Wrong param order (e.g. bufferSize as 3rd)Use bool&out changed as 3rd param
Expression must be of boolean type, instead found 'string'Using InputText return in if()Call InputText separately, check bool&out changed after
No matching function 'UI::SetNextWindowPos'Wrong param typesPass int coords: int(x), int(y)

Reference files

This skill ships with the official Openplanet API documentation as reference files. Load any of them when you need API details:

FileSizeContents
OpenPlanet-Global-API.md73KBFull global API reference (Time, UI, nvg, Net, IO, all namespaces)
OpenPlanet-API-Reference.md24KBComplete API reference with all namespaces, enums, and function signatures
Openplanet-Starter-API.md60KBPlugin development guide, callbacks, settings, icons
OpenPlanet-Basic-API.md42KBTutorials: NanoVG drawing, ImGui widgets, shapes, colors
Openplanet-Changelog-API.md16KBOpenplanet version history — what was added/changed/fixed
plugin-skeleton.as1.3KBMinimal plugin template to start from

8. Array initialization — inline int t[] = {...} fails inside functions

// WRONG — inline int array init does NOT work inside functions:
// int t[] = {0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4};  // ERROR: Expected '('

// CORRECT — use array<T> with InsertLast (dynamic):
array<int64> items;
items.InsertLast(123);

// CORRECT — pre-allocate at global scope using Resize():
int[] g_Array;
void Main() { g_Array.Resize(16); }

// CORRECT — inline array init works at global/namespace scope:
int[] monthDays = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};

9. Recurring weekly events pattern

Common pattern for game schedules (COTD, Pursuit, etc.). Store day-of-week (1=Mon..7=Sun), hour, minute:

const int MAX_EVENTS = 16;
int g_Count = 0;
int[] g_WeekDay; int[] g_Hour; int[] g_Min; string[] g_Label;

void InitSchedule() {
    g_WeekDay.Resize(MAX_EVENTS); g_Hour.Resize(MAX_EVENTS);
    g_Min.Resize(MAX_EVENTS); g_Label.Resize(MAX_EVENTS);
    AddEvent(1, 18, 0, "Event name"); // Monday 18:00
}
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++;
}

int64 GetNextEventTs(int dayOfWeek, int hour, int minute) {
    int64 now = Time::Stamp;
    // diff = targetDOW - curDOW; if diff < 0 diff += 7
    // if diff == 0 && time passed: diff = 7
    // return todayStart + diff*86400 + hour*3600 + minute*60
}

10. Converting between Gregorian and lunar calendar dates

For custom calendars (e.g., 13-moon with 28-day months):

int GetDayOfYear(int year, int month, int day) { /* Gregorian DOY */ }
void DayOfYearToGregorian(int year, int dayOfYear, int &out month, int &out day);
uint64 UnixFromGregorian(int year, int month, int day);
void GetGregorianFromUnix(uint64 unixTime, int &out year, int &out month, int &out day);

11. Calendar day indicators (event dots / inline data)

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;
    int curDOW = (curWday == 0) ? 7 : curWday;
    for (int i = 0; i < g_EventCount; i++) {
        int diff = g_WeekDay[i] - curDOW;
        int64 eventDay = curDay + diff;
        if (eventDay >= 1 && eventDay <= 31) g_DaysWithEvents[eventDay - 1] = true;
    }
}
// In DrawCalendar:
if (g_DaysWithEvents[day - 1])
    UI::PushStyleColor(UI::Col::Button, vec4(0.4f, 0.8f, 0.4f, 0.25f));

Inline data in each cell — show computed value without hover:

// In each 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 = Moon::GetTextureForPosition(synodic);
if (tex !is null) {
    UI::SameLine();
    UI::Image(tex, vec2(14, 14));
}

The inline percentage lets users see moon/sidereal cycle at a glance without mousing over each day. Combine with a tooltip for full details, and keep the inline text compact (2-3 chars: "23" not "23.4%").

12. Upcoming events list with countdown

array<int64> eTs; array<string> eLabel; int64 now = Time::Stamp;
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
for (int a = 0; a < int(eTs.Length); a++)
    for (int b = a + 1; b < int(eTs.Length); b++)
        if (eTs[b] < eTs[a]) { /* swap */ }
// Display first 5
for (int i = 0; i < Math::Min(5, int(eTs.Length)); i++) {
    string ds = Time::FormatString("%a %H:%M", eTs[i]);
    if (i == 0) { UI::PushStyleColor(UI::Col::Text, vec4(0.3f, 1.0f, 0.5f, 1.0f)); }
    UI::Text((i==0?"> ":"  ") + eLabel[i] + " " + ds);
    if (i == 0) UI::PopStyleColor();
}

13. Config & Debug Window Pattern

When a plugin grows beyond a simple display, add a tabbed config+debug window:

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();
}

Config tab — toggle rows helper:

void ConfigRow(const string &in label, bool value) {
    UI::TableNextRow();
    UI::TableSetColumnIndex(0); UI::Text(label);
    string s = value ? "ON" : "OFF";
    vec4 c = value ? vec4(0.3f, 1.0f, 0.4f, 1.0f) : vec4(0.6f, 0.6f, 0.6f, 1.0f);
    UI::TableSetColumnIndex(1);
    UI::PushStyleColor(UI::Col::Text, c); UI::Text(s); UI::PopStyleColor();
}

Note: bool is a primitive — pass by value, not &inout. See quirk #14 below.

Status tab — live plugin state:

void RenderStatusTab() {
    uint64 now = Time::get_Stamp();
    UI::Text("Unix: " + tostring(now));
    UI::Text("Today: " + format_string);
    UI::TextDisabled("Cached: " + tostring(g_Cache.GetKeys().Length));
    double val = Compute(now);
    UI::PushStyleColor(UI::Col::Text, GetColor(val));
    UI::Text("Phase: " + GetName(val));
    UI::PopStyleColor();
}

Typical tab breakdown:

TabContent
ConfigAll [Setting] toggles in a table with ON/OFF
StatusLive values, cache queues, computed data
HistoryData tables, map visits, recorded events
ReferenceStatic reference data (nodes, calibration tables)

14. &inout on primitive types is NOT allowed

Error if wrong: Only object types that support object handles can use &inout. Use &in or &out instead

Primitive types (bool, int, float, double, uint64, etc.) cannot use &inout in function parameters — only object types (string, arrays, classes) can.

// WRONG:
void ConfigRow(const string &in label, bool &inout value) { }  // ERROR

// CORRECT — pass by value for reads:
void ConfigRow(const string &in label, bool value) { }

// CORRECT — use &out for write-only, &in for read-only references:
void SetResult(int &out result) { result = 42; }
void ProcessData(const string &in data) { }  // &in is fine for objects

15. UI::InputText — return type vs. bool&out changed

Error if wrong:

  • Expression must be of boolean type, instead found 'string' — using return value directly in if()
  • No matching signatures to 'UI::InputText(...)' — wrong parameter order

Openplanet has TWO overloads:

// Overload 1 — returns string, NO bool&out
string UI::InputText(const string&in label, string str,
    int flags = UI::InputTextFlags::None,
    UI::InputTextCallback@ callback = null);

// Overload 2 — bool&out changed as 3rd param, returns string
string UI::InputText(const string&in label, string str,
    bool&out changed,
    int flags = UI::InputTextFlags::None,
    UI::InputTextCallback@ callback = null);

The trap: UI::InputText ALWAYS returns string, even with the bool&out overload. You CANNOT use it directly in if(). Instead, call it then check the changed flag separately.

// WRONG — overload 1 returns string, can't use in if():
if (UI::InputText("##Input", g_Text, UI::InputTextFlags::EnterReturnsTrue)) { }  // ERROR

// WRONG — overload 2 ALSO returns string, bool&out doesn't change return type:
if (UI::InputText("##Input", g_Text, changed, UI::InputTextFlags::EnterReturnsTrue)) { }  // ERROR

// WRONG — bufferSize (int) as 3rd param doesn't match any overload:
UI::InputText("##Input", g_Text, 256, UI::InputTextFlags::EnterReturnsTrue);     // ERROR

// CORRECT — call InputText, then check changed separately:
bool changed = false;
UI::InputText("##Input", g_Text, changed, UI::InputTextFlags::EnterReturnsTrue);
if (changed) {
    // Enter was pressed
}

Note: InputText accepts string (not string&) for the text parameter — the callback or changed flag handles incremental updates. You don't need to pass 256 as bufferSize; the string grows dynamically.

16. Text::Format takes exactly ONE value argument

Error if wrong: No matching signatures to 'Text::Format(const string, double, double)'

Text::Format() in Openplanet's AngelScript is NOT like C printf — it accepts only one value parameter, not variadic arguments:

// WRONG — multiple values in one call:
Text::Format("%.6f (%.2f%%)", sidereal, sidereal * 100.0);  // ERROR

// CORRECT — use two calls concatenated:
Text::Format("%.6f", sidereal) + " (" + Text::Format("%.2f%%", sidereal * 100.0) + ")";

// Single value works fine:
Text::Format("%.4f°", sidereal * 360.0);
Text::Format("%d items", count);
Text::Format("%.1f km/h", speed);

17. Signed/Unsigned mismatch warnings

Warning: WARN : Signed/Unsigned mismatch — appears when mixing int (signed) and uint (unsigned) in comparisons, assignments, or array indexing.

This is a WARNING, not an error — compilation succeeds, but indicates a potential logic bug.

// WARNING trigger examples:
uint length = arr.Length;    // arr.Length returns uint
int idx = someValue;
if (idx < length) { ... }    // int < uint → signed/unsigned mismatch warning

// CORRECT — cast to match types:
if (uint(idx) < length) { ... }
// or
int len = int(arr.Length);
if (idx < len) { ... }

Common places this appears: comparing loop counters against array.Length (which returns uint), or storing uint results in int variables. Fix by explicitly casting one side to match the other.

18. Cleaning up when removing a feature

Check EVERY .as file when removing feature from a multi-file plugin:

  1. Settings.as — Remove setting toggle, event arrays, InitSchedule, helpers
  2. Core/Math files — Remove calc functions (GetNextTs, HasEvent, etc.)
  3. UI files — Remove rendering: dots, tooltip lines, event sections
  4. Diagnostics file — Remove dead tab content and orphaned functions
  5. Main.as — Remove InitSchedule() call

Use grep -rn "DeletedName" Plugins/<name>/ before deleting to catch all references.


Openplanet Folder Structure Reference

Root layout

Openplanet4/
├── docs/                  # API documentation (Markdown + .h)
├── Plugins/               # Your installed/developed plugins
├── Plugins-Archive/       # Disabled/old plugins
├── Plugins-Developer/     # WIP/dev copies
├── Plugins-Downloaded/    # Downloaded .op files (ZIP archives)
├── PluginStorage/         # Per-plugin persistent data
├── Openplanet/            # Openplanet's runtime files
├── Scripts/               # User scripts (survives updates)
├── ManiaScript/           # ManiaScript libraries
├── Settings.ini           # Openplanet-wide settings
├── Gui.ini                # ImGui window positions/sizes
├── Openplanet.log         # Debug log — check for compilation errors
├── Openplanet.h           # C++ game class hierarchy
├── Openplanet4.json       # Plugin registry metadata
└── OpenplanetCore.json    # Built-in plugin metadata

Openplanet/ runtime files

Scripts/     — Built-in imports (Dialogs.as, Patch.as, etc.)
Plugins/     — System plugins (VehicleState, Camera, Controls, Discord)
Fonts/       — DroidSans*, Montserrat*, Oswald*, ManiaIcons.ttf

⚠️ Never put scripts in Openplanet/Scripts/ — deleted on update. Use Openplanet4/Scripts/.

Plugin dependencies in info.toml

[script]
dependencies = [ "VehicleState" ]  # Also: Camera, Controls, Discord
imports = [ "Dialogs.as", "Patch.as" ]

Reference files

FileSizeContents
OpenPlanet-Global-API.md73KBFull global API ref (Time, UI, nvg, Net, IO)
Openplanet-Starter-API.md60KBPlugin dev guide, callbacks, settings, icons
OpenPlanet-Basic-API.md42KBTutorials: NanoVG, ImGui, shapes, colors
Openplanet-Changelog-API.md16KBVersion history
plugin-skeleton.as1.3KBMinimal plugin template