The finished mod posts an informational message when the logical selected garage vehicle changes. It subscribes only while an Account owns the garage state and filters unrelated g_currentVehicle.onChanged notifications by inventory ID.

Inspect CurrentVehicle.py, Event.py. Follow current event producers, callers and cleanup paths before adapting this example.

What we’re building

When the mod observes a different owned vehicle selected in the garage, it shows Selected vehicle: [localized short name]. It announces the first valid selection after activation, suppresses non-selection refreshes for the same inventory ID, preserves deduplication through a battle round trip, and resets identity when the account changes or disconnects.

This is a logical inventory selection notifier. It does not claim that the 3D model, appearance, collision, camera, or hangar-space update has completed. Those belong to IHangarSpace and its own lifecycle signals.

Final result

  • One complete Python file plus meta.xml.
  • Four account/avatar lifecycle subscriptions and one account-scoped current-vehicle subscription.
  • No hook, timer, frame loop, persistent file, or custom UI.
  • Inventory-ID deduplication around an event whose name is broader than its selection use case.
  • Traceback isolation at the plain Event handler boundary.

What you’ll learn

  • How to verify an event through its producer sites, not its name.
  • How onChanged can mean selection, repair, lock, rent, rotation, customization, or descriptor change.
  • Why inventory ID and vehicle type compact descriptor are different identifiers.
  • How native and mod ownership overlap during account teardown.
  • Why a plain Event handler needs a defensive exception boundary.
  • How to make subscription and cleanup idempotent.

Difficulty and prerequisites

Level 2 — Events. Complete the Level 1 Garage Greeting first or understand module lifecycle, named logging, packaging, and account readiness.

Requirements and constraints

Functional requirements

  1. Become active only for a current PlayerAccount.
  2. Subscribe to g_currentVehicle.onChanged once per active account context.
  3. Announce the first present vehicle and later observed changes to a different inventory ID.
  4. Ignore refresh notifications for the same inventory ID.
  5. Do not announce an absent selection.
  6. Stop listening before/during battle and on account exit, disconnect, or finalization.
  7. Preserve the last ID through a normal battle round trip so returning to the same tank is quiet.

Non-functional requirements

  • No polling or arbitrary delay.
  • No retained Account or Vehicle object.
  • Exact handler identity for addition/removal.
  • Plain-event exceptions cannot escape this mod’s handler and disrupt later native listeners.
  • Repeated initialization and teardown are safe.
  • Python 2.7 and optional Error Reporter compatibility.

Acceptance criteria

  • Two init() calls install one lifecycle listener set.
  • One account activation installs one current-vehicle listener.
  • Repeated onChanged for inventory ID 42 produces one message.
  • An observed transition 42 → 84 produces one new message; coalesced intermediate choices need not be observed.
  • An absent selection produces none and permits a later actual re-selection to announce.
  • Battle/account teardown leaves no owned current-vehicle listener.
  • Return to the same stored vehicle does not announce again.
  • Disconnect or different account resets deduplication.
  • Two fini() calls are safe.

Architecture

process/mod lifetime
    g_playerEvents listeners
              │
       PlayerAccount active
              ▼
account-scoped g_currentVehicle.onChanged listener
              │ read current item; copy invID + short name
              ▼
   deduplicate inventory ID
              │
              ▼
     SystemMessages projection

The module-owned controller spans loader lifetime. Its child current-vehicle subscription exists only while the account context is active. Vehicle and account objects are read and released within one synchronous handler.

Researching the implementation

Requirement: tell the player when logical garage selection changes
  ↓ Owner? CurrentVehicle account helper
  ↓ Lifecycle? PlayerAccount / garage selection lifetime
  ↓ Signal? g_currentVehicle.onChanged
  ↓ Producer audit? selection callback plus repairs, locks, rent, rotation, inventory...
  ↓ Authority? current logical inventory item, not loaded 3D appearance
  ↓ Filter? compare vehicle.invID with last announced invID
  ↓ Cleanup? account exit/avatar entry/disconnect/fini
  ↓ Update check? Event semantics, CurrentVehicle init/destroy, item properties

What the source proves

  • _CurrentVehicle._selectVehicle() changes its inventory ID, requests a model refresh, and schedules _changeDone().
  • _changeDone() fires onChanged() only while the player is an Account.
  • The same event is also fired by IGR, rent, rotation, lock, inventory, repair, customization, and post-progression paths.
  • g_currentVehicle.destroy() clears its EventManager during account teardown.
  • The current plain Event logs and re-raises a listener exception; SafeEvent logs and continues. The current-vehicle event is plain.

Therefore “subscribe and display on every event” is incorrect. The event is a refresh signal for the current logical vehicle. This feature derives a narrower selection-change fact by comparing inventory IDs.

Source lookup

Follow the logical-selection signal through its actual owner instead of treating a rendered tank as authority:

Search the current source for g_currentVehicle, _CurrentVehicle._selectVehicle, _changeDone, onChanged, and destroy. Treat these names as investigation entry points: inspect current callers, firing sites, and teardown rather than stopping at the event definition.

Lifecycle and ownership

  1. Import: create an inert owner and saved bound-handler objects.
  2. Initialization: install global lifecycle listeners.
  3. Account GUI: verify PlayerAccount, reset dedupe only for a different account ID, add the current-vehicle handler, and inspect the current item.
  4. Vehicle event: read, validate, copy scalars, deduplicate, and present synchronously.
  5. Account/avatar exit: remove the current-vehicle handler. Removal is safe even if native teardown already cleared it.
  6. Disconnect: deactivate and clear account/selection identity.
  7. Finalization: deactivate, remove global handlers, and clear state.

State and data-authority model

StateScopeAuthorityInvalidation
Lifecycle subscriptionsModOwned registrationsfini()
Current-vehicle subscriptionAccountOwned registration on game-owned EventAccount/avatar exit, disconnect, fini
Account database IDLogin/accountCopied from current AccountDifferent account/disconnect
Last inventory IDAccount sessionDerived last announced logical itemExplicit no-selection, account change, disconnect
Vehicle nameOne handler callLocalized presentation fieldImmediately released

Implementation walkthrough

Broad event, narrow feature

_process_current_vehicle() compares vehicle.invID. It does not use intCD: two inventory vehicles cannot share an inventory ID, while a type descriptor answers a different question.

Initial empty state

Account GUI entry can precede g_currentVehicle.init(). The activation read uses clear_when_absent=False, preserving the last ID through battle return. An actual onChanged empty selection uses True, so a later re-selection is a real transition.

Rapid selections can coalesce

Current _CurrentVehicle._selectVehicle updates the inventory ID before requesting a delayed logical-change callback. _CachedVehicle._setChangeCallback schedules one 0.2-second callback while none is pending; later selections replace the stored completion callback without restarting that timer. The notifier reads the current item when onChanged fires, so intermediate choices can go unobserved. If A was last announced and selection passes through B to C before the callback, with no intervening refresh event, the notifier can announce C without B. Returning to A in that window can produce no new announcement. Other refresh producers can fire this event independently; it is not a fixed-delay event or a history of every carousel click. This boundary is verified from current source, without a timing or client experiment.

Plain Event boundary

The current Event.__call__ re-raises listener failures. The saved handler catches only around this mod’s complete event reaction, logs with _LOGGER.exception, and returns so a presentation failure cannot abort the remaining dispatch chain.

Complete source code

# -*- coding: utf-8 -*-
"""Notify when the logical selected garage vehicle inventory ID changes."""
from __future__ import absolute_import, unicode_literals

# Reader map:
#   1. Global player events select the current account lifetime.
#   2. g_currentVehicle.onChanged is a refresh signal inside that lifetime.
#   3. Inventory-ID comparison derives the narrower selection-change fact.

import logging

import BigWorld
from Account import PlayerAccount
from CurrentVehicle import g_currentVehicle
from PlayerEvents import g_playerEvents
from gui import SystemMessages


__version__ = '1.0.0'

_MOD_NAME = 'garage_vehicle_selection_notifier'
_LOGGER = logging.getLogger(__name__)


class _SelectionNotifier(object):
    """Own account-scoped selection observation and notification deduplication."""

    def __init__(self):
        self._installed = False
        self._subscriptions = []
        self._vehicle_subscribed = False
        self._account_database_id = None
        self._last_inventory_id = None

        self._account_gui_handler = self._on_account_show_gui
        self._account_exit_handler = self._on_account_become_non_player
        self._avatar_enter_handler = self._on_avatar_become_player
        self._disconnect_handler = self._on_disconnected
        self._vehicle_handler = self._on_current_vehicle_changed

    # ========================================================
    # Mod lifecycle
    # ========================================================

    def start(self):
        """Subscribe global lifecycle events before inspecting current state."""
        if self._installed:
            return
        try:
            self._subscribe(g_playerEvents.onAccountShowGUI,
                            self._account_gui_handler)
            self._subscribe(g_playerEvents.onAccountBecomeNonPlayer,
                            self._account_exit_handler)
            self._subscribe(g_playerEvents.onAvatarBecomePlayer,
                            self._avatar_enter_handler)
            self._subscribe(g_playerEvents.onDisconnected,
                            self._disconnect_handler)
        except Exception:
            _LOGGER.exception('%s: lifecycle subscription failed', _MOD_NAME)
            self._unsubscribe_all()
            return

        self._installed = True
        if isinstance(BigWorld.player(), PlayerAccount):
            self._activate_account()
        _LOGGER.info('%s %s: initialized', _MOD_NAME, __version__)

    def stop(self):
        if not self._installed and not self._subscriptions:
            return
        self._installed = False
        self._deactivate_account()
        self._unsubscribe_all()
        self._account_database_id = None
        self._last_inventory_id = None
        _LOGGER.info('%s %s: finalized', _MOD_NAME, __version__)

    def _subscribe(self, event, handler):
        """Register and remember the exact pair required for later removal."""
        event += handler
        self._subscriptions.append((event, handler))

    def _unsubscribe_all(self):
        while self._subscriptions:
            event, handler = self._subscriptions.pop()
            try:
                event -= handler
            except Exception:
                _LOGGER.exception('%s: lifecycle unsubscription failed',
                                  _MOD_NAME)

    # ========================================================
    # Account-scoped ownership
    # ========================================================

    def _on_account_show_gui(self, _ctx):
        self._activate_account()

    def _on_account_become_non_player(self):
        self._deactivate_account()

    def _on_avatar_become_player(self):
        self._deactivate_account()

    def _on_disconnected(self):
        self._deactivate_account()
        self._account_database_id = None
        self._last_inventory_id = None
        _LOGGER.info('%s: disconnected; selection state reset', _MOD_NAME)

    def _activate_account(self):
        """Bind the account-owned vehicle event and seed the current selection."""
        player = BigWorld.player()
        if not isinstance(player, PlayerAccount):
            return

        account_id = getattr(player, 'databaseID', None)
        if (account_id is not None and
                account_id != self._account_database_id):
            self._account_database_id = account_id
            self._last_inventory_id = None

        if not self._vehicle_subscribed:
            g_currentVehicle.onChanged += self._vehicle_handler
            self._vehicle_subscribed = True
            _LOGGER.info('%s: current-vehicle listener installed', _MOD_NAME)

        # Account GUI entry can precede CurrentVehicle.init(). An empty read
        # here must not erase cross-battle deduplication state.
        self._process_current_vehicle(clear_when_absent=False)

    def _deactivate_account(self):
        if not self._vehicle_subscribed:
            return
        g_currentVehicle.onChanged -= self._vehicle_handler
        self._vehicle_subscribed = False
        _LOGGER.info('%s: current-vehicle listener removed', _MOD_NAME)

    # ========================================================
    # Event handling and UI boundary
    # ========================================================

    def _on_current_vehicle_changed(self):
        """Treat the native event as a prompt to re-read authoritative state."""
        try:
            self._process_current_vehicle(clear_when_absent=True)
        except Exception:
            # CurrentVehicle.onChanged is a plain Event in the inspected
            # client. Contain this mod's failure so later listeners can run.
            _LOGGER.exception('%s: vehicle-change handling failed', _MOD_NAME)

    def _process_current_vehicle(self, clear_when_absent):
        """Copy stable presentation fields and notify only on inventory change."""
        if not isinstance(BigWorld.player(), PlayerAccount):
            return
        if not g_currentVehicle.isPresent():
            if clear_when_absent:
                self._last_inventory_id = None
            return

        vehicle = g_currentVehicle.item
        inventory_id = getattr(vehicle, 'invID', None)
        # The same logical selection may emit many refresh events.
        if inventory_id is None or inventory_id == self._last_inventory_id:
            return

        self._last_inventory_id = inventory_id
        vehicle_name = getattr(vehicle, 'shortUserName', None)
        if not vehicle_name:
            vehicle_name = 'inventory ID %s' % inventory_id

        SystemMessages.pushMessage(
            'Selected vehicle: %s.' % vehicle_name,
            type=SystemMessages.SM_TYPE.Information)
        _LOGGER.info('%s: selected inventory ID=%s',
                     _MOD_NAME, inventory_id)


_NOTIFIER = _SelectionNotifier()


def init():
    """World of Tanks mod-loader entry point."""
    _NOTIFIER.start()


def fini():
    """Remove every handler owned by this module."""
    _NOTIFIER.stop()

Minimal project structure

garage-vehicle-selection-notifier/
├── mod_garage_vehicle_selection_notifier.py
└── stage/
    ├── meta.xml
    └── res/scripts/client/gui/mods/
        └── mod_garage_vehicle_selection_notifier.pyc
<root>
    <id>tutorial.garage_vehicle_selection_notifier</id>
    <version>1.0.0</version>
    <name>Garage Vehicle Selection Notifier</name>
    <description>Notifies when the logical selected garage vehicle changes.</description>
</root>

Packaging and installation

  1. Compile with the target-compatible Python 2.7 compiler.
  2. Stage the bytecode under res/scripts/client/gui/mods/.
  3. Stage aligned meta.xml at archive root.
  4. Create a stored-member ZIP named garage_vehicle_selection_notifier_1.0.0.wotmod.
  5. Inspect and extract-test it before installing under mods/<target-client-version>/.
C:\Python27\python.exe -m py_compile .\mod_garage_vehicle_selection_notifier.py
Copy-Item .\mod_garage_vehicle_selection_notifier.pyc .\stage\res\scripts\client\gui\mods\
Push-Location .\stage
7z a -tzip -mx=0 ..\garage_vehicle_selection_notifier_1.0.0.wotmod .\meta.xml .\res
Pop-Location

Removal requires only closing the client and removing the package. This mod creates no persistent state.

Testing and debugging

1. Static/source validation

  • Python 2 grammar/bytecode compatibility.
  • CurrentVehicle producers, Event semantics, handler signatures, and account ordering.
  • All registration/removal pairs and module/manifest versions.

2. Package validation

  • Stored members, correct root, exact bytecode path, extraction/hash comparison.

3. In-client runtime matrix

ScenarioExpected evidenceFailure symptom
Clean loginOne listener install; first valid selected vehicle announced onceNo announcement or duplicate
Rapid carousel changesDistinct observed IDs announce; intermediate choices may coalesce. Check the final observed item, separately from visible deliveryRepeated observed ID or stale final item; a coalesced intermediate choice alone is not a failure
Repair/lock/customization updateNo message when inventory ID is unchangedBroad refresh misreported as selection
No vehicleNo message; later real selection announces“None” message or suppressed re-selection
Battle round tripListener absent in battle; same stored tank quiet on returnBattle callback or duplicate return message
Different tank after battleOne new messageStale selection suppresses change
Account switch/disconnectIdentity reset and clean new first selectionCross-account suppression/leak
Another listenerBoth handlers run; no hook or foreign cleanupDispatch abort or missing foreign handler
Forced message exceptionTraceback-bearing mod log; later Event listeners still runNative callback chain aborts
Repeated init/finiOne handler set; teardown twice safeDuplicate listener or removal error

Use game.log to compare listener installation/removal with selected inventory IDs. Do not log full vehicle objects or assume the message proves 3D model readiness.

Failure modes and edge cases

  • Event fires for non-selection update: inventory-ID comparison suppresses it.
  • Account GUI precedes selected-vehicle initialization: listener is installed; the later native selection event supplies the item.
  • Native teardown clears Event first: subtraction is a no-op in the inspected Event; local ownership flag still resets.
  • Avatar entry occurs without expected account-exit ordering: defensive avatar handler deactivates.
  • Vehicle item disappears: an actual empty event invalidates last selection; no stale object is retained.
  • Presentation raises: the mod logs and contains it inside the plain-event boundary.
  • Client update changes producer semantics: re-audit every onChanged() firing site.

Performance considerations

The handler runs only on low-frequency current-vehicle refreshes, does constant-time scalar comparisons, and pushes UI only on a distinct ID. Polling the carousel, item cache, or hangar model would add work and still provide a weaker selection contract.

Compatibility and version sensitivity

DependencyClassificationRecheck
CurrentVehicle.g_currentVehicleInternal singletonImport, init/destroy ownership
onChangedBroad internal EventEvery producer and exception behavior
Vehicle.invID, shortUserNameGUI item fieldsAvailability/types
Account/avatar eventsInternal lifecycleDefinitions, signatures, ordering
SystemMessagesPresentationImport/readiness/signature
Loader/packageBuild contractPython bytecode, archive paths, version folder

Why this architecture?

  • Why not use the event name literally? Producer sites prove broader semantics.
  • Why inventory ID? The requirement concerns the selected owned instance, not vehicle type.
  • Why not wait for hangar model readiness? Presentation needs only the logical item; adding 3D ownership would be unrelated complexity.
  • Why not subscribe forever? Native Event ownership resets at account teardown, and battle has no use for this handler.
  • Why catch this handler? The verified plain Event re-raises; containment protects other listeners.
  • Why no configuration? Level 1 already teaches it, and this feature has no necessary setting.

Possible extensions

  • Add a validated enable switch by reusing the Level 1 persistence boundary.
  • Project tier, nation, or class from verified GUI-item fields.
  • Wait for IHangarSpace.onVehicleChanged only if the feature later needs the loaded 3D representation.
  • Replace system messages with a Gameface view only when persistent presentation is required.

Maintenance checklist

  • Recheck CurrentVehicle/Event imports, onChanged producers, and callback signatures.
  • Verify event add/remove/clear semantics and account-lifecycle order.
  • Verify invID and localized short-name fields.
  • Compile with target Python 2.7.
  • Align versions and inspect archive.
  • Runtime-test same-ID refreshes, carousel changes, battle return, empty selection, and coexistence.
  • Confirm no selected Vehicle or Account object is retained.

What you learned

Events are observations, not requirements. The engineering work is to discover what can produce the event, choose the identity that matches the player-facing claim, scope the subscription to its real owner, and make cleanup follow the same boundary. That pattern scales from this notifier to far more complex garage systems.