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
- Download the connector from Admin → Game Servers. It needs Node 22 on the machine.
- Download
portal.tomlfrom the same page. Its[portal]section is already complete — the token is the one part you cannot write yourself. - Put both in a folder of their own and run the connector from that folder:
node portal-connector-metin2-<version>.mjsIt 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.
| Section | Advertises |
|---|---|
queries.list_characters | LIST_ACCOUNT_CHARACTERS |
queries.list_rankings | LIST_RANKINGS |
queries.online_count | GET_ONLINE_COUNT |
queries.verify_credentials | VERIFY_GAME_CREDENTIALS |
queries.create_account | CREATE_GAME_ACCOUNT |
queries.search_catalog | SEARCH_GAME_CATALOG |
delivery.item | DELIVER_ITEM_GRANT |
delivery.cash or delivery.mileage | DELIVER_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:
SELECTon the account, player and log databases (the log database serves the online-presence queries below)INSERTonplayer.item_award— item deliveryINSERTandUPDATEonaccount.account— account creation and the currency creditsCREATEon the account database, plusSELECT,INSERT,UPDATE,DELETEonaccount.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:CREATEalone 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:
passwordFormat | Where it is the right answer |
|---|---|
argon2id | Modern bases — old-metin2's core and web app both verify argon2id |
bcrypt | Some Laravel-era web CMSes |
mysql41 | Classic MySQL PASSWORD() — * + uppercase SHA1(SHA1(pw)), the 2014 mainline files on MySQL 5.x |
unknown | Any 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 NULLwith no default, and anINSERTthat omits any one of them fails withField '...' doesn't have a default value.NOW()on an*_expirecolumn means "no premium", which is what a fresh account should have. Drop any column your base does not carry. social_idis 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 whywindowisboth. Change it only if your base's award handling differs; the wrong window is silent. - Idempotency is native.
@commandIdnames one grant, keeps its value on every retry, and rides in thewhycolumn — so theWHERE NOT EXISTSmakes a replayedINSERTwrite nothing instead of delivering twice. Zero rows written is success here, not failure. given_timeis written explicitly, and has to be. The mainline schema defaults it to the zero date, so on MySQL 5.7 and 8 the column isNOT NULLwith no default and anINSERTthat 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:
| Capability | Why it is retired |
|---|---|
DELIVER_SKILL_GRANT, SEARCH_SKILL_CATALOG, LIST_CHARACTER_SKILLS | Skills are learned in-game, never granted through a table |
CHARACTER_SERVICES | Rename and gender change are ordinary consumable items — vnums 71055 and 71048 on the reference base — so they dissolve into item delivery |
PLAYER_ONLINE_EVENT | item_award is a queue the core polls; nothing is ever held for a login to release |
UNSTUCK_CHARACTER | No 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
loginlog2row per pid withlogout_timestillNULLmeans "in game now" — logins and logouts show on the portal immediately. - The
last_playwindow is the crash guard. A core that dies leaves the session row open forever; it also stops refreshinglast_play, so the 10-minute window expires the ghost. It isGREATESTof 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
loginlog2degrades gracefully: the subquery finds no row,COALESCEkeeps the row eligible, and presence falls back to thelast_playwindow 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: pid1–pid4 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: 0–7 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 @termlocale_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.