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
Eventhandler boundary.
What you’ll learn
- How to verify an event through its producer sites, not its name.
- How
onChangedcan 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
Eventhandler 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
- Become active only for a current
PlayerAccount. - Subscribe to
g_currentVehicle.onChangedonce per active account context. - Announce the first present vehicle and later observed changes to a different inventory ID.
- Ignore refresh notifications for the same inventory ID.
- Do not announce an absent selection.
- Stop listening before/during battle and on account exit, disconnect, or finalization.
- 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
onChangedfor 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()firesonChanged()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 itsEventManagerduring account teardown.- The current plain
Eventlogs and re-raises a listener exception;SafeEventlogs 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:
- Producer-to-consumer tracing — a repeatable way to locate who mutates and who observes a value.
- Selected vehicle and inventory identity — the account-owned selection contract used here.
- Vehicle representation chain and choosing the correct key — why inventory ID is the deduplication key rather than an appearance or descriptor object.
- Event dispatch contracts and exact handler identity — why the event is treated as a refresh signal and cleanup stores the same handler object.
- Lifecycle teardown order — where account-owned event managers can disappear.
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
- Import: create an inert owner and saved bound-handler objects.
- Initialization: install global lifecycle listeners.
- Account GUI: verify
PlayerAccount, reset dedupe only for a different account ID, add the current-vehicle handler, and inspect the current item. - Vehicle event: read, validate, copy scalars, deduplicate, and present synchronously.
- Account/avatar exit: remove the current-vehicle handler. Removal is safe even if native teardown already cleared it.
- Disconnect: deactivate and clear account/selection identity.
- Finalization: deactivate, remove global handlers, and clear state.
State and data-authority model
| State | Scope | Authority | Invalidation |
|---|---|---|---|
| Lifecycle subscriptions | Mod | Owned registrations | fini() |
| Current-vehicle subscription | Account | Owned registration on game-owned Event | Account/avatar exit, disconnect, fini |
| Account database ID | Login/account | Copied from current Account | Different account/disconnect |
| Last inventory ID | Account session | Derived last announced logical item | Explicit no-selection, account change, disconnect |
| Vehicle name | One handler call | Localized presentation field | Immediately 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
- Compile with the target-compatible Python 2.7 compiler.
- Stage the bytecode under
res/scripts/client/gui/mods/. - Stage aligned
meta.xmlat archive root. - Create a stored-member ZIP named
garage_vehicle_selection_notifier_1.0.0.wotmod. - 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-LocationRemoval 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
| Scenario | Expected evidence | Failure symptom |
|---|---|---|
| Clean login | One listener install; first valid selected vehicle announced once | No announcement or duplicate |
| Rapid carousel changes | Distinct observed IDs announce; intermediate choices may coalesce. Check the final observed item, separately from visible delivery | Repeated observed ID or stale final item; a coalesced intermediate choice alone is not a failure |
| Repair/lock/customization update | No message when inventory ID is unchanged | Broad refresh misreported as selection |
| No vehicle | No message; later real selection announces | “None” message or suppressed re-selection |
| Battle round trip | Listener absent in battle; same stored tank quiet on return | Battle callback or duplicate return message |
| Different tank after battle | One new message | Stale selection suppresses change |
| Account switch/disconnect | Identity reset and clean new first selection | Cross-account suppression/leak |
| Another listener | Both handlers run; no hook or foreign cleanup | Dispatch abort or missing foreign handler |
| Forced message exception | Traceback-bearing mod log; later Event listeners still run | Native callback chain aborts |
| Repeated init/fini | One handler set; teardown twice safe | Duplicate 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
| Dependency | Classification | Recheck |
|---|---|---|
CurrentVehicle.g_currentVehicle | Internal singleton | Import, init/destroy ownership |
onChanged | Broad internal Event | Every producer and exception behavior |
Vehicle.invID, shortUserName | GUI item fields | Availability/types |
| Account/avatar events | Internal lifecycle | Definitions, signatures, ordering |
SystemMessages | Presentation | Import/readiness/signature |
| Loader/package | Build contract | Python 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.onVehicleChangedonly 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/Eventimports,onChangedproducers, and callback signatures. - Verify event add/remove/clear semantics and account-lifecycle order.
- Verify
invIDand 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.