Metin2 servers (beta)

Connect a Metin2 server over MySQL with a sidecar connector, and no bridge to write

A Metin2 server runs a closed-source C++ core compiled per build — db, auth and the game channels — so there is no source to compile a bridge into. The connector runs as its own process beside the game server and reaches it entirely over MySQL.

Metin2 support is in beta. Everything on this page was measured end to end, but on one reference base and not yet across many live production servers — expect the occasional rough edge, and report what you hit.

You write no code. One file, portal.toml, describes your server, and a built-in bridge serves it — zero changes to the game's C++ core.

Pick Metin2 when you add the server

Admin → Game Servers → Add game server asks for the game. Metin2 has a single connector family — the standalone sidecar — so the page hands you exactly two files: the connector and portal.toml. Nothing to compile, nothing to patch.

Prove every capability on your own base before you sell through it

"Metin2" is not one product. It is a file-base of some lineage — the 2014 mainline files, a modern port, or years of private edits — and the database names, table postfixes, schema variants and above all the password hashing differ between them. Everything in this guide was measured on one base: old-metin2, the maintained Linux port of the 2014 files with TMP4 40250 gamefiles, which is the lineage most private servers descend from.

So for each capability you enable, do the end-to-end test once: perform it from the portal and then confirm the result in the game client. Until you have, treat the capability as unconfirmed regardless of what Connector check says — it proves the connector agrees with its own configuration, which is exactly what a wrong configuration also does.

Install

  1. Download the connector from Admin → Game Servers. It needs Node 22 on the machine.
  2. Download portal.toml from the same page. Its [portal] section is already complete — the token is the one part you cannot write yourself.
  3. Put both in a folder of their own and run the connector from that folder:
node portal-connector-metin2-<version>.mjs

It reads nothing else and writes nothing into your server beyond what the profile says.

One file

Credentials, database, delivery and queries all live in portal.toml: a sidecar has no config directory of its own to blend into, and every additional file is one more thing to place in the wrong folder.

[portal]
enabled = true
gatewayUrl = "wss://wss.forgeport.net/"
token = "gps_<gameServerId>.<secret>"

The token's prefix is the server identity and the rest authenticates, so the connector needs no second line to know who it is.

What the profile declares

Every section is optional except [portal], [pack] and [database]. Each query present advertises one capability; each one absent simply means the portal does not offer that feature for this server.

SectionAdvertises
queries.list_charactersLIST_ACCOUNT_CHARACTERS
queries.list_rankingsLIST_RANKINGS
queries.online_countGET_ONLINE_COUNT
queries.verify_credentialsVERIFY_GAME_CREDENTIALS
queries.create_accountCREATE_GAME_ACCOUNT
queries.search_catalogSEARCH_GAME_CATALOG
delivery.itemDELIVER_ITEM_GRANT
delivery.cash or delivery.mileageDELIVER_CURRENCY_GRANT

The generated profile is a working old-metin2 (TMP4 40250) starting point, not a blank form. Bases differ, so every line you must check against your own base is marked CHECK.

One connection, two databases

A Metin2 install splits its data across databases on one MySQL server — account.account holds logins and the two currencies, player.player / player.player_index hold characters, player.item_award is the delivery queue. The profile carries a single connection, and every statement names its database through two placeholders:

[database]
host = "127.0.0.1"
port = 3306
user = "metin2"
password = "..."
accountDb = "account"
playerDb = "player"

{accountDb} and {playerDb} in the statements resolve to these two values, so a base with renamed or postfixed schemas (player1, srv1_player, …) is fixed here once instead of in every query. They must be bare identifiers — letters, digits, _ or $ — because they land in identifier position, outside the parameter binder.

Grant the MySQL user what the profile actually does — and nothing more:

  • SELECT on the account, player and log databases (the log database serves the online-presence queries below)
  • INSERT on player.item_award — item delivery
  • INSERT and UPDATE on account.account — account creation and the currency credits
  • CREATE on the account database, plus SELECT, INSERT, UPDATE, DELETE on account.portal_grant_journal — the connector's idempotency journal (below), created at startup. MySQL accepts a table-level grant before the table exists, so grant it up front: CREATE alone lets the connector create the journal but not write to it.

Prefer a dedicated user with exactly these grants over reusing the game's own credentials.

Account verification

Accounts live in account.account, and the column never holds the password — every base stores a transform of what the player typed. The connector applies your base's own transform, declared in [account] passwordFormat, and there is no universal answer:

passwordFormatWhere it is the right answer
argon2idModern bases — old-metin2's core and web app both verify argon2id
bcryptSome Laravel-era web CMSes
mysql41Classic MySQL PASSWORD()* + uppercase SHA1(SHA1(pw)), the 2014 mainline files on MySQL 5.x
unknownAny base not listed — turns both account capabilities off

Do not guess the format

A wrong format fails every portal login against real accounts, and creates accounts nobody can log into. This was measured, not deduced: on old-metin2 an account seeded with a bcrypt hash is refused by the game itself with WRONGCRD, because the core verifies argon2id — not bcrypt, and not MySQL PASSWORD(), which MySQL 8 removed entirely. Read your base's auth code or its web app's hashing config rather than assuming the era implies the format.

unknown is a real setting, not a placeholder. The connector then advertises neither VERIFY_GAME_CREDENTIALS nor CREATE_GAME_ACCOUNT: a capability that is absent is visible in Admin → Game Servers, and one that quietly disagrees with your auth daemon is not.

The generated verify_credentials reads the hash and the account's status — anything but OK (BLOCK, …) reads as disabled. availDt is deliberately not checked: private bases routinely leave it in the past.

Creating accounts

create_account ships configured, and two of its lines are not defaults you can leave alone:

  • The datetime columns are all listed on purpose. The 2014 mainline schema defaults them to the zero date, which MySQL 5.7 and 8 refuse outright — so on those versions the columns are NOT NULL with no default, and an INSERT that omits any one of them fails with Field '...' doesn't have a default value. NOW() on an *_expire column means "no premium", which is what a fresh account should have. Drop any column your base does not carry.
  • social_id is the 7-digit delete code the client asks for when removing a character. The portal has no concept of it, so the fixed value in the statement is the owner's default — tell your players what it is, or wire your own scheme.

The connector hashes the password with [account] passwordFormat before it reaches @passwordHash, and a duplicate login reports already exists rather than an error.

Prove it once, in the game

Nothing the portal can check on its own is enough here. Create an account from the portal and log into the game with it, through to character selection, once per base. Until you have, treat both account capabilities as unconfirmed.

Delivery is the game's own queue

Metin2 needs no extender and no game-side change for delivery, because the core already ships the queue: player.item_award. The game reads it on its own five-second cycle, moves the item to the in-game gift box, and stamps the row with taken_time when the player claims it. With mall = 1 the item lands in the Item Shop storage tab, claimable on any character, online or offline.

[delivery.item]
window = "both"
sql = """
INSERT INTO {playerDb}.item_award (login, vnum, count, mall, why, given_time)
SELECT @account, @itemId, @count, 1, @commandId, NOW() FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM {playerDb}.item_award WHERE why = @commandId)
"""

Three things about this statement carry the design:

  • Grants are account-keyed, not character-keyed. The gift box is addressed by login, so there is no character to target and no online/offline window to satisfy — which is why window is both. Change it only if your base's award handling differs; the wrong window is silent.
  • Idempotency is native. @commandId names one grant, keeps its value on every retry, and rides in the why column — so the WHERE NOT EXISTS makes a replayed INSERT write nothing instead of delivering twice. Zero rows written is success here, not failure.
  • given_time is written explicitly, and has to be. The mainline schema defaults it to the zero date, so on MySQL 5.7 and 8 the column is NOT NULL with no default and an INSERT that leaves it out fails. NOW() is correct on every version, and it is also what the gift box shows as the grant date.

A grant is therefore queued, not applied on the spot, and the portal says so. The connector inserts the row and reports the purchase queued; within one poll of [queue] pollSeconds — 30 by default — it reads back what the core decided: taken_time set means the player claimed it, and the order settles as delivered.

Expiry is what closes an award nobody claims

The core never expires item_award rows itself, and the rows that need closing belong to the players who never log in. Past expiryDays in [queue] — 30 by default — the connector deletes the untaken row and closes the job as DELIVERY_EXPIRED, and the coins go back rather than a dead row holding them forever. That makes the setting load-bearing rather than housekeeping: set it to a year and a grant to a player who quit holds their coins for a year.

Why delivery does not write game tables directly

The same measurement every family keeps re-making. DBCache holds an online player's items, name and position in memory: a direct UPDATE of a player row looks correct in SQL and reaches the game only after a cache restart and a relog. item_award is the one table the core itself polls for exactly this purpose, and the two currency columns are the one thing it applies in real time — so the connector touches those and nothing else.

Currency is a credit, not a queue

Metin2 has a native donation currency pair on the account row: account.cash (Dragon Coins) and account.mileage (Dragon Marks). The core applies a change in real time — no relog, no poll — so a currency grant reports applied the moment the UPDATE commits.

[delivery.cash]
sql = """
UPDATE {accountDb}.account SET cash = cash + @amount WHERE login = @account
"""

[delivery.mileage]
sql = """
UPDATE {accountDb}.account SET mileage = mileage + @amount WHERE login = @account
"""

An UPDATE has no WHERE NOT EXISTS to make a replay harmless, so the connector wraps every grant in its own idempotency journal — portal_grant_journal, created automatically in the account database at startup. The journal row and the credit commit in one transaction, and a retried grant is recognised by its command id and credits nothing.

Never add your own idempotency to the currency statements

The journal is the idempotency. The UPDATE must stay a plain credit: a guard of your own that makes the statement match zero rows reads to the connector as "no such account", and the grant is refused with ACCOUNT_NOT_FOUND — which is what that outcome is actually for, releasing the player's coins when the account row is genuinely not there to credit.

Either statement on its own advertises DELIVER_CURRENCY_GRANT. Keep both unless your base has dropped one of the columns: with only [delivery.cash] configured the capability is still advertised, and a Dragon Marks grant then fails at delivery rather than being withheld at purchase.

What Metin2 does not need

Several capabilities other families implement simply have no Metin2 counterpart. They are marked not applicable for the family, so Admin → Game Servers does not hold their absence against your connector — and the player modules built on them (Character Services, Unstuck character) are not offered for a Metin2 server at all, rather than shown as switches that could never turn on:

CapabilityWhy it is retired
DELIVER_SKILL_GRANT, SEARCH_SKILL_CATALOG, LIST_CHARACTER_SKILLSSkills are learned in-game, never granted through a table
CHARACTER_SERVICESRename and gender change are ordinary consumable items — vnums 71055 and 71048 on the reference base — so they dissolve into item delivery
PLAYER_ONLINE_EVENTitem_award is a queue the core polls; nothing is ever held for a login to release
UNSTUCK_CHARACTERNo delivery needs a character position

The character services row is the practical one: sell the rename tincture and the gender-change charm as ordinary store products. On the reference base the rename applies in real time with no relog, and the gender change asks for a relog to rebuild the model — both enforced by the game's own quest scripts, so nothing in the connector reimplements the name rules.

Online presence

Metin2 has no online flag, and last_play alone is not one either — measured on the reference base, the core stamps account.last_play at auth login but saves the player row only every few minutes while playing and at logout. A query watching player.last_play marks a fresh login offline for the first several minutes of every session, and keeps showing a logged-out player online for up to the window's length.

What the core does write in real time is log.loginlog2: a session journal with one row per character session — login_time on entering the game, logout_time the moment the character leaves. The generated presence queries read both signals:

online_count = """
SELECT COUNT(DISTINCT p.id) AS online
FROM {playerDb}.player p
JOIN {playerDb}.player_index i ON p.id IN (i.pid1, i.pid2, i.pid3, i.pid4)
JOIN {accountDb}.account a ON a.id = i.id
WHERE COALESCE((SELECT ll.logout_time IS NULL FROM log.loginlog2 ll
                WHERE ll.pid = p.id ORDER BY ll.id DESC LIMIT 1), 1) = 1
  AND GREATEST(a.last_play, p.last_play) >= NOW() - INTERVAL 10 MINUTE
"""
  • The latest loginlog2 row per pid with logout_time still NULL means "in game now" — logins and logouts show on the portal immediately.
  • The last_play window is the crash guard. A core that dies leaves the session row open forever; it also stops refreshing last_play, so the 10-minute window expires the ghost. It is GREATEST of both stamps because the account one covers the start of a session, before the first periodic save touches the player row.
  • A base that never writes loginlog2 degrades gracefully: the subquery finds no row, COALESCE keeps the row eligible, and presence falls back to the last_play window alone.

The same expression is the online flag in list_characters and list_rankings. Once loginlog2 grows, an index keeps the per-row lookup cheap:

ALTER TABLE log.loginlog2 ADD INDEX idx_portal_pid_id (pid, id);

log.playercount looks like an alternative for the count, but a stock base only writes it when its logging is configured to — check SELECT COUNT(*) FROM log.playercount before trusting it, and note its per-empire columns are count_red / count_yellow / count_blue / count_total.

Characters, empires and rankings

An account's characters live behind player_index: pid1pid4 point at player rows, and empire lives on player_index, because it is chosen per account — 1 Shinsoo, 2 Chunjo, 3 Jinno. job is on the player row: 07 for the four classes × two genders, 8 on bases with the Lycan class. The guild is Metin2's clan, reached by a LEFT JOIN so a character in no guild still appears.

Rankings are a level board only — Metin2 has no PvP or PK boards — so the portal asks for level and the query orders by level and exp itself, the way the in-game ranking breaks ties. GM characters are usually kept out by name or by a flag your base has. On a standard schema the GM roster lives in common.gmlist — grant the connector's user SELECT on it and add to the generated list_rankings:

WHERE p.name NOT IN (SELECT mName FROM common.gmlist WHERE mAuthority <> 'PLAYER')

Names in the picker

A Metin2 base carries real item names in its own database: search_catalog reads player.item_proto.locale_name, so the portal's picker shows them with no client export to upload.

One conversion in that query is not optional:

WHERE CONVERT(ip.locale_name USING utf8) LIKE @term

locale_name is varbinary on a standard schema, and a binary column has no collation, so LIKE compares raw bytes — "full moon sword" finds nothing while "Full Moon Sword" finds ten rows. Converting first gives the string a utf8 collation, which folds case. The same CONVERT appears in the SELECT (and on guild names in the character queries) because bases store these columns in the client's local encoding, and raw bytes are what arrives without it.

No custom bridge, by design

Some connector families accept a custom bridge file for behaviour that cannot be expressed as configuration. The Metin2 connector has no such override, because there is nothing to override: a Metin2 server is described entirely by its MySQL schema, and every base speaks the same item_award / account tables. What varies between bases — names, postfixes, hashing, schema columns — is exactly what the profile parameterises.

On this page