L2Off servers (PTS)
Connect a server running the retail binaries, with no bridge to write
An L2Off server runs NCSoft's own binaries — L2Server.exe, Cached.exe, L2NPC.exe — so there
is no source to compile a bridge into. The connector runs as its own process beside the game
server and reaches it over SQL.
You write no code. One file, portal.toml, describes your pack, and a built-in bridge serves it.
Choose the family when you add the server
Admin → Game Servers → Add game server asks whether the server runs L2J or L2Off.
It decides what you are handed — a jar and portal.properties, or the standalone connector and
portal.toml — and it cannot be detected beforehand, because nothing has connected yet.
Prove every capability on your own pack before you sell through it
"L2Off" is not one product. It is a retail binary of some chronicle, plus whatever extender, schema, auth daemon and Windows locale the pack was assembled with — and none of that is detectable from outside. Everything in this guide was measured on one High Five pack.
What is known to differ between installs: table and column names, the password format and the codepage it is stored under, which extender features exist at all, the opcodes and memory addresses an extender uses, and whether a delivery queue is even collected. A statement that is right on the pack this was written against can be silently wrong on yours — silently, because a queue nobody reads and a working queue look identical from the portal.
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-pts-<version>.mjsIt reads nothing else and writes nothing into your pack.
One file, not two
An L2J bridge lives inside a game server that already has a config directory and a build, so it
takes portal.properties alongside the pack's own configuration. An L2Off server has neither, and
every additional file is one more thing to place in the wrong directory. Credentials, database,
delivery and queries all live in portal.toml.
[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.world]. 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.verify_credentials | VERIFY_GAME_CREDENTIALS |
queries.create_account | CREATE_GAME_ACCOUNT |
queries.online_count | GET_ONLINE_COUNT |
catalog.items | SEARCH_GAME_CATALOG |
catalog.skills | SEARCH_SKILL_CATALOG |
delivery.item | DELIVER_ITEM_GRANT |
delivery.skill | DELIVER_SKILL_GRANT |
delivery.rename or delivery.gender | CHARACTER_SERVICES |
delivery.unstuck | UNSTUCK_CHARACTER |
The generated profile is a working retail High Five starting point, not a blank form. Retail
schemas differ between chronicles, so every line you must check against your own pack is marked
CHECK.
Account verification
Accounts live in the auth database, not the world one, so the profile carries a second connection
for it — lin2db on a stock retail install, whichever database hAuthD.ini names under
[MSSQL]. Fill it in along with the world credentials: linking a game account is how a purchase
finds a character, and the portal will not link an account it cannot verify.
The password column never holds the password
Every L2Off daemon stores a transform of what the player typed, not the text. A connector that
compares them as strings cannot verify anyone — and on a pack where that column was once edited
by hand it does something worse: it accepts a password the game itself refuses. The connector
applies your daemon's own transform, which means [account] has to name the right one.
Three keys in hAuthD.ini decide it, and the connector cannot see them:
hAuthD.ini | passwordFormat | Column compared |
|---|---|---|
[Adv] MD5Simple = 0, [Adv] SHA1 = 0 | l2off | user_auth.password |
[Adv] MD5Simple = 1 | md5simple | user_auth.password |
[Adv] SHA1 = 1 | sha1 | user_auth.password |
| anything else | unknown | — |
l2off is the retail format and the right answer for a pack whose owner has changed nothing,
which is most of them. Setting both MD5Simple and SHA1 makes the daemon ignore SHA1.
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 login server is not.
The codepage the column is stored under
user_auth.password is an nvarchar, but the transform produces raw bytes. The daemon hands them
to ADO, which widens them to Unicode through the ANSI codepage of the machine hAuthD runs on
and narrows them back on read. So the code points actually sitting in that column are that
codepage's mapping of the bytes, not their numeric values: on a Western install the byte 0x98 is
stored as U+02DC, not U+0098.
Set [account] codepage to that machine's ANSI codepage. Read it there, not on the machine
running the connector and not from SQL Server's collation:
(Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Nls\CodePage').ACP| Value | Typical host |
|---|---|
1252 | Western European — the default |
1251 | Russian and other Cyrillic locales |
1250 | Polish, Czech, Hungarian and neighbours |
A wrong codepage fails every login, not some of them
Roughly one byte in eight lands in the range CP1252 remaps, and every byte above 0x7F is
remapped on CP1251 — so a mismatch rejects about 88% of passwords on a Western host and
effectively all of them on a Russian one. That is loud rather than subtle, which is the one
mercy here: if account linking works for one player it works for nearly all of them, and if the
codepage is wrong almost nobody gets in.
If your daemon runs on a locale not in the table, set passwordFormat = "unknown" and open a
ticket rather than guessing — a near-miss codepage is the one setting that can create accounts
which look right in the database and cannot log in.
[Ext] MD5Password
hAuthD adds a fourth case. When that key is set to anything but 0, the daemon compares against
user_auth.md5password instead, for any account where that column is filled. Copy the key into
[account] md5Key and the connector follows the same rule — md5password when it has a value,
password otherwise, exactly as the daemon decides it.
Two things to know before turning it on:
- It is hAuthD-only. Retail AuthD ignores the column, so an account whose only credential lives there cannot log in on one. The connector writes both columns when creating an account, so a pack that later clears the key keeps working.
- The key is part of the digest. Change it in
hAuthD.iniand every stored value becomes unmatchable.
An account that has logged in once since the key was set is migrated by the daemon itself, which
writes the digest and leaves the literal string '0' in password.
Creating accounts
create_account ships configured. A retail account is two rows, and the daemon joins them
with an INNER JOIN, so an account in only one table passes every check the portal can make and
is refused at the login screen. The connector runs both inserts in one transaction.
Two values are not obvious and are not defaults you can leave alone:
pay_statmust be1. It defaults to0, and0means banned.uidis anIDENTITYcolumn and must never be supplied.INFORMATION_SCHEMA.COLUMNSdoes not say so — it shows an ordinaryint NOT NULL. Readsys.columns.is_identityinstead.
Prove it once, in the game
Nothing the portal can check on its own is enough here. Connector check proves the connector
agrees with its own configuration, which is exactly what a wrong passwordFormat also does.
Create an account from the portal and log into the game with it, through to character selection, once per pack. Until you have, treat both account capabilities as unconfirmed.
Delivery goes through the game server
Read this before you configure delivery
Delivery on an L2Off server requires a change to your extender. The two [delivery]
statements in the generated profile queue a job; something inside L2Server.exe has to pick it
up. Without that half, the connector advertises a capability that accepts purchases and never
hands anything to a player.
Cached.exe builds its item registry in memory at startup and keeps a loaded character's
inventory there too. It never re-reads either while it runs, so no write from outside reaches a
player on a live server — not the inventory table, not the skill table, and not mail. On High
Five, 7 of 7 mails inserted by SQL were listed by the client and could be neither opened nor
deleted, because an attachment is three coordinated rows (post, a user_item row with
warehouse = 4, and the post_item_admin link) and the item registry still had no such object.
Restarting CacheD made them work, which is not a window a running server has.
So delivery is a handoff, not a write:
- The connector inserts a job into a queue table and reports the purchase queued.
- Your extender polls that queue on a timer, for each online player.
- It hands the item or skill to the live player object, then marks the job consumed — leaving
refused_codeNULL if it applied, or writing the reason if it declined. - The connector reads consumed rows back and settles the purchase as delivered or refused.
A grant is therefore queued, not applied on the spot, and the portal says so. It reaches the player within one poll interval of them being online, and immediately at login if it was waiting. Only when your server has actually applied it does the order read as delivered.
That distinction is the whole design. A booking reported as a delivery has to be undone later, and the player is told their item arrived while it sits in a table waiting for them to log in.
An item can wait longer than the rest and it is not a fault: a full inventory, an open trade or a private store leave the job pending, and the next tick retries it. Nothing is lost and nothing is delivered twice. The portal names those three after a minute, so the player checks their bags instead of opening a ticket — as the likely cause, not as a diagnosis, because the extender cannot tell them apart from a grant that will never succeed. Both look the same to it: the add returns false.
A skill is different. There a failure is final rather than transient, so the job is closed with
DELIVERY_FAILED instead of being retried forever.
Two columns the queue needs for this
ALTER TABLE portal_delivery ADD refused_code NVARCHAR(64) NULL; -- your extender writes it
ALTER TABLE portal_delivery ADD reported_at DATETIME NULL; -- the connector's own stampconsumed = 1 means the server decided, in either direction. refused_code says which, using the
refusal codes — an unknown one still returns the coins and
reads as the generic line. reported_at stops a row being reported twice; leave it alone.
Expiry is what closes a job that can never succeed
An extender cannot always tell "impossible" from "not yet". A grant for an item id that does not exist fails the same way a full inventory does — the add returns false and the job stays pending — and on the reference pack it is deliberately left that way, because the one call that could tell them apart takes the same number as both an object id and a class id, and guessing wrong crashes the server rather than the job.
So a purchase built on a wrong item id waits, and expiryDays is what eventually gives the player
their coins back. That makes it load-bearing rather than housekeeping: set it to a year and a
mistyped item id holds someone's coins for a year.
Jobs nobody collects are closed by the connector, not by your server: the extender's timers are per
player, and the rows that need closing belong to the players who never log in. Past expiryDays
in [queue] — 30 by default — the job is closed as DELIVERY_EXPIRED and the coins go back, rather
than a dead row holding them forever.
Which is why the connector never asks where a character is before queueing anything. Being logged
in is not a reason to refuse a purchase, a rename or an unstuck — the queue is the wait, and the
extender is what resolves it. For the same reason an L2Off server never needs
PLAYER_ONLINE_EVENT: nothing is ever held for a login
to release.
Which case are you in
What you can do depends entirely on what you have of your extender. There are three cases and no fourth.
| What you have | What Forgeport can deliver | |
|---|---|---|
| A | The extender's source, and a toolchain that builds it | Items and skills |
| B | Only a built extender, but one that already delivers items — Vanganth, ilExt, or a fork | Items |
| C | Only a built extender, with no delivery of its own | Nothing |
Case A — you can build your extender
Create the queue, add the delivery code to both sides, enable it.
The queue
l2off/portal_delivery/schema.sql on GitHubSET QUOTED_IDENTIFIER ON;
GO
CREATE TABLE portal_delivery
(
job_id INT IDENTITY(1,1) NOT NULL,
char_id INT NOT NULL,
kind TINYINT NOT NULL DEFAULT (0), -- 0 item, 1 skill, 2 gender, 3 rename, 4 unstuck
target_id INT NOT NULL, -- item id, or skill id
amount INT NOT NULL, -- item count, skill level, or gender
enchant INT NOT NULL DEFAULT (0),
target_name NVARCHAR(24) NULL, -- kind 3, the new name
loc_x INT NULL, -- kind 4, the destination
loc_y INT NULL,
loc_z INT NULL,
command_id NVARCHAR(64) NULL, -- the grant command that queued it; a bundle is one row per command
refused_code NVARCHAR(64) NULL, -- NULL once applied, the reason if declined
reported_at DATETIME NULL, -- written by the connector, not by you
consumed TINYINT NOT NULL DEFAULT (0), -- 1 once the server decided, either way
created_at DATETIME NOT NULL DEFAULT (GETDATE()),
consumed_at DATETIME NULL,
CONSTRAINT PK_portal_delivery PRIMARY KEY CLUSTERED (job_id)
);
CREATE NONCLUSTERED INDEX IX_portal_delivery_pending
ON portal_delivery (char_id, consumed) INCLUDE (target_id, amount, enchant);
-- Same grant, retried, inserts nothing instead of delivering twice. Filtered, so the rows a
-- statement of yours writes without a command_id are unaffected.
CREATE UNIQUE INDEX ux_portal_delivery_command
ON portal_delivery (command_id) WHERE command_id IS NOT NULL;
GO-- Both procedures must be created with QUOTED_IDENTIFIER ON — see the warning below.
SET QUOTED_IDENTIFIER ON;
GO
-- Column order is the contract: the CacheD side binds positionally, and it binds without
-- indicator variables, so a NULL anywhere in the result set fails the fetch with ODBC 22002
-- before the row reaches the handler. The ISNULLs are load-bearing: only a rename fills
-- target_name and only an unstuck fills the coordinates, so every item and skill job carries
-- NULLs in those columns, and returning them raw delivers nothing at all.
CREATE PROCEDURE lin_PortalDeliveryAsk @char_id INT
AS
SET NOCOUNT ON;
SELECT job_id, char_id, CAST(kind AS INT), target_id, amount, enchant,
ISNULL(target_name, N''), ISNULL(loc_x, 0), ISNULL(loc_y, 0), ISNULL(loc_z, 0)
FROM portal_delivery
WHERE char_id = @char_id AND consumed = 0
ORDER BY job_id;
GO
-- consumed = 0 in the WHERE is what makes a repeated acknowledgement harmless.
-- @refused_code is how the server's decision reaches the portal: the extender passes NULL for
-- a job it applied and the refusal code for one it declined, and the connector reports the
-- purchase delivered or refused accordingly.
CREATE PROCEDURE lin_PortalDeliveryDone @char_id INT, @job_id INT, @refused_code NVARCHAR(64)
AS
SET NOCOUNT ON;
UPDATE portal_delivery
SET consumed = 1, consumed_at = GETDATE(), refused_code = @refused_code
WHERE job_id = @job_id AND char_id = @char_id AND consumed = 0;
GOamount is INT rather than BIGINT because the extender's ODBC helper binds 32-bit integers.
QUOTED_IDENTIFIER is not a formality here
The filtered index requires SET QUOTED_IDENTIFIER ON to be created, and then for every
INSERT and UPDATE on the table — and CacheD's ODBC connection runs with it OFF. Creating the
procedures with the setting in effect is what fixes that: SQL Server stores it with the module
and applies it at execution, whatever the calling session has. sqlcmd also runs with it off
unless you pass -I.
Skip it and the acknowledging UPDATE fails, the row is never consumed, and the player is
handed the same item again on every poll. Confirm with:
SELECT o.name, m.uses_quoted_identifier
FROM sys.sql_modules m
JOIN sys.objects o ON o.object_id = m.object_id
WHERE o.name LIKE 'lin_PortalDelivery%';Both must read 1.
The extender half
Both sides of the pack need a change, and both are small:
| Side | What it does |
|---|---|
L2Server | Arms a per-player timer at enter-world; on each tick asks CacheD for pending jobs, grants each one to the live player object, then acknowledges it |
CacheD | Runs lin_PortalDeliveryAsk and lin_PortalDeliveryDone and replies with the rows |
The two talk over the socket they already share, so the only new pieces are one request opcode
per direction and one reply. Grants use the extender's existing AddItemToInventory wrapper for
items, and AcquireSkill plus SendAcquireSkillToDB for skills.
The two sides also deploy together, each in its own DLL — and a deploy that updates one is indistinguishable from working until delivery times out, because an L2Server polling into a stock CacheD gets silence rather than an error. The classic way to get there is a copy onto a file a running process still holds locked, which fails without stopping the rest of the deploy. Compare each DLL's hash against your build after copying, and restart both processes together — the pack is timing-coupled, so CacheD going away takes L2Server with it.
Then a section in l2ext.ini:
[ItemDelivery]
Enabled = 1
CheckInterval = 60 ; seconds between polls, per playerOne warning if you are writing this yourself: the packet handler tables are fixed-size arrays followed immediately by a per-opcode flag array, so binding an opcode past the end does not fail — it corrupts the flags and takes the DB socket down some time later. Read the size out of the initialiser loop rather than trusting the header, and prefer an opcode already bound to the extender's do-nothing stub.
Case B — your extender already delivers items
Vanganth's extender ships ItemDelivery and ilExt has the same feature; several forks carry it.
The architecture is the one described above, so nothing needs building — but the table and the
two stored procedures are not in those repositories, so you write them.
Their column order is fixed by their C++, which binds positionally and has no kind field:
jobId, charId, itemId, itemAmount, enchantCreate their queue accordingly, enable it in the extender's own ini ([ItemDelivery] Enabled=1),
and point the profile at that table:
[delivery.item]
sql = """
INSERT INTO ItemDelivery (char_id, item_id, item_amount, enchant)
VALUES (@characterId, @itemId, @count, 0)
"""Then delete [delivery.skill]. These extenders deliver items only; leaving the section in
advertises DELIVER_SKILL_GRANT, which lets an owner sell a skill that will never arrive. Items
are unaffected, and a portal that sells only items is a complete portal.
Case C — no delivery path
Remove both [delivery] sections. The connector then advertises neither DELIVER_ITEM_GRANT nor
DELIVER_SKILL_GRANT.
This server cannot use Forgeport
DELIVER_ITEM_GRANT is a required capability. Without it there is no way to hand a player what
they paid for, so the server stays out of the player portal entirely — the whole point of the
platform is the delivery.
Nothing here is a workaround waiting to be found. Writing the inventory table, the skill table
or the mail table all fail the same way, and they fail silently: the row looks correct in SQL,
the portal reports the purchase as delivered, and the player receives nothing until the next
CacheD restart. That failure mode is the reason delivery moved into the game server, and it is
why a [delivery] section with nothing behind it is worse than none at all.
Your options are to get your extender's source, or to move to an extender that has this feature.
Character services
Rename and gender change go on the same queue as items and skills, with kind selecting the
job: 2 changes gender, 3 renames. Add the column the rename needs before regenerating your
profile:
ALTER TABLE portal_delivery ADD target_name NVARCHAR(24) NULL;24 is the width lin_ChangeCharacterName2 itself takes, so a name that fits here cannot be
truncated by the procedure that consumes it.
[delivery.rename]
window = "both"
sql = """
INSERT INTO portal_delivery (char_id, kind, target_id, amount, enchant, target_name, command_id)
SELECT @characterId, 3, 0, 0, 0, @newName, @commandId
WHERE NOT EXISTS (SELECT 1 FROM portal_delivery WHERE command_id = @commandId)
"""
[delivery.gender]
window = "both"
sql = """
INSERT INTO portal_delivery (char_id, kind, target_id, amount, enchant, command_id)
SELECT @characterId, 2, 0, @gender, 0, @commandId
WHERE NOT EXISTS (SELECT 1 FROM portal_delivery WHERE command_id = @commandId)
"""@gender is the gender the character will have — 0 male, 1 female — not a toggle.
When the extender applies either job it returns the character to the selection screen, so the client rebuilds it with the new name and model.
These numbers have to match what you compiled
kind 2, kind 3 and the column name target_name are a contract between this profile and
your extender, and nothing enforces it. The statements above are yours to edit, so if your
extender reads different values, change the profile to match — a job queued under a kind
nobody handles is written, collected, and dropped, and the portal reports the purchase as
delivered either way.
Either section on its own advertises CHARACTER_SERVICES. Keep only the one your extender
implements: a pack with the stock extender's in-game gender change but no rename handler should
delete [delivery.rename], and the portal then offers only the service that works.
When a delivery lands: window
Every [delivery] statement declares one, and it is the single most important line in the section
after the SQL itself:
window | Use it when your statement |
|---|---|
both | reaches the character either way — a queue your extender polls |
online | only reaches a character who is in the world |
offline | only reaches one who is logged out — a direct write to a game table |
The portal obeys it. A purchase is offered only to characters in that state, the picker filters to them, and the modal says why. That is the whole point: an owner implements each capability however their pack allows, and the platform cannot know which without being told.
The wrong window is silent
Nothing errors. The statement runs, the row is written, the portal reports the purchase
delivered, and the player receives nothing — because the running server overwrote the row, or
because there was no live character to hand anything to. That is why window is required rather
than defaulted: it is not a value anyone can guess on your behalf.
A product whose grants disagree cannot be sold
A bundle holding a skill your pack grants only to a live character and an item it writes only while they are logged out has no moment that serves both. The portal takes the intersection of an order's windows, and when there is none it withholds the product on that server rather than selling something that would stall forever.
Players are never shown the conflict — it is a configuration only you can fix, and the builder names the two grants that disagree when you try to save it.
Unstuck
[delivery.unstuck], like every other delivery, with @account and @characterId available.
[delivery.unstuck]
#x = 82698
#y = 148638
#z = -3473
window = "offline"
sql = """
INSERT INTO portal_delivery (char_id, kind, target_id, amount, enchant, loc_x, loc_y, loc_z)
SELECT u.char_id, 4, 0, 0, 0, @locX, @locY, @locZ
FROM user_data u
WHERE u.char_id = @characterId AND u.account_name = @account
"""The destination rides in loc_x, loc_y and loc_z rather than borrowing the item columns, so a
negative z stays a coordinate instead of an enchant level, and the SELECT carries the ownership
check — a character id is only teleported for the account that owns it. There is no @commandId
here: an unstuck is a player action with no purchase behind it, so there is no command to name.
offline even though the extender can teleport a live character. A move to a safe town is an escape
from combat, and an extender guards trade and private store but not being under attack; queued while
the character is logged out, it applies at their next login, which is when a stuck character needs it
anyway. Set both if you would rather it applied immediately. If your extender has no unstuck job at
all, delete the section — the capability is not offered. A direct UPDATE of user_data has no
window in which it holds: CacheD caches a character at first load and does not release it at logout,
so the written position is ignored at the next login and then overwritten by CacheD's own flush, with
the row looking correct in SQL the whole time. That is the same measurement that moved delivery onto
the queue.
The coordinates are configuration rather than part of the statement: x, y and z default to
Giran and arrive as @locX, @locY and @locZ. That is the same split an L2J server has with
Unstuck.X/Y/Z in portal.properties, so moving the town is the same edit on either family.
Why neither can be done from SQL
The same reason delivery cannot. CacheD reads user_data once, caches the character, and does
not release it when the player logs out — nor does it write the name or gender back. So an
external change is not lost, it is invisible:
| Step | Database | What the client showed |
|---|---|---|
| Rename and gender change applied externally | 222 / female | 111 / male |
| Player enters the world and logs out fully | 222 / female | — |
| Player logs in again, no restart | 222 / female | 111 / male |
| CacheD restarted | 222 / female | 222 / female |
"Your new name appears after the next server restart" is not a service anyone can sell, which is why the extender applies both against the live character object.
The rename runs your pack's own lin_ChangeCharacterName2 — the procedure Cached.exe itself
references. It owns the prohibited-name lists (user_prohibit, user_prohibit_word), the
uniqueness check, and the denormalised copies in user_friend, user_comment and user_history.
Nothing in the connector reimplements those rules.
What the portal already handles
- The delivery window. The services are offered to characters in the window your
[delivery]statements declare —bothin the generated profile. The job is applied at the extender's next poll for an online character, and at login for an offline one. - The player returns to character selection when the change lands. This is the client, not the implementation: it reads a name and a gender only while building the character, so it has to rebuild it. The purchase dialog says so before the player pays.
- Kamael are refused for gender change, in the dialog, at purchase, and again in the connector. Those classes come in gendered pairs, so flipping the gender alone leaves the character inconsistent. Retail blocks it by class range; this blocks it by race, which is the same set.
A queued rename can still be refused in the game
The portal reports a rename as queued, which is not the same as applied. If the name turns
out to be taken or prohibited, lin_ChangeCharacterName2 refuses it at apply time — and the
extender marks the job consumed either way, because retrying a name that will never be accepted
would re-run the refusal on every poll for the life of the character.
It writes the reason onto the row, and the connector reports it back: the order closes as refused and the coins are returned without you doing anything. What it cannot do is promise success at purchase time — the server owns the name rules, so a rename order is a booking until the row settles.
The two cases for character services
Delivery has three cases; this has two, because there is no third path. No SQL fallback works on a running server, and no extender ships these features today.
You have your extender's source. Adapt the implementation. What transfers unchanged: the queue
schema, the validation rules, lin_ChangeCharacterName2 as the rename primitive, the requirement
to patch the cached name in both places the character object holds it, and the
disconnect-on-success behaviour. What must be re-derived for your binary: the opcodes — dump the
live handler table and take a slot still on the do-nothing stub — and every address, which is
valid for one build only.
You do not. Delete both sections. The capability is not offered, and that is the honest end state rather than a degraded one — there is no "applies at the next restart" version of a paid rename.
One opcode, many sub-commands
If you are adding this to an extender, do not spend an opcode per feature. L2Server's CacheD
handler table has exactly 0xC5 entries, nearly all registered by retail, and valid reply
indices stop at 0xC4 — the function-pointer array ends exactly where the per-opcode
registration flags begin, so writing one past the end corrupts them silently and crashes
somewhere unrelated later.
Use one opcode with a sub-command byte in front of the payload, keep the definitions in a single header included by both projects, and treat sub-command numbers as append-only. Renumbering across a version mismatch routes a request to the wrong handler instead of failing.
Names in the pickers
The scripts an L2Off server loads hold slugs — blessed_scroll_of_escape — not the text a player
reads, which ships in the client. catalog.items therefore gives the portal identity only:
exactly the ids your server has, custom ones included.
Upload the client's itemname-e export per game server in Admin → Game Servers to get real
names in the pickers. Without it they show prettified slugs. See
SEARCH_GAME_CATALOG.
A custom bridge, if SQL is not enough
Place a bridge.mjs next to portal.toml default-exporting an object implementing the same
GameBridge methods, and it takes over completely. Found by convention rather than a config key,
so a misnamed file fails loudly at startup instead of being silently ignored.