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.jsonunder 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
- Create a default configuration if none exists.
- Accept only a JSON object, a real Boolean
enabled, and non-empty stringmessageof at most 160 characters. - Show the greeting only after successful normal garage synchronization.
- Suppress repeat greetings for each account identity while its greeted-ID history is retained; disconnect or finalization resets that history.
- Allow a different logged-in account to receive its own greeting.
- 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
skipHangarsynchronization 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:
- Source-verification workflow — how to move from an exact symbol to definitions, callers, registrations, and firing sites.
- Account and lobby readiness layers — why account GUI presence and synchronized garage state are different milestones.
- Preferences-root storage and configuration validation and merging — the ownership and validation rules behind the JSON file.
- Player lifecycle anchors — where to investigate account entry, avatar entry, disconnect, and cleanup.
- Traceback-bearing diagnostics — how caught failures remain attributable to the mod.
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
- Import: define constants and construct an inert controller. The preferences path may be calculated, but no GUI state is read.
- Initialization: load configuration, then install two lifecycle listeners exactly once.
- Synchronization complete: validate the context and account identity, deduplicate, then display.
- Disconnect: clear greeted account identities so a future login starts clean.
- 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
| State | Scope | Authority | Reset |
|---|---|---|---|
| Validated config | Persistent | Derived from user JSON plus defaults | Reload/restart |
| Subscriptions | Mod lifetime | Owned registrations | Failed install or fini() |
| Greeted account IDs | Retained controller/login history | Observed Account.databaseID values | Disconnect or fini() |
| Account and GUI | Game-owned | Current client objects | Never 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
- Compile the source with the target-compatible Python 2.7 compiler.
- Stage bytecode at
res/scripts/client/gui/mods/and the alignedmeta.xmlat archive root. - Create
configurable_garage_greeting_1.0.0.wotmodas a ZIP-format archive with stored members. - Inspect the archive; its top-level entries must be
meta.xmlandres/. - 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
| Scenario | Expected result/log | Failure symptom |
|---|---|---|
| First login, no config | Default file created; one greeting after successful normal sync | No file, early message, or repeated message |
| Battle and return without a history reset | No second greeting for the same account | Login scope was reset at account exit |
| Disabled config | No greeting; clean initialization log | String/Boolean coercion ignores setting |
| Custom Unicode text | Normalized text displays once | Encoding traceback or mojibake |
| Malformed JSON | Traceback-bearing read error; safe default in memory | Mod fails to load |
| Failed sync | No greeting | Readiness failure ignored |
skipHangar | No garage greeting | Message shown in wrong account flow |
| Account switch | One greeting for new database ID | Old account identity suppresses it |
| Disconnect/reconnect | Session state resets; greeting may show after new successful login | State leaks across connections |
| Repeated init/fini | One listener set; cleanup twice is safe | Duplicate 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
| Dependency | Classification | Recheck |
|---|---|---|
getPreferencesDirPath() | Client helper | Import and returned root |
onAccountComponentsSynced(ctx, success) | Internal event | Signature, firing branch, ordering |
ctx['skipHangar'] | Context contract | Key semantics |
PlayerAccount.databaseID | Entity field | Availability at sync completion |
SystemMessages | Presentation API | Import, type, readiness |
Loader and .wotmod | Packaging | Compiler, 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
SystemMessagesmodule 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.