| name | gpb-architecture |
| description | Architecture of the Garage Progress Bar WoT mod specifically — its concrete wgmod_research file tree, the seven bar modes + priority order (including the opt-in POTENTIAL_TIER_XI speculative bar), the resolvers, per-item tech-tree pricing, blueprint discount, done-marker reconcile, and the ResearchVM/TickVM/UpgradeVM shapes. Use when editing or extending THIS mod's Python, adding a bar mode, tracing a click→research action, or debugging why the bar doesn't update. (For the reusable engine-free domain/adapter/bridge discipline and the conventions that bite, see the wotmod-architecture harness skill; for the JS/CSS widget, gpb-widget; for the ModsSettingsAPI panel's mechanics, wotmod-msa-settings; for the settings-panel localization pattern, wotmod-i18n-settings; for live game symbols, references/game-api.md.) |
wgmod architecture (this mod's specifics)
The reusable pattern — engine-free domain/ vs adapter/ (reads+writes) vs bridge/
(Wulf/Gameface), and the conventions that bite (listeners re-arm every mount, Wulf MAP-arg,
fail-soft reads, _compat.py shim, hand-numbered VM indices, import≠ready) — lives in the
wotmod-architecture harness skill, and the MSA settings panel's mechanics
(register/migrate lifecycle, replace-not-merge + saveState, guards, bump rules) in
wotmod-msa-settings. This skill is how the Garage Progress Bar realizes them.
src/res/scripts/client/
gui/mods/mod_wgmod.py # ENTRY POINT — monkey-patches a hangar sub-view
wgmod_research/
_compat.py # engine shims: LOG_* fallbacks + _safe/_safe_int guards
# owns the two log tiers (LOG_NOTE dev-gated / LOG_PROD
# always-on) + auto-path-scrubbed LOG_CURRENT_EXCEPTION
# -- full pattern in the wotmod-logger harness skill
adapter/engine_adapter.py # READ orchestrator: build_snapshot() composes the readers
adapter/tech_read.py # reader: tech-tree modules + next vehicles
adapter/post_progression_read.py # reader: linear field modifications
adapter/skill_tree_read.py # reader: tier-XI skill tree (+ is_skill_tree)
adapter/prestige_read.py # reader: Elite Levels ("prestige")
adapter/pricing_read.py # reader: done-tick credits purchase price
adapter/_read_common.py # shared read helpers (items-cache accessor, KPI text)
adapter/actions.py # WRITE-ONLY: invoke WG's research/unlock APIs
adapter/format.py # pure formatting helpers (roman, icons, KPI) — tested
adapter/i18n.py # widget labels from the game's OWN resource strings
adapter/recent.py # session "done" markers (optimistic record + reconcile) — tested
bridge/gameface_bridge.py # listeners, refresh scheduling, click handlers, push/marshal
bridge/view_models.py # Wulf VMs: ResearchVM/TickVM/UpgradeVM (hand-numbered indices)
bridge/wulf_args.py # engine-free MAP-arg parsing (cmd_int_arg/cmd_xy_arg/cmd_wh_arg) — tested
bridge/mod_settings.py # ModsSettingsAPI panel: per-mode toggles, auto-hide, position (+ capture viewport posW/posH)
domain/types.py # engine-free data types (2/3 compatible) + Mode
domain/constants.py # Category / GradeFamily string ids — the JS wire contract
domain/builder.py # MODE STATE MACHINE (build_model + bar_visible)
domain/resolvers/{techtree,fieldmods,skilltree,elite,potential}.py # pure snapshot -> ticks
domain/resolvers/complete.py # the COMPLETE gate: snapshot -> finished categories
src/res/gui/gameface/mods/14th_ua/WGModResearch/
WGModResearch.{js,css} # widget (see gpb-widget skill)
Refactor lineage: engine_adapter.py was a 593-LOC monolith; reads were carved into the
per-subsystem *_read.py modules, which engine_adapter re-imports under its old private
aliases (_read_tech_unlocks, _read_prestige, …) so build_snapshot() call sites are
unchanged. read_purchase_price is re-exported for the bridge. Similarly the VMs moved from
gameface_bridge into view_models.py, arg parsing into wulf_args.py (bridge re-imports as
_cmd_int_arg etc.).
Forward flow (game → bar)
mod_wgmod._install() patches HangarVehicleParamsPresenter._onLoading. On each mount it
injects JS/CSS via openwg_gameface.gf_mod_inject, hangs a ResearchVM on the sub-view model
(property wgResearch), then bridge.push(): engine_adapter.build_snapshot() →
builder.build_model(snapshot, enabled=mod_settings.enabled_modes()) (picks a Mode, calls
the matching resolver) → the bridge writes the ResearchProgressModel into ResearchVM in a
Wulf transaction(), plus channel fields: labels (JSON from i18n.widget_labels()),
colorBlind, posX/posY (+ posW/posH, the viewport a pinned position was captured at,
for resolution-aware rescale), eliteCurrentIcon, spendableXp, done-tick price. JS
ModelObserver("WGModResearch") re-renders.
Reverse flow (clicks → research)
JS invokeCommand() calls a Wulf command on wgResearch. Six commands (view_models.py):
researchUnlock (tech-tree int_cd) / unlockFieldMod (field-mod or skill-tree step_id) /
openSkillTree / openResearch / openFieldMods (no arg — done-marker clicks open the
native screen) / setPosition ({x, y[, w, h]} px; w/h = capture viewport; 0/0 = auto). Handlers
parse args via wulf_args.cmd_int_arg / cmd_xy_arg / cmd_wh_arg and delegate to actions.py
or mod_settings.set_position. Before a research
action the bridge calls _record_click() → recent.record(...) so the item can render as a
"done" marker after it vanishes (optimistic-record; reconciled next sync). Handlers do NOT
refresh — the game's onSyncCompleted does.
Mode state machine (builder.build_model, priority order)
TECH_TREE (any unlock remaining) → SKILL_TREE (tier-XI branching tree, count-based) →
FIELD_MODS → POTENTIAL_TIER_XI (opt-in speculative bar; entry-gated on enabled membership,
only for a Tier-X tank with NO real Tier XI — builder._b_potential) → ELITE_REWARDS (unearned
tier-XI milestone rewards) → ELITE (prestige grade band) → COMPLETE. This is the _BUILDERS
tuple order (_b_tech, _b_skill, _b_field, _b_potential, _b_elite_rewards, _b_elite); there are
SEVEN real modes plus HIDDEN. COMPLETE has NO builder of its own — see the next section.
build_model takes enabled (Mode strings left ON; None = all). If a vehicle
RESOLVES to a mode toggled off, _emit() returns a Mode.HIDDEN placeholder — no
fall-through to a lower-priority mode. bar_visible(overlay_closed, hide_always, hide_when_complete, mode, in_garage) combines that with the master hide switch, the
hide-when-complete option, the tank-setup-overlay state, and the fail-closed garage allowlist
(in_garage = only the plain hangar/{root} view). mod_settings.enabled_modes() is the
settings→builder seam — it maps the six per-mode checkbox settings to the Mode set
build_model consumes; builder tests pass a Mode set in directly, so a wrong/collapsed
toggle→Mode mapping regression here hides behind a green builder suite and has its own guard in
test_enabled_modes_* (tests/test_mod_settings_template.py).
COMPLETE ("Fully Progressed") — the gate, not a builder
Shipped in e0ae891. The gate is "every category that APPLIES to this vehicle is finished" —
NOT, as it was before, "no builder returned a candidate". The old not cands gate was almost
unreachable in practice: _b_elite returns a grade band even at the max elite level, so any
prestige vehicle kept showing the ELITE bar forever. (Behaviour change users see: a maxed-elite
tank now shows Fully Progressed instead of the Elite bar.)
-
Resolution order in build_model: explicit per-vehicle override (if still available) →
COMPLETE (done_cats non-empty AND POTENTIAL_TIER_XI not in by_mode) → the old
not cands no-data COMPLETE _placeholder → priority winner (or HIDDEN if its toggle is off).
POTENTIAL_TIER_XI still wins when it applies + is opted in: it is a speculative goal AHEAD of
the vehicle, never a category it has finished (and so it is deliberately absent from
complete._CATEGORIES). COMPLETE has no per-mode user toggle — bar_visible's
show_when_complete governs it.
-
domain/resolvers/complete.py is pure: resolve(snapshot) -> [(Mode string, raw total XP)],
one entry per applicable category in bar-priority order, or [] while anything is unfinished /
nothing applies. It adds NO engine reads — every applies/done/price fact is already on the
snapshot. Applies/done matrix:
| category | applies when | done when | total XP |
|---|
| TECH_TREE | tech_unlocks non-empty | every entry researched | Σ xp_cost of the OUTGOING unlock graph |
| SKILL_TREE | is_skill_tree | skilltree_done >= skilltree_total | skilltree_total_xp |
| FIELD_MODS | fieldmods_total > 0 | fieldmods_done == fieldmods_total | Σ step xp_cost up to fieldmods.max_level(tier) |
| ELITE_REWARDS | elite_rewards non-empty | every reward achieved | elite_level_xp[last reward level] |
| ELITE | has_prestige and elite_grades | elite_level >= elite_max_level | elite_level_xp[elite_max_level] |
FIELD_MODS and SKILL_TREE are mutually exclusive (a tier-XI vehicle reads field mods 0/0), so
a real row can hold at most FOUR categories — the widget's 5-entry dev preview is an impossible
state.
Conventions specific to this mod
- A GATE resolver must fail CLOSED — the repo-wide fail-soft rule INVERTS into a bug here.
"Every engine read fails soft to empty" (the harness
wotmod-architecture convention) is right
for a resolver that displays data: one bad read degrades one category. It is wrong wherever
the value gates a claim. Live bug, caught by qa before e0ae891 shipped: complete.resolve
originally wrapped applicability+doneness in one try, so a category whose probe raised was
DROPPED — resolve() then returned non-empty and an UNFINISHED vehicle rendered "Fully
Progressed". Real repro: tier=None made fieldmods.max_level() raise while field mods stood at
3/8. Fix = probe applicability separately from doneness (_CATEGORIES holds
(mode, applies, probe)), so an applicable-but-unreadable category is kept and counted as NOT
done and still vetoes; only a category we can't even tell applies is skipped (there, an
exception is indistinguishable from "not applicable", since the readers already degrade to
empty). Rule of thumb: fail soft when the answer is "what to show", fail closed when the answer
is "is it finished / allowed / paid". (Generic — propagate a terse version to the harness
wotmod-architecture fail-soft bullet. Not edited there.)
- Tech-tree ticks are priced PER ITEM, not cumulatively.
techtree.py places each tick at
its own cost (xp_position = cost, affordable = cost <= spendable) — items are
independently researchable. Field mods are the exception (fieldmods.py stays cumulative —
they unlock in sequence). Cost is getattr(u, "xp_cost_effective", u.xp_cost):
xp_cost_effective carries the blueprint-fragment-discounted price for a next-VEHICLE unlock
(set in tech_read via _read_common.blueprint_effective_cost; modules keep raw cost — WG's
validator rejects a module unlocked at a differing cost), and actions._do_research mirrors
it into UnlockProps (discounted xpCost + discount% + raw xpFullCost).
- Done-marker reconcile uses POSITIVE evidence, and expires.
recent._is_done confirms a
click by presence + a truthy flag (tech-tree: still in tech_unlocks with researched=True),
NOT by absence — the readers deliberately degrade to on failure, so an absence test would
turn one bad read into a permanent false check. Skill-tree has no per-node flag so it keeps
the absence test but guards the empty list. A pending that never confirms is dropped after
(~5, count-based/testable); is rejected in both
and .
Skill-tree "next skills" chain (locked successors one hop past the frontier)
- The engine's post-progression GRAPH API is now USED by this mod:
PostProgressionStepItem.getNextStepIDs() (→ successor step ids, backed by
descriptor.unlocks) and getParentStepID()/getParentStepIDs() (→
descriptor.requiredUnlocks). adapter/skill_tree_read.py does a SECOND pass over the
available frontier: for each available step it calls getNextStepIDs(), loads each successor
via pp.getStep(next_id), keeps only the STILL-LOCKED ones, and dedupes a shared
(convergent) successor down to one node that accumulates its parent_ids. A "next" node uses
an OR unlock rule — one parent unlocking it is enough to make it available next.
- New wire shapes.
NextUpgradeVM (actionId=0, icon, name, xpRequired, effect, category, done=False, stepId, parentIds), carried on ResearchVM.nextUpgrades; the existing available
UpgradeVM gained a stepId field too. Deliberate stepId-vs-actionId split:
actionId legitimately reads 0 for non-clickable rows (COMPLETE's finished-category
breakdown, and now the next chips), so stepId is the stable link key used to connect a next
node back to its parent(s) — never repurpose actionId for that. parentIds is a
comma-joined string ("1023" or "1023,1044"), the same packed-string idiom already used
for availModes.
- Testability boundary, unchanged: the bridge VM layer (
bridge/view_models.py Wulf VM
classes + bridge/gameface_bridge.py marshal loop) is NOT unit-testable in this repo —
frameworks.wulf/BigWorld are never stubbed. Domain tests can reach
ResearchProgressModel/skill_tree_read output, but NOT the actual VM property push or the
parentIds comma-join — those need in-client (prober) verification.
Key data types
VehicleSnapshot (adapter output / domain input), ResearchProgressModel (builder output →
bridge writes into ResearchVM), Tick (category drives glyph + clickability; action_id
= tech-tree int_cd / field-mod step_id, 0 = not clickable) — all in domain/types.py. The
ResearchVM/TickVM/UpgradeVM Wulf shapes live in bridge/view_models.py; their numeric
property indices are hand-maintained and must match _addXProperty registration order. The JS
reads by NAME, and the mode/category/grade/command string values are mirrored in the JS
MODE/CAT/CMD/GRADE constants — keep domain/types.py Mode, domain/constants.py, and
the view_models.py command names in lockstep (see gpb-widget).
Adding a new read or write?
The concrete WoT/BigWorld symbols this mod uses — and which reader/action each lives in — are
in references/game-api.md. The full generic symbol catalogue is the wotmod-architecture
harness skill's references/game-api.md. Read before adding a *_read.py or an actions.py path.