The finished mod renders a compact battle panel containing the current arena phase, a server-time countdown, and the arena identifier. Python owns battle truth and window lifetime; Gameface owns presentation and reports its surface-sizing attempt.
Follow current event producers, callers and cleanup paths before adapting this example.
What we’re building
A fixed, passive 300 × 78 battle overlay. It appears only after the shared battle page has populated, updates from arena-period events and a four-Hz countdown, remains unfocused, and is destroyed before the page is disposed.
Final result
- One Python mod file.
- One native resource JSON file plus HTML, CSS, and JavaScript.
- One panel-sized Wulf window on the passive window layer.
- One JSON payload property and one frontend-ready command.
- One cancellable countdown callback and one cancellable parent-readiness callback.
What you’ll learn
- Why avatar entry, page readiness, Wulf readiness, engine readiness, DOM readiness, and surface sizing are different facts.
- How a handwritten Wulf
ViewModelcrosses into Gameface. - How to make a native layout check and parent retry stale-safe.
- Why
resizeViewPxandshow(False)are part of correctness. - How to destroy Python, Wulf, model, callback, and frontend resources symmetrically.
Difficulty and prerequisites
Level 4 — Gameface. Complete the Level 3 Battle Session Counter and read Standalone Gameface overlays. This standalone tutorial uses native resource registration; no injection helper is required.
Requirements and constraints
Functional requirements
- Show only while a
PlayerAvatarand populated shared battle page own the context. - Display phase, remaining whole seconds, and arena ID.
- Update immediately on the four-argument arena-period event.
- Refresh the countdown without rebuilding unchanged payloads.
- Remain passive and never steal battle input focus.
- Destroy all owned resources on page disposal, avatar exit, disconnect, or mod finalization.
Non-functional requirements
- Python 2.7 compatibility.
- No fullscreen transparent input surface.
- No arbitrary creation sleep.
- Bounded, token-guarded readiness retries.
- Traceback-bearing diagnostics at unsafe hook and callback boundaries.
- Explicit resource names and matching cross-runtime contracts.
Acceptance criteria
- Repeated
init()installs one listener/hook set. - Avatar entry alone creates no window.
- One page population produces at most one window.
- CSS and JavaScript baselines and Python's declared size constants agree at
300 × 78; verify the accepted native size separately. - The first model value renders before any data-change event.
- Unchanged tick seconds skip publication; a period event can publish a changed phase with the same second.
- A stale parent callback cannot recreate a destroyed window.
- Page disposal cancels both callbacks and destroys the window before the native page.
- Repeated
fini()is safe.
Architecture
PlayerAvatar + arena period (authoritative battle context)
↓ copied scalars
BattlePhaseMonitor (battle-page owner)
↓ JSON string in one Wulf property
ViewModel → ViewImpl → panel-sized WindowImpl
↓ window.model.payload
Gameface JS → owned DOM
SharedPage._dispose
↓
callbacks cancelled → window destroyed → model releasedThe UI never reads BigWorld.player(). The frontend receives only presentation-ready scalars. Python never queries the DOM. The ready command is the sole reverse bridge and reports a Gameface surface-sizing attempt rather than battle-data authority or native size acceptance.
Researching the implementation
Investigation chain
Requirement: visible passive battle panel
↓ Owner? shared battle page + current PlayerAvatar
↓ View boundary? SharedPage._populate after original returns
↓ Window parent? current loaded Wulf main window
↓ Data signal? g_playerEvents.onArenaPeriodChange(period, end, length, info)
↓ Clock? BigWorld.serverTime(), sampled by an owned callback
↓ Frontend readiness? engine + DOM built + two layout frames
↓ Native sizing? viewEnv.resizeViewPx(exact scaled dimensions)
↓ Cleanup? SharedPage._dispose before original + avatar/disconnect/fini
↓ Update audit? recheck hooks, native declaration, bridge events, arena fields
What the source proves
PlayerEventsexposes a SafeEvent for arena-period changes.- Current consumers use the signature
(period, periodEndTime, periodLength, periodAdditionalInfo). - Native timers derive remaining time from
endTime - BigWorld.serverTime(). - The verified battle-overlay pattern creates after
SharedPage._populateand destroys beforeSharedPage._dispose. - A usable parent has a proxy and
WindowStatus.LOADED. - Raw Gameface HTML must explicitly resize its native surface after engine and DOM readiness.
Source lookup
The monitor crosses arena, Wulf, Gameface, and browser lifecycles. Read the matching owner for each boundary:
- Arena periods — period ownership, event meaning, and timer authority.
- Native resource registration — strict JSON, dotted accessors, and mounted paths.
- Python view/model contract, initial and later model values, and frontend data updates.
- Gameface readiness sequence and cleanup ownership — why page, resource, window, DOM, and model readiness are separate.
- Gameface diagnostic workflow — where to look when a panel is blank, stale, duplicated, or zero-sized.
Useful current-source searches include onArenaPeriodChange, BigWorld.serverTime, SharedPage._populate, SharedPage._dispose, WindowStatus.LOADED, and resizeViewPx. Treat them as investigation entry points and verify current registrations, callers, payloads, and teardown; aliases and resource APIs are build-sensitive.
Lifecycle and ownership
- Import: define inert classes, one owner, and composable wrapper functions.
- Initialization: subscribe global lifecycle events and install page wrappers once.
- Avatar entry: select a new battle generation, copy initial arena values, but create no window.
- Page population: mark the page ready and begin bounded parent-readiness checks.
- Frontend ready: center and show the already-owned window without focus once page, window and content gates all pass.
- Period/tick: update copied scalar state and transact only when the payload changes.
- Page disposal: invalidate the generation, then attempt cancellation and destruction before the previous page disposal.
- Avatar exit/disconnect/fini: avatar exit and disconnect deactivate the owner; finalization also attempts owned unsubscription and direct hook restoration.
State and data-authority model
| State | Scope | Authority | Invalidation |
|---|---|---|---|
| Period/end time | Battle | Arena-period event / current arena | New period or battle exit |
| Arena ID | Battle | PlayerAvatar.arenaUniqueID | Battle exit |
| Displayed seconds | Presentation cache | Derived from server time | End-time change or tick |
| Window/model | Battle page | Mod-owned Wulf objects | Page disposal |
| DOM/listeners | Gameface view | Frontend script | Window unload |
| Generation token | Owner | Monotonic invalidation | Every deactivate |
Implementation walkthrough
Model contract
The handwritten model registers one string property and must provide its own indexed setter. The initial JSON enters through the constructor; later updates run inside transaction(). The authored attach_model() also calls publish() during view construction, so this path does not wait for frontend readiness. publish() records _last_payload before the transaction; a failure can leave an equal later payload suppressed. A transaction does not roll back that Python cache or acknowledge display.
Readiness chain
The page chooses when creation may begin. The native resource tree supplies the layout ID before mod startup. The retry chooses when a current Wulf parent is loaded. JavaScript chooses when DOM layout is stable enough to size. No one signal substitutes for the others. The frontend calls its ready command after attempting a resize, including on later scale updates; it does not read back an accepted native size. Its unload cleanup removes registered engine/model callbacks but does not cancel queued animation frames or guard readiness continuations with a separate disposed flag. Preserve an owned disposal guard when adapting this illustrative code; these are source-visible limits, not observed client failures.
Hook contract
The wrappers call the previously installed implementation exactly once. Populate notifies after the previous method. Dispose cleans before the previous method. Finalization restores only when the class still contains this mod’s wrapper; if another mod wraps it later, safe removal cannot be guaranteed and that limitation must remain documented.
Python owner and failure boundaries
The arena producer updates its received period tuple, fires the local event and then forwards the unpacked values globally. The shared page supplies separate populate/dispose boundaries. Avatar entry copies context; it does not establish page or parent readiness.
- The parent check runs immediately and permits at most 80 delayed retries at 0.1 seconds. This bounds attempts, not elapsed wall time. Repeated calls while waiting can supersede a generation without cancelling its earlier callback; the shared callback slot is cleared before the token check. Stale creation is rejected, but this is not a complete overlapping-callback cancellation guarantee.
- The base window ready hook normally calls
show(). This example overrides it and waits for page, window and content flags beforeshow(False). Placement uses the parent size copied once before load; later ready commands do not refresh that snapshot. - The tick keeps one callback on its successful normal path. An exception before its final scheduling call can stop refresh. Cleanup is also sequential: cancellation or destruction failure can skip later releases, although the dispose wrapper logs the failure and still calls the previous page method.
- The module and owner have separate installed flags. A layout lookup failure can leave page hooks installed with no global listeners; partial subscription failure has no rollback ledger. Conditional restoration removes only a directly installed wrapper, not one retained by a later wrapper.
These are inspected limits of this illustrative implementation, not reproduced client failures. The acceptance checklist describes behavior to verify; it does not certify these failure paths or every event/timer boundary.
Complete source code
# -*- coding: utf-8 -*-
"""Show arena phase and remaining time in a standalone Gameface panel."""
from __future__ import absolute_import, unicode_literals
# Reader map:
# 1. Wulf model/view/window classes own the Python presentation surface.
# 2. _BattlePhaseMonitor owns battle facts, callbacks, and readiness state.
# 3. Page wrappers bind that owner to the shared battle-page lifetime.
import json
import logging
import BigWorld
import constants
from Avatar import PlayerAvatar
from PlayerEvents import g_playerEvents
from frameworks.wulf import (ViewFlags, ViewModel, ViewSettings, WindowFlags,
WindowLayer, WindowStatus)
from gui.Scaleform.daapi.view.battle.shared.page import SharedPage
from gui.impl.pub import ViewImpl, WindowImpl
from helpers import dependency
from skeletons.gui.impl import IGuiLoader
from gui.impl.gen import R
__version__ = '1.0.1'
_LOGGER = logging.getLogger(__name__)
_MOD_NAME = 'battle_phase_monitor'
_LAYOUT = R.mods.tutorial.BattlePhaseMonitor.views.BattlePhaseMonitor
_WIDTH, _HEIGHT = 300, 78
_TICK_INTERVAL = 0.25
_MAX_PARENT_RETRIES = 80
_PERIOD_NAMES = {
constants.ARENA_PERIOD.IDLE: 'Idle',
constants.ARENA_PERIOD.WAITING: 'Waiting',
constants.ARENA_PERIOD.PREBATTLE: 'Prebattle',
constants.ARENA_PERIOD.BATTLE: 'Battle',
constants.ARENA_PERIOD.AFTERBATTLE: 'After battle',
}
class _PhaseModel(ViewModel):
"""Expose one JSON string plus a frontend-ready command to Gameface."""
def __init__(self, payload):
self._initial_payload = payload
super(_PhaseModel, self).__init__(properties=1, commands=1)
def _initialize(self):
super(_PhaseModel, self)._initialize()
self._addStringProperty('payload', self._initial_payload)
self.onReady = self._addCommand('onReady')
def setPayload(self, value):
self._setString(0, value)
class _PhaseView(ViewImpl):
"""Bind the model command to the battle-scoped owner."""
def __init__(self, owner):
self._owner = owner
model = _PhaseModel(owner.payload)
owner.attach_model(model)
settings = ViewSettings(layoutID=_LAYOUT(), flags=ViewFlags.VIEW,
model=model)
super(_PhaseView, self).__init__(settings)
def _getEvents(self):
return ((self.getViewModel().onReady,
self._owner.on_content_ready),)
def _finalize(self):
self._owner.attach_model(None)
super(_PhaseView, self)._finalize()
class _PhaseWindow(WindowImpl):
"""Notify the owner when Wulf has loaded the native window surface."""
def __init__(self, content, parent, owner):
self._owner = owner
super(_PhaseWindow, self).__init__(
WindowFlags.WINDOW, content=content, layer=WindowLayer.WINDOW,
name='BattlePhaseMonitor', parent=parent)
def _onReady(self):
self._owner.on_window_ready()
class _BattlePhaseMonitor(object):
"""Coordinate arena data, recurring ticks, and all UI readiness gates."""
def __init__(self):
self._installed = False
self._page_ready = False
self._generation = 0
self._window = None
self._model = None
self._window_ready = False
self._content_ready = False
self._parent_size = None
self._parent_callback_id = None
self._tick_callback_id = None
self._period = constants.ARENA_PERIOD.IDLE
self._period_end_time = 0.0
self._arena_id = 0
self._last_seconds = None
self._last_payload = None
self._avatar_enter_handler = self._on_avatar_enter
self._avatar_exit_handler = self._on_avatar_exit
self._period_handler = self._on_period_change
self._disconnect_handler = self._on_disconnect
@property
def payload(self):
seconds = self._remaining_seconds()
return json.dumps({
'phase': _PERIOD_NAMES.get(self._period,
'Unknown (%s)' % self._period),
'seconds': seconds,
'arenaId': unicode(self._arena_id) if self._arena_id else '-',
}, separators=(',', ':'))
def start(self):
if self._installed:
return
try:
layout_id = _LAYOUT()
except Exception:
_LOGGER.exception('%s: native layout lookup failed', _MOD_NAME)
return
if layout_id < 0:
_LOGGER.error('%s: native layout declaration is missing', _MOD_NAME)
return
g_playerEvents.onAvatarBecomePlayer += self._avatar_enter_handler
g_playerEvents.onAvatarBecomeNonPlayer += self._avatar_exit_handler
g_playerEvents.onArenaPeriodChange += self._period_handler
g_playerEvents.onDisconnected += self._disconnect_handler
self._installed = True
_LOGGER.info('%s %s: initialized', _MOD_NAME, __version__)
def stop(self):
if not self._installed:
self.deactivate()
return
g_playerEvents.onAvatarBecomePlayer -= self._avatar_enter_handler
g_playerEvents.onAvatarBecomeNonPlayer -= self._avatar_exit_handler
g_playerEvents.onArenaPeriodChange -= self._period_handler
g_playerEvents.onDisconnected -= self._disconnect_handler
self._installed = False
self.deactivate()
_LOGGER.info('%s %s: finalized', _MOD_NAME, __version__)
def _on_avatar_enter(self):
self.deactivate()
player = BigWorld.player()
if not isinstance(player, PlayerAvatar):
return
self._arena_id = getattr(player, 'arenaUniqueID', 0)
arena = getattr(player, 'arena', None)
if arena is not None:
self._period = getattr(arena, 'period',
constants.ARENA_PERIOD.IDLE)
self._period_end_time = getattr(arena, 'periodEndTime', 0.0)
def _on_avatar_exit(self):
self.deactivate()
def _on_disconnect(self):
self.deactivate()
def _on_period_change(self, period, end_time, _length, _additional):
self._period = period
self._period_end_time = end_time
self._last_seconds = None
self.publish()
def on_page_ready(self):
"""Begin UI creation only after the shared battle page is populated."""
self._page_ready = True
if isinstance(BigWorld.player(), PlayerAvatar):
self.ensure_window()
self._schedule_tick()
def on_page_disposed(self):
self.deactivate()
def ensure_window(self):
if not self._page_ready or self._window is not None:
return
self._generation += 1
generation = self._generation
self._load_when_parent_ready(generation, 0)
def _load_when_parent_ready(self, generation, retry):
"""Use bounded retries and reject work from an obsolete battle."""
self._parent_callback_id = None
if (generation != self._generation or not self._page_ready or
self._window is not None or
not isinstance(BigWorld.player(), PlayerAvatar)):
return
parent = dependency.instance(
IGuiLoader).windowsManager.getMainWindow()
if (parent is None or parent.proxy is None or
parent.windowStatus != WindowStatus.LOADED):
if retry < _MAX_PARENT_RETRIES:
self._parent_callback_id = BigWorld.callback(
0.1, lambda: self._load_when_parent_ready(
generation, retry + 1))
else:
_LOGGER.error('%s: main-window readiness timed out',
_MOD_NAME)
return
self._window = _PhaseWindow(_PhaseView(self), parent, self)
self._parent_size = tuple(parent.size)
self._window.load()
def attach_model(self, model):
self._model = model
if model is not None:
self.publish()
def on_window_ready(self):
self._window_ready = True
self._show_if_ready()
def on_content_ready(self):
self._content_ready = True
self._show_if_ready()
def _show_if_ready(self):
if (self._window is None or not self._window_ready or
not self._content_ready or not self._page_ready):
return
width, _height = self._window.size
parent_width, _parent_height = self._parent_size
self._window.move(max(0, (parent_width - width) // 2), 72)
if self._window.isHidden():
self._window.show(False)
def _remaining_seconds(self):
if self._period_end_time <= 0:
return None
return max(0, int(self._period_end_time - BigWorld.serverTime()))
def _schedule_tick(self):
if self._tick_callback_id is None and self._page_ready:
self._tick_callback_id = BigWorld.callback(
_TICK_INTERVAL, self._tick)
def _tick(self):
"""Refresh derived countdown state and retain one owned callback."""
self._tick_callback_id = None
if not self._page_ready:
return
seconds = self._remaining_seconds()
if seconds != self._last_seconds:
self._last_seconds = seconds
self.publish()
self._schedule_tick()
def publish(self):
"""Cross the Wulf boundary only when serialized display state changes."""
payload = self.payload
if payload == self._last_payload:
return
self._last_payload = payload
if self._model is not None:
with self._model.transaction() as model:
model.setPayload(payload)
def deactivate(self):
"""Invalidate delayed work before cancelling and destroying resources."""
self._page_ready = False
self._generation += 1
if self._parent_callback_id is not None:
BigWorld.cancelCallback(self._parent_callback_id)
self._parent_callback_id = None
if self._tick_callback_id is not None:
BigWorld.cancelCallback(self._tick_callback_id)
self._tick_callback_id = None
if self._window is not None:
self._window.destroy()
self._window = None
self._model = None
self._window_ready = False
self._content_ready = False
self._parent_size = None
self._period = constants.ARENA_PERIOD.IDLE
self._period_end_time = 0.0
self._arena_id = 0
self._last_seconds = None
self._last_payload = None
_MONITOR = _BattlePhaseMonitor()
_INSTALLED = False
_ORIGINAL_POPULATE = None
_ORIGINAL_DISPOSE = None
def _page_populate(self, *args, **kwargs):
"""Call the previous populate first, then expose page readiness."""
result = _ORIGINAL_POPULATE(self, *args, **kwargs)
try:
_MONITOR.on_page_ready()
except Exception:
_LOGGER.exception('%s: page-populate handling failed', _MOD_NAME)
return result
def _page_dispose(self, *args, **kwargs):
"""Release mod state before the native page tears its owners down."""
try:
_MONITOR.on_page_disposed()
except Exception:
_LOGGER.exception('%s: page-dispose handling failed', _MOD_NAME)
return _ORIGINAL_DISPOSE(self, *args, **kwargs)
def init():
global _INSTALLED, _ORIGINAL_POPULATE, _ORIGINAL_DISPOSE
if _INSTALLED:
return
try:
_ORIGINAL_POPULATE = SharedPage._populate
_ORIGINAL_DISPOSE = SharedPage._dispose
SharedPage._populate = _page_populate
SharedPage._dispose = _page_dispose
_MONITOR.start()
_INSTALLED = True
except Exception:
_LOGGER.exception('%s: initialization failed', _MOD_NAME)
fini()
def fini():
global _INSTALLED
try:
_MONITOR.stop()
except Exception:
_LOGGER.exception('%s: finalization failed', _MOD_NAME)
if SharedPage._populate is _page_populate:
SharedPage._populate = _ORIGINAL_POPULATE
if SharedPage._dispose is _page_dispose:
SharedPage._dispose = _ORIGINAL_DISPOSE
_INSTALLED = False
[
{
"id": "R.mods.tutorial.BattlePhaseMonitor.views.BattlePhaseMonitor",
"type": "Layout",
"path": "coui://gui/gameface/mods/tutorial/BattlePhaseMonitor/BattlePhaseMonitor.html",
"parameters": {"entrance": "BattlePhaseMonitor", "impl": "gameface"}
}
]
Native declaration field guide: id must exactly match R.mods.tutorial.BattlePhaseMonitor.views.BattlePhaseMonitor in Python. The path resolves the packaged HTML, and parameters selects its entrance and implementation. Mount this strict JSON list at res/gui/gen_resources/tutorial_battle_phase_monitor.json; do not add legacy itemID or extension fields.
Registration and package boundaries
The client re-exports the resource root, obtains it from the GUI application, and delegates root lookup to a proxy. These Python wrappers do not expose the native JSON loader. The OpenWG 1.2.2 WG contract documents mounted registration and unique declaration filenames/IDs; this standalone example does not import that helper.
Lookup stages: the module evaluates its dotted _LAYOUT attribute during import, before init(). A missing attribute can fail there before the owner's logging handlers run. If the accessor exists, start() catches exceptions from calling it, while a negative ID takes its missing-declaration branch. Later view creation calls it again. Check import, startup and creation diagnostics separately; matching JSON and Python names does not prove mounted availability.
<!DOCTYPE html>
<html><head><meta charset="utf-8">
<link rel="stylesheet" href="BattlePhaseMonitor.css"></head>
<body><section id="panel">
<!-- Static fallbacks keep the panel understandable before model readiness. -->
<div class="title">BATTLE PHASE</div>
<div class="row"><strong id="phase">Waiting</strong><span id="seconds">—</span></div>
<div class="arena">Arena <span id="arena">—</span></div>
<!-- Load behavior after the DOM nodes it owns have been declared. -->
</section><script src="BattlePhaseMonitor.js"></script></body></html>
/* These dimensions must match Python and the JavaScript resize contract. */
html, body { width: 300px; height: 78px; margin: 0; overflow: hidden; background: transparent; }
* { box-sizing: border-box; }
/* The panel paints its own card; the native Gameface surface stays transparent. */
#panel { width: 300px; height: 78px; padding: 8px 12px; color: #f4f6f8; background: rgba(10,14,19,.78); border: 1px solid rgba(173,202,225,.55); border-radius: 4px; font: 14px/18px Arial, sans-serif; text-shadow: 0 1px 2px #000; }
.title { color: #94c9ef; font-size: 11px; letter-spacing: 1.5px; }
.row { display: flex; justify-content: space-between; align-items: baseline; white-space: nowrap; }
#phase { font-size: 21px; line-height: 24px; }
#seconds { color: #ffd77a; font-size: 19px; }
.arena { overflow: hidden; color: #aeb8c2; font-size: 11px; white-space: nowrap; text-overflow: ellipsis; }
Gameface exposes Wulf scalar properties through wrapper objects in this client generation. The frontend unwraps value before parsing JSON and explicitly registers a root-model data-change callback; otherwise the panel can remain on its HTML fallback while Python publishes fresh payloads.
(function () {
'use strict';
var BASE_WIDTH = 300;
var BASE_HEIGHT = 78;
var phaseNode = document.getElementById('phase');
var secondsNode = document.getElementById('seconds');
var arenaNode = document.getElementById('arena');
var panel = document.getElementById('panel');
var domBuiltHandler = null;
var dataChangedCallbackId = null;
var initialized = false;
/* Wulf scalar properties can arrive as { value: ... } wrappers. */
function unwrap(value) {
if (value && typeof value === 'object' && value.value !== undefined) {
return value.value;
}
return value;
}
/* Render only copied JSON primitives; never retain a Python-side object. */
function render() {
var payload = {};
try { payload = JSON.parse(String(unwrap(window.model.payload) || '{}')); }
catch (error) { payload = {}; }
phaseNode.textContent = payload.phase || 'Unknown';
secondsNode.textContent = payload.seconds === null || payload.seconds === undefined
? '—' : String(payload.seconds) + ' s';
arenaNode.textContent = payload.arenaId || '—';
}
/* Resize the native surface as well as scaling the DOM content. */
function applyScale() {
var scale = Number(viewEnv.getScale());
if (!isFinite(scale) || scale <= 0) scale = 1;
var width = Math.max(1, Math.round(BASE_WIDTH * scale));
var height = Math.max(1, Math.round(BASE_HEIGHT * scale));
panel.style.transform = 'scale(' + scale + ')';
panel.style.transformOrigin = '0 0';
document.documentElement.style.width = width + 'px';
document.documentElement.style.height = height + 'px';
document.body.style.width = width + 'px';
document.body.style.height = height + 'px';
viewEnv.resizeViewPx(width, height);
if (window.model && typeof window.model.onReady === 'function') {
window.model.onReady();
}
}
/* Idempotence prevents duplicate listeners if readiness converges twice. */
function initialize() {
if (initialized) return;
initialized = true;
render();
engine.on('viewEnv.onDataChanged', render);
dataChangedCallbackId = viewEnv.addDataChangedCallback('model', 0, true);
engine.on('self.onScaleUpdated', applyScale);
applyScale();
}
/* Listener identity and callback IDs must match their registrations. */
function cleanup() {
if (initialized) {
engine.off('viewEnv.onDataChanged', render);
engine.off('self.onScaleUpdated', applyScale);
if (dataChangedCallbackId !== null) {
viewEnv.removeDataChangedCallback(dataChangedCallbackId, 0);
dataChangedCallbackId = null;
}
initialized = false;
}
if (domBuiltHandler) {
engine.off('self.onDomBuilt', domBuiltHandler);
domBuiltHandler = null;
}
}
/* Two frames let DOM layout settle before the native surface is measured. */
function afterLayout() {
requestAnimationFrame(function () { requestAnimationFrame(initialize); });
}
/* Engine readiness and DOM readiness are independent gates. */
var domBuilt = window.isDomBuilt ? Promise.resolve() :
new Promise(function (resolve) {
domBuiltHandler = function () {
engine.off('self.onDomBuilt', domBuiltHandler);
domBuiltHandler = null;
resolve();
};
engine.on('self.onDomBuilt', domBuiltHandler);
});
window.addEventListener('unload', cleanup);
Promise.all([engine.whenReady, domBuilt]).then(afterLayout);
}());
<root>
<id>tutorial.battle_phase_monitor</id>
<version>1.0.1</version>
<name>Battle Phase Monitor</name>
<description>Displays the current arena phase and countdown in Gameface.</description>
</root>
Minimal project structure
battle-phase-monitor/
├── mod_battle_phase_monitor.py
└── stage/
├── meta.xml
└── res/
├── scripts/client/gui/mods/mod_battle_phase_monitor.pyc
├── gui/gen_resources/tutorial_battle_phase_monitor.json
└── gui/gameface/mods/tutorial/BattlePhaseMonitor/
├── BattlePhaseMonitor.html
├── BattlePhaseMonitor.css
└── BattlePhaseMonitor.jsThese standalone layouts use the client-owned resource tree directly. OpenWG injection is needed only when augmenting an existing native document through an owned child model.
Packaging and installation
- Target WG 2.4.1.0 and validate the native declaration before packaging.
- Compile the Python file with the target-compatible Python 2.7 compiler.
- Stage bytecode, the JSON declaration and the three frontend files at the exact paths shown; archive
stage/contents without astage/prefix. - Place aligned
meta.xmlat archive root. - Use 7-Zip ZIP store mode (
7z a -tzip -mx=0 ...) to createbattle_phase_monitor_1.0.1.wotmod; verify every member is stored. - Inspect and extract-test the archive, then install under
mods/x.x.x.x/, replacingx.x.x.xwith the actual target-client version. - Restart after replacing mounted resources; native registration does not merge a helper map.
Window-layer scope: WindowLayer.WINDOW keeps native menus and queued popups above these passive panels. The minimal example does not add TAB/full-statistics hiding; that surface belongs to the battle page below the panel. Verify this limitation in the target client and follow window-layer and keyboard-routing guidance when adding visibility controls.
Testing and debugging
Static/source validation
- Parse Python with Python 2.7 grammar.
- Parse JSON and confirm the dotted
idmatches_LAYOUT. - Confirm HTML IDs used by JavaScript exist once.
- Confirm
300 × 78in Python, CSS, and JavaScript. - Confirm every subscription, wrapper, callback, window, model, and listener has a cleanup owner.
Runtime test matrix
These are client-test targets, not results established by the static checklist. Test the documented failure boundaries as well as successful startup and teardown.
| Scenario | Target check and source limit |
|---|---|
| First battle after client restart | One centered panel; normal movement and keys work immediately |
| Waiting → prebattle → battle | Check received phase updates and sampled whole seconds; the callback interval does not guarantee an exact once-per-second schedule |
| Resolution/UI-scale change | Check accepted surface size, clipping and placement; this owner reuses the parent-size snapshot taken before load |
| Battle exit | Verify successful cancellation/destruction before page disposal and no late recreation; a cleanup exception can leave partial releases |
| Second battle | Fresh arena ID and one fresh window |
| Missing native declaration/resource | Inspect import, startup and later creation separately; a missing dotted attribute can abort import, while a startup lookup failure can leave hooks installed without owner subscriptions |
| Coexistence | Verify previous-method call counts, unrelated overlays and teardown with both direct and later nested wrappers |
Failure modes and edge cases
- Invalid layout ID: the native declaration or mounted path is missing or invalid; do not construct a view with it.
- Loaded page but loading parent: the bounded callback re-resolves the main window; successful teardown cancels its stored handle, and obsolete generations reject creation.
- Unsized Gameface surface: Wulf may fall back to a version-sensitive square size; verify readiness and
resizeViewPx. - Window steals focus: ensure
show(False)occurs from native/window readiness after content readiness. - Stale callback after battle: the parent retry checks generation and page readiness; the tick checks page readiness, while ready commands use separate flags. These are different guards.
- Another mod wraps after this one: finalization cannot safely splice this wrapper out of an unknown chain; restart is the reliable cleanup boundary.
- Mounted resources changed: restart the client and verify the native declaration and packaged path before diagnosing HTML.
Performance considerations
Each scheduled tick computes remaining seconds and compares the display cache. A changed second also builds JSON and may transact; period events and model attachment are additional publication paths. Successful ticks request the next callback at 0.25 seconds, which does not establish exact scheduling or measured cost. Gameface reacts to model data events rather than a repeating data poll. The panel has three text values and no continuous animation, entity scan, network request, or fullscreen surface. No performance benchmark was performed.
Compatibility and version sensitivity
| Dependency | Classification | Recheck |
|---|---|---|
gui.impl.gen.R | Native WG 2.4.1.0 resource tree | Strict JSON, unique declaration filename, dotted accessor parity |
| Wulf view/window classes | Private client UI | Imports, flags, layer, readiness, parent contract |
SharedPage | Private Scaleform page | Import and populate/dispose order |
| Arena-period event | Internal SafeEvent | Signature and firing sites |
| Gameface bridge | Embedded runtime | Engine events, model exposure, sizing calls |
| Package paths | Client VFS contract | Native declaration and coui:// resolution |
Why this architecture?
- Why not create on avatar entry? The loaded lobby parent may still be current; the battle page owns the correct boundary.
- Why not fullscreen with pointer-events disabled? Wulf input ownership exists outside DOM CSS.
- Why not let JavaScript read game state? It has no authoritative battle-object contract and would become a second state owner.
- Why JSON? A tiny versionable payload keeps the bridge explicit without generated model files.
- Why a callback for countdown? No event fires every displayed second, and the callback has explicit battle-page ownership.
- Why use the native tree? The client owns registration before mod startup; this standalone layout needs no merge manager or injected child carrier.
Possible extensions
- Add validated position persistence and Python-owned dragging.
- Add localized phase labels while keeping stable payload keys.
- Expose a frontend-to-Python toggle command with bounded input semantics.
- Carry the Gameface shell into Level 5 and replace phase data with entity-presence state.
Maintenance checklist
- Recheck Wulf/GUI module paths,
SharedPagehooks, arena-event signature, and service registration. - Verify Wulf parent status, layer, flags, and
show(False). - Verify view alias, native declaration path, bridge event names, and client restart behavior.
- Match Python/model/frontend payload properties and dimensions.
- Exercise stale callbacks and repeated battles.
- Compile with target Python 2.7 and align manifest version.
- Inspect archive paths and runtime VFS logs.
What you learned
A Gameface overlay is not “an HTML file on top of the game.” It is one coordinated lifecycle spanning battle context, a private native page, a Wulf parent and window, a Python model, an embedded browser, a DOM, and a sized native surface. Production safety comes from assigning each boundary one owner and invalidating every delayed crossing.