The battle overlay projects allied, enemy, and unknown membership from a started-only seed plus observed avatar enter/leave events. These paths have different readiness filters; the retained map is not a continuously reconciled count of visually started vehicles. It intentionally does not claim to show living vehicles, spotted vehicles, or the authoritative roster.
Inspect Avatar.py, Vehicle.py. Follow current event producers, callers and cleanup paths before adapting this example.
This census measures local entity observations. It is not a count of spotted or rendered vehicles; see entity, visual, and spotting boundaries.
What we’re building
A passive Gameface panel that activates after the shared battle page populates, subscribes to the current avatar’s plain onVehicleEnterWorld and onVehicleLeaveWorld events, seeds already-started vehicles once, stores only entity IDs and side labels, and cleans up before the avatar releases those events.
Final result
- An event-updated membership count with allied, enemy, and unknown labels copied on insertion.
- Per-battle enter and leave observations.
- No retained
Vehicleobjects. - No global
BigWorld.entitiespolling loop. - A complete Python/Gameface/native resource package using the Level 4 UI boundary.
What you’ll learn
- Why entity existence, in-world presence, visual start, appearance readiness, roster membership, spotting, and life state are separate facts.
- Why an avatar-owned event should be subscribed through the exact avatar instance and removed before that owner clears it.
- How to seed state without turning a one-time reconciliation into permanent polling.
- How respawn and area-of-interest transitions affect identity and counters.
- Why copied IDs and classifications are safer than retained entity references.
Difficulty and prerequisites
Level 5 — Entities. Complete the Level 4 Battle Phase Monitor. You should understand plain versus safe events, battle-page ownership, Wulf/Gameface sizing, and deterministic cleanup.
Requirements and constraints
Functional requirements
- Activate only for the current
PlayerAvatarand populated battle page. - Classify loaded
Vehicleentities relative toavatar.team. - Seed already-started vehicles once after subscription.
- Add and remove identities on avatar vehicle-world events.
- Publish only when the census changes.
- Reset counts and transition history between battles.
Truth limitations
- The initial seed includes started
Vehicleentities; event-driven additions do not recheckisStarted. - An absent entity may be outside area of interest, hidden by mode behavior, respawning, or leaving the battle.
- Entity presence is not authoritative proof of alive/dead, spotted/unspotted, or team-roster membership.
publicInfo.teamclassifies a present entity; missing data remains unknown rather than guessed.
Non-functional requirements
- Python 2.7 and optional Error Reporter compatibility.
- Expected constant-time dictionary membership updates, followed by a recount of tracked entries when building a payload; no per-frame entity scan.
- Exception containment because both avatar vehicle events are plain
Eventinstances. - Exact handler identity, symmetric cleanup, and no cross-battle references.
Acceptance criteria
- One page activation creates one avatar subscription pair.
- Seeding after subscription cannot miss a transition between scan and registration.
- A duplicate enter for an already-present ID does not inflate current counts or transition totals.
- Leave removes only an ID the mod currently owns.
- Same-ID leave/enter during respawn produces one observed leave and one enter.
- A missing team field increments unknown rather than raising.
- A handler exception is logged and does not abort later listeners on the plain event.
- Battle exit leaves no avatar, vehicle, model, window, or callback reference.
- A second battle starts with zero transition totals and a fresh seed.
Architecture
PlayerAvatar.onVehicleEnterWorld(vehicle) ─┐
PlayerAvatar.onVehicleLeaveWorld(vehicle) ─┼─→ copied ID + team class
one-time BigWorld.entities reconciliation ─┘ ↓
battle-scoped dict[id] = side
↓ counts
JSON presentation payload
↓
Wulf model → Gameface panel
SharedPage._dispose → unsubscribe exact avatar → clear dict → destroy UIThe dictionary represents mod-observed current presence. It does not replace avatar.arena.vehicles as roster authority. The UI renders this qualified claim verbatim.
Researching the implementation
Investigation chain
Requirement: count instantiated vehicle entities
↓ Owner? current PlayerAvatar and its area-of-interest entity set
↓ Signal? avatar.onVehicleEnterWorld / onVehicleLeaveWorld
↓ Readiness? enter event fires after vehicle.startVisual()
↓ Existing state? seed BigWorld.entities only once, after subscribing
↓ Identity? BigWorld entity ID for current-presence membership
↓ Classification? current entity publicInfo.team vs avatar.team
↓ Authority? observed client presence, not roster/alive/spotting truth
↓ Cleanup? page disposal before avatar clears the Event objects
↓ Update audit? event firing sites, respawn path, publicInfo schema
What the source proves
PlayerAvatarcreates ordinaryEvent.Eventinstances for vehicle enter and leave.- The enter event fires after a call to
vehicle.startVisual(), which can return before visuals start. - The started leave path fires before
vehicle.stopVisual(); the ordinary unstarted path returns without that event. - Avatar teardown walks started vehicles through the leave event, later fires
onAvatarBecomeNonPlayer, then clears and nulls both events. - Respawn can call leave and enter on the same Python entity, so leave is not proof of final destruction.
- Current client call sites classify teams through
vehicle.publicInfo.teamor its mapping equivalent.
Source lookup
These references help distinguish instantiated client entities from roster, spotting, and result data:
- Participant and entity layers — which battle representations answer which questions.
- BigWorld entity access and entity ownership rule — lookup, readiness, and retention limits.
- Event dispatch contracts — the plain-event failure behavior that shapes handler containment.
- Respawn and spectator modes — why leave/enter transitions are not destruction or spotting facts.
- Gameface payload design — how copied counters cross the Python/frontend boundary.
Search current source for onVehicleEnterWorld, onVehicleLeaveWorld, startVisual, stopVisual, BigWorld.entities, and publicInfo.team. Treat them as investigation entry points and inspect current event producers, entity attributes, teardown, and respawn callers.
Lifecycle and ownership
- Import/init: define one inert owner, install global context listeners, and wrap the page once.
- Avatar entry: invalidate any previous context; do not subscribe until the battle page exists.
- Page population: retain the exact current avatar, subscribe both events, then seed currently started vehicles.
- Enter/leave: copy identity/classification, update the dictionary, release the event argument, and publish counts.
- Page disposal: unsubscribe from the retained avatar before the native page and avatar teardown continue.
- Fallback exits: avatar non-player and disconnect perform the same idempotent cleanup.
- Finalization: remove global listeners and wrappers and destroy Gameface state.
State and data-authority model
| State | Scope | Authority | Invalidation |
|---|---|---|---|
| Retained avatar | Battle page | Current PlayerAvatar | Page/avatar exit |
entityID → side | Battle | Observed enter/leave plus seed | Leave/battle exit |
| Enter/leave totals | Battle observation | Handled event transitions | Battle exit |
| Roster/alive/spotted state | Not owned | Other client systems | Never inferred |
| Window/model/DOM | Battle page/view | Mod-owned presentation | Page disposal |
Implementation walkthrough
Subscribe before seed
Scanning first creates a race: an entity can enter after the scan but before registration. Register first, then seed. Duplicate suppression makes an enter delivered during the seed harmless.
Plain Event boundary
Current Event.__call__ logs and re-raises listener exceptions, stopping later listeners. Each saved handler therefore catches only this mod’s reaction and records a traceback. Cleanup remains outside those handlers.
Identity without retention
The owner stores integer entity IDs and short side strings, not Vehicle objects, matrices, appearances, or public-info proxies. Every event argument is released after the synchronous update.
Seed and event membership limits
The avatar producer sends enter after calling startVisual(). The vehicle method can return early when hidden, waiting for appearance reload or lacking a constructed appearance. This handler still remembers a new event ID; its seed would require isStarted. The ordinary unstarted leave path emits no matching leave event, so such an entry can remain until deactivation if no later recognized leave arrives. This is a source-visible cache limit, not an observed failure or a guarantee of exact current membership.
Classification is copied once per retained ID. Duplicate enters neither recount transitions nor refresh a previously unknown or changed team. Only known-ID leaves increment left; removal followed by reentry increments entered again. Neither counter is a battle-wide spawn/destruction total. The plain Event contract makes traceback-bearing handler diagnostics useful, but a caught publication failure does not roll back an earlier membership mutation.
Model, DOM and surface contract
Python projects six JSON integers through one string property, payload: total, ally, enemy, unknown, entered and left. The handwritten model explicitly implements setPayload at property index zero; registration does not generate it. The current ViewModel wrapper establishes that storage and transaction boundary. The view pairs its onReady command with the owner's content-ready handler through ViewImpl event ownership. This presentation contract adds no roster, spotting or life-state authority.
The constructor supplies initial JSON. attach_model() also calls publish() before the base view constructor; an equal cached payload may suppress it, but an unequal value can enter a transaction before frontend readiness. Publication stores _last_payload before the transaction, so a failed update can leave the Python cache ahead of delivery and an equal later update can be skipped. The transaction does not restore that cache or earlier membership/counter mutations. Compare the staged publication pattern when adapting this source-visible limitation; it has not been reproduced as a client failure here.
The HTML declares six matching field IDs before loading the sibling script. The renderer unwraps .value, parses JSON and assigns textContent; missing/falsy values and parse failures display zero. Zero therefore does not prove an empty valid census, and successfully parsed null is outside the parse catch. This is a renderer for the intended Python object schema, not arbitrary JSON validation.
Python, CSS and JavaScript share the 280 by 104 base dimensions. After engine and DOM readiness and two animation frames, the script requests a scaled pixel surface, then calls onReady; later scale notifications can call it again. Python separately gates showing on page, window and content readiness and positions from its captured parent width. Reaching the command is not a readback confirming native size acceptance. See raw Gameface sizing.
Unload removes the exact engine listeners and saved root-model callback with view ID zero, plus any unresolved DOM-built listener. The authored script does not cancel its two queued animation frames, guard its pending Promise continuation with a disposed flag, or remove its unload listener. Its initialization flag prevents ordinary duplicate setup, but is set before render/registration and is not a complete lifetime guard. These are inspected cleanup limits; no frontend, native API or game-client execution was performed for this review.
Owner, retry and cleanup limits
The populated SharedPage activates the census; avatar entry, non-player and disconnect handlers deactivate it. The same bound avatar skips a second seed, but page-ready handling does not first detach a different previous avatar. The ordinary page/global reset path is part of this ownership contract. Avatar availability alone does not recreate an already-populated page or establish replay recovery.
The parent loader checks a generation, page readiness, retained avatar and absence of a window before requiring a current loaded main-window proxy. Each attempt allows up to 80 delayed retries at a requested 0.1 seconds after the immediate check; scheduling is not an eight-second wall-clock guarantee. Repeated page-ready calls while waiting can create newer attempts without cancelling an older retry. Only one callback ID is retained, and the loader clears it before rejecting stale generations, so an old callback can clear a newer tracked handle. Generation rejection prevents stale construction on that path; it does not prove every pending callback was cancelled.
Normal deactivation invalidates the page/generation, unsubscribes through the retained avatar, releases it, cancels the tracked retry, destroys the window and resets presentation plus membership/counters. Both vehicle-event removals share one try, so a first failure skips the second. Cancellation or destruction can interrupt later resets. The current Window wrapper delegates destruction to the native proxy; that call alone is not proof of completed native release. Window/content readiness callbacks also lack a generation or window identity, and no separate external-window-release recovery is implemented.
The module marks itself installed after start() returns, even for its handled lookup failure or negative layout ID. Partial global subscription failure can precede the owner's installed flag. Page wrappers call their previous methods once in the normal path, and finalization restores only methods still owned by this module; it cannot unlink a wrapper captured by a later mod. These source-visible failure limits qualify the acceptance targets above. No tutorial or game-client runtime was executed for this review.
Complete source code
# -*- coding: utf-8 -*-
"""Display an explicitly qualified census of loaded battle vehicles."""
from __future__ import absolute_import, unicode_literals
# Reader map:
# 1. The Wulf shell projects copied counters into Gameface.
# 2. _BattleEntityCensus owns avatar event subscriptions and membership.
# 3. SharedPage wrappers align both halves with the battle UI lifetime.
import json
import logging
import BigWorld
from Avatar import PlayerAvatar
from PlayerEvents import g_playerEvents
from Vehicle import Vehicle
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_entity_census'
_LAYOUT = R.mods.tutorial.BattleEntityCensus.views.BattleEntityCensus
_WIDTH, _HEIGHT = 280, 104
_MAX_PARENT_RETRIES = 80
class _CensusModel(ViewModel):
"""Expose the serialized census and frontend-ready command."""
def __init__(self, payload):
self._initial_payload = payload
super(_CensusModel, self).__init__(properties=1, commands=1)
def _initialize(self):
super(_CensusModel, self)._initialize()
self._addStringProperty('payload', self._initial_payload)
self.onReady = self._addCommand('onReady')
def setPayload(self, value):
self._setString(0, value)
class _CensusView(ViewImpl):
"""Connect Gameface readiness back to the census owner."""
def __init__(self, owner):
self._owner = owner
model = _CensusModel(owner.payload)
owner.attach_model(model)
super(_CensusView, self).__init__(ViewSettings(
layoutID=_LAYOUT(), flags=ViewFlags.VIEW, model=model))
def _getEvents(self):
return ((self.getViewModel().onReady,
self._owner.on_content_ready),)
def _finalize(self):
self._owner.attach_model(None)
super(_CensusView, self)._finalize()
class _CensusWindow(WindowImpl):
"""Report native Wulf window readiness without owning census state."""
def __init__(self, content, parent, owner):
self._owner = owner
super(_CensusWindow, self).__init__(
WindowFlags.WINDOW, content=content, layer=WindowLayer.WINDOW,
name='BattleEntityCensus', parent=parent)
def _onReady(self):
self._owner.on_window_ready()
class _BattleEntityCensus(object):
"""Track current loaded-entity membership for one battle page."""
def __init__(self):
self._installed = False
self._page_ready = False
self._avatar = None
self._avatar_events_bound = False
self._entities = {}
self._enter_count = 0
self._leave_count = 0
self._generation = 0
self._parent_callback_id = None
self._window = None
self._model = None
self._window_ready = False
self._content_ready = False
self._parent_size = None
self._last_payload = None
self._avatar_enter_handler = self._on_avatar_enter
self._avatar_exit_handler = self._on_avatar_exit
self._disconnect_handler = self._on_disconnect
self._vehicle_enter_handler = self._on_vehicle_enter
self._vehicle_leave_handler = self._on_vehicle_leave
@property
def payload(self):
counts = {'ally': 0, 'enemy': 0, 'unknown': 0}
for side in self._entities.values():
counts[side] += 1
return json.dumps({
'ally': counts['ally'],
'enemy': counts['enemy'],
'unknown': counts['unknown'],
'total': len(self._entities),
'entered': self._enter_count,
'left': self._leave_count,
}, 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.onDisconnected += self._disconnect_handler
self._installed = True
_LOGGER.info('%s %s: initialized', _MOD_NAME, __version__)
def stop(self):
if self._installed:
g_playerEvents.onAvatarBecomePlayer -= self._avatar_enter_handler
g_playerEvents.onAvatarBecomeNonPlayer -= self._avatar_exit_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()
def _on_avatar_exit(self):
self.deactivate()
def _on_disconnect(self):
self.deactivate()
def on_page_ready(self):
"""Subscribe before seeding so an enter event cannot be missed."""
self._page_ready = True
avatar = BigWorld.player()
if not isinstance(avatar, PlayerAvatar):
return
if self._avatar_events_bound and avatar is self._avatar:
self.ensure_window()
return
self._avatar = avatar
try:
avatar.onVehicleEnterWorld += self._vehicle_enter_handler
self._avatar_events_bound = True
avatar.onVehicleLeaveWorld += self._vehicle_leave_handler
except Exception:
_LOGGER.exception('%s: avatar subscription failed', _MOD_NAME)
self._unbind_avatar_events()
return
self._seed_started_vehicles()
self.publish()
self.ensure_window()
def on_page_disposed(self):
self.deactivate()
def _seed_started_vehicles(self):
"""Reconcile already-started vehicles after event registration."""
for entity in BigWorld.entities.values():
if isinstance(entity, Vehicle) and getattr(entity, 'isStarted', False):
self._remember(entity, count_transition=False)
def _on_vehicle_enter(self, vehicle):
try:
if self._remember(vehicle, count_transition=True):
self.publish()
except Exception:
_LOGGER.exception('%s: vehicle-enter handling failed', _MOD_NAME)
def _on_vehicle_leave(self, vehicle):
try:
entity_id = getattr(vehicle, 'id', None)
if entity_id in self._entities:
del self._entities[entity_id]
self._leave_count += 1
self.publish()
except Exception:
_LOGGER.exception('%s: vehicle-leave handling failed', _MOD_NAME)
def _remember(self, vehicle, count_transition):
"""Copy stable identity/classification and release the entity object."""
entity_id = getattr(vehicle, 'id', None)
if entity_id is None or entity_id in self._entities:
return False
self._entities[entity_id] = self._classify(vehicle)
if count_transition:
self._enter_count += 1
return True
def _classify(self, vehicle):
"""Classify only from replicated team data; never infer spotting."""
public_info = getattr(vehicle, 'publicInfo', None)
team = getattr(public_info, 'team', None)
if team is None and public_info is not None:
try:
team = public_info['team']
except (KeyError, TypeError):
team = None
avatar_team = getattr(self._avatar, 'team', None)
if team is None or avatar_team is None:
return 'unknown'
return 'ally' if team == avatar_team else 'enemy'
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):
"""Wait for a loaded parent with a finite, generation-safe retry."""
self._parent_callback_id = None
if (generation != self._generation or not self._page_ready or
self._window is not None or self._avatar is None):
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._parent_size = tuple(parent.size)
self._window = _CensusWindow(_CensusView(self), parent, self)
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 - 24), 120)
if self._window.isHidden():
self._window.show(False)
def publish(self):
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 _unbind_avatar_events(self):
avatar = self._avatar
if self._avatar_events_bound and avatar is not None:
try:
avatar.onVehicleEnterWorld -= self._vehicle_enter_handler
avatar.onVehicleLeaveWorld -= self._vehicle_leave_handler
except Exception:
_LOGGER.exception('%s: avatar unsubscription failed',
_MOD_NAME)
self._avatar_events_bound = False
def deactivate(self):
"""Unsubscribe from the retained avatar before destroying the view."""
self._page_ready = False
self._generation += 1
self._unbind_avatar_events()
self._avatar = None
if self._parent_callback_id is not None:
BigWorld.cancelCallback(self._parent_callback_id)
self._parent_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._entities.clear()
self._enter_count = 0
self._leave_count = 0
self._last_payload = None
_CENSUS = _BattleEntityCensus()
_INSTALLED = False
_ORIGINAL_POPULATE = None
_ORIGINAL_DISPOSE = None
def _page_populate(self, *args, **kwargs):
"""Let native population finish before activating the census."""
result = _ORIGINAL_POPULATE(self, *args, **kwargs)
try:
_CENSUS.on_page_ready()
except Exception:
_LOGGER.exception('%s: page-populate handling failed', _MOD_NAME)
return result
def _page_dispose(self, *args, **kwargs):
"""Deactivate before native disposal clears avatar-owned events."""
try:
_CENSUS.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
_CENSUS.start()
_INSTALLED = True
except Exception:
_LOGGER.exception('%s: initialization failed', _MOD_NAME)
fini()
def fini():
global _INSTALLED
try:
_CENSUS.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.BattleEntityCensus.views.BattleEntityCensus",
"type": "Layout",
"path": "coui://gui/gameface/mods/tutorial/BattleEntityCensus/BattleEntityCensus.html",
"parameters": {"entrance": "BattleEntityCensus", "impl": "gameface"}
}
]
Native declaration field guide: id must exactly match R.mods.tutorial.BattleEntityCensus.views.BattleEntityCensus 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_entity_census.json; do not add legacy itemID or extension fields.
<!DOCTYPE html>
<html><head><meta charset="utf-8">
<link rel="stylesheet" href="BattleEntityCensus.css"></head>
<body><section id="panel">
<!-- IDs below are the stable DOM contract consumed by render(). -->
<div class="title">LOADED VEHICLE ENTITIES <span id="total">0</span></div>
<div class="counts"><span class="ally">Allies <b id="ally">0</b></span><span class="enemy">Enemies <b id="enemy">0</b></span><span>Unknown <b id="unknown">0</b></span></div>
<div class="transitions">Observed enters <b id="entered">0</b> · leaves <b id="left">0</b></div>
<!-- Keep the authority limitation visible beside the derived counters. -->
<div class="note">Client presence — not roster, spotting, or alive state</div>
</section><script src="BattleEntityCensus.js"></script></body></html>
/* Match the Python window and JavaScript native-resize dimensions. */
html, body { width: 280px; height: 104px; margin: 0; overflow: hidden; background: transparent; }
* { box-sizing: border-box; }
/* Only the card paints a background; the surrounding surface is transparent. */
#panel { width: 280px; height: 104px; padding: 9px 11px; color: #d9e0e6; background: rgba(10,14,19,.82); border: 1px solid rgba(173,202,225,.5); border-radius: 4px; font: 13px/19px Arial, sans-serif; text-shadow: 0 1px 2px #000; }
.title { color: #9bc8e8; font-size: 11px; letter-spacing: 1px; }
.title #total { float: right; color: #fff; font-size: 18px; }
.counts { display: flex; justify-content: space-between; white-space: nowrap; }
.ally { color: #8ee09a; } .enemy { color: #ef9292; }
.transitions { color: #d8c38a; }
.note { overflow: hidden; color: #8e99a3; font-size: 10px; white-space: nowrap; }
Gameface exposes Wulf scalar properties through wrapper objects in this client generation. Unwrap value before parsing the JSON string and register the root-model callback explicitly, with matching removal during unload. The model and surface contract distinguishes the normal listener-removal path from queued readiness work that this script does not cancel.
(function () {
'use strict';
var BASE_WIDTH = 280;
var BASE_HEIGHT = 104;
var panel = document.getElementById('panel');
var fields = ['total', 'ally', 'enemy', 'unknown', 'entered', 'left'];
var domBuiltHandler = null;
var dataChangedCallbackId = null;
var initialized = false;
/* Wulf scalar properties can be wrapper objects in this client. */
function unwrap(value) {
if (value && typeof value === 'object' && value.value !== undefined) {
return value.value;
}
return value;
}
/* Project copied counters into the fixed DOM contract. */
function render() {
var payload = {};
try { payload = JSON.parse(String(unwrap(window.model.payload) || '{}')); }
catch (error) { payload = {}; }
for (var index = 0; index < fields.length; index += 1) {
var name = fields[index];
document.getElementById(name).textContent = String(payload[name] || 0);
}
}
/* Keep DOM scaling and the native Gameface surface in agreement. */
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();
}
/* Guard against duplicate subscriptions if readiness repeats. */
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();
}
/* Remove exact listeners and the explicit root-model callback. */
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;
}
}
/* Wait two frames so layout is stable before reporting readiness. */
function afterLayout() {
requestAnimationFrame(function () { requestAnimationFrame(initialize); });
}
/* The engine bridge and the document can become ready independently. */
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_entity_census</id>
<version>1.0.1</version>
<name>Battle Entity Census</name>
<description>Displays currently loaded battle vehicle entities by side.</description>
</root>
Minimal project structure
battle-entity-census/
├── mod_battle_entity_census.py
└── stage/
├── meta.xml
└── res/
├── scripts/client/gui/mods/mod_battle_entity_census.pyc
├── gui/gen_resources/tutorial_battle_entity_census.json
└── gui/gameface/mods/tutorial/BattleEntityCensus/
├── BattleEntityCensus.html
├── BattleEntityCensus.css
└── BattleEntityCensus.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, native declaration, and frontend files at the exact VFS paths.
- Stage the aligned manifest at archive root.
- Create a stored-member
battle_entity_census_1.0.1.wotmodZIP. - Inspect and extract-test the archive before installation.
- Restart the client after replacing mounted resources; there is no helper-generated map restart.
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
Deterministic tests
- Use fake plain events and fake vehicles to test subscription ordering, duplicate enter suppression, leave handling, unknown classification, and repeated cleanup.
- Raise deliberately inside presentation and confirm the handler catches the failure so the next plain-event listener still runs.
- Simulate a late native resource callback after deactivation and assert no window is constructed.
- Compare Python, CSS, and JavaScript dimensions and Python/manifest versions.
Runtime test matrix
| Scenario | Expected |
|---|---|
| Normal random battle | Counts reflect currently loaded vehicle entities and change with client presence |
| Long-range/AOI changes | Counts may fall and rise; qualification remains visible |
| Respawn mode | Same entity may produce leave/enter; current membership remains deduplicated |
| Observer/replay | Record actual behavior; do not assume player-mode exposure |
| Battle exit during enter/leave activity | No retained vehicle/avatar and no lobby recreation |
| Second battle | Transition totals restart from zero |
| Another event listener fails | Understand that its earlier position can still abort this plain-event dispatch |
Failure modes and edge cases
- Seed and event overlap: ID membership makes reconciliation idempotent.
- Missing public info/team: classify unknown; never infer from entity name or ID.
- Leave without owned ID: ignore it; it may predate activation or follow partial initialization.
- Respawn leave: remove presentation membership but do not label it final destruction.
- Avatar events already cleared: the normal page-dispose and avatar-exit order should unsubscribe earlier; defensive cleanup logs an unexpected violation.
- Event handler raises: narrow exception containment protects later listeners.
- Window/resource failure: entity cleanup remains required even if presentation never loads; cancellation or destruction failure can interrupt later resets. Review the owner and cleanup limits.
Performance considerations
The initial global scan runs after each fresh avatar binding; repeated page-ready calls with the same bound avatar do not reseed. Membership changes use expected O(1) dictionary operations. Building each payload then recounts all N tracked side labels, so an accepted enter/leave update with publication costs O(N), followed by six-field JSON serialization. The payload comparison occurs after that recount; suppressing an unchanged model assignment does not avoid aggregation. No matrix, appearance, descriptor, or model object is retained or serialized. Exact selected payload checks with a recording dictionary visited 1, 4 and 100 entries for the corresponding census sizes while retaining the same six output fields. These are authored algorithm checks, not client timing benchmarks or a diagnosed performance bottleneck.
Compatibility and version sensitivity
| Dependency | Classification | Recheck |
|---|---|---|
| Avatar vehicle-world events | Private plain Events | Creation, firing, clear order, signature |
Vehicle.isStarted | Entity readiness field | Meaning and timing |
publicInfo.team | Replicated entity property | Schema/access form/mode availability |
BigWorld.entities | Engine entity collection | Scope, collection behavior |
| Respawn | Mode/client behavior | Same-object leave/enter paths |
| Gameface/Wulf shell | Private mixed runtime | All Level 4 contracts |
Why this architecture?
- Why not read the arena roster? That answers a different question: known participants, not instantiated entities.
- Why not count
BigWorld.entitiesevery frame? Events own changes; one seed closes the startup gap. - Why subscribe before seed? It removes the scan-to-registration loss window.
- Why not retain vehicles? Strong references can cross leave-world and battle teardown.
- Why not call absence “dead”? Area of interest and respawn make that conclusion false.
- Why keep unknown? Honest missing-data representation is safer than invented classification.
Possible extensions
- Add a short ring buffer of observed transitions using copied primitives.
- Correlate entity presence with the arena roster while preserving both distinct sources.
- Advance to Level 6 by sampling positions for present entities and transforming them into a mod-owned spatial model.
- Add replay-specific validation before claiming replay compatibility.
Maintenance checklist
- Recheck
Avatar/Vehicleimports, vehicle-world event names/signatures, and clear order. - Trace
Vehicle.startVisual, leave, destruction, AoI, and respawn paths. - Verify entity and
publicInfo.teamattributes in target modes. - Test seed/event overlap and plain-event exception containment.
- Confirm no game-owned object survives cleanup.
- Revalidate Level 4 view, resource, payload, and bridge contracts.
- Compile with target Python 2.7 and align manifest version.
- Runtime-test multiple battles, AoI changes, and respawn modes.
What you learned
Entity tracking begins by narrowing the claim. The client can tell this mod which vehicle entities entered and left its current world representation; that does not automatically reveal roster truth, visibility rules, or final destruction. Once authority is stated precisely, event ordering, seeding, identity, cleanup, and UI projection become tractable engineering decisions.