Interop with the Dunia engine
Symbol names, addresses and call counts are read from FarCry2_server's symbol table (the Linux
dedicated-server build links the same portable UI code with full magma:: and game-class symbols)
and cross-checked against Dunia.dll by string reference. Where a claim is inferred or untraced,
it says so.
A .mgb is a view with no behaviour. It draws, it animates, it can drive its own timeline — and
that is the whole of what it can do alone. Everything else is a contract with compiled C++ in
Dunia.dll, and the contract is made entirely of names. This page is that contract.
The split
Lives in the .mgb | Lives in Dunia.dll |
|---|---|
| Geometry, colour, materials, fonts | Every decision |
| Keyframe animation and its easing | What a click means |
Timeline control (Stop, Continue, GotoFrameIndex, GotoKeyframe) | The list of action names that exist at all |
| Which named action an event raises | The handler that receives it |
| Which widgets exist, and their names | What text/values go into them |
The runtime object model
Everything below assumes this shape. It is worth getting right first, because the C++ API is split
across three object families that the .mgb vocabulary presents as one thing.
magma::Engine one per process; owns loaded packages
└── magma::Package one .mgb
├── magma::GenericObjectTable the package's exported names
├── magma::Area a timeline container
│ └── magma::Element a node: transform, visibility, keyframes, actions, UserData
│ │ (magma::Focusable is an Element subclass — see below)
│ └── magma::Widget the typed behaviour, attached at Element+0x14
│ Text, Image, ListBox, Slider, CheckBox, EditBox,
│ AreaInstance, PageInstance, ButtonInstance, Placeholder…
└── magma::Page a focus/input root that can go on the page stack
Three consequences that shape every API call you will make:
ElementandWidgetare different objects.Area::FindElementreturns anElement*. TheListBox/Text/Imageyou actually want hangs off it atElement+0x14, and you must type-check it before use. That is exactly whatCMagmaFacade::GetWidgetFromXml<T>(0x08a65860) andCMagmaFacade::GetListBox(Element*)do: read*(T**)(element + 0x14), call itsGetType(), and runmagma::BaseObject::ObjectTypeInfo::IsKindOf(T::Type), returningnullptron mismatch.Focusableis not a Widget.CMagmaFacade::FindElement<magma::Focusable>(0x08af2e70) runsIsKindOf(Focusable::Type)against the element itself, not against+0x14. This is the runtime counterpart of the<FOCUSABLE>wrapper documented in the reference: the wrapper is the element, the widget body inside it is the thing at+0x14. So a list box is aFocusableelement carrying aListBoxwidget, and input arrives asListBox::OnKeyDown(Focusable*, const KeyInput&, MessageData&)— the widget is told which focusable it is acting for.- Names are resolved through a global registry, not a package pointer.
magma::GenericObjectServeris amagma::SingletonwithFindGenericObject(const Id&);magma::Engine::LoadPackagepublishes each package'sGenericObjectTableinto it viaRegisterGenericObjectTable, andUnloadPackagewithdraws it. Once a package is loaded, its exported names are visible process-wide with no reference to the package that supplied them.
| Class | Role | Selected API |
|---|---|---|
magma::Engine | Package lifetime | LoadPackage(const FileName*, LoadErrorId&) (+2 overloads), UnloadPackage(Package*&), UnloadAllPackages(), SetViewport, InitializeClientEngine(const ClientFactory*) |
magma::Package | One .mgb | FindArea(Id), FindArea(const BasicString&, bool), AppendArea, InsertArea, RemoveArea, AppendFont, InsertMaterial, SetDefaultMaterial |
magma::Area | Timeline container | FindElement(Id), FindElement(const BasicString&, bool), AppendElement, InsertElement(Element*, int), RemoveElement, LinkElement, UnLinkElement, SetVisible(bool,bool), SetPlaying(bool,bool), SetTime, Tick(uint,bool), SetFrameRate |
magma::Element | Scene node | SetVisible(bool), SetWidget(Widget*), ExecuteActions(uint,uint,ushort), SetTime, InsertKeyframe, PushDrawHandler, CopyFrom |
magma::Focusable | Input/focus element | Activate(), Escape(), SetFocus(InputType), KillFocus(InputType), Enable(bool), SetSelected, SetPressed, SetDefault, PushEventHandler, SetNeighbor, ResolveNeighbors() |
magma::Widget | Typed behaviour | SetParent(EngineObject*), InitState(Focusable*), OnChangeState(Focusable*), On{Key,Mouse}*, Interpolate, GetAreaLink(uint) |
magma::Page | Focus root / stack entry | Enter(), Exit(), SetSelected(InputType, Focusable*), SelectDefaultElement(InputType), MoveSelection(InputType, Direction::Type), PushEventHandler, FindTopPage(), Overlapped(Page&) |
CMagmaFacade: the engine-side UI API
Game code almost never touches magma:: types directly. It goes through CMagmaFacade, a
singleton (CMagmaFacade::ms_instance, ctor 0x09608020) of roughly 130 methods that wraps the
raw object model in CryStringBase-friendly, null-safe helpers. If you are writing a native UI
plugin, this is the surface to mirror — it already does the Element+0x14 casting, the
IsKindOf checks and the not-found handling.
| Group | Methods |
|---|---|
| Find by name | FindPackage(name), FindArea(Package*, name), FindPage(Package*, name), FindElement(Area*, name/Id), FindElement<T>(Area*, name), FindAreaRecurse(Area*, path), FindAreaInstance, FindAutonomousAreaInstance, FindList, FindText, FindImage, FindButton, FindMaterialInPackage, GetElement(Area*, index), GetElementIndex, FindElementIndex |
| Global registry | GetGenericObject<T>(name) for Area/Element/Focusable/Page/Keyframe/PageFocusable; GetGenericObjectWidget<T>(name / Id) for ListBox/Text/EditBox/Image/Widget/AreaInstance; GetGenericObjectId(name), GetGenericObjectFrameIndex(name) |
| Text | SetLabel(Text*, char/wchar_t, colour), SetLabel(Area*, …), SetLabel(AreaInstance*, …), GetLabel(const AreaInstance*), SetLabelRecurse(Area*, …), LocalizeText(Area*, name, CStringID, bool), GetVisibleStringLengthOfWrappedText |
| Geometry / visibility | SetVisible(Element*/Area*/AutonomousAreaInstance*, bool), SetChildVisible(Area*, name/Id, bool), GetPosition, SetPosition(Element*, Vec2i), GetSize, GetColor/SetColor(Element*, Vec4f), AlignElementPositionRelativeTo(Element*, Element*, bool, EElementAlignment), ResizeFitColumns |
| Timeline | SetPlaying/IsPlaying, SetCurrentFrame/GetCurrentFrame, SetFrameRate, GetNextKeyFrame(Keyframe*) — each overloaded for Area*, AreaInstance* and AutonomousAreaInstance* |
| Lists | AddItem(ListBox*, const char*, void* userData), RemoveItem, RemoveAllItems, SetItemColumn(…, const char*/Material*), SetItemColumnColor, SetItemDisabled, SetItemPlaying, SetHeaderText, UseAsColumn, SetMaxVisibleItems, SelectNextListItem, SelectPreviousListItem, SetNomadListCurrentItem, GetListBox(Element* / name) |
| Materials | GetMaterial(pkg, name), SetMaterial(Element*, Material*), SetMaterial(AutonomousAreaInstance*, Material*, name), CreateTexture(void*, size, name, Material*&), CreateTexture(CSmartResourcePtr<CTextureResource>&, name, Material*&), GetMaterialSize |
| Page stack / focus | Push(Page*, …), Pop(…), Peek(…), GetPage(NamedObject*), SelectDefaultElement(Page*, InputType), ClearDefaultElement, SelectElement(Page*, name, InputType), SelectWidget(Page*, Widget*), PushMouseCursor/PopMouseCursor |
| Handlers | PushEventHandler(Page*, CEventHandlerNomadUI*), PopEventHandler(Focusable*, EventHandler*), PopAllEventHandlers(Page*), PushPageHandler(Page*, CPageHandlerNomadUI*), PopAllPageHandlers, PopAreaHandler |
| UserData | GetUserDataInteger(Action*, key, int&), CopyUserData(UserData*, UserData*) |
| Type tests | IsMagmaListBox, IsMagmaText, IsMagmaImage, IsMagmaAreaInstance, IsMagmaAutonomousAreaInstance, IsMagmaButtonInstance, IsNameMatching(Focusable&, name) |
| XML config | GetWidgetFromXml<T>(Area*, XmlNodeRef&, tag, T*&), GetAreaFromXml(Area*, XmlNodeRef&, tag, Area*&) |
CreateTexture is the one route by which content that is not in any .mgb reaches the screen: it
wraps a raw buffer or a CTextureResource into a magma::Material you can then hand to
SetMaterial. Everything else in the table operates on objects the package already authored.
Two lighter-weight value handles wrap the same objects for call sites that only need a few
operations — CPageHandle (FindElement, GetSelected/SetSelected, SelectDefaultElement,
PushEventHandler, PushAreaHandler, KeyDown/KeyUp, IsOnStack(), SetPlaying,
Get/SetCurrentFrame, GetId) and CAreaHandle (FindElement, GetUserData, IsVisible,
IsPlaying, GetCurrentFrame, IsKindOf). CPageStackHandle::Push(CPageHandle, …) is the
handle-level page push.
Actions: a registry compiled into the binary
ACTIONNAME in a .mgb is CRC32(string). magma::ActionServer holds a name-hash → Action* (*create)() table, built once at startup by exactly two registrars:
| Registrar | FarCry2_server | Dunia.dll | Registers |
|---|---|---|---|
magma::ActionServer::RegisterStandardActions | 0x09fdb500 | 0x10ab8000 | 6 — magma's own |
CMagmaActionDispatcher::RegisterCustomActions | 0x095f47f0 | 0x105031b0 | 81 — the game's |
magma::ActionServer::RegisterAction(char const* name, Action* (*create)()) takes a literal string
and a factory pointer, hashes the name with magma::Id::Hash, and appends a CreateEntry.
MakeAction later runs FindActionCreateFunc — a linear scan — and calls the factory. There is no
data path into this table. A name your package fires that nobody registered resolves to nothing.
Each game action is a template instantiated over a per-action C++ symbol, which is what makes 81 distinct classes out of two templates:
CActionSignal<&_magmaactiondispatcher_NavBar_ButtonActivated>::CreateObject
CInputAction <&_magmaactiondispatcher_Vote>::CreateObject
CInputAction is the variant carrying a Trigger string — which is exactly the Trigger UserData
key you see on KeyPressed, Vote, Setting_Next_Value and the *_Gamepad actions in shipped
packages. An action's argument schema is compiled in too: SoundEvent reads
Sound Event Name + Sound Event Type because its C++ class does, and no other keys mean anything
to it.
The six standard actions
Continue, GotoFrameIndex, GotoKeyframe, PopPage, PushPage, Stop — the first five read
directly off Dunia.dll 0x10ab8000's string references, the sixth confirmed by
CRC32("Stop") = 0x1964B988, the hash on 1,279 corpus keyframes.
PushPage and PopPage are registered and no shipped package uses either (0 uses of
#3FDC56C2 / #2BB5AD8B across all 50). That is the one unexplored data-only capability worth
poking: page navigation without native code. Their argument keys are unknown for the same reason —
there is no shipped example to read them off.
The 81 game actions
The complete registry, from the union of RegisterCustomActions' string references and the
CActionSignal/CInputAction template instantiations (both sets agree at exactly 81):
| Group | Names |
|---|---|
| Generic widget | Activated, Escaped, CheckBox_Activate, ListBox_SelectionChanged, List_Item_Selected, Slider_ValueChanged, SetFocusNomad, SetFocusListNomad, ShowElementNomad, HideElementNomad, ShowSelectionListNomad, HidePageNomad, KeyPressed, SoundEvent |
| Menu lists | MenuList_Item_Activated, MenuList_Item_Escaped, MenuList_Item_Selected, MenuList_SelectItem, MenuList_Left_KeyDown, MenuList_Right_KeyDown, MapList_Item_Escaped, TapeList_Item_Selected |
| Settings rows | Setting_Activated, Setting_Next_Value, Setting_Previous_Value |
| Drop-downs | DropDown_Activate, DropDown_Item_Activate, DropDown_Item_Escape, DropDown_GotFocus, DropDown_LostFocus |
| Message boxes | MessageBox_Accept, MessageBox_Cancel, MessageBoxList_SelectItem |
| Nav bar | NavBar_ButtonActivated |
| Music / misc | OnMainMenuMusicStart, OnMainMenuMusicStop, OnCreditMusicStart, Diamond_StartCount |
| Bazaar | Bazaar_Buy, Bazaar_Buy_Gamepad, Bazaar_Cancel, Bazaar_CancelShopPad, Bazaar_CancelCheckoutPad, Bazaar_CheckOut, Bazaar_CheckOut_Gamepad, Bazaar_Checkout_List_Selected, Bazaar_Category_LeftSelectPad, Bazaar_Category_RightSelectPad, Bazaar_WeaponShop_Category_Selected, Bazaar_WeaponShop_MainList_Selected, Bazaar_Done, BazaarShop_AddRemove, BazaarCheckout_AddRemove |
| Map editor (IGE) | IGE_SelectTool, IGE_Tool_Settings_HeaderFocus, IGE_Tool_Settings_Update, IGE_Toolbox_Open_Done, IGE_Toolbox_Close_Done |
| Multiplayer | Multi_JoinMatch, Multi_DeleteMap, Multi_LaunchMapEditor, Multi_Loadout_ChangeWeapon, Multi_XP_ChangeRank, Multi_Avatar_ButtonActivated, Multi_Avatar_ButtonFocus, Multi_Avatar_CustomizationButton, Multi_Avatar_CustomizationLeftButton, Multi_Avatar_CustomizationRightButton, Vote |
| Profile / chat | Profile_Create, Profile_Load, Profile_ApplyChanges, Profile_UbiCom, CHAT_Setup, CHAT_Commit, CHAT_Cancel |
| Player popup | PlayerPopup_Show, PlayerPopup_Next, PlayerPopup_Previous |
| Pause screens | Pause_JackalFiles_TitleLR_Selected, Pause_PlayerStats_SettingsLR_Selected |
Names that read generically (Activated, Escaped, KeyPressed, SoundEvent, the *Nomad
family) are the reusable ones. Everything under Bazaar/IGE/Multi/Profile is a doorbell wired to one
screen's handler: fire Bazaar_Buy from your own page and you get either nothing or the weapon shop
doing something you did not intend.
Dispatch, and who is listening
CActionSignalBase::Execute (FarCry2_server 0x095f9630) is the Action::Execute override every
signal action shares — raising the action is a signal emission, not a direct call.
The receiving side is ordinary game code implementing OnActionSignal. Roughly forty classes do,
one per screen or service:
CFCXBaseOptionPage CFCXOptionNetworkPage CFCXBrightnessPage CFCXLobbyPage
CFCXPauseBuddiesPage CFCXPauseGameStatsPage CFCXPlayerStatsPage CFCXReputationPage
CBazaarComputerUI CLoadOutUI CPlayerPopupMenu CEndOfGamePage
CFCXMainHudUI CFCXHudService CSavePointSaveGamePage CGameOverLoadPage
CFCXMultiCreateMapRotationPage CValueListSetting<unsigned int> …
The routing, traced
Signals reach those classes through CMagmaActionDispatcher::OnActionSignal(const CStringID&, magma::Action*) (0x095f6370). Decompiled, it does four things in this order:
-
Snapshots the listener list. The live
std::list<IMagmaActionListener*>is copied to a temporary before anything is called, so a handler may register or unregister listeners mid-signal without invalidating the walk. -
Handles five actions itself, before any listener sees them — see below.
-
Otherwise broadcasts, in list order, to every registered
IMagmaActionListener:if (m_listenersEnabled) // byte at dispatcher+4for (IMagmaActionListener* l : snapshot)if (l->IsActionListenerEnabled()) // vtable slot 0if (l->OnActionSignal(id, action)) // vtable slot 1return; // consumed — no one else runs -
Frees the snapshot.
So dispatch is a global, ordered chain of responsibility, not page scoping. Four facts follow:
- Registration order is dispatch order.
CMagmaActionDispatcher::AddListener(0x095f4780) scans for the pointer first and appends only if absent — registration is idempotent, and the earliest registrant gets first refusal on every signal. - The first listener returning true consumes the signal. Nothing downstream runs. A listener that handles an action it did not author will silently starve the screen that owns it.
IsActionListenerEnabled()is the gate that makes a global broadcast behave like page scoping. Each listener declares whether it is currently interested;CUIPageBaseexposes this as the overridableOnIsActionListenerEnabled(), which is how a page that is loaded but not on top stops responding.- There is a global kill switch. The byte at
dispatcher+4gates the entire loop.CFCXUiService::DisableMagmaActionDispatcherOneFrame()(0x091eed00) clears it and sets a flag to restore it next frame — used to swallow input during transitions. Note the five built-in actions below sit outside this gate and still fire while listeners are muted.
Five actions the dispatcher handles itself
These never reach a listener, and therefore need no native code at all. Their UserData keys are
read by string, and the names match what the corpus actually uses
(reference):
| Action | Hash | Keys read | Effect |
|---|---|---|---|
ShowElementNomad | 0xD43EE265 | Target element (link) | Element::SetVisible(true) |
HideElementNomad | 0x4A5A04D2 | Target element (link) | Element::SetVisible(false) |
SetFocusNomad | 0xCA729FD4 | Target element (link) | Page::SetSelected(page, controller, element) |
SetFocusListNomad | 0x0AE41C5B | Target list (link), Top? (bool) | selects first or last item, then focuses the list |
ShowSelectionListNomad | 0xB8E88588 | Target list (link), Focus? (bool) | toggles the list's selection highlight without moving focus |
The hashes are plain CRC32 of the action name — confirmed by computing all five and matching the
dispatcher's compiled constants exactly, which also re-confirms CRC32("Stop") = 0x1964B988 from the
keyframe corpus. SetFocusNomad resolves the owning page with CMagmaFacade::GetPage(NamedObject*)
rather than being told it, so the target element may live anywhere in the loaded tree.
The second hop: pages and modules
CUIPageBase is itself an IMagmaActionListener. Its base OnActionSignal (0x091296e0) is a
second chain of responsibility:
bool CUIPageBase::OnActionSignal(const CStringID& id, magma::Action* a) {
for (IUIModule* m : m_modules) // populated by RegisterModule
if (m->OnActionSignal(id, a)) // vtable slot 6
return true;
return false;
}
A derived page overrides this, handles its own actions, and delegates the rest to the base — which
offers them to each IUIModule registered via CUIPageBase::RegisterModule(IUIModule*) /
UnRegisterModule. CNavBarModule is the worked example: it is a reusable widget-plus-behaviour
bundle with OnLinkedToPage(CUIPageBase*) / OnUnLinkedToPage(CUIPageBase*) hooks, shared by every
screen with a nav bar instead of being reimplemented per page.
The full round trip is therefore:
element raises ACTIONNAME
→ ActionServer::MakeAction builds the registered Action object (its UserData = the arguments)
→ CMagmaActionDispatcher::OnActionSignal(CStringID(name), action)
├── one of the five built-ins? handle inline, done
└── else, in registration order, to each enabled IMagmaActionListener
└── CUIPageBase::OnActionSignal
├── the derived page's own handling
└── each registered IUIModule, until one returns true
Registering for events
Actions are only one of four independent notification mechanisms. They differ in what they observe and where you attach them, and a real screen uses several at once.
| Mechanism | Interface | Attach to | Observes |
|---|---|---|---|
| Action listener | IMagmaActionListener | dispatcher (global) | named actions raised by any element |
| Event handler | magma::EventHandler | a Focusable, or a Page | input and focus on that widget/page |
| Page handler | magma::PageHandler | a Page | page lifecycle and page-level input |
| Draw / area handler | magma::DrawHandler, magma::AreaHandler | an Element / an Area | the render pass |
Event handlers — per-widget input and focus
magma::EventHandler is a virtual interface. Every method takes the Focusable it concerns, so one
handler instance can serve many widgets:
OnActivate(Focusable&) OnEscape(Focusable&)
OnSetFocus(Focusable&) OnKillFocus(Focusable&)
OnKeyDown(Focusable&, const KeyInput&) OnKeyUp(Focusable&, const KeyInput&)
OnMouseDown/OnMouseUp/OnMouseMove(Focusable&, const MouseInput&)
OnMouseEnter/OnMouseLeave/OnMouseDoubleClick(Focusable&, const MouseInput&)
OnEnterPageInstance(InputType, PageInstance*, Focusable&, Focusable&, Focusable*&)
Handlers form a stack, not a list: Focusable::PushEventHandler(RefPtr<EventHandler>),
PopEventHandler(), PopAllEventHandlers(), with the same trio on magma::Page
(plus Page::HasPushedEventHandler). The Focusable::Notify* family (NotifyActivate,
NotifySetFocus, NotifyKeyDown, NotifyMouseEnter, …) is what walks that stack; the
Focusable::KeyDown/MouseDown/Activate entry points are what the engine calls into.
Game code does not implement magma::EventHandler directly. It derives CEventHandlerNomadUI,
which owns a nested SEventHandlerImpl : magma::EventHandler and forwards to the outer class's own
virtuals (OnKeyDown, OnKeyUp, OnMouseDown, OnMouseUp, SetFocus, KillFocus) — a pimpl that
keeps game classes free of magma's inheritance. Registration is
CUIPageBase::AddEventHandler(CEventHandlerNomadUI*) / RemoveEventHandler, which reaches
CMagmaFacade::PushEventHandler(Page*, CEventHandlerNomadUI*).
Page handlers — lifecycle
magma::PageHandler covers what an event handler cannot see:
OnEnter(Page&) OnExit(Page&)
OnTick(Page&, uint)
OnKeyDown/OnKeyUp(Page&, const KeyInput&)
OnMouseDown/Up/Move/Enter/Leave/DoubleClick(Page&, const MouseInput&)
OnOverlapped(Page&, Page&) OnUnOverlapped(Page&, Page&)
Overlapped/UnOverlapped fire when another page is pushed on top of or popped off this one — the
correct hook for "pause when a dialog opens". The game's adapter is CPageHandlerNomadUI, pushed
with CMagmaFacade::PushPageHandler(Page*, CPageHandlerNomadUI*); Page::NotifyPageHandlers walks
them, and Page::HasPushedPageHandlerType(const Handler::ObjectTypeInfo*) tests for one by type.
Draw handlers — the render pass
magma::DrawHandler (OnPreDraw(const Element&), OnPostDraw(const Element&)) and
magma::AreaHandler (OnPreDraw(Area&), OnPostDraw(Area&)) bracket drawing. Push them with
Element::PushDrawHandler(RefPtr<DrawHandler>) / Area::PushDrawHandler /
Area::PushAreaHandler(RefPtr<AreaHandler>), and the matching Pop…/PopAll…. These are the only
hooks that let native code inject rendering into a Magma page rather than merely reposition
authored content.
Reading and writing parameters
magma::UserData is the parameter store. Every element, and every Action object, is a UserData
— a keyed map of magma::Variant, addressable by magma::Id (the name hash) or by the literal
string. It is readable and writable at runtime, which makes it the general-purpose channel
between the layout and the code.
| Direction | Methods |
|---|---|
| Read | GetUserData(key) → Variant; typed: GetUserDataBool, GetUserDataInteger, GetUserDataFloat, GetUserDataString, GetUserDataPointer, GetUserDataElement, GetUserDataArea, GetUserDataAreaLink (→ FullLink*), GetUserDataKeyframe, GetUserDataStringResourceExternalId — each (key, out&) returning bool |
| Enumerate | GetUserDataItem(uint index) |
| Write | AddUserData(key, const Variant&), SetUserData(key, const Variant&), RemoveUserData(key), ReserveNbUserData(uint), CopyFrom(EngineObject*) |
Every typed getter has both a magma::Id and a BasicString<char> overload, and they map
one-to-one onto the UserData value kinds you author in the .mgb
(reference) — so the XML side and the C++ side are the same
table seen from two directions.
This is how an action's arguments arrive. CMagmaActionDispatcher::OnActionSignal reads
Target element and Focus? straight off the Action* it is handed, and
CMagmaFacade::GetUserDataInteger(magma::Action*, const char*, int&) is the convenience wrapper
game handlers use. An action's argument schema is not declared anywhere — it is whichever keys its
C++ class chooses to read, which is why the tables in the reference were recovered by decompilation
rather than from a manifest.
Per-instance overrides on AreaInstance
The one place Magma does have something like data binding is magma::AreaInstance — an element that
re-instantiates another Area. Rather than editing the shared source area, you override values
per instance, keyed by Id:
| Call | Overrides |
|---|---|
AreaInstance::SetLabel(Id, const BasicString<wchar_t>&) (also (const char*, …) and PKw forms) | a text element's string inside this instance |
AreaInstance::SetStringResourceLabel(Id, const StringResourceExternalId&) | the same, as a localised OASIS key |
AreaInstance::SetMaterial(Id, Material*) / (const char*, Material*) | an image's material inside this instance |
AreaInstance::RemoveLabel(Id), RemoveResourceLabel(Id) | drops back to the source area's value |
AreaInstance::SetTimeOffset(int), SetIndexOffset(int) | staggers this instance's timeline |
CMagmaFacade mirrors these as SetLabel(AreaInstance*, …), GetLabel(const AreaInstance*) and
GetSubAreaInstance(AreaInstance*, name). Authoring one row/card/button area and stamping it out N
times with different labels is exactly this mechanism, and it is why AreaInstance is the
second-most common element in the corpus.
Nothing in a .mgb is substituted at load time — there is no templating pass. These overrides
are applied by code after the package is live.
Adding widgets at runtime
The blunt answer: you don't, and neither does the game.
The mutation API exists and is complete — Area::AppendElement(Element*),
Area::InsertElement(Element*, int), RemoveElement, LinkElement/UnLinkElement,
ReserveNbElement(int), Element::SetWidget(Widget*), Package::AppendArea/InsertArea, and every
element type has a ms_pool magma::MemoryPool to allocate from. But it is the package loader's
API: magma::BinaryLoadVisitor builds the tree with it while parsing the .mgb. No game-code call
sites were observed constructing an element and appending it to a live area, and the whole rest of
the API — find-by-name, type-check, set properties — is shaped around the assumption that the tree
is fixed once loaded.
What the game does instead, in descending order of how much you get for free:
ListBoxitems. The one genuine create-at-runtime path.ListBox::AddItem(const wstring&, void* userData)(alsoconst wchar_t*andStringResourceExternalIdoverloads) clones the authored row template per item;InsertItem(int, …),RemoveItem(int),RemoveAllItems(bool)andRemoveDuplicatedItems()complete it. Rows are addressed by index thereafter:SetItemColumn(row, col, text/Material*),SetItemColumnColor,SetItemDisabled,SetCurrentItem(int, bool, bool),Sort(int)/SetSortFunction(const SortFunctor*),FindItem(const wchar_t*),GetAreaLink(uint)for the row's own area. Thevoid* userDatayou pass toAddItemround-trips back to you, which is how rows carry a payload pointer.AreaInstancestamping. Instantiate an authored area repeatedly and override labels and materials per instance, as above.- Pre-authored slots. Author more widgets than you need and drive the ones you use by name.
This is the standard trick for a variable-length screen that is not a list — and it is what FCSE
does: ship a layout declaring 20
FCSE_SLOT_nnwidgets, then bind them one per row.
HIDDEN and revealing it from code does not workIt is the obvious way to build such a bank, and it fails in a way that costs a debugging session.
HIDDEN and magma::Element::SetVisible are different bits of the same flags byte at
element+0x34: SetVisible (0x10ab13f0) writes bit 0, while the authored HIDDEN flag is bit 1 —
and magma's draw collection (0x10ad3fb0) skips any element whose bit 1 is set. So SetVisible(true)
on an authored-hidden element leaves it in a state that is neither drawn nor inert, and the engine
dereferences a null shortly after.
Author every slot visible and hide the unused ones at runtime instead. That only ever moves bit 0,
which is what ShowElementNomad and HideElementNomad do, so it is a path the engine already
exercises. To reach a slot's element without binding anything to it, resolve the page's own
FullLink property by name with magma::UserData::GetUserDataElement (0x10a963a0,
bool(const std::string&, Element*&)) — the same lookup CUISettingBase::FetchMagmaElements
performs. Resolve once after the page's Init, cache the pointers, and toggle them per rebuild.
Area::SetDynamicSize() and Area::SetStaticBox(const Rect2D<short>&) control whether the
container re-measures itself after its contents change, which is what keeps a rebuilt list from
clipping.
RemoveAllItems at 70 call sites against AddItem at 81 is the shape of the whole system: screens
rebuild their lists from scratch every time they display. Anything inserted outside that rebuild
disappears on the next Display() — the trap FCSE hit twice.
Settings rows
Every options screen is a CSettingsPage, and its rows are not built widget by widget. Three calls
each produce a complete row — a label in the page's row ListBox plus a bound control — and they
are the whole vocabulary:
| Call | Dunia.dll | Control | Cell area |
|---|---|---|---|
CSettingsPage::AddBoolSetting | 0x10cde0d0 | YES/NO spinner | common #652FD37C |
CSettingsPage::AddValueListSetting<unsigned> | 0x1081d660 | ‹ value › spinner over N options | common #652FD37C |
CSettingsPage::AddSliderSetting | 0x10cddff0 | draggable slider | common #62EA6603 |
CUISettingBase* AddBoolSetting (page, const wchar_t* label, const char* labelListParam,
const char* slotParam, const wchar_t* yes, const wchar_t* no,
int enabled, void* handler);
CUISettingBase* AddValueListSetting (page, const wchar_t* label, const char* labelListParam,
const char* slotParam, unsigned count,
const wchar_t* const* itemLabels, const unsigned* itemValues,
int enabled, void* handler);
CUISettingBase* AddSliderSetting (page, const wchar_t* label, const char* labelListParam,
const char* slotParam, int min, int max,
int enabled, void* handler);
Three things are worth reading off that table.
The bool row and the dropdown are the same widget. #652FD37C is a ListBox with
BUTTONCOUNT="1" — a one-item viewport that scrolls whatever was added to it. Two items is a
toggle, four is the Difficulty row. A layout that declares one kind of cell per row already supports
both.
labelListParam and slotParam are UserData property names on the page's own area, not
widget names: each is a FullLink the page resolves through CUISettingBase::FetchMagmaElements.
This is the name contract in its most concrete form — the
layout publishes SETTING_DIFFICULTY, the code asks for it by that string.
Every stock call site passes handler = 0. A settings row is driven by the CUISettingBase
attached to it and by widget events, not by a menu-item handler. The setting caches its value and
exposes it through vtable slots 13 and 14 (+0x34 SetValue, +0x38 GetValue), both taking and
returning a pointer to the value — one byte for the bool variant, four for the others. The slider
stores an int and converts to float inside magma::Slider::SetValue.
Field layout differs by variant, which matters if you inspect a setting to check its slot resolved:
a CValueListSetting puts its element at +0x48 and its widget at +0x44 (plus the value array at
+0x4c/+0x50), while CSliderSetting::FetchMagmaElements (0x10cde3c0) uses +0x4c and +0x48.
An unresolved slotParam is silent: the item-adds are guarded on the widget being non-null, so the
row appears with no control rather than failing.
Text entry is an authored element, not a dialog
Worth stating plainly because the obvious guess is wrong: CGameMessageBoxEditBox is not a text
prompt. Raising it live produces the "you have unsaved changes" confirmation, not something a
player can type into. The message-box family is for confirmations.
The way the game actually takes text is far simpler, and the stock Options → Network page is the
reference: it authors bare EditBox elements directly on the page area, six of them, at the row
positions immediately below its four spinner rows. Each is a top-level element of type EditBox
carrying:
- a
FIELDLINKand aCURSORLINKintocommon.mgb(the field backing and the caret), - its own
ActionExecuterEditboxraising an action on theentertrigger, - a
FOCUSABLE, so the player can reach and type into it.
Two consequences for a mod. First, no modal and no new engine call are needed — text entry is
authoring. Second, those elements are not reachable through the page's SETTING_* links: the
stock page resolves them through the XML element factory, which is why their name hashes appear
nowhere in Dunia.dll. A layout you own can do better by giving each one a FullLink property like
any other slot — but note that every FullLink in the shipped corpus is a 5-id chain through an
instanced area (package, page, element, area, widget); a 3-id chain naming a direct element is
unattested. The safe shape is therefore a small local area holding the EditBox, instanced once per
row, exactly like the value and slider cells.
Modal dialogs
The game carries a family of modal boxes and all of their layouts live in common.mgb, which is
loaded whenever a menu is up. So any screen — including a mod's own — can raise one with no layout of
its own. (Verified by probing each page name's CRC32 against every shipped menu package:
MESSAGEBOX_EDIT_BOX (0xA98A4F3F), MESSAGEBOXLIST, MESSAGEBOX and MESSAGEBOX_SINGLEBTN
appear in common.mgb and nowhere else.)
CGameMessageBoxHelper's constructor (0x1004cc10) registers one factory per box type —
CGameMessageBox, CGameMessageBoxList, CGameMessageBoxListSingleButton,
CGameMessageBoxEditBox, its password variant, and one more. Raising one is four steps:
- Build a
CGameMessageBoxParamon the stack with0x1004d9b0(ctor(const wstring& title, const wstring& text, int flags);0x11is the stock two-button shape). Its destructor is0x1003d5e0. - Choose the class by overwriting the registration handle at
param+0x10, which the constructor defaults to the plain box. - Caption the buttons with
0x1004d890(SetButtonCaption(int buttonBit, const wstring&)); the stock caller uses bit0x01and bit0x10. - Show it with
CGameMessageBoxHelper::Show(0x1004cd50,__thiscall(T** out, Listener*, const Param&)on the singleton at*(void**)0x10fded3c).
The listener is where the answer comes back, and its contract is small enough to read off the
one call site that uses it (0x1004c6a7): this is the listener, and the engine calls
slot +0x04 with exactly one argument, the page. The word at listener+0x04 is not a virtual
at all — the adapter constructor (0x1004c2b0) writes the box's token into it, so a listener is
{ vptr; token; }.
0x10ab2900 narrows a wstring into a std::string (truncating each unit to a byte, 0x7f above
0xFF), and CFCXCustomMapService::DisplayDeleteMessageBox (0x107b5810) is the complete worked
example.
The page owns its rows by button id
CListMenuPage::AddButton returns an int, and CSettingsPage keeps a
std::map<int, CUISettingBase*> at page+0x190 mapping that id to the setting. A derived page then
stores the ids of the rows it cares about in its own members and looks them up later.
That last step is where reusing a shipped page class for a mod's own screen goes wrong.
CFCXOptionGamePage holds ten such ids at +0x1d8 … +0x1fc, and two functions walk them —
0x1081f800 (options → settings) and 0x1081fd10 (settings → options). Both do:
setting = m_settings[id]; // operator[] inserts a null for a missing key
if (setting && !IsKindOf(setting, ExpectedType))
setting = NULL; // the guard nulls it…
value = (*(setting->vtable + 0x38))(setting); // …and the call dereferences it anyway
On the page they were written for the ids always resolve to a setting of the expected type, so the
shipped bug never fires. Replace that page's rows with your own and it fires immediately: the new
rows are handed the same button ids back with the wrong types. Clearing the ids does not help —
operator[] inserts a null and the same dereference follows.
Reusing a concrete page class safely
The fix generalises past this one class, but only if you find all of the walkers. Scanning the class's translation unit for instructions that form the address of anything in the id block turns up 24 of them, in five functions, and each is reachable through exactly one vtable slot:
| Slot | Reaches | What it is |
|---|---|---|
+0x08 | RefreshOptionList | Display — RefreshOptionList(); this+0x200 = 0; CFCXBaseOptionPage::Display(); |
+0x10 | FUN_1081f4f0 | Update(float) — base tick, then a switch on this+0x200 |
+0x4c | FUN_1081f6c0 | OnSettingChanged(Action*) — see below |
+0x50 | ApplyOptionsFromSettings | apply |
+0x54 | UpdateSettingsFromOptions | refresh |
So a mod can construct the real class — which is what correctly initialises the settings map, the embedded strings and the secondary vptrs — and then point the object at a private copy of its vtable with those five slots replaced. Replacing only some of them is not a partial fix, it is a delayed crash: an earlier attempt at this replaced three, and the two it missed surfaced as an access violation the first time a player changed a value.
+0x4c is worth calling out, because it is the hook such a page actually wants.
CSettingsPage::OnActionSignal (0x10cdde80) offers each incoming action to every setting it owns,
and when one consumes it — which is how a row's value changes at all — it calls this slot through
the primary vtable. The setting has already updated itself by then, so this is a clean "a value just
changed" notification.
Two other pieces of CFCXBaseOptionPage state matter to a page that does its own persistence. The
byte at page+0x1B8 is the "unsaved changes" flag: SetDirty (0x1087eb50) raises it,
ApplyIfDirty (0x1087eb10) calls +0x50 and clears it, and the Back path reads it to decide
whether to warn. A page that writes its changes immediately should clear it rather than answer the
prompt. And this+0x200 is a small state machine whose only reader is the Update slot, so
neutralising that slot makes it inert no matter what writes it.
Nothing is patched, so the stock screen that shares the class is unaffected by construction rather
than by a this-comparison inside a global hook, and the option ids simply stay at the -1 the
constructor left them at, because only RefreshOptionList ever fills them. Hand-rolling a page class
from scratch instead is the tempting alternative and the wrong one: CFCXBaseOptionPage is abstract,
and constructing it works right up until the input path calls one of its pure virtuals and the
process dies with R6025.
Get the vtable's size right — it is 26 slots for CFCXOptionGamePage, and the bytes after it are
string data, not a 27th entry.
Element names are indirected through XML config
The name a page looks up is not always a literal in the binary. Two cooperating systems put an XML file in between.
CFCXUiService::AddMagmaUIConfig(CMagmaConfigUIResource*) (0x091f0640) walks a config
resource's XML children, hashes each node's name with magma::Id::Hash, and inserts it into a
std::map<magma::Id, XmlNodeRef> on the service, recursing into nested resource containers. The
result is a registry of config nodes keyed by hashed name.
CMagmaFacade::GetWidgetFromXml<T>(magma::Area* root, XmlNodeRef& cfg, const char* childTag, T*& out) then resolves a widget through one of those nodes. Both compiled specializations
(magma::Text at 0x08a65860, magma::AreaInstance at 0x08a656c0) are byte-for-byte the same
shape:
cfg->findChild(childTag)— bail if absent, leavingoutuntouched;- read the
pathattribute →CMagmaFacade::FindAreaRecurse(root, path); - read the
textattribute →magma::Area::FindElement(area, name); - type-check
*(T**)(element + 0x14)withIsKindOf(T::Type);nullptron mismatch.
So a config node child looks like <someTag path="a_containing_area" text="t_the_element"/>, and
the attribute names are fixed regardless of widget type. GetAreaFromXml is the same for areas.
Separately, CMagmaElementFactory (singleton, ctor 0x09283310) is a fuller version of the
same idea: LoadFromXML(const XmlConstNodeRef&) parses a config into per-page CFactoryPage
objects — each holding a package name, page attributes, an element map and a material map keyed by
CStringID — with ParseItems and ParseMaterials doing the reading. Pages then ask for logical
names: GetElement(pageId, id), GetText, GetListBox, GetImage, GetAreaInstance,
GetButtonInstance, GetPrivateElement, GetMaterial, GetElementName(pageId, id),
GetPackageName(pageId), GetPageAttributes(pageId). CFactoryPage::CacheElements(Area*, Package*) and CElement::Cache(Area*) resolve the logical names against a live package once, and
RebuildElement, CopyElements(pageA, pageB), Finalize(pageId) and Reset() manage the lifetime.
The modding consequence is real: where a screen goes through the factory or GetWidgetFromXml,
the binding between code and layout is a data file, not a hardcoded string — you can re-point it
at differently-named widgets without patching code. Where a screen calls FindElement with a
literal (as CUIPageBase::FetchMagmaElements does for p_menu_nav and a_title_bar), you cannot.
Which screens use which was not enumerated.
Page lifecycle
CUIPageBase is the base every menu screen derives from. Its surface is small and worth knowing in
full, because a native page must participate in all of it:
| Group | Methods |
|---|---|
| Lifetime | Init() / DoInit(), Unload() / DoUnload(), Shutdown() / DoShutdown() |
| Display | Display(), Hide(), Update(float), GetShowCursor(), GetLayer(), GetTopLevel() |
| Binding | SetPage(magma::Page*), FetchMagmaElements(), ConfigPage() |
| Navigation | PushPage(), PopPage() |
| Events | OnActionSignal(const CStringID&, magma::Action*), AddListener/RemoveListener(IMagmaActionListener*), IsActionListenerEnabled() / OnIsActionListenerEnabled(), AddEventHandler/RemoveEventHandler(CEventHandlerNomadUI*) |
| Composition | RegisterModule/UnRegisterModule(IUIModule*), AddCommand/RemoveCommand(CUICommand*), ExecuteCommands() |
Init() is the step that binds the C++ object to its layout, by hashing the page's authored name and
resolving it through GenericObjectServer::FindGenericObject — which is why a page's .mgb must be
loaded before its class is constructed, and why skipping Init() produces a page object whose
element pointers are all null. FetchMagmaElements() is the override where a derived page grabs and
caches the widgets it will drive; ConfigPage() is a pure forward to a vtable slot, i.e. a
derived-class hook with no base behaviour.
There is no Lua in the menus
Far Cry 2 does embed Lua — see the Lua API surface and
Domino scripts — but that surface is mission and gameplay
scripting. It exposes no UI construction, no widget access and no menu control; the only
menu-adjacent entries in the whole binding table are game-state toggles like SetCinematicUIMode
and SetShowRescueBuddyInMenu. Menu logic is compiled C++ with no scripting layer, which is why
adding behaviour means a native plugin rather than a script.
How runtime data reaches a widget
Nothing in a .mgb is filled in at load time — there is no templating pass and no substitution.
Native code finds live objects by name hash and writes into them (the nearest thing to binding
is the per-instance AreaInstance override described above,
which is also applied by code after load):
| Call | Call sites | Role |
|---|---|---|
magma::Package::FindArea | 20 | area by name |
magma::Area::FindElement | 60 | element by name |
magma::UserData::GetUserDataElement | 12 | resolve a FullLink property to a widget |
magma::ListBox::AddItem | 81 | append a row |
magma::ListBox::RemoveAllItems | 70 | clear before a rebuild |
magma::Element::SetVisible | 94 | show/hide |
magma::Slider::SetValue | 16 | push a value |
magma::TextBase::SetString | — | push text |
The name contract, in both directions
- Names the code demands of your layout.
CUIPageBase::FetchMagmaElementslooks upp_menu_nav→l_menu_nav_listanda_title_bar→t_page_titleby hardcoded name. Miss one and the page renders empty —AddButtonreturns −1 and does nothing. - Names your layout offers to the code. The
SETTING_*UserDataFullLinkproperties are a manifest: "the widget you will ask for asSETTING_DIFFICULTYis this one". That is the closest thing Magma has to a binding, and it carries a pointer, not a value.
Both are covered with worked examples in binding a page to native code.
What is genuinely data-driven
Worth knowing precisely, because it is the part you can use with no code at all:
- Timelines. Keyframes plus
Stop/Continue/GotoFrameIndex/GotoKeyframeare a real state machine — fades, reveals, page-flip animations and button states all run with no native involvement. - Show/hide and focus, via the five
*Nomadactions.ShowElementNomad,HideElementNomad,SetFocusNomad,SetFocusListNomadandShowSelectionListNomadare executed by the dispatcher itself and never reach game code — a.mgbcan toggle visibility and move focus anywhere in the loaded tree with no C++ behind it. This is the largest data-only capability on the list and the corpus already uses all five. - List row duplication. A
ListBoxclones its template area per item; you author one row. - Widget-to-widget links.
SLIDERLINK, and theListBox/Slider/EditBoxsub-links. PushPage/PopPage. Registered, unexplored.- Localised text. OASIS keys in a
Textresolve at draw time. - The
.mgb.descsidecar. The one declarative configuration layer — nav-bar prompts, HUD prompt element paths, controller layouts — keyed by page name.
The practical shape of a UI mod
- Reskinning and re-layout of an existing screen needs nothing but a
.mgbedit: the names stay the same, so the code keeps finding its widgets. - New animation and visual state likewise — it is all timeline.
- New screens need a native plugin, because a page is bound by a C++ page object calling
Init(), and rows are built by nativeAddButton/AddBoolSettingcalls. FCSE is the worked example: ship a layout declaring 20FCSE_SLOT_nnwidgets, then call against those names. - New behaviour always means code. There is no data-only path to a new action.
And if you are writing that native plugin, the shape it must take is now fully determined:
| You want to… | Do this |
|---|---|
| Get your layout's names resolvable | magma::Engine::LoadPackage, which publishes the package's GenericObjectTable to GenericObjectServer |
| Reach a widget | CMagmaFacade::GetGenericObjectWidget<T>(name), or FindElement + the Element+0x14 type-check |
| React to a click | Implement IMagmaActionListener, register it, and gate yourself with IsActionListenerEnabled() |
| React to focus/keys on one widget | Push a CEventHandlerNomadUI |
| React to your screen opening/closing | Push a CPageHandlerNomadUI and use OnEnter/OnExit/OnOverlapped |
| Read an action's arguments | magma::UserData typed getters on the Action* |
| Put text/values on screen | CMagmaFacade::SetLabel, SetMaterial, SetVisible, ListBox::AddItem |
| Show a variable number of things | A ListBox, or AreaInstance stamping, or pre-authored hidden slots — never Area::AppendElement |
| Draw something Magma cannot | CreateTexture into a Material, or push a DrawHandler |