The mod creates a validated JSON configuration beside the client preferences and displays its configured informational greeting once for each logged-in account, after the normal account synchronization path completes.

Follow current event producers, callers and cleanup paths before adapting this example.

What we’re building

On first load, the mod writes a small readable configuration containing an enable switch and greeting text. After a successful normal garage synchronization, it shows that greeting once. Returning from battle does not show it again for the same account; changing accounts can.

The feature does not claim that import means the garage exists. It waits for onAccountComponentsSynced(ctx, success), checks success, rejects skipHangar contexts, reacquires the current PlayerAccount, and only then crosses into SystemMessages.

Final result

  • One Python 2.7-compatible mod file and one required package manifest.
  • A runtime-generated config.json under the preferences directory, not inside the package.
  • Strict type and length validation with safe defaults.
  • Idempotent subscription ownership and symmetric teardown.
  • Traceback-bearing diagnostics for file and lifecycle failures.

What you’ll learn

  • Why import, account GUI entry, synchronization, and garage readiness are separate stages.
  • How to store user-owned configuration where mod reinstalls do not routinely remove it.
  • How to validate JSON rather than trusting its types or keys.
  • How temporary-file replacement reduces partial-write risk.
  • How version metadata supports optional crash attribution.
  • How to package, inspect, test, and remove a minimal mod.

Difficulty and prerequisites

Level 1 — Foundation. You should know basic Python syntax and how to locate the client’s mod and log directories. Read First Mod & Safe Patching for loader fundamentals and Python 2.7 & Runtime Safety for runtime traps.

Requirements and constraints

Functional requirements

  1. Create a default configuration if none exists.
  2. Accept only a JSON object, a real Boolean enabled, and non-empty string message of at most 160 characters.
  3. Show the greeting only after successful normal garage synchronization.
  4. Suppress repeat greetings for each account identity while its greeted-ID history is retained; disconnect or finalization resets that history.
  5. Allow a different logged-in account to receive its own greeting.
  6. Stop all owned listeners during finalization.

Non-functional requirements

  • No arbitrary delay, polling, hook, network request, or third-party dependency.
  • Invalid configuration falls back safely and logs useful diagnostics.
  • Configuration writes use deterministic formatting and a temporary file.
  • Repeated init()/fini() calls are safe.
  • Module and manifest versions stay aligned.
{
  "enabled": true,
  "message": "Welcome back. Garage Greeting is active.",
  "version": 1
}

Configuration field guide: enabled gates presentation, message is normalized and capped before use, and version identifies the mod-owned schema. Standard JSON has no comment syntax, so explanations stay outside the copyable file.

Acceptance criteria

  • Missing config creates the documented default file.
  • Malformed JSON, wrong root type, wrong Boolean type, blank text, multiline text, and oversized text cannot enter active state unvalidated.
  • Failed or skipHangar synchronization produces no greeting.
  • Successful normal sync shows one greeting for the current account.
  • Battle return or repeated sync does not duplicate it while greeted-ID history survives; after disconnect or finalization, a later successful sync can greet again.
  • A different non-null account database ID may receive one greeting.
  • Cleanup removes the exact two handlers and is idempotent.
  • Both version declarations equal 1.0.0.

Architecture

gui.mods loader
    ├── load + validate preferences config
    └── install lifecycle listeners
                 │
onAccountComponentsSynced(ctx, success)
                 │ verify success, !skipHangar, PlayerAccount
                 ▼
          account database ID
                 │ deduplicate once per account
                 ▼
          SystemMessages projection

Configuration is persistent state owned by the mod. The set of greeted account IDs is retained controller/login state, cleared on disconnect or finalization. The Account and UI remain game-owned and are reacquired only at use time.

Researching the implementation

Requirement: show one greeting in a ready garage
  ↓ Owner? Account GUI synchronization
  ↓ Signal? onAccountComponentsSynced(ctx, success)
  ↓ Validity? success is true, ctx is not skipHangar, current player is Account
  ↓ Presentation? SystemMessages informational message
  ↓ Persistence? helpers.getPreferencesDirPath()
  ↓ Verification? definitions, firing site, sync function list, current call sites
  ↓ Update check? signatures, sync branches, helper path, message API

The inspected account source fires onAccountShowGUI(ctx) before its asynchronous component chain. The normal branch then synchronizes caches, initializes hangar space and g_currentVehicle, and finally fires onAccountComponentsSynced(ctx, success). The skipHangar branch is shorter, so the context must be inspected rather than treating success as universal garage readiness.

The repository’s verified small-JSON pattern stores user state beneath helpers.getPreferencesDirPath(), validates types, uses stable JSON formatting, and writes through a temporary path. It also documents that remove-then-rename is a compatibility fallback, not a fully transactional filesystem guarantee.

Source lookup

Use these references to trace the tutorial’s APIs beyond the finished example:

Useful exact search terms in a current client tree are onAccountComponentsSynced, onAccountShowGUI, getPreferencesDirPath, and SystemMessages.pushMessage. Treat these names as investigation entry points: reconfirm callers, firing sites, signatures, and lifecycle order for the build you target.

Lifecycle and ownership

  1. Import: define constants and construct an inert controller. The preferences path may be calculated, but no GUI state is read.
  2. Initialization: load configuration, then install two lifecycle listeners exactly once.
  3. Synchronization complete: validate the context and account identity, deduplicate, then display.
  4. Disconnect: clear greeted account identities so a future login starts clean.
  5. Finalization: remove exact stored handlers and clear controller state.

There is no callback or worker. The current sync completion event already expresses the readiness requirement; adding a timer would weaken it.

State and data-authority model

StateScopeAuthorityReset
Validated configPersistentDerived from user JSON plus defaultsReload/restart
SubscriptionsMod lifetimeOwned registrationsFailed install or fini()
Greeted account IDsRetained controller/login historyObserved Account.databaseID valuesDisconnect or fini()
Account and GUIGame-ownedCurrent client objectsNever retained

Implementation walkthrough

Configuration boundary

_sanitize_config() constructs a fresh known schema. It never merges unknown keys into runtime state. A string such as "false" is not accepted as a Boolean, and line breaks are normalized before the message crosses into UI.

Write boundary

The default file is written only when missing. JSON is written to .tmp, the old file is removed, and the temporary file is renamed. Failures are logged with a traceback; a stale temporary file is removed on the next save attempt.

Readiness boundary

The handler requires four facts: configuration enabled, synchronization success, a normal hangar context, and a current PlayerAccount with a database ID. The ID is recorded before presentation so a UI failure cannot cause repeated spam from another sync notification. A stable account ID does not preserve cleared local history. Selected controller checks with inert account, event, configuration and message substitutes confirmed one publication attempt across repeated sync, another after disconnect, and another after stop/start, with two owned subscriptions. They do not establish real login/event ordering or actual message display; the tutorial's historical client verification retains its original scope.

Complete source code

# -*- coding: utf-8 -*-
"""Show one validated, configurable greeting per logged-in account."""
from __future__ import absolute_import, unicode_literals

# Reader map:
#   1. Configuration helpers validate and persist user-owned JSON.
#   2. _GreetingController owns every lifecycle subscription.
#   3. init()/fini() are the only loader-facing entry points.

import json
import logging
import os

import BigWorld
from Account import PlayerAccount
from PlayerEvents import g_playerEvents
from gui import SystemMessages
from helpers import getPreferencesDirPath


__version__ = '1.0.0'

_MOD_NAME = 'configurable_garage_greeting'
_LOGGER = logging.getLogger(__name__)
_CONFIG_VERSION = 1
_MAX_MESSAGE_LENGTH = 160
_CONFIG_PATH = os.path.join(
    getPreferencesDirPath(), 'mods', _MOD_NAME, 'config.json')

try:
    _TEXT_TYPE = unicode
except NameError:
    _TEXT_TYPE = str

_STRING_TYPES = (str, _TEXT_TYPE)
_DEFAULT_CONFIG = {
    'version': _CONFIG_VERSION,
    'enabled': True,
    'message': 'Welcome back. Garage Greeting is active.',
}


# ============================================================
# Configuration
# ============================================================

def _sanitize_config(data):
    """Return a complete safe config without trusting disk types or length."""
    config = dict(_DEFAULT_CONFIG)
    if not isinstance(data, dict):
        return config

    enabled = data.get('enabled')
    if isinstance(enabled, bool):
        config['enabled'] = enabled

    message = data.get('message')
    if isinstance(message, _STRING_TYPES):
        message = ' '.join(message.split())
        if message:
            config['message'] = message[:_MAX_MESSAGE_LENGTH]

    return config


def _ensure_parent_directory(path):
    directory = os.path.dirname(path)
    if directory and not os.path.isdir(directory):
        os.makedirs(directory)


def _save_config(config):
    """Write through a temporary file so partial JSON is never promoted."""
    temporary_path = _CONFIG_PATH + '.tmp'
    try:
        _ensure_parent_directory(_CONFIG_PATH)
        if os.path.exists(temporary_path):
            os.remove(temporary_path)
        with open(temporary_path, 'w') as stream:
            json.dump(config, stream, indent=2, sort_keys=True)
        if os.path.exists(_CONFIG_PATH):
            os.remove(_CONFIG_PATH)
        os.rename(temporary_path, _CONFIG_PATH)
    except Exception:
        _LOGGER.exception('%s: could not save config', _MOD_NAME)


def _load_config():
    """Load validated preferences, creating defaults on first use."""
    if not os.path.exists(_CONFIG_PATH):
        config = dict(_DEFAULT_CONFIG)
        _save_config(config)
        return config

    try:
        with open(_CONFIG_PATH, 'r') as stream:
            data = json.load(stream)
    except Exception:
        _LOGGER.exception('%s: could not read config; using defaults',
                          _MOD_NAME)
        return dict(_DEFAULT_CONFIG)

    return _sanitize_config(data)


# ============================================================
# Lifecycle owner
# ============================================================

class _GreetingController(object):
    """Own account-readiness events and per-account greeting deduplication."""

    def __init__(self):
        self._installed = False
        self._subscriptions = []
        self._config = dict(_DEFAULT_CONFIG)
        self._greeted_account_ids = set()

        self._sync_handler = self._on_account_components_synced
        self._disconnect_handler = self._on_disconnected

    def start(self):
        """Install exact saved handlers once, then await account readiness."""
        if self._installed:
            return

        self._config = _load_config()
        try:
            self._subscribe(g_playerEvents.onAccountComponentsSynced,
                            self._sync_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
        _LOGGER.info('%s %s: initialized; config=%s',
                     _MOD_NAME, __version__, _CONFIG_PATH)

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

    def _subscribe(self, event, handler):
        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)

    def _on_account_components_synced(self, ctx, success):
        """Greet only after the normal garage synchronization path succeeds."""
        if not self._config['enabled'] or not success:
            return
        if not isinstance(ctx, dict) or ctx.get('skipHangar', False):
            return

        player = BigWorld.player()
        if not isinstance(player, PlayerAccount):
            return
        account_id = getattr(player, 'databaseID', None)
        # Account identity, not a view instance, owns the once-per-login rule.
        if account_id is None or account_id in self._greeted_account_ids:
            return

        self._greeted_account_ids.add(account_id)
        SystemMessages.pushMessage(
            self._config['message'],
            type=SystemMessages.SM_TYPE.Information)
        _LOGGER.info('%s: greeting shown for current account', _MOD_NAME)

    def _on_disconnected(self):
        self._greeted_account_ids.clear()
        _LOGGER.info('%s: disconnected; greeting state reset', _MOD_NAME)


_CONTROLLER = _GreetingController()


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


def fini():
    """World of Tanks mod-loader cleanup entry point."""
    _CONTROLLER.stop()

There is deliberately no account-exit subscription. Battle entry is a normal transition for the same login, so it must not clear greeted-account state. Registering a no-op handler would add lifecycle surface without owning any work.

Minimal project structure

configurable-garage-greeting/
├── mod_configurable_garage_greeting.py
└── stage/
    ├── meta.xml
    └── res/scripts/client/gui/mods/
        └── mod_configurable_garage_greeting.pyc

runtime-generated outside the package:
preferences/mods/configurable_garage_greeting/config.json
<root>
    <id>tutorial.configurable_garage_greeting</id>
    <version>1.0.0</version>
    <name>Configurable Garage Greeting</name>
    <description>Shows one validated configurable greeting per login.</description>
</root>

Packaging and installation

  1. Compile the source with the target-compatible Python 2.7 compiler.
  2. Stage bytecode at res/scripts/client/gui/mods/ and the aligned meta.xml at archive root.
  3. Create configurable_garage_greeting_1.0.0.wotmod as a ZIP-format archive with stored members.
  4. Inspect the archive; its top-level entries must be meta.xml and res/.
  5. Install under <World_of_Tanks>/mods/<target-client-version>/.
C:\Python27\python.exe -m py_compile .\mod_configurable_garage_greeting.py
Copy-Item .\mod_configurable_garage_greeting.pyc .\stage\res\scripts\client\gui\mods\
Push-Location .\stage
7z a -tzip -mx=0 ..\configurable_garage_greeting_1.0.0.wotmod .\meta.xml .\res
Pop-Location

Close the client before replacement. Remove the package to roll back. Delete the runtime config separately only if you also want to discard user choices.

Testing and debugging

1. Static and source validation

  • Python 2 syntax/bytecode check.
  • Import and firing-site verification.
  • Config sanitizer tests for every accepted and rejected type.
  • Subscription symmetry and version alignment.

2. Package validation

  • Archive member, compression, extraction, and hash inspection.
  • No packaged config.json; it is user-owned runtime data.

3. In-client runtime matrix

ScenarioExpected result/logFailure symptom
First login, no configDefault file created; one greeting after successful normal syncNo file, early message, or repeated message
Battle and return without a history resetNo second greeting for the same accountLogin scope was reset at account exit
Disabled configNo greeting; clean initialization logString/Boolean coercion ignores setting
Custom Unicode textNormalized text displays onceEncoding traceback or mojibake
Malformed JSONTraceback-bearing read error; safe default in memoryMod fails to load
Failed syncNo greetingReadiness failure ignored
skipHangarNo garage greetingMessage shown in wrong account flow
Account switchOne greeting for new database IDOld account identity suppresses it
Disconnect/reconnectSession state resets; greeting may show after new successful loginState leaks across connections
Repeated init/finiOne listener set; cleanup twice is safeDuplicate message or removal error

Inspect the active client’s game.log. Runtime log location varies by installation; use the file written by the exact client instance under test.

Failure modes and edge cases

  • Unreadable config: log a traceback and use defaults without overwriting the broken file.
  • Failed default write: continue with in-memory defaults and preserve diagnostic evidence.
  • Partial subscriptions: roll back already-added handlers.
  • Sync failure or wrong branch: do nothing; readiness was not established.
  • Missing account ID: do nothing rather than inventing identity.
  • UI failure: the account was already marked greeted, preventing repeated spam; the SafeEvent boundary records the uncaught failure in the current implementation.
  • Filesystem replacement limits: a crash between remove and rename can lose the active file. Use a backup-backed writer for valuable or complex state.

Performance considerations

Configuration is read once, written only when first created, and never touched in a frame loop. Lifecycle handlers do constant work. The greeted-ID set remains tiny. There is no reason to cache client objects, poll readiness, or rewrite the config on every login.

Compatibility and version sensitivity

DependencyClassificationRecheck
getPreferencesDirPath()Client helperImport and returned root
onAccountComponentsSynced(ctx, success)Internal eventSignature, firing branch, ordering
ctx['skipHangar']Context contractKey semantics
PlayerAccount.databaseIDEntity fieldAvailability at sync completion
SystemMessagesPresentation APIImport, type, readiness
Loader and .wotmodPackagingCompiler, paths, metadata

Why this architecture?

  • Why not show at import? There is no account or UI authority.
  • Why not use a one-second callback? The component-sync event is a real readiness signal and can report failure.
  • Why not save config in the package? Package replacement would overwrite user choices.
  • Why not accept truthy values? "false" is truthy in Python and would surprise users.
  • Why not catch the entire mod? Only file and subscription boundaries have meaningful fallbacks.
  • Why not split settings into another module? This schema and feature are small enough for one coherent owner.

Possible extensions

  • Add localization keys and client-language fallback.
  • Add a schema migration when the config shape genuinely changes.
  • Add an in-client settings UI while keeping JSON as validated persistence.
  • Upgrade to backup-backed atomic recovery if the file stores valuable user data.

Maintenance checklist

  • Recheck the exact account-event, preferences-helper, and SystemMessages module paths.
  • Trace the sync event signature and both normal/skip branches.
  • Run malformed, wrong-type, Unicode, and filesystem-error tests.
  • Compile with target-compatible Python 2.7.
  • Align module, manifest, and filename versions.
  • Inspect archive paths and stored compression.
  • Runtime-test first login, battle return, disconnect, and account switch.

What you learned

A first mod can be small without being careless. The key is to treat configuration, account identity, readiness, presentation, and package metadata as separate boundaries. Once those boundaries are explicit, later features can grow without turning import-time side effects into hidden lifecycle debt.