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 Vehicle objects.
  • No global BigWorld.entities polling 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

  1. Activate only for the current PlayerAvatar and populated battle page.
  2. Classify loaded Vehicle entities relative to avatar.team.
  3. Seed already-started vehicles once after subscription.
  4. Add and remove identities on avatar vehicle-world events.
  5. Publish only when the census changes.
  6. Reset counts and transition history between battles.

Truth limitations

  • The initial seed includes started Vehicle entities; event-driven additions do not recheck isStarted.
  • 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.team classifies 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 Event instances.
  • 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 UI

The 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

  • PlayerAvatar creates ordinary Event.Event instances 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.team or its mapping equivalent.

Source lookup

These references help distinguish instantiated client entities from roster, spotting, and result data:

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

  1. Import/init: define one inert owner, install global context listeners, and wrap the page once.
  2. Avatar entry: invalidate any previous context; do not subscribe until the battle page exists.
  3. Page population: retain the exact current avatar, subscribe both events, then seed currently started vehicles.
  4. Enter/leave: copy identity/classification, update the dictionary, release the event argument, and publish counts.
  5. Page disposal: unsubscribe from the retained avatar before the native page and avatar teardown continue.
  6. Fallback exits: avatar non-player and disconnect perform the same idempotent cleanup.
  7. Finalization: remove global listeners and wrappers and destroy Gameface state.

State and data-authority model

StateScopeAuthorityInvalidation
Retained avatarBattle pageCurrent PlayerAvatarPage/avatar exit
entityID → sideBattleObserved enter/leave plus seedLeave/battle exit
Enter/leave totalsBattle observationHandled event transitionsBattle exit
Roster/alive/spotted stateNot ownedOther client systemsNever inferred
Window/model/DOMBattle page/viewMod-owned presentationPage 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.js

These 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

  1. Target WG 2.4.1.0 and validate the native declaration before packaging.
  2. Compile the Python file with the target-compatible Python 2.7 compiler.
  3. Stage bytecode, native declaration, and frontend files at the exact VFS paths.
  4. Stage the aligned manifest at archive root.
  5. Create a stored-member battle_entity_census_1.0.1.wotmod ZIP.
  6. Inspect and extract-test the archive before installation.
  7. 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

ScenarioExpected
Normal random battleCounts reflect currently loaded vehicle entities and change with client presence
Long-range/AOI changesCounts may fall and rise; qualification remains visible
Respawn modeSame entity may produce leave/enter; current membership remains deduplicated
Observer/replayRecord actual behavior; do not assume player-mode exposure
Battle exit during enter/leave activityNo retained vehicle/avatar and no lobby recreation
Second battleTransition totals restart from zero
Another event listener failsUnderstand 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

DependencyClassificationRecheck
Avatar vehicle-world eventsPrivate plain EventsCreation, firing, clear order, signature
Vehicle.isStartedEntity readiness fieldMeaning and timing
publicInfo.teamReplicated entity propertySchema/access form/mode availability
BigWorld.entitiesEngine entity collectionScope, collection behavior
RespawnMode/client behaviorSame-object leave/enter paths
Gameface/Wulf shellPrivate mixed runtimeAll 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.entities every 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/Vehicle imports, vehicle-world event names/signatures, and clear order.
  • Trace Vehicle.startVisual, leave, destruction, AoI, and respawn paths.
  • Verify entity and publicInfo.team attributes 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.